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: 4 additions & 0 deletions docs/mcp/home.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,6 +109,10 @@ Input and output recording is disabled by default. Leave `SENTRY_MCP_RECORD_INPU

## Tools reference

<Note>
Every tool is read-only except `track_container`, which creates a tracking request. If a container for the number is already in your account, `track_container` returns it instead of creating a new request; otherwise it creates a tracking request, so repeated calls before a container links can create additional requests.
</Note>

### `search_container`

Find containers by container number, BL, booking, or your own reference. This is the fastest way to locate containers.
Expand Down
99 changes: 99 additions & 0 deletions packages/mcp/src/annotations.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest';
import { createTerminal49McpServer } from './server.js';

vi.mock('@sentry/node', () => ({
captureException: vi.fn(),
flush: vi.fn().mockResolvedValue(true),
isInitialized: vi.fn(() => false),
wrapMcpServerWithSentry: vi.fn((server) => server),
}));

type ToolAnnotations = {
readOnlyHint?: boolean;
destructiveHint?: boolean;
idempotentHint?: boolean;
openWorldHint?: boolean;
};

function getRegisteredTools(): Record<
string,
{ annotations?: ToolAnnotations }
> {
const server = createTerminal49McpServer('token');
return (server as any)._registeredTools as Record<
string,
{ annotations?: ToolAnnotations }
>;
Comment thread
dodeja marked this conversation as resolved.
}

describe('MCP tool annotations', () => {
const readOnlyTools = [
'search_container',
'get_container',
'get_container_route',
'get_container_transport_events',
'get_shipment_details',
'get_supported_shipping_lines',
'list_containers',
'list_shipments',
'list_tracking_requests',
];

// get_supported_shipping_lines is a closed-world catalog; the other read
// tools query live shipment data and should be open-world.
const closedWorldReadTools = ['get_supported_shipping_lines'];

it('marks the nine read tools as read-only', () => {
const tools = getRegisteredTools();

for (const name of readOnlyTools) {
const annotations = tools[name]?.annotations;
expect(annotations, name).toBeDefined();
expect(annotations?.readOnlyHint, name).toBe(true);

if (!closedWorldReadTools.includes(name)) {
expect(annotations?.openWorldHint, name).toBe(true);
}
}
});
Comment thread
dodeja marked this conversation as resolved.

it('marks track_container as a non-destructive non-idempotent write', () => {
const tools = getRegisteredTools();
const annotations = tools.track_container?.annotations;

expect(annotations).toBeDefined();
expect(annotations?.readOnlyHint).toBe(false);
expect(annotations?.destructiveHint).toBe(false);
expect(annotations?.idempotentHint).toBe(false);
expect(annotations?.openWorldHint).toBe(true);
});

it('marks get_supported_shipping_lines as a closed-world catalog', () => {
const tools = getRegisteredTools();
const annotations = tools.get_supported_shipping_lines?.annotations;

expect(annotations).toBeDefined();
expect(annotations?.readOnlyHint).toBe(true);
expect(annotations?.openWorldHint).toBe(false);
});

it('annotates every registered tool', () => {
const tools = getRegisteredTools();

// Guard against the SDK renaming/restructuring `_registeredTools`: if the
// map ever resolves to undefined or empty, the per-tool loop below would
// pass vacuously. Assert it actually contains the tools we expect first.
expect(
tools,
'_registeredTools is empty - SDK internals may have changed',
).toBeTruthy();
expect(
Object.keys(tools).length,
'_registeredTools is empty - SDK internals may have changed',
).toBeGreaterThanOrEqual(readOnlyTools.length + 1);

for (const [name, tool] of Object.entries(tools)) {
expect(tool.annotations, name).toBeDefined();
}
});
});
10 changes: 10 additions & 0 deletions packages/mcp/src/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -633,6 +633,7 @@ export function createTerminal49McpServer(
'booking number, bill of lading, or reference number. ' +
'This is the fastest way to find container information. ' +
'Examples: CAIU2885402, MAEU123456789, or any reference number.',
annotations: { readOnlyHint: true, openWorldHint: true },
inputSchema: {
query: z.string().min(1).describe('Search query - can be a container number, booking number, BL number, or reference number'),
intent: toolIntentSchema,
Expand DownExpand Up@@ -672,6 +673,7 @@ export function createTerminal49McpServer(
'Track a container, bill of lading, or booking number. ' +
'Uses inference to choose the carrier/type when possible, creates a tracking request, ' +
'and returns detailed container information.',
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: {
number: z.string().optional().describe('Container, bill of lading, or booking number to track'),
numberType: z
Expand DownExpand Up@@ -714,6 +716,7 @@ export function createTerminal49McpServer(
'Get container information with flexible data loading. Returns core container data (status, location, equipment, dates) ' +
'plus optional related data. Choose includes based on user question and container state. ' +
'Response includes metadata hints to guide follow-up queries.',
annotations: { readOnlyHint: true, openWorldHint: true },
inputSchema: {
id: z.string().uuid().describe('The Terminal49 container ID (UUID format)'),
include: z
Expand DownExpand Up@@ -749,6 +752,7 @@ export function createTerminal49McpServer(
'Get detailed shipment information including routing, BOL, containers, and port details. ' +
'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.',
annotations: { readOnlyHint: true, openWorldHint: true },
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'),
Expand DownExpand Up@@ -777,6 +781,7 @@ export function createTerminal49McpServer(
'(vessel loaded, departed, arrived, discharged, rail movements, delivery). ' +
'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.',
annotations: { readOnlyHint: true, openWorldHint: true },
inputSchema: {
id: z.string().uuid().describe('The Terminal49 container ID (UUID format)'),
intent: toolIntentSchema,
Expand All@@ -802,6 +807,7 @@ export function createTerminal49McpServer(
'Get list of shipping lines (carriers) supported by Terminal49 for container tracking. ' +
'Returns SCAC codes, full names, and common abbreviations. ' +
'Use this when user asks which carriers are supported or to validate a carrier name.',
annotations: { readOnlyHint: true, openWorldHint: false },
inputSchema: {
search: z.string().optional().describe('Optional: Filter by carrier name or SCAC code'),
intent: toolIntentSchema,
Expand DownExpand Up@@ -841,6 +847,7 @@ export function createTerminal49McpServer(
'Shows complete multi-leg journey (origin → transshipment ports → destination). ' +
'NOTE: This is a paid feature and may not be available for all accounts. ' +
'Use for questions about routing, transshipments, or detailed vessel itinerary.',
annotations: { readOnlyHint: true, openWorldHint: true },
inputSchema: {
id: z.string().uuid().describe('The Terminal49 container ID (UUID format)'),
intent: toolIntentSchema,
Expand DownExpand Up@@ -917,6 +924,7 @@ export function createTerminal49McpServer(
description:
'List shipments with optional filters and pagination. ' +
'Use for queries like "show recent shipments" or "shipments for a carrier".',
annotations: { readOnlyHint: true, openWorldHint: true },
inputSchema: {
status: z.string().optional().describe('Filter by shipment status'),
port: z.string().optional().describe('Filter by POD port LOCODE'),
Expand DownExpand Up@@ -951,6 +959,7 @@ export function createTerminal49McpServer(
description:
'List containers with optional filters and pagination. ' +
'Use for queries like "containers at port" or "latest updates".',
annotations: { readOnlyHint: true, openWorldHint: true },
inputSchema: {
status: z.string().optional().describe('Filter by container status'),
port: z.string().optional().describe('Filter by POD port LOCODE'),
Expand DownExpand Up@@ -985,6 +994,7 @@ export function createTerminal49McpServer(
description:
'List tracking requests with optional filters and pagination. ' +
'Useful for monitoring recent tracking activity.',
annotations: { readOnlyHint: true, openWorldHint: true },
inputSchema: {
filters: z
.record(z.string(), z.string())
Expand Down
Loading