diff --git a/api/mcp.ts b/api/mcp.ts index 76b3b02e..41f13edc 100644 --- a/api/mcp.ts +++ b/api/mcp.ts @@ -539,12 +539,14 @@ export default async function handler(req: RequestLike, res: ResponseLike): Prom }); if (!res.headersSent) { setCorsHeaders(res); + // The real error is logged above (correlated by request_id). Never echo + // err.message to the client — it can carry upstream URLs, tokens, or + // stack detail. res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', - data: err.message, }, id: null, }); diff --git a/packages/mcp/src/mcp.test.ts b/packages/mcp/src/mcp.test.ts index de808797..d39320a7 100644 --- a/packages/mcp/src/mcp.test.ts +++ b/packages/mcp/src/mcp.test.ts @@ -341,10 +341,11 @@ describe('MCP server wiring', () => { expect(prompts).toContain('check-demurrage'); expect(prompts).toContain('analyze-delays'); - expect(resources).toHaveLength(2); + expect(resources).toHaveLength(3); expect(resourceTemplates).toHaveLength(1); expect(resources).toContain('terminal49://docs/milestone-glossary'); expect(resources).toContain('terminal49://docs/mcp-query-guidance'); + expect(resources).toContain('terminal49://docs/list-display-columns'); expect(resourceTemplates).toContain('container'); }); @@ -443,7 +444,10 @@ describe('MCP server wiring', () => { const result = await searchTool.handler({ query: ' ' }); - expect(result.content[0].text).toContain('Error: Search query is required'); + // DEV-10663: tool errors return a generic, non-leaking message; the raw + // internal error ("Search query is required") must not reach the client. + expect(result.content[0].text).not.toContain('Search query is required'); + expect(result.content[0].text).toContain('could not be completed'); expect(result.isError).toBe(true); expect(Object.hasOwn(result, 'structuredContent')).toBe(false); }); diff --git a/packages/mcp/src/resources/list-display.ts b/packages/mcp/src/resources/list-display.ts new file mode 100644 index 00000000..af718a10 --- /dev/null +++ b/packages/mcp/src/resources/list-display.ts @@ -0,0 +1,120 @@ +/** + * One-time list display column catalog resource. + * + * The list_* tools used to inline this ~2KB catalog on every response. It is + * static, so it now lives here and is exposed once as an MCP resource. The + * per-call `_response_contract.display` only carries the lightweight defaults + * plus `column_catalog_resource` pointing at this URI. + */ + +export type ListDisplayColumn = { + key: string; + label: string; + path?: string; + description?: string; + compute?: string; +}; + +export const listDisplayColumnsResource = { + uri: 'terminal49://docs/list-display-columns', + name: 'List Display Column Catalog', + description: + 'Full column catalog for list_containers / list_shipments / list_tracking_requests responses. ' + + 'Fetch once; per-call contracts reference this by URI instead of inlining the catalog.', + mimeType: 'application/json', +}; + +const CONTAINER_LIST_COLUMNS: ListDisplayColumn[] = [ + { key: 'number', label: 'Container', path: 'number' }, + { key: 'currentStatus', label: 'Status', path: 'currentStatus' }, + { key: 'podDischargedAt', label: 'Discharged', path: 'podDischargedAt' }, + { key: 'podFullOutAt', label: 'Picked Up', path: 'podFullOutAt' }, + { key: 'availableForPickup', label: 'Ready', path: 'availableForPickup' }, + { key: 'pickupLfd', label: 'LFD', path: 'pickupLfd' }, + { + key: 'pickupAppointmentAt', + label: 'Pickup Appt', + path: 'pickupAppointmentAt', + }, + { + key: 'holdsCount', + label: 'Holds', + path: 'holdsAtPodTerminal', + compute: 'length', + description: 'Count of active holds at POD terminal', + }, + { + key: 'holdsAtPodTerminal', + label: 'Hold Details', + path: 'holdsAtPodTerminal', + }, + { + key: 'feesCount', + label: 'Fees', + path: 'feesAtPodTerminal', + compute: 'length', + description: 'Count of fee items at POD terminal', + }, + { + key: 'locationAtPodTerminal', + label: 'Terminal Location', + path: 'locationAtPodTerminal', + }, + { + key: 'terminals.podTerminal.name', + label: 'POD Terminal', + path: 'terminals.podTerminal.name', + }, + { key: 'shipment.billOfLading', label: 'BL', path: 'shipment.billOfLading' }, + { + key: 'shipment.shippingLineScac', + label: 'SCAC', + path: 'shipment.shippingLineScac', + }, + { + key: 'podRailCarrierScac', + label: 'Rail Carrier', + path: 'podRailCarrierScac', + }, + { key: 'indEtaAt', label: 'Inland ETA', path: 'indEtaAt' }, + { key: 'indAtaAt', label: 'Inland ATA', path: 'indAtaAt' }, +]; + +const SHIPMENT_LIST_COLUMNS: ListDisplayColumn[] = [ + { key: 'billOfLading', label: 'BL', path: 'billOfLading' }, + { key: 'shippingLineScac', label: 'SCAC', path: 'shippingLineScac' }, + { key: 'shippingLineName', label: 'Carrier', path: 'shippingLineName' }, + { key: 'podVesselName', label: 'Vessel', path: 'podVesselName' }, + { key: 'podVoyageNumber', label: 'Voyage', path: 'podVoyageNumber' }, + { key: 'portOfDischargeName', label: 'POD', path: 'portOfDischargeName' }, + { key: 'podEtaAt', label: 'POD ETA', path: 'podEtaAt' }, + { key: 'podAtaAt', label: 'POD ATA', path: 'podAtaAt' }, + { key: 'destinationName', label: 'Destination', path: 'destinationName' }, + { key: 'destinationEtaAt', label: 'Dest ETA', path: 'destinationEtaAt' }, + { + key: 'lineTrackingLastSucceededAt', + label: 'Last Update', + path: 'lineTrackingLastSucceededAt', + }, +]; + +const TRACKING_REQUEST_LIST_COLUMNS: ListDisplayColumn[] = [ + { key: 'requestNumber', label: 'Request Number', path: 'requestNumber' }, + { key: 'requestType', label: 'Type', path: 'requestType' }, + { key: 'status', label: 'Status', path: 'status' }, + { key: 'scac', label: 'SCAC', path: 'scac' }, + { key: 'createdAt', label: 'Created', path: 'createdAt' }, + { key: 'updatedAt', label: 'Updated', path: 'updatedAt' }, + { key: 'failedReason', label: 'Failure Reason', path: 'failedReason' }, + { key: 'isRetrying', label: 'Retrying', path: 'isRetrying' }, +]; + +export const LIST_DISPLAY_COLUMN_CATALOG = { + container: CONTAINER_LIST_COLUMNS, + shipment: SHIPMENT_LIST_COLUMNS, + tracking_request: TRACKING_REQUEST_LIST_COLUMNS, +} as const; + +export function readListDisplayColumnsResource(): string { + return JSON.stringify(LIST_DISPLAY_COLUMN_CATALOG, null, 2); +} diff --git a/packages/mcp/src/resources/query-guidance.ts b/packages/mcp/src/resources/query-guidance.ts index f1144697..1d55c58e 100644 --- a/packages/mcp/src/resources/query-guidance.ts +++ b/packages/mcp/src/resources/query-guidance.ts @@ -42,7 +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', - '- Filter: status=discharged then has_hold from holds_at_pod_terminal', + '- 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.', '', '### 4) Arrival / ETAs / delays', '- Question examples:', @@ -64,13 +65,13 @@ 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', - '- Output expectation: sort by demurrage.pickup_lfd and hold flags', + '- 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.', '', '## Output Formatting Guidance', '', '- Always return concise status first.', '- Keep containers grouped by outcome state.', - '- When the response includes holds_at_pod_terminal, call out those explicitly and escalate urgency.', + '- When the response includes holdsAtPodTerminal entries, call out those explicitly and escalate urgency.', '- When dates are missing, explain that latest feed is partial and suggest get_container_transport_events for timeline context.', '- Always suggest 1-2 concrete next checks when data is incomplete.', '', diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index c1bbe6af..38fefc0b 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -14,14 +14,28 @@ 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 { executeGetContainerRoute, type FeatureNotEnabledResult } from './tools/get-container-route.js'; +import { + executeGetContainerRoute, + type FeatureNotEnabledResult, +} from './tools/get-container-route.js'; import { executeListShipments } from './tools/list-shipments.js'; import { executeListContainers } from './tools/list-containers.js'; import { executeListTrackingRequests } from './tools/list-tracking-requests.js'; import { readContainerResource } from './resources/container.js'; import { readMilestoneGlossaryResource } from './resources/milestone-glossary.js'; -import { queryGuidanceResource, readQueryGuidanceResource } from './resources/query-guidance.js'; -import { captureMcpException, flushMcpEvents, instrumentMcpServer } from './sentry.js'; +import { + queryGuidanceResource, + readQueryGuidanceResource, +} from './resources/query-guidance.js'; +import { + listDisplayColumnsResource, + readListDisplayColumnsResource, +} from './resources/list-display.js'; +import { + captureMcpException, + flushMcpEvents, + instrumentMcpServer, +} from './sentry.js'; /** * MCP content-block annotations (per spec). `audience` lets a client decide who @@ -97,7 +111,10 @@ type ResponseDisplay = { default_columns: string[]; sort: Array<{ key: string; direction: 'asc' | 'desc' }>; empty_state: string; - column_catalog: ResponseDisplayColumn[]; + // The full per-column catalog (~2KB) is intentionally NOT inlined on every + // list response. Agents fetch it once from the resource below. + column_catalog_resource: string; + column_catalog?: ResponseDisplayColumn[]; column_sets: ResponseDisplayColumnSet[]; selection_strategy: string; }; @@ -111,8 +128,90 @@ type ResponseContract = { suggested_follow_ups: string[]; suggested_tools: string[]; display?: ResponseDisplay; + // List-only honesty signals. Optional so non-list contracts stay unchanged. + dropped_filters?: string[]; + total_is_reliable?: boolean; }; +/** Resource URI for the one-time list display column catalog. */ +export const LIST_DISPLAY_COLUMNS_URI = listDisplayColumnsResource.uri; + +/** + * Filters the MCP list_* tools actually forward to the Terminal49 API, by + * entity. Anything outside an entity's vocabulary cannot scope its list and is + * reported back to the agent as a dropped filter so it never claims a false + * worklist. `page`, `page_size`, `include`, `include_containers` and `intent` + * are transport/shape knobs, not scoping filters, and are ignored here. + * + * `request_type` is intentionally excluded for `tracking_request`: the + * `GET /tracking_requests` OpenAPI source of truth does not define + * `filter[request_type]`, so a caller-supplied `request_type` cannot actually + * scope the list even though `executeListTrackingRequests` forwards it. It + * falls through to `droppedFilterKeys` instead, so the contract reports it as + * ignored rather than claiming it applied. + */ +const SUPPORTED_LIST_FILTERS_BY_ENTITY: Record< + ListEntityType, + readonly string[] +> = { + container: ['status', 'port', 'carrier', 'updated_after'], + shipment: ['status', 'port', 'carrier', 'updated_after'], + tracking_request: ['status', 'filters'], + unknown: ['status', 'port', 'carrier', 'updated_after'], +}; + +/** + * Real `GET /tracking_requests` API filter keys (per the OpenAPI source of + * truth), as they appear inside the raw `filters` pass-through bag. Only these + * actually scope the list; other raw keys like `include` are legitimate + * request knobs but must not be mistaken for a scoping filter. + */ +const REAL_TRACKING_REQUEST_FILTER_KEYS = new Set([ + 'filter[request_number]', + 'filter[status]', + 'filter[scac]', + 'filter[created_at][start]', + 'filter[created_at][end]', + 'filter[updated_at][start]', + 'filter[updated_at][end]', +]); + +/** + * Whether the raw `filters` pass-through bag contains at least one key that + * actually scopes the `list_tracking_requests` result. A bag containing only + * non-filter knobs (e.g. `{ include: 'tracked_object' }`) must not be mistaken + * for an applied filter, or an unscoped account list could be presented as + * filtered. + */ +function hasRealTrackingRequestFilterKey(rawFilters: unknown): boolean { + if ( + !rawFilters || + typeof rawFilters !== 'object' || + Array.isArray(rawFilters) + ) { + return false; + } + return Object.keys(rawFilters as Record).some((key) => + REAL_TRACKING_REQUEST_FILTER_KEYS.has(key), + ); +} + +/** Non-filter knobs that must never be treated as scoping filters. */ +const NON_FILTER_LIST_ARGS = new Set([ + 'page', + 'page_size', + 'include', + 'include_containers', + 'intent', +]); + +/** + * Above this row count an unfiltered list `meta.total` almost certainly + * reflects the whole account (admin-token firehose) rather than the user's + * worklist, so we never present it as the filtered result size. + */ +const PLAUSIBLE_TOTAL_THRESHOLD = 1000; + function buildContentPayload(result: unknown): ToolContent[] { if (result && typeof result === 'object' && (result as any).summary) { return [{ type: 'text', text: formatAsText((result as any).summary) }]; @@ -133,7 +232,9 @@ function buildContentPayload(result: unknown): ToolContent[] { if (hasMetadataError(result)) { const metadata = (result as any)._metadata; - const remediation = metadata.remediation ? `\n\nRemediation: ${metadata.remediation}` : ''; + const remediation = metadata.remediation + ? `\n\nRemediation: ${metadata.remediation}` + : ''; return [ { type: 'text', @@ -153,16 +254,20 @@ function formatAsText(result: unknown): string { } } -function isFeatureNotEnabledResult(result: unknown): result is FeatureNotEnabledResult { +function isFeatureNotEnabledResult( + result: unknown, +): result is FeatureNotEnabledResult { return Boolean( result && - typeof result === 'object' && - (result as any).error === 'FeatureNotEnabled' && - typeof (result as any).message === 'string' + typeof result === 'object' && + (result as any).error === 'FeatureNotEnabled' && + typeof (result as any).message === 'string', ); } -function hasMetadataError(result: unknown): result is { _metadata: { error: string } } { +function hasMetadataError( + result: unknown, +): result is { _metadata: { error: string } } { const metadata = (result as any)?._metadata; return Boolean(metadata && typeof metadata.error === 'string'); } @@ -193,7 +298,8 @@ const responseDisplaySchema = z.object({ }), ), empty_state: z.string(), - column_catalog: z.array(responseDisplayColumnSchema), + column_catalog_resource: z.string(), + column_catalog: z.array(responseDisplayColumnSchema).optional(), column_sets: z.array(responseDisplayColumnSetSchema), selection_strategy: z.string(), }); @@ -207,6 +313,8 @@ const responseContractSchema = z.object({ suggested_follow_ups: z.array(z.string()), suggested_tools: z.array(z.string()), display: responseDisplaySchema.optional(), + dropped_filters: z.array(z.string()).optional(), + total_is_reliable: z.boolean().optional(), }); const toolIntentSchema = z @@ -217,6 +325,28 @@ const toolIntentSchema = z 'Brief reason the agent is calling this tool. This is MCP-only telemetry for Sentry and is not forwarded to the Terminal49 API.', ); +/** Hard ceiling for list page size. Keeps a single MCP response bounded. */ +const MAX_LIST_PAGE_SIZE = 100; + +const listPageSchema = z + .number() + .int() + .positive() + .optional() + .describe('Page number (1-based)'); + +// Clamp page_size to the cap rather than rejecting, so an over-eager agent +// gets a bounded page instead of a tool error. +const listPageSizeSchema = z + .number() + .int() + .positive() + .transform((value) => Math.min(value, MAX_LIST_PAGE_SIZE)) + .optional() + .describe( + `Page size (1-${MAX_LIST_PAGE_SIZE}; values above ${MAX_LIST_PAGE_SIZE} are clamped)`, + ); + function normalizeContract(contract: ResponseContract): ResponseContract { return { purpose: contract.purpose, @@ -227,6 +357,8 @@ function normalizeContract(contract: ResponseContract): ResponseContract { suggested_follow_ups: contract.suggested_follow_ups, suggested_tools: contract.suggested_tools, display: contract.display, + dropped_filters: contract.dropped_filters, + total_is_reliable: contract.total_is_reliable, }; } @@ -244,9 +376,14 @@ function attachResponseContract( }; } -function buildSearchContract(result: any, args: { query: string }): ResponseContract { - const hasContainers = result.total_results > 0 && (result.containers?.length ?? 0) > 0; - const hasShipments = result.total_results > 0 && (result.shipments?.length ?? 0) > 0; +function buildSearchContract( + result: any, + args: { query: string }, +): ResponseContract { + const hasContainers = + result.total_results > 0 && (result.containers?.length ?? 0) > 0; + const hasShipments = + result.total_results > 0 && (result.shipments?.length ?? 0) > 0; return { purpose: `Resolve identifier ${args.query} into concrete container and shipment IDs.`, @@ -255,20 +392,30 @@ function buildSearchContract(result: any, args: { query: string }): ResponseCont 'carrier/scac hints for discovered items', 'what additional lookup step is needed', ], - requires_more_data: hasContainers || hasShipments ? [] : ['A valid/refined identifier (container/BL/reference)'], + requires_more_data: + hasContainers || hasShipments + ? [] + : ['A valid/refined identifier (container/BL/reference)'], relevant_fields: ['containers', 'shipments', 'total_results'], presentation_guidance: hasContainers || hasShipments ? 'Group matches by container and shipment. Ask for clarification only when multiple entities are strong candidates.' : 'Ask for a clearer identifier and verify format before calling another tool.', suggested_follow_ups: ['get_container', 'get_shipment_details'], - suggested_tools: hasContainers || hasShipments ? ['get_container', 'get_shipment_details'] : ['search_container'], + suggested_tools: + hasContainers || hasShipments + ? ['get_container', 'get_shipment_details'] + : ['search_container'], }; } -function buildTrackContract(result: any, args: { number: string }): ResponseContract { +function buildTrackContract( + result: any, + args: { number: string }, +): ResponseContract { const hasTrackedContainer = Boolean((result as any)?.id); - const isPending = Boolean((result as any)?.tracking_request_created) && !hasTrackedContainer; + const isPending = + Boolean((result as any)?.tracking_request_created) && !hasTrackedContainer; const state = (result as any)?._metadata?.container_state || 'unknown'; return { purpose: `Track ${args.number} and return the linked container view when possible.`, @@ -277,27 +424,42 @@ function buildTrackContract(result: any, args: { number: string }): ResponseCont 'basic container status and metadata', 'where to pull next (if container details are delayed)', ], - requires_more_data: isPending ? ['container UUID (once linking finishes)'] : [], - relevant_fields: ['tracking_request_created', 'container_state', 'id', 'status'], - 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.`, - suggested_follow_ups: - isPending - ? ['list_tracking_requests', 'get_container'] - : ['get_container_transport_events'], + requires_more_data: isPending + ? ['container UUID (once linking finishes)'] + : [], + relevant_fields: [ + 'tracking_request_created', + 'container_state', + 'id', + 'status', + ], + 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.`, + suggested_follow_ups: isPending + ? ['list_tracking_requests', 'get_container'] + : ['get_container_transport_events'], suggested_tools: ['get_container', 'get_container_transport_events'], }; } -function buildTransportEventsContract(result: any, _args: { id: string }): ResponseContract { +function buildTransportEventsContract( + result: any, + _args: { id: string }, +): ResponseContract { const totalEvents = result.total_events ?? result.timeline?.length ?? 0; return { - purpose: 'Summarize what happened and forecast next likely milestone for the container.', - can_answer: ['journey timeline', 'major milestones', 'rail/transshipment context'], + purpose: + 'Summarize what happened and forecast next likely milestone for the container.', + can_answer: [ + 'journey timeline', + 'major milestones', + 'rail/transshipment context', + ], requires_more_data: - totalEvents > 0 ? [] : ['recent container events becoming available from carrier feed'], + totalEvents > 0 + ? [] + : ['recent container events becoming available from carrier feed'], relevant_fields: ['timeline', 'event_categories', 'milestones'], presentation_guidance: totalEvents > 0 @@ -310,17 +472,27 @@ function buildTransportEventsContract(result: any, _args: { id: string }): Respo function buildShippingLineContract(result: any): ResponseContract { return { - purpose: 'Help user identify a supported SCAC before creating a track request.', - can_answer: ['SCAC lookup', 'carrier aliases and names', 'supported carrier search'], - requires_more_data: result.total_lines > 0 ? [] : ['additional query context'], + purpose: + 'Help user identify a supported SCAC before creating a track request.', + can_answer: [ + 'SCAC lookup', + 'carrier aliases and names', + 'supported carrier search', + ], + requires_more_data: + result.total_lines > 0 ? [] : ['additional query context'], relevant_fields: ['shipping_lines', 'total_lines'], - presentation_guidance: 'Sort carriers alphabetically and show both SCAC and company names.', + presentation_guidance: + 'Sort carriers alphabetically and show both SCAC and company names.', suggested_follow_ups: ['track_container'], suggested_tools: ['track_container'], }; } -function buildRouteContract(result: any, _args: { id: string }): ResponseContract { +function buildRouteContract( + result: any, + _args: { id: string }, +): ResponseContract { const available = Array.isArray(result.route_locations); return { purpose: 'Communicate container routing and vessel itinerary.', @@ -329,7 +501,9 @@ function buildRouteContract(result: any, _args: { id: string }): ResponseContrac 'leg-by-leg ETD/ETA', 'carrier and vessel coverage', ], - requires_more_data: available ? [] : ['event timeline via get_container_transport_events'], + requires_more_data: available + ? [] + : ['event timeline via get_container_transport_events'], relevant_fields: ['route_locations', 'total_legs', 'alternative'], presentation_guidance: available ? 'Show origin → transshipments → destination. Emphasize missing legs and ETA changes.' @@ -342,22 +516,45 @@ function buildRouteContract(result: any, _args: { id: string }): ResponseContrac function buildContainerContract(): ResponseContract { return { purpose: 'Provide current container snapshot and readiness context.', - can_answer: ['status', 'location', 'pickup readiness', 'rail and shipment context'], + can_answer: [ + 'status', + 'location', + 'pickup readiness', + 'rail and shipment context', + ], requires_more_data: ['holds, fees, and timeline by demand'], - relevant_fields: ['id', 'container_number', 'status', 'pod_terminal', 'demurrage'], + relevant_fields: [ + 'id', + 'container_number', + 'status', + 'pod_terminal', + 'demurrage', + ], presentation_guidance: 'Summarize state first, then call out LFD, holds, and fees if present. If terminal availability is unclear, suggest transport events.', - suggested_follow_ups: ['get_container_transport_events', 'get_container_route'], + suggested_follow_ups: [ + 'get_container_transport_events', + 'get_container_route', + ], suggested_tools: ['get_container_transport_events', 'get_container_route'], }; } function buildShipmentContract(): ResponseContract { return { - purpose: 'Explain shipment-level routing, container counts, and references.', + purpose: + 'Explain shipment-level routing, container counts, and references.', can_answer: ['shipment identifiers', 'routing summary', 'container list'], - requires_more_data: ['container-level ETA confidence when only one terminal is visible'], - relevant_fields: ['id', 'bill_of_lading', 'status', 'containers', 'routing'], + requires_more_data: [ + 'container-level ETA confidence when only one terminal is visible', + ], + relevant_fields: [ + 'id', + 'bill_of_lading', + 'status', + 'containers', + 'routing', + ], presentation_guidance: 'Group by shipment summary then container health signals (pickup ETA, pickup_lfd, holds).', suggested_follow_ups: ['get_container', 'list_containers'], @@ -366,23 +563,39 @@ function buildShipmentContract(): ResponseContract { } function asRecord(value: unknown): Record { - return value && typeof value === 'object' ? (value as Record) : {}; + return value && typeof value === 'object' + ? (value as Record) + : {}; } type ListEntityType = 'container' | 'shipment' | 'tracking_request' | 'unknown'; function detectListEntityType(result: any): ListEntityType { - const firstItem = Array.isArray(result?.items) ? asRecord(result.items[0]) : {}; - - if ('requestType' in firstItem || 'request_type' in firstItem || 'requestNumber' in firstItem) { + const firstItem = Array.isArray(result?.items) + ? asRecord(result.items[0]) + : {}; + + if ( + 'requestType' in firstItem || + 'request_type' in firstItem || + 'requestNumber' in firstItem + ) { return 'tracking_request'; } - if ('billOfLading' in firstItem || 'bill_of_lading' in firstItem || 'podVesselName' in firstItem) { + if ( + 'billOfLading' in firstItem || + 'bill_of_lading' in firstItem || + 'podVesselName' in firstItem + ) { return 'shipment'; } - if ('number' in firstItem || 'container_number' in firstItem || 'podDischargedAt' in firstItem) { + if ( + 'number' in firstItem || + 'container_number' in firstItem || + 'podDischargedAt' in firstItem + ) { return 'container'; } @@ -406,41 +619,15 @@ function buildContainerListDisplay(): ResponseDisplay { ], sort: [{ key: 'pickupLfd', direction: 'asc' }], empty_state: 'No matching containers found for the current filters.', - column_catalog: [ - { key: 'number', label: 'Container', path: 'number' }, - { key: 'currentStatus', label: 'Status', path: 'currentStatus' }, - { key: 'podDischargedAt', label: 'Discharged', path: 'podDischargedAt' }, - { key: 'podFullOutAt', label: 'Picked Up', path: 'podFullOutAt' }, - { key: 'availableForPickup', label: 'Ready', path: 'availableForPickup' }, - { key: 'pickupLfd', label: 'LFD', path: 'pickupLfd' }, - { key: 'pickupAppointmentAt', label: 'Pickup Appt', path: 'pickupAppointmentAt' }, - { - key: 'holdsCount', - label: 'Holds', - path: 'holdsAtPodTerminal', - compute: 'length', - description: 'Count of active holds at POD terminal', - }, - { key: 'holdsAtPodTerminal', label: 'Hold Details', path: 'holdsAtPodTerminal' }, - { - key: 'feesCount', - label: 'Fees', - path: 'feesAtPodTerminal', - compute: 'length', - description: 'Count of fee items at POD terminal', - }, - { key: 'locationAtPodTerminal', label: 'Terminal Location', path: 'locationAtPodTerminal' }, - { key: 'terminals.podTerminal.name', label: 'POD Terminal', path: 'terminals.podTerminal.name' }, - { key: 'shipment.billOfLading', label: 'BL', path: 'shipment.billOfLading' }, - { key: 'shipment.shippingLineScac', label: 'SCAC', path: 'shipment.shippingLineScac' }, - { key: 'podRailCarrierScac', label: 'Rail Carrier', path: 'podRailCarrierScac' }, - { key: 'indEtaAt', label: 'Inland ETA', path: 'indEtaAt' }, - { key: 'indAtaAt', label: 'Inland ATA', path: 'indAtaAt' }, - ], + column_catalog_resource: LIST_DISPLAY_COLUMNS_URI, column_sets: [ { intent: 'discharged_not_picked_up', - when_user_asks: ['discharged but not picked up', 'not picked up', 'still at terminal'], + when_user_asks: [ + 'discharged but not picked up', + 'not picked up', + 'still at terminal', + ], columns: [ 'number', 'currentStatus', @@ -467,7 +654,12 @@ function buildContainerListDisplay(): ResponseDisplay { }, { intent: 'holds_and_blocks', - when_user_asks: ['holds', 'blocked', 'customs hold', 'why not available'], + when_user_asks: [ + 'holds', + 'blocked', + 'customs hold', + 'why not available', + ], columns: [ 'number', 'currentStatus', @@ -511,23 +703,15 @@ function buildShipmentListDisplay(): ResponseDisplay { ], sort: [{ key: 'podEtaAt', direction: 'asc' }], empty_state: 'No matching shipments found for the current filters.', - column_catalog: [ - { key: 'billOfLading', label: 'BL', path: 'billOfLading' }, - { key: 'shippingLineScac', label: 'SCAC', path: 'shippingLineScac' }, - { key: 'shippingLineName', label: 'Carrier', path: 'shippingLineName' }, - { key: 'podVesselName', label: 'Vessel', path: 'podVesselName' }, - { key: 'podVoyageNumber', label: 'Voyage', path: 'podVoyageNumber' }, - { key: 'portOfDischargeName', label: 'POD', path: 'portOfDischargeName' }, - { key: 'podEtaAt', label: 'POD ETA', path: 'podEtaAt' }, - { key: 'podAtaAt', label: 'POD ATA', path: 'podAtaAt' }, - { key: 'destinationName', label: 'Destination', path: 'destinationName' }, - { key: 'destinationEtaAt', label: 'Dest ETA', path: 'destinationEtaAt' }, - { key: 'lineTrackingLastSucceededAt', label: 'Last Update', path: 'lineTrackingLastSucceededAt' }, - ], + column_catalog_resource: LIST_DISPLAY_COLUMNS_URI, column_sets: [ { intent: 'vessel_arrivals', - when_user_asks: ['when is vessel arriving', 'vessel arrival', 'eta by vessel'], + when_user_asks: [ + 'when is vessel arriving', + 'vessel arrival', + 'eta by vessel', + ], columns: [ 'podVesselName', 'podVoyageNumber', @@ -572,26 +756,35 @@ function buildTrackingRequestListDisplay(): ResponseDisplay { ], sort: [{ key: 'updatedAt', direction: 'desc' }], empty_state: 'No tracking requests found for the current filters.', - column_catalog: [ - { key: 'requestNumber', label: 'Request Number', path: 'requestNumber' }, - { key: 'requestType', label: 'Type', path: 'requestType' }, - { key: 'status', label: 'Status', path: 'status' }, - { key: 'scac', label: 'SCAC', path: 'scac' }, - { key: 'createdAt', label: 'Created', path: 'createdAt' }, - { key: 'updatedAt', label: 'Updated', path: 'updatedAt' }, - { key: 'failedReason', label: 'Failure Reason', path: 'failedReason' }, - { key: 'isRetrying', label: 'Retrying', path: 'isRetrying' }, - ], + column_catalog_resource: LIST_DISPLAY_COLUMNS_URI, column_sets: [ { intent: 'failed_requests', when_user_asks: ['failed tracking', 'why failed', 'tracking errors'], - columns: ['requestNumber', 'requestType', 'status', 'scac', 'failedReason', 'updatedAt'], + columns: [ + 'requestNumber', + 'requestType', + 'status', + 'scac', + 'failedReason', + 'updatedAt', + ], }, { intent: 'tracking_activity', - when_user_asks: ['recent tracking activity', 'latest requests', 'tracking queue'], - columns: ['requestNumber', 'requestType', 'status', 'scac', 'createdAt', 'updatedAt'], + when_user_asks: [ + 'recent tracking activity', + 'latest requests', + 'tracking queue', + ], + columns: [ + 'requestNumber', + 'requestType', + 'status', + 'scac', + 'createdAt', + 'updatedAt', + ], }, ], selection_strategy: @@ -599,9 +792,115 @@ function buildTrackingRequestListDisplay(): ResponseDisplay { }; } +export type ListRequestContext = { + /** The filter args the caller passed to the list_* tool. */ + filters?: Record; + /** Filters the SDK reports it could not apply (echoed verbatim). */ + unsupportedFilters?: string[]; +}; + +function isProvided(value: unknown): boolean { + if (value === undefined || value === null || value === '') { + return false; + } + // An empty array or empty plain object scopes nothing — e.g. the raw + // `filters` pass-through arg serialized as `{ filters: {} }`. Treating it as + // "provided" would mark an unfiltered firehose as the user's scoped worklist + // (and falsely trust meta.total), which is the dishonesty this contract avoids. + if (Array.isArray(value)) { + return value.length > 0; + } + if (typeof value === 'object') { + return Object.keys(value as Record).length > 0; + } + return true; +} + +/** Filter keys the caller supplied that actually scope the list. */ +function appliedFilterKeys( + filters: Record | undefined, + entityType: ListEntityType, +): string[] { + if (!filters) { + return []; + } + + const supported = SUPPORTED_LIST_FILTERS_BY_ENTITY[entityType]; + return supported.filter((key) => { + 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 + // actually scope the list. + return hasRealTrackingRequestFilterKey(filters[key]); + } + return isProvided(filters[key]); + }); +} + +/** + * Filter keys the caller supplied that the list endpoint cannot honor. Prefers + * the SDK's own `unsupportedFilters` when present, otherwise derives them from + * the supported vocabulary so the agent is never told a phantom filter applied. + */ +function droppedFilterKeys( + filters: Record | undefined, + unsupportedFilters: string[] | undefined, + entityType: ListEntityType, +): string[] { + const fromSdk = Array.isArray(unsupportedFilters) ? unsupportedFilters : []; + if (!filters) { + return [...new Set(fromSdk)]; + } + + const supported = SUPPORTED_LIST_FILTERS_BY_ENTITY[entityType]; + const derived = Object.keys(filters).filter((key) => { + if (NON_FILTER_LIST_ARGS.has(key)) { + return false; + } + return isProvided(filters[key]) && !supported.includes(key); + }); + + return [...new Set([...fromSdk, ...derived])]; +} + +/** + * Raw API pagination keys a caller can smuggle through the `list_tracking_requests` + * `filters` pass-through. They are pagination, not scoping, so they must be dropped + * before both the SDK call and the contract's "is this filtered?" judgement. + */ +const RAW_PAGINATION_FILTER_KEYS = ['page[size]', 'page[number]'] as const; + +/** + * Mirror the sanitization `executeListTrackingRequests` applies before the SDK + * call: strip raw pagination keys from the nested `filters` bag so the contract + * evaluates the same scoped/unscoped picture the API actually saw. A `filters` + * bag left empty after stripping is treated as unprovided by `isProvided`. + */ +export function sanitizeTrackingRequestFilters( + args: Record | undefined, +): Record | undefined { + if (!args || typeof args !== 'object') { + return args; + } + const rawFilters = (args as { filters?: unknown }).filters; + if ( + !rawFilters || + typeof rawFilters !== 'object' || + Array.isArray(rawFilters) + ) { + return args; + } + const safeFilters = { ...(rawFilters as Record) }; + for (const key of RAW_PAGINATION_FILTER_KEYS) { + delete safeFilters[key]; + } + return { ...args, filters: safeFilters }; +} + export function buildListContract( result: any, entityTypeHint?: ListEntityType, + requestContext: ListRequestContext = {}, ): ResponseContract { const count = result?.items ? result.items.length : 0; const entityType = @@ -612,23 +911,84 @@ export function buildListContract( entityType === 'container' ? buildContainerListDisplay() : entityType === 'shipment' - ? buildShipmentListDisplay() - : entityType === 'tracking_request' - ? buildTrackingRequestListDisplay() - : undefined; + ? buildShipmentListDisplay() + : entityType === 'tracking_request' + ? buildTrackingRequestListDisplay() + : undefined; + + const applied = appliedFilterKeys(requestContext.filters, entityType); + const dropped = droppedFilterKeys( + requestContext.filters, + requestContext.unsupportedFilters, + entityType, + ); + const isFiltered = applied.length > 0; + const supportedVocab = + SUPPORTED_LIST_FILTERS_BY_ENTITY[entityType].join(', '); + + const rawTotal = Number(result?.meta?.total); + const hasTotal = Number.isFinite(rawTotal); + // An unfiltered list whose total exceeds the plausibility threshold is almost + // certainly the whole-account firehose, not the user's worklist. A filtered + // total is the user's scoped result and is trusted. + const totalIsReliable = hasTotal + ? isFiltered || rawTotal <= PLAUSIBLE_TOTAL_THRESHOLD + : true; + + const canAnswer: string[] = ['count and paging state']; + if (isFiltered) { + canAnswer.unshift('which records match the applied filters'); + } + + const requiresMoreData: string[] = []; + if (!isFiltered) { + requiresMoreData.push(`a filter to scope this list (${supportedVocab})`); + } + if (dropped.length > 0) { + requiresMoreData.push( + `unsupported filter(s) were ignored: ${dropped.join(', ')} — re-query using only ${supportedVocab}`, + ); + } + if (hasTotal && !totalIsReliable) { + requiresMoreData.push( + 'meta.total reflects the entire account, not a filtered worklist — apply a filter before quoting a total', + ); + } + if (count === 0) { + requiresMoreData.push('alternative filters or tighter date ranges'); + } + + const presentationGuidance = [ + count === 0 + ? 'No rows matched; surface the empty_state guidance rather than an empty table.' + : count === 1 + ? 'For a single result, provide a concise row summary. For multiple rows, render a markdown table.' + : 'Render a markdown table using the response_contract display hints. Avoid dumping full nested records.', + !isFiltered + ? "This is an unfiltered list; do not describe it as the user's filtered worklist. State that results are unscoped." + : '', + hasTotal && !totalIsReliable + ? 'Do not quote meta.total as the answer count; it is an account-wide figure.' + : '', + ] + .filter(Boolean) + .join(' '); return { purpose: 'Surface aggregate operational worklist results.', - can_answer: ['which records match filters', 'count and paging state'], - requires_more_data: count === 0 ? ['alternative filters or tighter date ranges'] : [], + can_answer: canAnswer, + requires_more_data: requiresMoreData, relevant_fields: ['items', 'links', 'meta', 'count'], - presentation_guidance: - count <= 1 - ? 'For a single result, provide a concise row summary. For multiple rows, render a markdown table.' - : 'Render a markdown table using the response_contract display hints. Avoid dumping full nested records.', + presentation_guidance: presentationGuidance, suggested_follow_ups: ['list_containers', 'list_tracking_requests'], - suggested_tools: ['list_containers', 'list_tracking_requests', 'get_container'], + suggested_tools: [ + 'list_containers', + 'list_tracking_requests', + 'get_container', + ], display, + dropped_filters: dropped.length > 0 ? dropped : undefined, + total_is_reliable: hasTotal ? totalIsReliable : undefined, }; } @@ -710,7 +1070,11 @@ function wrapToolWithContract( handler: (args: TArgs) => Promise, buildContract?: (result: unknown, args: TArgs) => ResponseContract, buildResourceLinks?: (result: unknown, args: TArgs) => ResourceLinkContent[], -): (args: TArgs) => Promise<{ content: ToolContent[]; structuredContent?: any; isError?: boolean }> { +): (args: TArgs) => Promise<{ + content: ToolContent[]; + structuredContent?: any; + isError?: boolean; +}> { return async (args: TArgs) => { try { const result = await handler(args); @@ -739,8 +1103,23 @@ function wrapToolWithContract( const err = error as Error; captureMcpException(error); await flushMcpEvents(); + // Log the real error for operators; never echo internal messages (which + // can contain upstream URLs, tokens, or stack detail) back to the client. + console.error( + JSON.stringify({ + event: 'mcp.tool.error', + error: err.name, + message: err.message, + timestamp: new Date().toISOString(), + }), + ); return { - content: [{ type: 'text', text: `Error: ${err.message}` }], + content: [ + { + type: 'text', + text: 'The Terminal49 request could not be completed. Please retry; if it persists, contact support.', + }, + ], isError: true, }; } @@ -776,7 +1155,12 @@ export function createTerminal49McpServer( apiBaseUrl?: string, accountId?: string, ): McpServer { - const client = new Terminal49Client({ apiToken, apiBaseUrl, accountId, defaultFormat: "mapped" }); + const client = new Terminal49Client({ + apiToken, + apiBaseUrl, + accountId, + defaultFormat: 'mapped', + }); const completeCarrierScac = createCarrierScacCompleter(client); @@ -806,25 +1190,35 @@ export function createTerminal49McpServer( 'Examples: CAIU2885402, MAEU123456789, or any reference number.', annotations: { readOnlyHint: true, openWorldHint: true }, inputSchema: { - query: z.string().min(1).describe('Search query - can be a container number, booking number, BL number, or reference number'), + query: z + .string() + .min(1) + .describe( + 'Search query - can be a container number, booking number, BL number, or reference number', + ), intent: toolIntentSchema, }, outputSchema: { - containers: z.array(z.object({ - id: z.string(), - container_number: z.string(), - status: z.string(), - shipping_line: z.string(), - pod_terminal: z.string().optional(), - pol_terminal: z.string().optional(), - destination: z.string().optional(), - })), - shipments: z.array(z.object({ - id: z.string(), - ref_numbers: z.array(z.string()), - shipping_line: z.string(), - container_count: z.number(), - })), + containers: z.array( + z.object({ + id: z.string(), + container_number: z.string(), + status: z.string(), + shipping_line: z.string(), + pod_terminal: z.string().optional(), + pol_terminal: z.string().optional(), + destination: z.string().optional(), + duplicate_number: z.boolean().optional(), + }), + ), + shipments: z.array( + z.object({ + id: z.string(), + ref_numbers: z.array(z.string()), + shipping_line: z.string(), + container_count: z.number(), + }), + ), total_results: z.number(), _response_contract: responseContractSchema, }, @@ -832,7 +1226,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async ({ query }) => executeSearchContainer({ query }, client), (result, args) => buildSearchContract(result as any, args), - ) + ), ); // Tool 2: Track Container @@ -846,15 +1240,34 @@ export function createTerminal49McpServer( 'and returns detailed container information.', annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, inputSchema: { - number: z.string().optional().describe('Container, bill of lading, or booking number to track'), + number: z + .string() + .optional() + .describe('Container, bill of lading, or booking number to track'), numberType: z .string() .optional() - .describe('Optional override: container | bill_of_lading | booking_number'), - containerNumber: z.string().optional().describe('Deprecated alias for number (container)'), - bookingNumber: z.string().optional().describe('Deprecated alias for number (booking/BL)'), - scac: z.string().optional().describe('Optional SCAC code of the shipping line (e.g., MAEU for Maersk)'), - refNumbers: z.array(z.string()).optional().describe('Optional reference numbers for matching'), + .describe( + 'Optional override: container | bill_of_lading | booking_number', + ), + containerNumber: z + .string() + .optional() + .describe('Deprecated alias for number (container)'), + bookingNumber: z + .string() + .optional() + .describe('Deprecated alias for number (booking/BL)'), + scac: z + .string() + .optional() + .describe( + 'Optional SCAC code of the shipping line (e.g., MAEU for Maersk)', + ), + refNumbers: z + .array(z.string()) + .optional() + .describe('Optional reference numbers for matching'), intent: toolIntentSchema, }, outputSchema: { @@ -869,13 +1282,31 @@ export function createTerminal49McpServer( }, }, wrapToolWithContract( - async ({ number, numberType, containerNumber, scac, bookingNumber, refNumbers }) => + async ({ + number, + numberType, + containerNumber, + scac, + bookingNumber, + refNumbers, + }) => executeTrackContainer( - { number, numberType, containerNumber, scac, bookingNumber, refNumbers }, + { + number, + numberType, + containerNumber, + scac, + bookingNumber, + refNumbers, + }, client, ), - (result, args) => buildTrackContract(result as any, { number: args.number || args.containerNumber || args.bookingNumber || '' }) - ) + (result, args) => + buildTrackContract(result as any, { + number: + args.number || args.containerNumber || args.bookingNumber || '', + }), + ), ); // Tool 3: Get Container @@ -889,16 +1320,19 @@ export function createTerminal49McpServer( 'Response includes metadata hints to guide follow-up queries.', annotations: { readOnlyHint: true, openWorldHint: true }, inputSchema: { - id: z.string().uuid().describe('The Terminal49 container ID (UUID format)'), + id: z + .string() + .uuid() + .describe('The Terminal49 container ID (UUID format)'), include: z .array(z.enum(['shipment', 'pod_terminal', 'transport_events'])) .optional() .default(['shipment']) .describe( - 'Optional related data to include. Default: [\'shipment\'] covers most use cases. ' + - '• shipment: Routing, BOL, line, ref numbers (lightweight, always useful) ' + - '• pod_terminal: Terminal name, location, availability (lightweight, needed for demurrage questions) ' + - '• transport_events: Full event history, rail tracking (heavy 50-100 events, use for journey/timeline questions)' + "Optional related data to include. Default: ['shipment'] covers most use cases. " + + '• shipment: Routing, BOL, line, ref numbers (lightweight, always useful) ' + + '• pod_terminal: Terminal name, location, availability (lightweight, needed for demurrage questions) ' + + '• transport_events: Full event history, rail tracking (heavy 50-100 events, use for journey/timeline questions)', ), intent: toolIntentSchema, }, @@ -911,7 +1345,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async ({ id, include }) => executeGetContainer({ id, include }, client), () => buildContainerContract(), - ) + ), ); // Tool 4: Get Shipment Details @@ -925,8 +1359,17 @@ export function createTerminal49McpServer( 'Returns: Bill of Lading, shipping line, port details, vessel info, ETAs, container list.', annotations: { readOnlyHint: true, openWorldHint: true }, inputSchema: { - id: z.string().uuid().describe('The Terminal49 shipment ID (UUID format)'), - include_containers: z.boolean().optional().default(true).describe('Include list of containers in this shipment. Default: true'), + id: z + .string() + .uuid() + .describe('The Terminal49 shipment ID (UUID format)'), + include_containers: z + .boolean() + .optional() + .default(true) + .describe( + 'Include list of containers in this shipment. Default: true', + ), intent: toolIntentSchema, }, outputSchema: z @@ -934,12 +1377,12 @@ export function createTerminal49McpServer( _response_contract: responseContractSchema, }) .passthrough(), - }, + }, wrapToolWithContract( async ({ id, include_containers }) => executeGetShipmentDetails({ id, include_containers }, client), () => buildShipmentContract(), - ) + ), ); // Tool 5: Get Container Transport Events @@ -954,7 +1397,10 @@ export function createTerminal49McpServer( 'More efficient than get_container with transport_events when you only need event data.', annotations: { readOnlyHint: true, openWorldHint: true }, inputSchema: { - id: z.string().uuid().describe('The Terminal49 container ID (UUID format)'), + id: z + .string() + .uuid() + .describe('The Terminal49 container ID (UUID format)'), intent: toolIntentSchema, }, outputSchema: z @@ -966,7 +1412,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async ({ id }) => executeGetContainerTransportEvents({ id }, client), (result, args) => buildTransportEventsContract(result as any, args), - ) + ), ); // Tool 6: Get Supported Shipping Lines @@ -980,7 +1426,10 @@ export function createTerminal49McpServer( 'Use this when user asks which carriers are supported or to validate a carrier name.', annotations: { readOnlyHint: true, openWorldHint: false }, inputSchema: { - search: z.string().optional().describe('Optional: Filter by carrier name or SCAC code'), + search: z + .string() + .optional() + .describe('Optional: Filter by carrier name or SCAC code'), intent: toolIntentSchema, }, outputSchema: { @@ -992,7 +1441,7 @@ export function createTerminal49McpServer( short_name: z.string().optional(), bol_prefix: z.string().optional(), notes: z.string().optional(), - }) + }), ), _metadata: z.object({ presentation_guidance: z.string(), @@ -1003,9 +1452,10 @@ export function createTerminal49McpServer( }, }, wrapToolWithContract( - async ({ search }) => executeGetSupportedShippingLines({ search }, client), + async ({ search }) => + executeGetSupportedShippingLines({ search }, client), (result) => buildShippingLineContract(result as any), - ) + ), ); // Tool 7: Get Container Route @@ -1020,7 +1470,10 @@ export function createTerminal49McpServer( 'Use for questions about routing, transshipments, or detailed vessel itinerary.', annotations: { readOnlyHint: true, openWorldHint: true }, inputSchema: { - id: z.string().uuid().describe('The Terminal49 container ID (UUID format)'), + id: z + .string() + .uuid() + .describe('The Terminal49 container ID (UUID format)'), intent: toolIntentSchema, }, // Keep a single permissive schema because this tool can return either @@ -1063,7 +1516,7 @@ export function createTerminal49McpServer( }) .nullable(), }), - }) + }), ) .optional(), created_at: z.string().nullable().optional(), @@ -1084,7 +1537,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async ({ id }) => executeGetContainerRoute({ id }, client), (result, args) => buildRouteContract(result as any, args), - ) + ), ); // Tool 8: List Shipments @@ -1100,13 +1553,18 @@ export function createTerminal49McpServer( 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().optional().describe('Filter by updated_at (ISO8601) >= value'), + updated_after: z + .string() + .optional() + .describe('Filter by updated_at (ISO8601) >= value'), include_containers: z .boolean() .optional() - .describe('Include containers relationship in response. Default: true.'), - page: z.number().int().positive().optional().describe('Page number (1-based)'), - page_size: z.number().int().positive().optional().describe('Page size'), + .describe( + 'Include containers relationship in response. Default: true.', + ), + page: listPageSchema, + page_size: listPageSizeSchema, intent: toolIntentSchema, }, outputSchema: z.object({ @@ -1118,8 +1576,12 @@ export function createTerminal49McpServer( }, wrapToolWithContract( async (args) => executeListShipments(args, client), - (result) => buildListContract(result as any, 'shipment'), - ) + (result, args) => + buildListContract(result as any, 'shipment', { + filters: args, + unsupportedFilters: (result as any)?.unsupportedFilters, + }), + ), ); // Tool 9: List Containers @@ -1135,13 +1597,18 @@ export function createTerminal49McpServer( 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'), + updated_after: z + .string() + .optional() + .describe('Filter by updated_at (ISO8601) >= value'), include: z .string() .optional() - .describe('Comma-separated include list (e.g., shipment,pod_terminal)'), - page: z.number().int().positive().optional().describe('Page number (1-based)'), - page_size: z.number().int().positive().optional().describe('Page size'), + .describe( + 'Comma-separated include list (e.g., shipment,pod_terminal)', + ), + page: listPageSchema, + page_size: listPageSizeSchema, intent: toolIntentSchema, }, outputSchema: z.object({ @@ -1153,12 +1620,16 @@ export function createTerminal49McpServer( }, wrapToolWithContract( async (args) => executeListContainers(args, client), - (result) => buildListContract(result as any, 'container'), + (result, args) => + buildListContract(result as any, 'container', { + filters: args, + unsupportedFilters: (result as any)?.unsupportedFilters, + }), // ResourceLinks: each container row becomes a compact link to the // registered terminal49://container/{id} resource, so the client can // resolve full details on demand instead of paying for them up front. (result) => buildListResourceLinks(result, 'container'), - ) + ), ); // Tool 10: List Tracking Requests @@ -1175,12 +1646,16 @@ export function createTerminal49McpServer( .record(z.string(), z.string()) .optional() .describe('Raw query filters (e.g., filter[status]=succeeded)'), - status: z.string().optional().describe('Filter by request status (mapped to filter[status])'), - request_type: z.string() + status: z + .string() + .optional() + .describe('Filter by request status (mapped to filter[status])'), + request_type: z + .string() .optional() .describe('Filter by request type (mapped to filter[request_type])'), - page: z.number().int().positive().optional().describe('Page number (1-based)'), - page_size: z.number().int().positive().optional().describe('Page size'), + page: listPageSchema, + page_size: listPageSizeSchema, intent: toolIntentSchema, }, outputSchema: z.object({ @@ -1192,8 +1667,18 @@ export function createTerminal49McpServer( }, wrapToolWithContract( async (args) => executeListTrackingRequests(args, client), - (result) => buildListContract(result as any, 'tracking_request'), - ) + (result, args) => + buildListContract(result as any, 'tracking_request', { + // `executeListTrackingRequests` strips raw pagination keys from the + // nested `filters` bag before the SDK call, so the contract must judge + // "is this scoped?" against the same sanitized view. Otherwise a + // `filters: { 'page[size]': '10000' }` request — which is unfiltered + // once those keys are dropped — would still read as a non-empty + // `filters` arg, falsely report applied filters, and trust meta.total. + filters: sanitizeTrackingRequestFilters(args), + unsupportedFilters: (result as any)?.unsupportedFilters, + }), + ), ); // ==================== PROMPTS ==================== @@ -1203,7 +1688,8 @@ export function createTerminal49McpServer( 'track-shipment', { title: 'Track Container Shipment', - description: 'Quick container tracking workflow with carrier autocomplete', + description: + 'Quick container tracking workflow with carrier autocomplete', argsSchema: { container_number: z.string().describe('Container number (e.g., CAIU1234567)'), // Autocompletes from the live supported-carrier list (SCAC codes). @@ -1228,7 +1714,7 @@ export function createTerminal49McpServer( }, }, ], - }) + }), ); // Prompt 2: Check Demurrage @@ -1256,7 +1742,7 @@ export function createTerminal49McpServer( }, }, ], - }) + }), ); // Prompt 3: Analyze Delays @@ -1284,7 +1770,7 @@ export function createTerminal49McpServer( }, }, ], - }) + }), ); // ==================== RESOURCES ==================== @@ -1302,7 +1788,7 @@ export function createTerminal49McpServer( return { contents: [resource], }; - } + }, ); // Resource 2: Milestone Glossary (static resource) @@ -1319,7 +1805,7 @@ export function createTerminal49McpServer( return { contents: [resource], }; - } + }, ); // Resource 3: Query Guidance (internal LLM tool routing hints) @@ -1342,7 +1828,30 @@ export function createTerminal49McpServer( }, ], }; - } + }, + ); + + // Resource 4: List Display Column Catalog (fetched once; referenced by + // list_* contracts via column_catalog_resource instead of being inlined). + server.registerResource( + 'list-display-columns', + listDisplayColumnsResource.uri, + { + title: listDisplayColumnsResource.name, + description: listDisplayColumnsResource.description, + mimeType: listDisplayColumnsResource.mimeType, + }, + async () => { + return { + contents: [ + { + uri: listDisplayColumnsResource.uri, + mimeType: listDisplayColumnsResource.mimeType, + text: readListDisplayColumnsResource(), + }, + ], + }; + }, ); return server; @@ -1359,7 +1868,9 @@ export async function runStdioServer() { console.error('Please set your Terminal49 API token:'); console.error(' export T49_API_TOKEN=your_token_here'); console.error(''); - console.error('Get your API token at: https://app.terminal49.com/developers/api-keys'); + console.error( + 'Get your API token at: https://app.terminal49.com/developers/api-keys', + ); process.exit(1); } @@ -1368,7 +1879,7 @@ export async function runStdioServer() { if (process.env.T49_MCP_STDIO_BANNER === '1') { console.error('Terminal49 MCP Server v1.0.0 running on stdio'); - console.error('Available: 10 tools | 3 prompts | 3 resources'); + console.error('Available: 10 tools | 3 prompts | 4 resources'); console.error('SDK: @modelcontextprotocol/sdk (McpServer API)'); } diff --git a/packages/mcp/src/tools/contracts.test.ts b/packages/mcp/src/tools/contracts.test.ts index 3000338d..cac31107 100644 --- a/packages/mcp/src/tools/contracts.test.ts +++ b/packages/mcp/src/tools/contracts.test.ts @@ -9,6 +9,10 @@ import { executeListShipments } from './list-shipments.js'; import { executeListTrackingRequests } from './list-tracking-requests.js'; import { executeSearchContainer } from './search-container.js'; import { executeTrackContainer } from './track-container.js'; +import { + buildListContract, + sanitizeTrackingRequestFilters, +} from '../server.js'; function asClient(client: unknown): Terminal49Client { return client as Terminal49Client; @@ -1043,4 +1047,316 @@ describe('MCP tool contracts', () => { ); expect(result.items).toHaveLength(1); }); + + it('list_tracking_requests strips raw page[size]/page[number] from filters so the cap cannot be bypassed', async () => { + const list = vi.fn().mockResolvedValue({ items: [{ id: 'tr-3' }] }); + const client = asClient({ + trackingRequests: { list }, + }); + + await executeListTrackingRequests( + { + filters: { + 'filter[status]': 'failed', + 'page[size]': '10000', + 'page[number]': '5', + }, + }, + client, + ); + + expect(list).toHaveBeenCalledWith( + { 'filter[status]': 'failed' }, + { format: 'mapped', page: undefined, pageSize: undefined }, + ); + }); + + it('buildListContract does not claim filter-match for an unfiltered firehose', () => { + const contract = buildListContract( + { items: [{ id: 'c1' }, { id: 'c2' }], meta: { total: 2 } }, + 'container', + { filters: {} }, + ); + + 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. + expect(contract.requires_more_data).toContain( + 'a filter to scope this list (status, port, carrier, updated_after)', + ); + }); + + it('buildListContract treats an empty raw filters object as unscoped', () => { + // tracking_request is the only entity whose supported vocabulary includes + // the raw `filters` pass-through; `{ filters: {} }` must not be mistaken for + // an applied filter, or the firehose total would be flagged reliable. + const contract = buildListContract( + { items: [{ id: 't1' }, { id: 't2' }], meta: { total: 250000 } }, + 'tracking_request', + { filters: { filters: {} } }, + ); + + expect(contract.can_answer).not.toContain( + 'which records match the applied filters', + ); + expect(contract.total_is_reliable).toBe(false); + expect( + contract.requires_more_data.some((entry) => + entry.startsWith('a filter to scope this list'), + ), + ).toBe(true); + }); + + it('buildListContract over sanitized tracking filters treats page-only filters as unscoped', () => { + // executeListTrackingRequests strips raw page[size]/page[number] before the + // SDK call, so the contract must judge scope against the same sanitized view. + // A `filters: { 'page[size]': '10000' }` request is unfiltered after + // sanitization and must not report applied filters or a reliable total. + const contract = buildListContract( + { items: [{ id: 't1' }, { id: 't2' }], meta: { total: 250000 } }, + 'tracking_request', + { + filters: sanitizeTrackingRequestFilters({ + filters: { 'page[size]': '10000', 'page[number]': '5' }, + }), + }, + ); + + expect(contract.can_answer).not.toContain( + 'which records match the applied filters', + ); + expect(contract.total_is_reliable).toBe(false); + expect( + contract.requires_more_data.some((entry) => + entry.startsWith('a filter to scope this list'), + ), + ).toBe(true); + }); + + it('buildListContract over sanitized tracking filters keeps a real filter scoped', () => { + // Sanitization must not strip genuine filters: a real filter alongside a raw + // pagination key still counts as a scoped, trustworthy result. + const contract = buildListContract( + { items: [{ id: 't1' }], meta: { total: 5 } }, + 'tracking_request', + { + filters: sanitizeTrackingRequestFilters({ + filters: { 'filter[status]': 'failed', 'page[size]': '10000' }, + }), + }, + ); + + expect(contract.can_answer).toContain( + 'which records match the applied filters', + ); + expect(contract.total_is_reliable).toBe(true); + }); + + it('buildListContract does not treat request_type as a scoping filter', () => { + // GET /tracking_requests has no filter[request_type] in the OpenAPI source + // of truth, so a bare request_type arg cannot actually scope the list even + // though executeListTrackingRequests forwards it; it must be reported as + // dropped, not as an applied filter over a reliable total. + const contract = buildListContract( + { items: [{ id: 't1' }, { id: 't2' }], meta: { total: 250000 } }, + 'tracking_request', + { filters: { request_type: 'manual' } }, + ); + + expect(contract.can_answer).not.toContain( + 'which records match the applied filters', + ); + expect(contract.total_is_reliable).toBe(false); + expect( + contract.requires_more_data.some((entry) => + entry.includes('unsupported filter(s) were ignored: request_type'), + ), + ).toBe(true); + }); + + it('buildListContract does not treat a raw filters bag of only non-filter knobs as scoped', () => { + // `include` is a legitimate raw query knob but not a `filter[...]` key, so + // `{ filters: { include: 'tracked_object' } }` must not read as scoped. + const contract = buildListContract( + { items: [{ id: 't1' }, { id: 't2' }], meta: { total: 250000 } }, + 'tracking_request', + { filters: { filters: { include: 'tracked_object' } } }, + ); + + expect(contract.can_answer).not.toContain( + 'which records match the applied filters', + ); + expect(contract.total_is_reliable).toBe(false); + expect( + contract.requires_more_data.some((entry) => + entry.startsWith('a filter to scope this list'), + ), + ).toBe(true); + }); + + it('buildListContract presentation guidance does not claim a single result when empty', () => { + const contract = buildListContract( + { items: [], meta: { total: 0 } }, + 'container', + ); + + expect(contract.presentation_guidance).not.toContain('single result'); + expect(contract.presentation_guidance).toContain('empty_state'); + }); + + it('buildListContract reports which records match when a filter was applied', () => { + const contract = buildListContract( + { items: [{ id: 'c1' }], meta: { total: 1 } }, + 'container', + { filters: { status: 'available_for_pickup' } }, + ); + + expect(contract.can_answer).toContain( + 'which records match the applied filters', + ); + }); + + it('buildListContract echoes dropped/unsupported filters from the SDK', () => { + const contract = buildListContract( + { + items: [{ id: 'c1' }], + meta: { total: 1 }, + unsupportedFilters: ['has_hold'], + }, + 'container', + { filters: { status: 'available_for_pickup', has_hold: true } }, + ); + + expect(contract.dropped_filters).toEqual(['has_hold']); + expect( + contract.requires_more_data.some((entry) => entry.includes('has_hold')), + ).toBe(true); + }); + + it('buildListContract does not surface an implausibly large total as the worklist size', () => { + const contract = buildListContract( + { items: [{ id: 'c1' }, { id: 'c2' }], meta: { total: 250000 } }, + 'container', + { filters: {} }, + ); + + expect(contract.total_is_reliable).toBe(false); + expect( + contract.requires_more_data.some((entry) => /total/i.test(entry)), + ).toBe(true); + }); + + it('buildListContract trusts a plausible total when a filter is applied', () => { + const contract = buildListContract( + { items: [{ id: 'c1' }], meta: { total: 12 } }, + 'container', + { filters: { carrier: 'MAEU' } }, + ); + + expect(contract.total_is_reliable).toBe(true); + }); + + it('buildListContract omits the heavy column_catalog from the per-call contract', () => { + const contract = buildListContract( + { items: [{ id: 'c1' }], meta: { total: 1 } }, + 'container', + { filters: { status: 'available_for_pickup' } }, + ); + + expect(contract.display).toBeDefined(); + expect((contract.display as any).column_catalog).toBeUndefined(); + // The catalog must remain discoverable via a one-time resource. + expect((contract.display as any).column_catalog_resource).toBe( + 'terminal49://docs/list-display-columns', + ); + }); + + it('get_supported_shipping_lines hides the T49 Test Carrier', async () => { + const shippingList = vi.fn().mockResolvedValue([ + { scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' }, + { scac: 'TEST', name: 'T49 Test Carrier', shortName: 'TEST' }, + ]); + + const client = asClient({ shippingLines: { list: shippingList } }); + + const result = await executeSupportedShippingLines({}, client); + + expect(result.shipping_lines.some((line) => line.scac === 'TEST')).toBe( + false, + ); + expect( + result.shipping_lines.some((line) => line.name === 'T49 Test Carrier'), + ).toBe(false); + expect(result.total_lines).toBe(1); + }); + + it('search_container derives a real status instead of returning "unknown" blindly', async () => { + const client = asClient({ + search: vi.fn().mockResolvedValue({ + data: [ + { + id: 'sr-real', + type: 'search_result', + attributes: { + entity_type: 'container', + number: 'CAIU7777777', + scac: 'MAEU', + // No explicit status field, but availability/timestamps are present. + available_for_pickup: true, + pod_discharged_at: '2026-02-16T01:00:00Z', + }, + }, + ], + }), + }); + + const result = await executeSearchContainer( + { query: 'CAIU7777777' }, + client, + ); + + expect(result.containers[0].status).not.toBe('unknown'); + expect(result.containers[0].status).toBe('available_for_pickup'); + }); + + it('search_container disambiguates duplicate container numbers', async () => { + const client = asClient({ + search: vi.fn().mockResolvedValue({ + data: [ + { + id: 'dup-1', + type: 'search_result', + attributes: { + entity_type: 'container', + number: 'DUPU0000001', + scac: 'MAEU', + available_for_pickup: false, + pod_discharged_at: '2026-02-16T01:00:00Z', + }, + }, + { + id: 'dup-2', + type: 'search_result', + attributes: { + entity_type: 'container', + number: 'DUPU0000001', + scac: 'MSCU', + available_for_pickup: true, + }, + }, + ], + }), + }); + + const result = await executeSearchContainer( + { query: 'DUPU0000001' }, + client, + ); + + // Both rows survive (distinct ids) and are flagged so the agent can disambiguate. + expect(result.containers).toHaveLength(2); + expect(result.containers.every((c) => c.duplicate_number === true)).toBe( + true, + ); + }); }); diff --git a/packages/mcp/src/tools/get-supported-shipping-lines.ts b/packages/mcp/src/tools/get-supported-shipping-lines.ts index df74c4f6..22d656fc 100644 --- a/packages/mcp/src/tools/get-supported-shipping-lines.ts +++ b/packages/mcp/src/tools/get-supported-shipping-lines.ts @@ -5,23 +5,6 @@ import { Terminal49Client } from '@terminal49/sdk'; -export const getSupportedShippingLinesTool = { - name: 'get_supported_shipping_lines', - description: - 'Get list of shipping lines (carriers) supported by Terminal49 for container tracking. ' + - 'Returns SCAC codes, names, nicknames, and BOL prefixes from the Terminal49 shipping_lines API. ' + - 'Use this when validating whether a carrier is supported or mapping SCAC codes.', - inputSchema: { - type: 'object', - properties: { - search: { - type: 'string', - description: 'Optional: Filter by carrier name, nickname, or SCAC code', - }, - }, - }, -}; - interface SupportedLinesResponse { total_lines: number; shipping_lines: ShippingLineRecord[]; @@ -36,9 +19,21 @@ export interface ShippingLineRecord { notes?: string; } +// Internal carrier used only for Terminal49 QA/sandbox flows. It must never +// surface to MCP clients as a real, trackable shipping line. +const HIDDEN_CARRIER_SCACS = new Set(['TEST']); +const HIDDEN_CARRIER_NAMES = new Set(['t49 test carrier']); + +function isHiddenCarrier(line: ShippingLineRecord): boolean { + return ( + HIDDEN_CARRIER_SCACS.has(line.scac.trim().toUpperCase()) || + HIDDEN_CARRIER_NAMES.has(line.name.trim().toLowerCase()) + ); +} + export async function executeGetSupportedShippingLines( args: { search?: string }, - client: Terminal49Client + client: Terminal49Client, ): Promise { const search = args.search?.trim().toLowerCase(); const lines = await loadShippingLines(client); @@ -47,7 +42,7 @@ export async function executeGetSupportedShippingLines( ? lines.filter((line) => [line.scac, line.name, line.short_name] .filter(Boolean) - .some((value) => value!.toLowerCase().includes(search)) + .some((value) => value!.toLowerCase().includes(search)), ) : lines; @@ -62,19 +57,31 @@ export async function executeGetSupportedShippingLines( }; } -async function loadShippingLines(client: Terminal49Client): Promise { - const response = await client.shippingLines.list(undefined, { format: 'mapped' }); +async function loadShippingLines( + client: Terminal49Client, +): Promise { + const response = await client.shippingLines.list(undefined, { + format: 'mapped', + }); const data = Array.isArray(response) ? response : []; - const mapped = data.map((item: any): Partial => ({ - scac: item.scac, - name: item.name, - short_name: item.shortName, - bol_prefix: item.bolPrefix, - notes: item.notes, - })); + const mapped = data.map( + (item: any): Partial => ({ + scac: item.scac, + name: item.name, + short_name: item.shortName, + bol_prefix: item.bolPrefix, + notes: item.notes, + }), + ); return mapped - .filter((item): item is ShippingLineRecord => item != null && Boolean(item.scac) && Boolean(item.name)) - .sort((a: ShippingLineRecord, b: ShippingLineRecord) => a.name.localeCompare(b.name)); + .filter( + (item): item is ShippingLineRecord => + item != null && Boolean(item.scac) && Boolean(item.name), + ) + .filter((item) => !isHiddenCarrier(item)) + .sort((a: ShippingLineRecord, b: ShippingLineRecord) => + a.name.localeCompare(b.name), + ); } diff --git a/packages/mcp/src/tools/list-tracking-requests.ts b/packages/mcp/src/tools/list-tracking-requests.ts index 19ea9dd1..007cf861 100644 --- a/packages/mcp/src/tools/list-tracking-requests.ts +++ b/packages/mcp/src/tools/list-tracking-requests.ts @@ -30,8 +30,16 @@ export async function executeListTrackingRequests( ); try { + // Pagination is owned exclusively by the capped `page`/`page_size` schema. + // The raw `filters` pass-through is copied verbatim into the SDK query, so a + // caller could otherwise smuggle `page[size]`/`page[number]` through it and + // bypass the MAX_LIST_PAGE_SIZE clamp. Drop those keys here. + const safeFilters = { ...args.filters }; + delete safeFilters['page[size]']; + delete safeFilters['page[number]']; + const filters = { - ...args.filters, + ...safeFilters, ...(args.status ? { 'filter[status]': args.status } : {}), ...(args.request_type ? { 'filter[request_type]': args.request_type } : {}), }; diff --git a/packages/mcp/src/tools/search-container.ts b/packages/mcp/src/tools/search-container.ts index f0a77bf8..01f1847c 100644 --- a/packages/mcp/src/tools/search-container.ts +++ b/packages/mcp/src/tools/search-container.ts @@ -18,6 +18,8 @@ export interface SearchResult { pod_terminal?: string; pol_terminal?: string; destination?: string; + /** True when more than one result shares this container_number. */ + duplicate_number?: boolean; }>; shipments: Array<{ id: string; @@ -46,32 +48,14 @@ function toStringList(value: unknown): string[] { return []; } - return value.map((item) => toTextOrUndefined(item)).filter((item): item is string => Boolean(item)); + return value + .map((item) => toTextOrUndefined(item)) + .filter((item): item is string => Boolean(item)); } -export const searchContainerTool = { - name: 'search_container', - 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. ' + - 'Examples: CAIU2885402, MAEU123456789, or any reference number.', - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - description: - 'Search query - can be a container number, booking number, BL number, or reference number', - }, - }, - required: ['query'], - }, -}; - export async function executeSearchContainer( args: SearchContainerArgs, - client: Terminal49Client + client: Terminal49Client, ): Promise { const query = args.query.trim(); if (!query) { @@ -79,14 +63,14 @@ export async function executeSearchContainer( } const startTime = Date.now(); - console.error( - JSON.stringify({ - event: 'tool.execute.start', - tool: 'search_container', - query, - timestamp: new Date().toISOString(), - }) - ); + console.error( + JSON.stringify({ + event: 'tool.execute.start', + tool: 'search_container', + query, + timestamp: new Date().toISOString(), + }), + ); try { const result = await client.search(query); @@ -103,7 +87,7 @@ export async function executeSearchContainer( shipments_found: formattedResult.shipments.length, duration_ms: duration, timestamp: new Date().toISOString(), - }) + }), ); return formattedResult; @@ -119,7 +103,7 @@ export async function executeSearchContainer( message: (error as Error).message, duration_ms: duration, timestamp: new Date().toISOString(), - }) + }), ); throw error; @@ -135,7 +119,9 @@ function formatSearchResponse(apiResponse: any): SearchResult { ? apiResponse.data : [apiResponse.data] : []; - const included = Array.isArray(apiResponse?.included) ? apiResponse.included : []; + const included = Array.isArray(apiResponse?.included) + ? apiResponse.included + : []; const containers: SearchResult['containers'] = []; const shipments: SearchResult['shipments'] = []; @@ -177,6 +163,8 @@ function formatSearchResponse(apiResponse: any): SearchResult { } } + flagDuplicateContainerNumbers(containers); + return { containers, shipments, @@ -184,6 +172,29 @@ function formatSearchResponse(apiResponse: any): SearchResult { }; } +/** + * Same container number can resolve to multiple records (re-tracked, multiple + * carriers/shipments). Flag every member of a duplicate group so the caller can + * disambiguate by id/SCAC/status instead of silently picking one. + */ +function flagDuplicateContainerNumbers( + containers: SearchResult['containers'], +): void { + const counts = new Map(); + for (const container of containers) { + const key = container.container_number; + if (key && key !== 'Unknown') { + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + + for (const container of containers) { + if ((counts.get(container.container_number) ?? 0) > 1) { + container.duplicate_number = true; + } + } +} + /** * Format search_result type container */ @@ -193,7 +204,10 @@ function formatSearchResult(searchResult: any): SearchResult['containers'][0] { return { id: String(searchResult.id), container_number: toText(attrs.number), - status: toText(attrs.status, 'unknown'), + // Honor an explicit status when the search API supplies one; otherwise + // derive a real status from availability/timestamp signals instead of + // labeling every result "unknown". + status: toTextOrUndefined(attrs.status) ?? determineContainerStatus(attrs), shipping_line: toText(attrs.scac || attrs.carrier_scac || attrs.carrier), pod_terminal: toTextOrUndefined(attrs.port_of_discharge_name), pol_terminal: toTextOrUndefined(attrs.port_of_lading_name), @@ -204,7 +218,9 @@ function formatSearchResult(searchResult: any): SearchResult['containers'][0] { /** * Format search_result type shipment */ -function formatSearchResultShipment(searchResult: any): SearchResult['shipments'][0] { +function formatSearchResultShipment( + searchResult: any, +): SearchResult['shipments'][0] { const attrs = searchResult.attributes || {}; return { @@ -217,7 +233,10 @@ function formatSearchResultShipment(searchResult: any): SearchResult['shipments' }; } -function formatContainer(container: any, included: any[]): SearchResult['containers'][0] { +function formatContainer( + container: any, + included: any[], +): SearchResult['containers'][0] { const attrs = container.attributes || {}; const relationships = container.relationships || {}; @@ -226,30 +245,37 @@ function formatContainer(container: any, included: any[]): SearchResult['contain const polTerminalId = relationships.pol_terminal?.data?.id; const podTerminal = included.find( - (item: any) => item.type === 'terminal' && item.id === podTerminalId + (item: any) => item.type === 'terminal' && item.id === podTerminalId, ); const polTerminal = included.find( - (item: any) => item.type === 'terminal' && item.id === polTerminalId + (item: any) => item.type === 'terminal' && item.id === polTerminalId, ); // Find related shipment for shipping line const shipmentId = relationships.shipment?.data?.id; const shipment = included.find( - (item: any) => item.type === 'shipment' && item.id === shipmentId + (item: any) => item.type === 'shipment' && item.id === shipmentId, ); return { id: String(container.id), container_number: toText(attrs.number), status: determineContainerStatus(attrs), - shipping_line: toText(shipment?.attributes?.line_name || attrs.shipping_line_name), + shipping_line: toText( + shipment?.attributes?.line_name || attrs.shipping_line_name, + ), pod_terminal: toTextOrUndefined(podTerminal?.attributes?.name), pol_terminal: toTextOrUndefined(polTerminal?.attributes?.name), - destination: toTextOrUndefined(podTerminal?.attributes?.nickname || podTerminal?.attributes?.name), + destination: toTextOrUndefined( + podTerminal?.attributes?.nickname || podTerminal?.attributes?.name, + ), }; } -function formatShipment(shipment: any, _included: any[]): SearchResult['shipments'][0] { +function formatShipment( + shipment: any, + _included: any[], +): SearchResult['shipments'][0] { const attrs = shipment.attributes || {}; const relationships = shipment.relationships || {}; @@ -265,17 +291,47 @@ function formatShipment(shipment: any, _included: any[]): SearchResult['shipment }; } +/** + * Derive a coarse container status from lifecycle signals. + * + * NOTE: these fields (`available_for_pickup`, `pod_*_at`, `pol_loaded_at`) + * originate on the full container resource. The lightweight `/search` payload + * is not guaranteed to carry them, and their exact presence/shape there is + * unverified — so this stays deliberately defensive: it never throws on a + * missing or oddly-typed `attrs`, treats only meaningful signals as set, and + * falls back to `'unknown'` rather than guessing. When an explicit + * `attrs.status` is present the caller prefers it over this derivation. + */ function determineContainerStatus(attrs: any): string { - if (attrs.available_for_pickup) { + if (!attrs || typeof attrs !== 'object') { + return 'unknown'; + } + if (isTruthySignal(attrs.available_for_pickup)) { return 'available_for_pickup'; - } else if (attrs.pod_full_out_at) { + } else if (isTruthySignal(attrs.pod_full_out_at)) { return 'full_out'; - } else if (attrs.pod_discharged_at) { + } else if (isTruthySignal(attrs.pod_discharged_at)) { return 'discharged'; - } else if (attrs.pod_arrived_at) { + } else if (isTruthySignal(attrs.pod_arrived_at)) { return 'arrived'; - } else if (attrs.pol_loaded_at) { + } else if (isTruthySignal(attrs.pol_loaded_at)) { return 'in_transit'; } return 'unknown'; } + +/** + * A lifecycle signal is "set" only when it carries real information. Guards + * against the API serializing absent timestamps as `null`/empty string and + * against a boolean-ish flag arriving as the string `'false'`. + */ +function isTruthySignal(value: unknown): boolean { + if (value === undefined || value === null || value === false) { + return false; + } + if (typeof value === 'string') { + const text = value.trim().toLowerCase(); + return text.length > 0 && text !== 'false'; + } + return Boolean(value); +} diff --git a/packages/mcp/tests/api-handler.test.ts b/packages/mcp/tests/api-handler.test.ts index 5f1b4b64..40ed3345 100644 --- a/packages/mcp/tests/api-handler.test.ts +++ b/packages/mcp/tests/api-handler.test.ts @@ -15,16 +15,18 @@ const mockState = vi.hoisted(() => ({ })); vi.mock('../src/server.js', () => ({ - createTerminal49McpServer: vi.fn((apiToken: string, apiBaseUrl?: string, accountId?: string) => { - const server = { - connect: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - }; - - mockState.serverCreateArgs.push({ apiToken, apiBaseUrl, accountId }); - mockState.servers.push(server); - return server; - }), + createTerminal49McpServer: vi.fn( + (apiToken: string, apiBaseUrl?: string, accountId?: string) => { + const server = { + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; + + mockState.serverCreateArgs.push({ apiToken, apiBaseUrl, accountId }); + mockState.servers.push(server); + return server; + }, + ), })); vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({ @@ -33,11 +35,13 @@ vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({ close: ReturnType; constructor() { - this.handleRequest = vi.fn(async (req: unknown, res: unknown, body: unknown) => { - if (mockState.handleRequestImpl) { - await mockState.handleRequestImpl(req, res, body); - } - }); + this.handleRequest = vi.fn( + async (req: unknown, res: unknown, body: unknown) => { + if (mockState.handleRequestImpl) { + await mockState.handleRequestImpl(req, res, body); + } + }, + ); this.close = vi.fn().mockResolvedValue(undefined); mockState.transports.push(this); @@ -72,8 +76,11 @@ class MockResponse extends EventEmitter { } } -function createRequest(overrides: Partial> = {}): Record { - const overrideHeaders = (overrides.headers as Record) ?? {}; +function createRequest( + overrides: Partial> = {}, +): Record { + const overrideHeaders = + (overrides.headers as Record) ?? {}; return { method: 'POST', @@ -143,10 +150,12 @@ describe('api/mcp handler lifecycle', () => { expect(res.statusCode).toBe(500); expect(res.payload).toMatchObject({ - error: { code: -32603, message: 'Internal server error', data: 'simulated failure' }, + error: { code: -32603, message: 'Internal server error' }, id: null, jsonrpc: '2.0', }); + // DEV-10663: the internal error message must never leak to clients. + expect((res.payload as any)?.error?.data).toBeUndefined(); const server = mockState.servers[0]; const transport = mockState.transports[0]; @@ -246,18 +255,19 @@ describe('api/mcp handler lifecycle', () => { process.env.T49_CONNECTED_CLIENTS_RESOLVE_SECRET = 'resolve-secret'; process.env.T49_API_BASE_URL = 'https://api.test/v2'; process.env.WORKOS_MCP_RESOURCE = 'https://mcp.test'; - const fetchMock = vi.fn(async () => - new Response( - JSON.stringify({ - data: { - attributes: { - access_token: 'terminal49-local-jwt', - account_id: 'account-123', + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: { + attributes: { + access_token: 'terminal49-local-jwt', + account_id: 'account-123', + }, }, - }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), ); vi.stubGlobal('fetch', fetchMock); @@ -296,7 +306,12 @@ describe('api/mcp handler lifecycle', () => { process.env.WORKOS_AUTHORIZATION_SERVER_URL = 'https://auth.workos.test'; vi.stubGlobal( 'fetch', - vi.fn(async () => new Response(JSON.stringify({ error: 'not connected' }), { status: 401 })), + vi.fn( + async () => + new Response(JSON.stringify({ error: 'not connected' }), { + status: 401, + }), + ), ); const { default: handler } = await import('../../../api/mcp.ts'); @@ -369,7 +384,12 @@ describe('api/mcp handler lifecycle', () => { process.env.WORKOS_MCP_RESOURCE = 'https://mcp.test'; vi.stubGlobal( 'fetch', - vi.fn(async () => new Response(JSON.stringify({ error: 'upstream down' }), { status: 503 })), + vi.fn( + async () => + new Response(JSON.stringify({ error: 'upstream down' }), { + status: 503, + }), + ), ); const { default: handler } = await import('../../../api/mcp.ts');