Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/referenced-object-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/server': patch
---

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.
77 changes: 56 additions & 21 deletions packages/core-internal/src/util/standardSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,29 +234,64 @@ 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`). `$ref` is not followed. Used to
* decide whether stamping `type:'object'` is safe (redundant-but-valid) versus self-contradictory.
*/
function isProvablyObjectShapedRoot(schema: Record<string, unknown>): boolean {
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' &&
((m as Record<string, unknown>).type === 'object' || isProvablyObjectShapedRoot(m as Record<string, unknown>))
);
// Inline object keywords retain legacy behavior; referenced schemas must constrain their values to objects.
function isProvablyObjectShapedRoot(root: Record<string, unknown>): boolean {
const completedReferences = new Map<object, boolean>();
const ancestors = new Set<object>();

function classify(schema: Record<string, unknown>, 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<string, unknown>, requireObjectType)
);
break;
}
}
}
ancestors.delete(schema);
if (requireObjectType) completedReferences.set(schema, objectShaped);
return objectShaped;
}

return classify(root);
}

function resolveLocalSchemaReference(root: Record<string, unknown>, ref: unknown): Record<string, unknown> | 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<string, unknown>)[key];
}
return false;
return target !== null && typeof target === 'object' && !Array.isArray(target) ? (target as Record<string, unknown>) : undefined;
}

// Validation
Expand Down
103 changes: 103 additions & 0 deletions packages/core-internal/test/util/standardSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,106 @@ 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<string, unknown>) =>
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<string, unknown>)).toEqual({ type: 'object', ...original });
expect(schema).toEqual(original);
});

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 } }],
['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<string, unknown>)).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<string, unknown> = { 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);
});
});
69 changes: 69 additions & 0 deletions test/integration/test/client/namedOutputSchema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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<typeof output>({
$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 }));
}
for (const [name, schema] of Object.entries({ string: { type: 'string' }, typeless: { additionalProperties: false } })) {
server.registerTool(
name,
{ outputSchema: fromJsonSchema<string>({ $ref: '#/$defs/Text', $defs: { Text: schema } }) },
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');
}
}
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 {
await client.close();
}
});
});
Loading