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
7 changes: 7 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8374,6 +8374,13 @@
"truncated": {
"type": "boolean",
"description": "True when more than 200 groups exist; only the first 200 are returned."
},
"ignored_filters": {
"type": "array",
"items": {
"type": "string"
},
"description": "Requested filter keys that were not applied. Omitted when all filters are recognized; the response remains HTTP 200 for compatibility."
}
}
}
Expand Down
60 changes: 60 additions & 0 deletions packages/mcp/src/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1197,3 +1197,63 @@ describe('trade intelligence tools', () => {
}
});
});

describe('ignored-filter errors at the MCP server boundary', () => {
it.each(['list_containers', 'summarize_containers'])(
'%s exposes correction guidance without any result payload',
async (name) => {
const ignored = ['current_status', 'custom_fields.unknown'];
containersList.mockResolvedValue({
items: [{ id: 'must-not-leak' }],
meta: { total: 99, ignored_filters: ignored },
});
containersSummary.mockResolvedValue({
groups: [{ key: 'must-not-leak', count: 99 }],
total: 99,
ignored_filters: ignored,
});
const { client, handler } = await connectClientForToolCall();
try {
const result = await client.callTool({
name,
arguments:
name === 'summarize_containers'
? { group_by: 'current_status' }
: {},
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toBeUndefined();
const text = JSON.stringify(result.content);
expect(text).toContain('current_status, custom_fields.unknown');
expect(text).toContain('Correct the filter names');
expect(text).toContain('do not silently remove');
expect(text).not.toContain('must-not-leak');
expect(text).not.toContain('applied_filters');
} finally {
await client.close();
await handler.close();
}
},
);

it('keeps malformed upstream diagnostics behind the generic error boundary', async () => {
containersList.mockResolvedValue({
items: [],
meta: { ignored_filters: ['private upstream message: secret'] },
});
const { client, handler } = await connectClientForToolCall();
try {
const result = await client.callTool({
name: 'list_containers',
arguments: {},
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toBeUndefined();
expect(JSON.stringify(result.content)).toContain('Contact support');
expect(JSON.stringify(result)).not.toContain('private upstream');
} finally {
await client.close();
await handler.close();
}
});
});
40 changes: 40 additions & 0 deletions packages/mcp/src/tools/ignored-filters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vite-plus/test';
import { Terminal49Client } from '@terminal49/sdk';
import { executeListContainers } from './list-containers.js';
import { executeSummarizeContainers } from './summarize-containers.js';

// Exercise the real SDK mapping, not a mock that could mask dropped metadata.
describe('container tools refuse ignored filters', () => {
for (const summary of [false, true]) {
for (const ignored of [undefined, [], ['current_status']]) {
it(`${summary ? 'summary' : 'list'} ${ignored?.length ? 'refuses ignored filters' : 'allows supported filters'}`, async () => {
const client = new Terminal49Client({
apiToken: 'TEST_KEY',
fetchImpl: async () =>
Response.json({
data: [],
meta: {
total: 0,
group_by: 'current_status',
truncated: false,
...(ignored === undefined ? {} : { ignored_filters: ignored }),
},
}),
});
const result = summary
? executeSummarizeContainers(
{ group_by: 'current_status', current_status: ['available'] },
client,
)
: executeListContainers({ current_status: ['available'] }, client);
if (ignored?.length) {
await expect(result).rejects.toThrow(
'The container API did not apply these filters: current_status',
);
} else {
await expect(result).resolves.toBeDefined();
}
});
}
}
});
22 changes: 22 additions & 0 deletions packages/mcp/src/tools/ignored-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ToolInputError } from './soft-results.js';

/** Refuse results that the API could not scope to every requested filter. */
export function assertNoIgnoredFilters(ignored: unknown): void {
if (ignored === undefined) return;
if (
!Array.isArray(ignored) ||
!ignored.every(
(key) => typeof key === 'string' && /^[a-zA-Z0-9_.-]{1,128}$/.test(key),
)
) {
throw new Error(
'Invalid ignored_filters diagnostics from the container API; results were withheld.',
);
}
if (ignored.length > 0) {
// Only validated filter identifiers are exposed, never upstream error text.
throw new ToolInputError(
`The container API did not apply these filters: ${ignored.join(', ')}. Results were withheld to avoid presenting unfiltered data. Correct the filter names or account-specific custom field slugs and retry; do not silently remove the requested constraints.`,
);
}
}
3 changes: 3 additions & 0 deletions packages/mcp/src/tools/list-containers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { Terminal49Client } from '@terminal49/sdk';
import { logMcpEvent } from '../logging.js';
import { assertNoIgnoredFilters } from './ignored-filters.js';
import { compactContainer } from './compact-rows.js';

const MAX_PAGE_SIZE = 25;
Expand Down Expand Up @@ -56,6 +57,8 @@ export async function executeListContainers(
},
);

assertNoIgnoredFilters(result?.meta?.ignored_filters);

const duration = Date.now() - startTime;
logMcpEvent({
event: 'tool.execute.complete',
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp/src/tools/summarize-containers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { z } from 'zod';
import { assertNoIgnoredFilters } from './ignored-filters.js';
import { Terminal49Client } from '@terminal49/sdk';
import { logMcpEvent } from '../logging.js';
import {
Expand Down Expand Up @@ -65,6 +66,7 @@ export async function executeSummarizeContainers(
const summary = await client.containers.summary(groupBy, filters as any, {
format: 'mapped',
});
assertNoIgnoredFilters(summary.ignored_filters);
logMcpEvent({
event: 'tool.execute.complete',
tool: 'summarize_containers',
Expand Down
49 changes: 49 additions & 0 deletions sdks/typescript-sdk/src/client.filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,3 +569,52 @@ describe('container summary response contract', () => {
},
);
});

describe('container ignored-filter diagnostics', () => {
for (const format of ['mapped', 'raw'] as const) {
it(`preserves summary diagnostics in ${format} format`, async () => {
const ignored = ['custom_fields.unknown'];
const { client } = clientFixture({
data: [{ key: 'available', label: 'available', count: 3 }],
meta: {
total: 3,
group_by: 'current_status',
truncated: false,
ignored_filters: ignored,
},
});
const result = await client.containers.summary(
'current_status',
{},
{ format },
);
expect(
format === 'mapped'
? result.ignored_filters
: result.meta.ignored_filters,
).toEqual(ignored);
});
it(`preserves list diagnostics in ${format} format`, async () => {
const { client } = clientFixture({
data: [],
meta: { total: 0, ignored_filters: ['custom_fields.unknown'] },
});
const result = await client.containers.list({}, { format });
expect(result.meta.ignored_filters).toEqual(['custom_fields.unknown']);
});
}
it('rejects malformed summary diagnostics', async () => {
const { client } = clientFixture({
data: [],
meta: {
total: 0,
group_by: 'current_status',
truncated: false,
ignored_filters: 'unknown',
},
});
await expect(client.containers.summary('current_status')).rejects.toThrow(
'Invalid container summary response',
);
});
});
8 changes: 7 additions & 1 deletion sdks/typescript-sdk/src/client/managers/containers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ function parseSummaryResponse(
!Number.isSafeInteger(doc.meta.total) ||
doc.meta.total < 0 ||
doc.meta.group_by !== groupBy ||
typeof doc.meta.truncated !== 'boolean'
typeof doc.meta.truncated !== 'boolean' ||
(doc.meta.ignored_filters !== undefined &&
(!Array.isArray(doc.meta.ignored_filters) ||
!doc.meta.ignored_filters.every((key) => typeof key === 'string')))
)
throw invalid();
const keys = new Set<string | null>();
Expand Down Expand Up @@ -191,6 +194,9 @@ export class ContainerManager extends BaseManager {
total: doc.meta.total,
groupBy: doc.meta.group_by,
truncated: doc.meta.truncated,
...(doc.meta.ignored_filters === undefined
? {}
: { ignored_filters: doc.meta.ignored_filters }),
}));
}

Expand Down
2 changes: 2 additions & 0 deletions sdks/typescript-sdk/src/generated/terminal49.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7197,6 +7197,8 @@ export interface operations {
group_by: "current_status" | "pod_terminal" | "shipping_line" | "port_of_discharge" | "hold_type" | "pickup_lfd_date" | "pod_arrival_date";
/** @description True when more than 200 groups exist; only the first 200 are returned. */
truncated: boolean;
/** @description Requested filter keys that were not applied. Omitted when all filters are recognized; the response remains HTTP 200 for compatibility. */
ignored_filters?: string[];
};
};
};
Expand Down
Loading