diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 5e9ae70..3c82faf 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,90 @@ 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' }, + { id: 23658, name: 'Plan', value: 'Pro', type: 'text' }, + ], + }; + + 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 {