From c8d3cdabaf9d91f5535e0a79d0fc1ee3140eba80 Mon Sep 17 00:00:00 2001 From: skulitom Date: Thu, 15 Oct 2026 15:18:55 +0100 Subject: [PATCH 1/2] fix: recognize object output schemas behind local references --- .changeset/referenced-object-output.md | 6 ++ .../core-internal/src/util/standardSchema.ts | 36 ++++++++-- .../test/util/standardSchema.test.ts | 58 +++++++++++++++++ .../test/client/namedOutputSchema.test.ts | 65 +++++++++++++++++++ 4 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 .changeset/referenced-object-output.md create mode 100644 test/integration/test/client/namedOutputSchema.test.ts diff --git a/.changeset/referenced-object-output.md b/.changeset/referenced-object-output.md new file mode 100644 index 0000000000..ae530d7150 --- /dev/null +++ b/.changeset/referenced-object-output.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/server': patch +--- + +Recognize same-document JSON Pointer references to object output schemas so 2025-era clients receive the tool's object directly, matching equivalent inline schemas, instead of an extra `{ result: ... }` wrapper. References to non-object schemas keep their existing wrapping. Cyclic, unresolved, external, and nested-resource references remain conservatively classified. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index d904c7f5fa..73b5ad1e4d 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -238,10 +238,18 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords * directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a * composition (`oneOf`/`anyOf`/`allOf`) whose every member is itself `type:'object'` or recursively - * provably object-shaped (e.g. a nested `discriminatedUnion`). `$ref` is not followed. Used to + * provably object-shaped (e.g. a nested `discriminatedUnion`). Local JSON Pointer references + * are followed without crossing nested `$id` boundaries. Used to * decide whether stamping `type:'object'` is safe (redundant-but-valid) versus self-contradictory. */ -function isProvablyObjectShapedRoot(schema: Record): boolean { +function isProvablyObjectShapedRoot(schema: Record, root = schema, ancestors: ReadonlySet = new Set()): boolean { + if (schema.type !== undefined) return schema.type === 'object'; + if (ancestors.has(schema) || (schema !== root && schema.$id !== undefined)) return false; + const seen = new Set(ancestors).add(schema); + if (schema.$ref !== undefined) { + const target = resolveLocalSchemaReference(root, schema.$ref); + return target !== undefined && isProvablyObjectShapedRoot(target, root, seen); + } if ('properties' in schema || 'patternProperties' in schema || 'additionalProperties' in schema || 'required' in schema) { return true; } @@ -249,16 +257,32 @@ function isProvablyObjectShapedRoot(schema: Record): boolean { const members = schema[key]; if (Array.isArray(members) && members.length > 0) { return members.every( - m => - m !== null && - typeof m === 'object' && - ((m as Record).type === 'object' || isProvablyObjectShapedRoot(m as Record)) + m => m !== null && typeof m === 'object' && isProvablyObjectShapedRoot(m as Record, root, seen) ); } } return false; } +function resolveLocalSchemaReference(root: Record, ref: unknown): Record | undefined { + if (typeof ref !== 'string' || !ref.startsWith('#/')) return undefined; + let pointer: string; + try { + pointer = decodeURIComponent(ref.slice(2)); + } catch { + return undefined; + } + let target: unknown = root; + for (const token of pointer.split('/')) { + if (target === null || typeof target !== 'object' || /~(?:[^01]|$)/.test(token)) return undefined; + if (target !== root && '$id' in target) return undefined; + const key = token.replaceAll('~1', '/').replaceAll('~0', '~'); + if (!Object.hasOwn(target, key)) return undefined; + target = (target as Record)[key]; + } + return target !== null && typeof target === 'object' && !Array.isArray(target) ? (target as Record) : undefined; +} + // Validation export type StandardSchemaValidationResult = { success: true; data: T } | { success: false; error: string }; diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 8856592ff0..c486a47ed7 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -40,3 +40,61 @@ describe('standardSchemaToJsonSchema', () => { expect(result.type).toBe('object'); }); }); + +describe('referenced output schemas', () => { + const object = { type: 'object', properties: { value: { type: 'string' } } }; + const reference = { $ref: '#/$defs/Value' }; + const convert = (schema: Record) => + standardSchemaToJsonSchema( + { + '~standard': { + version: 1, + vendor: 'test', + jsonSchema: { input: () => schema, output: () => schema } + } + }, + 'output' + ); + + test.each([ + ['local object', { ...reference, $defs: { Value: object } }], + ['root with an ID', { ...reference, $id: 'https://example.com/schema', $defs: { Value: object } }], + ['reference chain', { ...reference, $defs: { Value: { $ref: '#/$defs/Object' }, Object: object } }], + ['recursive object', { ...reference, $defs: { Value: { type: 'object', properties: { child: reference } } } }], + ['repeated reference in a union', { anyOf: [reference, reference], $defs: { Value: object } }], + ['referenced object union', { ...reference, $defs: { Value: { oneOf: [object, { type: 'object' }] } } }], + ['escaped definition name', { $ref: '#/$defs/a~1b~0c%20d', $defs: { 'a/b~c d': object } }], + ['draft-07 definitions', { $ref: '#/definitions/Value', definitions: { Value: object } }] + ])('adds an object root for %s without changing the reference', (_name, schema) => { + const original = JSON.parse(JSON.stringify(schema)); + expect(convert(schema as Record)).toEqual({ type: 'object', ...original }); + expect(schema).toEqual(original); + }); + + test.each([ + ['string', { ...reference, $defs: { Value: { type: 'string' } } }], + ['nullable object', { ...reference, $defs: { Value: { type: ['object', 'null'] } } }], + ['mixed union', { anyOf: [reference, { type: 'string' }], $defs: { Value: object } }], + ['missing target', { ...reference, $defs: {} }], + ['external reference', { $ref: 'https://example.com/schema', $defs: { Value: object } }], + ['anchor', { $ref: '#Value', $defs: { Value: { $anchor: 'Value', ...object } } }], + ['direct cycle', { ...reference, $defs: { Value: reference } }], + ['indirect cycle', { ...reference, $defs: { Value: { allOf: [reference] } } }], + ['root cycle', { $ref: '#' }], + ['boolean target', { ...reference, $defs: { Value: true } }], + ['array target', { ...reference, $defs: { Value: [object] } }], + ['malformed URI escape', { $ref: '#/$defs/%', $defs: { '%': object } }], + ['malformed pointer escape', { $ref: '#/$defs/a~2b', $defs: { 'a~2b': object } }], + ['inherited property', { $ref: '#/$defs/Value', $defs: Object.create({ Value: object }) }], + [ + 'reference in a nested resource', + { ...reference, $defs: { Value: { $id: 'other.json', $ref: '#/$defs/Object' }, Object: object } } + ], + [ + 'pointer crossing a nested resource', + { $ref: '#/$defs/Value/$defs/Object', $defs: { Value: { $id: 'other.json', $defs: { Object: object } } } } + ] + ])('leaves %s unchanged when an object root cannot be established', (_name, schema) => { + expect(convert(schema as Record)).toBe(schema); + }); +}); diff --git a/test/integration/test/client/namedOutputSchema.test.ts b/test/integration/test/client/namedOutputSchema.test.ts new file mode 100644 index 0000000000..fbe8dcbb41 --- /dev/null +++ b/test/integration/test/client/namedOutputSchema.test.ts @@ -0,0 +1,65 @@ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { createMcpHandler, fromJsonSchema, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const output = { id: 'n1', body: 'hello' }; +const inline = z.object({ id: z.string(), body: z.string() }); +const referenced = fromJsonSchema({ + $ref: '#/$defs/Note', + $defs: { + Note: { + type: 'object', + properties: { id: { type: 'string' }, body: { type: 'string' } }, + required: ['id', 'body'] + } + } +}); + +describe('named object output schemas', () => { + test.each(['2025-11-25', '2026-07-28'] as const)('preserves object results on %s', async version => { + const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'named-output-server', version: '1.0.0' }); + for (const [name, outputSchema] of Object.entries({ inline, referenced })) { + server.registerTool(name, { outputSchema }, async () => ({ content: [], structuredContent: output })); + } + server.registerTool( + 'string', + { outputSchema: fromJsonSchema({ $ref: '#/$defs/Text', $defs: { Text: { type: 'string' } } }) }, + async () => ({ content: [], structuredContent: 'hello' }) + ); + server.registerTool('invalid', { outputSchema: referenced }, async () => ({ + content: [], + structuredContent: { id: 'n1' } as typeof output + })); + return server; + }); + const client = new Client( + { name: 'named-output-client', version: '1.0.0' }, + { versionNegotiation: { mode: version === '2026-07-28' ? { pin: version } : 'legacy' } } + ); + try { + await client.connect( + new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { + fetch: (url, init) => handler.fetch(new Request(url, init)) + }) + ); + expect(client.getNegotiatedProtocolVersion()).toBe(version); + const { tools } = await client.listTools(); + for (const name of ['inline', 'referenced']) { + const result = await client.callTool({ name, arguments: {} }); + expect(result.structuredContent).toEqual(output); + if (version !== '2026-07-28') { + const schema = tools.find(tool => tool.name === name)?.outputSchema; + expect(schema).toMatchObject({ type: 'object' }); + expect(schema).not.toHaveProperty('properties.result'); + } + } + const stringResult = await client.callTool({ name: 'string' }); + expect(stringResult.structuredContent).toEqual(version === '2026-07-28' ? 'hello' : { result: 'hello' }); + const invalidResult = await client.callTool({ name: 'invalid' }); + expect(invalidResult.isError).toBe(true); + } finally { + await client.close(); + } + }); +}); From 135384af8692a1ecd778a10fec30c3126d5a2212 Mon Sep 17 00:00:00 2001 From: skulitom Date: Thu, 15 Oct 2026 15:41:54 +0100 Subject: [PATCH 2/2] fix: bound reference traversal and require object constraints --- .changeset/referenced-object-output.md | 2 +- .../core-internal/src/util/standardSchema.ts | 63 +++++++++++-------- .../test/util/standardSchema.test.ts | 45 +++++++++++++ .../test/client/namedOutputSchema.test.ts | 18 +++--- 4 files changed, 94 insertions(+), 34 deletions(-) diff --git a/.changeset/referenced-object-output.md b/.changeset/referenced-object-output.md index ae530d7150..620dbc8bc7 100644 --- a/.changeset/referenced-object-output.md +++ b/.changeset/referenced-object-output.md @@ -3,4 +3,4 @@ '@modelcontextprotocol/server': patch --- -Recognize same-document JSON Pointer references to object output schemas so 2025-era clients receive the tool's object directly, matching equivalent inline schemas, instead of an extra `{ result: ... }` wrapper. References to non-object schemas keep their existing wrapping. Cyclic, unresolved, external, and nested-resource references remain conservatively classified. +Recognize same-document JSON Pointer references to output schemas constrained to objects so 2025-era clients receive the tool's object directly, matching equivalent inline schemas, instead of an extra `{ result: ... }` wrapper. References to schemas that allow non-object values keep their existing wrapping. Cyclic, unresolved, external, and nested-resource references remain conservatively classified. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 73b5ad1e4d..3434e81970 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -234,34 +234,45 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in return { type: 'object', ...result }; } -/** - * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords - * directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a - * composition (`oneOf`/`anyOf`/`allOf`) whose every member is itself `type:'object'` or recursively - * provably object-shaped (e.g. a nested `discriminatedUnion`). Local JSON Pointer references - * are followed without crossing nested `$id` boundaries. Used to - * decide whether stamping `type:'object'` is safe (redundant-but-valid) versus self-contradictory. - */ -function isProvablyObjectShapedRoot(schema: Record, root = schema, ancestors: ReadonlySet = new Set()): boolean { - if (schema.type !== undefined) return schema.type === 'object'; - if (ancestors.has(schema) || (schema !== root && schema.$id !== undefined)) return false; - const seen = new Set(ancestors).add(schema); - if (schema.$ref !== undefined) { - const target = resolveLocalSchemaReference(root, schema.$ref); - return target !== undefined && isProvablyObjectShapedRoot(target, root, seen); - } - if ('properties' in schema || 'patternProperties' in schema || 'additionalProperties' in schema || 'required' in schema) { - return true; - } - for (const key of ['oneOf', 'anyOf', 'allOf'] as const) { - const members = schema[key]; - if (Array.isArray(members) && members.length > 0) { - return members.every( - m => m !== null && typeof m === 'object' && isProvablyObjectShapedRoot(m as Record, root, seen) - ); +// Inline object keywords retain legacy behavior; referenced schemas must constrain their values to objects. +function isProvablyObjectShapedRoot(root: Record): boolean { + const completedReferences = new Map(); + const ancestors = new Set(); + + function classify(schema: Record, requireObjectType = false): boolean { + if (schema.type !== undefined) return schema.type === 'object'; + if (ancestors.has(schema) || (schema !== root && schema.$id !== undefined)) return false; + if (requireObjectType) { + const completed = completedReferences.get(schema); + if (completed !== undefined) return completed; } + ancestors.add(schema); + let objectShaped = false; + if (schema.$ref !== undefined) { + const target = resolveLocalSchemaReference(root, schema.$ref); + objectShaped = target !== undefined && classify(target, true); + } else if ( + !requireObjectType && + ('properties' in schema || 'patternProperties' in schema || 'additionalProperties' in schema || 'required' in schema) + ) { + objectShaped = true; + } else { + for (const key of ['oneOf', 'anyOf', 'allOf'] as const) { + const members = schema[key]; + if (Array.isArray(members) && members.length > 0) { + objectShaped = members.every( + m => m !== null && typeof m === 'object' && classify(m as Record, requireObjectType) + ); + break; + } + } + } + ancestors.delete(schema); + if (requireObjectType) completedReferences.set(schema, objectShaped); + return objectShaped; } - return false; + + return classify(root); } function resolveLocalSchemaReference(root: Record, ref: unknown): Record | undefined { diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index c486a47ed7..26cddcd93b 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -74,6 +74,18 @@ describe('referenced output schemas', () => { test.each([ ['string', { ...reference, $defs: { Value: { type: 'string' } } }], ['nullable object', { ...reference, $defs: { Value: { type: ['object', 'null'] } } }], + ['typeless properties', { ...reference, $defs: { Value: { properties: { value: { type: 'string' } } } } }], + ['typeless pattern properties', { ...reference, $defs: { Value: { patternProperties: { '.*': { type: 'string' } } } } }], + ['typeless required', { ...reference, $defs: { Value: { required: ['value'] } } }], + ['typeless additional properties', { ...reference, $defs: { Value: { additionalProperties: false } } }], + [ + 'reference chain to a typeless target', + { ...reference, $defs: { Value: { $ref: '#/$defs/Other' }, Other: { required: ['value'] } } } + ], + [ + 'referenced union with a typeless member', + { ...reference, $defs: { Value: { anyOf: [object, { additionalProperties: false }] } } } + ], ['mixed union', { anyOf: [reference, { type: 'string' }], $defs: { Value: object } }], ['missing target', { ...reference, $defs: {} }], ['external reference', { $ref: 'https://example.com/schema', $defs: { Value: object } }], @@ -97,4 +109,37 @@ describe('referenced output schemas', () => { ])('leaves %s unchanged when an object root cannot be established', (_name, schema) => { expect(convert(schema as Record)).toBe(schema); }); + + test.each([{ properties: {} }, { patternProperties: {} }, { required: ['value'] }, { additionalProperties: false }])( + 'preserves inline object-keyword compatibility for %j', + schema => { + expect(convert(schema)).toEqual({ type: 'object', ...schema }); + } + ); + + test.each([false, true])('keeps inline and referenced classification separate (reference first: %s)', referenceFirst => { + const target = { additionalProperties: false }; + const members = referenceFirst ? [reference, target] : [target, reference]; + const schema = { anyOf: members, $defs: { Value: target } }; + expect(convert(schema)).toBe(schema); + }); + + test('bounds reference lookups for a shared definition graph', () => { + const definitions: Record = { Level0: object }; + for (let level = 1; level <= 22; level++) { + definitions[`Level${level}`] = { anyOf: [{ $ref: `#/$defs/Level${level - 1}` }, { $ref: `#/$defs/Level${level - 1}` }] }; + } + let lookups = 0; + const schema = { + $ref: '#/$defs/Level22', + $defs: new Proxy(definitions, { + get(target, key) { + if (++lookups > 100) throw new Error('Shared definitions were repeatedly traversed'); + return Reflect.get(target, key); + } + }) + }; + expect(convert(schema).type).toBe('object'); + expect(lookups).toBeLessThanOrEqual(100); + }); }); diff --git a/test/integration/test/client/namedOutputSchema.test.ts b/test/integration/test/client/namedOutputSchema.test.ts index fbe8dcbb41..92495f7f9b 100644 --- a/test/integration/test/client/namedOutputSchema.test.ts +++ b/test/integration/test/client/namedOutputSchema.test.ts @@ -22,11 +22,13 @@ describe('named object output schemas', () => { for (const [name, outputSchema] of Object.entries({ inline, referenced })) { server.registerTool(name, { outputSchema }, async () => ({ content: [], structuredContent: output })); } - server.registerTool( - 'string', - { outputSchema: fromJsonSchema({ $ref: '#/$defs/Text', $defs: { Text: { type: 'string' } } }) }, - async () => ({ content: [], structuredContent: 'hello' }) - ); + for (const [name, schema] of Object.entries({ string: { type: 'string' }, typeless: { additionalProperties: false } })) { + server.registerTool( + name, + { outputSchema: fromJsonSchema({ $ref: '#/$defs/Text', $defs: { Text: schema } }) }, + async () => ({ content: [], structuredContent: 'hello' }) + ); + } server.registerTool('invalid', { outputSchema: referenced }, async () => ({ content: [], structuredContent: { id: 'n1' } as typeof output @@ -54,8 +56,10 @@ describe('named object output schemas', () => { expect(schema).not.toHaveProperty('properties.result'); } } - const stringResult = await client.callTool({ name: 'string' }); - expect(stringResult.structuredContent).toEqual(version === '2026-07-28' ? 'hello' : { result: 'hello' }); + for (const name of ['string', 'typeless']) { + const stringResult = await client.callTool({ name }); + expect(stringResult.structuredContent).toEqual(version === '2026-07-28' ? 'hello' : { result: 'hello' }); + } const invalidResult = await client.callTool({ name: 'invalid' }); expect(invalidResult.isError).toBe(true); } finally {