From 90dc24d4d8acd900f49256bfe5fab2de60172fa1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 06:18:13 +0000 Subject: [PATCH 1/8] fix(mcp): harden tool outputs and transport coverage Co-authored-by: Akshay Dodeja --- chatgpt-app-submission.json | 4 +- packages/mcp/src/annotations.test.ts | 6 +- packages/mcp/src/mcp.test.ts | 34 +- packages/mcp/src/server.ts | 99 ++-- packages/mcp/src/tool-transport.test.ts | 559 ++++++++++++++++++ packages/mcp/src/tools/contracts.test.ts | 143 +++-- packages/mcp/src/tools/get-container-route.ts | 4 +- packages/mcp/src/tools/list-containers.ts | 5 +- packages/mcp/src/tools/list-shipments.ts | 10 +- .../mcp/src/tools/list-tracking-requests.ts | 5 +- packages/mcp/src/tools/track-container.ts | 32 +- 11 files changed, 799 insertions(+), 102 deletions(-) create mode 100644 packages/mcp/src/tool-transport.test.ts diff --git a/chatgpt-app-submission.json b/chatgpt-app-submission.json index 4cbdb6cf..14a80934 100644 --- a/chatgpt-app-submission.json +++ b/chatgpt-app-submission.json @@ -135,7 +135,7 @@ "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 }, { @@ -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..08296caf 100644 --- a/packages/mcp/src/annotations.test.ts +++ b/packages/mcp/src/annotations.test.ts @@ -17,12 +17,12 @@ type ToolAnnotations = { 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,6 +88,8 @@ 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(); } }); diff --git a/packages/mcp/src/mcp.test.ts b/packages/mcp/src/mcp.test.ts index 116eb703..094aa99b 100644 --- a/packages/mcp/src/mcp.test.ts +++ b/packages/mcp/src/mcp.test.ts @@ -507,14 +507,16 @@ describe('MCP server wiring', () => { }); it('returns carrier SCAC completion values over MCP', async () => { - shippingLinesList.mockResolvedValue([ - { scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' }, - { - scac: 'MSCU', - name: 'Mediterranean Shipping Company', - shortName: 'MSC', - }, - ]); + shippingLinesList + .mockRejectedValueOnce(new Error('upstream unavailable')) + .mockResolvedValue([ + { scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' }, + { + scac: 'MSCU', + name: 'Mediterranean Shipping Company', + shortName: 'MSC', + }, + ]); const handler = createMcpHandler( () => createTerminal49McpServer('token', 'https://api.test'), @@ -537,6 +539,12 @@ describe('MCP server wiring', () => { try { await client.connect(transport); + const degraded = await client.complete({ + ref: { type: 'ref/prompt', name: 'track-shipment' }, + argument: { name: 'carrier', value: 'ma' }, + }); + expect(degraded.completion.values).toEqual([]); + const broadMatch = await client.complete({ ref: { type: 'ref/prompt', name: 'track-shipment' }, argument: { name: 'carrier', value: 'm' }, @@ -548,13 +556,9 @@ describe('MCP server wiring', () => { argument: { name: 'carrier', value: 'ma' }, }); expect(narrowMatch.completion.values).toEqual(['MAEU']); - - shippingLinesList.mockRejectedValue(new Error('upstream unavailable')); - const degraded = await client.complete({ - ref: { type: 'ref/prompt', name: 'track-shipment' }, - argument: { name: 'carrier', value: 'ma' }, - }); - expect(degraded.completion.values).toEqual([]); + // One retry after the failed load, then "m" and "ma" share the same + // per-server carrier catalog instead of issuing duplicate API requests. + expect(shippingLinesList).toHaveBeenCalledTimes(2); } finally { await client.close(); await handler.close(); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index d9198443..9fa824e9 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -15,7 +15,10 @@ import { executeTrackContainer } from './tools/track-container.js'; import { executeSearchContainer } from './tools/search-container.js'; import { executeGetShipmentDetails } from './tools/get-shipment-details.js'; import { executeGetContainerTransportEvents } from './tools/get-container-transport-events.js'; -import { executeGetSupportedShippingLines } from './tools/get-supported-shipping-lines.js'; +import { + executeGetSupportedShippingLines, + type ShippingLineRecord, +} from './tools/get-supported-shipping-lines.js'; import { executeGetContainerRoute, type FeatureNotEnabledResult, @@ -350,8 +353,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 +427,9 @@ 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 state = (result as any)?._metadata?.container_state || 'unknown'; return { purpose: `Track ${args.number} and return the linked container view when possible.`, @@ -433,7 +440,9 @@ function buildTrackContract( ], requires_more_data: isPending ? ['container UUID (once linking finishes)'] - : [], + : wasNotCreated + ? ['a verified identifier and carrier SCAC'] + : [], relevant_fields: [ 'tracking_request_created', 'container_state', @@ -442,11 +451,17 @@ 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.`, + : 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'], + : 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'], }; } @@ -1146,14 +1161,33 @@ function wrapToolWithContract( function createCarrierScacCompleter( client: Terminal49Client, ): (value: string | undefined) => Promise { + let cachedLines: Promise | undefined; + + const loadLines = (): Promise => { + if (!cachedLines) { + cachedLines = executeGetSupportedShippingLines({}, client) + .then((result) => result.shipping_lines) + .catch((error: unknown) => { + cachedLines = undefined; + throw error; + }); + } + return cachedLines; + }; + return async (value: string | undefined): Promise => { try { - const search = typeof value === 'string' ? value.trim() : ''; - const { shipping_lines } = await executeGetSupportedShippingLines( - { search }, - client, - ); - return shipping_lines.slice(0, 100).map((line) => line.scac); + const search = + typeof value === 'string' ? value.trim().toLowerCase() : ''; + const lines = await loadLines(); + return lines + .filter((line) => + [line.scac, line.name, line.short_name] + .filter((candidate): candidate is string => Boolean(candidate)) + .some((candidate) => candidate.toLowerCase().includes(search)), + ) + .slice(0, 100) + .map((line) => line.scac); } catch { return []; } @@ -1205,8 +1239,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 +1332,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 +1380,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 +1422,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 +1463,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 +1495,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 +1542,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, @@ -1613,8 +1643,9 @@ export function createTerminal49McpServer( 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, diff --git a/packages/mcp/src/tool-transport.test.ts b/packages/mcp/src/tool-transport.test.ts new file mode 100644 index 00000000..bc3b2c78 --- /dev/null +++ b/packages/mcp/src/tool-transport.test.ts @@ -0,0 +1,559 @@ +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 { carrier: 'MAEU', page: 1, page_size: 10 }; + case 'list_containers': + return { status: 'available_for_pickup', 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 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({ + _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.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..dd23d372 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 () => { @@ -1143,6 +1171,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({ 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..312bb474 100644 --- a/packages/mcp/src/tools/list-containers.ts +++ b/packages/mcp/src/tools/list-containers.ts @@ -22,6 +22,7 @@ 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', @@ -33,7 +34,7 @@ export async function executeListContainers( include: include, }, page: args.page, - page_size: args.page_size, + page_size: pageSize, timestamp: new Date().toISOString(), }); @@ -51,7 +52,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..3e96e71e 100644 --- a/packages/mcp/src/tools/list-shipments.ts +++ b/packages/mcp/src/tools/list-shipments.ts @@ -21,6 +21,8 @@ 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', @@ -29,10 +31,10 @@ export async function executeListShipments( port: args.port, carrier: args.carrier, updated_after: args.updated_after, - include_containers: args.include_containers, + include_containers: includeContainers, }, page: args.page, - page_size: args.page_size, + page_size: pageSize, timestamp: new Date().toISOString(), }); @@ -43,12 +45,12 @@ export async function executeListShipments( port: args.port, carrier: args.carrier, updatedAfter: args.updated_after, - includeContainers: args.include_containers, + 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..b64fd287 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, @@ -308,6 +316,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) || From 8fccef8659e453a45e1882c4769eedc452c278e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 06:19:16 +0000 Subject: [PATCH 2/8] fix(mcp): avoid unsafe cross-account completion caching Co-authored-by: Akshay Dodeja --- packages/mcp/src/mcp.test.ts | 34 +++++++++++++++------------------- packages/mcp/src/server.ts | 36 +++++++----------------------------- 2 files changed, 22 insertions(+), 48 deletions(-) diff --git a/packages/mcp/src/mcp.test.ts b/packages/mcp/src/mcp.test.ts index 094aa99b..116eb703 100644 --- a/packages/mcp/src/mcp.test.ts +++ b/packages/mcp/src/mcp.test.ts @@ -507,16 +507,14 @@ describe('MCP server wiring', () => { }); it('returns carrier SCAC completion values over MCP', async () => { - shippingLinesList - .mockRejectedValueOnce(new Error('upstream unavailable')) - .mockResolvedValue([ - { scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' }, - { - scac: 'MSCU', - name: 'Mediterranean Shipping Company', - shortName: 'MSC', - }, - ]); + shippingLinesList.mockResolvedValue([ + { scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' }, + { + scac: 'MSCU', + name: 'Mediterranean Shipping Company', + shortName: 'MSC', + }, + ]); const handler = createMcpHandler( () => createTerminal49McpServer('token', 'https://api.test'), @@ -539,12 +537,6 @@ describe('MCP server wiring', () => { try { await client.connect(transport); - const degraded = await client.complete({ - ref: { type: 'ref/prompt', name: 'track-shipment' }, - argument: { name: 'carrier', value: 'ma' }, - }); - expect(degraded.completion.values).toEqual([]); - const broadMatch = await client.complete({ ref: { type: 'ref/prompt', name: 'track-shipment' }, argument: { name: 'carrier', value: 'm' }, @@ -556,9 +548,13 @@ describe('MCP server wiring', () => { argument: { name: 'carrier', value: 'ma' }, }); expect(narrowMatch.completion.values).toEqual(['MAEU']); - // One retry after the failed load, then "m" and "ma" share the same - // per-server carrier catalog instead of issuing duplicate API requests. - expect(shippingLinesList).toHaveBeenCalledTimes(2); + + shippingLinesList.mockRejectedValue(new Error('upstream unavailable')); + const degraded = await client.complete({ + ref: { type: 'ref/prompt', name: 'track-shipment' }, + argument: { name: 'carrier', value: 'ma' }, + }); + expect(degraded.completion.values).toEqual([]); } finally { await client.close(); await handler.close(); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 9fa824e9..22b45578 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -15,10 +15,7 @@ import { executeTrackContainer } from './tools/track-container.js'; import { executeSearchContainer } from './tools/search-container.js'; import { executeGetShipmentDetails } from './tools/get-shipment-details.js'; import { executeGetContainerTransportEvents } from './tools/get-container-transport-events.js'; -import { - executeGetSupportedShippingLines, - type ShippingLineRecord, -} from './tools/get-supported-shipping-lines.js'; +import { executeGetSupportedShippingLines } from './tools/get-supported-shipping-lines.js'; import { executeGetContainerRoute, type FeatureNotEnabledResult, @@ -1161,33 +1158,14 @@ function wrapToolWithContract( function createCarrierScacCompleter( client: Terminal49Client, ): (value: string | undefined) => Promise { - let cachedLines: Promise | undefined; - - const loadLines = (): Promise => { - if (!cachedLines) { - cachedLines = executeGetSupportedShippingLines({}, client) - .then((result) => result.shipping_lines) - .catch((error: unknown) => { - cachedLines = undefined; - throw error; - }); - } - return cachedLines; - }; - return async (value: string | undefined): Promise => { try { - const search = - typeof value === 'string' ? value.trim().toLowerCase() : ''; - const lines = await loadLines(); - return lines - .filter((line) => - [line.scac, line.name, line.short_name] - .filter((candidate): candidate is string => Boolean(candidate)) - .some((candidate) => candidate.toLowerCase().includes(search)), - ) - .slice(0, 100) - .map((line) => line.scac); + const search = typeof value === 'string' ? value.trim() : ''; + const { shipping_lines } = await executeGetSupportedShippingLines( + { search }, + client, + ); + return shipping_lines.slice(0, 100).map((line) => line.scac); } catch { return []; } From d2699bc22cfdd165cb6130ff7e029d2d864ef203 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 06:19:43 +0000 Subject: [PATCH 3/8] test(mcp): align list expectations with compact defaults Co-authored-by: Akshay Dodeja --- packages/mcp/src/tools/contracts.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/tools/contracts.test.ts b/packages/mcp/src/tools/contracts.test.ts index dd23d372..6fa91b0d 100644 --- a/packages/mcp/src/tools/contracts.test.ts +++ b/packages/mcp/src/tools/contracts.test.ts @@ -1088,7 +1088,7 @@ describe('MCP tool contracts', () => { port: undefined, carrier: 'MAEU', updatedAfter: undefined, - includeContainers: undefined, + includeContainers: false, }, { format: 'mapped', page: 2, pageSize: 25 }, ); @@ -1227,7 +1227,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); }); @@ -1251,7 +1251,7 @@ describe('MCP tool contracts', () => { expect(list).toHaveBeenCalledWith( { 'filter[status]': 'failed' }, - { format: 'mapped', page: undefined, pageSize: undefined }, + { format: 'mapped', page: undefined, pageSize: 25 }, ); }); From 459b16f6ea3d0ebf8ef589b8493e8656a27b6548 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 06:20:50 +0000 Subject: [PATCH 4/8] test(mcp): enforce listing and not-found contracts Co-authored-by: Akshay Dodeja --- packages/mcp/src/annotations.test.ts | 53 +++++++++++++++++++++++++ packages/mcp/src/tool-transport.test.ts | 32 +++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/packages/mcp/src/annotations.test.ts b/packages/mcp/src/annotations.test.ts index 08296caf..a3c04d0b 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,6 +16,19 @@ type ToolAnnotations = { openWorldHint?: boolean; }; +type ChatGptSubmission = { + app_info: { + display_name: string; + subtitle: string; + }; + tools: Record; +}; + +type ClaudeSubmission = { + server: { url: string; authentication: string }; + listing: { name: string; tagline: string }; +}; + function getRegisteredTools(): Record< string, { title?: string; annotations?: ToolAnnotations } @@ -93,4 +107,43 @@ describe('MCP tool annotations', () => { 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)) { + expect(chatGpt.tools[name]?.annotations, name).toMatchObject( + tool.annotations ?? {}, + ); + } + + expect(chatGpt.app_info).toMatchObject({ + display_name: 'Terminal49', + subtitle: 'Track ocean shipments', + }); + expect(claude.server).toMatchObject({ + url: 'https://mcp.terminal49.com', + authentication: 'oauth', + }); + expect(claude.listing).toMatchObject({ + name: 'Terminal49', + tagline: 'Track ocean shipments', + }); + expect(claude.listing.tagline.length).toBeLessThanOrEqual(55); + }); }); diff --git a/packages/mcp/src/tool-transport.test.ts b/packages/mcp/src/tool-transport.test.ts index bc3b2c78..6be2f57e 100644 --- a/packages/mcp/src/tool-transport.test.ts +++ b/packages/mcp/src/tool-transport.test.ts @@ -468,6 +468,38 @@ describe('all public tools over MCP client transport', () => { }, ); + 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.each(TOOL_NAMES)( '%s redacts upstream failure details over MCP client transport', async (toolName) => { From 66737ade81b16c4fd9519d5dce6487ba61bf002c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 06:21:10 +0000 Subject: [PATCH 5/8] test(mcp): compare required store annotations Co-authored-by: Akshay Dodeja --- packages/mcp/src/annotations.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/annotations.test.ts b/packages/mcp/src/annotations.test.ts index a3c04d0b..2b1d7318 100644 --- a/packages/mcp/src/annotations.test.ts +++ b/packages/mcp/src/annotations.test.ts @@ -127,9 +127,12 @@ describe('MCP tool annotations', () => { Object.keys(tools).sort(), ); for (const [name, tool] of Object.entries(tools)) { - expect(chatGpt.tools[name]?.annotations, name).toMatchObject( - tool.annotations ?? {}, - ); + const liveAnnotations = tool.annotations; + expect(chatGpt.tools[name]?.annotations, name).toMatchObject({ + readOnlyHint: liveAnnotations?.readOnlyHint, + destructiveHint: liveAnnotations?.destructiveHint, + openWorldHint: liveAnnotations?.openWorldHint, + }); } expect(chatGpt.app_info).toMatchObject({ From 43d614c6baa2731fb7d57bf7b6a7aec3dc30d96a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 06:25:49 +0000 Subject: [PATCH 6/8] fix(mcp): preserve writes and sanitize telemetry errors Co-authored-by: Akshay Dodeja --- packages/mcp/src/annotations.test.ts | 70 ++++++++++- packages/mcp/src/mcp.test.ts | 27 ++++- packages/mcp/src/sentry.ts | 9 +- packages/mcp/src/server.ts | 2 +- packages/mcp/src/tool-transport.test.ts | 137 ++++++++++++++++++++++ packages/mcp/src/tools/track-container.ts | 32 ++++- 6 files changed, 261 insertions(+), 16 deletions(-) diff --git a/packages/mcp/src/annotations.test.ts b/packages/mcp/src/annotations.test.ts index 2b1d7318..467220fe 100644 --- a/packages/mcp/src/annotations.test.ts +++ b/packages/mcp/src/annotations.test.ts @@ -17,16 +17,45 @@ type ToolAnnotations = { }; type ChatGptSubmission = { + $schema: string; app_info: { display_name: string; subtitle: string; + description: string; }; - tools: Record; + tools: Record< + string, + { + annotations: ToolAnnotations; + justifications: Record; + } + >; + test_cases: Array<{ expected_output: string }>; + negative_test_cases: unknown[]; }; type ClaudeSubmission = { - server: { url: string; authentication: string }; - listing: { name: string; tagline: string }; + 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< @@ -133,20 +162,55 @@ describe('MCP tool annotations', () => { 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.app_info).toMatchObject({ display_name: 'Terminal49', subtitle: 'Track ocean shipments', }); + 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', + ); 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..6754f669 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); }); 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 22b45578..b97b21d4 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -436,7 +436,7 @@ 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'] : wasNotCreated ? ['a verified identifier and carrier SCAC'] : [], diff --git a/packages/mcp/src/tool-transport.test.ts b/packages/mcp/src/tool-transport.test.ts index 6be2f57e..e305b06b 100644 --- a/packages/mcp/src/tool-transport.test.ts +++ b/packages/mcp/src/tool-transport.test.ts @@ -386,6 +386,100 @@ function argumentsFor(toolName: ToolName): Record { } } +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': @@ -447,6 +541,7 @@ describe('all public tools over MCP client transport', () => { expect(result.isError).not.toBe(true); expect(result.structuredContent).toMatchObject({ + ...expectedOutputFor(toolName), _response_contract: { purpose: expect.any(String), presentation_guidance: expect.any(String), @@ -500,6 +595,48 @@ describe('all public tools over MCP client transport', () => { } }); + 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.each(TOOL_NAMES)( '%s redacts upstream failure details over MCP client transport', async (toolName) => { diff --git a/packages/mcp/src/tools/track-container.ts b/packages/mcp/src/tools/track-container.ts index b64fd287..5e920c71 100644 --- a/packages/mcp/src/tools/track-container.ts +++ b/packages/mcp/src/tools/track-container.ts @@ -291,11 +291,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({ From cb3e59e39dfaeb72061b5f213173c5696f14ff84 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 06:27:56 +0000 Subject: [PATCH 7/8] fix(mcp): preserve temporarily unavailable matches Co-authored-by: Akshay Dodeja --- packages/mcp/src/annotations.test.ts | 38 +++++++++++++++++- packages/mcp/src/server.ts | 26 +++++++----- packages/mcp/src/tool-transport.test.ts | 48 +++++++++++++++++++++++ packages/mcp/src/tools/track-container.ts | 27 +++++++++++-- 4 files changed, 124 insertions(+), 15 deletions(-) diff --git a/packages/mcp/src/annotations.test.ts b/packages/mcp/src/annotations.test.ts index 467220fe..760a05ad 100644 --- a/packages/mcp/src/annotations.test.ts +++ b/packages/mcp/src/annotations.test.ts @@ -18,10 +18,12 @@ type ToolAnnotations = { type ChatGptSubmission = { $schema: string; + schema_version: number; app_info: { display_name: string; subtitle: string; description: string; + category: string; }; tools: Record< string, @@ -30,8 +32,22 @@ type ChatGptSubmission = { justifications: Record; } >; - test_cases: Array<{ expected_output: string }>; - negative_test_cases: unknown[]; + 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 = { @@ -177,9 +193,11 @@ describe('MCP tool annotations', () => { 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); @@ -190,6 +208,22 @@ describe('MCP tool annotations', () => { 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', diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index b97b21d4..c359e09e 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -427,6 +427,8 @@ function buildTrackContract( 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.`, @@ -437,9 +439,11 @@ function buildTrackContract( ], requires_more_data: isPending ? ['container details becoming available after request linking'] - : wasNotCreated - ? ['a verified identifier and carrier SCAC'] - : [], + : matchedButUnavailable + ? ['the matched container details becoming available'] + : wasNotCreated + ? ['a verified identifier and carrier SCAC'] + : [], relevant_fields: [ 'tracking_request_created', 'container_state', @@ -448,14 +452,18 @@ function buildTrackContract( ], presentation_guidance: isPending ? 'Tracking request was created but container linking is not immediate. Mention this and provide next-check guidance.' - : 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.`, + : 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'] - : wasNotCreated - ? ['get_supported_shipping_lines', 'search_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'], diff --git a/packages/mcp/src/tool-transport.test.ts b/packages/mcp/src/tool-transport.test.ts index e305b06b..29215a53 100644 --- a/packages/mcp/src/tool-transport.test.ts +++ b/packages/mcp/src/tool-transport.test.ts @@ -637,6 +637,54 @@ describe('all public tools over MCP client transport', () => { } }); + 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) => { diff --git a/packages/mcp/src/tools/track-container.ts b/packages/mcp/src/tools/track-container.ts index 5e920c71..64ab55f3 100644 --- a/packages/mcp/src/tools/track-container.ts +++ b/packages/mcp/src/tools/track-container.ts @@ -198,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, From c285883928ae83dbef5e8112d92801aab3cd65e9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 06:30:38 +0000 Subject: [PATCH 8/8] fix(mcp): advertise only supported list filters Co-authored-by: Akshay Dodeja --- chatgpt-app-submission.json | 12 ++--- packages/mcp/src/mcp.test.ts | 4 +- packages/mcp/src/resources/query-guidance.ts | 10 ++-- packages/mcp/src/server.ts | 55 ++++++++++--------- packages/mcp/src/tool-transport.test.ts | 4 +- packages/mcp/src/tools/contracts.test.ts | 56 +++++++++----------- packages/mcp/src/tools/list-containers.ts | 16 +----- packages/mcp/src/tools/list-shipments.ts | 18 +++---- 8 files changed, 79 insertions(+), 96 deletions(-) diff --git a/chatgpt-app-submission.json b/chatgpt-app-submission.json index 14a80934..d38c6e73 100644 --- a/chatgpt-app-submission.json +++ b/chatgpt-app-submission.json @@ -139,19 +139,19 @@ "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 }, { diff --git a/packages/mcp/src/mcp.test.ts b/packages/mcp/src/mcp.test.ts index 6754f669..90224e84 100644 --- a/packages/mcp/src/mcp.test.ts +++ b/packages/mcp/src/mcp.test.ts @@ -637,7 +637,7 @@ describe('MCP server wiring', () => { { name: 'list_shipments', args: { - carrier: 'MAEU', + tracking_stopped: false, include_containers: true, page: 1, page_size: 10, @@ -661,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/server.ts b/packages/mcp/src/server.ts index c359e09e..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: [], }; /** @@ -847,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 @@ -943,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); @@ -963,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) { @@ -1611,21 +1627,18 @@ 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() @@ -1661,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 index 29215a53..c8a7c29e 100644 --- a/packages/mcp/src/tool-transport.test.ts +++ b/packages/mcp/src/tool-transport.test.ts @@ -374,9 +374,9 @@ function argumentsFor(toolName: ToolName): Record { case 'get_supported_shipping_lines': return { search: 'ma' }; case 'list_shipments': - return { carrier: 'MAEU', page: 1, page_size: 10 }; + return { tracking_stopped: false, page: 1, page_size: 10 }; case 'list_containers': - return { status: 'available_for_pickup', page: 1, page_size: 10 }; + return { page: 1, page_size: 10 }; case 'list_tracking_requests': return { status: 'succeeded', page: 1, page_size: 10 }; default: { diff --git a/packages/mcp/src/tools/contracts.test.ts b/packages/mcp/src/tools/contracts.test.ts index 6fa91b0d..192bb67b 100644 --- a/packages/mcp/src/tools/contracts.test.ts +++ b/packages/mcp/src/tools/contracts.test.ts @@ -1074,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, }, @@ -1084,10 +1084,8 @@ describe('MCP tool contracts', () => { expect(list).toHaveBeenCalledWith( { - status: 'in_transit', - port: undefined, - carrier: 'MAEU', - updatedAfter: undefined, + number: 'MAEU123456789', + trackingStopped: false, includeContainers: false, }, { format: 'mapped', page: 2, pageSize: 25 }, @@ -1101,7 +1099,6 @@ describe('MCP tool contracts', () => { const result = await executeListContainers( { - status: 'available_for_pickup', include: 'shipment,pod_terminal', page: 1, page_size: 50, @@ -1111,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 }, @@ -1137,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 }, @@ -1263,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', ); }); @@ -1388,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( @@ -1400,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); }); @@ -1432,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); @@ -1444,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/list-containers.ts b/packages/mcp/src/tools/list-containers.ts index 312bb474..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; @@ -26,13 +22,7 @@ export async function executeListContainers( 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: pageSize, timestamp: new Date().toISOString(), @@ -41,10 +31,6 @@ export async function executeListContainers( 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, diff --git a/packages/mcp/src/tools/list-shipments.ts b/packages/mcp/src/tools/list-shipments.ts index 3e96e71e..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; @@ -27,10 +25,8 @@ export async function executeListShipments( event: 'tool.execute.start', tool: 'list_shipments', filters: { - status: args.status, - port: args.port, - carrier: args.carrier, - updated_after: args.updated_after, + number: args.number, + tracking_stopped: args.tracking_stopped, include_containers: includeContainers, }, page: args.page, @@ -41,10 +37,8 @@ export async function executeListShipments( try { const result = await client.shipments.list( { - status: args.status, - port: args.port, - carrier: args.carrier, - updatedAfter: args.updated_after, + number: args.number, + trackingStopped: args.tracking_stopped, includeContainers, }, {