diff --git a/chatgpt-app-submission.json b/chatgpt-app-submission.json index 4cbdb6cf..d38c6e73 100644 --- a/chatgpt-app-submission.json +++ b/chatgpt-app-submission.json @@ -135,23 +135,23 @@ "user_prompt": "Find the synthetic review fixture container CAIU1234567 in my Terminal49 account and summarize its current status.", "file_attachment_urls": null, "tools_triggered": "search_container", - "expected_output": "Returns matching container records with identifiers, status, shipping line, and available terminal information.", + "expected_output": "If the fixture exists in the connected account, returns matching container identifiers, status, shipping line, and available terminal information. Otherwise, clearly reports zero matches without inventing shipment data.", "expected_output_url": null }, { - "description": "List recently updated containers with a bounded result set.", - "user_prompt": "Show the first 10 containers updated recently in my Terminal49 account.", + "description": "List a bounded page of containers.", + "user_prompt": "Show the first 10 containers in my Terminal49 account.", "file_attachment_urls": null, "tools_triggered": "list_containers", - "expected_output": "Returns up to 10 recent container records and clearly indicates any pagination or result limits.", + "expected_output": "Returns up to 10 container records, clearly labels the page as unfiltered, and indicates pagination or result limits.", "expected_output_url": null }, { - "description": "List shipments filtered by carrier.", - "user_prompt": "Show my recent Maersk shipments and include their containers.", + "description": "List actively tracked shipments without nested containers.", + "user_prompt": "Show the first 10 shipments where shipping-line tracking has not stopped. Do not include nested containers.", "file_attachment_urls": null, "tools_triggered": "list_shipments", - "expected_output": "Returns shipments matching the carrier filter with available container relationships and pagination details.", + "expected_output": "Returns up to 10 shipments matching tracking_stopped=false without nested container relationships, with pagination details.", "expected_output_url": null }, { @@ -167,7 +167,7 @@ "user_prompt": "Track the synthetic review fixture container CAIU1234567 with carrier SCAC MAEU in my Terminal49 account.", "file_attachment_urls": null, "tools_triggered": "track_container", - "expected_output": "Returns the existing matching container or creates a tracking request and clearly reports whether a new request was created.", + "expected_output": "Returns the existing matching container, creates a tracking request, or clearly reports that no request was created because the number/carrier could not be resolved. It must not claim the fixture exists or is pending unless the account response confirms that state.", "expected_output_url": null } ], diff --git a/packages/mcp/src/annotations.test.ts b/packages/mcp/src/annotations.test.ts index 3fd27abd..760a05ad 100644 --- a/packages/mcp/src/annotations.test.ts +++ b/packages/mcp/src/annotations.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { describe, expect, it, vi } from 'vite-plus/test'; import { createTerminal49McpServer } from './server.js'; @@ -15,14 +16,72 @@ type ToolAnnotations = { openWorldHint?: boolean; }; +type ChatGptSubmission = { + $schema: string; + schema_version: number; + app_info: { + display_name: string; + subtitle: string; + description: string; + category: string; + }; + tools: Record< + string, + { + annotations: ToolAnnotations; + justifications: Record; + } + >; + test_cases: Array<{ + description: string; + user_prompt: string; + file_attachment_urls: string[] | null; + tools_triggered: string; + expected_output: string; + expected_output_url: string | null; + }>; + negative_test_cases: Array<{ + description: string; + user_prompt: string; + file_attachment_urls: string[] | null; + tools_triggered: null; + expected_output: string; + expected_output_url: string | null; + }>; +}; + +type ClaudeSubmission = { + server: { + url: string; + transport: string; + url_type: string; + authentication: string; + }; + listing: { + name: string; + tagline: string; + documentation_url: string; + privacy_policy_url: string; + terms_of_service_url: string; + support_email: string; + icon: string; + icon_dark: string; + }; + capabilities: { + reads_data: boolean; + writes_data: boolean; + primary_use_cases: string[]; + }; +}; + function getRegisteredTools(): Record< string, - { annotations?: ToolAnnotations } + { title?: string; annotations?: ToolAnnotations } > { const server = createTerminal49McpServer('token'); return (server as any)._registeredTools as Record< string, - { annotations?: ToolAnnotations } + { title?: string; annotations?: ToolAnnotations } >; } @@ -88,7 +147,104 @@ describe('MCP tool annotations', () => { ).toBeGreaterThanOrEqual(allTools.length); for (const [name, tool] of Object.entries(tools)) { + expect(tool.title, `${name}.title`).toEqual(expect.any(String)); + expect(tool.title?.trim().length, `${name}.title`).toBeGreaterThan(0); expect(tool.annotations, name).toBeDefined(); } }); + + it('keeps live annotations and locked store listings consistent', () => { + const tools = getRegisteredTools(); + const chatGpt = JSON.parse( + readFileSync( + new URL('../../../chatgpt-app-submission.json', import.meta.url), + 'utf8', + ), + ) as ChatGptSubmission; + const claude = JSON.parse( + readFileSync( + new URL('../../../claude-connector-submission.json', import.meta.url), + 'utf8', + ), + ) as ClaudeSubmission; + + expect(Object.keys(chatGpt.tools).sort()).toEqual( + Object.keys(tools).sort(), + ); + for (const [name, tool] of Object.entries(tools)) { + const liveAnnotations = tool.annotations; + expect(chatGpt.tools[name]?.annotations, name).toMatchObject({ + readOnlyHint: liveAnnotations?.readOnlyHint, + destructiveHint: liveAnnotations?.destructiveHint, + openWorldHint: liveAnnotations?.openWorldHint, + }); + expect( + Object.values(chatGpt.tools[name]?.justifications ?? {}).every( + (justification) => justification.trim().length > 0, + ), + `${name}.justifications`, + ).toBe(true); + expect( + Object.keys(chatGpt.tools[name]?.justifications ?? {}), + `${name}.justifications`, + ).toHaveLength(3); + } + + expect(chatGpt.$schema).toBe( + 'https://developers.openai.com/apps-sdk/schemas/chatgpt-app-submission.v1.json', + ); + expect(chatGpt.schema_version).toBe(1); + expect(chatGpt.app_info).toMatchObject({ + display_name: 'Terminal49', + subtitle: 'Track ocean shipments', + category: 'BUSINESS', + }); + expect(chatGpt.app_info.description).toContain('Terminal49 helps users'); + expect(chatGpt.test_cases).toHaveLength(5); + expect(chatGpt.negative_test_cases).toHaveLength(3); + expect(chatGpt.test_cases[0]?.expected_output).toContain( + 'Otherwise, clearly reports zero matches', + ); + expect(chatGpt.test_cases[4]?.expected_output).toContain( + 'no request was created', + ); + for (const testCase of chatGpt.test_cases) { + expect(testCase.description.trim()).not.toBe(''); + expect(testCase.user_prompt.trim()).not.toBe(''); + expect(testCase.tools_triggered).toBeTypeOf('string'); + expect(testCase.expected_output.trim()).not.toBe(''); + expect(testCase.file_attachment_urls).toBeNull(); + expect(testCase.expected_output_url).toBeNull(); + } + for (const testCase of chatGpt.negative_test_cases) { + expect(testCase.description.trim()).not.toBe(''); + expect(testCase.user_prompt.trim()).not.toBe(''); + expect(testCase.tools_triggered).toBeNull(); + expect(testCase.expected_output.trim()).not.toBe(''); + expect(testCase.file_attachment_urls).toBeNull(); + expect(testCase.expected_output_url).toBeNull(); + } + expect(claude.server).toMatchObject({ + url: 'https://mcp.terminal49.com', + transport: 'streamable-http', + url_type: 'universal', + authentication: 'oauth', + }); + expect(claude.listing).toMatchObject({ + name: 'Terminal49', + tagline: 'Track ocean shipments', + documentation_url: 'https://docs.terminal49.com/mcp/home', + privacy_policy_url: 'https://terminal49.com/privacy', + terms_of_service_url: 'https://terminal49.com/terms', + support_email: 'support@terminal49.com', + }); + expect(claude.listing.icon).toMatch(/terminal49-light\.png$/); + expect(claude.listing.icon_dark).toMatch(/terminal49-dark\.png$/); + expect(claude.listing.tagline.length).toBeLessThanOrEqual(55); + expect(claude.capabilities).toMatchObject({ + reads_data: true, + writes_data: true, + }); + expect(claude.capabilities.primary_use_cases.length).toBeGreaterThan(0); + }); }); diff --git a/packages/mcp/src/mcp.test.ts b/packages/mcp/src/mcp.test.ts index 116eb703..90224e84 100644 --- a/packages/mcp/src/mcp.test.ts +++ b/packages/mcp/src/mcp.test.ts @@ -20,14 +20,18 @@ vi.mock('@sentry/node', () => ({ // Stubbed Terminal49Client so server tools can be exercised end-to-end without // hitting the live API. Tests configure these mocks per-case. `vi.hoisted` // is required because vi.mock factories are hoisted above normal declarations. -const { shippingLinesList, containersList, shipmentsList } = vi.hoisted(() => ({ - shippingLinesList: vi.fn(), - containersList: vi.fn(), - shipmentsList: vi.fn(), -})); +const { search, shippingLinesList, containersList, shipmentsList } = vi.hoisted( + () => ({ + search: vi.fn(), + shippingLinesList: vi.fn(), + containersList: vi.fn(), + shipmentsList: vi.fn(), + }), +); vi.mock('@terminal49/sdk', () => ({ Terminal49Client: class Terminal49Client { + search = search; shippingLines = { list: shippingLinesList }; containers = { list: containersList }; shipments = { list: shipmentsList }; @@ -37,6 +41,7 @@ vi.mock('@terminal49/sdk', () => ({ })); beforeEach(() => { + search.mockReset(); shippingLinesList.mockReset(); containersList.mockReset(); shipmentsList.mockReset(); @@ -476,14 +481,24 @@ describe('MCP server wiring', () => { it('captures handled tool errors when Sentry is initialized', async () => { const Sentry = await import('@sentry/node'); vi.mocked(Sentry.isInitialized).mockReturnValue(true); + search.mockRejectedValue( + new Error( + 'upstream failed at https://internal.example/v2?token=secret-token', + ), + ); const server = createTerminal49McpServer('token'); const searchTool = (server as any)._registeredTools.search_container; - const result = await searchTool.handler({ query: ' ' }); + const result = await searchTool.handler({ query: 'CAIU1234567' }); expect(result.isError).toBe(true); expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); + const captured = vi.mocked(Sentry.captureException).mock.calls[0]?.[0]; + expect(captured).toBeInstanceOf(Error); + expect((captured as Error).message).not.toContain('internal.example'); + expect((captured as Error).message).not.toContain('secret-token'); + expect((captured as Error).message).toContain('could not be completed'); expect(Sentry.flush).toHaveBeenCalledWith(2000); }); @@ -622,7 +637,7 @@ describe('MCP server wiring', () => { { name: 'list_shipments', args: { - carrier: 'MAEU', + tracking_stopped: false, include_containers: true, page: 1, page_size: 10, @@ -646,7 +661,7 @@ describe('MCP server wiring', () => { self: 'https://api.test/shipments?page[number]=1&page[size]=10', }, meta: { total: 1 }, - unsupportedFilters: ['carrier'], + unsupportedFilters: [], }, }, ])( diff --git a/packages/mcp/src/resources/query-guidance.ts b/packages/mcp/src/resources/query-guidance.ts index 1d55c58e..e79804a3 100644 --- a/packages/mcp/src/resources/query-guidance.ts +++ b/packages/mcp/src/resources/query-guidance.ts @@ -42,8 +42,8 @@ export function readQueryGuidanceResource(): string { ' - "Which containers have been discharged but not picked up?"', ' - "Any holds on [X]?"', '- Primary tools: list_containers or list_shipments then get_container', - '- Supported list filters: status, port, carrier, updated_after (no has_hold filter exists).', - '- "Discharged but not picked up" is derived client-side: keep rows where podDischargedAt is set and podFullOutAt is empty. Hold state comes from the holdsAtPodTerminal field on each row, not a filter.', + '- list_containers has no server-side operational filters. Paginate and inspect returned rows; do not describe one page as a complete account-wide worklist.', + '- "Discharged but not picked up" is derived client-side from returned rows: keep rows where podDischargedAt is set and podFullOutAt is empty. Hold state comes from holdsAtPodTerminal, not a filter.', '', '### 4) Arrival / ETAs / delays', '- Question examples:', @@ -65,7 +65,11 @@ export function readQueryGuidanceResource(): string { ' - "Do I have any containers with demurrage risk?"', ' - "Which containers are at risk of LFD?"', '- Primary tool: list_containers plus get_container', - '- Supported list filters: status, port, carrier, updated_after. The list endpoint has no server-side sort; order rows client-side by the pickupLfd field returned on each container, and surface holdsAtPodTerminal alongside it.', + '- list_containers has no server-side filter or sort. Order each returned page client-side by pickupLfd and surface holdsAtPodTerminal, but do not claim the page represents every at-risk container in the account.', + '', + '### 7) Shipment list filtering', + '- list_shipments supports shipment number and tracking_stopped filters.', + '- Status, port, carrier, and updated-time filters are not supported by the API and must not be presented as applied.', '', '## Output Formatting Guidance', '', diff --git a/packages/mcp/src/sentry.ts b/packages/mcp/src/sentry.ts index efca4d1b..75b664f4 100644 --- a/packages/mcp/src/sentry.ts +++ b/packages/mcp/src/sentry.ts @@ -94,7 +94,14 @@ export function instrumentMcpServer( export function captureMcpException(error: unknown): void { if (Sentry.isInitialized()) { - Sentry.captureException(error); + const safeError = new Error( + 'The Terminal49 upstream request could not be completed.', + ); + const name = error instanceof Error ? error.name : 'Error'; + safeError.name = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(name) + ? name + : 'Error'; + Sentry.captureException(safeError); } } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index d9198443..7ee1051d 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -161,10 +161,10 @@ const SUPPORTED_LIST_FILTERS_BY_ENTITY: Record< ListEntityType, readonly string[] > = { - container: ['status', 'port', 'carrier', 'updated_after'], - shipment: ['status', 'port', 'carrier', 'updated_after'], + container: [], + shipment: ['number', 'tracking_stopped'], tracking_request: ['status', 'filters'], - unknown: ['status', 'port', 'carrier', 'updated_after'], + unknown: [], }; /** @@ -350,8 +350,9 @@ const listPageSizeSchema = z .positive() .transform((value) => Math.min(value, MAX_LIST_PAGE_SIZE)) .optional() + .default(25) .describe( - `Page size (1-${MAX_LIST_PAGE_SIZE}; values above ${MAX_LIST_PAGE_SIZE} are clamped)`, + `Page size (default 25; maximum ${MAX_LIST_PAGE_SIZE}; larger values are clamped)`, ); function normalizeContract(contract: ResponseContract): ResponseContract { @@ -423,6 +424,11 @@ function buildTrackContract( const hasTrackedContainer = Boolean((result as any)?.id); const isPending = Boolean((result as any)?.tracking_request_created) && !hasTrackedContainer; + const wasNotCreated = + (result as any)?.error === 'NotFound' && + (result as any)?.tracking_request_created === false; + const matchedButUnavailable = + (result as any)?.error === 'ContainerUnavailable'; const state = (result as any)?._metadata?.container_state || 'unknown'; return { purpose: `Track ${args.number} and return the linked container view when possible.`, @@ -432,8 +438,12 @@ function buildTrackContract( 'where to pull next (if container details are delayed)', ], requires_more_data: isPending - ? ['container UUID (once linking finishes)'] - : [], + ? ['container details becoming available after request linking'] + : matchedButUnavailable + ? ['the matched container details becoming available'] + : wasNotCreated + ? ['a verified identifier and carrier SCAC'] + : [], relevant_fields: [ 'tracking_request_created', 'container_state', @@ -442,11 +452,21 @@ function buildTrackContract( ], presentation_guidance: isPending ? 'Tracking request was created but container linking is not immediate. Mention this and provide next-check guidance.' - : `Use container state "${state}" to answer readiness, holds, and pickup timing.`, + : matchedButUnavailable + ? 'A tracked container match exists, but its details are temporarily unavailable. Do not claim that a new tracking request was created.' + : wasNotCreated + ? 'No tracking request was created. Ask the user to verify the identifier and carrier; do not describe this as pending.' + : `Use container state "${state}" to answer readiness, holds, and pickup timing.`, suggested_follow_ups: isPending ? ['list_tracking_requests', 'get_container'] - : ['get_container_transport_events'], - suggested_tools: ['get_container', 'get_container_transport_events'], + : matchedButUnavailable + ? ['get_container', 'search_container'] + : wasNotCreated + ? ['get_supported_shipping_lines', 'search_container'] + : ['get_container_transport_events'], + suggested_tools: wasNotCreated + ? ['get_supported_shipping_lines', 'search_container'] + : ['get_container', 'get_container_transport_events'], }; } @@ -827,13 +847,18 @@ function isProvided(value: unknown): boolean { function appliedFilterKeys( filters: Record | undefined, entityType: ListEntityType, + unsupportedFilters: string[] | undefined, ): string[] { if (!filters) { return []; } const supported = SUPPORTED_LIST_FILTERS_BY_ENTITY[entityType]; + const unsupported = new Set(unsupportedFilters ?? []); return supported.filter((key) => { + if (unsupported.has(key)) { + return false; + } if (key === 'filters') { // The raw pass-through bag can carry non-filter knobs like `include` // alongside (or instead of) real `filter[...]` keys; only the latter @@ -923,15 +948,23 @@ export function buildListContract( ? buildTrackingRequestListDisplay() : undefined; - const applied = appliedFilterKeys(requestContext.filters, entityType); + const applied = appliedFilterKeys( + requestContext.filters, + entityType, + requestContext.unsupportedFilters, + ); const dropped = droppedFilterKeys( requestContext.filters, requestContext.unsupportedFilters, entityType, ); const isFiltered = applied.length > 0; - const supportedVocab = - SUPPORTED_LIST_FILTERS_BY_ENTITY[entityType].join(', '); + const supportedFilters = SUPPORTED_LIST_FILTERS_BY_ENTITY[entityType]; + const supportedVocab = supportedFilters.join(', '); + const filterGuidance = + supportedFilters.length > 0 + ? `a filter to scope this list (${supportedVocab})` + : 'server-side filters are not available for this list endpoint; use pagination and inspect returned rows'; const rawTotal = Number(result?.meta?.total); const hasTotal = Number.isFinite(rawTotal); @@ -943,17 +976,20 @@ export function buildListContract( : true; const canAnswer: string[] = ['count and paging state']; + canAnswer.unshift('records in the current page'); if (isFiltered) { canAnswer.unshift('which records match the applied filters'); } const requiresMoreData: string[] = []; if (!isFiltered) { - requiresMoreData.push(`a filter to scope this list (${supportedVocab})`); + requiresMoreData.push(filterGuidance); } if (dropped.length > 0) { requiresMoreData.push( - `unsupported filter(s) were ignored: ${dropped.join(', ')} — re-query using only ${supportedVocab}`, + supportedFilters.length > 0 + ? `unsupported filter(s) were ignored: ${dropped.join(', ')} — re-query using only ${supportedVocab}` + : `unsupported filter(s) were ignored: ${dropped.join(', ')} — this endpoint has no server-side filters`, ); } if (hasTotal && !totalIsReliable) { @@ -1205,8 +1241,7 @@ export function createTerminal49McpServer( title: 'Search Containers', description: 'Search for containers, shipments, and tracking information by container number, ' + - 'booking number, bill of lading, or reference number. ' + - 'This is the fastest way to find container information. ' + + 'booking number, bill of lading, or reference number. Returns matching private-account records. ' + 'Examples: CAIU2885402, MAEU123456789, or any reference number.', annotations: { readOnlyHint: true, @@ -1299,16 +1334,18 @@ export function createTerminal49McpServer( .describe('Optional reference numbers for matching'), intent: toolIntentSchema, }), - outputSchema: z.object({ - error: z.string().optional(), - message: z.string().optional(), - id: z.string().optional(), - container_number: z.string().optional(), - status: z.string().optional(), - tracking_request_created: z.boolean().optional(), - infer_result: z.any().optional(), - _response_contract: responseContractSchema.optional(), - }), + outputSchema: z + .object({ + error: z.string().optional(), + message: z.string().optional(), + id: z.string().optional(), + container_number: z.string().optional(), + status: z.string().optional(), + tracking_request_created: z.boolean().optional(), + infer_result: z.any().optional(), + _response_contract: responseContractSchema, + }) + .passthrough(), }, wrapToolWithContract( async ({ @@ -1345,8 +1382,7 @@ export function createTerminal49McpServer( title: 'Get Container Details', description: 'Get container information with flexible data loading. Returns core container data (status, location, equipment, dates) ' + - 'plus optional related data. Choose includes based on user question and container state. ' + - 'Response includes metadata hints to guide follow-up queries.', + 'plus optional shipment, terminal, or transport-event data. Transport events are excluded by default to keep snapshots compact.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -1388,7 +1424,6 @@ export function createTerminal49McpServer( title: 'Get Shipment Details', description: 'Get detailed shipment information including routing, BOL, containers, and port details. ' + - 'Use this when user asks about a shipment (vs a specific container). ' + 'Returns: Bill of Lading, shipping line, port details, vessel info, ETAs, container list.', annotations: { readOnlyHint: true, @@ -1430,8 +1465,7 @@ export function createTerminal49McpServer( description: 'Get detailed transport event timeline for a container. Returns all milestones and movements ' + '(vessel loaded, departed, arrived, discharged, rail movements, delivery). ' + - 'Use this for questions about journey history, "what happened", timeline analysis, rail tracking. ' + - 'More efficient than get_container with transport_events when you only need event data.', + 'Provides journey history, timeline analysis, and rail tracking without loading the full container snapshot.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -1463,8 +1497,7 @@ export function createTerminal49McpServer( title: 'Get Supported Shipping Lines', description: 'Get list of shipping lines (carriers) supported by Terminal49 for container tracking. ' + - 'Returns SCAC codes, full names, and common abbreviations. ' + - 'Use this when user asks which carriers are supported or to validate a carrier name.', + 'Returns SCAC codes, full names, and common abbreviations, with optional name or SCAC filtering.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -1511,8 +1544,7 @@ export function createTerminal49McpServer( description: 'Get detailed routing and vessel itinerary for a container including all ports, vessels, and ETAs. ' + 'Shows complete multi-leg journey (origin → transshipment ports → destination). ' + - 'NOTE: This is a paid feature and may not be available for all accounts. ' + - 'Use for questions about routing, transshipments, or detailed vessel itinerary.', + 'This paid feature may not be available for every account.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -1595,26 +1627,24 @@ export function createTerminal49McpServer( { title: 'List Shipments', description: - 'List shipments with optional filters and pagination. ' + - 'Use for queries like "show recent shipments" or "shipments for a carrier".', + 'List shipments with pagination and supported filters for shipment number or tracking-stopped state.', annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false, }, inputSchema: z.object({ - status: z.string().optional().describe('Filter by shipment status'), - port: z.string().optional().describe('Filter by POD port LOCODE'), - carrier: z.string().optional().describe('Filter by shipping line SCAC'), - updated_after: z - .string() + number: z.string().optional().describe('Filter by shipment number'), + tracking_stopped: z + .boolean() .optional() - .describe('Filter by updated_at (ISO8601) >= value'), + .describe('Filter by whether shipping-line tracking has stopped'), include_containers: z .boolean() .optional() + .default(false) .describe( - 'Include containers relationship in response. Default: true.', + 'Include container relationships in each shipment. Default: false to keep list responses compact.', ), page: listPageSchema, page_size: listPageSizeSchema, @@ -1644,21 +1674,13 @@ export function createTerminal49McpServer( { title: 'List Containers', description: - 'List containers with optional filters and pagination. ' + - 'Use for queries like "containers at port" or "latest updates".', + 'List a paginated page of containers. The API does not expose server-side status, port, carrier, or update-time filters.', annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false, }, inputSchema: z.object({ - status: z.string().optional().describe('Filter by container status'), - port: z.string().optional().describe('Filter by POD port LOCODE'), - carrier: z.string().optional().describe('Filter by shipping line SCAC'), - updated_after: z - .string() - .optional() - .describe('Filter by updated_at (ISO8601) >= value'), include: z .string() .optional() diff --git a/packages/mcp/src/tool-transport.test.ts b/packages/mcp/src/tool-transport.test.ts new file mode 100644 index 00000000..c8a7c29e --- /dev/null +++ b/packages/mcp/src/tool-transport.test.ts @@ -0,0 +1,776 @@ +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import { createMcpHandler } from '@modelcontextprotocol/server'; +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { createTerminal49McpServer } from './server.js'; + +vi.mock('@sentry/node', () => ({ + captureException: vi.fn(), + flush: vi.fn().mockResolvedValue(true), + isInitialized: vi.fn(() => false), + wrapMcpServerWithSentry: vi.fn((server) => server), +})); + +const sdk = vi.hoisted(() => ({ + search: vi.fn(), + createTrackingRequestFromInfer: vi.fn(), + createTrackingRequest: vi.fn(), + containersGet: vi.fn(), + containersEvents: vi.fn(), + containersRoute: vi.fn(), + containersList: vi.fn(), + shipmentsGet: vi.fn(), + shipmentsList: vi.fn(), + shippingLinesList: vi.fn(), + trackingRequestsList: vi.fn(), +})); + +vi.mock('@terminal49/sdk', () => ({ + Terminal49Client: class Terminal49Client { + search = sdk.search; + createTrackingRequestFromInfer = sdk.createTrackingRequestFromInfer; + createTrackingRequest = sdk.createTrackingRequest; + getContainer = sdk.containersGet; + containers = { + get: sdk.containersGet, + events: sdk.containersEvents, + route: sdk.containersRoute, + list: sdk.containersList, + }; + shipments = { + get: sdk.shipmentsGet, + list: sdk.shipmentsList, + }; + shippingLines = { list: sdk.shippingLinesList }; + trackingRequests = { list: sdk.trackingRequestsList }; + }, + FeatureNotEnabledError: class FeatureNotEnabledError extends Error {}, + NotFoundError: class NotFoundError extends Error {}, +})); + +const CONTAINER_ID = '11111111-1111-4111-8111-111111111111'; +const SHIPMENT_ID = '22222222-2222-4222-8222-222222222222'; +const TOOL_NAMES = [ + 'search_container', + 'track_container', + 'get_container', + 'get_shipment_details', + 'get_container_transport_events', + 'get_supported_shipping_lines', + 'get_container_route', + 'list_shipments', + 'list_containers', + 'list_tracking_requests', +] as const; +type ToolName = (typeof TOOL_NAMES)[number]; + +function containerRaw() { + return { + data: { + id: CONTAINER_ID, + type: 'container', + attributes: { + number: 'CAIU1234567', + current_status: 'available_for_pickup', + available_for_pickup: true, + equipment_type: 'dry', + equipment_length: 40, + equipment_height: 'high_cube', + location_at_pod_terminal: 'Yard 4', + pod_arrived_at: '2026-08-18T10:00:00Z', + pod_discharged_at: '2026-08-19T14:00:00Z', + pickup_lfd: '2026-08-24', + pod_timezone: 'America/Los_Angeles', + terminal_checked_at: '2026-08-21T05:00:00Z', + holds_at_pod_terminal: [], + fees_at_pod_terminal: [], + created_at: '2026-07-01T00:00:00Z', + }, + relationships: { + shipment: { data: { id: SHIPMENT_ID, type: 'shipment' } }, + pod_terminal: { data: { id: 'terminal-1', type: 'terminal' } }, + }, + }, + included: [ + { + id: SHIPMENT_ID, + type: 'shipment', + attributes: { + ref_numbers: ['PO-2048'], + shipping_line_scac: 'MAEU', + shipping_line_name: 'Maersk', + bill_of_lading_number: 'MAEU123456789', + }, + }, + { + id: 'terminal-1', + type: 'terminal', + attributes: { + name: 'APM Terminals Pier 400', + firms_code: 'W185', + }, + }, + ], + }; +} + +function shipmentRaw() { + return { + data: { + id: SHIPMENT_ID, + type: 'shipment', + attributes: { + bill_of_lading_number: 'MAEU123456789', + shipping_line_scac: 'MAEU', + shipping_line_name: 'Maersk', + ref_numbers: ['PO-2048'], + pol_atd_at: '2026-07-25T08:00:00Z', + pod_eta_at: '2026-08-18T10:00:00Z', + port_of_lading_locode: 'CNSHA', + port_of_lading_name: 'Shanghai', + port_of_discharge_locode: 'USLAX', + port_of_discharge_name: 'Los Angeles', + pod_vessel_name: 'MAERSK ESSEN', + pod_voyage_number: '628E', + created_at: '2026-07-01T00:00:00Z', + updated_at: '2026-08-21T05:00:00Z', + }, + relationships: { + containers: { data: [{ id: CONTAINER_ID, type: 'container' }] }, + }, + }, + included: [ + { + id: CONTAINER_ID, + type: 'container', + attributes: { + number: 'CAIU1234567', + equipment_type: 'dry', + equipment_length: 40, + available_for_pickup: true, + pod_discharged_at: '2026-08-19T14:00:00Z', + pickup_lfd: '2026-08-24', + }, + }, + ], + }; +} + +function eventsRaw() { + return { + data: [ + { + id: 'event-1', + type: 'transport_event', + attributes: { + event: 'container.transport.vessel_departed', + timestamp: '2026-07-25T08:00:00Z', + timezone: 'Asia/Shanghai', + }, + }, + { + id: 'event-2', + type: 'transport_event', + attributes: { + event: 'container.transport.discharged', + timestamp: '2026-08-19T14:00:00Z', + timezone: 'America/Los_Angeles', + }, + }, + ], + included: [], + }; +} + +function routeRaw() { + return { + data: { + id: 'route-1', + type: 'route', + attributes: { + created_at: '2026-07-01T00:00:00Z', + updated_at: '2026-08-21T05:00:00Z', + }, + relationships: { + route_locations: { + data: [{ id: 'route-location-1', type: 'route_location' }], + }, + }, + }, + included: [ + { + id: 'route-location-1', + type: 'route_location', + attributes: { + inbound_mode: 'vessel', + inbound_scac: 'MAEU', + inbound_eta_at: '2026-08-18T10:00:00Z', + outbound_mode: 'truck', + }, + relationships: { + port: { data: { id: 'port-1', type: 'port' } }, + }, + }, + { + id: 'port-1', + type: 'port', + attributes: { + code: 'USLAX', + name: 'Los Angeles', + city: 'Los Angeles', + country_code: 'US', + }, + }, + ], + }; +} + +async function connectClient() { + const handler = createMcpHandler( + () => createTerminal49McpServer('fixture-token', 'https://api.test'), + { legacy: 'stateless', responseMode: 'json' }, + ); + const client = new Client( + { name: 'terminal49-all-tools-test', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } }, + ); + const transport = new StreamableHTTPClientTransport( + new URL('https://mcp.test/mcp'), + { + fetch: (url, init) => handler.fetch(new Request(url, init)), + }, + ); + await client.connect(transport); + return { client, handler }; +} + +function configureHappyPath(toolName: ToolName): void { + switch (toolName) { + case 'search_container': + sdk.search.mockResolvedValue({ + data: [ + { + id: CONTAINER_ID, + type: 'search_result', + attributes: { + entity_type: 'container', + number: 'CAIU1234567', + status: 'available_for_pickup', + scac: 'MAEU', + port_of_discharge_name: 'Los Angeles', + }, + }, + ], + }); + return; + case 'track_container': + sdk.search.mockResolvedValue({ data: [] }); + sdk.createTrackingRequestFromInfer.mockResolvedValue({ + infer: { inferred_type: 'container', selected_scac: 'MAEU' }, + trackingRequest: { + included: [{ id: CONTAINER_ID, type: 'container' }], + }, + }); + sdk.containersGet.mockResolvedValue({ raw: containerRaw() }); + return; + case 'get_container': + sdk.containersGet.mockResolvedValue({ raw: containerRaw() }); + return; + case 'get_shipment_details': + sdk.shipmentsGet.mockResolvedValue({ raw: shipmentRaw() }); + return; + case 'get_container_transport_events': + sdk.containersEvents.mockResolvedValue({ raw: eventsRaw() }); + return; + case 'get_supported_shipping_lines': + sdk.shippingLinesList.mockResolvedValue([ + { scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' }, + { + scac: 'MSCU', + name: 'Mediterranean Shipping Company', + shortName: 'MSC', + }, + ]); + return; + case 'get_container_route': + sdk.containersRoute.mockResolvedValue({ raw: routeRaw() }); + return; + case 'list_shipments': + sdk.shipmentsList.mockResolvedValue({ + items: [ + { + id: SHIPMENT_ID, + billOfLading: 'MAEU123456789', + shippingLineScac: 'MAEU', + podVesselName: 'MAERSK ESSEN', + portOfDischargeName: 'Los Angeles', + podEtaAt: '2026-08-18T10:00:00Z', + }, + ], + links: { self: 'https://api.test/shipments?page[number]=1' }, + meta: { total: 1 }, + unsupportedFilters: [], + }); + return; + case 'list_containers': + sdk.containersList.mockResolvedValue({ + items: [ + { + id: CONTAINER_ID, + number: 'CAIU1234567', + currentStatus: 'available_for_pickup', + podDischargedAt: '2026-08-19T14:00:00Z', + availableForPickup: true, + pickupLfd: '2026-08-24', + holdsAtPodTerminal: [], + terminals: { + podTerminal: { name: 'APM Terminals Pier 400' }, + }, + }, + ], + links: { self: 'https://api.test/containers?page[number]=1' }, + meta: { total: 1 }, + unsupportedFilters: [], + }); + return; + case 'list_tracking_requests': + sdk.trackingRequestsList.mockResolvedValue({ + items: [ + { + id: 'tracking-request-1', + requestNumber: 'CAIU1234567', + requestType: 'container', + status: 'succeeded', + scac: 'MAEU', + createdAt: '2026-08-20T12:00:00Z', + updatedAt: '2026-08-20T12:05:00Z', + }, + ], + links: { self: 'https://api.test/tracking_requests?page[number]=1' }, + meta: { total: 1 }, + }); + return; + default: { + const exhaustive: never = toolName; + throw new Error(`Unhandled tool ${exhaustive}`); + } + } +} + +function argumentsFor(toolName: ToolName): Record { + switch (toolName) { + case 'search_container': + return { query: 'CAIU1234567' }; + case 'track_container': + return { number: 'CAIU1234567', scac: 'MAEU' }; + case 'get_container': + case 'get_container_transport_events': + case 'get_container_route': + return { id: CONTAINER_ID }; + case 'get_shipment_details': + return { id: SHIPMENT_ID, include_containers: true }; + case 'get_supported_shipping_lines': + return { search: 'ma' }; + case 'list_shipments': + return { tracking_stopped: false, page: 1, page_size: 10 }; + case 'list_containers': + return { page: 1, page_size: 10 }; + case 'list_tracking_requests': + return { status: 'succeeded', page: 1, page_size: 10 }; + default: { + const exhaustive: never = toolName; + throw new Error(`Unhandled tool ${exhaustive}`); + } + } +} + +function expectedOutputFor(toolName: ToolName): Record { + switch (toolName) { + case 'search_container': + return { + total_results: 1, + containers: [ + expect.objectContaining({ + id: CONTAINER_ID, + container_number: 'CAIU1234567', + shipping_line: 'MAEU', + }), + ], + }; + case 'track_container': + return { + container_number: 'CAIU1234567', + tracking_request_created: true, + }; + case 'get_container': + return { + id: CONTAINER_ID, + container_number: 'CAIU1234567', + status: 'available_for_pickup', + shipment: expect.objectContaining({ id: SHIPMENT_ID, line: 'MAEU' }), + }; + case 'get_shipment_details': + return { + id: SHIPMENT_ID, + bill_of_lading: 'MAEU123456789', + shipping_line: expect.objectContaining({ scac: 'MAEU' }), + containers: expect.objectContaining({ count: 1 }), + }; + case 'get_container_transport_events': + return { + total_events: 2, + timeline: expect.arrayContaining([ + expect.objectContaining({ + event: 'container.transport.vessel_departed', + }), + ]), + }; + case 'get_supported_shipping_lines': + return { + total_lines: 1, + shipping_lines: [ + expect.objectContaining({ scac: 'MAEU', name: 'Maersk' }), + ], + }; + case 'get_container_route': + return { + route_id: 'route-1', + total_legs: 1, + route_locations: [ + expect.objectContaining({ + port: expect.objectContaining({ code: 'USLAX' }), + }), + ], + }; + case 'list_shipments': + return { + items: [ + expect.objectContaining({ + id: SHIPMENT_ID, + billOfLading: 'MAEU123456789', + }), + ], + unsupportedFilters: [], + }; + case 'list_containers': + return { + items: [ + expect.objectContaining({ + id: CONTAINER_ID, + number: 'CAIU1234567', + }), + ], + unsupportedFilters: [], + }; + case 'list_tracking_requests': + return { + items: [ + expect.objectContaining({ + requestNumber: 'CAIU1234567', + status: 'succeeded', + }), + ], + }; + default: { + const exhaustive: never = toolName; + throw new Error(`Unhandled tool ${exhaustive}`); + } + } +} + +function configureFailure(toolName: ToolName, error: Error): void { + switch (toolName) { + case 'search_container': + sdk.search.mockRejectedValue(error); + return; + case 'track_container': + sdk.search.mockResolvedValue({ data: [] }); + sdk.createTrackingRequestFromInfer.mockRejectedValue(error); + return; + case 'get_container': + sdk.containersGet.mockRejectedValue(error); + return; + case 'get_shipment_details': + sdk.shipmentsGet.mockRejectedValue(error); + return; + case 'get_container_transport_events': + sdk.containersEvents.mockRejectedValue(error); + return; + case 'get_supported_shipping_lines': + sdk.shippingLinesList.mockRejectedValue(error); + return; + case 'get_container_route': + sdk.containersRoute.mockRejectedValue(error); + return; + case 'list_shipments': + sdk.shipmentsList.mockRejectedValue(error); + return; + case 'list_containers': + sdk.containersList.mockRejectedValue(error); + return; + case 'list_tracking_requests': + sdk.trackingRequestsList.mockRejectedValue(error); + return; + default: { + const exhaustive: never = toolName; + throw new Error(`Unhandled tool ${exhaustive}`); + } + } +} + +beforeEach(() => { + for (const mock of Object.values(sdk)) { + mock.mockReset(); + } +}); + +describe('all public tools over MCP client transport', () => { + it.each(TOOL_NAMES)( + '%s returns realistic structured content that validates its output schema', + async (toolName) => { + configureHappyPath(toolName); + const { client, handler } = await connectClient(); + + try { + const result = await client.callTool({ + name: toolName, + arguments: argumentsFor(toolName), + }); + + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + ...expectedOutputFor(toolName), + _response_contract: { + purpose: expect.any(String), + presentation_guidance: expect.any(String), + suggested_tools: expect.any(Array), + }, + }); + expect( + result.content.some( + (block) => + block.type === 'text' && + block.annotations?.audience?.includes('assistant') && + block.text.includes('_agent_steering'), + ), + ).toBe(true); + } finally { + await client.close(); + await handler.close(); + } + }, + ); + + it('track_container returns a validated uncreated state for a not-found response', async () => { + sdk.search.mockResolvedValue({ data: [] }); + const notFound = new Error('internal route not found'); + notFound.name = 'NotFoundError'; + sdk.createTrackingRequestFromInfer.mockRejectedValue(notFound); + const { client, handler } = await connectClient(); + + try { + const result = await client.callTool({ + name: 'track_container', + arguments: { number: 'CAIU1234567', scac: 'MAEU' }, + }); + + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + error: 'NotFound', + tracking_request_created: false, + message: expect.stringContaining('could not create a tracking request'), + _response_contract: { + requires_more_data: ['a verified identifier and carrier SCAC'], + presentation_guidance: expect.stringContaining( + 'No tracking request was created', + ), + }, + }); + expect(JSON.stringify(result)).not.toContain('internal route'); + } finally { + await client.close(); + await handler.close(); + } + }); + + it('track_container preserves a created request when its linked container is not readable yet', async () => { + sdk.search.mockResolvedValue({ data: [] }); + sdk.createTrackingRequestFromInfer.mockResolvedValue({ + infer: { inferred_type: 'container', selected_scac: 'MAEU' }, + trackingRequest: { + included: [{ id: CONTAINER_ID, type: 'container' }], + }, + }); + const notFound = new Error('internal read model not ready'); + notFound.name = 'NotFoundError'; + sdk.containersGet.mockRejectedValue(notFound); + const { client, handler } = await connectClient(); + + try { + const result = await client.callTool({ + name: 'track_container', + arguments: { number: 'CAIU1234567', scac: 'MAEU' }, + }); + + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + tracking_request_created: true, + tracking_request: { + request_number: 'CAIU1234567', + container_id: CONTAINER_ID, + }, + _response_contract: { + requires_more_data: [ + 'container details becoming available after request linking', + ], + presentation_guidance: expect.stringContaining( + 'container linking is not immediate', + ), + }, + }); + expect(JSON.stringify(result)).not.toContain('internal read model'); + } finally { + await client.close(); + await handler.close(); + } + }); + + it('track_container preserves an existing match when its details are temporarily unavailable', async () => { + sdk.search.mockResolvedValue({ + data: [ + { + id: CONTAINER_ID, + type: 'search_result', + attributes: { + entity_type: 'container', + number: 'CAIU1234567', + scac: 'MAEU', + }, + }, + ], + }); + const notFound = new Error('internal container read failed'); + notFound.name = 'NotFoundError'; + sdk.containersGet.mockRejectedValue(notFound); + const { client, handler } = await connectClient(); + + try { + const result = await client.callTool({ + name: 'track_container', + arguments: { number: 'CAIU1234567', scac: 'MAEU' }, + }); + + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + error: 'ContainerUnavailable', + tracking_request_created: false, + container: { id: CONTAINER_ID }, + message: expect.stringContaining('tracked container matched'), + _response_contract: { + requires_more_data: [ + 'the matched container details becoming available', + ], + presentation_guidance: expect.stringContaining( + 'tracked container match exists', + ), + }, + }); + expect(sdk.createTrackingRequestFromInfer).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain('internal container read'); + } finally { + await client.close(); + await handler.close(); + } + }); + + it.each(TOOL_NAMES)( + '%s redacts upstream failure details over MCP client transport', + async (toolName) => { + const leakedUrl = 'https://internal.example/v2?token=secret-token'; + configureFailure(toolName, new Error(`upstream failed at ${leakedUrl}`)); + const { client, handler } = await connectClient(); + + try { + const result = await client.callTool({ + name: toolName, + arguments: argumentsFor(toolName), + }); + const text = result.content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join('\n'); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toBeUndefined(); + expect(text).toContain('could not be completed'); + expect(text).not.toContain('internal.example'); + expect(text).not.toContain('secret-token'); + } finally { + await client.close(); + await handler.close(); + } + }, + ); +}); + +describe('prompts and resources over MCP client transport', () => { + it('renders all prompts with realistic arguments', async () => { + const { client, handler } = await connectClient(); + + try { + const track = await client.getPrompt({ + name: 'track-shipment', + arguments: { + container_number: 'CAIU1234567', + carrier: 'MAEU', + }, + }); + const demurrage = await client.getPrompt({ + name: 'check-demurrage', + arguments: { container_id: CONTAINER_ID }, + }); + const delays = await client.getPrompt({ + name: 'analyze-delays', + arguments: { container_id: CONTAINER_ID }, + }); + + expect(JSON.stringify(track.messages)).toContain('CAIU1234567'); + expect(JSON.stringify(track.messages)).toContain('MAEU'); + expect(JSON.stringify(demurrage.messages)).toContain('Last Free Day'); + expect(JSON.stringify(delays.messages)).toContain('journey timeline'); + } finally { + await client.close(); + await handler.close(); + } + }); + + it('reads all static resources and the container resource', async () => { + sdk.containersGet.mockResolvedValue({ raw: containerRaw() }); + const { client, handler } = await connectClient(); + + try { + const uris = [ + 'terminal49://docs/milestone-glossary', + 'terminal49://docs/mcp-query-guidance', + 'terminal49://docs/list-display-columns', + `terminal49://container/${CONTAINER_ID}`, + ]; + const results = await Promise.all( + uris.map((uri) => client.readResource({ uri })), + ); + + for (const [index, result] of results.entries()) { + expect(result.contents[0]?.uri).toBe(uris[index]); + expect(result.contents[0]).toMatchObject({ + text: expect.any(String), + }); + } + expect(JSON.stringify(results[3]?.contents)).toContain('CAIU1234567'); + } finally { + await client.close(); + await handler.close(); + } + }); +}); diff --git a/packages/mcp/src/tools/contracts.test.ts b/packages/mcp/src/tools/contracts.test.ts index 53c5ca5f..192bb67b 100644 --- a/packages/mcp/src/tools/contracts.test.ts +++ b/packages/mcp/src/tools/contracts.test.ts @@ -902,6 +902,32 @@ describe('MCP tool contracts', () => { }); }); + it('track_container distinguishes an uncreated request from a hard upstream failure', async () => { + const client = asClient({ + search: vi.fn().mockResolvedValue({ data: [] }), + createTrackingRequestFromInfer: vi + .fn() + .mockRejectedValue(new NotFoundError('internal route not found')), + }); + + const result = await executeTrackContainer( + { number: 'CAIU1234567', scac: 'MAEU' }, + client, + ); + + expect(result).toMatchObject({ + error: 'NotFound', + tracking_request_created: false, + message: expect.stringContaining('could not create a tracking request'), + _metadata: { + presentation_guidance: expect.stringContaining( + 'no tracking request was created', + ), + }, + }); + expect(JSON.stringify(result)).not.toContain('internal route'); + }); + it('get_supported_shipping_lines filters response by search term', async () => { const shippingList = vi.fn().mockResolvedValue([ { scac: 'MSCU', name: 'MSC', shortName: 'MSC' }, @@ -966,47 +992,48 @@ describe('MCP tool contracts', () => { }); it('get_container_route returns route summary when route data exists', async () => { - const client = asClient({ - containers: { - route: vi.fn().mockResolvedValue({ - raw: { - data: { - id: 'route-1', - type: 'route', - attributes: { - created_at: '2025-01-01T00:00:00Z', - updated_at: '2025-01-02T00:00:00Z', - }, - relationships: { - route_locations: { - data: [{ id: 'rl-1', type: 'route_location' }], - }, - }, + const route = vi.fn().mockResolvedValue({ + raw: { + data: { + id: 'route-1', + type: 'route', + attributes: { + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-02T00:00:00Z', + }, + relationships: { + route_locations: { + data: [{ id: 'rl-1', type: 'route_location' }], }, - included: [ - { - id: 'rl-1', - type: 'route_location', - attributes: { - inbound_mode: 'vessel', - outbound_mode: 'vessel', - }, - relationships: { - port: { data: { id: 'port-1', type: 'port' } }, - }, - }, - { - id: 'port-1', - type: 'port', - attributes: { - code: 'USLAX', - name: 'Los Angeles', - }, - }, - ], }, - mapped: { id: 'route-1' }, - }), + }, + included: [ + { + id: 'rl-1', + type: 'route_location', + attributes: { + inbound_mode: 'vessel', + outbound_mode: 'vessel', + }, + relationships: { + port: { data: { id: 'port-1', type: 'port' } }, + }, + }, + { + id: 'port-1', + type: 'port', + attributes: { + code: 'USLAX', + name: 'Los Angeles', + }, + }, + ], + }, + mapped: { id: 'route-1' }, + }); + const client = asClient({ + containers: { + route, }, }); @@ -1018,6 +1045,7 @@ describe('MCP tool contracts', () => { expect(result.route_id).toBe('route-1'); expect(result.total_legs).toBe(1); expect(result.route_locations[0].port).toMatchObject({ code: 'USLAX' }); + expect(route).toHaveBeenCalledWith('container-1', { format: 'raw' }); }); it('get_container_route returns feature-not-enabled contract instead of throwing', async () => { @@ -1046,8 +1074,8 @@ describe('MCP tool contracts', () => { const result = await executeListShipments( { - status: 'in_transit', - carrier: 'MAEU', + number: 'MAEU123456789', + tracking_stopped: false, page: 2, page_size: 25, }, @@ -1056,11 +1084,9 @@ describe('MCP tool contracts', () => { expect(list).toHaveBeenCalledWith( { - status: 'in_transit', - port: undefined, - carrier: 'MAEU', - updatedAfter: undefined, - includeContainers: undefined, + number: 'MAEU123456789', + trackingStopped: false, + includeContainers: false, }, { format: 'mapped', page: 2, pageSize: 25 }, ); @@ -1073,7 +1099,6 @@ describe('MCP tool contracts', () => { const result = await executeListContainers( { - status: 'available_for_pickup', include: 'shipment,pod_terminal', page: 1, page_size: 50, @@ -1083,10 +1108,6 @@ describe('MCP tool contracts', () => { expect(list).toHaveBeenCalledWith( { - status: 'available_for_pickup', - port: undefined, - carrier: undefined, - updatedAfter: undefined, include: ['shipment', 'pod_terminal'], }, { format: 'mapped', page: 1, pageSize: 50 }, @@ -1109,10 +1130,6 @@ describe('MCP tool contracts', () => { expect(list).toHaveBeenCalledWith( { - status: undefined, - port: undefined, - carrier: undefined, - updatedAfter: undefined, include: undefined, }, { format: 'mapped', page: 1, pageSize: 10 }, @@ -1143,6 +1160,43 @@ describe('MCP tool contracts', () => { expect(result.items).toHaveLength(1); }); + it('list tools use compact defaults when pagination and relationships are omitted', async () => { + const containersList = vi.fn().mockResolvedValue({ items: [] }); + const shipmentsList = vi.fn().mockResolvedValue({ items: [] }); + const trackingRequestsList = vi.fn().mockResolvedValue({ items: [] }); + const client = asClient({ + containers: { list: containersList }, + shipments: { list: shipmentsList }, + trackingRequests: { list: trackingRequestsList }, + }); + + await executeListContainers({}, client); + await executeListShipments({}, client); + await executeListTrackingRequests({}, client); + + expect(containersList).toHaveBeenCalledWith(expect.any(Object), { + format: 'mapped', + page: undefined, + pageSize: 25, + }); + expect(shipmentsList).toHaveBeenCalledWith( + expect.objectContaining({ includeContainers: false }), + { + format: 'mapped', + page: undefined, + pageSize: 25, + }, + ); + expect(trackingRequestsList).toHaveBeenCalledWith( + {}, + { + format: 'mapped', + page: undefined, + pageSize: 25, + }, + ); + }); + it('list_tracking_requests maps status and request_type args to filter keys', async () => { const list = vi.fn().mockResolvedValue({ items: [{ id: 'tr-2' }] }); const client = asClient({ @@ -1162,7 +1216,7 @@ describe('MCP tool contracts', () => { 'filter[status]': 'failed', 'filter[request_type]': 'manual', }, - { format: 'mapped', page: undefined, pageSize: undefined }, + { format: 'mapped', page: undefined, pageSize: 25 }, ); expect(result.items).toHaveLength(1); }); @@ -1186,7 +1240,7 @@ describe('MCP tool contracts', () => { expect(list).toHaveBeenCalledWith( { 'filter[status]': 'failed' }, - { format: 'mapped', page: undefined, pageSize: undefined }, + { format: 'mapped', page: undefined, pageSize: 25 }, ); }); @@ -1198,10 +1252,10 @@ describe('MCP tool contracts', () => { ); expect(contract.can_answer).not.toContain('which records match filters'); - // An unfiltered list cannot be presented as the user's filtered worklist; - // the agent must be told it needs a filter to answer scoped questions. + // The API exposes no server-side container filters, so the contract must + // tell the agent to paginate rather than invent a scoped worklist. expect(contract.requires_more_data).toContain( - 'a filter to scope this list (status, port, carrier, updated_after)', + 'server-side filters are not available for this list endpoint; use pagination and inspect returned rows', ); }); @@ -1323,11 +1377,11 @@ describe('MCP tool contracts', () => { expect(contract.presentation_guidance).toContain('empty_state'); }); - it('buildListContract reports which records match when a filter was applied', () => { + it('buildListContract reports which shipments match a supported filter', () => { const contract = buildListContract( - { items: [{ id: 'c1' }], meta: { total: 1 } }, - 'container', - { filters: { status: 'available_for_pickup' } }, + { items: [{ id: 's1' }], meta: { total: 1 } }, + 'shipment', + { filters: { number: 'MAEU123456789' } }, ); expect(contract.can_answer).toContain( @@ -1335,20 +1389,25 @@ describe('MCP tool contracts', () => { ); }); - it('buildListContract echoes dropped/unsupported filters from the SDK', () => { + it('buildListContract never treats SDK-unsupported filters as applied', () => { const contract = buildListContract( { items: [{ id: 'c1' }], meta: { total: 1 }, - unsupportedFilters: ['has_hold'], }, 'container', - { filters: { status: 'available_for_pickup', has_hold: true } }, + { + filters: { status: 'available_for_pickup' }, + unsupportedFilters: ['status'], + }, ); - expect(contract.dropped_filters).toEqual(['has_hold']); + expect(contract.can_answer).not.toContain( + 'which records match the applied filters', + ); + expect(contract.dropped_filters).toEqual(['status']); expect( - contract.requires_more_data.some((entry) => entry.includes('has_hold')), + contract.requires_more_data.some((entry) => entry.includes('status')), ).toBe(true); }); @@ -1367,9 +1426,9 @@ describe('MCP tool contracts', () => { it('buildListContract trusts a plausible total when a filter is applied', () => { const contract = buildListContract( - { items: [{ id: 'c1' }], meta: { total: 12 } }, - 'container', - { filters: { carrier: 'MAEU' } }, + { items: [{ id: 's1' }], meta: { total: 12 } }, + 'shipment', + { filters: { tracking_stopped: false } }, ); expect(contract.total_is_reliable).toBe(true); @@ -1379,7 +1438,7 @@ describe('MCP tool contracts', () => { const contract = buildListContract( { items: [{ id: 'c1' }], meta: { total: 1 } }, 'container', - { filters: { status: 'available_for_pickup' } }, + { filters: {} }, ); expect(contract.display).toBeDefined(); diff --git a/packages/mcp/src/tools/get-container-route.ts b/packages/mcp/src/tools/get-container-route.ts index d492eba4..2eef2cd3 100644 --- a/packages/mcp/src/tools/get-container-route.ts +++ b/packages/mcp/src/tools/get-container-route.ts @@ -63,7 +63,9 @@ export async function executeGetContainerRoute( }); try { - const result = await client.containers.route(args.id, { format: 'both' }); + // The handler builds a curated route response entirely from JSON:API data; + // requesting the mapped representation as well only duplicates SDK work. + const result = await client.containers.route(args.id, { format: 'raw' }); const raw = (result as any)?.raw ?? result; const duration = Date.now() - startTime; diff --git a/packages/mcp/src/tools/list-containers.ts b/packages/mcp/src/tools/list-containers.ts index 71e575af..cf2f0745 100644 --- a/packages/mcp/src/tools/list-containers.ts +++ b/packages/mcp/src/tools/list-containers.ts @@ -7,10 +7,6 @@ import { Terminal49Client } from '@terminal49/sdk'; import { logMcpEvent } from '../logging.js'; export interface ListContainersArgs { - status?: string; - port?: string; - carrier?: string; - updated_after?: string; include?: string; page?: number; page_size?: number; @@ -22,28 +18,19 @@ export async function executeListContainers( ): Promise { const startTime = Date.now(); const include = args.include?.trim() || undefined; + const pageSize = args.page_size ?? 25; logMcpEvent({ event: 'tool.execute.start', tool: 'list_containers', - filters: { - status: args.status, - port: args.port, - carrier: args.carrier, - updated_after: args.updated_after, - include: include, - }, + include, page: args.page, - page_size: args.page_size, + page_size: pageSize, timestamp: new Date().toISOString(), }); try { const result = await client.containers.list( { - status: args.status, - port: args.port, - carrier: args.carrier, - updatedAfter: args.updated_after, include: include ? (include.split(',').map((s) => s.trim()) as any) : undefined, @@ -51,7 +38,7 @@ export async function executeListContainers( { format: 'mapped', page: args.page, - pageSize: args.page_size, + pageSize, }, ); diff --git a/packages/mcp/src/tools/list-shipments.ts b/packages/mcp/src/tools/list-shipments.ts index 302c2290..d95927bf 100644 --- a/packages/mcp/src/tools/list-shipments.ts +++ b/packages/mcp/src/tools/list-shipments.ts @@ -7,10 +7,8 @@ import { Terminal49Client } from '@terminal49/sdk'; import { logMcpEvent } from '../logging.js'; export interface ListShipmentsArgs { - status?: string; - port?: string; - carrier?: string; - updated_after?: string; + number?: string; + tracking_stopped?: boolean; include_containers?: boolean; page?: number; page_size?: number; @@ -21,34 +19,32 @@ export async function executeListShipments( client: Terminal49Client, ): Promise { const startTime = Date.now(); + const includeContainers = args.include_containers ?? false; + const pageSize = args.page_size ?? 25; logMcpEvent({ event: 'tool.execute.start', tool: 'list_shipments', filters: { - status: args.status, - port: args.port, - carrier: args.carrier, - updated_after: args.updated_after, - include_containers: args.include_containers, + number: args.number, + tracking_stopped: args.tracking_stopped, + include_containers: includeContainers, }, page: args.page, - page_size: args.page_size, + page_size: pageSize, timestamp: new Date().toISOString(), }); try { const result = await client.shipments.list( { - status: args.status, - port: args.port, - carrier: args.carrier, - updatedAfter: args.updated_after, - includeContainers: args.include_containers, + number: args.number, + trackingStopped: args.tracking_stopped, + includeContainers, }, { format: 'mapped', page: args.page, - pageSize: args.page_size, + pageSize, }, ); diff --git a/packages/mcp/src/tools/list-tracking-requests.ts b/packages/mcp/src/tools/list-tracking-requests.ts index 69402ba2..3e29eed4 100644 --- a/packages/mcp/src/tools/list-tracking-requests.ts +++ b/packages/mcp/src/tools/list-tracking-requests.ts @@ -19,12 +19,13 @@ export async function executeListTrackingRequests( client: Terminal49Client, ): Promise { const startTime = Date.now(); + const pageSize = args.page_size ?? 25; logMcpEvent({ event: 'tool.execute.start', tool: 'list_tracking_requests', filters: args.filters, page: args.page, - page_size: args.page_size, + page_size: pageSize, timestamp: new Date().toISOString(), }); @@ -48,7 +49,7 @@ export async function executeListTrackingRequests( const result = await client.trackingRequests.list(filters, { format: 'mapped', page: args.page, - pageSize: args.page_size, + pageSize, }); const duration = Date.now() - startTime; diff --git a/packages/mcp/src/tools/track-container.ts b/packages/mcp/src/tools/track-container.ts index 9f86aebc..64ab55f3 100644 --- a/packages/mcp/src/tools/track-container.ts +++ b/packages/mcp/src/tools/track-container.ts @@ -3,7 +3,7 @@ * Creates a tracking request for a container/BL/booking number and returns the container details */ -import { Terminal49Client } from '@terminal49/sdk'; +import { NotFoundError, Terminal49Client } from '@terminal49/sdk'; import { logMcpEvent } from '../logging.js'; import { executeGetContainer } from './get-container.js'; import { executeSearchContainer } from './search-container.js'; @@ -117,6 +117,14 @@ function parseValidationPointer(message: string): string | undefined { return pointerMatch?.[1]; } +function isNotFound(error: unknown): boolean { + return ( + error instanceof NotFoundError || + (error as { status?: number })?.status === 404 || + (error as { name?: string })?.name === 'NotFoundError' + ); +} + async function findExistingTrackedContainer( number: string, client: Terminal49Client, @@ -190,10 +198,29 @@ export async function executeTrackContainer( client, ); if (existingContainer?.id) { - const containerDetails = await executeGetContainer( - { id: existingContainer.id }, - client, - ); + let containerDetails: Awaited>; + try { + containerDetails = await executeGetContainer( + { id: existingContainer.id }, + client, + ); + } catch (error) { + if (!isNotFound(error)) { + throw error; + } + return { + error: 'ContainerUnavailable', + message: + 'A tracked container matched this number, but its details are not available yet. Retry the container lookup shortly.', + tracking_request_created: false, + container: { id: existingContainer.id }, + _metadata: { + presentation_guidance: + 'State that the container match exists but its details are temporarily unavailable. Do not claim that a new tracking request was created.', + recommendations: ['get_container', 'search_container'], + }, + }; + } return { ...containerDetails, tracking_request_created: false, @@ -283,11 +310,33 @@ export async function executeTrackContainer( timestamp: new Date().toISOString(), }); - // Step 2: Get full container details using the ID - const containerDetails = await executeGetContainer( - { id: containerId }, - client, - ); + // Step 2: Get full container details using the ID. A newly-created request + // can expose its relationship before the container read model is available. + // Preserve the successful write state instead of misreporting the request as + // uncreated when that follow-up read briefly returns 404. + let containerDetails: Awaited>; + try { + containerDetails = await executeGetContainer({ id: containerId }, client); + } catch (error) { + if (!isNotFound(error)) { + throw error; + } + return { + tracking_request_created: true, + infer_result: infer, + tracking_request: { + request_number: number, + number_type: inferredNumberType, + scac: requestedScac || heuristicScac, + container_id: containerId, + }, + _metadata: { + presentation_guidance: + 'Tracking request was created and linked, but container details are not available yet. Poll list_tracking_requests or retry shortly.', + recommendations: ['list_tracking_requests', 'get_container'], + }, + }; + } const duration = Date.now() - startTime; logMcpEvent({ @@ -308,6 +357,28 @@ export async function executeTrackContainer( const duration = Date.now() - startTime; const message = (error as Error).message; + if (isNotFound(error)) { + logMcpEvent({ + event: 'tracking_request.not_found', + number, + numberType: inferredNumberType, + scac: requestedScac || heuristicScac, + duration_ms: duration, + timestamp: new Date().toISOString(), + }); + return { + error: 'NotFound', + message: + 'No tracked container matched this number, and Terminal49 could not create a tracking request for it. Verify the number and carrier SCAC, then retry.', + tracking_request_created: false, + _metadata: { + presentation_guidance: + 'Clearly state that no tracking request was created. Ask the user to verify the identifier and carrier; do not imply that tracking is pending.', + recommendations: ['get_supported_shipping_lines', 'search_container'], + }, + }; + } + if ( /Unable to infer/.test(message) || /SCAC/.test(message) ||