* 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>
352 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|