Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 163 additions & 1 deletion packages/mcp/src/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createTerminal49McpServer,
TERMINAL49_SERVER_INSTRUCTIONS,
} from './server.js';
import { readQueryGuidanceResource } from './resources/query-guidance.js';

vi.mock('@sentry/node', () => ({
captureException: vi.fn(),
Expand All @@ -20,15 +21,17 @@ vi.mock('@sentry/node', () => ({
// Stubbed Terminal49Client so server tools can be exercised end-to-end without
// hitting the live API. Tests configure these mocks per-case. `vi.hoisted`
// is required because vi.mock factories are hoisted above normal declarations.
const { shippingLinesList, containersList } = vi.hoisted(() => ({
const { shippingLinesList, containersList, shipmentsList } = vi.hoisted(() => ({
shippingLinesList: vi.fn(),
containersList: vi.fn(),
shipmentsList: vi.fn(),
}));

vi.mock('@terminal49/sdk', () => ({
Terminal49Client: class Terminal49Client {
shippingLines = { list: shippingLinesList };
containers = { list: containersList };
shipments = { list: shipmentsList };
},
FeatureNotEnabledError: class FeatureNotEnabledError extends Error {},
NotFoundError: class NotFoundError extends Error {},
Expand All @@ -37,6 +40,7 @@ vi.mock('@terminal49/sdk', () => ({
beforeEach(() => {
shippingLinesList.mockReset();
containersList.mockReset();
shipmentsList.mockReset();
});

function _hasResponseContract(schema: unknown): boolean {
Expand Down Expand Up @@ -300,6 +304,29 @@ class MockTransport {
close = vi.fn();
}

async function connectClientForToolCall() {
const handler = createMcpHandler(
() => createTerminal49McpServer('token', 'https://api.test'),
{
legacy: 'stateless',
responseMode: 'json',
},
);
const client = new Client(
{ name: 'terminal49-tool-output-test', version: '1.0.0' },
{ versionNegotiation: { mode: { pin: '2026-07-28' } } },
);
const transport = new StreamableHTTPClientTransport(
new URL('https://mcp.test/mcp'),
{
fetch: (url, init) => handler.fetch(new Request(url, init)),
},
);

await client.connect(transport);
return { client, handler };
}

describe('MCP server wiring', () => {
it('connects without throwing and registers MCP handlers', async () => {
const server = createTerminal49McpServer('token', 'https://api.test');
Expand Down Expand Up @@ -368,6 +395,37 @@ describe('MCP server wiring', () => {
).not.toThrow();
});

it('list input schemas advertise only API-supported filters', () => {
const server = createTerminal49McpServer('token');
const tools = (server as any)._registeredTools as Record<
string,
{ inputSchema: unknown }
>;
const droppedFilters = ['status', 'port', 'carrier', 'updated_after'];

for (const toolName of ['list_containers', 'list_shipments']) {
for (const filter of droppedFilters) {
expect(
_objectSchemaHasProperty(tools[toolName]?.inputSchema, filter),
`${toolName}.${filter}`,
).toBe(false);
}
}

expect(
_objectSchemaHasProperty(tools.list_shipments.inputSchema, 'number'),
).toBe(true);
expect(
_objectSchemaHasProperty(
tools.list_shipments.inputSchema,
'tracking_stopped',
),
).toBe(true);
expect(
_objectSchemaHasProperty(tools.list_shipments.inputSchema, 'include'),
).toBe(true);
});

it('tools include _response_contract in output schemas', () => {
const server = createTerminal49McpServer('token');
const tools = (server as any)._registeredTools as Record<
Expand Down Expand Up @@ -477,9 +535,27 @@ describe('MCP server wiring', () => {
expect(instructions).toMatch(/LFD/);
expect(instructions).toMatch(/search_container/);
expect(instructions).toMatch(/track_container/);
expect(instructions).toMatch(
/do not apply status, port, carrier, or updated_after filters/,
);
expect(instructions.length).toBeGreaterThan(400);
});

it('query guidance does not advertise unsupported list filters', () => {
const guidance = readQueryGuidanceResource();

expect(guidance).not.toContain(
'Supported list filters: status, port, carrier, updated_after',
);
expect(guidance).toContain(
'has no server-side status, port, carrier, or updated-after filter',
);
expect(guidance).toContain(
'cannot currently be server-filtered with these list tools',
);
expect(guidance).toContain('non-empty `unsupportedFilters`');
});

it('returns carrier SCAC completion values over MCP', async () => {
shippingLinesList.mockResolvedValue([
{ scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' },
Expand Down Expand Up @@ -569,6 +645,92 @@ describe('MCP server wiring', () => {
}
});

it.each([
{
name: 'list_containers',
args: { page: 1, page_size: 10 },
listMock: containersList,
payload: {
items: [
{
id: '11111111-1111-1111-1111-111111111111',
number: 'CAIU1234567',
currentStatus: 'available',
terminals: {
podTerminal: { name: 'APM Los Angeles', firmsCode: 'Y123' },
},
},
],
links: {
self: 'https://api.test/containers?page[number]=1&page[size]=10',
next: 'https://api.test/containers?page[number]=2&page[size]=10',
},
meta: { total: 42 },
unsupportedFilters: [],
},
},
{
name: 'list_shipments',
args: {
number: 'MAEU123456789',
include_containers: true,
page: 1,
page_size: 10,
},
listMock: shipmentsList,
payload: {
items: [
{
id: '22222222-2222-2222-2222-222222222222',
billOfLading: 'MAEU123456789',
shippingLineScac: 'MAEU',
containers: [
{
id: '11111111-1111-1111-1111-111111111111',
number: 'CAIU1234567',
},
],
},
],
links: {
self: 'https://api.test/shipments?page[number]=1&page[size]=10',
},
meta: { total: 1 },
unsupportedFilters: [],
},
},
])(
'$name structured content validates with mapped list sidecars',
async ({ name, args, listMock, payload }) => {
listMock.mockResolvedValue(payload);
const { client, handler } = await connectClientForToolCall();

try {
const result = await client.callTool({ name, arguments: args });

expect(result.structuredContent).toMatchObject({
...payload,
_response_contract: {
purpose: expect.any(String),
presentation_guidance: expect.any(String),
suggested_tools: expect.any(Array),
},
});
expect(
result.content.some(
(block) =>
block.type === 'text' &&
block.annotations?.audience?.includes('assistant') &&
block.text.includes('_agent_steering'),
),
).toBe(true);
} finally {
await client.close();
await handler.close();
}
},
);

it('marks steering-only content with audience:[assistant] and keeps the answer user-visible', async () => {
containersList.mockResolvedValue({ items: [], links: {}, meta: {} });

Expand Down
84 changes: 26 additions & 58 deletions packages/mcp/src/resources/query-guidance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,73 +14,41 @@ export function readQueryGuidanceResource(): string {
return [
'# Terminal49 MCP Query Guidance',
'',
'Use this document to map user questions to the right tool sequence, plus output expectations.',
'Use this document to map user questions to tools without claiming that an unsupported filter was applied.',
'',
'## Intent → Tool Mapping',
'## Glossary',
'',
'### 1) Single container status / pickup readiness',
'- Question examples:',
' - "Is container [X] ready for pickup?"',
' - "What is the pickup status?"',
' - "Is container ready for pickup yet?"',
'- Primary tool: get_container',
'- Input: use container UUID from search',
'- Follow-up: call get_container with include: ["shipment","pod_terminal"]',
'- If uncertain state: call get_container_transport_events',
'- **Container number:** an ISO 6346 equipment identifier, normally four letters followed by seven digits (including the check digit), such as `CAIU1234567`.',
'- **Bill of Lading (BOL) / booking number:** shipment identifiers, not container numbers. A shipment can contain multiple containers.',
'- **SCAC:** the four-letter Standard Carrier Alpha Code, such as `MAEU`. A carrier name such as "Maersk" or "Ocean Network Express" is not a SCAC.',
'- **UN/LOCODE:** a five-character location code such as `USLAX`; use the code, not a city name such as "Los Angeles", when an API argument requires a LOCODE.',
'- **POL / POD:** port of lading (origin loading port) / port of discharge (destination unloading port). Do not substitute one for the other.',
'- **tracking_stopped:** a shipment boolean. `true` means Terminal49 is no longer polling the shipping line; `false` means tracking remains active.',
'- **include:** related records to side-load, not a filter. Container includes include `shipment`, `pod_terminal`, and `transport_events`; transport events are the heaviest option.',
'',
'### 2) Current position / what is container doing',
'- Question examples:',
' - "What is container [X] doing?"',
' - "What\'s going on with container [X]?"',
' - "Where is it?"',
'- Primary tool: get_container_transport_events',
'- Input: include container UUID',
'- Output expectation: timeline + event_categories + milestones',
'## Lookup and carrier playbook',
'',
'### 3) Discharge / pickup availability',
'- Question examples:',
' - "Which containers have been discharged but not picked up?"',
' - "Any holds on [X]?"',
'- Primary tools: list_containers or list_shipments then get_container',
'- Supported list filters: status, port, carrier, updated_after (no has_hold filter exists).',
'- "Discharged but not picked up" is derived client-side: keep rows where podDischargedAt is set and podFullOutAt is empty. Hold state comes from the holdsAtPodTerminal field on each row, not a filter.',
'1. For a container number, BOL, booking number, or customer reference, call `search_container`. Do not try to find an identifier by inventing a list filter.',
'2. Resolve a carrier name with `get_supported_shipping_lines` before any carrier-scoped call. Pass the returned SCAC; never pass `"Maersk"` or `"Ocean Network Express"` as a SCAC.',
'3. Use the Terminal49 UUID returned by search with `get_container` or `get_shipment_details`.',
'4. Use `get_container_transport_events` for the milestone timeline and `get_container_route` for multi-leg routing when available.',
'',
'### 4) Arrival / ETAs / delays',
'- Question examples:',
' - "When is [vessel] arriving?"',
' - "What is arriving at LA this week?"',
' - "When should I get to LA this week?"',
'- Primary tool: search_container then get_container for specific container',
'- Secondary: get_container_transport_events for delay context',
'## Honest list behavior',
'',
'### 5) Shipment-level discovery',
'- Question examples:',
' - "Show me everything on BL [X]"',
' - "Show shipment [X] and all containers"',
'- Primary tool: search_container or get_shipment_details',
'- Output expectation: shipment-level identifiers and container list',
'- `list_containers` supports pagination and `include`; it has no server-side status, port, carrier, or updated-after filter.',
'- `list_shipments` supports pagination, `include`, exact original tracking-request `number` (normally a BOL or booking number, not a container number), and `tracking_stopped`.',
'- Requests such as "containers at USLAX", "Maersk fleet", or "recently updated containers/shipments" cannot currently be server-filtered with these list tools. Say so plainly; do not claim the returned page matches that scope.',
'- If a list result has a non-empty `unsupportedFilters` array, those filters were not applied. Treat the page as unscoped and disclose the limitation.',
'- `include` only changes related data in each row. It never narrows the result set.',
'',
'### 6) Demurrage monitoring',
'- Question examples:',
' - "Do I have any containers with demurrage risk?"',
' - "Which containers are at risk of LFD?"',
'- Primary tool: list_containers plus get_container',
'- Supported list filters: status, port, carrier, updated_after. The list endpoint has no server-side sort; order rows client-side by the pickupLfd field returned on each container, and surface holdsAtPodTerminal alongside it.',
'## Client-side operational analysis',
'',
'## Output Formatting Guidance',
'- "Discharged but not picked up": inspect the returned page client-side; keep rows where `podDischargedAt` is set and `podFullOutAt` is empty.',
'- Holds: inspect `holdsAtPodTerminal`; there is no holds list filter.',
'- Last Free Day (LFD) / demurrage risk: order the returned page client-side by `pickupLfd` and show `holdsAtPodTerminal`. The list endpoint has no server-side LFD sort.',
'- These checks apply only to the page retrieved. Do not describe a page-level client-side selection as a complete account-wide result.',
'',
'- Always return concise status first.',
'- Keep containers grouped by outcome state.',
'- When the response includes holdsAtPodTerminal entries, call out those explicitly and escalate urgency.',
'- When dates are missing, explain that latest feed is partial and suggest get_container_transport_events for timeline context.',
'- Always suggest 1-2 concrete next checks when data is incomplete.',
'',
'## Recommended follow-up tool calls',
'',
'1. Use search_container for any unrecognized identifier.',
'2. Resolve to a container UUID.',
'3. Use get_container for baseline state.',
'4. If timeline needed, follow with get_container_transport_events.',
'Return concise status first. Call out holds explicitly. When dates are missing, explain that the feed is partial and suggest the event timeline as the next check.',
'',
].join('\n');
}
Loading
Loading