From 6e0468a9c9ae73cf66760c6a5215ae0a6f626f8f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 06:01:08 +0000 Subject: [PATCH 1/3] fix(mcp): validate list tool sidecars Co-authored-by: Akshay Dodeja --- packages/mcp/src/mcp.test.ts | 114 ++++++++++++++++++++++++++++++++++- packages/mcp/src/server.ts | 2 + 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/mcp.test.ts b/packages/mcp/src/mcp.test.ts index 56412eb8..116eb703 100644 --- a/packages/mcp/src/mcp.test.ts +++ b/packages/mcp/src/mcp.test.ts @@ -20,15 +20,17 @@ vi.mock('@sentry/node', () => ({ // Stubbed Terminal49Client so server tools can be exercised end-to-end without // hitting the live API. Tests configure these mocks per-case. `vi.hoisted` // is required because vi.mock factories are hoisted above normal declarations. -const { shippingLinesList, containersList } = vi.hoisted(() => ({ +const { shippingLinesList, containersList, shipmentsList } = vi.hoisted(() => ({ shippingLinesList: vi.fn(), containersList: vi.fn(), + shipmentsList: vi.fn(), })); vi.mock('@terminal49/sdk', () => ({ Terminal49Client: class Terminal49Client { shippingLines = { list: shippingLinesList }; containers = { list: containersList }; + shipments = { list: shipmentsList }; }, FeatureNotEnabledError: class FeatureNotEnabledError extends Error {}, NotFoundError: class NotFoundError extends Error {}, @@ -37,6 +39,7 @@ vi.mock('@terminal49/sdk', () => ({ beforeEach(() => { shippingLinesList.mockReset(); containersList.mockReset(); + shipmentsList.mockReset(); }); function _hasResponseContract(schema: unknown): boolean { @@ -300,6 +303,29 @@ class MockTransport { close = vi.fn(); } +async function connectClientForToolCall() { + const handler = createMcpHandler( + () => createTerminal49McpServer('token', 'https://api.test'), + { + legacy: 'stateless', + responseMode: 'json', + }, + ); + const client = new Client( + { name: 'terminal49-tool-output-test', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } }, + ); + const transport = new StreamableHTTPClientTransport( + new URL('https://mcp.test/mcp'), + { + fetch: (url, init) => handler.fetch(new Request(url, init)), + }, + ); + + await client.connect(transport); + return { client, handler }; +} + describe('MCP server wiring', () => { it('connects without throwing and registers MCP handlers', async () => { const server = createTerminal49McpServer('token', 'https://api.test'); @@ -569,6 +595,92 @@ describe('MCP server wiring', () => { } }); + it.each([ + { + name: 'list_containers', + args: { page: 1, page_size: 10 }, + listMock: containersList, + payload: { + items: [ + { + id: '11111111-1111-1111-1111-111111111111', + number: 'CAIU1234567', + currentStatus: 'available', + terminals: { + podTerminal: { name: 'APM Los Angeles', firmsCode: 'Y123' }, + }, + }, + ], + links: { + self: 'https://api.test/containers?page[number]=1&page[size]=10', + next: 'https://api.test/containers?page[number]=2&page[size]=10', + }, + meta: { total: 42 }, + unsupportedFilters: [], + }, + }, + { + name: 'list_shipments', + args: { + carrier: 'MAEU', + include_containers: true, + page: 1, + page_size: 10, + }, + listMock: shipmentsList, + payload: { + items: [ + { + id: '22222222-2222-2222-2222-222222222222', + billOfLading: 'MAEU123456789', + shippingLineScac: 'MAEU', + containers: [ + { + id: '11111111-1111-1111-1111-111111111111', + number: 'CAIU1234567', + }, + ], + }, + ], + links: { + self: 'https://api.test/shipments?page[number]=1&page[size]=10', + }, + meta: { total: 1 }, + unsupportedFilters: ['carrier'], + }, + }, + ])( + '$name structured content validates with mapped list sidecars', + async ({ name, args, listMock, payload }) => { + listMock.mockResolvedValue(payload); + const { client, handler } = await connectClientForToolCall(); + + 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); + } finally { + await client.close(); + await handler.close(); + } + }, + ); + it('marks steering-only content with audience:[assistant] and keeps the answer user-visible', async () => { containersList.mockResolvedValue({ items: [], links: {}, meta: {} }); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 3f7f751d..d9198443 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1624,6 +1624,7 @@ 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(), + unsupportedFilters: z.array(z.string()), _response_contract: responseContractSchema, }), }, @@ -1672,6 +1673,7 @@ 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(), + unsupportedFilters: z.array(z.string()), _response_contract: responseContractSchema, }), }, From d46298dd09ed50d1e2688032d68fb502e3a40b29 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 06:36:24 +0000 Subject: [PATCH 2/3] fix(mcp): advertise only supported list filters Co-authored-by: Akshay Dodeja --- packages/mcp/src/mcp.test.ts | 54 +++++++++- packages/mcp/src/resources/query-guidance.ts | 84 +++++---------- packages/mcp/src/server.ts | 106 +++++++++++-------- packages/mcp/src/tools/contracts.test.ts | 87 +++++++-------- packages/mcp/src/tools/list-containers.ts | 12 --- packages/mcp/src/tools/list-shipments.ts | 21 ++-- 6 files changed, 185 insertions(+), 179 deletions(-) diff --git a/packages/mcp/src/mcp.test.ts b/packages/mcp/src/mcp.test.ts index 116eb703..21bc5a55 100644 --- a/packages/mcp/src/mcp.test.ts +++ b/packages/mcp/src/mcp.test.ts @@ -9,6 +9,7 @@ import { createTerminal49McpServer, TERMINAL49_SERVER_INSTRUCTIONS, } from './server.js'; +import { readQueryGuidanceResource } from './resources/query-guidance.js'; vi.mock('@sentry/node', () => ({ captureException: vi.fn(), @@ -394,6 +395,37 @@ describe('MCP server wiring', () => { ).not.toThrow(); }); + it('list input schemas advertise only API-supported filters', () => { + const server = createTerminal49McpServer('token'); + const tools = (server as any)._registeredTools as Record< + string, + { inputSchema: unknown } + >; + const droppedFilters = ['status', 'port', 'carrier', 'updated_after']; + + for (const toolName of ['list_containers', 'list_shipments']) { + for (const filter of droppedFilters) { + expect( + _objectSchemaHasProperty(tools[toolName]?.inputSchema, filter), + `${toolName}.${filter}`, + ).toBe(false); + } + } + + expect( + _objectSchemaHasProperty(tools.list_shipments.inputSchema, 'number'), + ).toBe(true); + expect( + _objectSchemaHasProperty( + tools.list_shipments.inputSchema, + 'tracking_stopped', + ), + ).toBe(true); + expect( + _objectSchemaHasProperty(tools.list_shipments.inputSchema, 'include'), + ).toBe(true); + }); + it('tools include _response_contract in output schemas', () => { const server = createTerminal49McpServer('token'); const tools = (server as any)._registeredTools as Record< @@ -503,9 +535,27 @@ describe('MCP server wiring', () => { expect(instructions).toMatch(/LFD/); expect(instructions).toMatch(/search_container/); expect(instructions).toMatch(/track_container/); + expect(instructions).toMatch( + /do not apply status, port, carrier, or updated_after filters/, + ); expect(instructions.length).toBeGreaterThan(400); }); + it('query guidance does not advertise unsupported list filters', () => { + const guidance = readQueryGuidanceResource(); + + expect(guidance).not.toContain( + 'Supported list filters: status, port, carrier, updated_after', + ); + expect(guidance).toContain( + 'has no server-side status, port, carrier, or updated-after filter', + ); + expect(guidance).toContain( + 'cannot currently be server-filtered with these list tools', + ); + expect(guidance).toContain('non-empty `unsupportedFilters`'); + }); + it('returns carrier SCAC completion values over MCP', async () => { shippingLinesList.mockResolvedValue([ { scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' }, @@ -622,7 +672,7 @@ describe('MCP server wiring', () => { { name: 'list_shipments', args: { - carrier: 'MAEU', + number: 'MAEU123456789', include_containers: true, page: 1, page_size: 10, @@ -646,7 +696,7 @@ describe('MCP server wiring', () => { self: 'https://api.test/shipments?page[number]=1&page[size]=10', }, meta: { total: 1 }, - unsupportedFilters: ['carrier'], + unsupportedFilters: [], }, }, ])( diff --git a/packages/mcp/src/resources/query-guidance.ts b/packages/mcp/src/resources/query-guidance.ts index 1d55c58e..fb32d601 100644 --- a/packages/mcp/src/resources/query-guidance.ts +++ b/packages/mcp/src/resources/query-guidance.ts @@ -14,73 +14,41 @@ export function readQueryGuidanceResource(): string { return [ '# Terminal49 MCP Query Guidance', '', - 'Use this document to map user questions to the right tool sequence, plus output expectations.', + 'Use this document to map user questions to tools without claiming that an unsupported filter was applied.', '', - '## Intent → Tool Mapping', + '## Glossary', '', - '### 1) Single container status / pickup readiness', - '- Question examples:', - ' - "Is container [X] ready for pickup?"', - ' - "What is the pickup status?"', - ' - "Is container ready for pickup yet?"', - '- Primary tool: get_container', - '- Input: use container UUID from search', - '- Follow-up: call get_container with include: ["shipment","pod_terminal"]', - '- If uncertain state: call get_container_transport_events', + '- **Container number:** an ISO 6346 equipment identifier, normally four letters followed by seven digits (including the check digit), such as `CAIU1234567`.', + '- **Bill of Lading (BOL) / booking number:** shipment identifiers, not container numbers. A shipment can contain multiple containers.', + '- **SCAC:** the four-letter Standard Carrier Alpha Code, such as `MAEU`. A carrier name such as "Maersk" or "Ocean Network Express" is not a SCAC.', + '- **UN/LOCODE:** a five-character location code such as `USLAX`; use the code, not a city name such as "Los Angeles", when an API argument requires a LOCODE.', + '- **POL / POD:** port of lading (origin loading port) / port of discharge (destination unloading port). Do not substitute one for the other.', + '- **tracking_stopped:** a shipment boolean. `true` means Terminal49 is no longer polling the shipping line; `false` means tracking remains active.', + '- **include:** related records to side-load, not a filter. Container includes include `shipment`, `pod_terminal`, and `transport_events`; transport events are the heaviest option.', '', - '### 2) Current position / what is container doing', - '- Question examples:', - ' - "What is container [X] doing?"', - ' - "What\'s going on with container [X]?"', - ' - "Where is it?"', - '- Primary tool: get_container_transport_events', - '- Input: include container UUID', - '- Output expectation: timeline + event_categories + milestones', + '## Lookup and carrier playbook', '', - '### 3) Discharge / pickup availability', - '- Question examples:', - ' - "Which containers have been discharged but not picked up?"', - ' - "Any holds on [X]?"', - '- Primary tools: list_containers or list_shipments then get_container', - '- Supported list filters: status, port, carrier, updated_after (no has_hold filter exists).', - '- "Discharged but not picked up" is derived client-side: keep rows where podDischargedAt is set and podFullOutAt is empty. Hold state comes from the holdsAtPodTerminal field on each row, not a filter.', + '1. For a container number, BOL, booking number, or customer reference, call `search_container`. Do not try to find an identifier by inventing a list filter.', + '2. Resolve a carrier name with `get_supported_shipping_lines` before any carrier-scoped call. Pass the returned SCAC; never pass `"Maersk"` or `"Ocean Network Express"` as a SCAC.', + '3. Use the Terminal49 UUID returned by search with `get_container` or `get_shipment_details`.', + '4. Use `get_container_transport_events` for the milestone timeline and `get_container_route` for multi-leg routing when available.', '', - '### 4) Arrival / ETAs / delays', - '- Question examples:', - ' - "When is [vessel] arriving?"', - ' - "What is arriving at LA this week?"', - ' - "When should I get to LA this week?"', - '- Primary tool: search_container then get_container for specific container', - '- Secondary: get_container_transport_events for delay context', + '## Honest list behavior', '', - '### 5) Shipment-level discovery', - '- Question examples:', - ' - "Show me everything on BL [X]"', - ' - "Show shipment [X] and all containers"', - '- Primary tool: search_container or get_shipment_details', - '- Output expectation: shipment-level identifiers and container list', + '- `list_containers` supports pagination and `include`; it has no server-side status, port, carrier, or updated-after filter.', + '- `list_shipments` supports pagination, `include`, exact original tracking-request `number` (normally a BOL or booking number, not a container number), and `tracking_stopped`.', + '- Requests such as "containers at USLAX", "Maersk fleet", or "recently updated containers/shipments" cannot currently be server-filtered with these list tools. Say so plainly; do not claim the returned page matches that scope.', + '- If a list result has a non-empty `unsupportedFilters` array, those filters were not applied. Treat the page as unscoped unless another supported filter was applied, and disclose the limitation.', + '- `include` only changes related data in each row. It never narrows the result set.', '', - '### 6) Demurrage monitoring', - '- Question examples:', - ' - "Do I have any containers with demurrage risk?"', - ' - "Which containers are at risk of LFD?"', - '- Primary tool: list_containers plus get_container', - '- Supported list filters: status, port, carrier, updated_after. The list endpoint has no server-side sort; order rows client-side by the pickupLfd field returned on each container, and surface holdsAtPodTerminal alongside it.', + '## Client-side operational analysis', '', - '## Output Formatting Guidance', + '- "Discharged but not picked up": inspect the returned page client-side; keep rows where `podDischargedAt` is set and `podFullOutAt` is empty.', + '- Holds: inspect `holdsAtPodTerminal`; there is no holds list filter.', + '- Last Free Day (LFD) / demurrage risk: order the returned page client-side by `pickupLfd` and show `holdsAtPodTerminal`. The list endpoint has no server-side LFD sort.', + '- These checks apply only to the page retrieved. Do not describe a page-level client-side selection as a complete account-wide result.', '', - '- Always return concise status first.', - '- Keep containers grouped by outcome state.', - '- 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.', - '', - '## Recommended follow-up tool calls', - '', - '1. Use search_container for any unrecognized identifier.', - '2. Resolve to a container UUID.', - '3. Use get_container for baseline state.', - '4. If timeline needed, follow with get_container_transport_events.', + 'Return concise status first. Call out holds explicitly. When dates are missing, explain that the feed is partial and suggest the event timeline as the next check.', '', ].join('\n'); } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index d9198443..a07acb45 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -95,6 +95,8 @@ Only track_container changes Terminal49 account records: it creates a tracking r 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. +Read terminal49://docs/mcp-query-guidance before answering scoped list questions. list_containers and list_shipments do not apply status, port, carrier, or updated_after filters; never claim that a returned page was filtered by one of them, and treat a non-empty unsupportedFilters array as an explicit warning that the page is unscoped by those inputs. + Tool results carry a _response_contract with presentation and follow-up hints; treat it as steering for you, not content to show the user.`; type ResponseDisplayColumn = { @@ -149,22 +151,15 @@ export const LIST_DISPLAY_COLUMNS_URI = listDisplayColumnsResource.uri; * 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'], + container: [], + shipment: ['number', 'tracking_stopped'], + tracking_request: ['status', 'request_type', 'filters'], + unknown: [], }; /** @@ -176,6 +171,7 @@ const SUPPORTED_LIST_FILTERS_BY_ENTITY: Record< const REAL_TRACKING_REQUEST_FILTER_KEYS = new Set([ 'filter[request_number]', 'filter[status]', + 'filter[request_type]', 'filter[scac]', 'filter[created_at][start]', 'filter[created_at][end]', @@ -932,6 +928,10 @@ export function buildListContract( const isFiltered = applied.length > 0; const supportedVocab = SUPPORTED_LIST_FILTERS_BY_ENTITY[entityType].join(', '); + const scopeRequirement = + supportedVocab.length > 0 + ? `a filter to scope this list (${supportedVocab})` + : 'no server-side scoping filters are available for this list; treat the returned page as unscoped'; const rawTotal = Number(result?.meta?.total); const hasTotal = Number.isFinite(rawTotal); @@ -949,11 +949,13 @@ export function buildListContract( const requiresMoreData: string[] = []; if (!isFiltered) { - requiresMoreData.push(`a filter to scope this list (${supportedVocab})`); + requiresMoreData.push(scopeRequirement); } if (dropped.length > 0) { requiresMoreData.push( - `unsupported filter(s) were ignored: ${dropped.join(', ')} — re-query using only ${supportedVocab}`, + supportedVocab.length > 0 + ? `unsupported filter(s) were ignored: ${dropped.join(', ')} — re-query using only ${supportedVocab}` + : `unsupported filter(s) were ignored: ${dropped.join(', ')} — this list has no server-side scoping filters`, ); } if (hasTotal && !totalIsReliable) { @@ -1204,10 +1206,9 @@ export function createTerminal49McpServer( { title: 'Search Containers', description: - 'Search for containers, shipments, and tracking information by container number, ' + - 'booking number, bill of lading, or reference number. ' + - 'This is the fastest way to find container information. ' + - 'Examples: CAIU2885402, MAEU123456789, or any reference number.', + 'Resolve a known container number, booking number, Bill of Lading (BOL), or customer reference into Terminal49 container and shipment UUIDs. ' + + 'Use this for identifier lookup; use list tools only to page through account records. ' + + 'A container number is an ISO 6346 equipment identifier (normally four letters plus seven digits, including the check digit); BOL and booking numbers identify shipments.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -1218,7 +1219,7 @@ export function createTerminal49McpServer( .string() .min(1) .describe( - 'Search query - can be a container number, booking number, BL number, or reference number', + 'Exact or partial identifier: ISO 6346 container number (for example CAIU1234567), shipment BOL, booking number, or customer reference. This is not a carrier, port, or free-text fleet filter.', ), intent: toolIntentSchema, }), @@ -1260,6 +1261,7 @@ export function createTerminal49McpServer( title: 'Track Container', description: 'Track a container, bill of lading, or booking number. ' + + 'Container numbers are ISO 6346 equipment identifiers; BOL and booking numbers identify shipments. ' + 'Uses inference to choose the carrier/type when possible, creates a tracking request, ' + 'and returns detailed container information.', annotations: { @@ -1272,7 +1274,9 @@ export function createTerminal49McpServer( number: z .string() .optional() - .describe('Container, bill of lading, or booking number to track'), + .describe( + 'ISO 6346 container number (normally four letters plus seven digits), Bill of Lading (BOL), or booking number to track', + ), numberType: z .string() .optional() @@ -1291,7 +1295,7 @@ export function createTerminal49McpServer( .string() .optional() .describe( - 'Optional SCAC code of the shipping line (e.g., MAEU for Maersk)', + 'Optional four-letter carrier SCAC (for example MAEU), never a carrier name. Resolve names such as Maersk or Ocean Network Express with get_supported_shipping_lines first.', ), refNumbers: z .array(z.string()) @@ -1464,7 +1468,7 @@ export function createTerminal49McpServer( description: 'Get list of shipping lines (carriers) supported by Terminal49 for container tracking. ' + 'Returns SCAC codes, full names, and common abbreviations. ' + - 'Use this when user asks which carriers are supported or to validate a carrier name.', + 'Use this to resolve a name such as Maersk or Ocean Network Express to its four-letter SCAC before any carrier-scoped call; never pass the carrier name as a SCAC.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -1474,7 +1478,9 @@ export function createTerminal49McpServer( search: z .string() .optional() - .describe('Optional: Filter by carrier name or SCAC code'), + .describe( + 'Carrier name, abbreviation, or four-letter SCAC to resolve (for example Maersk or MAEU)', + ), intent: toolIntentSchema, }), outputSchema: z.object({ @@ -1595,21 +1601,32 @@ export function createTerminal49McpServer( { title: 'List Shipments', description: - 'List shipments with optional filters and pagination. ' + - 'Use for queries like "show recent shipments" or "shipments for a carrier".', + 'Page through account shipments, optionally filtering by the original tracking-request number or whether tracking is stopped. ' + + 'This tool cannot server-filter by status, POL/POD port, carrier SCAC/name, or update time. Use search_container for a known container, BOL, booking, or reference identifier.', annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false, }, inputSchema: z.object({ - status: z.string().optional().describe('Filter by shipment status'), - port: z.string().optional().describe('Filter by POD port LOCODE'), - carrier: z.string().optional().describe('Filter by shipping line SCAC'), - updated_after: z + number: z + .string() + .optional() + .describe( + 'Filter by the original tracking request number, normally a Bill of Lading (BOL) or booking number. This does not match ISO 6346 container numbers; use search_container for those.', + ), + tracking_stopped: z + .boolean() + .optional() + .describe( + 'Filter by tracking state: true means Terminal49 stopped polling the shipping line; false means tracking is active. Maps to filter[tracking_stopped].', + ), + include: z .string() .optional() - .describe('Filter by updated_at (ISO8601) >= value'), + .describe( + 'Comma-separated relationships to side-load: containers, pod_terminal, port_of_lading, port_of_discharge, destination, destination_terminal. POL is port of lading (origin); POD is port of discharge (destination). Include changes row shape, not result scope.', + ), include_containers: z .boolean() .optional() @@ -1644,26 +1661,19 @@ export function createTerminal49McpServer( { title: 'List Containers', description: - 'List containers with optional filters and pagination. ' + - 'Use for queries like "containers at port" or "latest updates".', + 'Page through account containers with optional related records. ' + + 'This endpoint has no server-side status, port, carrier, or update-time filters: a SCAC such as MAEU, UN/LOCODE such as USLAX (not "Los Angeles"), POL/POD, or date cannot scope this list. Use search_container for a known container, BOL, booking, or reference identifier.', annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false, }, inputSchema: z.object({ - status: z.string().optional().describe('Filter by container status'), - port: z.string().optional().describe('Filter by POD port LOCODE'), - carrier: z.string().optional().describe('Filter by shipping line SCAC'), - updated_after: z - .string() - .optional() - .describe('Filter by updated_at (ISO8601) >= value'), include: z .string() .optional() .describe( - 'Comma-separated include list (e.g., shipment,pod_terminal)', + 'Comma-separated relationships to side-load: shipment, pod_terminal, pickup_facility, transport_events. shipment adds BOL/booking and carrier context; pod_terminal adds POD terminal data; transport_events adds the heavy milestone timeline. Include changes row shape, not result scope.', ), page: listPageSchema, page_size: listPageSizeSchema, @@ -1698,7 +1708,7 @@ export function createTerminal49McpServer( title: 'List Tracking Requests', description: 'List tracking requests with optional filters and pagination. ' + - 'Useful for monitoring recent tracking activity.', + 'Use status or request_type for server-side filtering. Request type distinguishes an ISO 6346 container number from shipment-level BOL and booking identifiers.', annotations: { readOnlyHint: true, destructiveHint: false, @@ -1708,15 +1718,21 @@ export function createTerminal49McpServer( filters: z .record(z.string(), z.string()) .optional() - .describe('Raw query filters (e.g., filter[status]=succeeded)'), + .describe( + 'Advanced raw query filters. Prefer the typed status and request_type arguments. SCAC values must be four-letter codes resolved with get_supported_shipping_lines, not carrier names.', + ), status: z - .string() + .enum(['created', 'pending', 'failed']) .optional() - .describe('Filter by request status (mapped to filter[status])'), + .describe( + 'Tracking request status: created, pending, or failed. Maps to filter[status].', + ), request_type: z - .string() + .enum(['bill_of_lading', 'booking_number', 'container']) .optional() - .describe('Filter by request type (mapped to filter[request_type])'), + .describe( + 'Identifier type: bill_of_lading, booking_number, or container. Maps to filter[request_type].', + ), page: listPageSchema, page_size: listPageSizeSchema, intent: toolIntentSchema, diff --git a/packages/mcp/src/tools/contracts.test.ts b/packages/mcp/src/tools/contracts.test.ts index 53c5ca5f..c4ebefa3 100644 --- a/packages/mcp/src/tools/contracts.test.ts +++ b/packages/mcp/src/tools/contracts.test.ts @@ -1040,14 +1040,16 @@ describe('MCP tool contracts', () => { }); }); - it('list_shipments forwards filters and pagination to SDK', async () => { + it('list_shipments forwards supported filters, includes, and pagination to SDK', async () => { const list = vi.fn().mockResolvedValue({ items: [{ id: 'shipment-1' }] }); const client = asClient({ shipments: { list } }); const result = await executeListShipments( { - status: 'in_transit', - carrier: 'MAEU', + number: 'MAEU123456789', + tracking_stopped: false, + include: 'containers,pod_terminal', + include_containers: true, page: 2, page_size: 25, }, @@ -1056,24 +1058,22 @@ describe('MCP tool contracts', () => { expect(list).toHaveBeenCalledWith( { - status: 'in_transit', - port: undefined, - carrier: 'MAEU', - updatedAfter: undefined, - includeContainers: undefined, + number: 'MAEU123456789', + trackingStopped: false, + include: 'containers,pod_terminal', + includeContainers: true, }, { format: 'mapped', page: 2, pageSize: 25 }, ); expect(result.items).toHaveLength(1); }); - it('list_containers forwards filters and pagination to SDK', async () => { + it('list_containers forwards include and pagination to SDK', async () => { const list = vi.fn().mockResolvedValue({ items: [{ id: 'container-1' }] }); const client = asClient({ containers: { list } }); const result = await executeListContainers( { - status: 'available_for_pickup', include: 'shipment,pod_terminal', page: 1, page_size: 50, @@ -1083,10 +1083,6 @@ describe('MCP tool contracts', () => { expect(list).toHaveBeenCalledWith( { - status: 'available_for_pickup', - port: undefined, - carrier: undefined, - updatedAfter: undefined, include: ['shipment', 'pod_terminal'], }, { format: 'mapped', page: 1, pageSize: 50 }, @@ -1109,10 +1105,6 @@ describe('MCP tool contracts', () => { expect(list).toHaveBeenCalledWith( { - status: undefined, - port: undefined, - carrier: undefined, - updatedAfter: undefined, include: undefined, }, { format: 'mapped', page: 1, pageSize: 10 }, @@ -1129,7 +1121,7 @@ describe('MCP tool contracts', () => { const result = await executeListTrackingRequests( { filters: { 'filter[status]': 'failed' }, - status: 'succeeded', + status: 'created', page: 3, page_size: 10, }, @@ -1137,7 +1129,7 @@ describe('MCP tool contracts', () => { ); expect(list).toHaveBeenCalledWith( - { 'filter[status]': 'succeeded' }, + { 'filter[status]': 'created' }, { format: 'mapped', page: 3, pageSize: 10 }, ); expect(result.items).toHaveLength(1); @@ -1152,7 +1144,7 @@ describe('MCP tool contracts', () => { const result = await executeListTrackingRequests( { status: 'failed', - request_type: 'manual', + request_type: 'booking_number', }, client, ); @@ -1160,7 +1152,7 @@ describe('MCP tool contracts', () => { expect(list).toHaveBeenCalledWith( { 'filter[status]': 'failed', - 'filter[request_type]': 'manual', + 'filter[request_type]': 'booking_number', }, { format: 'mapped', page: undefined, pageSize: undefined }, ); @@ -1201,7 +1193,7 @@ describe('MCP tool contracts', () => { // 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)', + 'no server-side scoping filters are available for this list; treat the returned page as unscoped', ); }); @@ -1271,26 +1263,18 @@ describe('MCP tool contracts', () => { 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. + it('buildListContract treats request_type as a tracking-request filter', () => { const contract = buildListContract( - { items: [{ id: 't1' }, { id: 't2' }], meta: { total: 250000 } }, + { items: [{ id: 't1' }], meta: { total: 1 } }, 'tracking_request', - { filters: { request_type: 'manual' } }, + { filters: { request_type: 'booking_number' } }, ); - expect(contract.can_answer).not.toContain( + expect(contract.can_answer).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); + expect(contract.total_is_reliable).toBe(true); + expect(contract.dropped_filters).toBeUndefined(); }); it('buildListContract does not treat a raw filters bag of only non-filter knobs as scoped', () => { @@ -1323,11 +1307,11 @@ describe('MCP tool contracts', () => { expect(contract.presentation_guidance).toContain('empty_state'); }); - it('buildListContract reports which records match when a filter was applied', () => { + it('buildListContract reports which shipments match a supported filter', () => { const contract = buildListContract( - { items: [{ id: 'c1' }], meta: { total: 1 } }, - 'container', - { filters: { status: 'available_for_pickup' } }, + { items: [{ id: 's1' }], meta: { total: 1 } }, + 'shipment', + { filters: { tracking_stopped: false } }, ); expect(contract.can_answer).toContain( @@ -1335,21 +1319,24 @@ describe('MCP tool contracts', () => { ); }); - it('buildListContract echoes dropped/unsupported filters from the SDK', () => { + it('buildListContract reports unsupported container filters as dropped', () => { const contract = buildListContract( { items: [{ id: 'c1' }], meta: { total: 1 }, - unsupportedFilters: ['has_hold'], + unsupportedFilters: ['status'], }, 'container', - { filters: { status: 'available_for_pickup', has_hold: true } }, + { filters: { status: 'available_for_pickup' } }, ); - expect(contract.dropped_filters).toEqual(['has_hold']); + expect(contract.dropped_filters).toEqual(['status']); expect( - contract.requires_more_data.some((entry) => entry.includes('has_hold')), + contract.requires_more_data.some((entry) => entry.includes('status')), ).toBe(true); + expect(contract.can_answer).not.toContain( + 'which records match the applied filters', + ); }); it('buildListContract does not surface an implausibly large total as the worklist size', () => { @@ -1367,9 +1354,9 @@ describe('MCP tool contracts', () => { it('buildListContract trusts a plausible total when a filter is applied', () => { const contract = buildListContract( - { items: [{ id: 'c1' }], meta: { total: 12 } }, - 'container', - { filters: { carrier: 'MAEU' } }, + { items: [{ id: 's1' }], meta: { total: 12 } }, + 'shipment', + { filters: { number: 'MAEU123456789' } }, ); expect(contract.total_is_reliable).toBe(true); @@ -1379,7 +1366,7 @@ describe('MCP tool contracts', () => { const contract = buildListContract( { items: [{ id: 'c1' }], meta: { total: 1 } }, 'container', - { filters: { status: 'available_for_pickup' } }, + { filters: {} }, ); expect(contract.display).toBeDefined(); diff --git a/packages/mcp/src/tools/list-containers.ts b/packages/mcp/src/tools/list-containers.ts index 71e575af..5795a98c 100644 --- a/packages/mcp/src/tools/list-containers.ts +++ b/packages/mcp/src/tools/list-containers.ts @@ -7,10 +7,6 @@ import { Terminal49Client } from '@terminal49/sdk'; import { logMcpEvent } from '../logging.js'; export interface ListContainersArgs { - status?: string; - port?: string; - carrier?: string; - updated_after?: string; include?: string; page?: number; page_size?: number; @@ -26,10 +22,6 @@ export async function executeListContainers( event: 'tool.execute.start', tool: 'list_containers', filters: { - status: args.status, - port: args.port, - carrier: args.carrier, - updated_after: args.updated_after, include: include, }, page: args.page, @@ -40,10 +32,6 @@ export async function executeListContainers( try { const result = await client.containers.list( { - status: args.status, - port: args.port, - carrier: args.carrier, - updatedAfter: args.updated_after, include: include ? (include.split(',').map((s) => s.trim()) as any) : undefined, diff --git a/packages/mcp/src/tools/list-shipments.ts b/packages/mcp/src/tools/list-shipments.ts index 302c2290..882e8963 100644 --- a/packages/mcp/src/tools/list-shipments.ts +++ b/packages/mcp/src/tools/list-shipments.ts @@ -7,10 +7,9 @@ import { Terminal49Client } from '@terminal49/sdk'; import { logMcpEvent } from '../logging.js'; export interface ListShipmentsArgs { - status?: string; - port?: string; - carrier?: string; - updated_after?: string; + number?: string; + tracking_stopped?: boolean; + include?: string; include_containers?: boolean; page?: number; page_size?: number; @@ -25,10 +24,9 @@ export async function executeListShipments( event: 'tool.execute.start', tool: 'list_shipments', filters: { - status: args.status, - port: args.port, - carrier: args.carrier, - updated_after: args.updated_after, + number: args.number, + tracking_stopped: args.tracking_stopped, + include: args.include, include_containers: args.include_containers, }, page: args.page, @@ -39,10 +37,9 @@ export async function executeListShipments( try { const result = await client.shipments.list( { - status: args.status, - port: args.port, - carrier: args.carrier, - updatedAfter: args.updated_after, + number: args.number, + trackingStopped: args.tracking_stopped, + include: args.include, includeContainers: args.include_containers, }, { From fd25778d093b43aa05e05a2965a62a4cc415ec2b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 06:37:39 +0000 Subject: [PATCH 3/3] fix(mcp): mark partially scoped lists unscoped Co-authored-by: Akshay Dodeja --- packages/mcp/src/resources/query-guidance.ts | 2 +- packages/mcp/src/server.ts | 7 +++++-- packages/mcp/src/tools/contracts.test.ts | 21 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/resources/query-guidance.ts b/packages/mcp/src/resources/query-guidance.ts index fb32d601..41f99ab9 100644 --- a/packages/mcp/src/resources/query-guidance.ts +++ b/packages/mcp/src/resources/query-guidance.ts @@ -38,7 +38,7 @@ export function readQueryGuidanceResource(): string { '- `list_containers` supports pagination and `include`; it has no server-side status, port, carrier, or updated-after filter.', '- `list_shipments` supports pagination, `include`, exact original tracking-request `number` (normally a BOL or booking number, not a container number), and `tracking_stopped`.', '- Requests such as "containers at USLAX", "Maersk fleet", or "recently updated containers/shipments" cannot currently be server-filtered with these list tools. Say so plainly; do not claim the returned page matches that scope.', - '- If a list result has a non-empty `unsupportedFilters` array, those filters were not applied. Treat the page as unscoped unless another supported filter was applied, and disclose the limitation.', + '- If a list result has a non-empty `unsupportedFilters` array, those filters were not applied. Treat the page as unscoped and disclose the limitation.', '- `include` only changes related data in each row. It never narrows the result set.', '', '## Client-side operational analysis', diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index a07acb45..1757712f 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -95,7 +95,7 @@ Only track_container changes Terminal49 account records: it creates a tracking r 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. -Read terminal49://docs/mcp-query-guidance before answering scoped list questions. list_containers and list_shipments do not apply status, port, carrier, or updated_after filters; never claim that a returned page was filtered by one of them, and treat a non-empty unsupportedFilters array as an explicit warning that the page is unscoped by those inputs. +Read terminal49://docs/mcp-query-guidance before answering scoped list questions. list_containers and list_shipments do not apply status, port, carrier, or updated_after filters; never claim that a returned page was filtered by one of them, and treat any non-empty unsupportedFilters array as an explicit warning that the page is unscoped. Tool results carry a _response_contract with presentation and follow-up hints; treat it as steering for you, not content to show the user.`; @@ -925,7 +925,10 @@ export function buildListContract( requestContext.unsupportedFilters, entityType, ); - const isFiltered = applied.length > 0; + // A partially applied request is not the worklist the caller asked for. + // Even when one supported filter was applied, any dropped filter makes the + // page unscoped for presentation purposes. + const isFiltered = applied.length > 0 && dropped.length === 0; const supportedVocab = SUPPORTED_LIST_FILTERS_BY_ENTITY[entityType].join(', '); const scopeRequirement = diff --git a/packages/mcp/src/tools/contracts.test.ts b/packages/mcp/src/tools/contracts.test.ts index c4ebefa3..48bed829 100644 --- a/packages/mcp/src/tools/contracts.test.ts +++ b/packages/mcp/src/tools/contracts.test.ts @@ -1339,6 +1339,27 @@ describe('MCP tool contracts', () => { ); }); + it('buildListContract treats partially applied filters as unscoped', () => { + const contract = buildListContract( + { + items: [{ id: 's1' }], + meta: { total: 1 }, + unsupportedFilters: ['carrier'], + }, + 'shipment', + { + filters: { number: 'MAEU123456789', carrier: 'MAEU' }, + unsupportedFilters: ['carrier'], + }, + ); + + expect(contract.dropped_filters).toEqual(['carrier']); + expect(contract.can_answer).not.toContain( + 'which records match the applied filters', + ); + expect(contract.presentation_guidance).toContain('results are unscoped'); + }); + it('buildListContract does not surface an implausibly large total as the worklist size', () => { const contract = buildListContract( { items: [{ id: 'c1' }, { id: 'c2' }], meta: { total: 250000 } },