From bb99fe27ce4b3a6f37c3c2d9fe6cc39231a0bdbc Mon Sep 17 00:00:00 2001 From: Akshay Dodeja Date: Fri, 26 Jun 2026 05:36:42 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(mcp):=20MCP=20spec-adherence=20?= =?UTF-8?q?=E2=80=94=20server=20instructions,=20completions,=20resource=20?= =?UTF-8?q?links,=20content=20audience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additively bring the Terminal49 MCP closer to the MCP spec, matching the existing stateless Streamable-HTTP server style. Four features: 1. Server instructions — createTerminal49McpServer now passes a second ServerOptions arg with `instructions` (TERMINAL49_SERVER_INSTRUCTIONS): a concise operating guide covering the ocean container/shipment domain, key vocab (SCAC, BOL/booking, POL/POD, LFD, demurrage, holds, transport events), the read tools vs the single write tool (track_container), and the canonical chaining order. Advertised to the client at initialize. 2. Completions — the `track-shipment` prompt's `carrier` arg is wrapped with the SDK's completable(); the completer is sourced live from get_supported_shipping_lines, filtered by the partial value, returning SCAC codes. Degrades to no suggestions on API error. 3. ResourceLinks — list_containers now appends MCP `resource_link` content blocks (one per row) pointing at the registered terminal49://container/{id} resource, so clients can resolve full detail on demand instead of paying for it up front. README ResourceLinks claim updated to match reality. 4. Content audience annotations — steering-only payload (presentation guidance + suggested follow-ups derived from _response_contract) is emitted as a discrete text block annotated { audience: ['assistant'] } so clients can hide it from end users; the answer block stays unannotated/user-visible. Out of scope (stateless transport): resource subscriptions, progress, logging, elicitation, sampling — tracked for a future RFC. Tests: assert the server exposes `instructions`; the carrier completer returns the expected filtered SCAC values (and empty on error); list_containers results carry resource_link blocks with valid container URIs; the steering block carries audience:['assistant']. Green gate: SDK 51 pass/2 skip, MCP 82 pass; both type-check + build clean; changed files oxfmt/oxlint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp/README.md | 35 +- packages/mcp/src/mcp.test.ts | 240 ++++++++++- packages/mcp/src/server.ts | 760 ++++++++++++++++++++++++++++------- 3 files changed, 859 insertions(+), 176 deletions(-) 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..c5771bf3 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,129 @@ 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('wires carrier SCAC completion on the track-shipment prompt', async () => { + shippingLinesList.mockResolvedValue([ + { scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' }, + { + scac: 'MSCU', + name: 'Mediterranean Shipping Company', + shortName: 'MSC', + }, + ]); + + const server = createTerminal49McpServer('token'); + const prompt = (server as any)._registeredPrompts['track-shipment']; + + // The SDK stores the prompt args as a Zod object; pull the completable + // `carrier` field and run its completer the same way the SDK would. + const carrierField = getArgShape(prompt.argsSchema).carrier; + const completer = getCompleter(carrierField as any); + expect(completer).toBeTypeOf('function'); + + // "m" matches Maersk (MAEU) and Mediterranean (MSCU); "ma" matches only Maersk. + const broad = await completer!('m', undefined); + expect(broad).toEqual(['MAEU', 'MSCU']); + + const narrow = await completer!('ma', undefined); + expect(narrow).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']; + const completer = getCompleter( + getArgShape(prompt.argsSchema).carrier 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..f062167e 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -3,7 +3,11 @@ * Implementation using @modelcontextprotocol/sdk with McpServer API */ -import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +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'; @@ -13,16 +17,77 @@ import { executeSearchContainer } from './tools/search-container.js'; import { executeGetShipmentDetails } from './tools/get-shipment-details.js'; import { executeGetContainerTransportEvents } from './tools/get-container-transport-events.js'; import { executeGetSupportedShippingLines } from './tools/get-supported-shipping-lines.js'; -import { executeGetContainerRoute, type FeatureNotEnabledResult } from './tools/get-container-route.js'; +import { + executeGetContainerRoute, + type FeatureNotEnabledResult, +} from './tools/get-container-route.js'; import { executeListShipments } from './tools/list-shipments.js'; import { executeListContainers } from './tools/list-containers.js'; import { executeListTrackingRequests } from './tools/list-tracking-requests.js'; import { readContainerResource } from './resources/container.js'; import { readMilestoneGlossaryResource } from './resources/milestone-glossary.js'; -import { queryGuidanceResource, readQueryGuidanceResource } from './resources/query-guidance.js'; -import { captureMcpException, flushMcpEvents, instrumentMcpServer } from './sentry.js'; +import { + queryGuidanceResource, + readQueryGuidanceResource, +} from './resources/query-guidance.js'; +import { + 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; @@ -81,7 +146,9 @@ function buildContentPayload(result: unknown): ToolContent[] { if (hasMetadataError(result)) { const metadata = (result as any)._metadata; - const remediation = metadata.remediation ? `\n\nRemediation: ${metadata.remediation}` : ''; + const remediation = metadata.remediation + ? `\n\nRemediation: ${metadata.remediation}` + : ''; return [ { type: 'text', @@ -101,16 +168,20 @@ function formatAsText(result: unknown): string { } } -function isFeatureNotEnabledResult(result: unknown): result is FeatureNotEnabledResult { +function isFeatureNotEnabledResult( + result: unknown, +): result is FeatureNotEnabledResult { return Boolean( result && - typeof result === 'object' && - (result as any).error === 'FeatureNotEnabled' && - typeof (result as any).message === 'string' + typeof result === 'object' && + (result as any).error === 'FeatureNotEnabled' && + typeof (result as any).message === 'string', ); } -function hasMetadataError(result: unknown): result is { _metadata: { error: string } } { +function hasMetadataError( + result: unknown, +): result is { _metadata: { error: string } } { const metadata = (result as any)?._metadata; return Boolean(metadata && typeof metadata.error === 'string'); } @@ -192,9 +263,14 @@ function attachResponseContract( }; } -function buildSearchContract(result: any, args: { query: string }): ResponseContract { - const hasContainers = result.total_results > 0 && (result.containers?.length ?? 0) > 0; - const hasShipments = result.total_results > 0 && (result.shipments?.length ?? 0) > 0; +function buildSearchContract( + result: any, + args: { query: string }, +): ResponseContract { + const hasContainers = + result.total_results > 0 && (result.containers?.length ?? 0) > 0; + const hasShipments = + result.total_results > 0 && (result.shipments?.length ?? 0) > 0; return { purpose: `Resolve identifier ${args.query} into concrete container and shipment IDs.`, @@ -203,20 +279,30 @@ function buildSearchContract(result: any, args: { query: string }): ResponseCont 'carrier/scac hints for discovered items', 'what additional lookup step is needed', ], - requires_more_data: hasContainers || hasShipments ? [] : ['A valid/refined identifier (container/BL/reference)'], + requires_more_data: + hasContainers || hasShipments + ? [] + : ['A valid/refined identifier (container/BL/reference)'], relevant_fields: ['containers', 'shipments', 'total_results'], presentation_guidance: hasContainers || hasShipments ? 'Group matches by container and shipment. Ask for clarification only when multiple entities are strong candidates.' : 'Ask for a clearer identifier and verify format before calling another tool.', suggested_follow_ups: ['get_container', 'get_shipment_details'], - suggested_tools: hasContainers || hasShipments ? ['get_container', 'get_shipment_details'] : ['search_container'], + suggested_tools: + hasContainers || hasShipments + ? ['get_container', 'get_shipment_details'] + : ['search_container'], }; } -function buildTrackContract(result: any, args: { number: string }): ResponseContract { +function buildTrackContract( + result: any, + args: { number: string }, +): ResponseContract { const hasTrackedContainer = Boolean((result as any)?.id); - const isPending = Boolean((result as any)?.tracking_request_created) && !hasTrackedContainer; + const isPending = + Boolean((result as any)?.tracking_request_created) && !hasTrackedContainer; const state = (result as any)?._metadata?.container_state || 'unknown'; return { purpose: `Track ${args.number} and return the linked container view when possible.`, @@ -225,27 +311,42 @@ function buildTrackContract(result: any, args: { number: string }): ResponseCont 'basic container status and metadata', 'where to pull next (if container details are delayed)', ], - requires_more_data: isPending ? ['container UUID (once linking finishes)'] : [], - relevant_fields: ['tracking_request_created', 'container_state', 'id', 'status'], - presentation_guidance: - isPending - ? 'Tracking request was created but container linking is not immediate. Mention this and provide next-check guidance.' - : `Use container state "${state}" to answer readiness, holds, and pickup timing.`, - suggested_follow_ups: - isPending - ? ['list_tracking_requests', 'get_container'] - : ['get_container_transport_events'], + requires_more_data: isPending + ? ['container UUID (once linking finishes)'] + : [], + relevant_fields: [ + 'tracking_request_created', + 'container_state', + 'id', + 'status', + ], + presentation_guidance: isPending + ? 'Tracking request was created but container linking is not immediate. Mention this and provide next-check guidance.' + : `Use container state "${state}" to answer readiness, holds, and pickup timing.`, + suggested_follow_ups: isPending + ? ['list_tracking_requests', 'get_container'] + : ['get_container_transport_events'], suggested_tools: ['get_container', 'get_container_transport_events'], }; } -function buildTransportEventsContract(result: any, _args: { id: string }): ResponseContract { +function buildTransportEventsContract( + result: any, + _args: { id: string }, +): ResponseContract { const totalEvents = result.total_events ?? result.timeline?.length ?? 0; return { - purpose: 'Summarize what happened and forecast next likely milestone for the container.', - can_answer: ['journey timeline', 'major milestones', 'rail/transshipment context'], + purpose: + 'Summarize what happened and forecast next likely milestone for the container.', + can_answer: [ + 'journey timeline', + 'major milestones', + 'rail/transshipment context', + ], requires_more_data: - totalEvents > 0 ? [] : ['recent container events becoming available from carrier feed'], + totalEvents > 0 + ? [] + : ['recent container events becoming available from carrier feed'], relevant_fields: ['timeline', 'event_categories', 'milestones'], presentation_guidance: totalEvents > 0 @@ -258,17 +359,27 @@ function buildTransportEventsContract(result: any, _args: { id: string }): Respo function buildShippingLineContract(result: any): ResponseContract { return { - purpose: 'Help user identify a supported SCAC before creating a track request.', - can_answer: ['SCAC lookup', 'carrier aliases and names', 'supported carrier search'], - requires_more_data: result.total_lines > 0 ? [] : ['additional query context'], + purpose: + 'Help user identify a supported SCAC before creating a track request.', + can_answer: [ + 'SCAC lookup', + 'carrier aliases and names', + 'supported carrier search', + ], + requires_more_data: + result.total_lines > 0 ? [] : ['additional query context'], relevant_fields: ['shipping_lines', 'total_lines'], - presentation_guidance: 'Sort carriers alphabetically and show both SCAC and company names.', + presentation_guidance: + 'Sort carriers alphabetically and show both SCAC and company names.', suggested_follow_ups: ['track_container'], suggested_tools: ['track_container'], }; } -function buildRouteContract(result: any, _args: { id: string }): ResponseContract { +function buildRouteContract( + result: any, + _args: { id: string }, +): ResponseContract { const available = Array.isArray(result.route_locations); return { purpose: 'Communicate container routing and vessel itinerary.', @@ -277,7 +388,9 @@ function buildRouteContract(result: any, _args: { id: string }): ResponseContrac 'leg-by-leg ETD/ETA', 'carrier and vessel coverage', ], - requires_more_data: available ? [] : ['event timeline via get_container_transport_events'], + requires_more_data: available + ? [] + : ['event timeline via get_container_transport_events'], relevant_fields: ['route_locations', 'total_legs', 'alternative'], presentation_guidance: available ? 'Show origin → transshipments → destination. Emphasize missing legs and ETA changes.' @@ -290,22 +403,45 @@ function buildRouteContract(result: any, _args: { id: string }): ResponseContrac function buildContainerContract(): ResponseContract { return { purpose: 'Provide current container snapshot and readiness context.', - can_answer: ['status', 'location', 'pickup readiness', 'rail and shipment context'], + can_answer: [ + 'status', + 'location', + 'pickup readiness', + 'rail and shipment context', + ], requires_more_data: ['holds, fees, and timeline by demand'], - relevant_fields: ['id', 'container_number', 'status', 'pod_terminal', 'demurrage'], + relevant_fields: [ + 'id', + 'container_number', + 'status', + 'pod_terminal', + 'demurrage', + ], presentation_guidance: 'Summarize state first, then call out LFD, holds, and fees if present. If terminal availability is unclear, suggest transport events.', - suggested_follow_ups: ['get_container_transport_events', 'get_container_route'], + suggested_follow_ups: [ + 'get_container_transport_events', + 'get_container_route', + ], suggested_tools: ['get_container_transport_events', 'get_container_route'], }; } function buildShipmentContract(): ResponseContract { return { - purpose: 'Explain shipment-level routing, container counts, and references.', + purpose: + 'Explain shipment-level routing, container counts, and references.', can_answer: ['shipment identifiers', 'routing summary', 'container list'], - requires_more_data: ['container-level ETA confidence when only one terminal is visible'], - relevant_fields: ['id', 'bill_of_lading', 'status', 'containers', 'routing'], + requires_more_data: [ + 'container-level ETA confidence when only one terminal is visible', + ], + relevant_fields: [ + 'id', + 'bill_of_lading', + 'status', + 'containers', + 'routing', + ], presentation_guidance: 'Group by shipment summary then container health signals (pickup ETA, pickup_lfd, holds).', suggested_follow_ups: ['get_container', 'list_containers'], @@ -314,23 +450,39 @@ function buildShipmentContract(): ResponseContract { } function asRecord(value: unknown): Record { - return value && typeof value === 'object' ? (value as Record) : {}; + return value && typeof value === 'object' + ? (value as Record) + : {}; } type ListEntityType = 'container' | 'shipment' | 'tracking_request' | 'unknown'; function detectListEntityType(result: any): ListEntityType { - const firstItem = Array.isArray(result?.items) ? asRecord(result.items[0]) : {}; - - if ('requestType' in firstItem || 'request_type' in firstItem || 'requestNumber' in firstItem) { + const firstItem = Array.isArray(result?.items) + ? asRecord(result.items[0]) + : {}; + + if ( + 'requestType' in firstItem || + 'request_type' in firstItem || + 'requestNumber' in firstItem + ) { return 'tracking_request'; } - if ('billOfLading' in firstItem || 'bill_of_lading' in firstItem || 'podVesselName' in firstItem) { + if ( + 'billOfLading' in firstItem || + 'bill_of_lading' in firstItem || + 'podVesselName' in firstItem + ) { return 'shipment'; } - if ('number' in firstItem || 'container_number' in firstItem || 'podDischargedAt' in firstItem) { + if ( + 'number' in firstItem || + 'container_number' in firstItem || + 'podDischargedAt' in firstItem + ) { return 'container'; } @@ -361,7 +513,11 @@ function buildContainerListDisplay(): ResponseDisplay { { key: 'podFullOutAt', label: 'Picked Up', path: 'podFullOutAt' }, { key: 'availableForPickup', label: 'Ready', path: 'availableForPickup' }, { key: 'pickupLfd', label: 'LFD', path: 'pickupLfd' }, - { key: 'pickupAppointmentAt', label: 'Pickup Appt', path: 'pickupAppointmentAt' }, + { + key: 'pickupAppointmentAt', + label: 'Pickup Appt', + path: 'pickupAppointmentAt', + }, { key: 'holdsCount', label: 'Holds', @@ -369,7 +525,11 @@ function buildContainerListDisplay(): ResponseDisplay { compute: 'length', description: 'Count of active holds at POD terminal', }, - { key: 'holdsAtPodTerminal', label: 'Hold Details', path: 'holdsAtPodTerminal' }, + { + key: 'holdsAtPodTerminal', + label: 'Hold Details', + path: 'holdsAtPodTerminal', + }, { key: 'feesCount', label: 'Fees', @@ -377,18 +537,42 @@ function buildContainerListDisplay(): ResponseDisplay { compute: 'length', description: 'Count of fee items at POD terminal', }, - { key: 'locationAtPodTerminal', label: 'Terminal Location', path: 'locationAtPodTerminal' }, - { key: 'terminals.podTerminal.name', label: 'POD Terminal', path: 'terminals.podTerminal.name' }, - { key: 'shipment.billOfLading', label: 'BL', path: 'shipment.billOfLading' }, - { key: 'shipment.shippingLineScac', label: 'SCAC', path: 'shipment.shippingLineScac' }, - { key: 'podRailCarrierScac', label: 'Rail Carrier', path: 'podRailCarrierScac' }, + { + key: 'locationAtPodTerminal', + label: 'Terminal Location', + path: 'locationAtPodTerminal', + }, + { + key: 'terminals.podTerminal.name', + label: 'POD Terminal', + path: 'terminals.podTerminal.name', + }, + { + key: 'shipment.billOfLading', + label: 'BL', + path: 'shipment.billOfLading', + }, + { + key: 'shipment.shippingLineScac', + label: 'SCAC', + path: 'shipment.shippingLineScac', + }, + { + key: 'podRailCarrierScac', + label: 'Rail Carrier', + path: 'podRailCarrierScac', + }, { key: 'indEtaAt', label: 'Inland ETA', path: 'indEtaAt' }, { key: 'indAtaAt', label: 'Inland ATA', path: 'indAtaAt' }, ], column_sets: [ { intent: 'discharged_not_picked_up', - when_user_asks: ['discharged but not picked up', 'not picked up', 'still at terminal'], + when_user_asks: [ + 'discharged but not picked up', + 'not picked up', + 'still at terminal', + ], columns: [ 'number', 'currentStatus', @@ -415,7 +599,12 @@ function buildContainerListDisplay(): ResponseDisplay { }, { intent: 'holds_and_blocks', - when_user_asks: ['holds', 'blocked', 'customs hold', 'why not available'], + when_user_asks: [ + 'holds', + 'blocked', + 'customs hold', + 'why not available', + ], columns: [ 'number', 'currentStatus', @@ -470,12 +659,20 @@ function buildShipmentListDisplay(): ResponseDisplay { { key: 'podAtaAt', label: 'POD ATA', path: 'podAtaAt' }, { key: 'destinationName', label: 'Destination', path: 'destinationName' }, { key: 'destinationEtaAt', label: 'Dest ETA', path: 'destinationEtaAt' }, - { key: 'lineTrackingLastSucceededAt', label: 'Last Update', path: 'lineTrackingLastSucceededAt' }, + { + key: 'lineTrackingLastSucceededAt', + label: 'Last Update', + path: 'lineTrackingLastSucceededAt', + }, ], column_sets: [ { intent: 'vessel_arrivals', - when_user_asks: ['when is vessel arriving', 'vessel arrival', 'eta by vessel'], + when_user_asks: [ + 'when is vessel arriving', + 'vessel arrival', + 'eta by vessel', + ], columns: [ 'podVesselName', 'podVoyageNumber', @@ -534,12 +731,30 @@ function buildTrackingRequestListDisplay(): ResponseDisplay { { intent: 'failed_requests', when_user_asks: ['failed tracking', 'why failed', 'tracking errors'], - columns: ['requestNumber', 'requestType', 'status', 'scac', 'failedReason', 'updatedAt'], + columns: [ + 'requestNumber', + 'requestType', + 'status', + 'scac', + 'failedReason', + 'updatedAt', + ], }, { intent: 'tracking_activity', - when_user_asks: ['recent tracking activity', 'latest requests', 'tracking queue'], - columns: ['requestNumber', 'requestType', 'status', 'scac', 'createdAt', 'updatedAt'], + when_user_asks: [ + 'recent tracking activity', + 'latest requests', + 'tracking queue', + ], + columns: [ + 'requestNumber', + 'requestType', + 'status', + 'scac', + 'createdAt', + 'updatedAt', + ], }, ], selection_strategy: @@ -560,39 +775,138 @@ export function buildListContract( entityType === 'container' ? buildContainerListDisplay() : entityType === 'shipment' - ? buildShipmentListDisplay() - : entityType === 'tracking_request' - ? buildTrackingRequestListDisplay() - : undefined; + ? buildShipmentListDisplay() + : entityType === 'tracking_request' + ? buildTrackingRequestListDisplay() + : undefined; return { purpose: 'Surface aggregate operational worklist results.', can_answer: ['which records match filters', 'count and paging state'], - requires_more_data: count === 0 ? ['alternative filters or tighter date ranges'] : [], + requires_more_data: + count === 0 ? ['alternative filters or tighter date ranges'] : [], relevant_fields: ['items', 'links', 'meta', 'count'], presentation_guidance: count <= 1 ? 'For a single result, provide a concise row summary. For multiple rows, render a markdown table.' : 'Render a markdown table using the response_contract display hints. Avoid dumping full nested records.', suggested_follow_ups: ['list_containers', 'list_tracking_requests'], - suggested_tools: ['list_containers', 'list_tracking_requests', 'get_container'], + suggested_tools: [ + 'list_containers', + 'list_tracking_requests', + 'get_container', + ], display, }; } +/** + * 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, -): (args: TArgs) => Promise<{ content: ToolContent[]; structuredContent?: any; isError?: boolean }> { + 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,18 +921,57 @@ 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, accountId?: string, ): McpServer { - const client = new Terminal49Client({ apiToken, apiBaseUrl, accountId, defaultFormat: "mapped" }); + const client = new Terminal49Client({ + apiToken, + apiBaseUrl, + accountId, + defaultFormat: 'mapped', + }); + + const completeCarrierScac = createCarrierScacCompleter(client); 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 ==================== @@ -634,25 +987,34 @@ export function createTerminal49McpServer( 'This is the fastest way to find container information. ' + 'Examples: CAIU2885402, MAEU123456789, or any reference number.', inputSchema: { - query: z.string().min(1).describe('Search query - can be a container number, booking number, BL number, or reference number'), + query: z + .string() + .min(1) + .describe( + 'Search query - can be a container number, booking number, BL number, or reference number', + ), intent: toolIntentSchema, }, outputSchema: { - containers: z.array(z.object({ - id: z.string(), - container_number: z.string(), - status: z.string(), - shipping_line: z.string(), - pod_terminal: z.string().optional(), - pol_terminal: z.string().optional(), - destination: z.string().optional(), - })), - shipments: z.array(z.object({ - id: z.string(), - ref_numbers: z.array(z.string()), - shipping_line: z.string(), - container_count: z.number(), - })), + containers: z.array( + z.object({ + id: z.string(), + container_number: z.string(), + status: z.string(), + shipping_line: z.string(), + pod_terminal: z.string().optional(), + pol_terminal: z.string().optional(), + destination: z.string().optional(), + }), + ), + shipments: z.array( + z.object({ + id: z.string(), + ref_numbers: z.array(z.string()), + shipping_line: z.string(), + container_count: z.number(), + }), + ), total_results: z.number(), _response_contract: responseContractSchema, }, @@ -660,7 +1022,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async ({ query }) => executeSearchContainer({ query }, client), (result, args) => buildSearchContract(result as any, args), - ) + ), ); // Tool 2: Track Container @@ -673,15 +1035,34 @@ export function createTerminal49McpServer( 'Uses inference to choose the carrier/type when possible, creates a tracking request, ' + 'and returns detailed container information.', inputSchema: { - number: z.string().optional().describe('Container, bill of lading, or booking number to track'), + number: z + .string() + .optional() + .describe('Container, bill of lading, or booking number to track'), numberType: z .string() .optional() - .describe('Optional override: container | bill_of_lading | booking_number'), - containerNumber: z.string().optional().describe('Deprecated alias for number (container)'), - bookingNumber: z.string().optional().describe('Deprecated alias for number (booking/BL)'), - scac: z.string().optional().describe('Optional SCAC code of the shipping line (e.g., MAEU for Maersk)'), - refNumbers: z.array(z.string()).optional().describe('Optional reference numbers for matching'), + .describe( + 'Optional override: container | bill_of_lading | booking_number', + ), + containerNumber: z + .string() + .optional() + .describe('Deprecated alias for number (container)'), + bookingNumber: z + .string() + .optional() + .describe('Deprecated alias for number (booking/BL)'), + scac: z + .string() + .optional() + .describe( + 'Optional SCAC code of the shipping line (e.g., MAEU for Maersk)', + ), + refNumbers: z + .array(z.string()) + .optional() + .describe('Optional reference numbers for matching'), intent: toolIntentSchema, }, outputSchema: { @@ -696,13 +1077,31 @@ export function createTerminal49McpServer( }, }, wrapToolWithContract( - async ({ number, numberType, containerNumber, scac, bookingNumber, refNumbers }) => + async ({ + number, + numberType, + containerNumber, + scac, + bookingNumber, + refNumbers, + }) => executeTrackContainer( - { number, numberType, containerNumber, scac, bookingNumber, refNumbers }, + { + number, + numberType, + containerNumber, + scac, + bookingNumber, + refNumbers, + }, client, ), - (result, args) => buildTrackContract(result as any, { number: args.number || args.containerNumber || args.bookingNumber || '' }) - ) + (result, args) => + buildTrackContract(result as any, { + number: + args.number || args.containerNumber || args.bookingNumber || '', + }), + ), ); // Tool 3: Get Container @@ -715,16 +1114,19 @@ export function createTerminal49McpServer( 'plus optional related data. Choose includes based on user question and container state. ' + 'Response includes metadata hints to guide follow-up queries.', inputSchema: { - id: z.string().uuid().describe('The Terminal49 container ID (UUID format)'), + id: z + .string() + .uuid() + .describe('The Terminal49 container ID (UUID format)'), include: z .array(z.enum(['shipment', 'pod_terminal', 'transport_events'])) .optional() .default(['shipment']) .describe( - 'Optional related data to include. Default: [\'shipment\'] covers most use cases. ' + - '• shipment: Routing, BOL, line, ref numbers (lightweight, always useful) ' + - '• pod_terminal: Terminal name, location, availability (lightweight, needed for demurrage questions) ' + - '• transport_events: Full event history, rail tracking (heavy 50-100 events, use for journey/timeline questions)' + "Optional related data to include. Default: ['shipment'] covers most use cases. " + + '• shipment: Routing, BOL, line, ref numbers (lightweight, always useful) ' + + '• pod_terminal: Terminal name, location, availability (lightweight, needed for demurrage questions) ' + + '• transport_events: Full event history, rail tracking (heavy 50-100 events, use for journey/timeline questions)', ), intent: toolIntentSchema, }, @@ -737,7 +1139,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async ({ id, include }) => executeGetContainer({ id, include }, client), () => buildContainerContract(), - ) + ), ); // Tool 4: Get Shipment Details @@ -750,8 +1152,17 @@ export function createTerminal49McpServer( 'Use this when user asks about a shipment (vs a specific container). ' + 'Returns: Bill of Lading, shipping line, port details, vessel info, ETAs, container list.', inputSchema: { - id: z.string().uuid().describe('The Terminal49 shipment ID (UUID format)'), - include_containers: z.boolean().optional().default(true).describe('Include list of containers in this shipment. Default: true'), + id: z + .string() + .uuid() + .describe('The Terminal49 shipment ID (UUID format)'), + include_containers: z + .boolean() + .optional() + .default(true) + .describe( + 'Include list of containers in this shipment. Default: true', + ), intent: toolIntentSchema, }, outputSchema: z @@ -759,12 +1170,12 @@ export function createTerminal49McpServer( _response_contract: responseContractSchema, }) .passthrough(), - }, + }, wrapToolWithContract( async ({ id, include_containers }) => executeGetShipmentDetails({ id, include_containers }, client), () => buildShipmentContract(), - ) + ), ); // Tool 5: Get Container Transport Events @@ -778,7 +1189,10 @@ export function createTerminal49McpServer( 'Use this for questions about journey history, "what happened", timeline analysis, rail tracking. ' + 'More efficient than get_container with transport_events when you only need event data.', inputSchema: { - id: z.string().uuid().describe('The Terminal49 container ID (UUID format)'), + id: z + .string() + .uuid() + .describe('The Terminal49 container ID (UUID format)'), intent: toolIntentSchema, }, outputSchema: z @@ -790,7 +1204,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async ({ id }) => executeGetContainerTransportEvents({ id }, client), (result, args) => buildTransportEventsContract(result as any, args), - ) + ), ); // Tool 6: Get Supported Shipping Lines @@ -803,7 +1217,10 @@ export function createTerminal49McpServer( 'Returns SCAC codes, full names, and common abbreviations. ' + 'Use this when user asks which carriers are supported or to validate a carrier name.', inputSchema: { - search: z.string().optional().describe('Optional: Filter by carrier name or SCAC code'), + search: z + .string() + .optional() + .describe('Optional: Filter by carrier name or SCAC code'), intent: toolIntentSchema, }, outputSchema: { @@ -815,7 +1232,7 @@ export function createTerminal49McpServer( short_name: z.string().optional(), bol_prefix: z.string().optional(), notes: z.string().optional(), - }) + }), ), _metadata: z.object({ presentation_guidance: z.string(), @@ -826,9 +1243,10 @@ export function createTerminal49McpServer( }, }, wrapToolWithContract( - async ({ search }) => executeGetSupportedShippingLines({ search }, client), + async ({ search }) => + executeGetSupportedShippingLines({ search }, client), (result) => buildShippingLineContract(result as any), - ) + ), ); // Tool 7: Get Container Route @@ -842,7 +1260,10 @@ export function createTerminal49McpServer( 'NOTE: This is a paid feature and may not be available for all accounts. ' + 'Use for questions about routing, transshipments, or detailed vessel itinerary.', inputSchema: { - id: z.string().uuid().describe('The Terminal49 container ID (UUID format)'), + id: z + .string() + .uuid() + .describe('The Terminal49 container ID (UUID format)'), intent: toolIntentSchema, }, // Keep a single permissive schema because this tool can return either @@ -885,7 +1306,7 @@ export function createTerminal49McpServer( }) .nullable(), }), - }) + }), ) .optional(), created_at: z.string().nullable().optional(), @@ -906,7 +1327,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async ({ id }) => executeGetContainerRoute({ id }, client), (result, args) => buildRouteContract(result as any, args), - ) + ), ); // Tool 8: List Shipments @@ -921,12 +1342,22 @@ export function createTerminal49McpServer( status: z.string().optional().describe('Filter by shipment status'), port: z.string().optional().describe('Filter by POD port LOCODE'), carrier: z.string().optional().describe('Filter by shipping line SCAC'), - updated_after: z.string().optional().describe('Filter by updated_at (ISO8601) >= value'), + updated_after: z + .string() + .optional() + .describe('Filter by updated_at (ISO8601) >= value'), include_containers: z .boolean() .optional() - .describe('Include containers relationship in response. Default: true.'), - page: z.number().int().positive().optional().describe('Page number (1-based)'), + .describe( + 'Include containers relationship in response. Default: true.', + ), + page: z + .number() + .int() + .positive() + .optional() + .describe('Page number (1-based)'), page_size: z.number().int().positive().optional().describe('Page size'), intent: toolIntentSchema, }, @@ -940,7 +1371,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async (args) => executeListShipments(args, client), (result) => buildListContract(result as any, 'shipment'), - ) + ), ); // Tool 9: List Containers @@ -955,12 +1386,22 @@ export function createTerminal49McpServer( status: z.string().optional().describe('Filter by container status'), port: z.string().optional().describe('Filter by POD port LOCODE'), carrier: z.string().optional().describe('Filter by shipping line SCAC'), - updated_after: z.string().optional().describe('Filter by updated_at (ISO8601) >= value'), + updated_after: z + .string() + .optional() + .describe('Filter by updated_at (ISO8601) >= value'), include: z .string() .optional() - .describe('Comma-separated include list (e.g., shipment,pod_terminal)'), - page: z.number().int().positive().optional().describe('Page number (1-based)'), + .describe( + 'Comma-separated include list (e.g., shipment,pod_terminal)', + ), + page: z + .number() + .int() + .positive() + .optional() + .describe('Page number (1-based)'), page_size: z.number().int().positive().optional().describe('Page size'), intent: toolIntentSchema, }, @@ -974,7 +1415,11 @@ 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'), + ), ); // Tool 10: List Tracking Requests @@ -990,11 +1435,20 @@ export function createTerminal49McpServer( .record(z.string(), z.string()) .optional() .describe('Raw query filters (e.g., filter[status]=succeeded)'), - status: z.string().optional().describe('Filter by request status (mapped to filter[status])'), - request_type: z.string() + status: z + .string() + .optional() + .describe('Filter by request status (mapped to filter[status])'), + request_type: z + .string() .optional() .describe('Filter by request type (mapped to filter[request_type])'), - page: z.number().int().positive().optional().describe('Page number (1-based)'), + page: z + .number() + .int() + .positive() + .optional() + .describe('Page number (1-based)'), page_size: z.number().int().positive().optional().describe('Page size'), intent: toolIntentSchema, }, @@ -1008,7 +1462,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async (args) => executeListTrackingRequests(args, client), (result) => buildListContract(result as any, 'tracking_request'), - ) + ), ); // ==================== PROMPTS ==================== @@ -1018,10 +1472,20 @@ export function createTerminal49McpServer( 'track-shipment', { title: 'Track Container Shipment', - description: 'Quick container tracking workflow with carrier autocomplete', + description: + 'Quick container tracking workflow with carrier autocomplete', argsSchema: { - container_number: z.string().describe('Container number (e.g., CAIU1234567)'), - carrier: z.string().optional().describe('Shipping line SCAC code (e.g., MAEU for Maersk)'), + container_number: z + .string() + .describe('Container number (e.g., CAIU1234567)'), + // Autocompletes from the live supported-carrier list (SCAC codes). + carrier: completable( + z + .string() + .optional() + .describe('Shipping line SCAC code (e.g., MAEU for Maersk)'), + completeCarrierScac, + ), }, }, async ({ container_number, carrier }) => ({ @@ -1036,7 +1500,7 @@ export function createTerminal49McpServer( }, }, ], - }) + }), ); // Prompt 2: Check Demurrage @@ -1064,7 +1528,7 @@ export function createTerminal49McpServer( }, }, ], - }) + }), ); // Prompt 3: Analyze Delays @@ -1092,7 +1556,7 @@ export function createTerminal49McpServer( }, }, ], - }) + }), ); // ==================== RESOURCES ==================== @@ -1110,7 +1574,7 @@ export function createTerminal49McpServer( return { contents: [resource], }; - } + }, ); // Resource 2: Milestone Glossary (static resource) @@ -1127,7 +1591,7 @@ export function createTerminal49McpServer( return { contents: [resource], }; - } + }, ); // Resource 3: Query Guidance (internal LLM tool routing hints) @@ -1150,7 +1614,7 @@ export function createTerminal49McpServer( }, ], }; - } + }, ); return server; @@ -1167,7 +1631,9 @@ export async function runStdioServer() { console.error('Please set your Terminal49 API token:'); console.error(' export T49_API_TOKEN=your_token_here'); console.error(''); - console.error('Get your API token at: https://app.terminal49.com/developers/api-keys'); + console.error( + 'Get your API token at: https://app.terminal49.com/developers/api-keys', + ); process.exit(1); } From f2cc18daa84ee35bcc2147392c8921fb7cf27a1f Mon Sep 17 00:00:00 2001 From: Akshay Dodeja Date: Fri, 26 Jun 2026 05:55:36 -0500 Subject: [PATCH 2/2] fix(mcp): register completions capability + strip server.ts reformat churn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1 (HIGH — completions never registered): the carrier prompt arg wired `completable` around the OUTER ZodOptional. The MCP SDK unwraps ZodOptional and checks isCompletable on the INNER ZodString when deciding whether to advertise `completions` and register a completion handler, so the symbol on the optional was missed and the capability was never advertised. Reorder to `completable(z.string()...).optional()` so the inner string carries the completion metadata; the SDK now advertises `completions` and registers the completion/complete handler (verified via server.getCapabilities() and _completionHandlerInitialized). Fix 2 (HIGH — false-confidence test): the old completions test called the completer function directly, bypassing MCP registration, so it passed even though the capability was never advertised. Rewrite it to assert the registered path: server.getCapabilities().completions is defined and _completionHandlerInitialized is true (these FAIL against the pre-fix outer-optional wiring and PASS after Fix 1). The value assertions ('m' -> ['MAEU','MSCU'], 'ma' -> ['MAEU']) are kept as a secondary unit assertion run against the completer resolved off the inner string, the same way the SDK registration keys off it. Fix 3 (churn): the implementer's mass oxfmt reformat produced ~760 lines of formatting churn in server.ts unrelated to the 4 features. Restored server.ts to origin/main formatting and re-applied only the 4 substantive edits (server instructions wiring, the completable carrier-arg change, list_containers resource_link blocks, and the audience:['assistant'] steering annotation). server.ts diff vs origin/main is now 191 insertions / 9 deletions (down from 613 / 147). The other 3 features are intact. Green gate: SDK+MCP build, type-check, and tests all pass (SDK 51 passed, MCP 82 passed). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp/src/mcp.test.ts | 51 +++- packages/mcp/src/server.ts | 574 +++++++++-------------------------- 2 files changed, 181 insertions(+), 444 deletions(-) diff --git a/packages/mcp/src/mcp.test.ts b/packages/mcp/src/mcp.test.ts index c5771bf3..de808797 100644 --- a/packages/mcp/src/mcp.test.ts +++ b/packages/mcp/src/mcp.test.ts @@ -481,7 +481,7 @@ describe('MCP server wiring', () => { expect(instructions.length).toBeGreaterThan(400); }); - it('wires carrier SCAC completion on the track-shipment prompt', async () => { + it('registers the completions capability so carrier SCAC completion is reachable', async () => { shippingLinesList.mockResolvedValue([ { scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' }, { @@ -492,20 +492,38 @@ describe('MCP server wiring', () => { ]); const server = createTerminal49McpServer('token'); - const prompt = (server as any)._registeredPrompts['track-shipment']; - // The SDK stores the prompt args as a Zod object; pull the completable - // `carrier` field and run its completer the same way the SDK would. - const carrierField = getArgShape(prompt.argsSchema).carrier; - const completer = getCompleter(carrierField as any); - expect(completer).toBeTypeOf('function'); + // 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. - const broad = await completer!('m', undefined); - expect(broad).toEqual(['MAEU', 'MSCU']); - - const narrow = await completer!('ma', undefined); - expect(narrow).toEqual(['MAEU']); + 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(); @@ -516,9 +534,12 @@ describe('MCP server wiring', () => { const server = createTerminal49McpServer('token'); const prompt = (server as any)._registeredPrompts['track-shipment']; - const completer = getCompleter( - getArgShape(prompt.argsSchema).carrier as any, - ); + // 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([]); }); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index f062167e..a6b7b3ac 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -3,10 +3,7 @@ * Implementation using @modelcontextprotocol/sdk with McpServer API */ -import { - McpServer, - ResourceTemplate, -} from '@modelcontextprotocol/sdk/server/mcp.js'; +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'; @@ -17,24 +14,14 @@ import { executeSearchContainer } from './tools/search-container.js'; import { executeGetShipmentDetails } from './tools/get-shipment-details.js'; import { executeGetContainerTransportEvents } from './tools/get-container-transport-events.js'; import { executeGetSupportedShippingLines } from './tools/get-supported-shipping-lines.js'; -import { - executeGetContainerRoute, - type FeatureNotEnabledResult, -} from './tools/get-container-route.js'; +import { executeGetContainerRoute, type FeatureNotEnabledResult } from './tools/get-container-route.js'; import { executeListShipments } from './tools/list-shipments.js'; import { executeListContainers } from './tools/list-containers.js'; import { executeListTrackingRequests } from './tools/list-tracking-requests.js'; import { readContainerResource } from './resources/container.js'; import { readMilestoneGlossaryResource } from './resources/milestone-glossary.js'; -import { - queryGuidanceResource, - readQueryGuidanceResource, -} from './resources/query-guidance.js'; -import { - captureMcpException, - flushMcpEvents, - instrumentMcpServer, -} from './sentry.js'; +import { queryGuidanceResource, readQueryGuidanceResource } from './resources/query-guidance.js'; +import { captureMcpException, flushMcpEvents, instrumentMcpServer } from './sentry.js'; /** * MCP content-block annotations (per spec). `audience` lets a client decide who @@ -146,9 +133,7 @@ function buildContentPayload(result: unknown): ToolContent[] { if (hasMetadataError(result)) { const metadata = (result as any)._metadata; - const remediation = metadata.remediation - ? `\n\nRemediation: ${metadata.remediation}` - : ''; + const remediation = metadata.remediation ? `\n\nRemediation: ${metadata.remediation}` : ''; return [ { type: 'text', @@ -168,20 +153,16 @@ function formatAsText(result: unknown): string { } } -function isFeatureNotEnabledResult( - result: unknown, -): result is FeatureNotEnabledResult { +function isFeatureNotEnabledResult(result: unknown): result is FeatureNotEnabledResult { return Boolean( result && - typeof result === 'object' && - (result as any).error === 'FeatureNotEnabled' && - typeof (result as any).message === 'string', + typeof result === 'object' && + (result as any).error === 'FeatureNotEnabled' && + typeof (result as any).message === 'string' ); } -function hasMetadataError( - result: unknown, -): result is { _metadata: { error: string } } { +function hasMetadataError(result: unknown): result is { _metadata: { error: string } } { const metadata = (result as any)?._metadata; return Boolean(metadata && typeof metadata.error === 'string'); } @@ -263,14 +244,9 @@ function attachResponseContract( }; } -function buildSearchContract( - result: any, - args: { query: string }, -): ResponseContract { - const hasContainers = - result.total_results > 0 && (result.containers?.length ?? 0) > 0; - const hasShipments = - result.total_results > 0 && (result.shipments?.length ?? 0) > 0; +function buildSearchContract(result: any, args: { query: string }): ResponseContract { + const hasContainers = result.total_results > 0 && (result.containers?.length ?? 0) > 0; + const hasShipments = result.total_results > 0 && (result.shipments?.length ?? 0) > 0; return { purpose: `Resolve identifier ${args.query} into concrete container and shipment IDs.`, @@ -279,30 +255,20 @@ function buildSearchContract( 'carrier/scac hints for discovered items', 'what additional lookup step is needed', ], - requires_more_data: - hasContainers || hasShipments - ? [] - : ['A valid/refined identifier (container/BL/reference)'], + requires_more_data: hasContainers || hasShipments ? [] : ['A valid/refined identifier (container/BL/reference)'], relevant_fields: ['containers', 'shipments', 'total_results'], presentation_guidance: hasContainers || hasShipments ? 'Group matches by container and shipment. Ask for clarification only when multiple entities are strong candidates.' : 'Ask for a clearer identifier and verify format before calling another tool.', suggested_follow_ups: ['get_container', 'get_shipment_details'], - suggested_tools: - hasContainers || hasShipments - ? ['get_container', 'get_shipment_details'] - : ['search_container'], + suggested_tools: hasContainers || hasShipments ? ['get_container', 'get_shipment_details'] : ['search_container'], }; } -function buildTrackContract( - result: any, - args: { number: string }, -): ResponseContract { +function buildTrackContract(result: any, args: { number: string }): ResponseContract { const hasTrackedContainer = Boolean((result as any)?.id); - const isPending = - Boolean((result as any)?.tracking_request_created) && !hasTrackedContainer; + const isPending = Boolean((result as any)?.tracking_request_created) && !hasTrackedContainer; const state = (result as any)?._metadata?.container_state || 'unknown'; return { purpose: `Track ${args.number} and return the linked container view when possible.`, @@ -311,42 +277,27 @@ function buildTrackContract( 'basic container status and metadata', 'where to pull next (if container details are delayed)', ], - requires_more_data: isPending - ? ['container UUID (once linking finishes)'] - : [], - relevant_fields: [ - 'tracking_request_created', - 'container_state', - 'id', - 'status', - ], - presentation_guidance: isPending - ? 'Tracking request was created but container linking is not immediate. Mention this and provide next-check guidance.' - : `Use container state "${state}" to answer readiness, holds, and pickup timing.`, - suggested_follow_ups: isPending - ? ['list_tracking_requests', 'get_container'] - : ['get_container_transport_events'], + requires_more_data: isPending ? ['container UUID (once linking finishes)'] : [], + relevant_fields: ['tracking_request_created', 'container_state', 'id', 'status'], + presentation_guidance: + isPending + ? 'Tracking request was created but container linking is not immediate. Mention this and provide next-check guidance.' + : `Use container state "${state}" to answer readiness, holds, and pickup timing.`, + suggested_follow_ups: + isPending + ? ['list_tracking_requests', 'get_container'] + : ['get_container_transport_events'], suggested_tools: ['get_container', 'get_container_transport_events'], }; } -function buildTransportEventsContract( - result: any, - _args: { id: string }, -): ResponseContract { +function buildTransportEventsContract(result: any, _args: { id: string }): ResponseContract { const totalEvents = result.total_events ?? result.timeline?.length ?? 0; return { - purpose: - 'Summarize what happened and forecast next likely milestone for the container.', - can_answer: [ - 'journey timeline', - 'major milestones', - 'rail/transshipment context', - ], + purpose: 'Summarize what happened and forecast next likely milestone for the container.', + can_answer: ['journey timeline', 'major milestones', 'rail/transshipment context'], requires_more_data: - totalEvents > 0 - ? [] - : ['recent container events becoming available from carrier feed'], + totalEvents > 0 ? [] : ['recent container events becoming available from carrier feed'], relevant_fields: ['timeline', 'event_categories', 'milestones'], presentation_guidance: totalEvents > 0 @@ -359,27 +310,17 @@ function buildTransportEventsContract( function buildShippingLineContract(result: any): ResponseContract { return { - purpose: - 'Help user identify a supported SCAC before creating a track request.', - can_answer: [ - 'SCAC lookup', - 'carrier aliases and names', - 'supported carrier search', - ], - requires_more_data: - result.total_lines > 0 ? [] : ['additional query context'], + purpose: 'Help user identify a supported SCAC before creating a track request.', + can_answer: ['SCAC lookup', 'carrier aliases and names', 'supported carrier search'], + requires_more_data: result.total_lines > 0 ? [] : ['additional query context'], relevant_fields: ['shipping_lines', 'total_lines'], - presentation_guidance: - 'Sort carriers alphabetically and show both SCAC and company names.', + presentation_guidance: 'Sort carriers alphabetically and show both SCAC and company names.', suggested_follow_ups: ['track_container'], suggested_tools: ['track_container'], }; } -function buildRouteContract( - result: any, - _args: { id: string }, -): ResponseContract { +function buildRouteContract(result: any, _args: { id: string }): ResponseContract { const available = Array.isArray(result.route_locations); return { purpose: 'Communicate container routing and vessel itinerary.', @@ -388,9 +329,7 @@ function buildRouteContract( 'leg-by-leg ETD/ETA', 'carrier and vessel coverage', ], - requires_more_data: available - ? [] - : ['event timeline via get_container_transport_events'], + requires_more_data: available ? [] : ['event timeline via get_container_transport_events'], relevant_fields: ['route_locations', 'total_legs', 'alternative'], presentation_guidance: available ? 'Show origin → transshipments → destination. Emphasize missing legs and ETA changes.' @@ -403,45 +342,22 @@ function buildRouteContract( function buildContainerContract(): ResponseContract { return { purpose: 'Provide current container snapshot and readiness context.', - can_answer: [ - 'status', - 'location', - 'pickup readiness', - 'rail and shipment context', - ], + can_answer: ['status', 'location', 'pickup readiness', 'rail and shipment context'], requires_more_data: ['holds, fees, and timeline by demand'], - relevant_fields: [ - 'id', - 'container_number', - 'status', - 'pod_terminal', - 'demurrage', - ], + relevant_fields: ['id', 'container_number', 'status', 'pod_terminal', 'demurrage'], presentation_guidance: 'Summarize state first, then call out LFD, holds, and fees if present. If terminal availability is unclear, suggest transport events.', - suggested_follow_ups: [ - 'get_container_transport_events', - 'get_container_route', - ], + suggested_follow_ups: ['get_container_transport_events', 'get_container_route'], suggested_tools: ['get_container_transport_events', 'get_container_route'], }; } function buildShipmentContract(): ResponseContract { return { - purpose: - 'Explain shipment-level routing, container counts, and references.', + purpose: 'Explain shipment-level routing, container counts, and references.', can_answer: ['shipment identifiers', 'routing summary', 'container list'], - requires_more_data: [ - 'container-level ETA confidence when only one terminal is visible', - ], - relevant_fields: [ - 'id', - 'bill_of_lading', - 'status', - 'containers', - 'routing', - ], + requires_more_data: ['container-level ETA confidence when only one terminal is visible'], + relevant_fields: ['id', 'bill_of_lading', 'status', 'containers', 'routing'], presentation_guidance: 'Group by shipment summary then container health signals (pickup ETA, pickup_lfd, holds).', suggested_follow_ups: ['get_container', 'list_containers'], @@ -450,39 +366,23 @@ function buildShipmentContract(): ResponseContract { } function asRecord(value: unknown): Record { - return value && typeof value === 'object' - ? (value as Record) - : {}; + return value && typeof value === 'object' ? (value as Record) : {}; } type ListEntityType = 'container' | 'shipment' | 'tracking_request' | 'unknown'; function detectListEntityType(result: any): ListEntityType { - const firstItem = Array.isArray(result?.items) - ? asRecord(result.items[0]) - : {}; - - if ( - 'requestType' in firstItem || - 'request_type' in firstItem || - 'requestNumber' in firstItem - ) { + const firstItem = Array.isArray(result?.items) ? asRecord(result.items[0]) : {}; + + if ('requestType' in firstItem || 'request_type' in firstItem || 'requestNumber' in firstItem) { return 'tracking_request'; } - if ( - 'billOfLading' in firstItem || - 'bill_of_lading' in firstItem || - 'podVesselName' in firstItem - ) { + if ('billOfLading' in firstItem || 'bill_of_lading' in firstItem || 'podVesselName' in firstItem) { return 'shipment'; } - if ( - 'number' in firstItem || - 'container_number' in firstItem || - 'podDischargedAt' in firstItem - ) { + if ('number' in firstItem || 'container_number' in firstItem || 'podDischargedAt' in firstItem) { return 'container'; } @@ -513,11 +413,7 @@ function buildContainerListDisplay(): ResponseDisplay { { key: 'podFullOutAt', label: 'Picked Up', path: 'podFullOutAt' }, { key: 'availableForPickup', label: 'Ready', path: 'availableForPickup' }, { key: 'pickupLfd', label: 'LFD', path: 'pickupLfd' }, - { - key: 'pickupAppointmentAt', - label: 'Pickup Appt', - path: 'pickupAppointmentAt', - }, + { key: 'pickupAppointmentAt', label: 'Pickup Appt', path: 'pickupAppointmentAt' }, { key: 'holdsCount', label: 'Holds', @@ -525,11 +421,7 @@ function buildContainerListDisplay(): ResponseDisplay { compute: 'length', description: 'Count of active holds at POD terminal', }, - { - key: 'holdsAtPodTerminal', - label: 'Hold Details', - path: 'holdsAtPodTerminal', - }, + { key: 'holdsAtPodTerminal', label: 'Hold Details', path: 'holdsAtPodTerminal' }, { key: 'feesCount', label: 'Fees', @@ -537,42 +429,18 @@ function buildContainerListDisplay(): ResponseDisplay { compute: 'length', description: 'Count of fee items at POD terminal', }, - { - key: 'locationAtPodTerminal', - label: 'Terminal Location', - path: 'locationAtPodTerminal', - }, - { - key: 'terminals.podTerminal.name', - label: 'POD Terminal', - path: 'terminals.podTerminal.name', - }, - { - key: 'shipment.billOfLading', - label: 'BL', - path: 'shipment.billOfLading', - }, - { - key: 'shipment.shippingLineScac', - label: 'SCAC', - path: 'shipment.shippingLineScac', - }, - { - key: 'podRailCarrierScac', - label: 'Rail Carrier', - path: 'podRailCarrierScac', - }, + { key: 'locationAtPodTerminal', label: 'Terminal Location', path: 'locationAtPodTerminal' }, + { key: 'terminals.podTerminal.name', label: 'POD Terminal', path: 'terminals.podTerminal.name' }, + { key: 'shipment.billOfLading', label: 'BL', path: 'shipment.billOfLading' }, + { key: 'shipment.shippingLineScac', label: 'SCAC', path: 'shipment.shippingLineScac' }, + { key: 'podRailCarrierScac', label: 'Rail Carrier', path: 'podRailCarrierScac' }, { key: 'indEtaAt', label: 'Inland ETA', path: 'indEtaAt' }, { key: 'indAtaAt', label: 'Inland ATA', path: 'indAtaAt' }, ], column_sets: [ { intent: 'discharged_not_picked_up', - when_user_asks: [ - 'discharged but not picked up', - 'not picked up', - 'still at terminal', - ], + when_user_asks: ['discharged but not picked up', 'not picked up', 'still at terminal'], columns: [ 'number', 'currentStatus', @@ -599,12 +467,7 @@ function buildContainerListDisplay(): ResponseDisplay { }, { intent: 'holds_and_blocks', - when_user_asks: [ - 'holds', - 'blocked', - 'customs hold', - 'why not available', - ], + when_user_asks: ['holds', 'blocked', 'customs hold', 'why not available'], columns: [ 'number', 'currentStatus', @@ -659,20 +522,12 @@ function buildShipmentListDisplay(): ResponseDisplay { { key: 'podAtaAt', label: 'POD ATA', path: 'podAtaAt' }, { key: 'destinationName', label: 'Destination', path: 'destinationName' }, { key: 'destinationEtaAt', label: 'Dest ETA', path: 'destinationEtaAt' }, - { - key: 'lineTrackingLastSucceededAt', - label: 'Last Update', - path: 'lineTrackingLastSucceededAt', - }, + { key: 'lineTrackingLastSucceededAt', label: 'Last Update', path: 'lineTrackingLastSucceededAt' }, ], column_sets: [ { intent: 'vessel_arrivals', - when_user_asks: [ - 'when is vessel arriving', - 'vessel arrival', - 'eta by vessel', - ], + when_user_asks: ['when is vessel arriving', 'vessel arrival', 'eta by vessel'], columns: [ 'podVesselName', 'podVoyageNumber', @@ -731,30 +586,12 @@ function buildTrackingRequestListDisplay(): ResponseDisplay { { intent: 'failed_requests', when_user_asks: ['failed tracking', 'why failed', 'tracking errors'], - columns: [ - 'requestNumber', - 'requestType', - 'status', - 'scac', - 'failedReason', - 'updatedAt', - ], + columns: ['requestNumber', 'requestType', 'status', 'scac', 'failedReason', 'updatedAt'], }, { intent: 'tracking_activity', - when_user_asks: [ - 'recent tracking activity', - 'latest requests', - 'tracking queue', - ], - columns: [ - 'requestNumber', - 'requestType', - 'status', - 'scac', - 'createdAt', - 'updatedAt', - ], + when_user_asks: ['recent tracking activity', 'latest requests', 'tracking queue'], + columns: ['requestNumber', 'requestType', 'status', 'scac', 'createdAt', 'updatedAt'], }, ], selection_strategy: @@ -775,27 +612,22 @@ export function buildListContract( entityType === 'container' ? buildContainerListDisplay() : entityType === 'shipment' - ? buildShipmentListDisplay() - : entityType === 'tracking_request' - ? buildTrackingRequestListDisplay() - : undefined; + ? buildShipmentListDisplay() + : entityType === 'tracking_request' + ? buildTrackingRequestListDisplay() + : undefined; return { purpose: 'Surface aggregate operational worklist results.', can_answer: ['which records match filters', 'count and paging state'], - requires_more_data: - count === 0 ? ['alternative filters or tighter date ranges'] : [], + requires_more_data: count === 0 ? ['alternative filters or tighter date ranges'] : [], relevant_fields: ['items', 'links', 'meta', 'count'], presentation_guidance: count <= 1 ? 'For a single result, provide a concise row summary. For multiple rows, render a markdown table.' : 'Render a markdown table using the response_contract display hints. Avoid dumping full nested records.', suggested_follow_ups: ['list_containers', 'list_tracking_requests'], - suggested_tools: [ - 'list_containers', - 'list_tracking_requests', - 'get_container', - ], + suggested_tools: ['list_containers', 'list_tracking_requests', 'get_container'], display, }; } @@ -863,9 +695,7 @@ function buildListResourceLinks( if (entityType !== 'container') { return []; } - const items = Array.isArray((result as any)?.items) - ? (result as any).items - : []; + 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)); @@ -880,11 +710,7 @@ function wrapToolWithContract( handler: (args: TArgs) => Promise, buildContract?: (result: unknown, args: TArgs) => ResponseContract, buildResourceLinks?: (result: unknown, args: TArgs) => ResourceLinkContent[], -): (args: TArgs) => Promise<{ - content: ToolContent[]; - structuredContent?: any; - isError?: boolean; -}> { +): (args: TArgs) => Promise<{ content: ToolContent[]; structuredContent?: any; isError?: boolean }> { return async (args: TArgs) => { try { const result = await handler(args); @@ -937,10 +763,7 @@ function createCarrierScacCompleter( return async (value: string | undefined): Promise => { try { const search = typeof value === 'string' ? value.trim() : ''; - const { shipping_lines } = await executeGetSupportedShippingLines( - { search }, - client, - ); + const { shipping_lines } = await executeGetSupportedShippingLines({ search }, client); return shipping_lines.slice(0, 100).map((line) => line.scac); } catch { return []; @@ -953,12 +776,7 @@ export function createTerminal49McpServer( apiBaseUrl?: string, accountId?: string, ): McpServer { - const client = new Terminal49Client({ - apiToken, - apiBaseUrl, - accountId, - defaultFormat: 'mapped', - }); + const client = new Terminal49Client({ apiToken, apiBaseUrl, accountId, defaultFormat: "mapped" }); const completeCarrierScac = createCarrierScacCompleter(client); @@ -987,34 +805,25 @@ export function createTerminal49McpServer( 'This is the fastest way to find container information. ' + 'Examples: CAIU2885402, MAEU123456789, or any reference number.', inputSchema: { - query: z - .string() - .min(1) - .describe( - 'Search query - can be a container number, booking number, BL number, or reference number', - ), + query: z.string().min(1).describe('Search query - can be a container number, booking number, BL number, or reference number'), intent: toolIntentSchema, }, outputSchema: { - containers: z.array( - z.object({ - id: z.string(), - container_number: z.string(), - status: z.string(), - shipping_line: z.string(), - pod_terminal: z.string().optional(), - pol_terminal: z.string().optional(), - destination: z.string().optional(), - }), - ), - shipments: z.array( - z.object({ - id: z.string(), - ref_numbers: z.array(z.string()), - shipping_line: z.string(), - container_count: z.number(), - }), - ), + containers: z.array(z.object({ + id: z.string(), + container_number: z.string(), + status: z.string(), + shipping_line: z.string(), + pod_terminal: z.string().optional(), + pol_terminal: z.string().optional(), + destination: z.string().optional(), + })), + shipments: z.array(z.object({ + id: z.string(), + ref_numbers: z.array(z.string()), + shipping_line: z.string(), + container_count: z.number(), + })), total_results: z.number(), _response_contract: responseContractSchema, }, @@ -1022,7 +831,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async ({ query }) => executeSearchContainer({ query }, client), (result, args) => buildSearchContract(result as any, args), - ), + ) ); // Tool 2: Track Container @@ -1035,34 +844,15 @@ export function createTerminal49McpServer( 'Uses inference to choose the carrier/type when possible, creates a tracking request, ' + 'and returns detailed container information.', inputSchema: { - number: z - .string() - .optional() - .describe('Container, bill of lading, or booking number to track'), + number: z.string().optional().describe('Container, bill of lading, or booking number to track'), numberType: z .string() .optional() - .describe( - 'Optional override: container | bill_of_lading | booking_number', - ), - containerNumber: z - .string() - .optional() - .describe('Deprecated alias for number (container)'), - bookingNumber: z - .string() - .optional() - .describe('Deprecated alias for number (booking/BL)'), - scac: z - .string() - .optional() - .describe( - 'Optional SCAC code of the shipping line (e.g., MAEU for Maersk)', - ), - refNumbers: z - .array(z.string()) - .optional() - .describe('Optional reference numbers for matching'), + .describe('Optional override: container | bill_of_lading | booking_number'), + containerNumber: z.string().optional().describe('Deprecated alias for number (container)'), + bookingNumber: z.string().optional().describe('Deprecated alias for number (booking/BL)'), + scac: z.string().optional().describe('Optional SCAC code of the shipping line (e.g., MAEU for Maersk)'), + refNumbers: z.array(z.string()).optional().describe('Optional reference numbers for matching'), intent: toolIntentSchema, }, outputSchema: { @@ -1077,31 +867,13 @@ export function createTerminal49McpServer( }, }, wrapToolWithContract( - async ({ - number, - numberType, - containerNumber, - scac, - bookingNumber, - refNumbers, - }) => + async ({ number, numberType, containerNumber, scac, bookingNumber, refNumbers }) => executeTrackContainer( - { - number, - numberType, - containerNumber, - scac, - bookingNumber, - refNumbers, - }, + { number, numberType, containerNumber, scac, bookingNumber, refNumbers }, client, ), - (result, args) => - buildTrackContract(result as any, { - number: - args.number || args.containerNumber || args.bookingNumber || '', - }), - ), + (result, args) => buildTrackContract(result as any, { number: args.number || args.containerNumber || args.bookingNumber || '' }) + ) ); // Tool 3: Get Container @@ -1114,19 +886,16 @@ export function createTerminal49McpServer( 'plus optional related data. Choose includes based on user question and container state. ' + 'Response includes metadata hints to guide follow-up queries.', inputSchema: { - id: z - .string() - .uuid() - .describe('The Terminal49 container ID (UUID format)'), + id: z.string().uuid().describe('The Terminal49 container ID (UUID format)'), include: z .array(z.enum(['shipment', 'pod_terminal', 'transport_events'])) .optional() .default(['shipment']) .describe( - "Optional related data to include. Default: ['shipment'] covers most use cases. " + - '• shipment: Routing, BOL, line, ref numbers (lightweight, always useful) ' + - '• pod_terminal: Terminal name, location, availability (lightweight, needed for demurrage questions) ' + - '• transport_events: Full event history, rail tracking (heavy 50-100 events, use for journey/timeline questions)', + 'Optional related data to include. Default: [\'shipment\'] covers most use cases. ' + + '• shipment: Routing, BOL, line, ref numbers (lightweight, always useful) ' + + '• pod_terminal: Terminal name, location, availability (lightweight, needed for demurrage questions) ' + + '• transport_events: Full event history, rail tracking (heavy 50-100 events, use for journey/timeline questions)' ), intent: toolIntentSchema, }, @@ -1139,7 +908,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async ({ id, include }) => executeGetContainer({ id, include }, client), () => buildContainerContract(), - ), + ) ); // Tool 4: Get Shipment Details @@ -1152,17 +921,8 @@ export function createTerminal49McpServer( 'Use this when user asks about a shipment (vs a specific container). ' + 'Returns: Bill of Lading, shipping line, port details, vessel info, ETAs, container list.', inputSchema: { - id: z - .string() - .uuid() - .describe('The Terminal49 shipment ID (UUID format)'), - include_containers: z - .boolean() - .optional() - .default(true) - .describe( - 'Include list of containers in this shipment. Default: true', - ), + id: z.string().uuid().describe('The Terminal49 shipment ID (UUID format)'), + include_containers: z.boolean().optional().default(true).describe('Include list of containers in this shipment. Default: true'), intent: toolIntentSchema, }, outputSchema: z @@ -1170,12 +930,12 @@ export function createTerminal49McpServer( _response_contract: responseContractSchema, }) .passthrough(), - }, + }, wrapToolWithContract( async ({ id, include_containers }) => executeGetShipmentDetails({ id, include_containers }, client), () => buildShipmentContract(), - ), + ) ); // Tool 5: Get Container Transport Events @@ -1189,10 +949,7 @@ export function createTerminal49McpServer( 'Use this for questions about journey history, "what happened", timeline analysis, rail tracking. ' + 'More efficient than get_container with transport_events when you only need event data.', inputSchema: { - id: z - .string() - .uuid() - .describe('The Terminal49 container ID (UUID format)'), + id: z.string().uuid().describe('The Terminal49 container ID (UUID format)'), intent: toolIntentSchema, }, outputSchema: z @@ -1204,7 +961,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async ({ id }) => executeGetContainerTransportEvents({ id }, client), (result, args) => buildTransportEventsContract(result as any, args), - ), + ) ); // Tool 6: Get Supported Shipping Lines @@ -1217,10 +974,7 @@ export function createTerminal49McpServer( 'Returns SCAC codes, full names, and common abbreviations. ' + 'Use this when user asks which carriers are supported or to validate a carrier name.', inputSchema: { - search: z - .string() - .optional() - .describe('Optional: Filter by carrier name or SCAC code'), + search: z.string().optional().describe('Optional: Filter by carrier name or SCAC code'), intent: toolIntentSchema, }, outputSchema: { @@ -1232,7 +986,7 @@ export function createTerminal49McpServer( short_name: z.string().optional(), bol_prefix: z.string().optional(), notes: z.string().optional(), - }), + }) ), _metadata: z.object({ presentation_guidance: z.string(), @@ -1243,10 +997,9 @@ export function createTerminal49McpServer( }, }, wrapToolWithContract( - async ({ search }) => - executeGetSupportedShippingLines({ search }, client), + async ({ search }) => executeGetSupportedShippingLines({ search }, client), (result) => buildShippingLineContract(result as any), - ), + ) ); // Tool 7: Get Container Route @@ -1260,10 +1013,7 @@ export function createTerminal49McpServer( 'NOTE: This is a paid feature and may not be available for all accounts. ' + 'Use for questions about routing, transshipments, or detailed vessel itinerary.', inputSchema: { - id: z - .string() - .uuid() - .describe('The Terminal49 container ID (UUID format)'), + id: z.string().uuid().describe('The Terminal49 container ID (UUID format)'), intent: toolIntentSchema, }, // Keep a single permissive schema because this tool can return either @@ -1306,7 +1056,7 @@ export function createTerminal49McpServer( }) .nullable(), }), - }), + }) ) .optional(), created_at: z.string().nullable().optional(), @@ -1327,7 +1077,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async ({ id }) => executeGetContainerRoute({ id }, client), (result, args) => buildRouteContract(result as any, args), - ), + ) ); // Tool 8: List Shipments @@ -1342,22 +1092,12 @@ export function createTerminal49McpServer( status: z.string().optional().describe('Filter by shipment status'), port: z.string().optional().describe('Filter by POD port LOCODE'), carrier: z.string().optional().describe('Filter by shipping line SCAC'), - updated_after: z - .string() - .optional() - .describe('Filter by updated_at (ISO8601) >= value'), + updated_after: z.string().optional().describe('Filter by updated_at (ISO8601) >= value'), include_containers: z .boolean() .optional() - .describe( - 'Include containers relationship in response. Default: true.', - ), - page: z - .number() - .int() - .positive() - .optional() - .describe('Page number (1-based)'), + .describe('Include containers relationship in response. Default: true.'), + page: z.number().int().positive().optional().describe('Page number (1-based)'), page_size: z.number().int().positive().optional().describe('Page size'), intent: toolIntentSchema, }, @@ -1371,7 +1111,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async (args) => executeListShipments(args, client), (result) => buildListContract(result as any, 'shipment'), - ), + ) ); // Tool 9: List Containers @@ -1386,22 +1126,12 @@ export function createTerminal49McpServer( status: z.string().optional().describe('Filter by container status'), port: z.string().optional().describe('Filter by POD port LOCODE'), carrier: z.string().optional().describe('Filter by shipping line SCAC'), - updated_after: z - .string() - .optional() - .describe('Filter by updated_at (ISO8601) >= value'), + updated_after: z.string().optional().describe('Filter by updated_at (ISO8601) >= value'), include: z .string() .optional() - .describe( - 'Comma-separated include list (e.g., shipment,pod_terminal)', - ), - page: z - .number() - .int() - .positive() - .optional() - .describe('Page number (1-based)'), + .describe('Comma-separated include list (e.g., shipment,pod_terminal)'), + page: z.number().int().positive().optional().describe('Page number (1-based)'), page_size: z.number().int().positive().optional().describe('Page size'), intent: toolIntentSchema, }, @@ -1419,7 +1149,7 @@ export function createTerminal49McpServer( // registered terminal49://container/{id} resource, so the client can // resolve full details on demand instead of paying for them up front. (result) => buildListResourceLinks(result, 'container'), - ), + ) ); // Tool 10: List Tracking Requests @@ -1435,20 +1165,11 @@ export function createTerminal49McpServer( .record(z.string(), z.string()) .optional() .describe('Raw query filters (e.g., filter[status]=succeeded)'), - status: z - .string() - .optional() - .describe('Filter by request status (mapped to filter[status])'), - request_type: z - .string() + status: z.string().optional().describe('Filter by request status (mapped to filter[status])'), + request_type: z.string() .optional() .describe('Filter by request type (mapped to filter[request_type])'), - page: z - .number() - .int() - .positive() - .optional() - .describe('Page number (1-based)'), + page: z.number().int().positive().optional().describe('Page number (1-based)'), page_size: z.number().int().positive().optional().describe('Page size'), intent: toolIntentSchema, }, @@ -1462,7 +1183,7 @@ export function createTerminal49McpServer( wrapToolWithContract( async (args) => executeListTrackingRequests(args, client), (result) => buildListContract(result as any, 'tracking_request'), - ), + ) ); // ==================== PROMPTS ==================== @@ -1472,20 +1193,17 @@ export function createTerminal49McpServer( 'track-shipment', { title: 'Track Container Shipment', - description: - 'Quick container tracking workflow with carrier autocomplete', + description: 'Quick container tracking workflow with carrier autocomplete', argsSchema: { - container_number: z - .string() - .describe('Container number (e.g., CAIU1234567)'), + container_number: z.string().describe('Container number (e.g., CAIU1234567)'), // 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() - .optional() - .describe('Shipping line SCAC code (e.g., MAEU for Maersk)'), + z.string().describe('Shipping line SCAC code (e.g., MAEU for Maersk)'), completeCarrierScac, - ), + ).optional(), }, }, async ({ container_number, carrier }) => ({ @@ -1500,7 +1218,7 @@ export function createTerminal49McpServer( }, }, ], - }), + }) ); // Prompt 2: Check Demurrage @@ -1528,7 +1246,7 @@ export function createTerminal49McpServer( }, }, ], - }), + }) ); // Prompt 3: Analyze Delays @@ -1556,7 +1274,7 @@ export function createTerminal49McpServer( }, }, ], - }), + }) ); // ==================== RESOURCES ==================== @@ -1574,7 +1292,7 @@ export function createTerminal49McpServer( return { contents: [resource], }; - }, + } ); // Resource 2: Milestone Glossary (static resource) @@ -1591,7 +1309,7 @@ export function createTerminal49McpServer( return { contents: [resource], }; - }, + } ); // Resource 3: Query Guidance (internal LLM tool routing hints) @@ -1614,7 +1332,7 @@ export function createTerminal49McpServer( }, ], }; - }, + } ); return server; @@ -1631,9 +1349,7 @@ export async function runStdioServer() { console.error('Please set your Terminal49 API token:'); console.error(' export T49_API_TOKEN=your_token_here'); console.error(''); - console.error( - 'Get your API token at: https://app.terminal49.com/developers/api-keys', - ); + console.error('Get your API token at: https://app.terminal49.com/developers/api-keys'); process.exit(1); }