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
2,540 changes: 2,184 additions & 356 deletions Terminal49-API.postman_collection.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/api-docs/api-reference/list-filters.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "Shipment and container list filter reference"
title: "Shipment and Container Filter Reference"
sidebarTitle: "List filters"
description: "Reference for every shipment and container list filter in the Terminal49 API: filter names, allowed values, date and presence operators, and AND/OR rules."
description: "Exact public list filter names, values, operators, combination rules, and limitations for shipments and containers."
---

{/* Generated from docs/openapi.json by the SDK generate:types command. */}
Expand Down
5 changes: 3 additions & 2 deletions docs/mcp/home.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,9 @@ Count containers grouped by one dimension, using the same filters as `list_conta
**Parameters**

- The filters accepted by `list_containers` (including `advanced_filters` and `include_stopped_tracking`).
- `group_by` *(string, required)* - `pod_terminal`, `current_status`, `shipping_line`, `hold_type`, `pickup_lfd_date` or `pod_arrival_date`.
- `max_rows` *(number, optional)* - most containers to count. Default 3000, maximum 6000; `truncated` is true when the set is larger.
- `group_by` *(string, required)* - `pod_terminal`, `current_status`, `shipping_line`, `port_of_discharge`, `hold_type`, `pickup_lfd_date` or `pod_arrival_date`.

Counts come from `GET /v2/containers/summary` in one request. `hold_type` counts each distinct current POD hold name, including pending holds, so a container with two holds counts twice; dates follow dashboard display timezones, falling back to UTC when missing or invalid. Groups are ordered by count and capped at 200; `truncated` is true when there are more.

**Good for**

Expand Down
786 changes: 786 additions & 0 deletions docs/openapi.json

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions docs/sdk/reference/client/managers/classes/ContainerManager.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,27 @@ Create a custom field value on a container.

***

### summary()

> **summary**(`groupBy`, `filters?`, `options?`): `Promise`\<`any`\>

Count the containers `list` would return for the same filters, grouped by
one dimension, in one request (`GET /containers/summary`).

#### Parameters

| Parameter | Type |
| ------ | ------ |
| `groupBy` | [`ContainerSummaryGroupBy`](/sdk/reference/types/options/type-aliases/ContainerSummaryGroupBy) |
| `filters` | [`ContainerListFilters`](/sdk/reference/types/filters/interfaces/ContainerListFilters) |
| `options?` | [`CallOptions`](/sdk/reference/types/options/interfaces/CallOptions) |

#### Returns

`Promise`\<`any`\>

***

### update()

> **update**(`id`, `attrs`, `options?`): `Promise`\<`any`\>
Expand Down
1 change: 1 addition & 0 deletions docs/sdk/reference/types/options/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ description: "Terminal49 TypeScript SDK option types: CallOptions, ListOptions,
| Type Alias | Description |
| ------ | ------ |
| [ContainerInclude](/sdk/reference/types/options/type-aliases/ContainerInclude) | - |
| [ContainerSummaryGroupBy](/sdk/reference/types/options/type-aliases/ContainerSummaryGroupBy) | Dimensions `containers.summary` can count by. |
| [IncludeParam](/sdk/reference/types/options/type-aliases/IncludeParam) | - |
| [ResponseFormat](/sdk/reference/types/options/type-aliases/ResponseFormat) | Controls how SDK methods return API responses. |
| [ShipmentInclude](/sdk/reference/types/options/type-aliases/ShipmentInclude) | - |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
title: "Type Alias: ContainerSummaryGroupBy"
---

# Type Alias: ContainerSummaryGroupBy

> **ContainerSummaryGroupBy** = `"current_status"` \| `"pod_terminal"` \| `"shipping_line"` \| `"port_of_discharge"` \| `"hold_type"` \| `"pickup_lfd_date"` \| `"pod_arrival_date"`

Dimensions `containers.summary` can count by.
52 changes: 52 additions & 0 deletions packages/mcp/eval/fixtures.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vite-plus/test';
import type { ToolResult } from './client.js';
import { fixtureItems, fixtureListArgs } from './fixtures.js';

const result = (overrides: Partial<ToolResult> = {}): ToolResult => ({
http: 200,
isError: false,
latencyMs: 1,
bytes: 0,
blocks: [],
payload: { items: [] },
errorMessage: undefined,
rawText: '',
...overrides,
});

describe('fixture discovery', () => {
it('includes historical shipments instead of requiring active tracking', () => {
expect(fixtureListArgs.include_stopped_tracking).toBe(true);
});
it('keeps a genuine empty list distinct from a failed request', () => {
expect(fixtureItems('list_shipments', result())).toEqual([]);
expect(() => fixtureItems('list_shipments', result({ http: 401 }))).toThrow(
'HTTP 401',
);
expect(() =>
fixtureItems('list_containers', result({ isError: true })),
).toThrow('tool error: true');
});
it('rejects malformed payloads without exposing their contents', () => {
expect(() =>
fixtureItems(
'list_shipments',
result({ payload: { error: 'PRIVATE_VALUE' } }),
),
).toThrow('returned no items array');
try {
fixtureItems(
'list_shipments',
result({ isError: true, rawText: 'PRIVATE_VALUE' }),
);
} catch (error) {
expect(String(error)).not.toContain('PRIVATE_VALUE');
}
});
it('preserves discovered rows', () => {
const items = [{ id: 'fixture-id' }];
expect(fixtureItems('list_shipments', result({ payload: { items } }))).toBe(
items,
);
});
});
22 changes: 22 additions & 0 deletions packages/mcp/eval/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { isRecord, type ToolResult } from './client.js';

// Fixture discovery must not depend on shipments still being actively tracked.
export const fixtureListArgs = {
page_size: 10,
include_stopped_tracking: true,
};

export function fixtureItems(tool: string, result: ToolResult): unknown[] {
if (result.http !== 200 || result.isError) {
// Never print upstream messages or payloads: they may contain customer data.
throw new Error(

Check failure on line 12 in packages/mcp/eval/fixtures.ts

View workflow job for this annotation

GitHub Actions / MCP preview 2026-07-28

eval/tools.eval.ts > Terminal49 MCP tool eval

Error: Fixture discovery list_shipments failed (HTTP 200, tool error: true); check eval credentials and upstream availability. ❯ fixtureItems eval/fixtures.ts:12:11 ❯ eval/tools.eval.ts:90:25
`Fixture discovery ${tool} failed (HTTP ${result.http}, tool error: ${result.isError}); check eval credentials and upstream availability.`,
);
}
if (!isRecord(result.payload) || !Array.isArray(result.payload.items)) {
throw new Error(
`Fixture discovery ${tool} returned no items array; check the tool response contract.`,
);
}
return result.payload.items;
}
22 changes: 9 additions & 13 deletions packages/mcp/eval/tools.eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
} from './quality.js';
import { formatScorecard, writeReport, type EvalRow } from './report.js';

import { fixtureItems, fixtureListArgs } from './fixtures.js';

const cfg = resolveConfig();

if (!cfg) {
Expand Down Expand Up @@ -82,13 +84,10 @@ if (!cfg) {

// Discover real ids to feed the detail tools.
const ships = await client.callTool('list_shipments', {
page_size: 10,
...fixtureListArgs,
include_containers: true,
});
const shipItems =
isRecord(ships.payload) && Array.isArray(ships.payload.items)
? ships.payload.items
: [];
const shipItems = fixtureItems('list_shipments', ships);
for (const shipment of shipItems) {
if (!isRecord(shipment) || typeof shipment.id !== 'string') continue;
fixtures.shipmentId ??= shipment.id;
Expand All @@ -108,14 +107,11 @@ if (!cfg) {
}

if (!fixtures.containerId) {
const containersList = await client.callTool('list_containers', {
page_size: 5,
});
const items =
isRecord(containersList.payload) &&
Array.isArray(containersList.payload.items)
? containersList.payload.items
: [];
const containersList = await client.callTool(
'list_containers',
fixtureListArgs,
);
const items = fixtureItems('list_containers', containersList);
const first = items.find(
(c) => isRecord(c) && typeof c.id === 'string',
);
Expand Down
76 changes: 44 additions & 32 deletions packages/mcp/src/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,25 @@ 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 { search, shippingLinesList, containersList, shipmentsList } = vi.hoisted(
() => ({
search: vi.fn(),
shippingLinesList: vi.fn(),
containersList: vi.fn(),
shipmentsList: vi.fn(),
}),
);
const {
search,
shippingLinesList,
containersList,
shipmentsList,
containersSummary,
} = vi.hoisted(() => ({
search: vi.fn(),
shippingLinesList: vi.fn(),
containersList: vi.fn(),
containersSummary: vi.fn(),
shipmentsList: vi.fn(),
}));

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

Expand Down Expand Up @@ -415,31 +421,30 @@ describe('MCP server wiring', () => {
}
});

it('summarize_containers returns a schema-valid uncounted total when pages remain', async () => {
containersList.mockImplementation(
async (_filters: unknown, options: { page: number }) => ({
items: Array.from({ length: 50 }, (_, i) => ({
id: `C${options.page}-${i}`,
terminals: { podTerminal: { name: 'APM' } },
})),
links: {
next: `https://api.example/v2/containers?page[number]=${options.page + 1}`,
},
}),
);
it('summarize_containers returns schema-valid counts from one summary call', async () => {
containersSummary.mockResolvedValue({
total: 3,
truncated: false,
groups: [
{ key: 'customs', label: 'customs', count: 2 },
{ key: null, label: null, count: 1 },
],
});
const server = createTerminal49McpServer('token');
const tool = (server as any)._registeredTools.summarize_containers;
const result = await tool.handler(
{ group_by: 'pod_terminal', max_rows: 50 },
{},
);
const result = await tool.handler({ group_by: 'hold_type' }, {});

expect(result.isError).toBeUndefined();
expect(containersSummary).toHaveBeenCalledTimes(1);
expect(containersSummary.mock.calls[0][0]).toBe('hold_type');
expect(containersList).not.toHaveBeenCalled();
expect(result.structuredContent).toMatchObject({
total: null,
counted: 50,
truncated: true,
groups: [{ key: 'APM', count: 50 }],
total: 3,
group_by: 'hold_type',
groups: [
{ key: 'customs', count: 2 },
{ key: null, count: 1 },
],
});
expect(tool.outputSchema.safeParse(result.structuredContent).success).toBe(
true,
Expand Down Expand Up @@ -502,7 +507,7 @@ describe('MCP server wiring', () => {
}
});

it.each(['list_containers', 'list_shipments'])(
it.each(['list_containers', 'list_shipments', 'summarize_containers'])(
'%s returns actionable SDK validation errors without submitted values',
async (name) => {
const error = Object.assign(
Expand All @@ -513,11 +518,18 @@ describe('MCP server wiring', () => {
);
(name === 'list_containers'
? containersList
: shipmentsList
: name === 'summarize_containers'
? containersSummary
: shipmentsList
).mockRejectedValue(error);
const result = await (
createTerminal49McpServer('token') as any
)._registeredTools[name].handler({ pod_code: '=PRIVATE_VALUE' });
)._registeredTools[name].handler({
pod_code: '=PRIVATE_VALUE',
...(name === 'summarize_containers'
? { group_by: 'pod_terminal' }
: {}),
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe(
'Invalid list filter "pod_code": expected literal values, not comparison expressions. Use the filter values and operators documented in the tool schema.',
Expand Down
28 changes: 15 additions & 13 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -901,7 +901,9 @@ function formatToolError(
}

if (
(toolName === 'list_shipments' || toolName === 'list_containers') &&
(toolName === 'list_shipments' ||
toolName === 'list_containers' ||
toolName === 'summarize_containers') &&
err.name === 'ValidationError'
) {
const details = asRecord(err.details);
Expand Down Expand Up @@ -1419,10 +1421,11 @@ export function createTerminal49McpServer(
{
title: 'Summarize Containers',
description:
'Count containers grouped by POD terminal, status, shipping line, hold type, LFD date or arrival date, using the same filters as list_containers. ' +
'Count containers grouped by POD terminal, status, shipping line, port of discharge, hold type, LFD date or POD arrival date, using the same filters as list_containers. ' +
'Use it for "how many" and "break down by" questions; use list_containers for "which ones". ' +
'Like the dashboard, only actively tracked containers are counted unless include_stopped_tracking is true. ' +
'Report the total, and say so when truncated is true.',
'hold_type counts each distinct current POD hold name, including pending holds, so a container with two holds counts twice; dates follow dashboard display timezones, falling back to UTC when missing or invalid. A null key means the container has no value (no hold, no LFD, no arrival). ' +
'Report the total, and say so when truncated is true (more than 200 groups).',
annotations: {
readOnlyHint: true,
destructiveHint: false,
Expand All @@ -1433,17 +1436,16 @@ export function createTerminal49McpServer(
summarizeContainersInputSchema,
),
outputSchema: z.object({
total: z
.number()
.nullable()
.describe(
'Null when the API reports no total and pages remain uncounted.',
),
counted: z.number(),
truncated: z.boolean(),
total: z.number(),
group_by: z.string(),
groups: z.array(z.object({ key: z.string(), count: z.number() })),
other_groups: z.number(),
truncated: z.boolean(),
groups: z.array(
z.object({
key: z.string().nullable(),
label: z.string().nullable(),
count: z.number(),
}),
),
applied_filters: z.record(z.string(), z.unknown()),
}),
},
Expand Down
Loading
Loading