Skip to content
Merged
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
4 changes: 3 additions & 1 deletion api/mcp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -539,12 +539,14 @@ export default async function handler(req: RequestLike, res: ResponseLike): Prom
});
if (!res.headersSent) {
setCorsHeaders(res);
// The real error is logged above (correlated by request_id). Never echo
// err.message to the client — it can carry upstream URLs, tokens, or
// stack detail.
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
data: err.message,
},
id: null,
});
Expand Down
8 changes: 6 additions & 2 deletions packages/mcp/src/mcp.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -341,10 +341,11 @@ describe('MCP server wiring', () => {
expect(prompts).toContain('check-demurrage');
expect(prompts).toContain('analyze-delays');

expect(resources).toHaveLength(2);
expect(resources).toHaveLength(3);
expect(resourceTemplates).toHaveLength(1);
expect(resources).toContain('terminal49://docs/milestone-glossary');
expect(resources).toContain('terminal49://docs/mcp-query-guidance');
expect(resources).toContain('terminal49://docs/list-display-columns');
expect(resourceTemplates).toContain('container');
});

Expand DownExpand Up@@ -443,7 +444,10 @@ describe('MCP server wiring', () => {

const result = await searchTool.handler({ query: ' ' });

expect(result.content[0].text).toContain('Error: Search query is required');
// DEV-10663: tool errors return a generic, non-leaking message; the raw
// internal error ("Search query is required") must not reach the client.
expect(result.content[0].text).not.toContain('Search query is required');
expect(result.content[0].text).toContain('could not be completed');
expect(result.isError).toBe(true);
expect(Object.hasOwn(result, 'structuredContent')).toBe(false);
});
Expand Down
120 changes: 120 additions & 0 deletions packages/mcp/src/resources/list-display.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
/**
* One-time list display column catalog resource.
*
* The list_* tools used to inline this ~2KB catalog on every response. It is
* static, so it now lives here and is exposed once as an MCP resource. The
* per-call `_response_contract.display` only carries the lightweight defaults
* plus `column_catalog_resource` pointing at this URI.
*/

export type ListDisplayColumn = {
key: string;
label: string;
path?: string;
description?: string;
compute?: string;
};

export const listDisplayColumnsResource = {
uri: 'terminal49://docs/list-display-columns',
name: 'List Display Column Catalog',
description:
'Full column catalog for list_containers / list_shipments / list_tracking_requests responses. ' +
'Fetch once; per-call contracts reference this by URI instead of inlining the catalog.',
mimeType: 'application/json',
};

const CONTAINER_LIST_COLUMNS: ListDisplayColumn[] = [
{ key: 'number', label: 'Container', path: 'number' },
{ key: 'currentStatus', label: 'Status', path: 'currentStatus' },
{ key: 'podDischargedAt', label: 'Discharged', path: 'podDischargedAt' },
{ 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: 'holdsCount',
label: 'Holds',
path: 'holdsAtPodTerminal',
compute: 'length',
description: 'Count of active holds at POD terminal',
},
{
key: 'holdsAtPodTerminal',
label: 'Hold Details',
path: 'holdsAtPodTerminal',
},
{
key: 'feesCount',
label: 'Fees',
path: 'feesAtPodTerminal',
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: 'indEtaAt', label: 'Inland ETA', path: 'indEtaAt' },
{ key: 'indAtaAt', label: 'Inland ATA', path: 'indAtaAt' },
];

const SHIPMENT_LIST_COLUMNS: ListDisplayColumn[] = [
{ key: 'billOfLading', label: 'BL', path: 'billOfLading' },
{ key: 'shippingLineScac', label: 'SCAC', path: 'shippingLineScac' },
{ key: 'shippingLineName', label: 'Carrier', path: 'shippingLineName' },
{ key: 'podVesselName', label: 'Vessel', path: 'podVesselName' },
{ key: 'podVoyageNumber', label: 'Voyage', path: 'podVoyageNumber' },
{ key: 'portOfDischargeName', label: 'POD', path: 'portOfDischargeName' },
{ key: 'podEtaAt', label: 'POD ETA', path: 'podEtaAt' },
{ 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',
},
];

const TRACKING_REQUEST_LIST_COLUMNS: ListDisplayColumn[] = [
{ key: 'requestNumber', label: 'Request Number', path: 'requestNumber' },
{ key: 'requestType', label: 'Type', path: 'requestType' },
{ key: 'status', label: 'Status', path: 'status' },
{ key: 'scac', label: 'SCAC', path: 'scac' },
{ key: 'createdAt', label: 'Created', path: 'createdAt' },
{ key: 'updatedAt', label: 'Updated', path: 'updatedAt' },
{ key: 'failedReason', label: 'Failure Reason', path: 'failedReason' },
{ key: 'isRetrying', label: 'Retrying', path: 'isRetrying' },
];

export const LIST_DISPLAY_COLUMN_CATALOG = {
container: CONTAINER_LIST_COLUMNS,
shipment: SHIPMENT_LIST_COLUMNS,
tracking_request: TRACKING_REQUEST_LIST_COLUMNS,
} as const;

export function readListDisplayColumnsResource(): string {
return JSON.stringify(LIST_DISPLAY_COLUMN_CATALOG, null, 2);
}
7 changes: 4 additions & 3 deletions packages/mcp/src/resources/query-guidance.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,7 +42,8 @@ export function readQueryGuidanceResource(): string {
' - "Which containers have been discharged but not picked up?"',
' - "Any holds on [X]?"',
'- Primary tools: list_containers or list_shipments then get_container',
'- Filter: status=discharged then has_hold from holds_at_pod_terminal',
'- 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.',
'',
'### 4) Arrival / ETAs / delays',
'- Question examples:',
Expand All@@ -64,13 +65,13 @@ export function readQueryGuidanceResource(): string {
' - "Do I have any containers with demurrage risk?"',
' - "Which containers are at risk of LFD?"',
'- Primary tool: list_containers plus get_container',
'- Output expectation: sortby demurrage.pickup_lfd and hold flags',
'- 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.',
'',
'## Output Formatting Guidance',
'',
'- Always return concise status first.',
'- Keep containers grouped by outcome state.',
'- When the response includes holds_at_pod_terminal, call out those explicitly and escalate urgency.',
'- 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.',
'',
Expand Down
Loading
Loading