Skip to content

Commit f087e44

Browse files
Add JSON Schema 2020-12 conformance test (SEP-1613) (#45)
Add conformance test to validate that MCP servers correctly preserve JSON Schema 2020-12 keywords ($schema, $defs, additionalProperties) in tool definitions. Changes: - Add JsonSchema2020_12Scenario in src/scenarios/server/json-schema-2020-12.ts - Add json_schema_2020_12_tool to everything-server with tools/list override to return raw JSON Schema instead of Zod-converted schema - Register scenario in pendingClientScenariosList (requires SDK PR #1135) The test is added to pending because the client SDK currently strips the JSON Schema 2020-12 keywords. Once PR #1135 is merged into the SDK, this scenario can be moved to the active list.
1 parent 8ef618b commit f087e44

File tree

3 files changed

+329
-2
lines changed

3 files changed

+329
-2
lines changed

examples/servers/typescript/everything-server.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ import {
1313
ResourceTemplate
1414
} from '@modelcontextprotocol/sdk/server/mcp.js';
1515
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
16-
import { ElicitResultSchema } from '@modelcontextprotocol/sdk/types.js';
16+
import {
17+
ElicitResultSchema,
18+
ListToolsRequestSchema,
19+
type ListToolsResult,
20+
type Tool
21+
} from '@modelcontextprotocol/sdk/types.js';
22+
import { zodToJsonSchema } from 'zod-to-json-schema';
1723
import { z } from 'zod';
1824
import express from 'express';
1925
import cors from 'cors';
@@ -35,6 +41,28 @@ const TEST_IMAGE_BASE64 =
3541
const TEST_AUDIO_BASE64 =
3642
'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=';
3743

44+
// SEP-1613: Raw JSON Schema 2020-12 definition for conformance testing
45+
// This schema includes $schema, $defs, and additionalProperties to test
46+
// that SDKs correctly preserve these fields
47+
const JSON_SCHEMA_2020_12_INPUT_SCHEMA = {
48+
$schema: 'https://json-schema.org/draft/2020-12/schema',
49+
type: 'object' as const,
50+
$defs: {
51+
address: {
52+
type: 'object',
53+
properties: {
54+
street: { type: 'string' },
55+
city: { type: 'string' }
56+
}
57+
}
58+
},
59+
properties: {
60+
name: { type: 'string' },
61+
address: { $ref: '#/$defs/address' }
62+
},
63+
additionalProperties: false
64+
};
65+
3866
// Function to create a new MCP server instance (one per session)
3967
function createMcpServer() {
4068
const mcpServer = new McpServer(
@@ -566,6 +594,40 @@ function createMcpServer() {
566594
}
567595
);
568596

597+
// SEP-1613: JSON Schema 2020-12 conformance test tool
598+
// This tool is registered with a Zod schema for tools/call validation,
599+
// but the tools/list handler (below) returns the raw JSON Schema 2020-12
600+
// definition to test that SDKs preserve $schema, $defs, additionalProperties
601+
mcpServer.registerTool(
602+
'json_schema_2020_12_tool',
603+
{
604+
description:
605+
'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)',
606+
inputSchema: {
607+
name: z.string().optional(),
608+
address: z
609+
.object({
610+
street: z.string().optional(),
611+
city: z.string().optional()
612+
})
613+
.optional()
614+
}
615+
},
616+
async (args: {
617+
name?: string;
618+
address?: { street?: string; city?: string };
619+
}) => {
620+
return {
621+
content: [
622+
{
623+
type: 'text',
624+
text: `JSON Schema 2020-12 tool called with: ${JSON.stringify(args)}`
625+
}
626+
]
627+
};
628+
}
629+
);
630+
569631
// Dynamic tool (registered later via timer)
570632

571633
// ===== RESOURCES =====
@@ -829,6 +891,80 @@ function createMcpServer() {
829891
}
830892
);
831893

894+
// ===== SEP-1613: Override tools/list to return raw JSON Schema 2020-12 =====
895+
// This override is necessary because registerTool converts Zod schemas to
896+
// JSON Schema without preserving $schema, $defs, and additionalProperties.
897+
// We need to return the raw JSON Schema for our test tool while using the
898+
// SDK's conversion for other tools.
899+
mcpServer.server.setRequestHandler(
900+
ListToolsRequestSchema,
901+
(): ListToolsResult => {
902+
// Access internal registered tools (this is internal SDK API but stable)
903+
const registeredTools = (mcpServer as any)._registeredTools as Record<
904+
string,
905+
{
906+
enabled: boolean;
907+
title?: string;
908+
description?: string;
909+
inputSchema?: any;
910+
outputSchema?: any;
911+
annotations?: any;
912+
_meta?: any;
913+
}
914+
>;
915+
916+
return {
917+
tools: Object.entries(registeredTools)
918+
.filter(([, tool]) => tool.enabled)
919+
.map(([name, tool]): Tool => {
920+
// For our SEP-1613 test tool, return raw JSON Schema 2020-12
921+
if (name === 'json_schema_2020_12_tool') {
922+
return {
923+
name,
924+
description: tool.description,
925+
inputSchema: JSON_SCHEMA_2020_12_INPUT_SCHEMA
926+
};
927+
}
928+
929+
// For other tools, convert Zod to JSON Schema
930+
// Handle different inputSchema formats:
931+
// - undefined/null: use empty object schema
932+
// - Zod schema (has _def): convert directly
933+
// - Raw shape (object with Zod values): wrap in z.object first
934+
let inputSchema: Tool['inputSchema'];
935+
if (!tool.inputSchema) {
936+
inputSchema = { type: 'object' as const, properties: {} };
937+
} else if ('_def' in tool.inputSchema) {
938+
// Already a Zod schema
939+
inputSchema = zodToJsonSchema(tool.inputSchema, {
940+
strictUnions: true
941+
}) as Tool['inputSchema'];
942+
} else if (
943+
typeof tool.inputSchema === 'object' &&
944+
Object.keys(tool.inputSchema).length > 0
945+
) {
946+
// Raw shape with Zod values
947+
inputSchema = zodToJsonSchema(z.object(tool.inputSchema), {
948+
strictUnions: true
949+
}) as Tool['inputSchema'];
950+
} else {
951+
// Empty object or unknown format
952+
inputSchema = { type: 'object' as const, properties: {} };
953+
}
954+
955+
return {
956+
name,
957+
title: tool.title,
958+
description: tool.description,
959+
inputSchema,
960+
annotations: tool.annotations,
961+
_meta: tool._meta
962+
};
963+
})
964+
};
965+
}
966+
);
967+
832968
return mcpServer;
833969
}
834970

src/scenarios/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
ToolsCallEmbeddedResourceScenario
2626
} from './server/tools.js';
2727

28+
import { JsonSchema2020_12Scenario } from './server/json-schema-2020-12.js';
29+
2830
import { ElicitationDefaultsScenario } from './server/elicitation-defaults.js';
2931
import { ElicitationEnumsScenario } from './server/elicitation-enums.js';
3032

@@ -51,7 +53,12 @@ import { listMetadataScenarios } from './client/auth/discovery-metadata.js';
5153
// Pending client scenarios (not yet fully tested/implemented)
5254
const pendingClientScenariosList: ClientScenario[] = [
5355
// Elicitation scenarios (SEP-1330)
54-
new ElicitationEnumsScenario()
56+
new ElicitationEnumsScenario(),
57+
58+
// JSON Schema 2020-12 (SEP-1613)
59+
// This test is pending until the SDK includes PR #1135 which preserves
60+
// $schema, $defs, and additionalProperties fields in tool schemas.
61+
new JsonSchema2020_12Scenario()
5562
];
5663

5764
// All client scenarios
@@ -76,6 +83,9 @@ const allClientScenariosList: ClientScenario[] = [
7683
new ToolsCallSamplingScenario(),
7784
new ToolsCallElicitationScenario(),
7885

86+
// JSON Schema 2020-12 support (SEP-1613)
87+
new JsonSchema2020_12Scenario(),
88+
7989
// Elicitation scenarios (SEP-1034)
8090
new ElicitationDefaultsScenario(),
8191

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* JSON Schema 2020-12 conformance test scenario (SEP-1613)
3+
*
4+
* Validates that MCP servers correctly preserve JSON Schema 2020-12 keywords
5+
* in tool definitions, ensuring implementations don't strip $schema, $defs,
6+
* or additionalProperties fields.
7+
*/
8+
9+
import { ClientScenario, ConformanceCheck } from '../../types.js';
10+
import { connectToServer } from './client-helper.js';
11+
12+
const EXPECTED_TOOL_NAME = 'json_schema_2020_12_tool';
13+
const EXPECTED_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema';
14+
15+
export class JsonSchema2020_12Scenario implements ClientScenario {
16+
name = 'json-schema-2020-12';
17+
description = `Validates JSON Schema 2020-12 keyword preservation (SEP-1613).
18+
19+
**Server Implementation Requirements:**
20+
21+
Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema 2020-12 features:
22+
23+
\`\`\`json
24+
{
25+
"name": "${EXPECTED_TOOL_NAME}",
26+
"description": "Tool with JSON Schema 2020-12 features",
27+
"inputSchema": {
28+
"$schema": "${EXPECTED_SCHEMA_DIALECT}",
29+
"type": "object",
30+
"$defs": {
31+
"address": {
32+
"type": "object",
33+
"properties": {
34+
"street": { "type": "string" },
35+
"city": { "type": "string" }
36+
}
37+
}
38+
},
39+
"properties": {
40+
"name": { "type": "string" },
41+
"address": { "$ref": "#/$defs/address" }
42+
},
43+
"additionalProperties": false
44+
}
45+
}
46+
\`\`\`
47+
48+
**Verification**: The test verifies that \`$schema\`, \`$defs\`, and \`additionalProperties\` are preserved in the tool listing response.`;
49+
50+
async run(serverUrl: string): Promise<ConformanceCheck[]> {
51+
const checks: ConformanceCheck[] = [];
52+
const specReferences = [
53+
{
54+
id: 'SEP-1613',
55+
url: 'https://github.com/modelcontextprotocol/specification/pull/655'
56+
}
57+
];
58+
59+
try {
60+
const connection = await connectToServer(serverUrl);
61+
const result = await connection.client.listTools();
62+
63+
// Find the test tool
64+
const tool = result.tools?.find((t) => t.name === EXPECTED_TOOL_NAME);
65+
66+
// Check 1: Tool exists
67+
checks.push({
68+
id: 'json-schema-2020-12-tool-found',
69+
name: 'JsonSchema2020_12ToolFound',
70+
description: `Server advertises tool '${EXPECTED_TOOL_NAME}'`,
71+
status: tool ? 'SUCCESS' : 'FAILURE',
72+
timestamp: new Date().toISOString(),
73+
errorMessage: tool
74+
? undefined
75+
: `Tool '${EXPECTED_TOOL_NAME}' not found. Available tools: ${result.tools?.map((t) => t.name).join(', ') || 'none'}`,
76+
specReferences,
77+
details: {
78+
toolFound: !!tool,
79+
availableTools: result.tools?.map((t) => t.name) || []
80+
}
81+
});
82+
83+
if (!tool) {
84+
await connection.close();
85+
return checks;
86+
}
87+
88+
const inputSchema = tool.inputSchema as Record<string, unknown>;
89+
90+
// Check 2: $schema field preserved
91+
const hasSchema = '$schema' in inputSchema;
92+
const schemaValue = inputSchema['$schema'];
93+
const schemaCorrect = schemaValue === EXPECTED_SCHEMA_DIALECT;
94+
95+
checks.push({
96+
id: 'json-schema-2020-12-$schema',
97+
name: 'JsonSchema2020_12$Schema',
98+
description: `inputSchema.$schema field preserved with value '${EXPECTED_SCHEMA_DIALECT}'`,
99+
status: hasSchema && schemaCorrect ? 'SUCCESS' : 'FAILURE',
100+
timestamp: new Date().toISOString(),
101+
errorMessage: !hasSchema
102+
? '$schema field missing from inputSchema - field was likely stripped'
103+
: !schemaCorrect
104+
? `$schema has unexpected value: ${JSON.stringify(schemaValue)}`
105+
: undefined,
106+
specReferences,
107+
details: {
108+
hasSchema,
109+
schemaValue,
110+
expected: EXPECTED_SCHEMA_DIALECT
111+
}
112+
});
113+
114+
// Check 3: $defs field preserved
115+
const hasDefs = '$defs' in inputSchema;
116+
const defsValue = inputSchema['$defs'] as
117+
| Record<string, unknown>
118+
| undefined;
119+
const defsHasAddress = defsValue && 'address' in defsValue;
120+
121+
checks.push({
122+
id: 'json-schema-2020-12-$defs',
123+
name: 'JsonSchema2020_12$Defs',
124+
description:
125+
'inputSchema.$defs field preserved with expected structure',
126+
status: hasDefs && defsHasAddress ? 'SUCCESS' : 'FAILURE',
127+
timestamp: new Date().toISOString(),
128+
errorMessage: !hasDefs
129+
? '$defs field missing from inputSchema - field was likely stripped'
130+
: !defsHasAddress
131+
? '$defs exists but missing expected "address" definition'
132+
: undefined,
133+
specReferences,
134+
details: {
135+
hasDefs,
136+
defsKeys: defsValue ? Object.keys(defsValue) : [],
137+
defsValue
138+
}
139+
});
140+
141+
// Check 4: additionalProperties field preserved
142+
const hasAdditionalProps = 'additionalProperties' in inputSchema;
143+
const additionalPropsValue = inputSchema['additionalProperties'];
144+
const additionalPropsCorrect = additionalPropsValue === false;
145+
146+
checks.push({
147+
id: 'json-schema-2020-12-additionalProperties',
148+
name: 'JsonSchema2020_12AdditionalProperties',
149+
description: 'inputSchema.additionalProperties field preserved',
150+
status:
151+
hasAdditionalProps && additionalPropsCorrect ? 'SUCCESS' : 'FAILURE',
152+
timestamp: new Date().toISOString(),
153+
errorMessage: !hasAdditionalProps
154+
? 'additionalProperties field missing from inputSchema - field was likely stripped'
155+
: !additionalPropsCorrect
156+
? `additionalProperties has unexpected value: ${JSON.stringify(additionalPropsValue)}, expected: false`
157+
: undefined,
158+
specReferences,
159+
details: {
160+
hasAdditionalProps,
161+
additionalPropsValue,
162+
expected: false
163+
}
164+
});
165+
166+
await connection.close();
167+
} catch (error) {
168+
checks.push({
169+
id: 'json-schema-2020-12-error',
170+
name: 'JsonSchema2020_12Error',
171+
description: 'JSON Schema 2020-12 conformance test',
172+
status: 'FAILURE',
173+
timestamp: new Date().toISOString(),
174+
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
175+
specReferences
176+
});
177+
}
178+
179+
return checks;
180+
}
181+
}

0 commit comments

Comments
 (0)