From b86515701456fe5a2f9a5d632c04ce6c4f59672d Mon Sep 17 00:00:00 2001 From: akushonkamen Date: Sat, 10 Oct 2026 16:40:25 +0800 Subject: [PATCH 1/4] fix(mcp): keep inputSchema $defs/definitions when converting preloaded MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit convertMCPServersToTools rebuilt function-tool parameters from only inputSchema.properties/required, dropping top-level $defs/definitions while keeping the $refs that point into them. FastMCP-style servers (official MCP Python SDK) hoist every nested pydantic model to top-level $defs, so any preloaded tool with structured args reached the LLM with dangling #/defs-style refs — failing provider schema validation or hiding the argument shape from the model. Carry both containers over only when present, so schemas without them are emitted byte-identically to before. Fixes #945 --- .../src/core/mcp/convertMCPServers.ts | 5 + .../core/mcp/convertMCPServersSchema.test.ts | 176 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 packages/trueforge-core/tests/core/mcp/convertMCPServersSchema.test.ts diff --git a/packages/trueforge-core/src/core/mcp/convertMCPServers.ts b/packages/trueforge-core/src/core/mcp/convertMCPServers.ts index 9da2e6b3f5..d31e3a421a 100644 --- a/packages/trueforge-core/src/core/mcp/convertMCPServers.ts +++ b/packages/trueforge-core/src/core/mcp/convertMCPServers.ts @@ -76,6 +76,9 @@ export async function convertMCPServersToTools(params: { const { name: toolName } = getUniqueSanitizedToolName(mcpTool.name, registeredNames); registeredNames.add(toolName); const description = `mcp server: ${serverName}\n${mcpTool.description ?? ''}`; + // Keep local definition containers so `$ref`s inside `properties` (e.g. FastMCP-style + // `#/$defs/` pointers to nested pydantic models) resolve on the wire. + const { $defs, definitions } = mcpTool.inputSchema; tools.push({ type: 'function', function: { @@ -85,6 +88,8 @@ export async function convertMCPServersToTools(params: { type: 'object', properties: mcpTool.inputSchema.properties ?? {}, required: mcpTool.inputSchema.required ?? [], + ...($defs !== undefined ? { $defs } : {}), + ...(definitions !== undefined ? { definitions } : {}), }, }, }); diff --git a/packages/trueforge-core/tests/core/mcp/convertMCPServersSchema.test.ts b/packages/trueforge-core/tests/core/mcp/convertMCPServersSchema.test.ts new file mode 100644 index 0000000000..021f35eaf4 --- /dev/null +++ b/packages/trueforge-core/tests/core/mcp/convertMCPServersSchema.test.ts @@ -0,0 +1,176 @@ +import type { ChatCompletionTool } from 'openai/resources/chat/completions'; +import type { AgentToolSchema, IToolSet, ListToolsResponse } from '../../../src/core/mcp/IMCPServer'; +import { convertMCPServersToTools } from '../../../src/core/mcp/convertMCPServers'; +import '../harnessMocks'; +import { makeMockIMCPServer } from '../harnessMocks'; + +/** + * Schema shape emitted by the official MCP Python SDK (FastMCP): every nested + * pydantic model is hoisted to the top-level `$defs` container and referenced + * with `#/$defs/` JSON pointers. + */ +const FASTMCP_STYLE_INPUT_SCHEMA = { + type: 'object' as const, + title: 'create_contactArguments', + properties: { + name: { title: 'Name', type: 'string' }, + address: { $ref: '#/$defs/Address' }, + }, + required: ['name', 'address'], + $defs: { + Address: { + properties: { + street: { title: 'Street', type: 'string' }, + city: { title: 'City', type: 'string' }, + }, + required: ['street', 'city'], + type: 'object', + title: 'Address', + }, + }, +}; + +/** Draft-04 spelling of the same container, still produced by older generators. */ +const DRAFT04_DEFINITIONS_INPUT_SCHEMA = { + type: 'object' as const, + properties: { + address: { $ref: '#/definitions/Address' }, + }, + required: ['address'], + definitions: { + Address: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + required: ['city'], + }, + }, +}; + +function makeServer(name: string, tools: AgentToolSchema[]): IToolSet { + const base = makeMockIMCPServer({ name, preload: true, tools }); + return { + ...base, + listTools: jest.fn((): Promise => + Promise.resolve({ + result: { tools }, + wasInitialized: undefined, + }), + ), + }; +} + +/** `ChatCompletionTool` is a union; narrow to the function variant and return its `parameters`. */ +function firstFunctionParameters(tool: ChatCompletionTool | undefined): Record | undefined { + if (tool === undefined) { + return undefined; + } + if (tool.type !== 'function') { + throw new Error('Expected converted tool to be a function tool'); + } + return tool.function.parameters; +} + +/** + * Collect every local `$ref` in the schema that cannot be resolved by walking + * JSON-pointer segments from the root, so dangling refs are reported precisely. + */ +function collectUnresolvableRefs(root: unknown, node: unknown = root): string[] { + const dangling: string[] = []; + if (Array.isArray(node)) { + for (const item of node) { + dangling.push(...collectUnresolvableRefs(root, item)); + } + return dangling; + } + if (node === null || typeof node !== 'object') { + return dangling; + } + for (const [key, value] of Object.entries(node)) { + if (key === '$ref' && typeof value === 'string' && value.startsWith('#/')) { + let cursor: unknown = root; + let resolved = true; + for (const rawSegment of value.slice(2).split('/')) { + const segment = rawSegment.replace(/~1/g, '/').replace(/~0/g, '~'); + if (cursor !== null && typeof cursor === 'object' && segment in (cursor as Record)) { + cursor = (cursor as Record)[segment]; + } else { + resolved = false; + break; + } + } + if (!resolved) { + dangling.push(value); + } + } else { + dangling.push(...collectUnresolvableRefs(root, value)); + } + } + return dangling; +} + +describe('convertMCPServersToTools JSON Schema containers', () => { + it('preserves FastMCP-style top-level $defs so every $ref resolves after conversion', async () => { + const tools: AgentToolSchema[] = [ + { + name: 'create_contact', + description: 'Create a contact', + inputSchema: FASTMCP_STYLE_INPUT_SCHEMA, + preload: true, + }, + ]; + const { convertedTools } = await convertMCPServersToTools({ + tfyManagedServers: [], + userServers: [makeServer('fastmcp-server', tools)], + }); + + expect(convertedTools.tools).toHaveLength(1); + const parameters = firstFunctionParameters(convertedTools.tools[0]); + expect(parameters).toBeDefined(); + // The $defs container must survive the conversion… + expect(parameters?.['$defs']).toEqual(FASTMCP_STYLE_INPUT_SCHEMA.$defs); + // …so that every local $ref in the emitted schema resolves against it. + expect(collectUnresolvableRefs(parameters)).toEqual([]); + }); + + it('preserves the draft-04 definitions container for legacy schemas', async () => { + const tools: AgentToolSchema[] = [ + { + name: 'legacy_tool', + description: 'Legacy schema tool', + inputSchema: DRAFT04_DEFINITIONS_INPUT_SCHEMA, + preload: true, + }, + ]; + const { convertedTools } = await convertMCPServersToTools({ + tfyManagedServers: [], + userServers: [makeServer('legacy-server', tools)], + }); + + const parameters = firstFunctionParameters(convertedTools.tools[0]); + expect(parameters?.['definitions']).toEqual(DRAFT04_DEFINITIONS_INPUT_SCHEMA.definitions); + expect(collectUnresolvableRefs(parameters)).toEqual([]); + }); + + it('does not introduce $defs or definitions for schemas without them', async () => { + const tools: AgentToolSchema[] = [ + { + name: 'plain_tool', + description: 'Plain tool', + inputSchema: { type: 'object', properties: {} }, + preload: true, + }, + ]; + const { convertedTools } = await convertMCPServersToTools({ + tfyManagedServers: [], + userServers: [makeServer('plain-server', tools)], + }); + + expect(convertedTools.tools).toHaveLength(1); + const parameters = firstFunctionParameters(convertedTools.tools[0]); + expect(parameters).toEqual({ type: 'object', properties: {}, required: [] }); + expect(Object.keys(parameters ?? {})).not.toContain('$defs'); + expect(Object.keys(parameters ?? {})).not.toContain('definitions'); + }); +}); From 24d0e80896cc947729e43e41846e082a49a66aa7 Mon Sep 17 00:00:00 2001 From: akushonkamen Date: Tue, 13 Oct 2026 13:38:37 +0800 Subject: [PATCH 2/4] test(mcp): pin model-vendor compatibility of the converted tool schemas Review ask on #1030: check model-vendor compatibility before merging. The converted parameters flow convertTools() -> jsonSchema() -> the configured @ai-sdk provider package. Against the pinned provider sources: @ai-sdk/anthropic preserves both $defs and legacy definitions recursively; @ai-sdk/google passes unknown keywords (and $defs explicitly) through unstripped; @ai-sdk/openai and the OpenAI-compatible vendors ship the parameters JSON Schema verbatim. The tests pin what the converter itself controls: emitted parameters stay on the vendor-accepted top-level surface (type/properties/required plus the two container spellings and nothing else), every local $ref resolves inside the emitted document for both spellings, and container-less schemas are emitted byte-identically so vendor behavior for them is unchanged. --- .../core/mcp/convertMCPServersVendors.test.ts | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 packages/trueforge-core/tests/core/mcp/convertMCPServersVendors.test.ts diff --git a/packages/trueforge-core/tests/core/mcp/convertMCPServersVendors.test.ts b/packages/trueforge-core/tests/core/mcp/convertMCPServersVendors.test.ts new file mode 100644 index 0000000000..7177200d47 --- /dev/null +++ b/packages/trueforge-core/tests/core/mcp/convertMCPServersVendors.test.ts @@ -0,0 +1,187 @@ +import type { ChatCompletionTool } from 'openai/resources/chat/completions'; +import type { AgentToolSchema, IToolSet, ListToolsResponse } from '../../../src/core/mcp/IMCPServer'; +import { convertMCPServersToTools } from '../../../src/core/mcp/convertMCPServers'; +import '../harnessMocks'; +import { makeMockIMCPServer } from '../harnessMocks'; + +/** + * Model-vendor compatibility check for MCP tool schemas (review ask on #1030: + * "do a check for model vendors compatibility"). + * + * The conversion output flows `convertMCPServersToTools` → `convertTools()` + * (`src/core/llm/VercelAILLM.ts`, wrapping `parameters` via the AI SDK's + * `jsonSchema()`) → the provider package for the configured vendor. The + * provider switch in `buildLanguageModel` supports: openai, anthropic, + * google-gemini, moonshot, alibaba, and the OpenAI-compatible fireworks, zai, + * together, custom and truefoundry endpoints. + * + * Per-package behavior for the containers this PR now carries over (verified + * against the pinned `@ai-sdk/*` sources in this repo's lockfile): + * - `@ai-sdk/anthropic` explicitly preserves BOTH `$defs` and legacy + * `definitions`, recursively, into the tool `input_schema`. + * - `@ai-sdk/google`'s `sanitizeResponseJsonSchema` spreads unknown keywords + * through (`...result`) and handles `$defs` explicitly, so `$ref`, + * `definitions` and `$defs` all reach `functionDeclarations` unstripped. + * - `@ai-sdk/openai` (chat + responses) and `@ai-sdk/openai-compatible` + * (fireworks, zai, together, custom, truefoundry, moonshot, alibaba) ship + * the tool `parameters` JSON Schema essentially verbatim; `$defs`/`$ref` is + * part of the OpenAI tool-calling schema dialect. + * + * What this converter itself must therefore guarantee for every vendor is: + * (1) the top-level `parameters` object stays on the vendor-accepted surface + * (`type`/`properties`/`required` plus the two container spellings and + * nothing else), and (2) every local `$ref` resolves inside the emitted + * schema document, whichever container spelling the server used. The tests + * below pin exactly that. + */ + +/** Schema shape emitted by the official MCP Python SDK (FastMCP). */ +const FASTMCP_STYLE_INPUT_SCHEMA = { + type: 'object' as const, + title: 'create_contactArguments', + properties: { + name: { title: 'Name', type: 'string' }, + address: { $ref: '#/$defs/Address' }, + }, + required: ['name', 'address'], + $defs: { + Address: { + properties: { + street: { title: 'Street', type: 'string' }, + city: { title: 'City', type: 'string' }, + }, + required: ['street', 'city'], + type: 'object', + title: 'Address', + }, + }, +}; + +/** Draft-04 spelling, still produced by older schema generators. */ +const DRAFT04_DEFINITIONS_INPUT_SCHEMA = { + type: 'object' as const, + properties: { + address: { $ref: '#/definitions/Address' }, + }, + required: ['address'], + definitions: { + Address: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + required: ['city'], + }, + }, +}; + +/** Top-level keywords every supported vendor accepts on a function tool. */ +const VENDOR_ACCEPTED_PARAMETER_KEYS = new Set(['type', 'properties', 'required', '$defs', 'definitions']); + +function makeServer(name: string, tools: AgentToolSchema[]): IToolSet { + const base = makeMockIMCPServer({ name, preload: true, tools }); + return { + ...base, + listTools: jest.fn((): Promise => + Promise.resolve({ + result: { tools }, + wasInitialized: undefined, + }), + ), + }; +} + +/** Narrow `ChatCompletionTool` to the function variant and return its `parameters`. */ +function firstFunctionParameters(tool: ChatCompletionTool | undefined): Record | undefined { + if (tool === undefined) { + return undefined; + } + if (tool.type !== 'function') { + throw new Error('Expected converted tool to be a function tool'); + } + return tool.function.parameters; +} + +/** Collect every local `$ref` that cannot be resolved by walking JSON pointers from the root. */ +function collectUnresolvableRefs(root: unknown, node: unknown = root): string[] { + const dangling: string[] = []; + if (Array.isArray(node)) { + for (const item of node) { + dangling.push(...collectUnresolvableRefs(root, item)); + } + return dangling; + } + if (node === null || typeof node !== 'object') { + return dangling; + } + for (const [key, value] of Object.entries(node)) { + if (key === '$ref' && typeof value === 'string' && value.startsWith('#/')) { + let cursor: unknown = root; + let resolved = true; + for (const rawSegment of value.slice(2).split('/')) { + const segment = rawSegment.replace(/~1/g, '/').replace(/~0/g, '~'); + if (cursor !== null && typeof cursor === 'object' && segment in (cursor as Record)) { + cursor = (cursor as Record)[segment]; + } else { + resolved = false; + break; + } + } + if (!resolved) { + dangling.push(value); + } + } else { + dangling.push(...collectUnresolvableRefs(root, value)); + } + } + return dangling; +} + +async function convertSingleTool(inputSchema: AgentToolSchema['inputSchema']) { + const tools: AgentToolSchema[] = [ + { + name: 'vendor_tool', + description: 'Vendor compatibility probe', + inputSchema, + preload: true, + }, + ]; + const { convertedTools } = await convertMCPServersToTools({ + tfyManagedServers: [], + userServers: [makeServer('vendor-server', tools)], + }); + expect(convertedTools.tools).toHaveLength(1); + return firstFunctionParameters(convertedTools.tools[0]); +} + +describe('convertMCPServersToTools model-vendor compatibility', () => { + it.each([ + ['FastMCP-style $defs', FASTMCP_STYLE_INPUT_SCHEMA], + ['draft-04 definitions', DRAFT04_DEFINITIONS_INPUT_SCHEMA], + ])('keeps the emitted parameters on the vendor-accepted surface (%s)', async (_label, inputSchema) => { + const parameters = await convertSingleTool(inputSchema); + expect(parameters).toBeDefined(); + // Nothing beyond the vendor-accepted top-level keywords may leak: a + // stray key is exactly the kind of surface change that breaks one + // provider's schema validation while passing another's. + expect(Object.keys(parameters ?? {}).every(key => VENDOR_ACCEPTED_PARAMETER_KEYS.has(key))).toBe(true); + // The minimal function-tool surface every vendor requires. + expect(parameters?.['type']).toBe('object'); + expect(Array.isArray(parameters?.['required'])).toBe(true); + }); + + it.each([ + ['FastMCP-style $defs', FASTMCP_STYLE_INPUT_SCHEMA], + ['draft-04 definitions', DRAFT04_DEFINITIONS_INPUT_SCHEMA], + ])('resolves every local $ref inside the emitted schema document (%s)', async (_label, inputSchema) => { + const parameters = await convertSingleTool(inputSchema); + // A dangling local ref fails provider-side schema validation on every + // vendor; carrying the container over is what keeps this list empty. + expect(collectUnresolvableRefs(parameters)).toEqual([]); + }); + + it('emits container-less schemas byte-identically, so vendor behavior for them is unchanged', async () => { + const parameters = await convertSingleTool({ type: 'object', properties: {} }); + expect(parameters).toEqual({ type: 'object', properties: {}, required: [] }); + }); +}); From dad738ff86e3e5f59c91b9a00d5bd235f175fd2b Mon Sep 17 00:00:00 2001 From: akushonkamen Date: Mon, 19 Oct 2026 23:47:08 +0800 Subject: [PATCH 3/4] chore(changeset): add patch changeset for trueforge-core MCP $defs fix --- .changeset/mcp-preserve-inputschema-defs.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mcp-preserve-inputschema-defs.md diff --git a/.changeset/mcp-preserve-inputschema-defs.md b/.changeset/mcp-preserve-inputschema-defs.md new file mode 100644 index 0000000000..2493e40831 --- /dev/null +++ b/.changeset/mcp-preserve-inputschema-defs.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-core': patch +--- + +Keep local `$defs`/`definitions` containers when converting preloaded MCP tool input schemas, so `$ref` pointers resolve instead of being dropped. From d2b43265a8287a0f447205cccd5a3db788c2bd7d Mon Sep 17 00:00:00 2001 From: Chirag Jain Date: Thu, 22 Oct 2026 14:10:50 +0530 Subject: [PATCH 4/4] Reduce the amount of tests --- .../core/mcp/convertMCPServersSchema.test.ts | 141 +++---------- .../core/mcp/convertMCPServersVendors.test.ts | 187 ------------------ 2 files changed, 24 insertions(+), 304 deletions(-) delete mode 100644 packages/trueforge-core/tests/core/mcp/convertMCPServersVendors.test.ts diff --git a/packages/trueforge-core/tests/core/mcp/convertMCPServersSchema.test.ts b/packages/trueforge-core/tests/core/mcp/convertMCPServersSchema.test.ts index 021f35eaf4..d7ff290268 100644 --- a/packages/trueforge-core/tests/core/mcp/convertMCPServersSchema.test.ts +++ b/packages/trueforge-core/tests/core/mcp/convertMCPServersSchema.test.ts @@ -4,33 +4,24 @@ import { convertMCPServersToTools } from '../../../src/core/mcp/convertMCPServer import '../harnessMocks'; import { makeMockIMCPServer } from '../harnessMocks'; -/** - * Schema shape emitted by the official MCP Python SDK (FastMCP): every nested - * pydantic model is hoisted to the top-level `$defs` container and referenced - * with `#/$defs/` JSON pointers. - */ +/** FastMCP hoists nested models to top-level `$defs` with `#/$defs/` refs. */ const FASTMCP_STYLE_INPUT_SCHEMA = { type: 'object' as const, - title: 'create_contactArguments', properties: { - name: { title: 'Name', type: 'string' }, + name: { type: 'string' }, address: { $ref: '#/$defs/Address' }, }, required: ['name', 'address'], $defs: { Address: { - properties: { - street: { title: 'Street', type: 'string' }, - city: { title: 'City', type: 'string' }, - }, - required: ['street', 'city'], type: 'object', - title: 'Address', + properties: { street: { type: 'string' }, city: { type: 'string' } }, + required: ['street', 'city'], }, }, }; -/** Draft-04 spelling of the same container, still produced by older generators. */ +/** Draft-04 spelling still emitted by older generators. */ const DRAFT04_DEFINITIONS_INPUT_SCHEMA = { type: 'object' as const, properties: { @@ -40,9 +31,7 @@ const DRAFT04_DEFINITIONS_INPUT_SCHEMA = { definitions: { Address: { type: 'object', - properties: { - city: { type: 'string' }, - }, + properties: { city: { type: 'string' } }, required: ['city'], }, }, @@ -61,116 +50,34 @@ function makeServer(name: string, tools: AgentToolSchema[]): IToolSet { }; } -/** `ChatCompletionTool` is a union; narrow to the function variant and return its `parameters`. */ -function firstFunctionParameters(tool: ChatCompletionTool | undefined): Record | undefined { - if (tool === undefined) { - return undefined; - } - if (tool.type !== 'function') { - throw new Error('Expected converted tool to be a function tool'); +function functionParameters(tool: ChatCompletionTool | undefined): Record | undefined { + if (tool === undefined || tool.type !== 'function') { + throw new Error('Expected a function tool'); } return tool.function.parameters; } -/** - * Collect every local `$ref` in the schema that cannot be resolved by walking - * JSON-pointer segments from the root, so dangling refs are reported precisely. - */ -function collectUnresolvableRefs(root: unknown, node: unknown = root): string[] { - const dangling: string[] = []; - if (Array.isArray(node)) { - for (const item of node) { - dangling.push(...collectUnresolvableRefs(root, item)); - } - return dangling; - } - if (node === null || typeof node !== 'object') { - return dangling; - } - for (const [key, value] of Object.entries(node)) { - if (key === '$ref' && typeof value === 'string' && value.startsWith('#/')) { - let cursor: unknown = root; - let resolved = true; - for (const rawSegment of value.slice(2).split('/')) { - const segment = rawSegment.replace(/~1/g, '/').replace(/~0/g, '~'); - if (cursor !== null && typeof cursor === 'object' && segment in (cursor as Record)) { - cursor = (cursor as Record)[segment]; - } else { - resolved = false; - break; - } - } - if (!resolved) { - dangling.push(value); - } - } else { - dangling.push(...collectUnresolvableRefs(root, value)); - } - } - return dangling; +async function convertParameters(inputSchema: AgentToolSchema['inputSchema']) { + const tools: AgentToolSchema[] = [{ name: 'probe', description: 'probe', inputSchema, preload: true }]; + const { convertedTools } = await convertMCPServersToTools({ + tfyManagedServers: [], + userServers: [makeServer('server', tools)], + }); + expect(convertedTools.tools).toHaveLength(1); + return functionParameters(convertedTools.tools[0]); } describe('convertMCPServersToTools JSON Schema containers', () => { - it('preserves FastMCP-style top-level $defs so every $ref resolves after conversion', async () => { - const tools: AgentToolSchema[] = [ - { - name: 'create_contact', - description: 'Create a contact', - inputSchema: FASTMCP_STYLE_INPUT_SCHEMA, - preload: true, - }, - ]; - const { convertedTools } = await convertMCPServersToTools({ - tfyManagedServers: [], - userServers: [makeServer('fastmcp-server', tools)], - }); - - expect(convertedTools.tools).toHaveLength(1); - const parameters = firstFunctionParameters(convertedTools.tools[0]); - expect(parameters).toBeDefined(); - // The $defs container must survive the conversion… - expect(parameters?.['$defs']).toEqual(FASTMCP_STYLE_INPUT_SCHEMA.$defs); - // …so that every local $ref in the emitted schema resolves against it. - expect(collectUnresolvableRefs(parameters)).toEqual([]); - }); - - it('preserves the draft-04 definitions container for legacy schemas', async () => { - const tools: AgentToolSchema[] = [ - { - name: 'legacy_tool', - description: 'Legacy schema tool', - inputSchema: DRAFT04_DEFINITIONS_INPUT_SCHEMA, - preload: true, - }, - ]; - const { convertedTools } = await convertMCPServersToTools({ - tfyManagedServers: [], - userServers: [makeServer('legacy-server', tools)], - }); - - const parameters = firstFunctionParameters(convertedTools.tools[0]); - expect(parameters?.['definitions']).toEqual(DRAFT04_DEFINITIONS_INPUT_SCHEMA.definitions); - expect(collectUnresolvableRefs(parameters)).toEqual([]); + it.each([ + ['$defs', FASTMCP_STYLE_INPUT_SCHEMA, FASTMCP_STYLE_INPUT_SCHEMA.$defs], + ['definitions', DRAFT04_DEFINITIONS_INPUT_SCHEMA, DRAFT04_DEFINITIONS_INPUT_SCHEMA.definitions], + ] as const)('preserves top-level %s so local $refs stay resolvable', async (container, inputSchema, expected) => { + const parameters = await convertParameters(inputSchema); + expect(parameters?.[container]).toEqual(expected); }); it('does not introduce $defs or definitions for schemas without them', async () => { - const tools: AgentToolSchema[] = [ - { - name: 'plain_tool', - description: 'Plain tool', - inputSchema: { type: 'object', properties: {} }, - preload: true, - }, - ]; - const { convertedTools } = await convertMCPServersToTools({ - tfyManagedServers: [], - userServers: [makeServer('plain-server', tools)], - }); - - expect(convertedTools.tools).toHaveLength(1); - const parameters = firstFunctionParameters(convertedTools.tools[0]); + const parameters = await convertParameters({ type: 'object', properties: {} }); expect(parameters).toEqual({ type: 'object', properties: {}, required: [] }); - expect(Object.keys(parameters ?? {})).not.toContain('$defs'); - expect(Object.keys(parameters ?? {})).not.toContain('definitions'); }); }); diff --git a/packages/trueforge-core/tests/core/mcp/convertMCPServersVendors.test.ts b/packages/trueforge-core/tests/core/mcp/convertMCPServersVendors.test.ts deleted file mode 100644 index 7177200d47..0000000000 --- a/packages/trueforge-core/tests/core/mcp/convertMCPServersVendors.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { ChatCompletionTool } from 'openai/resources/chat/completions'; -import type { AgentToolSchema, IToolSet, ListToolsResponse } from '../../../src/core/mcp/IMCPServer'; -import { convertMCPServersToTools } from '../../../src/core/mcp/convertMCPServers'; -import '../harnessMocks'; -import { makeMockIMCPServer } from '../harnessMocks'; - -/** - * Model-vendor compatibility check for MCP tool schemas (review ask on #1030: - * "do a check for model vendors compatibility"). - * - * The conversion output flows `convertMCPServersToTools` → `convertTools()` - * (`src/core/llm/VercelAILLM.ts`, wrapping `parameters` via the AI SDK's - * `jsonSchema()`) → the provider package for the configured vendor. The - * provider switch in `buildLanguageModel` supports: openai, anthropic, - * google-gemini, moonshot, alibaba, and the OpenAI-compatible fireworks, zai, - * together, custom and truefoundry endpoints. - * - * Per-package behavior for the containers this PR now carries over (verified - * against the pinned `@ai-sdk/*` sources in this repo's lockfile): - * - `@ai-sdk/anthropic` explicitly preserves BOTH `$defs` and legacy - * `definitions`, recursively, into the tool `input_schema`. - * - `@ai-sdk/google`'s `sanitizeResponseJsonSchema` spreads unknown keywords - * through (`...result`) and handles `$defs` explicitly, so `$ref`, - * `definitions` and `$defs` all reach `functionDeclarations` unstripped. - * - `@ai-sdk/openai` (chat + responses) and `@ai-sdk/openai-compatible` - * (fireworks, zai, together, custom, truefoundry, moonshot, alibaba) ship - * the tool `parameters` JSON Schema essentially verbatim; `$defs`/`$ref` is - * part of the OpenAI tool-calling schema dialect. - * - * What this converter itself must therefore guarantee for every vendor is: - * (1) the top-level `parameters` object stays on the vendor-accepted surface - * (`type`/`properties`/`required` plus the two container spellings and - * nothing else), and (2) every local `$ref` resolves inside the emitted - * schema document, whichever container spelling the server used. The tests - * below pin exactly that. - */ - -/** Schema shape emitted by the official MCP Python SDK (FastMCP). */ -const FASTMCP_STYLE_INPUT_SCHEMA = { - type: 'object' as const, - title: 'create_contactArguments', - properties: { - name: { title: 'Name', type: 'string' }, - address: { $ref: '#/$defs/Address' }, - }, - required: ['name', 'address'], - $defs: { - Address: { - properties: { - street: { title: 'Street', type: 'string' }, - city: { title: 'City', type: 'string' }, - }, - required: ['street', 'city'], - type: 'object', - title: 'Address', - }, - }, -}; - -/** Draft-04 spelling, still produced by older schema generators. */ -const DRAFT04_DEFINITIONS_INPUT_SCHEMA = { - type: 'object' as const, - properties: { - address: { $ref: '#/definitions/Address' }, - }, - required: ['address'], - definitions: { - Address: { - type: 'object', - properties: { - city: { type: 'string' }, - }, - required: ['city'], - }, - }, -}; - -/** Top-level keywords every supported vendor accepts on a function tool. */ -const VENDOR_ACCEPTED_PARAMETER_KEYS = new Set(['type', 'properties', 'required', '$defs', 'definitions']); - -function makeServer(name: string, tools: AgentToolSchema[]): IToolSet { - const base = makeMockIMCPServer({ name, preload: true, tools }); - return { - ...base, - listTools: jest.fn((): Promise => - Promise.resolve({ - result: { tools }, - wasInitialized: undefined, - }), - ), - }; -} - -/** Narrow `ChatCompletionTool` to the function variant and return its `parameters`. */ -function firstFunctionParameters(tool: ChatCompletionTool | undefined): Record | undefined { - if (tool === undefined) { - return undefined; - } - if (tool.type !== 'function') { - throw new Error('Expected converted tool to be a function tool'); - } - return tool.function.parameters; -} - -/** Collect every local `$ref` that cannot be resolved by walking JSON pointers from the root. */ -function collectUnresolvableRefs(root: unknown, node: unknown = root): string[] { - const dangling: string[] = []; - if (Array.isArray(node)) { - for (const item of node) { - dangling.push(...collectUnresolvableRefs(root, item)); - } - return dangling; - } - if (node === null || typeof node !== 'object') { - return dangling; - } - for (const [key, value] of Object.entries(node)) { - if (key === '$ref' && typeof value === 'string' && value.startsWith('#/')) { - let cursor: unknown = root; - let resolved = true; - for (const rawSegment of value.slice(2).split('/')) { - const segment = rawSegment.replace(/~1/g, '/').replace(/~0/g, '~'); - if (cursor !== null && typeof cursor === 'object' && segment in (cursor as Record)) { - cursor = (cursor as Record)[segment]; - } else { - resolved = false; - break; - } - } - if (!resolved) { - dangling.push(value); - } - } else { - dangling.push(...collectUnresolvableRefs(root, value)); - } - } - return dangling; -} - -async function convertSingleTool(inputSchema: AgentToolSchema['inputSchema']) { - const tools: AgentToolSchema[] = [ - { - name: 'vendor_tool', - description: 'Vendor compatibility probe', - inputSchema, - preload: true, - }, - ]; - const { convertedTools } = await convertMCPServersToTools({ - tfyManagedServers: [], - userServers: [makeServer('vendor-server', tools)], - }); - expect(convertedTools.tools).toHaveLength(1); - return firstFunctionParameters(convertedTools.tools[0]); -} - -describe('convertMCPServersToTools model-vendor compatibility', () => { - it.each([ - ['FastMCP-style $defs', FASTMCP_STYLE_INPUT_SCHEMA], - ['draft-04 definitions', DRAFT04_DEFINITIONS_INPUT_SCHEMA], - ])('keeps the emitted parameters on the vendor-accepted surface (%s)', async (_label, inputSchema) => { - const parameters = await convertSingleTool(inputSchema); - expect(parameters).toBeDefined(); - // Nothing beyond the vendor-accepted top-level keywords may leak: a - // stray key is exactly the kind of surface change that breaks one - // provider's schema validation while passing another's. - expect(Object.keys(parameters ?? {}).every(key => VENDOR_ACCEPTED_PARAMETER_KEYS.has(key))).toBe(true); - // The minimal function-tool surface every vendor requires. - expect(parameters?.['type']).toBe('object'); - expect(Array.isArray(parameters?.['required'])).toBe(true); - }); - - it.each([ - ['FastMCP-style $defs', FASTMCP_STYLE_INPUT_SCHEMA], - ['draft-04 definitions', DRAFT04_DEFINITIONS_INPUT_SCHEMA], - ])('resolves every local $ref inside the emitted schema document (%s)', async (_label, inputSchema) => { - const parameters = await convertSingleTool(inputSchema); - // A dangling local ref fails provider-side schema validation on every - // vendor; carrying the container over is what keeps this list empty. - expect(collectUnresolvableRefs(parameters)).toEqual([]); - }); - - it('emits container-less schemas byte-identically, so vendor behavior for them is unchanged', async () => { - const parameters = await convertSingleTool({ type: 'object', properties: {} }); - expect(parameters).toEqual({ type: 'object', properties: {}, required: [] }); - }); -});