feat: add client-side JSON Schema 2020-12 preservation scenario (#335)

* feat: add client-side JSON Schema 2020-12 preservation scenario

The mock server, using the version-aware `MockServer` abstraction from #321,
first advertises a focal tool with a detailed inputSchema and also offers a
permissive `json_schema_echo` tool. The client under test retrieves the
inputSchema via `tools/list`, then sends the same inputSchema back to the server
using the echo tool, completing the round-trip.

The scenario diffs from the echoed schema against the canonical fixture. It
emits eight per-keyword conformance checks. SEP-2106 vocabulary checks are
soft-gated using the existing sep2106KeywordCheckStatus helper. They only flip
to FAILURE during DRAFT-2026-v1 runs.

Signed-off-by: Satoshi Ito <satoshi.ito.tf@hitachi.com>

* Drive draft-target preservation tests through the stateless lifecycle

The stateful test helper passed createServerStateful bare, which no longer
compiles now that it takes a second specVersion argument, and forcing the
stateful mock at the draft version trips wire-schema validation (draft
results require the SEP-2549 resultType/ttlMs/cacheScope members). Use
testScenarioContext(DRAFT_PROTOCOL_VERSION), which selects the stateless
mock server, and drive it with sendStatelessRequest, matching the pattern
in json-schema-ref-deref.test.ts.

* Reword client keyword-preservation rows as issue-derived expectations

SEP-2106 §1 contains no normative preservation sentence, so the six new
sep-2106.yaml rows should not attribute a spec MUST to it. Reword the
remaining rows as expectations derived from issue #101. Also drop the
three rows whose check IDs are not sep-prefixed: the traceability
manifest only collects IDs matching /^sep-\d+-/, so those rows could
never be marked tested.

* Replace stale DRAFT-2026-v1 references with the 2026-07-28 draft name

The draft protocol identifier was renamed in #331. Interpolate
DRAFT_PROTOCOL_VERSION into the SKIPPED errorMessage suffix (matching
the server-side scenario) and update the header comment.

---------

Signed-off-by: Satoshi Ito <satoshi.ito.tf@hitachi.com>
Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
Satoshi Ito 2026-08-01 02:24:05 +09:00 • committed by GitHub
commit 81eb1c3eda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 812 additions and 38 deletions

View file

@ -228,6 +228,69 @@ async function runListToolsOnlyClient(serverUrl: string): Promise<void> {
registerScenario('json-schema-ref-no-deref', runListToolsOnlyClient);
// ============================================================================
// json-schema-2020-12-preservation (SEP-1613, SEP-2106, Issue #101)
//
// Scenario contract:
// 1. tools/list — observe `json_schema_2020_12_tool` and its inputSchema
// 2. tools/call json_schema_echo with `{ schema: <observed inputSchema> }`
// The scenario diffs the echoed schema against its fixture to detect
// client-side keyword stripping; this handler just round-trips the schema
// verbatim, which is the compliant behavior.
// ============================================================================
const FOCAL_TOOL_NAME = 'json_schema_2020_12_tool';
const ECHO_TOOL_NAME = 'json_schema_echo';
async function runJsonSchema2020_12PreservationClient(
serverUrl: string
): Promise<void> {
if (USE_STATELESS_LIFECYCLE) {
logger.debug(
'Stateless lifecycle: listing tools and echoing observed schema'
);
const list = await statelessRequest(serverUrl, 'tools/list');
const focal = list?.tools?.find(
(t: { name: string }) => t.name === FOCAL_TOOL_NAME
);
if (!focal) {
throw new Error(`Focal tool '${FOCAL_TOOL_NAME}' not advertised`);
}
await statelessRequest(serverUrl, 'tools/call', {
name: ECHO_TOOL_NAME,
arguments: { schema: focal.inputSchema }
});
logger.debug('Successfully echoed observed inputSchema');
return;
}
const client = new Client(
{ name: 'test-client', version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
try {
const list = await client.listTools();
const focal = list.tools.find((t) => t.name === FOCAL_TOOL_NAME);
if (!focal) {
throw new Error(`Focal tool '${FOCAL_TOOL_NAME}' not advertised`);
}
await client.callTool({
name: ECHO_TOOL_NAME,
arguments: { schema: focal.inputSchema as Record<string, unknown> }
});
logger.debug('Successfully echoed observed inputSchema');
} finally {
await transport.close();
}
}
registerScenario(
'json-schema-2020-12-preservation',
runJsonSchema2020_12PreservationClient
);
// ============================================================================
// request-metadata scenario (SEP-2575)
// ============================================================================

View file

@ -0,0 +1,271 @@
import { testScenarioContext } from '../../mock-server/testing';
import { describe, it, expect } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { JsonSchema2020_12PreservationScenario } from './json-schema-2020-12-preservation';
import { sendStatelessRequest } from '../../connection/stateless';
import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../../types';
import { JSON_SCHEMA_2020_12_FIXTURE } from '../server/json-schema-2020-12';
const FOCAL_TOOL = 'json_schema_2020_12_tool';
const ECHO_TOOL = 'json_schema_echo';
/**
* Connect a real SDK client to the scenario's mock server, list tools, and
* echo back the focal tool's inputSchema (verbatim or after the caller-
* supplied transform). Returns nothing — the scenario observes the echo
* internally.
*/
async function runEchoClient(
serverUrl: string,
transform: (schema: Record<string, unknown>) => Record<string, unknown> = (
s
) => s
): Promise<void> {
const client = new Client(
{ name: 'preservation-test-client', version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
try {
const listed = await client.listTools();
const focal = listed.tools.find((t) => t.name === FOCAL_TOOL);
if (!focal) {
throw new Error(`Focal tool ${FOCAL_TOOL} not advertised`);
}
const observed = focal.inputSchema as Record<string, unknown>;
await client.callTool({
name: ECHO_TOOL,
arguments: { schema: transform(observed) }
});
} finally {
await transport.close();
}
}
/**
* Drive the scenario's mock server through the SEP-2575 stateless lifecycle
* that the draft protocol version uses: list tools, then echo the focal
* tool's inputSchema back (verbatim or after the caller-supplied transform).
* The SDK `Client` does not support the stateless lifecycle, so these
* requests go through the raw stateless helper instead.
*/
async function runStatelessEchoClient(
serverUrl: string,
transform: (schema: Record<string, unknown>) => Record<string, unknown> = (
s
) => s
): Promise<void> {
const listed = await sendStatelessRequest(serverUrl, 'tools/list');
const tools = (
listed.body?.result as
| { tools?: { name: string; inputSchema: Record<string, unknown> }[] }
| undefined
)?.tools;
const focal = tools?.find((t) => t.name === FOCAL_TOOL);
if (!focal) {
throw new Error(`Focal tool ${FOCAL_TOOL} not advertised`);
}
await sendStatelessRequest(serverUrl, 'tools/call', {
name: ECHO_TOOL,
arguments: { schema: transform(focal.inputSchema) }
});
}
describe('json-schema-2020-12-preservation scenario', () => {
it('emits FAILURE preconditions and SKIPPED preservation checks when no client connects', async () => {
const scenario = new JsonSchema2020_12PreservationScenario();
await scenario.start(testScenarioContext());
try {
const checks = scenario.getChecks();
expect(checks).toHaveLength(8);
expect(
checks.find((c) => c.id === 'json-schema-2020-12-client-tool-found')
?.status
).toBe('FAILURE');
expect(
checks.find((c) => c.id === 'json-schema-2020-12-client-echo-completed')
?.status
).toBe('FAILURE');
const preservationIds = [
'json-schema-2020-12-client-$schema-preserved',
'json-schema-2020-12-client-$defs-preserved',
'json-schema-2020-12-client-additionalProperties-preserved',
'sep-2106-client-composition-keywords-preserved',
'sep-2106-client-conditional-keywords-preserved',
'sep-2106-client-anchor-keyword-preserved'
];
for (const id of preservationIds) {
expect(checks.find((c) => c.id === id)?.status).toBe('SKIPPED');
}
} finally {
await scenario.stop();
}
});
it('emits SUCCESS for SEP-1613 keywords and SKIPPED for SEP-2106 when a compliant client echoes back on a dated target', async () => {
const scenario = new JsonSchema2020_12PreservationScenario();
const { serverUrl } = await scenario.start(
testScenarioContext(LATEST_SPEC_VERSION)
);
try {
await runEchoClient(serverUrl);
const first = scenario.getChecks();
expect(first).toHaveLength(8);
const byId = (id: string) =>
first.find((c) => c.id === id) ?? expect.fail(`missing check ${id}`);
expect(byId('json-schema-2020-12-client-tool-found').status).toBe(
'SUCCESS'
);
expect(byId('json-schema-2020-12-client-echo-completed').status).toBe(
'SUCCESS'
);
expect(byId('json-schema-2020-12-client-$schema-preserved').status).toBe(
'SUCCESS'
);
expect(byId('json-schema-2020-12-client-$defs-preserved').status).toBe(
'SUCCESS'
);
expect(
byId('json-schema-2020-12-client-additionalProperties-preserved').status
).toBe('SUCCESS');
// SEP-2106 keywords were preserved (compliant client), so the soft gate
// returns SUCCESS regardless of target version.
expect(
byId('sep-2106-client-composition-keywords-preserved').status
).toBe('SUCCESS');
expect(
byId('sep-2106-client-conditional-keywords-preserved').status
).toBe('SUCCESS');
expect(byId('sep-2106-client-anchor-keyword-preserved').status).toBe(
'SUCCESS'
);
// getChecks() must be idempotent — duplicate IDs would break the
// traceability manifest.
expect(scenario.getChecks()).toHaveLength(8);
} finally {
await scenario.stop();
}
});
it('emits all SUCCESS when a compliant client echoes back on the draft target', async () => {
const scenario = new JsonSchema2020_12PreservationScenario();
const { serverUrl } = await scenario.start(
testScenarioContext(DRAFT_PROTOCOL_VERSION)
);
try {
await runStatelessEchoClient(serverUrl);
const checks = scenario.getChecks();
for (const c of checks) {
expect(c.status, `check ${c.id}`).toBe('SUCCESS');
}
} finally {
await scenario.stop();
}
});
it('flags SEP-1613 FAILURE when a client strips $schema and $defs before echoing', async () => {
const scenario = new JsonSchema2020_12PreservationScenario();
const { serverUrl } = await scenario.start(
testScenarioContext(LATEST_SPEC_VERSION)
);
try {
await runEchoClient(serverUrl, (schema) => {
const stripped = { ...schema };
delete stripped['$schema'];
delete stripped['$defs'];
return stripped;
});
const checks = scenario.getChecks();
const byId = (id: string) =>
checks.find((c) => c.id === id) ?? expect.fail(`missing check ${id}`);
expect(byId('json-schema-2020-12-client-tool-found').status).toBe(
'SUCCESS'
);
expect(byId('json-schema-2020-12-client-echo-completed').status).toBe(
'SUCCESS'
);
expect(byId('json-schema-2020-12-client-$schema-preserved').status).toBe(
'FAILURE'
);
expect(byId('json-schema-2020-12-client-$defs-preserved').status).toBe(
'FAILURE'
);
// additionalProperties was left intact, so it still passes.
expect(
byId('json-schema-2020-12-client-additionalProperties-preserved').status
).toBe('SUCCESS');
// $anchor lives inside the stripped $defs, so it's also missing — but
// the soft gate is SKIPPED on the dated target rather than FAILURE.
expect(byId('sep-2106-client-anchor-keyword-preserved').status).toBe(
'SKIPPED'
);
} finally {
await scenario.stop();
}
});
it('flags SEP-2106 FAILURE on the draft target when composition keywords are stripped', async () => {
const scenario = new JsonSchema2020_12PreservationScenario();
const { serverUrl } = await scenario.start(
testScenarioContext(DRAFT_PROTOCOL_VERSION)
);
try {
await runStatelessEchoClient(serverUrl, (schema) => {
const stripped = { ...schema };
delete stripped['allOf'];
delete stripped['anyOf'];
delete stripped['if'];
delete stripped['then'];
delete stripped['else'];
return stripped;
});
const checks = scenario.getChecks();
const byId = (id: string) =>
checks.find((c) => c.id === id) ?? expect.fail(`missing check ${id}`);
expect(byId('json-schema-2020-12-client-$schema-preserved').status).toBe(
'SUCCESS'
);
expect(
byId('sep-2106-client-composition-keywords-preserved').status
).toBe('FAILURE');
expect(
byId('sep-2106-client-conditional-keywords-preserved').status
).toBe('FAILURE');
// $anchor is preserved inside $defs.address, which was left intact.
expect(byId('sep-2106-client-anchor-keyword-preserved').status).toBe(
'SUCCESS'
);
} finally {
await scenario.stop();
}
});
it('preserves the focal fixture verbatim through the echo when the client does not transform it', async () => {
// Sanity check that the round-trip itself does not introduce drift; if
// this fails, all the other tests are unreliable.
const scenario = new JsonSchema2020_12PreservationScenario();
const { serverUrl } = await scenario.start(testScenarioContext());
try {
await runEchoClient(serverUrl);
const checks = scenario.getChecks();
const preserved = checks.find(
(c) => c.id === 'json-schema-2020-12-client-$defs-preserved'
);
expect(preserved?.details).toMatchObject({
hasDefs: true,
defsKeys: Object.keys(JSON_SCHEMA_2020_12_FIXTURE['$defs'])
});
} finally {
await scenario.stop();
}
});
});

View file

@ -0,0 +1,403 @@
/**
* Client-side JSON Schema 2020-12 keyword preservation (SEP-1613, SEP-2106)
*
* Validates that a client receiving a tool definition via `tools/list`
* preserves the full JSON Schema 2020-12 vocabulary in its internal
* representation, rather than silently stripping `$schema`, `$defs`,
* `additionalProperties`, `$anchor`, composition (`allOf`/`anyOf`), or
* conditional (`if`/`then`/`else`) keywords during parsing.
*
* This is the client-side counterpart of the server-side
* `json-schema-2020-12` scenario.
* This scenario exercises:
*
* 1. The mock server advertises a focal tool (`json_schema_2020_12_tool`)
* whose inputSchema contains the full SEP-1613/SEP-2106 vocabulary, plus
* a permissive echo tool (`json_schema_echo`).
* 2. The client under test calls `tools/list`, observes the focal tool's
* inputSchema, then round-trips it back via `tools/call json_schema_echo`
* with `{ schema: <observed inputSchema> }`.
* 3. The scenario diffs the echoed schema against the original fixture and
* flags any missing or altered keywords.
*
* SEP-2106 vocabulary checks are soft-gated via `sep2106KeywordCheckStatus`:
* stripping is FAILURE only when the run targets the draft version
* (`2026-07-28`); on earlier dated versions the check reports SKIPPED.
*/
import type { ScenarioContext, MockServer } from '../../mock-server';
import type {
CallToolRequest,
ListToolsResult
} from '../../spec-types/2025-11-25';
import {
DRAFT_PROTOCOL_VERSION,
LATEST_SPEC_VERSION,
type ConformanceCheck,
type Scenario,
type ScenarioUrls,
type SpecVersion
} from '../../types';
import {
EXPECTED_SCHEMA_DIALECT,
EXPECTED_TOOL_NAME,
JSON_SCHEMA_2020_12_FIXTURE,
sep2106KeywordCheckStatus
} from '../server/json-schema-2020-12';
const ECHO_TOOL_NAME = 'json_schema_echo';
const SEP_1613_REF = {
id: 'SEP-1613',
url: 'https://github.com/modelcontextprotocol/specification/pull/655'
};
const SEP_2106_REF = {
id: 'SEP-2106',
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2106'
};
export class JsonSchema2020_12PreservationScenario implements Scenario {
name = 'json-schema-2020-12-preservation';
readonly source = { introducedIn: '2025-11-25' } as const;
description = `Validates client-side preservation of JSON Schema 2020-12 keywords (SEP-1613, SEP-2106).
**Client Implementation Requirements:**
1. Call \`tools/list\`. The mock server advertises two tools: \`${EXPECTED_TOOL_NAME}\` (focal, carries the rich inputSchema) and \`${ECHO_TOOL_NAME}\` (permissive echo).
2. Round-trip the focal tool's \`inputSchema\` back via \`tools/call\` on \`${ECHO_TOOL_NAME}\`, passing it verbatim as the \`schema\` argument:
\`\`\`json
{
"name": "${ECHO_TOOL_NAME}",
"arguments": {
"schema": "<the inputSchema the client received for ${EXPECTED_TOOL_NAME}>"
}
}
\`\`\`
The scenario compares the echoed schema against the original fixture and flags any keyword that was stripped or altered during the client's internal parsing.
**Verification**: \`$schema\`, \`$defs\`, and \`additionalProperties\` must be preserved (SEP-1613). The SEP-2106 vocabulary (\`$anchor\` inside \`$defs\`, composition \`allOf\`/\`anyOf\`, conditional \`if\`/\`then\`/\`else\`) must also survive; for dated protocol versions these checks are SKIPPED rather than FAILURE since SEP-2106 applies from the draft version.`;
private srv: MockServer | null = null;
private specVersion: SpecVersion | null = null;
private observedSchema: Record<string, unknown> | null = null;
private toolsListed = false;
async start(ctx: ScenarioContext): Promise<ScenarioUrls> {
this.specVersion = ctx.specVersion;
this.observedSchema = null;
this.toolsListed = false;
this.srv = await ctx.createServer({
'tools/list': (): ListToolsResult => {
this.toolsListed = true;
return {
tools: [
{
name: EXPECTED_TOOL_NAME,
description: 'Tool with JSON Schema 2020-12 features',
inputSchema: JSON_SCHEMA_2020_12_FIXTURE
},
{
name: ECHO_TOOL_NAME,
description:
'Echoes back an arbitrary JSON Schema object so the harness can observe what the client preserved.',
inputSchema: {
type: 'object',
properties: {
schema: {
type: 'object',
description: `The inputSchema the client observed for ${EXPECTED_TOOL_NAME}, passed back verbatim.`
}
},
required: ['schema']
}
}
]
};
},
'tools/call': (params) => {
const p = params as unknown as CallToolRequest['params'];
if (p.name === ECHO_TOOL_NAME) {
const args = p.arguments as { schema?: unknown } | undefined;
if (args && typeof args.schema === 'object' && args.schema !== null) {
this.observedSchema = args.schema as Record<string, unknown>;
}
return { content: [{ type: 'text', text: 'echoed' }] };
}
if (p.name === EXPECTED_TOOL_NAME) {
return { content: [{ type: 'text', text: 'ok' }] };
}
throw new Error(`Unknown tool: ${p.name}`);
}
});
return { serverUrl: this.srv.url };
}
async stop(): Promise<void> {
await this.srv?.close();
this.srv = null;
}
getChecks(): ConformanceCheck[] {
// Built fresh on every call so getChecks() is idempotent — the runner may
// call it more than once and we must not accumulate duplicates.
const timestamp = new Date().toISOString();
const targetVersion = this.specVersion ?? LATEST_SPEC_VERSION;
const echo = this.observedSchema;
const checks: ConformanceCheck[] = [];
checks.push({
id: 'json-schema-2020-12-client-tool-found',
name: 'JsonSchema2020_12ClientToolFound',
description: `Client called tools/list and the mock server advertised '${EXPECTED_TOOL_NAME}'`,
status: this.toolsListed ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences: [SEP_1613_REF],
errorMessage: this.toolsListed
? undefined
: 'Client never called tools/list; the scenario cannot observe schema preservation',
details: { toolsListCalled: this.toolsListed }
});
checks.push({
id: 'json-schema-2020-12-client-echo-completed',
name: 'JsonSchema2020_12ClientEchoCompleted',
description: `Client called tools/call '${ECHO_TOOL_NAME}' with a 'schema' object argument`,
status: echo !== null ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences: [SEP_1613_REF],
errorMessage:
echo !== null
? undefined
: 'Client did not echo back the inputSchema; downstream preservation checks are SKIPPED',
details: { echoCompleted: echo !== null }
});
// If the client never echoed, the downstream preservation checks cannot
// be evaluated. Emit SKIPPED for each so the SEP traceability manifest
// still records all known check IDs from this scenario.
if (echo === null) {
const skipReason =
'Skipped because the client did not complete the schema echo';
checks.push(
{
id: 'json-schema-2020-12-client-$schema-preserved',
name: 'JsonSchema2020_12Client$SchemaPreserved',
description:
'Client preserved the $schema field of the focal tool inputSchema',
status: 'SKIPPED',
timestamp,
specReferences: [SEP_1613_REF],
errorMessage: skipReason
},
{
id: 'json-schema-2020-12-client-$defs-preserved',
name: 'JsonSchema2020_12Client$DefsPreserved',
description:
'Client preserved the $defs field of the focal tool inputSchema',
status: 'SKIPPED',
timestamp,
specReferences: [SEP_1613_REF],
errorMessage: skipReason
},
{
id: 'json-schema-2020-12-client-additionalProperties-preserved',
name: 'JsonSchema2020_12ClientAdditionalPropertiesPreserved',
description:
'Client preserved the additionalProperties field of the focal tool inputSchema',
status: 'SKIPPED',
timestamp,
specReferences: [SEP_1613_REF],
errorMessage: skipReason
},
{
id: 'sep-2106-client-composition-keywords-preserved',
name: 'JsonSchema2020_12ClientCompositionKeywordsPreserved',
description:
'Client preserved composition keywords (allOf/anyOf) on the focal tool inputSchema',
status: 'SKIPPED',
timestamp,
specReferences: [SEP_2106_REF],
errorMessage: skipReason
},
{
id: 'sep-2106-client-conditional-keywords-preserved',
name: 'JsonSchema2020_12ClientConditionalKeywordsPreserved',
description:
'Client preserved conditional keywords (if/then/else) on the focal tool inputSchema',
status: 'SKIPPED',
timestamp,
specReferences: [SEP_2106_REF],
errorMessage: skipReason
},
{
id: 'sep-2106-client-anchor-keyword-preserved',
name: 'JsonSchema2020_12ClientAnchorKeywordPreserved',
description:
'Client preserved the $anchor keyword inside $defs.address',
status: 'SKIPPED',
timestamp,
specReferences: [SEP_2106_REF],
errorMessage: skipReason
}
);
return checks;
}
// SEP-1613: $schema preserved
const hasSchema = '$schema' in echo;
const schemaCorrect = echo['$schema'] === EXPECTED_SCHEMA_DIALECT;
checks.push({
id: 'json-schema-2020-12-client-$schema-preserved',
name: 'JsonSchema2020_12Client$SchemaPreserved',
description: `Client preserved $schema = '${EXPECTED_SCHEMA_DIALECT}'`,
status: hasSchema && schemaCorrect ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences: [SEP_1613_REF],
errorMessage: !hasSchema
? '$schema missing from echoed inputSchema — client likely stripped it during parsing'
: !schemaCorrect
? `$schema has unexpected value: ${JSON.stringify(echo['$schema'])}`
: undefined,
details: { hasSchema, schemaValue: echo['$schema'] }
});
// SEP-1613: $defs preserved
const defs = echo['$defs'] as Record<string, unknown> | undefined;
const hasDefs = '$defs' in echo;
const defsHasAddress = hasDefs && defs !== undefined && 'address' in defs;
checks.push({
id: 'json-schema-2020-12-client-$defs-preserved',
name: 'JsonSchema2020_12Client$DefsPreserved',
description:
'Client preserved $defs with the expected nested definitions',
status: hasDefs && defsHasAddress ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences: [SEP_1613_REF],
errorMessage: !hasDefs
? '$defs missing from echoed inputSchema — client likely stripped it during parsing'
: !defsHasAddress
? '$defs exists but missing expected "address" definition'
: undefined,
details: {
hasDefs,
defsKeys: defs !== undefined ? Object.keys(defs) : []
}
});
// SEP-1613: additionalProperties preserved
const hasAdditionalProps = 'additionalProperties' in echo;
const additionalPropsCorrect = echo['additionalProperties'] === false;
checks.push({
id: 'json-schema-2020-12-client-additionalProperties-preserved',
name: 'JsonSchema2020_12ClientAdditionalPropertiesPreserved',
description:
'Client preserved additionalProperties = false on the focal inputSchema',
status:
hasAdditionalProps && additionalPropsCorrect ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences: [SEP_1613_REF],
errorMessage: !hasAdditionalProps
? 'additionalProperties missing from echoed inputSchema — client likely stripped it during parsing'
: !additionalPropsCorrect
? `additionalProperties has unexpected value: ${JSON.stringify(echo['additionalProperties'])}`
: undefined,
details: { hasAdditionalProps, value: echo['additionalProperties'] }
});
// SEP-2106 vocabulary — soft-gated via sep2106KeywordCheckStatus.
const skippedSuffix = ` (run targets protocol version ${targetVersion}; SEP-2106 applies from ${DRAFT_PROTOCOL_VERSION})`;
// SEP-2106: composition (allOf/anyOf) preserved
const allOf = echo['allOf'];
const hasAllOf = Array.isArray(allOf) && allOf.length > 0;
const hasNestedAnyOf =
Array.isArray(allOf) &&
allOf.some(
(sub) =>
typeof sub === 'object' &&
sub !== null &&
Array.isArray((sub as Record<string, unknown>)['anyOf'])
);
const compositionPreserved = hasAllOf && hasNestedAnyOf;
const compositionStatus = sep2106KeywordCheckStatus(
compositionPreserved,
targetVersion
);
checks.push({
id: 'sep-2106-client-composition-keywords-preserved',
name: 'JsonSchema2020_12ClientCompositionKeywordsPreserved',
description:
'Client preserved composition keywords (allOf/anyOf) on the focal inputSchema',
status: compositionStatus,
timestamp,
specReferences: [SEP_2106_REF],
errorMessage: compositionPreserved
? undefined
: (!hasAllOf
? 'allOf missing from echoed inputSchema — composition keyword was likely stripped'
: 'nested anyOf missing from echoed allOf — composition keyword was likely stripped') +
(compositionStatus === 'SKIPPED' ? skippedSuffix : ''),
details: {
hasAllOf,
hasNestedAnyOf,
targetProtocolVersion: targetVersion
}
});
// SEP-2106: conditional (if/then/else) preserved
const hasIf = 'if' in echo;
const hasThen = 'then' in echo;
const hasElse = 'else' in echo;
const conditionalPreserved = hasIf && hasThen && hasElse;
const conditionalStatus = sep2106KeywordCheckStatus(
conditionalPreserved,
targetVersion
);
checks.push({
id: 'sep-2106-client-conditional-keywords-preserved',
name: 'JsonSchema2020_12ClientConditionalKeywordsPreserved',
description:
'Client preserved conditional keywords (if/then/else) on the focal inputSchema',
status: conditionalStatus,
timestamp,
specReferences: [SEP_2106_REF],
errorMessage: conditionalPreserved
? undefined
: `Conditional keywords missing (if=${hasIf}, then=${hasThen}, else=${hasElse}) — likely stripped` +
(conditionalStatus === 'SKIPPED' ? skippedSuffix : ''),
details: {
hasIf,
hasThen,
hasElse,
targetProtocolVersion: targetVersion
}
});
// SEP-2106: $anchor inside $defs.address preserved
const address = defs?.['address'] as Record<string, unknown> | undefined;
const hasAnchor = !!address && '$anchor' in address;
const anchorStatus = sep2106KeywordCheckStatus(hasAnchor, targetVersion);
checks.push({
id: 'sep-2106-client-anchor-keyword-preserved',
name: 'JsonSchema2020_12ClientAnchorKeywordPreserved',
description: 'Client preserved $anchor inside $defs.address',
status: anchorStatus,
timestamp,
specReferences: [SEP_2106_REF],
errorMessage: hasAnchor
? undefined
: '$anchor missing from echoed $defs.address — reference keyword was likely stripped' +
(anchorStatus === 'SKIPPED' ? skippedSuffix : ''),
details: {
hasAnchor,
anchorValue: address?.['$anchor']
}
});
return checks;
}
}

View file

@ -119,6 +119,7 @@ import {
HttpInvalidToolHeadersScenario
} from './client/http-custom-headers';
import { JsonSchemaRefDerefScenario } from './client/json-schema-ref-deref';
import { JsonSchema2020_12PreservationScenario } from './client/json-schema-2020-12-preservation';
// Pending client scenarios (not yet fully tested/implemented)
const pendingClientScenariosList: ClientScenario[] = [
@ -313,7 +314,10 @@ const scenariosList: Scenario[] = [
new HttpInvalidToolHeadersScenario(),
// JSON Schema network $ref dereferencing (SEP-2106)
new JsonSchemaRefDerefScenario()
new JsonSchemaRefDerefScenario(),
// JSON Schema 2020-12 client-side keyword preservation (SEP-1613, SEP-2106)
new JsonSchema2020_12PreservationScenario()
];
// Core scenarios (tier 1 requirements)

View file

@ -20,8 +20,48 @@ import {
import type { RunContext } from '../../connection';
import type { ListToolsResult } from '../../spec-types/2025-11-25';
const EXPECTED_TOOL_NAME = 'json_schema_2020_12_tool';
const EXPECTED_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema';
export const EXPECTED_TOOL_NAME = 'json_schema_2020_12_tool';
export const EXPECTED_SCHEMA_DIALECT =
'https://json-schema.org/draft/2020-12/schema';
/**
* Canonical focal `inputSchema` used by both the server-side
* `json-schema-2020-12` scenario (as the schema servers under test are
* expected to emit) and the client-side `json-schema-2020-12-preservation`
* scenario (as the schema the mock server actually emits on the wire).
*
* Keeping a single source of truth ensures the documented requirements and
* the runtime test fixture stay in sync.
*/
export const JSON_SCHEMA_2020_12_FIXTURE = {
$schema: EXPECTED_SCHEMA_DIALECT,
type: 'object',
$defs: {
address: {
$anchor: 'addressDef',
type: 'object',
properties: {
street: { type: 'string' },
city: { type: 'string' }
}
}
},
properties: {
name: { type: 'string' },
address: { $ref: '#/$defs/address' },
contactMethod: { type: 'string', enum: ['phone', 'email'] },
phone: { type: 'string' },
email: { type: 'string' }
},
allOf: [{ anyOf: [{ required: ['phone'] }, { required: ['email'] }] }],
if: {
properties: { contactMethod: { const: 'phone' } },
required: ['contactMethod']
},
then: { required: ['phone'] },
else: { required: ['email'] },
additionalProperties: false
} as const;
/**
* Soft version gate for the SEP-2106 keyword-preservation checks.
@ -55,41 +95,15 @@ export class JsonSchema2020_12Scenario implements ClientScenario {
Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema 2020-12 features, including the broader vocabulary permitted by SEP-2106 (an \`$anchor\` inside \`$defs\`, composition keywords \`allOf\`/\`anyOf\`, and conditional keywords \`if\`/\`then\`/\`else\`):
\`\`\`json
{
"name": "${EXPECTED_TOOL_NAME}",
"description": "Tool with JSON Schema 2020-12 features",
"inputSchema": {
"$schema": "${EXPECTED_SCHEMA_DIALECT}",
"type": "object",
"$defs": {
"address": {
"$anchor": "addressDef",
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" }
}
}
},
"properties": {
"name": { "type": "string" },
"address": { "$ref": "#/$defs/address" },
"contactMethod": { "type": "string", "enum": ["phone", "email"] },
"phone": { "type": "string" },
"email": { "type": "string" }
},
"allOf": [
{ "anyOf": [{ "required": ["phone"] }, { "required": ["email"] }] }
],
"if": {
"properties": { "contactMethod": { "const": "phone" } },
"required": ["contactMethod"]
},
"then": { "required": ["phone"] },
"else": { "required": ["email"] },
"additionalProperties": false
}
}
${JSON.stringify(
{
name: EXPECTED_TOOL_NAME,
description: 'Tool with JSON Schema 2020-12 features',
inputSchema: JSON_SCHEMA_2020_12_FIXTURE
},
null,
2
)}
\`\`\`
**Verification**: The test verifies that \`$schema\`, \`$defs\`, and \`additionalProperties\` are preserved (SEP-1613), and that the composition (\`allOf\`/\`anyOf\`), conditional (\`if\`/\`then\`/\`else\`), and \`$anchor\` keywords are preserved (SEP-2106), in the tool listing response.`;

View file

@ -4,6 +4,25 @@ requirements:
- check: sep-2106-no-network-ref-deref
text: 'Implementations MUST NOT automatically dereference `$ref` values that resolve to a network URI (i.e. anything that is not a same-document JSON Pointer such as `#/$defs/Foo` or an internal `$anchor`); an opt-in mode that fetches non-local `$ref`s MUST be disabled by default / Schemas that fail to validate due to an unresolved external `$ref` SHOULD be rejected rather than silently treated as permissive'
# Client-side keyword-preservation checks, derived from issue #101 rather
# than a normative sentence in the SEP: SEP-2106 §1 "Loosen inputSchema"
# redefines `inputSchema` to permit any additional JSON Schema 2020-12
# keyword, and a client that silently strips such a keyword while parsing
# a server's tool definition breaks that contract, since downstream
# consumers can no longer recover the intended semantics from what the
# client exposes. The scenario's SEP-1613-scoped checks ($schema, $defs,
# additionalProperties) are not declared here: their IDs are not
# sep-2106-prefixed, so the traceability manifest never collects them.
- check: sep-2106-client-composition-keywords-preserved
text: 'The `inputSchema` field "now accepts any additional JSON Schema properties. This enables: Composition keywords: anyOf, oneOf, allOf, not". Derived expectation (issue #101): a client parsing a tool definition received from a server is expected to preserve these composition keywords.'
url: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#1-loosen-inputschema
- check: sep-2106-client-conditional-keywords-preserved
text: 'The `inputSchema` field "now accepts any additional JSON Schema properties. This enables: ... Conditional schemas: if/then/else". Derived expectation (issue #101): a client parsing a tool definition received from a server is expected to preserve these conditional keywords.'
url: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#1-loosen-inputschema
- check: sep-2106-client-anchor-keyword-preserved
text: 'The `inputSchema` field "now accepts any additional JSON Schema properties. This enables: ... Reference schemas: $ref, $defs". Derived expectation (issue #101): a client parsing a tool definition received from a server is expected to preserve `$anchor` (a JSON Schema 2020-12 reference-anchor keyword that lives inside `$defs`).'
url: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#1-loosen-inputschema
- text: "SDK maintainers SHOULD: Document the migration in SDK release notes; Where ergonomic, provide typed helpers (e.g. generics over a tool's `outputSchema`) so consumers do not need to write narrowing guards by hand"
excluded: 'Migration/deprecation guidance for SDK maintainers; about SDK source code, not protocol-observable wire behavior'
url: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#backward-compatibility