diff --git a/packages/mcp/src/mcp.test.ts b/packages/mcp/src/mcp.test.ts index 93b98ef4..6f255ed3 100644 --- a/packages/mcp/src/mcp.test.ts +++ b/packages/mcp/src/mcp.test.ts @@ -435,6 +435,16 @@ describe('MCP server wiring', () => { ).toThrow(); }); + it('requires number in the track_container input schema', () => { + const server = createTerminal49McpServer('token'); + const schema = (server as any)._registeredTools.track_container + .inputSchema as { parse: (value: unknown) => unknown }; + + expect(() => schema.parse({})).toThrow(); + expect(() => schema.parse({ containerNumber: 'CAIU2885402' })).toThrow(); + expect(() => schema.parse({ number: 'CAIU2885402' })).not.toThrow(); + }); + it('advertises and enforces a maximum page size of 25', () => { const server = createTerminal49McpServer('token'); const tools = (server as any)._registeredTools as Record< @@ -476,7 +486,7 @@ describe('MCP server wiring', () => { expect(() => schema.parse({ scac: 'not a scac' })).toThrow(); }); - it('tools include _response_contract in output schemas', () => { + it('tool output schemas do not advertise response steering', () => { const server = createTerminal49McpServer('token'); const tools = (server as any)._registeredTools as Record< string, @@ -499,11 +509,11 @@ describe('MCP server wiring', () => { for (const name of expectedToolSchemas) { const outputSchema = tools[name]?.outputSchema; const hasResponseContract = _hasResponseContract(outputSchema); - expect(hasResponseContract).toBe(true); + expect(hasResponseContract).toBe(false); } }); - it('list tool contracts include display hints for table rendering', () => { + it('list tool output schemas do not advertise response display steering', () => { const server = createTerminal49McpServer('token'); const tools = (server as any)._registeredTools as Record< string, @@ -518,7 +528,7 @@ describe('MCP server wiring', () => { for (const name of listTools) { const outputSchema = tools[name]?.outputSchema; - expect(_hasDisplayHintsInResponseContract(outputSchema)).toBe(true); + expect(_hasDisplayHintsInResponseContract(outputSchema)).toBe(false); } }); @@ -750,22 +760,10 @@ describe('MCP server wiring', () => { try { const result = await client.callTool({ name, arguments: args }); - expect(result.structuredContent).toMatchObject({ - ...payload, - _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); + expect(result.structuredContent).toMatchObject(payload); + expect(JSON.stringify(result)).not.toMatch( + /_agent_steering|_response_contract|presentation_guidance|suggested_follow_ups|suggested_tools/, + ); } finally { await client.close(); await handler.close(); @@ -773,7 +771,7 @@ describe('MCP server wiring', () => { }, ); - it('marks steering-only content with audience:[assistant] and keeps the answer user-visible', async () => { + it('returns data without assistant-only steering content', async () => { containersList.mockResolvedValue({ items: [], links: {}, meta: {} }); const server = createTerminal49McpServer('token'); @@ -788,10 +786,10 @@ describe('MCP server wiring', () => { block.annotations.audience[0] === 'assistant', ); - // Exactly one assistant-only steering block carrying the contract hints. - expect(steeringBlocks).toHaveLength(1); - expect(steeringBlocks[0].text).toContain('_agent_steering'); - expect(steeringBlocks[0].text).toContain('presentation_guidance'); + expect(steeringBlocks).toHaveLength(0); + expect(JSON.stringify(result)).not.toMatch( + /_agent_steering|_response_contract|presentation_guidance|suggested_follow_ups|suggested_tools/, + ); // The first (answer) block is NOT annotated assistant-only, so it stays // visible to end users. diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 1d5f96dc..f7e8ab18 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -44,13 +44,6 @@ import { } from './sentry.js'; import { logMcpEvent } from './logging.js'; -/** - * MCP content-block annotations (per spec). `audience` lets a client decide who - * a block is for: end "user", the "assistant" (model), or both. We tag - * agent-steering payload (the response contract / metadata that exists only to - * guide the model) as assistant-only so clients can hide it from end users, - * while the human-readable answer stays unannotated (visible to everyone). - */ type ContentAnnotations = { audience?: Array<'user' | 'assistant'>; priority?: number; @@ -73,15 +66,6 @@ type ResourceLinkContent = { type ToolContent = TextContent | ResourceLinkContent; -/** - * Annotation marking a content block as steering-only (assistant/model - * audience). Clients that respect audience annotations can hide these blocks - * from end users, since they carry tool-routing hints rather than answers. - */ -const ASSISTANT_ONLY_ANNOTATION: ContentAnnotations = { - audience: ['assistant'], -}; - /** * Server-level instructions (MCP `ServerOptions.instructions`). This is a * concise operating guide handed to the LLM at initialize time so it @@ -93,9 +77,7 @@ Domain vocabulary: SCAC = 4-letter carrier code; BOL = bill of lading and bookin Only track_container changes Terminal49 account records: it creates a tracking request to begin monitoring a number and is marked non-read-only. The other tools only fetch data and are marked read-only. All tools operate within the user's private Terminal49 account and none delete or overwrite data. -Canonical chaining: start with search_container to resolve a container number / BOL / reference into Terminal49 UUIDs, then get_container or get_shipment_details for a snapshot, then get_container_transport_events for the milestone timeline (and get_container_route for multi-leg routing if the account has it). Use get_supported_shipping_lines to resolve a carrier name to its SCAC before track_container. Use list_containers / list_shipments / list_tracking_requests for fleet-level worklists. - -Tool results carry a _response_contract with presentation and follow-up hints; treat it as steering for you, not content to show the user.`; +Canonical chaining: start with search_container to resolve a container number / BOL / reference into Terminal49 UUIDs, then get_container or get_shipment_details for a snapshot, then get_container_transport_events for the milestone timeline (and get_container_route for multi-leg routing if the account has it). Use get_supported_shipping_lines to resolve a carrier name to its SCAC before track_container. Use list_containers / list_shipments / list_tracking_requests for fleet-level worklists.`; type ResponseDisplayColumn = { key: string; @@ -239,50 +221,41 @@ function hasMetadataError( return Boolean(metadata && typeof metadata.error === 'string'); } -const responseDisplayColumnSchema = z.object({ - key: z.string(), - label: z.string(), - path: z.string().optional(), - description: z.string().optional(), - compute: z.string().optional(), -}); - -const responseDisplayColumnSetSchema = z.object({ - intent: z.string(), - when_user_asks: z.array(z.string()), - columns: z.array(z.string()), -}); - -const responseDisplaySchema = z.object({ - preferred_format: z.enum(['table', 'list']), - table_when_rows_gte: z.number().int().positive(), - max_rows: z.number().int().positive(), - default_columns: z.array(z.string()), - sort: z.array( - z.object({ - key: z.string(), - direction: z.enum(['asc', 'desc']), - }), - ), - empty_state: z.string(), - column_catalog_resource: z.string(), - column_catalog: z.array(responseDisplayColumnSchema).optional(), - column_sets: z.array(responseDisplayColumnSetSchema), - selection_strategy: z.string(), -}); - -const responseContractSchema = z.object({ - purpose: z.string(), - can_answer: z.array(z.string()), - requires_more_data: z.array(z.string()), - relevant_fields: z.array(z.string()), - presentation_guidance: z.string(), - 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 METADATA_STEERING_FIELDS = new Set([ + 'presentation_guidance', + 'recommendations', + 'suggestions', + 'suggested_follow_ups', + 'suggested_tools', +]); + +/** + * Defense in depth for tool executors that return factual `_metadata`. + * Runtime responses must not carry model instructions or tool-routing hints. + */ +function stripResponseSteering(value: unknown, inMetadata = false): unknown { + if (Array.isArray(value)) { + return value.map((item) => stripResponseSteering(item, inMetadata)); + } + if (!value || typeof value !== 'object') { + return value; + } + + const sanitized: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + if (key === '_agent_steering' || key === '_response_contract') { + continue; + } + if (inMetadata && METADATA_STEERING_FIELDS.has(key)) { + continue; + } + sanitized[key] = stripResponseSteering( + nestedValue, + key === '_metadata' || inMetadata, + ); + } + return sanitized; +} /** Hard ceiling for list page size. Keeps a single MCP response bounded. */ const MAX_LIST_PAGE_SIZE = 25; @@ -313,240 +286,6 @@ function stripLegacyIntent(value: unknown): unknown { return args; } -function normalizeContract(contract: ResponseContract): ResponseContract { - return { - purpose: contract.purpose, - can_answer: contract.can_answer, - requires_more_data: contract.requires_more_data, - relevant_fields: contract.relevant_fields, - presentation_guidance: contract.presentation_guidance, - 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, - }; -} - -function attachResponseContract( - result: unknown, - contract: ResponseContract, -): unknown { - if (!result || typeof result !== 'object' || Array.isArray(result)) { - return result; - } - - return { - ...result, - _response_contract: normalizeContract(contract), - }; -} - -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.`, - can_answer: [ - 'container IDs and shipment references', - 'carrier/scac hints for discovered items', - 'what additional lookup step is needed', - ], - 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'], - }; -} - -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 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.`, - can_answer: [ - 'tracking request creation state', - 'basic container status and metadata', - 'where to pull next (if container details are delayed)', - ], - requires_more_data: isPending - ? ['container details becoming available after request linking'] - : matchedButUnavailable - ? ['the matched container details becoming available'] - : wasNotCreated - ? ['a verified identifier and carrier SCAC'] - : [], - relevant_fields: [ - 'tracking_request_created', - 'container_state', - 'id', - 'status', - ], - presentation_guidance: isPending - ? 'Tracking request was created but container linking is not immediate. Mention this and provide next-check guidance.' - : 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'] - : 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'], - }; -} - -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', - ], - requires_more_data: - totalEvents > 0 - ? [] - : ['recent container events becoming available from carrier feed'], - relevant_fields: ['timeline', 'event_categories', 'milestones'], - presentation_guidance: - totalEvents > 0 - ? 'Render in chronological order. Prioritize milestones over minor terminal noise.' - : 'No events found yet; recommend checking base container context and retrying later.', - suggested_follow_ups: ['get_container', 'get_container_route'], - suggested_tools: ['get_container', 'get_container_route'], - }; -} - -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'], - relevant_fields: ['shipping_lines', 'total_lines'], - 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 { - const available = Array.isArray(result.route_locations); - return { - purpose: 'Communicate container routing and vessel itinerary.', - can_answer: [ - 'transshipment structure', - 'leg-by-leg ETD/ETA', - 'carrier and vessel coverage', - ], - 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.' - : 'This account has no route payload; switch to events and container snapshot.', - suggested_follow_ups: ['get_container_transport_events', 'get_container'], - suggested_tools: ['get_container_transport_events', 'get_container'], - }; -} - -function buildContainerContract(): ResponseContract { - return { - purpose: 'Provide current container snapshot and readiness 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', - ], - 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_tools: ['get_container_transport_events', 'get_container_route'], - }; -} - -function buildShipmentContract(): ResponseContract { - return { - 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', - ], - presentation_guidance: - 'Group by shipment summary then container health signals (pickup ETA, pickup_lfd, holds).', - suggested_follow_ups: ['get_container', 'list_containers'], - suggested_tools: ['get_container', 'list_containers'], - }; -} - function asRecord(value: unknown): Record { return value && typeof value === 'object' ? (value as Record) @@ -952,30 +691,6 @@ export function buildListContract( }; } -/** - * Builds an assistant-only steering content block from a response contract. - * - * This surfaces the agent-steering hints (presentation guidance, suggested - * follow-up tools) as a discrete content block annotated `audience: - * ['assistant']`, so spec-aware clients can hide it from end users while still - * delivering it to the model. The user-facing answer block (built by - * buildContentPayload) is left unannotated and remains visible to everyone. - */ -function buildSteeringContent(contract: ResponseContract): TextContent { - const steering = { - _agent_steering: true, - purpose: contract.purpose, - presentation_guidance: contract.presentation_guidance, - suggested_follow_ups: contract.suggested_follow_ups, - suggested_tools: contract.suggested_tools, - }; - return { - type: 'text', - text: formatAsText(steering), - annotations: ASSISTANT_ONLY_ANNOTATION, - }; -} - /** * The container resource template registered below. Resource-link content * blocks reference these URIs so large list payloads can be replaced by compact @@ -1028,9 +743,9 @@ function buildListResourceLinks( return links; } -function wrapToolWithContract( +function wrapTool( + toolName: string, handler: (args: TArgs) => Promise, - buildContract?: (result: unknown, args: TArgs) => ResponseContract, buildResourceLinks?: (result: unknown, args: TArgs) => ResourceLinkContent[], ): (args: TArgs) => Promise<{ content: ToolContent[]; @@ -1040,21 +755,11 @@ function wrapToolWithContract( return async (args: TArgs) => { try { const result = await handler(args); - const contract = buildContract ? buildContract(result, args) : undefined; - const structuredContent = contract - ? attachResponseContract(result, contract) - : result; - - const content: ToolContent[] = buildContentPayload(result); + const structuredContent = stripResponseSteering(result); + const content: ToolContent[] = buildContentPayload(structuredContent); if (buildResourceLinks) { - content.push(...buildResourceLinks(result, args)); - } - - // Steering metadata is appended as an assistant-only block so clients can - // hide it from end users; the answer block above stays user-visible. - if (contract) { - content.push(buildSteeringContent(contract)); + content.push(...buildResourceLinks(structuredContent, args)); } return { @@ -1077,7 +782,7 @@ function wrapToolWithContract( content: [ { type: 'text', - text: 'The Terminal49 request could not be completed. Please retry; if it persists, contact support.', + text: formatToolError(toolName, args, error), }, ], isError: true, @@ -1086,6 +791,71 @@ function wrapToolWithContract( }; } +function formatToolError( + toolName: string, + args: unknown, + error: unknown, +): string { + const input = + args && typeof args === 'object' ? (args as Record) : {}; + const id = typeof input.id === 'string' ? input.id : undefined; + const number = + typeof input.number === 'string' + ? input.number + : typeof input.containerNumber === 'string' + ? input.containerNumber + : typeof input.bookingNumber === 'string' + ? input.bookingNumber + : undefined; + const err = error as { + name?: string; + message?: string; + status?: number; + details?: unknown; + }; + const errorText = `${err.message ?? ''} ${safeStringify(err.details)}`; + + if (toolName === 'track_container') { + if (!number?.trim() || /number is required/i.test(errorText)) { + return 'number is required.'; + } + if ( + err.name === 'ContainerCheckDigitError' || + /(?:iso\s*6346|check[-_\s]?digit)/i.test(errorText) + ) { + return `Container number ${number} fails the ISO 6346 check digit.`; + } + } + + if (err.name === 'NotFoundError' || err.status === 404) { + switch (toolName) { + case 'get_container': + case 'get_container_transport_events': + return `No container found with id ${id ?? '(missing)'}.`; + case 'get_shipment_details': + return `No shipment found with id ${id ?? '(missing)'}.`; + case 'get_container_route': + return `No route found for container id ${id ?? '(missing)'}.`; + default: + return 'The requested Terminal49 record was not found.'; + } + } + + if (toolName === 'track_container' && err.name === 'ValidationError') { + return `Tracking identifier ${number} is invalid. Verify the identifier type and carrier SCAC.`; + } + + return 'The Terminal49 request could not be completed. Contact support if the problem persists.'; +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? ''; + } catch { + return ''; + } +} + /** * Builds a completion callback for a carrier/SCAC prompt argument. It reuses * the live get_supported_shipping_lines data, filters by the partial value the @@ -1159,6 +929,7 @@ export function createTerminal49McpServer( description: 'Search for containers, shipments, and tracking information by container number, ' + 'booking number, bill of lading, or reference number. Returns matching private-account records. ' + + 'Use get_container or get_shipment_details with a returned UUID for a detailed snapshot. ' + 'Pass exactly one identifier, never a user message or conversation history. ' + 'Examples: CAIU2885402, MAEU123456789, or a customer reference number.', annotations: { @@ -1198,12 +969,10 @@ export function createTerminal49McpServer( }), ), total_results: z.number(), - _response_contract: responseContractSchema, }), }, - wrapToolWithContract( - async ({ query }) => executeSearchContainer({ query }, client), - (result, args) => buildSearchContract(result as any, args), + wrapTool('search_container', async ({ query }) => + executeSearchContainer({ query }, client), ), ); @@ -1215,7 +984,8 @@ export function createTerminal49McpServer( description: 'Track a container, bill of lading, or booking number. ' + 'Uses inference to choose the carrier/type when possible, creates a tracking request, ' + - 'and returns detailed container information.', + 'and returns detailed container information. If a newly created request is still pending, ' + + 'use list_tracking_requests to check its status.', annotations: { readOnlyHint: false, destructiveHint: false, @@ -1228,7 +998,6 @@ export function createTerminal49McpServer( .trim() .min(1) .max(64) - .optional() .describe( 'One container, Bill of Lading, or booking number (maximum 64 characters). Identifier only; never pass conversation text.', ), @@ -1282,11 +1051,11 @@ export function createTerminal49McpServer( status: z.string().optional(), tracking_request_created: z.boolean().optional(), infer_result: z.any().optional(), - _response_contract: responseContractSchema, }) .passthrough(), }, - wrapToolWithContract( + wrapTool( + 'track_container', async ({ number, numberType, @@ -1306,11 +1075,6 @@ export function createTerminal49McpServer( }, client, ), - (result, args) => - buildTrackContract(result as any, { - number: - args.number || args.containerNumber || args.bookingNumber || '', - }), ), ); @@ -1321,7 +1085,8 @@ 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 shipment, terminal, or transport-event data. Transport events are excluded by default to keep snapshots compact.', + 'plus optional shipment, terminal, or transport-event data. Transport events are excluded by default to keep snapshots compact. ' + + 'Call get_container_transport_events for the complete milestone timeline.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -1343,15 +1108,10 @@ export function createTerminal49McpServer( '• transport_events: Full event history, rail tracking (heavy 50-100 events, use for journey/timeline questions)', ), }), - outputSchema: z - .object({ - _response_contract: responseContractSchema, - }) - .passthrough(), + outputSchema: z.object({}).passthrough(), }, - wrapToolWithContract( - async ({ id, include }) => executeGetContainer({ id, include }, client), - () => buildContainerContract(), + wrapTool('get_container', async ({ id, include }) => + executeGetContainer({ id, include }, client), ), ); @@ -1362,7 +1122,8 @@ export function createTerminal49McpServer( title: 'Get Shipment Details', description: 'Get detailed shipment information including routing, BOL, containers, and port details. ' + - 'Returns: Bill of Lading, shipping line, port details, vessel info, ETAs, container list.', + 'Returns: Bill of Lading, shipping line, port details, vessel info, ETAs, container list. ' + + 'Use get_container with a returned container UUID for pickup availability, holds, fees, and last free day.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -1381,16 +1142,10 @@ export function createTerminal49McpServer( 'Include list of containers in this shipment. Default: true', ), }), - outputSchema: z - .object({ - _response_contract: responseContractSchema, - }) - .passthrough(), + outputSchema: z.object({}).passthrough(), }, - wrapToolWithContract( - async ({ id, include_containers }) => - executeGetShipmentDetails({ id, include_containers }, client), - () => buildShipmentContract(), + wrapTool('get_shipment_details', async ({ id, include_containers }) => + executeGetShipmentDetails({ id, include_containers }, client), ), ); @@ -1414,15 +1169,10 @@ export function createTerminal49McpServer( .uuid() .describe('The Terminal49 container ID (UUID format)'), }), - outputSchema: z - .object({ - _response_contract: responseContractSchema, - }) - .passthrough(), + outputSchema: z.object({}).passthrough(), }, - wrapToolWithContract( - async ({ id }) => executeGetContainerTransportEvents({ id }, client), - (result, args) => buildTransportEventsContract(result as any, args), + wrapTool('get_container_transport_events', async ({ id }) => + executeGetContainerTransportEvents({ id }, client), ), ); @@ -1433,7 +1183,8 @@ 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, with optional name or SCAC filtering.', + 'Returns SCAC codes, full names, and common abbreviations, with optional name or SCAC filtering. ' + + 'Pass the returned four-letter SCAC to track_container when carrier inference is ambiguous.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -1461,18 +1212,10 @@ export function createTerminal49McpServer( notes: z.string().optional(), }), ), - _metadata: z.object({ - presentation_guidance: z.string(), - error: z.string().optional(), - remediation: z.string().optional(), - }), - _response_contract: responseContractSchema, }), }, - wrapToolWithContract( - async ({ search }) => - executeGetSupportedShippingLines({ search }, client), - (result) => buildShippingLineContract(result as any), + wrapTool('get_supported_shipping_lines', async ({ search }) => + executeGetSupportedShippingLines({ search }, client), ), ); @@ -1541,22 +1284,14 @@ export function createTerminal49McpServer( .optional(), created_at: z.string().nullable().optional(), updated_at: z.string().nullable().optional(), - _metadata: z - .object({ - presentation_guidance: z.string().optional(), - }) - .optional(), - // Feature gating / errors error: z.string().optional(), message: z.string().optional(), alternative: z.string().optional(), - _response_contract: responseContractSchema.optional(), }), }, - wrapToolWithContract( - async ({ id }) => executeGetContainerRoute({ id }, client), - (result, args) => buildRouteContract(result as any, args), + wrapTool('get_container_route', async ({ id }) => + executeGetContainerRoute({ id }, client), ), ); @@ -1566,7 +1301,7 @@ export function createTerminal49McpServer( { title: 'List Shipments', description: - 'Return one intentionally requested page of shipments, optionally filtered by one shipment identifier or tracking-stopped state. Page size is capped at 25. Never pass conversation text into identifier fields.', + 'Return one intentionally requested page of shipments, optionally filtered by one shipment identifier or tracking-stopped state. Page size is capped at 25. Use get_shipment_details with a returned UUID for routing and container details. Never pass conversation text into identifier fields.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -1601,16 +1336,10 @@ export function createTerminal49McpServer( links: z.record(z.string(), z.string()).optional(), meta: z.record(z.string(), z.any()).optional(), unsupportedFilters: z.array(z.string()), - _response_contract: responseContractSchema, }), }, - wrapToolWithContract( - async (args) => executeListShipments(args, client), - (result, args) => - buildListContract(result as any, 'shipment', { - filters: args, - unsupportedFilters: (result as any)?.unsupportedFilters, - }), + wrapTool('list_shipments', async (args) => + executeListShipments(args, client), ), ); @@ -1620,7 +1349,7 @@ export function createTerminal49McpServer( { title: 'List Containers', description: - 'Return one intentionally requested page of containers, capped at 25 rows. The API does not expose server-side status, port, carrier, or update-time filters. Do not use this tool to pass or retrieve conversation text.', + 'Return one intentionally requested page of containers, capped at 25 rows. Use get_container with a returned UUID for a detailed snapshot. The API does not expose server-side status, port, carrier, or update-time filters. Do not use this tool to pass or retrieve conversation text.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -1642,16 +1371,11 @@ export function createTerminal49McpServer( links: z.record(z.string(), z.string()).optional(), meta: z.record(z.string(), z.any()).optional(), unsupportedFilters: z.array(z.string()), - _response_contract: responseContractSchema, }), }, - wrapToolWithContract( + wrapTool( + 'list_containers', async (args) => executeListContainers(args, client), - (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. @@ -1665,7 +1389,7 @@ export function createTerminal49McpServer( { title: 'List Tracking Requests', description: - 'Return one intentionally requested page of tracking requests, optionally filtered by request identifier, status, or carrier SCAC. Page size is capped at 25. Identifier fields must never contain user messages or conversation history.', + 'Return one intentionally requested page of tracking requests, optionally filtered by request identifier, status, or carrier SCAC. Page size is capped at 25. For succeeded requests, use search_container to resolve the tracked identifier into container or shipment UUIDs. Identifier fields must never contain user messages or conversation history.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -1704,16 +1428,10 @@ export function createTerminal49McpServer( items: z.array(z.record(z.string(), z.any())), links: z.record(z.string(), z.string()).optional(), meta: z.record(z.string(), z.any()).optional(), - _response_contract: responseContractSchema, }), }, - wrapToolWithContract( - async (args) => executeListTrackingRequests(args, client), - (result, args) => - buildListContract(result as any, 'tracking_request', { - filters: args, - unsupportedFilters: (result as any)?.unsupportedFilters, - }), + wrapTool('list_tracking_requests', async (args) => + executeListTrackingRequests(args, client), ), ); diff --git a/packages/mcp/src/tool-transport.test.ts b/packages/mcp/src/tool-transport.test.ts index c8a7c29e..b288f952 100644 --- a/packages/mcp/src/tool-transport.test.ts +++ b/packages/mcp/src/tool-transport.test.ts @@ -364,7 +364,7 @@ function argumentsFor(toolName: ToolName): Record { case 'search_container': return { query: 'CAIU1234567' }; case 'track_container': - return { number: 'CAIU1234567', scac: 'MAEU' }; + return { number: 'CAIU2885402', scac: 'MAEU' }; case 'get_container': case 'get_container_transport_events': case 'get_container_route': @@ -540,22 +540,12 @@ 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), - 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); + expect(result.structuredContent).toMatchObject( + expectedOutputFor(toolName), + ); + expect(JSON.stringify(result)).not.toMatch( + /_agent_steering|_response_contract|presentation_guidance|suggested_follow_ups|suggested_tools/, + ); } finally { await client.close(); await handler.close(); @@ -573,20 +563,14 @@ describe('all public tools over MCP client transport', () => { try { const result = await client.callTool({ name: 'track_container', - arguments: { number: 'CAIU1234567', scac: 'MAEU' }, + arguments: { number: 'CAIU2885402', 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', - ), - }, + message: expect.stringContaining('No container found'), }); expect(JSON.stringify(result)).not.toContain('internal route'); } finally { @@ -595,6 +579,73 @@ describe('all public tools over MCP client transport', () => { } }); + it('track_container returns a specific ISO 6346 check-digit error', async () => { + const { client, handler } = await connectClient(); + + try { + const result = await client.callTool({ + name: 'track_container', + arguments: { number: 'CAIU1234567', scac: 'MAEU' }, + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + expect.objectContaining({ + text: 'Container number CAIU1234567 fails the ISO 6346 check digit.', + }), + ]); + expect(sdk.createTrackingRequestFromInfer).not.toHaveBeenCalled(); + } finally { + await client.close(); + await handler.close(); + } + }); + + it.each([ + { + toolName: 'get_container' as const, + expected: `No container found with id ${CONTAINER_ID}.`, + }, + { + toolName: 'get_shipment_details' as const, + expected: `No shipment found with id ${SHIPMENT_ID}.`, + }, + { + toolName: 'get_container_transport_events' as const, + expected: `No container found with id ${CONTAINER_ID}.`, + }, + ])( + '$toolName returns a specific not-found error', + async ({ toolName, expected }) => { + const notFound = new Error('private upstream not-found detail'); + notFound.name = 'NotFoundError'; + configureFailure(toolName, notFound); + if (toolName === 'get_container_transport_events') { + sdk.containersGet.mockRejectedValue(notFound); + } + 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(text).toBe(expected); + expect(text).not.toContain('retry'); + expect(text).not.toContain('private upstream'); + } finally { + await client.close(); + await handler.close(); + } + }, + ); + it('track_container preserves a created request when its linked container is not readable yet', async () => { sdk.search.mockResolvedValue({ data: [] }); sdk.createTrackingRequestFromInfer.mockResolvedValue({ @@ -611,24 +662,16 @@ describe('all public tools over MCP client transport', () => { try { const result = await client.callTool({ name: 'track_container', - arguments: { number: 'CAIU1234567', scac: 'MAEU' }, + arguments: { number: 'CAIU2885402', scac: 'MAEU' }, }); expect(result.isError).not.toBe(true); expect(result.structuredContent).toMatchObject({ tracking_request_created: true, tracking_request: { - request_number: 'CAIU1234567', + request_number: 'CAIU2885402', 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 { @@ -645,7 +688,7 @@ describe('all public tools over MCP client transport', () => { type: 'search_result', attributes: { entity_type: 'container', - number: 'CAIU1234567', + number: 'CAIU2885402', scac: 'MAEU', }, }, @@ -659,7 +702,7 @@ describe('all public tools over MCP client transport', () => { try { const result = await client.callTool({ name: 'track_container', - arguments: { number: 'CAIU1234567', scac: 'MAEU' }, + arguments: { number: 'CAIU2885402', scac: 'MAEU' }, }); expect(result.isError).not.toBe(true); @@ -668,14 +711,6 @@ describe('all public tools over MCP client transport', () => { 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'); diff --git a/packages/mcp/src/tools/contracts.test.ts b/packages/mcp/src/tools/contracts.test.ts index e403cb4b..1ac0da9a 100644 --- a/packages/mcp/src/tools/contracts.test.ts +++ b/packages/mcp/src/tools/contracts.test.ts @@ -221,11 +221,11 @@ describe('MCP tool contracts', () => { }); const result = await executeTrackContainer( - { number: 'CAIU1234567', scac: 'MAEU' }, + { number: 'CAIU2885402', scac: 'MAEU' }, client, ); - expect(createFromInfer).toHaveBeenCalledWith('CAIU1234567', { + expect(createFromInfer).toHaveBeenCalledWith('CAIU2885402', { scac: 'MAEU', numberType: undefined, refNumbers: undefined, @@ -307,14 +307,14 @@ describe('MCP tool contracts', () => { }); const result = await executeTrackContainer( - { number: 'MSCU1234567', numberType: 'container', scac: 'MSCU' }, + { number: 'MSCU1234566', numberType: 'container', scac: 'MSCU' }, client, ); expect(createFromInfer).toHaveBeenCalledTimes(1); expect(createTrackingRequest).toHaveBeenCalledWith({ requestType: 'container', - requestNumber: 'MSCU1234567', + requestNumber: 'MSCU1234566', scac: 'MSCU', refNumbers: undefined, }); @@ -893,10 +893,7 @@ describe('MCP tool contracts', () => { number_type: 'container', scac: 'TEMU', }); - expect(result._metadata).toMatchObject({ - presentation_guidance: - 'Tracking request was created, but no container is linked yet. Poll list_tracking_requests or retry in a short while.', - }); + expect(result._metadata).toBeUndefined(); }); it('track_container distinguishes an uncreated request from a hard upstream failure', async () => { @@ -908,23 +905,30 @@ describe('MCP tool contracts', () => { }); const result = await executeTrackContainer( - { number: 'CAIU1234567', scac: 'MAEU' }, + { number: 'CAIU2885402', 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', - ), - }, + message: expect.stringContaining('No container found'), }); expect(JSON.stringify(result)).not.toContain('internal route'); }); + it('track_container rejects an invalid ISO 6346 check digit before calling the API', async () => { + const createFromInfer = vi.fn(); + const client = asClient({ + createTrackingRequestFromInfer: createFromInfer, + }); + + await expect( + executeTrackContainer({ number: 'CAIU1234567', scac: 'MAEU' }, client), + ).rejects.toThrow('fails the ISO 6346 check digit'); + expect(createFromInfer).not.toHaveBeenCalled(); + }); + it('get_supported_shipping_lines filters response by search term', async () => { const shippingList = vi.fn().mockResolvedValue([ { scac: 'MSCU', name: 'MSC', shortName: 'MSC' }, diff --git a/packages/mcp/src/tools/get-container-route.ts b/packages/mcp/src/tools/get-container-route.ts index 2eef2cd3..902e4fd9 100644 --- a/packages/mcp/src/tools/get-container-route.ts +++ b/packages/mcp/src/tools/get-container-route.ts @@ -235,11 +235,5 @@ function formatRouteResponse(apiResponse: any): any { route_locations: routeLocations, created_at: route.created_at, updated_at: route.updated_at, - _metadata: { - presentation_guidance: - 'Present route as a journey: Origin → [Transshipment Ports] → Destination. ' + - 'For each leg, show vessel name, carrier, and ETD/ETA/ATD/ATA. ' + - 'Highlight transshipment ports (where container changes vessels).', - }, }; } diff --git a/packages/mcp/src/tools/get-container-transport-events.ts b/packages/mcp/src/tools/get-container-transport-events.ts index 848c7717..c92efddc 100644 --- a/packages/mcp/src/tools/get-container-transport-events.ts +++ b/packages/mcp/src/tools/get-container-transport-events.ts @@ -219,13 +219,6 @@ function formatTransportEventsResponse( const metadata: Record = { source: options.source, - presentation_guidance: - events.length > 0 - ? 'Present events chronologically as a journey timeline. ' + - 'Highlight key milestones: vessel loaded, departed, arrived, discharged, delivery. ' + - 'For rail containers, emphasize rail movements.' - : 'This container exists but has no transport events yet. ' + - 'Report an empty timeline (not an error) and use get_container for current status.', }; if (options.containerFound !== undefined) { diff --git a/packages/mcp/src/tools/get-container.ts b/packages/mcp/src/tools/get-container.ts index e050d8c1..83334206 100644 --- a/packages/mcp/src/tools/get-container.ts +++ b/packages/mcp/src/tools/get-container.ts @@ -89,31 +89,21 @@ export interface ContainerStatus { name: string; firms_code: string; } | null; - events?: - | { - count: number; - latest_event?: { - event: string; - timestamp: string; - location?: string; - }; - rail_events_count?: number; - } - | string; + events?: { + count: number; + latest_event?: { + event: string; + timestamp: string; + location?: string; + }; + rail_events_count?: number; + }; created_at: string; _metadata: { container_state: string; status_is_authoritative: boolean; derived_lifecycle: string; includes_loaded: string[]; - can_answer: string[]; - needs_more_data_for: string[]; - relevant_for_current_state: string[]; - presentation_guidance: string; - suggestions?: { - message?: string; - recommended_follow_up?: string | null; - }; }; } @@ -206,7 +196,7 @@ function formatContainerResponse( const eventsData = includes.includes('transport_events') ? formatEventsData(transportEvents) - : `Call get_container with include=['transport_events'] to fetch ${transportEvents.length || '~50-100'} event records`; + : undefined; const podTimezone: string | null = container.pod_timezone ?? null; // Compute the LFD countdown in terminal-local days so "N days until LFD" never @@ -228,13 +218,7 @@ function formatContainerResponse( const importDeadlines = container.import_deadlines || {}; - const metadata = generateMetadata( - container, - statusResult, - demurrage, - podTimezone, - includes, - ); + const metadata = generateMetadata(statusResult, includes); return { id: apiResponse.data?.id, @@ -374,272 +358,16 @@ function formatEventsData(events: any[]): any { }; } -/** - * Generate metadata hints to steer LLM decision-making. The derived lifecycle - * is exposed here as non-authoritative steering metadata only — the headline - * `status` above is the source of truth. - */ function generateMetadata( - container: any, statusResult: ContainerStatusResult, - demurrage: DemurrageEvaluation, - podTimezone: string | null, includes: string[], ): ContainerStatus['_metadata'] { const lifecycle = statusResult.derived_lifecycle; - const canAnswer: string[] = [ - 'container status', - 'equipment details', - 'basic timeline', - ]; - const needsMoreDataFor: string[] = []; - - if (includes.includes('shipment')) { - canAnswer.push( - 'routing information', - 'shipping line details', - 'reference numbers', - ); - } - - if (includes.includes('pod_terminal')) { - canAnswer.push( - 'availability status', - 'demurrage/LFD', - 'holds and fees', - 'terminal location', - ); - } - - if (includes.includes('transport_events')) { - canAnswer.push( - 'full journey timeline', - 'milestone analysis', - 'rail tracking details', - 'event history', - ); - } else { - needsMoreDataFor.push( - "journey timeline → include: ['transport_events']", - "milestone analysis → include: ['transport_events']", - "rail movement details → include: ['transport_events']", - ); - } - - const suggestions = generateSuggestions( - container, - lifecycle, - demurrage, - includes, - ); - const relevantFields = getRelevantFieldsForState(lifecycle, container); - const presentationGuidance = getPresentationGuidance( - lifecycle, - container, - demurrage, - ); return { container_state: lifecycle, status_is_authoritative: statusResult.status_source === 'current_status', derived_lifecycle: lifecycle, includes_loaded: includes, - can_answer: canAnswer, - needs_more_data_for: needsMoreDataFor, - relevant_for_current_state: relevantFields, - presentation_guidance: presentationGuidance, - suggestions, }; } - -function generateSuggestions( - container: any, - state: string, - demurrage: DemurrageEvaluation, - includes: string[], -): { message?: string; recommended_follow_up?: string | null } { - let message: string | undefined; - let recommendedFollowUp: string | null = null; - - switch (state) { - case 'in_transit': - message = - 'Container is still in transit. User may ask about vessel ETA or shipping route.'; - break; - - case 'arrived': - message = - 'Container has arrived but not yet discharged. User may ask about discharge timing.'; - break; - - case 'at_terminal': - case 'available_for_pickup': - if ( - Array.isArray(container.holds_at_pod_terminal) && - container.holds_at_pod_terminal.length > 0 - ) { - const holdTypes = container.holds_at_pod_terminal - .map((h: any) => h.name) - .join(', '); - message = `Container has holds: ${holdTypes}. User may ask about hold details or clearance timeline.`; - } else if ( - container.holds_at_pod_terminal == null && - includes.includes('pod_terminal') - ) { - message = - 'Hold/fee/LFD data is not available for this container/terminal via the API response. ' + - 'User may need to check terminal portal or customs/broker docs.'; - } else if (demurrage.urgency_suppressed) { - message = `LFD urgency is unavailable: ${demurrage.suppression_reason}. Do not assert demurrage urgency from this data alone.`; - } else if (demurrage.days_until_lfd !== null) { - const days = demurrage.days_until_lfd; - if (demurrage.urgency === 'overdue') { - message = `Container is ${Math.abs(days)} days past LFD. User may ask about demurrage charges.`; - } else if (demurrage.urgency === 'imminent') { - message = `LFD is in ${days} days. Urgent pickup needed to avoid demurrage.`; - } else { - message = `Container available for pickup. LFD is in ${days} days.`; - } - } - break; - - case 'on_rail': - message = - 'Container is on rail transport. User may ask about rail carrier, destination ETA, or inland movement.'; - if (!includes.includes('transport_events')) { - recommendedFollowUp = 'transport_events'; - } - break; - - case 'delivered': - message = - 'Container has been delivered. User may ask about delivery details or empty return.'; - if (!includes.includes('transport_events')) { - recommendedFollowUp = 'transport_events'; - } - break; - } - - return { message, recommended_follow_up: recommendedFollowUp }; -} - -function getRelevantFieldsForState(state: string, container: any): string[] { - switch (state) { - case 'in_transit': - return [ - 'shipment.pod_eta_at - When arriving at destination', - 'shipment.pod_vessel_name - Current vessel', - 'shipment.port_of_discharge_name - Destination port', - 'shipment.pol_atd_at - When departed origin', - ]; - - case 'arrived': - return [ - 'location.pod_arrived_at - When vessel docked', - 'location.pod_discharged_at - Discharge status (null = still on vessel)', - 'pod_terminal.name - Which terminal', - ]; - - case 'at_terminal': - case 'available_for_pickup': { - const fields = [ - 'location.available_for_pickup - Ready to pick up?', - 'demurrage.last_free_days - Per-channel LFDs (terminal/rail/line)', - 'demurrage.holds_at_pod_terminal - Blocks pickup if present', - 'location.current_location - Where in terminal yard', - ]; - if (container.fees_at_pod_terminal?.length > 0) { - fields.push( - 'demurrage.fees_at_pod_terminal - Storage/handling charges', - ); - } - if (container.pickup_appointment_at) { - fields.push('demurrage.pickup_appointment_at - Scheduled pickup time'); - } - return fields; - } - - case 'on_rail': - return [ - 'rail.pod_rail_carrier - Rail carrier SCAC code', - 'rail.destination_eta - When arriving inland destination', - 'rail.pod_rail_departed_at - When left port', - 'shipment.destination_name - Inland city', - 'events - Rail milestones (if transport_events included)', - ]; - - case 'delivered': - return [ - 'location.pod_full_out_at - When picked up from terminal', - 'Complete journey timeline - Helpful for delivered containers', - 'empty_terminated_at - Empty return status (if applicable)', - ]; - - default: - return ['status', 'location', 'equipment']; - } -} - -function getPresentationGuidance( - state: string, - container: any, - demurrage: DemurrageEvaluation, -): string { - switch (state) { - case 'in_transit': - return 'Focus on ETA and vessel information. User wants to know WHEN it will arrive and WHERE it is now.'; - - case 'arrived': - return 'Explain vessel arrived but container not yet discharged. User wants to know WHEN discharge will happen.'; - - case 'at_terminal': - case 'available_for_pickup': { - if (container.holds_at_pod_terminal?.length > 0) { - const holdTypes = container.holds_at_pod_terminal - .map((h: any) => h.name) - .join(', '); - return `URGENT: Lead with holds (${holdTypes}) - they BLOCK pickup. Explain what each hold means and how to clear. Then mention LFD and location.`; - } - - if (demurrage.urgency_suppressed) { - return `Availability/LFD data is not reliable here (${demurrage.suppression_reason}). State availability cautiously and do NOT assert demurrage urgency. Suggest verifying with the terminal directly.`; - } - - if ( - demurrage.urgency === 'overdue' && - demurrage.days_until_lfd !== null - ) { - const fees = describeFees(demurrage); - return `Container is ${Math.abs(demurrage.days_until_lfd)} days past LFD.${fees} Emphasize that pickup is overdue; report only the fees the API returned (do not estimate a daily rate).`; - } - - if ( - demurrage.urgency === 'imminent' && - demurrage.days_until_lfd !== null - ) { - return `Only ${demurrage.days_until_lfd} days until LFD. Pickup needed soon to avoid demurrage charges.`; - } - - if (demurrage.days_until_lfd !== null) { - return `Lead with availability status. Mention LFD date and days remaining (${demurrage.days_until_lfd}). Include location if user picking up.`; - } - - return 'State availability clearly. Mention location in terminal. Note any fees the API returned.'; - } - - case 'on_rail': - return 'Explain rail journey: Departed [port] on [date] via [carrier], heading to [city]. ETA: [date]. Emphasize destination and timing.'; - - case 'delivered': - return 'Confirm delivery completed with date/time. Optionally summarize full journey from origin to delivery.'; - - default: - return 'Present information clearly based on container lifecycle stage. Prioritize actionable details.'; - } -} - -function describeFees(demurrage: DemurrageEvaluation): string { - if (demurrage.total_amount == null) return ''; - const currency = demurrage.currency_code ? ` ${demurrage.currency_code}` : ''; - return ` Reported fees total ${demurrage.total_amount}${currency}.`; -} diff --git a/packages/mcp/src/tools/get-shipment-details.ts b/packages/mcp/src/tools/get-shipment-details.ts index 6faf1b16..e0f63115 100644 --- a/packages/mcp/src/tools/get-shipment-details.ts +++ b/packages/mcp/src/tools/get-shipment-details.ts @@ -8,7 +8,6 @@ import { Terminal49Client } from '@terminal49/sdk'; import { logMcpEvent } from '../logging.js'; -import { dayDeltaInZone } from '../lib/temporal.js'; export interface GetShipmentArgs { id: string; @@ -79,7 +78,7 @@ function formatShipmentResponse( // Extract containers if included const containerData = includeContainers ? extractContainers(relationships, included) - : `Call get_shipment_details with include_containers=true to fetch container list`; + : undefined; // Extract port/terminal info const portOfLading = included.find( @@ -200,7 +199,6 @@ function formatShipmentResponse( includes_loaded: includeContainers ? ['containers', 'ports', 'terminals'] : ['ports', 'terminals'], - presentation_guidance: getShipmentPresentationGuidance(status, shipment), }, }; } @@ -246,39 +244,3 @@ function determineShipmentStatus(shipment: any): string { if (shipment.pol_etd_at) return 'awaiting_departure'; return 'pending'; } - -function getShipmentPresentationGuidance( - status: string, - shipment: any, -): string { - switch (status) { - case 'pending': - return 'Shipment is being prepared. Focus on expected departure date and origin details.'; - - case 'awaiting_departure': - return 'Vessel has not yet departed. Emphasize ETD and vessel details.'; - - case 'in_transit': { - // Compute the day delta in the destination terminal's local time so the - // "ETA in N days" count never lands on the wrong calendar day (the classic - // UTC off-by-one near midnight). - const daysToArrival = dayDeltaInZone( - shipment.pod_eta_at, - shipment.pod_timezone, - ); - if (daysToArrival !== null) { - return `Shipment is in transit. ETA in ${daysToArrival} days (destination-local). Focus on vessel name, route, and arrival timing.`; - } - return 'Shipment is in transit. Focus on vessel and expected arrival.'; - } - - case 'arrived_at_pod': - return 'Shipment has arrived at destination port. Focus on containers and their discharge/availability status.'; - - case 'delivered_to_destination': - return 'Shipment delivered to final destination. Provide summary of journey and container delivery status.'; - - default: - return 'Present shipment routing and status clearly.'; - } -} diff --git a/packages/mcp/src/tools/get-supported-shipping-lines.ts b/packages/mcp/src/tools/get-supported-shipping-lines.ts index 74018098..525bcf98 100644 --- a/packages/mcp/src/tools/get-supported-shipping-lines.ts +++ b/packages/mcp/src/tools/get-supported-shipping-lines.ts @@ -8,7 +8,6 @@ import { Terminal49Client } from '@terminal49/sdk'; interface SupportedLinesResponse { total_lines: number; shipping_lines: ShippingLineRecord[]; - _metadata: Record; } export interface ShippingLineRecord { @@ -49,11 +48,6 @@ export async function executeGetSupportedShippingLines( return { total_lines: filtered.length, shipping_lines: filtered, - _metadata: { - presentation_guidance: search - ? `User searched for "${args.search}". Present matching carriers clearly.` - : 'Present carriers alphabetically. Data sourced from Terminal49 shipping_lines API.', - }, }; } diff --git a/packages/mcp/src/tools/track-container.ts b/packages/mcp/src/tools/track-container.ts index 8527f462..9f8a9fa1 100644 --- a/packages/mcp/src/tools/track-container.ts +++ b/packages/mcp/src/tools/track-container.ts @@ -9,7 +9,7 @@ import { executeGetContainer } from './get-container.js'; import { executeSearchContainer } from './search-container.js'; export interface TrackContainerArgs { - number?: string; + number: string; numberType?: string; containerNumber?: string; bookingNumber?: string; @@ -72,6 +72,61 @@ function inferNumberTypeFromPattern(number: string): string | undefined { return undefined; } +const ISO_6346_LETTER_VALUES: Record = { + A: 10, + B: 12, + C: 13, + D: 14, + E: 15, + F: 16, + G: 17, + H: 18, + I: 19, + J: 20, + K: 21, + L: 23, + M: 24, + N: 25, + O: 26, + P: 27, + Q: 28, + R: 29, + S: 30, + T: 31, + U: 32, + V: 34, + W: 35, + X: 36, + Y: 37, + Z: 38, +}; + +class ContainerCheckDigitError extends Error { + constructor(number: string) { + super(`Container number ${number} fails the ISO 6346 check digit`); + this.name = 'ContainerCheckDigitError'; + } +} + +function hasValidIso6346CheckDigit(number: string): boolean { + if (!/^[A-Z]{3}[UJZ]\d{7}$/.test(number)) { + return true; + } + + let sum = 0; + for (const [index, character] of [...number.slice(0, 10)].entries()) { + const value = /\d/.test(character) + ? Number(character) + : ISO_6346_LETTER_VALUES[character]; + if (value === undefined) { + return false; + } + sum += value * 2 ** index; + } + + return (sum % 11) % 10 === Number(number.at(-1)); +} + function parseValidationPointer(message: string): string | undefined { const pointerMatch = message.match(/\((\/data\/attributes\/[a-z_]+)\)/i); return pointerMatch?.[1]; @@ -126,8 +181,11 @@ export async function executeTrackContainer( const number = normalizeTrackingNumber( args.number || args.containerNumber || args.bookingNumber || '', ); - if (!number || number.trim() === '') { - throw new Error('Tracking number is required'); + if (!number) { + throw new Error('number is required'); + } + if (!hasValidIso6346CheckDigit(number)) { + throw new ContainerCheckDigitError(number); } const numberTypeOverride = normalizeNumberType( @@ -174,11 +232,6 @@ export async function executeTrackContainer( '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 { @@ -255,11 +308,6 @@ export async function executeTrackContainer( number_type: inferredNumberType, scac: requestedScac || heuristicScac, }, - _metadata: { - presentation_guidance: - 'Tracking request was created, but no container is linked yet. Poll list_tracking_requests or retry in a short while.', - recommendations: ['list_tracking_requests', 'get_container'], - }, }; } @@ -290,11 +338,6 @@ export async function executeTrackContainer( 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'], - }, }; } @@ -328,14 +371,8 @@ export async function executeTrackContainer( }); 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.', + message: `No container found for identifier ${number}. Verify the number and carrier SCAC.`, 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'], - }, }; }