diff --git a/docs/openapi.json b/docs/openapi.json index f4a55534..6021ed84 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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." } } } diff --git a/packages/mcp/src/mcp.test.ts b/packages/mcp/src/mcp.test.ts index 21da2daa..5dd48008 100644 --- a/packages/mcp/src/mcp.test.ts +++ b/packages/mcp/src/mcp.test.ts @@ -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(); + } + }); +}); diff --git a/packages/mcp/src/tools/ignored-filters.test.ts b/packages/mcp/src/tools/ignored-filters.test.ts new file mode 100644 index 00000000..4a0fa1bd --- /dev/null +++ b/packages/mcp/src/tools/ignored-filters.test.ts @@ -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(); + } + }); + } + } +}); diff --git a/packages/mcp/src/tools/ignored-filters.ts b/packages/mcp/src/tools/ignored-filters.ts new file mode 100644 index 00000000..9d0b307e --- /dev/null +++ b/packages/mcp/src/tools/ignored-filters.ts @@ -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.`, + ); + } +} diff --git a/packages/mcp/src/tools/list-containers.ts b/packages/mcp/src/tools/list-containers.ts index b10dfa33..399ce9d1 100644 --- a/packages/mcp/src/tools/list-containers.ts +++ b/packages/mcp/src/tools/list-containers.ts @@ -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; @@ -56,6 +57,8 @@ export async function executeListContainers( }, ); + assertNoIgnoredFilters(result?.meta?.ignored_filters); + const duration = Date.now() - startTime; logMcpEvent({ event: 'tool.execute.complete', diff --git a/packages/mcp/src/tools/summarize-containers.ts b/packages/mcp/src/tools/summarize-containers.ts index e8d1c062..8ccaa098 100644 --- a/packages/mcp/src/tools/summarize-containers.ts +++ b/packages/mcp/src/tools/summarize-containers.ts @@ -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 { @@ -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', diff --git a/sdks/typescript-sdk/src/client.filters.test.ts b/sdks/typescript-sdk/src/client.filters.test.ts index 79043915..47d46f44 100644 --- a/sdks/typescript-sdk/src/client.filters.test.ts +++ b/sdks/typescript-sdk/src/client.filters.test.ts @@ -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', + ); + }); +}); diff --git a/sdks/typescript-sdk/src/client/managers/containers.ts b/sdks/typescript-sdk/src/client/managers/containers.ts index 32211c8b..3aced739 100644 --- a/sdks/typescript-sdk/src/client/managers/containers.ts +++ b/sdks/typescript-sdk/src/client/managers/containers.ts @@ -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(); @@ -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 }), })); } diff --git a/sdks/typescript-sdk/src/generated/terminal49.ts b/sdks/typescript-sdk/src/generated/terminal49.ts index aae7f902..b176108e 100644 --- a/sdks/typescript-sdk/src/generated/terminal49.ts +++ b/sdks/typescript-sdk/src/generated/terminal49.ts @@ -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[]; }; }; };