From 5a9952f9006941fda820331989af2b14cfd7aca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:11:58 +0000 Subject: [PATCH 1/2] fix(mcp): accept embedded custom fields without a type (#56) Help Scout's conversation list/search embed returns custom fields as { id, name, value, text } with no `type`. customFieldSchema required `type`, so every conversation-returning MCP tool (search_conversations, search_by_customer, list_conversations, get_conversation) failed output validation whenever a conversation had custom fields set. Make `type` optional, declare the embed's `text` label, and mirror both in the CustomField type. Adds regression coverage for all four tools. Fixes #56 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NkDvdNG1AWbYCDzZ6XbXEz --- src/mcp/server.test.ts | 82 ++++++++++++++++++++++++++++++++++++++++++ src/mcp/server.ts | 5 ++- src/types/index.ts | 3 +- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 5e9ae70..a5457f5 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -2,6 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { client as helpScoutClient } from '../lib/api-client.js'; +import type { Conversation } from '../types/index.js'; let mcpServer: typeof import('./server.js'); let testClient: Client; @@ -147,6 +148,87 @@ describe('Help Scout MCP server helpers', () => { expect(parsed.action?.associatedEntities).toEqual({ mailboxIds: [42] }); }); + // Help Scout's conversation list/search embed returns custom fields as + // { id, name, value, text } with no `type`, so the shared conversation output + // schema must not require it (#56). + describe('conversations whose custom fields come from the embed', () => { + const conversation: Conversation = { + id: 123, + number: 48172, + threads: 1, + type: 'email', + folderId: 1, + status: 'active', + state: 'published', + subject: 'Audio Hijack license', + preview: 'Hi there', + mailboxId: 1, + createdAt: '2026-06-23T00:00:00Z', + customFields: [{ id: 23657, name: 'App', value: '116013', text: 'Audio Hijack' }], + }; + + function expectEmbeddedCustomFields(result: Awaited>) { + expect(result.isError).toBeFalsy(); + const content = result.structuredContent as { + conversations?: Array<{ customFields?: unknown }>; + conversation?: { customFields?: unknown }; + }; + const returned = content.conversation ?? content.conversations?.[0]; + expect(returned?.customFields).toEqual(conversation.customFields); + } + + it.each([ + ['search_conversations', {}], + ['search_by_customer', { email: 'someone@example.com' }], + ])('returns them from %s', async (name, args) => { + const listAllConversations = vi + .spyOn(helpScoutClient, 'listAllConversations') + .mockResolvedValue([conversation]); + + try { + expectEmbeddedCustomFields(await testClient.callTool({ name, arguments: args })); + } finally { + listAllConversations.mockRestore(); + } + }); + + it('returns them from list_conversations', async () => { + const listConversations = vi.spyOn(helpScoutClient, 'listConversations').mockResolvedValue({ + conversations: [conversation], + page: { size: 25, totalElements: 1, totalPages: 1, number: 1 }, + }); + + try { + expectEmbeddedCustomFields( + await testClient.callTool({ name: 'list_conversations', arguments: {} }) + ); + } finally { + listConversations.mockRestore(); + } + }); + + it('returns them from get_conversation', async () => { + const resolveConversationId = vi + .spyOn(helpScoutClient, 'resolveConversationId') + .mockResolvedValue(123); + const getConversation = vi + .spyOn(helpScoutClient, 'getConversation') + .mockResolvedValue(conversation); + + try { + expectEmbeddedCustomFields( + await testClient.callTool({ + name: 'get_conversation', + arguments: { conversationId: 123 }, + }) + ); + } finally { + getConversation.mockRestore(); + resolveConversationId.mockRestore(); + } + }); + }); + it('accepts only verified unsent message drafts in lifecycle outputs', () => { const { draftReplyWriteOutputSchema } = mcpServer.getDraftReplySchemasForTesting(); const result = { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index fd23f1e..fd4ab65 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -271,7 +271,10 @@ const customFieldSchema = z id: z.number().optional(), name: z.string(), value: z.string(), - type: z.string(), + // The conversation list/search embed omits `type` (only the field-definition + // endpoints carry it) and adds `text`, the human-readable label for `value`. + type: z.string().optional(), + text: z.string().optional(), }) .passthrough(); diff --git a/src/types/index.ts b/src/types/index.ts index dbdea5a..964c6fc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -258,7 +258,8 @@ export interface CustomField { id: number; name: string; value: string; - type: string; + type?: string; + text?: string; } export interface Mailbox { From 328fd74e226159b6f23045a5708ef246cfc6e083 Mon Sep 17 00:00:00 2001 From: Stephen Dolan Date: Thu, 1 Oct 2026 09:04:11 -0400 Subject: [PATCH 2/2] test(mcp): preserve typed and embedded custom fields together --- src/mcp/server.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index a5457f5..3c82faf 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -164,7 +164,10 @@ describe('Help Scout MCP server helpers', () => { preview: 'Hi there', mailboxId: 1, createdAt: '2026-06-23T00:00:00Z', - customFields: [{ id: 23657, name: 'App', value: '116013', text: 'Audio Hijack' }], + customFields: [ + { id: 23657, name: 'App', value: '116013', text: 'Audio Hijack' }, + { id: 23658, name: 'Plan', value: 'Pro', type: 'text' }, + ], }; function expectEmbeddedCustomFields(result: Awaited>) {