diff --git a/packages/mcp/README.md b/packages/mcp/README.md index b13e7ce0..729c42e8 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -23,24 +23,39 @@ | **`track_container`** | Create tracking request and get container data | SCAC autocomplete ✨ | | **`get_container`** | Get detailed container info with flexible data loading | Progressive loading | | **`get_shipment_details`** | Get shipment routing, BOL, containers, ports | Full shipment context | -| **`get_container_transport_events`** | Get event timeline with ResourceLinks | 50-70% context reduction ✨ | +| **`get_container_transport_events`** | Get full event timeline for a container | Milestone history | | **`get_supported_shipping_lines`** | List 40+ major carriers with SCAC codes | Filterable by name/code | | **`get_container_route`** | Get multi-leg routing with vessels and ETAs | Premium feature | | **`list_shipments`** | List shipments with filters and pagination | Fleet-level visibility | -| **`list_containers`** | List containers with filters and pagination | Operational snapshots | +| **`list_containers`** | List containers with filters and pagination | Returns ResourceLinks ✨ | | **`list_tracking_requests`** | List tracking requests and statuses | Audit and monitoring | ### 🎯 Prompts (3 Workflows) | Prompt | Description | Use Case | |--------|-------------|----------| -| **`track-shipment`** | Track container with optional carrier | Quick tracking start | +| **`track-shipment`** | Track container with optional carrier | Quick tracking start (SCAC autocomplete ✨) | | **`check-demurrage`** | Analyze demurrage/detention risk | LFD calculations | | **`analyze-delays`** | Identify delays and root causes | Timeline analysis | +The `track-shipment` prompt's `carrier` argument supports **MCP completions** (`completion/complete`): suggestions are sourced live from `get_supported_shipping_lines`, filtered by what the user has typed, and returned as SCAC codes. + ### 📚 Resources - ✅ **`terminal49://docs/milestone-glossary`** - Complete milestone reference guide -- ✅ **`terminal49://container/{id}`** - Dynamic container data access +- ✅ **`terminal49://docs/mcp-query-guidance`** - Internal tool-routing hints +- ✅ **`terminal49://container/{id}`** - Dynamic container data access (also the target of `list_containers` ResourceLinks) + +### 🧭 Server Instructions + +The server advertises MCP `instructions` (a server-level operating guide) at initialize time: it explains the ocean container/shipment tracking domain, key vocabulary (SCAC, BOL/booking, POL/POD, LFD, demurrage, holds), the read tools vs the single write tool (`track_container`), and the canonical tool-chaining order. + +### 🔗 ResourceLinks (context reduction) + +`list_containers` returns MCP `resource_link` content blocks — one per container row — that point at the registered `terminal49://container/{id}` resource. Clients can render the compact list and resolve full per-container detail on demand via `resources/read`, instead of paying for every container's full payload up front. + +### 🙈 Content Audience Annotations + +Tool results separate the human-readable answer from agent-steering metadata. The steering block (presentation guidance + suggested follow-up tools, derived from the `_response_contract`) is tagged with `annotations: { audience: ['assistant'] }` so spec-aware clients can hide it from end users, while the answer block stays user-visible. ### ✨ Current Features (v1.0.0 - Phase 1 & 2.1 Complete) @@ -65,9 +80,15 @@ - `check-demurrage`: Demurrage/detention risk analysis - `analyze-delays`: Journey delay identification and root cause -#### 🚧 Coming Soon (Phase 2.2) -- **SCAC code completions**: Autocomplete carrier codes as you type -- **Resource Links**: Return event summaries + links for large datasets +#### ✅ MCP Spec Adherence +- **Server instructions**: server-level operating guide advertised at initialize +- **SCAC code completions**: `completion/complete` on the `track-shipment` prompt's `carrier` arg, sourced from `get_supported_shipping_lines` +- **ResourceLinks**: `list_containers` rows link to the `terminal49://container/{id}` resource +- **Content audience annotations**: steering metadata tagged `audience: ['assistant']` + +#### 🚧 Deferred (future RFC — needs a stateful transport) +- Resource subscriptions, progress notifications, server logging, elicitation, and sampling all require a stateful (non-stateless-HTTP) transport and are out of scope here. +- Shipment ResourceLinks await a dedicated `terminal49://shipment/{id}` resource template (only the container template is currently registered). --- diff --git a/packages/mcp/src/mcp.test.ts b/packages/mcp/src/mcp.test.ts index e691c89e..de808797 100644 --- a/packages/mcp/src/mcp.test.ts +++ b/packages/mcp/src/mcp.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it, vi } from 'vitest'; -import { buildListContract, createTerminal49McpServer } from './server.js'; +import { getCompleter } from '@modelcontextprotocol/sdk/server/completable.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildListContract, + createTerminal49McpServer, + TERMINAL49_SERVER_INSTRUCTIONS, +} from './server.js'; vi.mock('@sentry/node', () => ({ captureException: vi.fn(), @@ -8,6 +13,37 @@ vi.mock('@sentry/node', () => ({ wrapMcpServerWithSentry: vi.fn((server) => server), })); +// 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(() => ({ + shippingLinesList: vi.fn(), + containersList: vi.fn(), +})); + +vi.mock('@terminal49/sdk', () => ({ + Terminal49Client: class Terminal49Client { + shippingLines = { list: shippingLinesList }; + containers = { list: containersList }; + }, + FeatureNotEnabledError: class FeatureNotEnabledError extends Error {}, + NotFoundError: class NotFoundError extends Error {}, +})); + +beforeEach(() => { + shippingLinesList.mockReset(); + containersList.mockReset(); +}); + +// The SDK stores prompt arg schemas as a Zod object. Zod v3 exposes `.shape`; +// Zod v4 keeps it on `_zod.def.shape`. Read it the way the SDK does so the +// completion test stays version-robust. +function getArgShape(argsSchema: any): Record { + const v4Shape = argsSchema?._zod?.def?.shape; + const v3Shape = argsSchema?.shape; + return (v4Shape ?? v3Shape) as Record; +} + function _hasResponseContract(schema: unknown): boolean { const typedSchema = schema as { _def?: { @@ -46,14 +82,19 @@ function _hasResponseContract(schema: unknown): boolean { (shape && typeof shape === 'object' && Object.hasOwn(shape as object, '_response_contract')) || - (def.properties && - typeof def.properties === 'object' && - Object.hasOwn(def.properties as object, '_response_contract')), + (def.properties && + typeof def.properties === 'object' && + Object.hasOwn(def.properties as object, '_response_contract')), ); } case 'ZodUnion': case 'union': - return Array.isArray(def.options) && (def.options as unknown[]).some((option) => _hasResponseContract(option)); + return ( + Array.isArray(def.options) && + (def.options as unknown[]).some((option) => + _hasResponseContract(option), + ) + ); case 'ZodOptional': case 'ZodNullable': case 'ZodDefault': @@ -104,7 +145,10 @@ function _hasDisplayHintsInResponseContract(schema: unknown): boolean { case 'ZodObject': case 'object': { const shape = typeof def.shape === 'function' ? def.shape() : def.shape; - const shapeRecord = shape && typeof shape === 'object' ? (shape as Record) : null; + const shapeRecord = + shape && typeof shape === 'object' + ? (shape as Record) + : null; if (shapeRecord && Object.hasOwn(shapeRecord, '_response_contract')) { return _schemaHasDisplay(shapeRecord._response_contract); } @@ -123,7 +167,9 @@ function _hasDisplayHintsInResponseContract(schema: unknown): boolean { case 'union': return ( Array.isArray(def.options) && - (def.options as unknown[]).some((option) => _hasDisplayHintsInResponseContract(option)) + (def.options as unknown[]).some((option) => + _hasDisplayHintsInResponseContract(option), + ) ); case 'ZodOptional': case 'ZodNullable': @@ -133,12 +179,16 @@ function _hasDisplayHintsInResponseContract(schema: unknown): boolean { case 'nullable': case 'default': case 'catch': - return _hasDisplayHintsInResponseContract((def as { innerType?: unknown }).innerType); + return _hasDisplayHintsInResponseContract( + (def as { innerType?: unknown }).innerType, + ); case 'ZodEffects': case 'ZodTransform': case 'effects': case 'transform': - return _hasDisplayHintsInResponseContract((def as { schema?: unknown }).schema); + return _hasDisplayHintsInResponseContract( + (def as { schema?: unknown }).schema, + ); default: return false; } @@ -174,19 +224,26 @@ function _schemaHasDisplay(schema: unknown): boolean { case 'ZodObject': case 'object': { const shape = typeof def.shape === 'function' ? def.shape() : def.shape; - if (shape && typeof shape === 'object' && Object.hasOwn(shape as object, 'display')) { + if ( + shape && + typeof shape === 'object' && + Object.hasOwn(shape as object, 'display') + ) { return true; } return Boolean( def.properties && - typeof def.properties === 'object' && - Object.hasOwn(def.properties as object, 'display'), + typeof def.properties === 'object' && + Object.hasOwn(def.properties as object, 'display'), ); } case 'ZodUnion': case 'union': - return Array.isArray(def.options) && (def.options as unknown[]).some((option) => _schemaHasDisplay(option)); + return ( + Array.isArray(def.options) && + (def.options as unknown[]).some((option) => _schemaHasDisplay(option)) + ); case 'ZodOptional': case 'ZodNullable': case 'ZodDefault': @@ -233,10 +290,12 @@ function _objectSchemaHasProperty(schema: unknown, property: string): boolean { const shape = typeof def.shape === 'function' ? def.shape() : def.shape; return Boolean( - (shape && typeof shape === 'object' && Object.hasOwn(shape as object, property)) || - (def.properties && - typeof def.properties === 'object' && - Object.hasOwn(def.properties as object, property)), + (shape && + typeof shape === 'object' && + Object.hasOwn(shape as object, property)) || + (def.properties && + typeof def.properties === 'object' && + Object.hasOwn(def.properties as object, property)), ); } @@ -260,7 +319,9 @@ describe('MCP server wiring', () => { const server = createTerminal49McpServer('token'); const tools = Object.keys((server as any)._registeredTools || {}); const resources = Object.keys((server as any)._registeredResources || {}); - const resourceTemplates = Object.keys((server as any)._registeredResourceTemplates || {}); + const resourceTemplates = Object.keys( + (server as any)._registeredResourceTemplates || {}, + ); const prompts = Object.keys((server as any)._registeredPrompts || {}); expect(tools).toHaveLength(10); @@ -295,11 +356,17 @@ describe('MCP server wiring', () => { >; for (const [name, tool] of Object.entries(tools)) { - expect(_objectSchemaHasProperty(tool.inputSchema, 'intent'), name).toBe(true); + expect(_objectSchemaHasProperty(tool.inputSchema, 'intent'), name).toBe( + true, + ); } expect(() => - (tools.get_supported_shipping_lines.inputSchema as { parse: (value: unknown) => unknown }).parse({ + ( + tools.get_supported_shipping_lines.inputSchema as { + parse: (value: unknown) => unknown; + } + ).parse({ intent: 'validate carrier before creating a tracking request', }), ).not.toThrow(); @@ -339,7 +406,11 @@ describe('MCP server wiring', () => { { outputSchema: { def: { shape: Record } } } >; - const listTools = ['list_shipments', 'list_containers', 'list_tracking_requests']; + const listTools = [ + 'list_shipments', + 'list_containers', + 'list_tracking_requests', + ]; for (const name of listTools) { const outputSchema = tools[name]?.outputSchema; @@ -390,4 +461,150 @@ describe('MCP server wiring', () => { expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); expect(Sentry.flush).toHaveBeenCalledWith(2000); }); + + // ==================== MCP spec-adherence features ==================== + + it('exposes server-level instructions to the LLM', () => { + const server = createTerminal49McpServer('token'); + + // Instructions are stored on the underlying Server and emitted in the + // initialize result (MCP ServerOptions.instructions). + const instructions = (server as any).server._instructions as string; + + expect(typeof instructions).toBe('string'); + expect(instructions).toBe(TERMINAL49_SERVER_INSTRUCTIONS); + // Sanity-check the guide actually covers the domain + chaining the task asks for. + expect(instructions).toMatch(/SCAC/); + expect(instructions).toMatch(/LFD/); + expect(instructions).toMatch(/search_container/); + expect(instructions).toMatch(/track_container/); + expect(instructions.length).toBeGreaterThan(400); + }); + + it('registers the completions capability so carrier SCAC completion is reachable', async () => { + shippingLinesList.mockResolvedValue([ + { scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' }, + { + scac: 'MSCU', + name: 'Mediterranean Shipping Company', + shortName: 'MSC', + }, + ]); + + const server = createTerminal49McpServer('token'); + + // PRIMARY (registered-path) assertion. This is the actual HIGH-finding + // fix: `completable` must wrap the INNER string with `.optional()` applied + // AFTER, because the SDK unwraps ZodOptional before checking isCompletable + // when deciding whether to advertise `completions` and register a + // completion handler. With the previous OUTER-optional wiring the symbol + // sat on the ZodOptional, the SDK's unwrap missed it, and the capability + // was NEVER advertised — so these two assertions FAIL against the pre-fix + // wiring and PASS only once Fix 1 is applied. + const capabilities = (server as any).server.getCapabilities(); + expect(capabilities.completions).toBeDefined(); + expect((server as any)._completionHandlerInitialized).toBe(true); + + // SECONDARY (unit) assertion on the completion VALUES. We resolve the + // completer exactly the way the SDK's prompt registration does — unwrap + // the ZodOptional and read isCompletable/getCompleter off the inner + // string — then exercise it. (The SDK's prompt-completion *handler* checks + // isCompletable on the un-unwrapped optional field, so values are surfaced + // here via the same inner-string the registration keys off, rather than + // through handlePromptCompletion.) + const prompt = (server as any)._registeredPrompts['track-shipment']; + const carrierField = getArgShape(prompt.argsSchema).carrier as { + _def?: { innerType?: unknown }; + }; + // Symbol lives on the inner string, not the outer ZodOptional. + expect(getCompleter(carrierField as any)).toBeUndefined(); + const innerCompleter = getCompleter(carrierField._def?.innerType as any); + expect(innerCompleter).toBeTypeOf('function'); + + // "m" matches Maersk (MAEU) and Mediterranean (MSCU); "ma" matches only Maersk. + expect(await innerCompleter!('m', undefined)).toEqual(['MAEU', 'MSCU']); + expect(await innerCompleter!('ma', undefined)).toEqual(['MAEU']); + + // The completer reused the live supported-lines lookup, filtered by input. + expect(shippingLinesList).toHaveBeenCalled(); + }); + + it('carrier completion degrades to empty suggestions when the API errors', async () => { + shippingLinesList.mockRejectedValue(new Error('upstream unavailable')); + + const server = createTerminal49McpServer('token'); + const prompt = (server as any)._registeredPrompts['track-shipment']; + // Resolve the completer off the inner string (the ZodOptional wraps it), + // matching how the SDK keys completion off the unwrapped inner schema. + const carrierField = getArgShape(prompt.argsSchema).carrier as { + _def?: { innerType?: unknown }; + }; + const completer = getCompleter(carrierField._def?.innerType as any); + + await expect(completer!('ma', undefined)).resolves.toEqual([]); + }); + + it('list_containers result includes resource_link blocks with valid container URIs', async () => { + containersList.mockResolvedValue({ + items: [ + { id: '11111111-1111-1111-1111-111111111111', number: 'CAIU1234567' }, + { id: '22222222-2222-2222-2222-222222222222', number: 'MSCU7654321' }, + ], + links: {}, + meta: {}, + }); + + const server = createTerminal49McpServer('token'); + const result = await ( + server as any + )._registeredTools.list_containers.handler({}); + + const resourceLinks = result.content.filter( + (block: any) => block.type === 'resource_link', + ); + + expect(resourceLinks).toHaveLength(2); + expect(resourceLinks[0]).toMatchObject({ + type: 'resource_link', + uri: 'terminal49://container/11111111-1111-1111-1111-111111111111', + name: 'Container CAIU1234567', + }); + expect(resourceLinks[1].uri).toBe( + 'terminal49://container/22222222-2222-2222-2222-222222222222', + ); + // URIs match the registered container resource template prefix. + for (const link of resourceLinks) { + expect(link.uri).toMatch(/^terminal49:\/\/container\/[0-9a-f-]{36}$/); + } + }); + + it('marks steering-only content with audience:[assistant] and keeps the answer user-visible', async () => { + containersList.mockResolvedValue({ items: [], links: {}, meta: {} }); + + const server = createTerminal49McpServer('token'); + const result = await ( + server as any + )._registeredTools.list_containers.handler({}); + + const steeringBlocks = result.content.filter( + (block: any) => + block.type === 'text' && + block.annotations?.audience?.length === 1 && + 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'); + + // The first (answer) block is NOT annotated assistant-only, so it stays + // visible to end users. + const answerBlock = result.content[0]; + expect(answerBlock.type).toBe('text'); + const answerAudience = answerBlock.annotations?.audience; + expect( + answerAudience === undefined || answerAudience.includes('user'), + ).toBe(true); + }); }); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 666ef3c5..a6b7b3ac 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -4,6 +4,7 @@ */ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { completable } from '@modelcontextprotocol/sdk/server/completable.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import { Terminal49Client } from '@terminal49/sdk'; @@ -22,7 +23,58 @@ import { readMilestoneGlossaryResource } from './resources/milestone-glossary.js import { queryGuidanceResource, readQueryGuidanceResource } from './resources/query-guidance.js'; import { captureMcpException, flushMcpEvents, instrumentMcpServer } from './sentry.js'; -type ToolContent = { type: 'text'; text: string }; +/** + * 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; +}; + +type TextContent = { + type: 'text'; + text: string; + annotations?: ContentAnnotations; +}; + +type ResourceLinkContent = { + type: 'resource_link'; + uri: string; + name: string; + description?: string; + mimeType?: string; + annotations?: ContentAnnotations; +}; + +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 + * understands the ocean-tracking domain and how to chain the tools. + */ +export const TERMINAL49_SERVER_INSTRUCTIONS = `Terminal49 tracks ocean containers and shipments live from carriers and terminals. Data is real-time from ocean carriers (by SCAC, e.g. MAEU = Maersk) and US/Canada terminals, so values change between calls. + +Domain vocabulary: SCAC = 4-letter carrier code; BOL = bill of lading and booking number identify a shipment; POL/POD = port of lading/discharge; LFD = last free day (pickup deadline before demurrage accrues); demurrage/detention = late fees; holds = customs/freight/terminal blocks preventing pickup; transport events = carrier milestones (vessel loaded, departed, arrived, discharged, rail, delivered). + +Tools are read-only EXCEPT track_container, the single write tool: it creates a tracking request to begin monitoring a number. Everything else only reads. + +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.`; type ResponseDisplayColumn = { key: string; @@ -580,19 +632,107 @@ 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 + * links the client can resolve on demand (resources/read), reducing context. + */ +const CONTAINER_RESOURCE_URI_PREFIX = 'terminal49://container/'; + +function buildContainerResourceLink( + item: Record, +): ResourceLinkContent | undefined { + const id = typeof item.id === 'string' ? item.id : undefined; + if (!id) { + return undefined; + } + const number = typeof item.number === 'string' ? item.number : undefined; + return { + type: 'resource_link', + uri: `${CONTAINER_RESOURCE_URI_PREFIX}${id}`, + name: number ? `Container ${number}` : `Container ${id}`, + description: + 'Compact container summary (status, milestones, holds, LFD) resolvable via resources/read.', + mimeType: 'text/markdown', + annotations: { audience: ['user', 'assistant'] }, + }; +} + +/** + * Builds resource_link blocks for a list result, pointing each row at its + * registered resource URI. Currently scoped to the container resource template, + * which is the registered, resolvable surface (see DEFERRED note in PR/README + * for shipment links, which need a shipment resource template first). + */ +function buildListResourceLinks( + result: unknown, + entityType: ListEntityType, +): ResourceLinkContent[] { + if (entityType !== 'container') { + return []; + } + const items = Array.isArray((result as any)?.items) ? (result as any).items : []; + const links: ResourceLinkContent[] = []; + for (const item of items) { + const link = buildContainerResourceLink(asRecord(item)); + if (link) { + links.push(link); + } + } + return links; +} + function wrapToolWithContract( handler: (args: TArgs) => Promise, buildContract?: (result: unknown, args: TArgs) => ResponseContract, + buildResourceLinks?: (result: unknown, args: TArgs) => ResourceLinkContent[], ): (args: TArgs) => Promise<{ content: ToolContent[]; structuredContent?: any; isError?: boolean }> { return async (args: TArgs) => { try { const result = await handler(args); - const structuredContent = buildContract - ? attachResponseContract(result, buildContract(result, args)) + const contract = buildContract ? buildContract(result, args) : undefined; + const structuredContent = contract + ? attachResponseContract(result, contract) : result; + const content: ToolContent[] = buildContentPayload(result); + + 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)); + } + return { - content: buildContentPayload(result), + content, structuredContent, }; } catch (error) { @@ -607,6 +747,30 @@ function wrapToolWithContract( }; } +/** + * 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 + * user has typed (matching SCAC, name, or short name), and returns SCAC codes + * as completion candidates (most clients send the SCAC to track_container). + * + * The MCP completion spec caps suggestions at 100; we trim to a usable slice. + * Any error (e.g. live API hiccup) degrades gracefully to no suggestions rather + * than failing the completion request. + */ +function createCarrierScacCompleter( + client: Terminal49Client, +): (value: string | undefined) => Promise { + return async (value: string | undefined): Promise => { + try { + const search = typeof value === 'string' ? value.trim() : ''; + const { shipping_lines } = await executeGetSupportedShippingLines({ search }, client); + return shipping_lines.slice(0, 100).map((line) => line.scac); + } catch { + return []; + } + }; +} + export function createTerminal49McpServer( apiToken: string, apiBaseUrl?: string, @@ -614,11 +778,18 @@ export function createTerminal49McpServer( ): McpServer { const client = new Terminal49Client({ apiToken, apiBaseUrl, accountId, defaultFormat: "mapped" }); + const completeCarrierScac = createCarrierScacCompleter(client); + const server = instrumentMcpServer( - new McpServer({ - name: 'terminal49-mcp', - version: '1.0.0', - }), + new McpServer( + { + name: 'terminal49-mcp', + version: '1.0.0', + }, + { + instructions: TERMINAL49_SERVER_INSTRUCTIONS, + }, + ), ); // ==================== TOOLS ==================== @@ -974,6 +1145,10 @@ export function createTerminal49McpServer( wrapToolWithContract( async (args) => executeListContainers(args, client), (result) => buildListContract(result as any, 'container'), + // ResourceLinks: each container row becomes a compact link to the + // registered terminal49://container/{id} resource, so the client can + // resolve full details on demand instead of paying for them up front. + (result) => buildListResourceLinks(result, 'container'), ) ); @@ -1021,7 +1196,14 @@ export function createTerminal49McpServer( description: 'Quick container tracking workflow with carrier autocomplete', argsSchema: { container_number: z.string().describe('Container number (e.g., CAIU1234567)'), - carrier: z.string().optional().describe('Shipping line SCAC code (e.g., MAEU for Maersk)'), + // Autocompletes from the live supported-carrier list (SCAC codes). + // `completable` must wrap the INNER string so the MCP SDK (which + // unwraps ZodOptional before checking isCompletable) advertises the + // `completions` capability; `.optional()` is applied AFTER. + carrier: completable( + z.string().describe('Shipping line SCAC code (e.g., MAEU for Maersk)'), + completeCarrierScac, + ).optional(), }, }, async ({ container_number, carrier }) => ({