diff --git a/.changeset/mcp-preserve-inputschema-defs.md b/.changeset/mcp-preserve-inputschema-defs.md new file mode 100644 index 000000000..2493e4083 --- /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. diff --git a/packages/trueforge-core/src/core/mcp/convertMCPServers.ts b/packages/trueforge-core/src/core/mcp/convertMCPServers.ts index 9da2e6b3f..d31e3a421 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 000000000..d7ff29026 --- /dev/null +++ b/packages/trueforge-core/tests/core/mcp/convertMCPServersSchema.test.ts @@ -0,0 +1,83 @@ +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'; + +/** FastMCP hoists nested models to top-level `$defs` with `#/$defs/` refs. */ +const FASTMCP_STYLE_INPUT_SCHEMA = { + type: 'object' as const, + properties: { + name: { type: 'string' }, + address: { $ref: '#/$defs/Address' }, + }, + required: ['name', 'address'], + $defs: { + Address: { + type: 'object', + properties: { street: { type: 'string' }, city: { type: 'string' } }, + required: ['street', 'city'], + }, + }, +}; + +/** Draft-04 spelling still emitted 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, + }), + ), + }; +} + +function functionParameters(tool: ChatCompletionTool | undefined): Record | undefined { + if (tool === undefined || tool.type !== 'function') { + throw new Error('Expected a function tool'); + } + return tool.function.parameters; +} + +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.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 parameters = await convertParameters({ type: 'object', properties: {} }); + expect(parameters).toEqual({ type: 'object', properties: {}, required: [] }); + }); +});