conformance/src/scenarios/server/json-schema-2020-12.ts
Satoshi Ito 81eb1c3eda
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>
2026-07-31 17:24:05 +00:00

352 lines
13 KiB
TypeScript

/**
* JSON Schema 2020-12 conformance test scenario (SEP-1613, SEP-2106)
*
* Validates that MCP servers correctly preserve JSON Schema 2020-12 keywords
* in tool definitions, ensuring implementations don't strip $schema, $defs,
* or additionalProperties fields (SEP-1613).
*
* SEP-2106 broadened inputSchema to permit the full JSON Schema 2020-12
* vocabulary alongside the required root `type: "object"`. This scenario also
* verifies that composition (allOf/anyOf), conditional (if/then/else), and
* reference ($anchor) keywords survive tools/list rather than being stripped.
*/
import {
CheckStatus,
ClientScenario,
ConformanceCheck,
DRAFT_PROTOCOL_VERSION
} from '../../types.js';
import type { RunContext } from '../../connection';
import type { ListToolsResult } from '../../spec-types/2025-11-25';
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.
*
* Preserving the broader JSON Schema 2020-12 vocabulary is good behavior at
* any protocol version, so a server that preserves the keywords gets SUCCESS
* regardless. Stripping them is only a conformance FAILURE when the run
* targets a protocol version that includes SEP-2106 (the draft); when
* targeting an earlier dated version the requirement does not apply, so the
* check is SKIPPED rather than failed.
*/
export function sep2106KeywordCheckStatus(
preserved: boolean,
targetProtocolVersion: string
): CheckStatus {
if (preserved) {
return 'SUCCESS';
}
return targetProtocolVersion === DRAFT_PROTOCOL_VERSION
? 'FAILURE'
: 'SKIPPED';
}
export class JsonSchema2020_12Scenario implements ClientScenario {
name = 'json-schema-2020-12';
readonly source = { introducedIn: '2025-11-25' } as const;
description = `Validates JSON Schema 2020-12 keyword preservation (SEP-1613, SEP-2106).
**Server Implementation Requirements:**
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
${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.`;
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
const checks: ConformanceCheck[] = [];
const specReferences = [
{
id: 'SEP-1613',
url: 'https://github.com/modelcontextprotocol/specification/pull/655'
}
];
const sep2106References = [
{
id: 'SEP-2106',
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2106'
}
];
try {
const conn = await ctx.connect();
// Spec version this run is targeting; used to soft-gate the SEP-2106
// checks below.
const targetVersion = ctx.specVersion;
const result = await conn.request<ListToolsResult>('tools/list');
// Find the test tool
const tool = result.tools?.find((t) => t.name === EXPECTED_TOOL_NAME);
// Check 1: Tool exists
checks.push({
id: 'json-schema-2020-12-tool-found',
name: 'JsonSchema2020_12ToolFound',
description: `Server advertises tool '${EXPECTED_TOOL_NAME}'`,
status: tool ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: tool
? undefined
: `Tool '${EXPECTED_TOOL_NAME}' not found. Available tools: ${result.tools?.map((t) => t.name).join(', ') || 'none'}`,
specReferences,
details: {
toolFound: !!tool,
availableTools: result.tools?.map((t) => t.name) || []
}
});
if (!tool) {
await conn.close();
return checks;
}
const inputSchema = tool.inputSchema as Record<string, unknown>;
// Check 2: $schema field preserved
const hasSchema = '$schema' in inputSchema;
const schemaValue = inputSchema['$schema'];
const schemaCorrect = schemaValue === EXPECTED_SCHEMA_DIALECT;
checks.push({
id: 'json-schema-2020-12-$schema',
name: 'JsonSchema2020_12$Schema',
description: `inputSchema.$schema field preserved with value '${EXPECTED_SCHEMA_DIALECT}'`,
status: hasSchema && schemaCorrect ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: !hasSchema
? '$schema field missing from inputSchema - field was likely stripped'
: !schemaCorrect
? `$schema has unexpected value: ${JSON.stringify(schemaValue)}`
: undefined,
specReferences,
details: {
hasSchema,
schemaValue,
expected: EXPECTED_SCHEMA_DIALECT
}
});
// Check 3: $defs field preserved
const hasDefs = '$defs' in inputSchema;
const defsValue = inputSchema['$defs'] as
| Record<string, unknown>
| undefined;
const defsHasAddress = defsValue && 'address' in defsValue;
checks.push({
id: 'json-schema-2020-12-$defs',
name: 'JsonSchema2020_12$Defs',
description:
'inputSchema.$defs field preserved with expected structure',
status: hasDefs && defsHasAddress ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: !hasDefs
? '$defs field missing from inputSchema - field was likely stripped'
: !defsHasAddress
? '$defs exists but missing expected "address" definition'
: undefined,
specReferences,
details: {
hasDefs,
defsKeys: defsValue ? Object.keys(defsValue) : [],
defsValue
}
});
// Check 4: additionalProperties field preserved
const hasAdditionalProps = 'additionalProperties' in inputSchema;
const additionalPropsValue = inputSchema['additionalProperties'];
const additionalPropsCorrect = additionalPropsValue === false;
checks.push({
id: 'json-schema-2020-12-additionalProperties',
name: 'JsonSchema2020_12AdditionalProperties',
description: 'inputSchema.additionalProperties field preserved',
status:
hasAdditionalProps && additionalPropsCorrect ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: !hasAdditionalProps
? 'additionalProperties field missing from inputSchema - field was likely stripped'
: !additionalPropsCorrect
? `additionalProperties has unexpected value: ${JSON.stringify(additionalPropsValue)}, expected: false`
: undefined,
specReferences,
details: {
hasAdditionalProps,
additionalPropsValue,
expected: false
}
});
// SEP-2106: the full JSON Schema 2020-12 vocabulary is permitted in
// inputSchema and must survive tools/list rather than being stripped to
// properties/required. These checks are soft-gated on the protocol
// version this run targets (see sep2106KeywordCheckStatus): stripping
// the keywords is only a FAILURE when targeting the draft version, and
// SKIPPED otherwise.
const skippedSuffix = ` (run targets protocol version ${targetVersion}; SEP-2106 applies from ${DRAFT_PROTOCOL_VERSION})`;
// Check 5: composition keywords (allOf / anyOf) preserved
const allOf = inputSchema['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-composition-keywords-preserved',
name: 'JsonSchema2020_12CompositionKeywords',
description:
'inputSchema composition keywords (allOf/anyOf) preserved (SEP-2106)',
status: compositionStatus,
timestamp: new Date().toISOString(),
errorMessage: compositionPreserved
? undefined
: (!hasAllOf
? 'allOf keyword missing from inputSchema - composition keyword was likely stripped'
: 'nested anyOf missing from allOf - composition keyword was likely stripped') +
(compositionStatus === 'SKIPPED' ? skippedSuffix : ''),
specReferences: sep2106References,
details: {
hasAllOf,
hasNestedAnyOf,
targetProtocolVersion: targetVersion
}
});
// Check 6: conditional keywords (if / then / else) preserved
const hasIf = 'if' in inputSchema;
const hasThen = 'then' in inputSchema;
const hasElse = 'else' in inputSchema;
const conditionalPreserved = hasIf && hasThen && hasElse;
const conditionalStatus = sep2106KeywordCheckStatus(
conditionalPreserved,
targetVersion
);
checks.push({
id: 'sep-2106-conditional-keywords-preserved',
name: 'JsonSchema2020_12ConditionalKeywords',
description:
'inputSchema conditional keywords (if/then/else) preserved (SEP-2106)',
status: conditionalStatus,
timestamp: new Date().toISOString(),
errorMessage: conditionalPreserved
? undefined
: `Conditional keywords missing (if=${hasIf}, then=${hasThen}, else=${hasElse}) - likely stripped` +
(conditionalStatus === 'SKIPPED' ? skippedSuffix : ''),
specReferences: sep2106References,
details: {
hasIf,
hasThen,
hasElse,
targetProtocolVersion: targetVersion
}
});
// Check 7: reference keyword ($anchor) preserved within $defs.address
const addressDef = defsValue?.['address'] as
| Record<string, unknown>
| undefined;
const hasAnchor = !!addressDef && '$anchor' in addressDef;
const anchorStatus = sep2106KeywordCheckStatus(hasAnchor, targetVersion);
checks.push({
id: 'sep-2106-anchor-keyword-preserved',
name: 'JsonSchema2020_12AnchorKeyword',
description:
'inputSchema reference keyword ($anchor) preserved in $defs (SEP-2106)',
status: anchorStatus,
timestamp: new Date().toISOString(),
errorMessage: hasAnchor
? undefined
: '$anchor missing from $defs.address - reference keyword was likely stripped' +
(anchorStatus === 'SKIPPED' ? skippedSuffix : ''),
specReferences: sep2106References,
details: {
hasAnchor,
anchorValue: addressDef?.['$anchor']
}
});
await conn.close();
} catch (error) {
checks.push({
id: 'json-schema-2020-12-error',
name: 'JsonSchema2020_12Error',
description: 'JSON Schema 2020-12 conformance test',
status: 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
specReferences
});
}
return checks;
}
}