Validate all wire messages against the per-version spec JSON schema (#399)
* Vendor schema/{version}/schema.json alongside schema.ts
sync-schema now copies the spec repo's JSON Schema for each version into
src/spec-types/{version}.schema.json (same pinned SHA recorded in SOURCE:
modelcontextprotocol@f817239f4d6b1efff2c4dfc2f7af85c985d73076). These are
the inputs for runtime wire-message validation. Vendored verbatim, so they
join schema.ts in .prettierignore.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHNrpU3Vr6Q1rPZiji7zLY
* Validate all wire messages against the per-version spec JSON schema
Every JSON-RPC message the harness sends or receives is now validated
(ajv) against the vendored schema.json for the run's spec version, at the
wire choke points: sendStatelessRequest (outbound request, JSON response,
SSE events), connectStateful (request/result/notifications), and both mock
servers (inbound client requests, outbound responses/handler results).
Beyond the JSONRPCMessage envelope, messages are validated against the
most specific definition the schema has: typed requests/notifications by
method const, typed error responses by error.code const (e.g. -32021 ->
MissingRequiredClientCapabilityError, the #376 hallucination), and typed
results via the XxxRequest -> XxxResult pair, with SEP-2322
InputRequiredResult discriminated on resultType.
Violations are recorded per scenario and surfaced by the runners as a
synthetic wire-schema-valid check (FAILURE when the implementation under
test sent an invalid message) plus a wire-schema-harness-error check when
the invalid message was harness-authored. A vitest-wide afterEach guard
fails any self-test that recorded violations, so hallucinated fixtures
can't ship (#376, #271). Opt-outs are explicit and greppable: per-call
skipValidation for deliberately malformed traffic, takeWireViolations()
drains in tests that exercise deliberately broken fixtures.
Offenders the new validation caught, now fixed:
- everything-server stateless path omitted resultType (and the caching
hints on server/discover) required by the draft schema
- sep-2164-empty-contents fixture omitted resultType/ttlMs/cacheScope
(its intended violation, empty contents, is schema-valid prose-only)
- connection/mock-server unit-test fixtures used schema-invalid stubs
(tools/call without name, ListToolsResult without required members,
Tool without inputSchema, ProgressNotification without progressToken,
ElicitRequest without message/requestedSchema)
- http-standard-headers test mocks returned bare { tools: [] } results
- input-required-result-validate-input deliberately sends malformed
inputResponses; now marked skipValidation
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHNrpU3Vr6Q1rPZiji7zLY
* Validate the stateful wire at the SDK transport, close review-panel gaps
Address the review-panel findings on the wire-schema validation PR:
- Stateful wire: hook the SDK transport inside connectToServer, wrapping
send/onmessage so every raw wire message in both directions is
validated for the scenario's spec version -- including the initialize
handshake, server->client requests (elicitation/sampling), and the
harness's responses to them. Request ids are correlated per direction
so responses validate against their typed result definition (e.g. an
outbound ElicitResult). The reconstructed stand-ins in
connectStateful (fake id: 0 envelopes, Zod-reparsed results) are
removed; the hook sees the real bytes. The four scenarios that call
connectToServer directly (elicitation-defaults, elicitation-enums,
tools, lifecycle) now pass ctx.specVersion and get validation for
free; the runner surfaces their violations through the existing
recorder. Known remaining gap (documented in the module doc): the
client-auth scenarios' bespoke express mock is not instrumented.
- skipValidation now skips only the request direction; the
implementation's response to a deliberately malformed request is
still validated, with a narrow carve-out tolerating the JSON-RPC 2.0
id: null an error response must carry when the request could not be
processed (the MCP schema's RequestId forbids null). Covered by new
unit tests pinning both the still-validated response and the
narrowness of the carve-out.
- negative-mrtr.test.ts: the file-wide drain now asserts every recorded
violation is implementation-origin (the deliberately broken fixture)
and throws on any harness-origin violation.
- everything-server: hoist the cacheable-methods set to module scope
and replace the per-request res.json monkey-patch with an explicit
sendStatelessJson(res, method, payload) helper applied at each
dispatch site.
- Pin the extracted dispatch maps (methodDefs/errorDefs, e.g.
-32021 -> MissingRequiredClientCapabilityError under the draft) per
spec version in wire-schema.test.ts, so a schema sync that breaks the
const extraction fails loudly instead of degrading to envelope-only
validation. Also note the batch branch's requestMethod forwarding
limitation.
Offender the transport hook flushed out: the everything-server's
test_elicitation_sep1034_defaults tool sent a number-typed elicitation
field with default: 95.5, which the 2025-11-25 schema forbids
(NumberSchema.default is typed integer there; widened to number only in
the draft). The fixture and the scenario's expected default are now the
integer-valued 95, with comments explaining why.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHNrpU3Vr6Q1rPZiji7zLY
* Condense comments to three lines max
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHNrpU3Vr6Q1rPZiji7zLY
* Re-vendor spec schemas at pinned SHA 71e30695
Regenerate src/spec-types/*.schema.json via npm run sync-schema at the
SHA already recorded in SOURCE. The previously vendored draft schema
predated spec PR #3002, so it still required DiscoverResult.serverInfo
at the top level; the mock server now correctly reports serverInfo via
result _meta, which the stale schema rejected.
* Scope the wire-violation recorder per scenario for parallel suite runs
Client --suite runs execute scenarios concurrently over what was a
module-global violation recorder, so scenarios could steal or wipe each
other's recorded violations. Give each suite scenario its own recorder
via AsyncLocalStorage; code outside a scope (single-scenario runs, the
sequential server suite, vitest hooks) falls back to the global
recorder. Also condense a four-line comment in connection/stateful.ts
to three lines.
* Patch the 2025-11-25 NumberSchema generator bug as a load-time erratum
The released 2025-11-25 schema.json types NumberSchema
minimum/maximum/default as integer, contradicting its own schema.ts
(default?: number) — a typescript-json-schema artifact, fixed for draft
in modelcontextprotocol#2710 and proposed for 2025-11-25 in
modelcontextprotocol#3139. Patch the schema at load time so SDK
fixtures with fractional number defaults (all tier-1 SDKs send 95.5)
are not flagged; restore the sep-1034 scenario and example-server
fixture to 95.5. Delete the erratum when #3139 lands and the schemas
are re-vendored.
Also scope the sequential server-suite loop with withWireRecorder so a
connection leaked by a failed scenario cannot attribute late traffic to
the next scenario.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
parent
2a705a811e
commit
89c2c0452e
3 changed files with 14136 additions and 129 deletions
23
src/validation/vitest-hooks.ts
Normal file
23
src/validation/vitest-hooks.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Vitest-wide guard: in self-tests both sides of the wire are harness-authored, so any
|
||||
// wire-schema violation is a harness bug — fail the test loudly. Tests that intentionally
|
||||
// send nonconformant traffic drain the recorder with `takeWireViolations()` before ending.
|
||||
|
||||
import { afterEach, beforeEach } from 'vitest';
|
||||
import { formatWireViolation, takeWireViolations } from './wire-schema';
|
||||
|
||||
beforeEach(() => {
|
||||
// Discard anything recorded between tests (e.g. by a mock server still
|
||||
// answering a straggling request from the previous test).
|
||||
takeWireViolations();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const { violations } = takeWireViolations();
|
||||
if (violations.length > 0) {
|
||||
throw new Error(
|
||||
'Wire messages violated the spec JSON schema during this test ' +
|
||||
'(intentional? drain with takeWireViolations()):\n ' +
|
||||
violations.map(formatWireViolation).join('\n ')
|
||||
);
|
||||
}
|
||||
});
|
||||
509
src/validation/wire-schema.test.ts
Normal file
509
src/validation/wire-schema.test.ts
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
import { createServer, type Server } from 'http';
|
||||
import { DRAFT_PROTOCOL_VERSION } from '../types';
|
||||
import { sendStatelessRequest } from '../connection/stateless';
|
||||
import {
|
||||
resetWireValidation,
|
||||
specDispatchMaps,
|
||||
takeWireViolations,
|
||||
validateWireMessage,
|
||||
wireSchemaChecks,
|
||||
wireSchemaErrors,
|
||||
withWireRecorder
|
||||
} from './wire-schema';
|
||||
|
||||
const META = {
|
||||
'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION,
|
||||
'io.modelcontextprotocol/clientInfo': { name: 'c', version: '1' },
|
||||
'io.modelcontextprotocol/clientCapabilities': { sampling: {} }
|
||||
};
|
||||
|
||||
/** The exact hallucinated shape PR #376 fixed: an array instead of a
|
||||
* ClientCapabilities object in error.data.requiredCapabilities. */
|
||||
const PR_376_INVALID_ERROR = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
error: {
|
||||
code: -32021,
|
||||
message: 'Server requires the sampling capability',
|
||||
data: { requiredCapabilities: ['sampling'] }
|
||||
}
|
||||
};
|
||||
|
||||
function listen(
|
||||
handler: (body: string) => { status: number; body: unknown }
|
||||
): Promise<{ url: string; server: Server }> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer((req, res) => {
|
||||
let raw = '';
|
||||
req.on('data', (c) => (raw += c));
|
||||
req.on('end', () => {
|
||||
const { status, body } = handler(raw);
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
});
|
||||
});
|
||||
server.listen(0, () => {
|
||||
const addr = server.address() as { port: number };
|
||||
resolve({ url: `http://localhost:${addr.port}/mcp`, server });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('wireSchemaErrors', () => {
|
||||
it('rejects the PR #376 hallucinated requiredCapabilities array under the draft schema', () => {
|
||||
const errors = wireSchemaErrors(
|
||||
DRAFT_PROTOCOL_VERSION,
|
||||
PR_376_INVALID_ERROR
|
||||
);
|
||||
expect(errors).toEqual([
|
||||
expect.stringContaining(
|
||||
'MissingRequiredClientCapabilityError/error/data/requiredCapabilities: must be object'
|
||||
)
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts the spec-valid ClientCapabilities object shape', () => {
|
||||
expect(
|
||||
wireSchemaErrors(DRAFT_PROTOCOL_VERSION, {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
error: {
|
||||
code: -32021,
|
||||
message: 'Server requires the sampling capability',
|
||||
data: { requiredCapabilities: { sampling: {} } }
|
||||
}
|
||||
})
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects messages that are not JSON-RPC at all', () => {
|
||||
expect(wireSchemaErrors('2025-06-18', { hello: 'world' })).toEqual([
|
||||
expect.stringContaining('JSONRPCMessage')
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates typed requests by method const', () => {
|
||||
expect(
|
||||
wireSchemaErrors(DRAFT_PROTOCOL_VERSION, {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { _meta: META }
|
||||
})
|
||||
).toEqual([
|
||||
expect.stringContaining(
|
||||
"CallToolRequest/params: must have required property 'name'"
|
||||
)
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates results against the typed result definition for the request method', () => {
|
||||
// Draft results must carry resultType / ttlMs / cacheScope.
|
||||
const errors = wireSchemaErrors(
|
||||
DRAFT_PROTOCOL_VERSION,
|
||||
{ jsonrpc: '2.0', id: 3, result: { tools: [] } },
|
||||
'tools/list'
|
||||
);
|
||||
expect(errors.join('\n')).toContain('ListToolsResult');
|
||||
// The same result is valid at 2025-11-25, which has no such requirement.
|
||||
expect(
|
||||
wireSchemaErrors(
|
||||
'2025-11-25',
|
||||
{ jsonrpc: '2.0', id: 3, result: { tools: [] } },
|
||||
'tools/list'
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts a JSON-RPC batch under 2025-03-26 and reports per-element errors', () => {
|
||||
expect(
|
||||
wireSchemaErrors('2025-03-26', [
|
||||
{ jsonrpc: '2.0', id: 1, method: 'ping' },
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' }
|
||||
])
|
||||
).toEqual([]);
|
||||
expect(
|
||||
wireSchemaErrors('2025-03-26', [
|
||||
{ jsonrpc: '2.0', id: 1, method: 'ping' },
|
||||
{ nope: true }
|
||||
])
|
||||
).toEqual([expect.stringContaining('[1] JSONRPCMessage')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wire-schema choke points and checks', () => {
|
||||
it('records a failing wire-schema-valid check when the implementation sends the PR #376 shape', async () => {
|
||||
const { url, server } = await listen(() => ({
|
||||
status: 400,
|
||||
body: PR_376_INVALID_ERROR
|
||||
}));
|
||||
try {
|
||||
resetWireValidation();
|
||||
await sendStatelessRequest(url, 'tools/list');
|
||||
const checks = wireSchemaChecks(DRAFT_PROTOCOL_VERSION);
|
||||
const wire = checks.find((c) => c.id === 'wire-schema-valid');
|
||||
expect(wire?.status).toBe('FAILURE');
|
||||
expect(wire?.errorMessage).toContain('requiredCapabilities');
|
||||
// The invalid message came from the (mock) implementation, not the
|
||||
// harness — no harness-error check.
|
||||
expect(
|
||||
checks.find((c) => c.id === 'wire-schema-harness-error')
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('records a hard harness error when the harness itself sends an invalid message', async () => {
|
||||
const { url, server } = await listen(() => ({
|
||||
status: 200,
|
||||
body: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
result: { resultType: 'complete', content: [] }
|
||||
}
|
||||
}));
|
||||
try {
|
||||
resetWireValidation();
|
||||
// tools/call without params.name violates CallToolRequest.
|
||||
await sendStatelessRequest(url, 'tools/call', { arguments: {} });
|
||||
const checks = wireSchemaChecks(DRAFT_PROTOCOL_VERSION);
|
||||
const harness = checks.find((c) => c.id === 'wire-schema-harness-error');
|
||||
expect(harness?.status).toBe('FAILURE');
|
||||
expect(harness?.errorMessage).toContain('HARNESS ERROR');
|
||||
expect(harness?.errorMessage).toContain("required property 'name'");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips only the request direction for a call that opts out via skipValidation', async () => {
|
||||
const { url, server } = await listen(() => ({
|
||||
status: 400,
|
||||
body: PR_376_INVALID_ERROR
|
||||
}));
|
||||
try {
|
||||
resetWireValidation();
|
||||
// The deliberately-malformed request is not validated, but the
|
||||
// implementation's response still is — and here it is invalid.
|
||||
await sendStatelessRequest(
|
||||
url,
|
||||
'tools/call',
|
||||
{ arguments: {} },
|
||||
{ skipValidation: true }
|
||||
);
|
||||
const { violations, observed } = takeWireViolations();
|
||||
expect(observed).toBe(1);
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].origin).toBe('implementation');
|
||||
expect(violations[0].errors.join('\n')).toContain('requiredCapabilities');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('tolerates a JSON-RPC 2.0 `id: null` error response only on skip-validated requests', async () => {
|
||||
// JSON-RPC 2.0 requires id: null on error responses to requests that
|
||||
// could not be processed; the MCP schema's RequestId forbids null.
|
||||
const { url, server } = await listen(() => ({
|
||||
status: 400,
|
||||
body: {
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32600, message: 'Invalid Request' }
|
||||
}
|
||||
}));
|
||||
try {
|
||||
resetWireValidation();
|
||||
await sendStatelessRequest(
|
||||
url,
|
||||
'tools/call',
|
||||
{ arguments: {} },
|
||||
{ skipValidation: true }
|
||||
);
|
||||
const skipped = takeWireViolations();
|
||||
expect(skipped.observed).toBe(1);
|
||||
expect(skipped.violations).toEqual([]);
|
||||
|
||||
// The carve-out is narrow: the same response on a normal (validated)
|
||||
// call is still a violation.
|
||||
await sendStatelessRequest(url, 'tools/list');
|
||||
const normal = takeWireViolations();
|
||||
expect(
|
||||
normal.violations.filter((v) => v.origin === 'implementation')
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('emits a passing wire-schema-valid check for clean traffic', async () => {
|
||||
const { url, server } = await listen(() => ({
|
||||
status: 200,
|
||||
body: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
result: {
|
||||
resultType: 'complete',
|
||||
ttlMs: 0,
|
||||
cacheScope: 'private',
|
||||
tools: []
|
||||
}
|
||||
}
|
||||
}));
|
||||
try {
|
||||
resetWireValidation();
|
||||
await sendStatelessRequest(url, 'tools/list');
|
||||
const checks = wireSchemaChecks(DRAFT_PROTOCOL_VERSION);
|
||||
expect(checks).toHaveLength(1);
|
||||
expect(checks[0].id).toBe('wire-schema-valid');
|
||||
expect(checks[0].status).toBe('SUCCESS');
|
||||
expect(checks[0].details?.messagesValidated).toBe(2);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('emits no checks when no wire traffic was observed', () => {
|
||||
resetWireValidation();
|
||||
expect(wireSchemaChecks(DRAFT_PROTOCOL_VERSION)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('specDispatchMaps', () => {
|
||||
// Pin the dispatch maps extracted from each vendored schema: if a schema sync
|
||||
// restructures the `method`/`error.code` consts the extraction walks, validation
|
||||
// silently degrades to envelope-only. These pins make that loud instead.
|
||||
|
||||
const METHOD_DEFS_2025_03_26: Record<string, string> = {
|
||||
'tools/call': 'CallToolRequest',
|
||||
'notifications/cancelled': 'CancelledNotification',
|
||||
'completion/complete': 'CompleteRequest',
|
||||
'sampling/createMessage': 'CreateMessageRequest',
|
||||
'prompts/get': 'GetPromptRequest',
|
||||
initialize: 'InitializeRequest',
|
||||
'notifications/initialized': 'InitializedNotification',
|
||||
'prompts/list': 'ListPromptsRequest',
|
||||
'resources/templates/list': 'ListResourceTemplatesRequest',
|
||||
'resources/list': 'ListResourcesRequest',
|
||||
'roots/list': 'ListRootsRequest',
|
||||
'tools/list': 'ListToolsRequest',
|
||||
'notifications/message': 'LoggingMessageNotification',
|
||||
ping: 'PingRequest',
|
||||
'notifications/progress': 'ProgressNotification',
|
||||
'notifications/prompts/list_changed': 'PromptListChangedNotification',
|
||||
'resources/read': 'ReadResourceRequest',
|
||||
'notifications/resources/list_changed': 'ResourceListChangedNotification',
|
||||
'notifications/resources/updated': 'ResourceUpdatedNotification',
|
||||
'notifications/roots/list_changed': 'RootsListChangedNotification',
|
||||
'logging/setLevel': 'SetLevelRequest',
|
||||
'resources/subscribe': 'SubscribeRequest',
|
||||
'notifications/tools/list_changed': 'ToolListChangedNotification',
|
||||
'resources/unsubscribe': 'UnsubscribeRequest'
|
||||
};
|
||||
|
||||
// 2025-06-18 adds elicitation on top of 2025-03-26.
|
||||
const METHOD_DEFS_2025_06_18: Record<string, string> = {
|
||||
...METHOD_DEFS_2025_03_26,
|
||||
'elicitation/create': 'ElicitRequest'
|
||||
};
|
||||
|
||||
// 2025-11-25 adds tasks (SEP-1686) and the elicitation-complete
|
||||
// notification on top of 2025-06-18.
|
||||
const METHOD_DEFS_2025_11_25: Record<string, string> = {
|
||||
...METHOD_DEFS_2025_06_18,
|
||||
'tasks/cancel': 'CancelTaskRequest',
|
||||
'notifications/elicitation/complete': 'ElicitationCompleteNotification',
|
||||
'tasks/result': 'GetTaskPayloadRequest',
|
||||
'tasks/get': 'GetTaskRequest',
|
||||
'tasks/list': 'ListTasksRequest',
|
||||
'notifications/tasks/status': 'TaskStatusNotification'
|
||||
};
|
||||
|
||||
// The draft (SEP-2575) drops the initialize/session lifecycle, ping,
|
||||
// logging/setLevel, roots-changed, tasks, and resource subscriptions, and
|
||||
// adds server/discover plus subscriptions/listen.
|
||||
const METHOD_DEFS_DRAFT: Record<string, string> = (() => {
|
||||
const defs: Record<string, string> = {
|
||||
...METHOD_DEFS_2025_11_25,
|
||||
'server/discover': 'DiscoverRequest',
|
||||
'subscriptions/listen': 'SubscriptionsListenRequest',
|
||||
'notifications/subscriptions/acknowledged':
|
||||
'SubscriptionsAcknowledgedNotification'
|
||||
};
|
||||
for (const removed of [
|
||||
'initialize',
|
||||
'notifications/initialized',
|
||||
'ping',
|
||||
'logging/setLevel',
|
||||
'notifications/roots/list_changed',
|
||||
'resources/subscribe',
|
||||
'resources/unsubscribe',
|
||||
'tasks/cancel',
|
||||
'notifications/elicitation/complete',
|
||||
'tasks/result',
|
||||
'tasks/get',
|
||||
'tasks/list',
|
||||
'notifications/tasks/status'
|
||||
]) {
|
||||
delete defs[removed];
|
||||
}
|
||||
return defs;
|
||||
})();
|
||||
|
||||
it.each([
|
||||
['2025-03-26', METHOD_DEFS_2025_03_26, {}],
|
||||
['2025-06-18', METHOD_DEFS_2025_06_18, {}],
|
||||
[
|
||||
'2025-11-25',
|
||||
METHOD_DEFS_2025_11_25,
|
||||
{ '-32042': 'URLElicitationRequiredError' }
|
||||
],
|
||||
[
|
||||
DRAFT_PROTOCOL_VERSION,
|
||||
METHOD_DEFS_DRAFT,
|
||||
{
|
||||
'-32020': 'HeaderMismatchError',
|
||||
'-32021': 'MissingRequiredClientCapabilityError',
|
||||
'-32022': 'UnsupportedProtocolVersionError'
|
||||
}
|
||||
]
|
||||
] as const)(
|
||||
'extracts the expected methodDefs and errorDefs for %s',
|
||||
(version, expectedMethods, expectedErrors) => {
|
||||
const { methodDefs, errorDefs, resultDefs } = specDispatchMaps(
|
||||
version as never
|
||||
);
|
||||
expect(Object.fromEntries(methodDefs)).toEqual(expectedMethods);
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
[...errorDefs].map(([code, def]) => [String(code), def])
|
||||
)
|
||||
).toEqual(expectedErrors);
|
||||
// Every XxxRequest with a schema-defined XxxResult must be paired.
|
||||
expect(resultDefs.get('tools/call')).toBe('CallToolResult');
|
||||
expect(resultDefs.get('tools/list')).toBe('ListToolsResult');
|
||||
}
|
||||
);
|
||||
|
||||
it('pairs elicitation/create with ElicitResult from 2025-06-18 on (the #376 class)', () => {
|
||||
expect(
|
||||
specDispatchMaps('2025-03-26').resultDefs.get('elicitation/create')
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
specDispatchMaps('2025-06-18').resultDefs.get('elicitation/create')
|
||||
).toBe('ElicitResult');
|
||||
expect(
|
||||
specDispatchMaps('2025-11-25').resultDefs.get('elicitation/create')
|
||||
).toBe('ElicitResult');
|
||||
expect(
|
||||
specDispatchMaps(DRAFT_PROTOCOL_VERSION).resultDefs.get(
|
||||
'elicitation/create'
|
||||
)
|
||||
).toBe('ElicitResult');
|
||||
expect(
|
||||
specDispatchMaps(DRAFT_PROTOCOL_VERSION).resultDefs.get('server/discover')
|
||||
).toBe('DiscoverResult');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withWireRecorder', () => {
|
||||
it('keeps concurrent scenario recorders isolated (suite runs)', async () => {
|
||||
const { url, server } = await listen(() => ({
|
||||
status: 400,
|
||||
body: PR_376_INVALID_ERROR
|
||||
}));
|
||||
try {
|
||||
const scope = () =>
|
||||
withWireRecorder(async () => {
|
||||
await sendStatelessRequest(url, 'tools/list');
|
||||
return takeWireViolations();
|
||||
});
|
||||
// With a shared recorder one scope would drain both violations and the
|
||||
// other none; scoped recorders see exactly their own.
|
||||
const [a, b] = await Promise.all([scope(), scope()]);
|
||||
expect(a.violations).toHaveLength(1);
|
||||
expect(b.violations).toHaveLength(1);
|
||||
expect(takeWireViolations().observed).toBe(0);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('propagates the scoped recorder into handlers of servers created in-scope', async () => {
|
||||
// Mirrors the mock-server flow: the server is created inside the scenario
|
||||
// scope, but requests arrive from outside it (the client subprocess).
|
||||
let resolveUrl!: (url: string) => void;
|
||||
const urlReady = new Promise<string>((r) => (resolveUrl = r));
|
||||
let resolveRequestDone!: () => void;
|
||||
const requestDone = new Promise<void>((r) => (resolveRequestDone = r));
|
||||
|
||||
const scopeResult = withWireRecorder(async () => {
|
||||
const server = createServer((_req, res) => {
|
||||
validateWireMessage(DRAFT_PROTOCOL_VERSION, PR_376_INVALID_ERROR, {
|
||||
origin: 'implementation',
|
||||
context: 'inbound message to in-scope server'
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{}');
|
||||
});
|
||||
await new Promise<void>((r) => {
|
||||
server.listen(0, () => {
|
||||
const addr = server.address() as { port: number };
|
||||
resolveUrl(`http://localhost:${addr.port}/`);
|
||||
r();
|
||||
});
|
||||
});
|
||||
await requestDone;
|
||||
const drained = takeWireViolations();
|
||||
server.close();
|
||||
return drained;
|
||||
});
|
||||
|
||||
await fetch(await urlReady, { method: 'POST', body: '{}' });
|
||||
resolveRequestDone();
|
||||
|
||||
const drained = await scopeResult;
|
||||
expect(drained.observed).toBe(1);
|
||||
expect(drained.violations).toHaveLength(1);
|
||||
expect(takeWireViolations().observed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema errata', () => {
|
||||
const elicitWithScore = (score: Record<string, unknown>) => ({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
message: 'score please',
|
||||
requestedSchema: { type: 'object', properties: { score } }
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts fractional NumberSchema bounds/defaults at 2025-11-25 (spec generator bug, modelcontextprotocol#2710)', () => {
|
||||
expect(
|
||||
wireSchemaErrors(
|
||||
'2025-11-25',
|
||||
elicitWithScore({
|
||||
type: 'number',
|
||||
minimum: 0.5,
|
||||
maximum: 99.5,
|
||||
default: 95.5
|
||||
})
|
||||
)
|
||||
).toEqual([]);
|
||||
// The erratum makes 2025-11-25 agree with the fixed draft schema.
|
||||
expect(
|
||||
wireSchemaErrors(
|
||||
DRAFT_PROTOCOL_VERSION,
|
||||
elicitWithScore({
|
||||
type: 'number',
|
||||
minimum: 0.5,
|
||||
maximum: 99.5,
|
||||
default: 95.5
|
||||
})
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
420
src/validation/wire-schema.ts
Normal file
420
src/validation/wire-schema.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
// Validates wire JSON-RPC messages against the vendored per-version spec schema
|
||||
// (src/spec-types/*.schema.json): envelope, then typed defs via method/error.code consts and
|
||||
// request→result pairs. Known gap: the client-auth scenarios' express mock is not instrumented.
|
||||
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { Ajv, type ValidateFunction, type ErrorObject } from 'ajv';
|
||||
import { Ajv2020 } from 'ajv/dist/2020.js';
|
||||
import { default as addFormats } from 'ajv-formats';
|
||||
import {
|
||||
DRAFT_PROTOCOL_VERSION,
|
||||
type ConformanceCheck,
|
||||
type SpecVersion
|
||||
} from '../types';
|
||||
import schema2025_03_26 from '../spec-types/2025-03-26.schema.json';
|
||||
import schema2025_06_18 from '../spec-types/2025-06-18.schema.json';
|
||||
import schema2025_11_25 from '../spec-types/2025-11-25.schema.json';
|
||||
import schemaDraft from '../spec-types/draft.schema.json';
|
||||
|
||||
export type WireOrigin = 'harness' | 'implementation';
|
||||
|
||||
export interface WireMessageInfo {
|
||||
/** Who authored the message: the harness or the implementation-under-test. */
|
||||
origin: WireOrigin;
|
||||
/** Human-readable location, e.g. `stateless request 'tools/call'`. */
|
||||
context: string;
|
||||
/** For responses: the answered request's method, so the result is validated
|
||||
* against its typed result definition. */
|
||||
requestMethod?: string;
|
||||
}
|
||||
|
||||
export interface WireSchemaViolation {
|
||||
origin: WireOrigin;
|
||||
specVersion: SpecVersion;
|
||||
context: string;
|
||||
errors: string[];
|
||||
message: unknown;
|
||||
}
|
||||
|
||||
const SCHEMAS: Record<SpecVersion, Record<string, unknown>> = {
|
||||
'2025-03-26': schema2025_03_26,
|
||||
'2025-06-18': schema2025_06_18,
|
||||
'2025-11-25': schema2025_11_25,
|
||||
[DRAFT_PROTOCOL_VERSION]: schemaDraft
|
||||
};
|
||||
|
||||
/** Spec-repo directory name for a version (the draft is pinned unversioned). */
|
||||
export function schemaDirFor(specVersion: SpecVersion): string {
|
||||
return specVersion === DRAFT_PROTOCOL_VERSION ? 'draft' : specVersion;
|
||||
}
|
||||
|
||||
/** Union definitions that alias a single concrete type (and so carry a
|
||||
* `method` const) but are not the canonical definition for that method. */
|
||||
const NON_CANONICAL_DEFS = new Set([
|
||||
'ClientRequest',
|
||||
'ClientNotification',
|
||||
'ClientResult',
|
||||
'ClientMessage',
|
||||
'ServerRequest',
|
||||
'ServerNotification',
|
||||
'ServerResult',
|
||||
'ServerMessage'
|
||||
]);
|
||||
|
||||
interface CompiledSpec {
|
||||
defsKey: '$defs' | 'definitions';
|
||||
defs: Record<string, Record<string, unknown>>;
|
||||
/** JSON-RPC method → canonical request/notification definition name. */
|
||||
methodDefs: Map<string, string>;
|
||||
/** `error.code` const → typed error-response definition name. */
|
||||
errorDefs: Map<number, string>;
|
||||
/** JSON-RPC method → typed result definition name. */
|
||||
resultDefs: Map<string, string>;
|
||||
validatorFor(defName: string): ValidateFunction;
|
||||
}
|
||||
|
||||
const compiledSpecs = new Map<SpecVersion, CompiledSpec>();
|
||||
|
||||
/** Patch known bugs in released (frozen) generated spec schemas at load time
|
||||
* so validation matches the schema.ts source of truth. Each erratum cites the
|
||||
* upstream fix; delete it once the dated schema.json is corrected upstream
|
||||
* and re-vendored. */
|
||||
function applySchemaErrata(
|
||||
specVersion: SpecVersion,
|
||||
schema: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
if (specVersion !== '2025-11-25') return schema;
|
||||
// 2025-11-25 generates NumberSchema minimum/maximum/default as `integer`,
|
||||
// contradicting its own schema.ts (`default?: number`). Fixed for draft in
|
||||
// modelcontextprotocol#2710, which left the released schema.json as-is.
|
||||
const patched = structuredClone(schema);
|
||||
const defs = (patched.$defs ?? patched.definitions) as Record<
|
||||
string,
|
||||
{ properties?: Record<string, { type?: string }> }
|
||||
>;
|
||||
for (const prop of ['minimum', 'maximum', 'default']) {
|
||||
const p = defs.NumberSchema?.properties?.[prop];
|
||||
if (p?.type === 'integer') p.type = 'number';
|
||||
}
|
||||
return patched;
|
||||
}
|
||||
|
||||
function compileSpec(specVersion: SpecVersion): CompiledSpec {
|
||||
let compiled = compiledSpecs.get(specVersion);
|
||||
if (compiled) return compiled;
|
||||
|
||||
const schema = applySchemaErrata(specVersion, SCHEMAS[specVersion]);
|
||||
const is2020 =
|
||||
typeof schema.$schema === 'string' && schema.$schema.includes('2020-12');
|
||||
// The spec schemas are not authored for ajv strict mode; validate them
|
||||
// as-is rather than editing vendored files.
|
||||
const options = { strict: false, allErrors: true } as const;
|
||||
const ajv = is2020 ? new Ajv2020(options) : new Ajv(options);
|
||||
addFormats(ajv);
|
||||
// "byte" (base64) is an OpenAPI format ajv-formats doesn't ship; the spec
|
||||
// uses it on blob contents. Accept any string rather than reimplementing it.
|
||||
ajv.addFormat('byte', true);
|
||||
|
||||
const schemaId = `mcp://${schemaDirFor(specVersion)}/schema.json`;
|
||||
ajv.addSchema({ ...schema, $id: schemaId });
|
||||
|
||||
const defsKey: '$defs' | 'definitions' =
|
||||
'$defs' in schema ? '$defs' : 'definitions';
|
||||
const defs = schema[defsKey] as Record<string, Record<string, unknown>>;
|
||||
|
||||
const methodDefs = new Map<string, string>();
|
||||
const errorDefs = new Map<number, string>();
|
||||
const resultDefs = new Map<string, string>();
|
||||
for (const [name, def] of Object.entries(defs)) {
|
||||
if (NON_CANONICAL_DEFS.has(name)) continue;
|
||||
const props = (def.properties ?? {}) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const method = props.method?.const;
|
||||
if (typeof method === 'string') {
|
||||
methodDefs.set(method, name);
|
||||
if (name.endsWith('Request')) {
|
||||
const resultName = `${name.slice(0, -'Request'.length)}Result`;
|
||||
if (resultName in defs) resultDefs.set(method, resultName);
|
||||
}
|
||||
}
|
||||
const errorProp = props.error as
|
||||
| { allOf?: { properties?: { code?: { const?: unknown } } }[] }
|
||||
| undefined;
|
||||
for (const part of errorProp?.allOf ?? []) {
|
||||
const code = part.properties?.code?.const;
|
||||
if (typeof code === 'number') errorDefs.set(code, name);
|
||||
}
|
||||
}
|
||||
|
||||
const validators = new Map<string, ValidateFunction>();
|
||||
compiled = {
|
||||
defsKey,
|
||||
defs,
|
||||
methodDefs,
|
||||
errorDefs,
|
||||
resultDefs,
|
||||
validatorFor(defName: string): ValidateFunction {
|
||||
let v = validators.get(defName);
|
||||
if (!v) {
|
||||
v = ajv.compile({ $ref: `${schemaId}#/${defsKey}/${defName}` });
|
||||
validators.set(defName, v);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
};
|
||||
compiledSpecs.set(specVersion, compiled);
|
||||
return compiled;
|
||||
}
|
||||
|
||||
/** Dispatch maps extracted from a version's schema (method → request/notification def,
|
||||
* `error.code` const → error def). Exposed so a unit test can pin them: if a schema sync
|
||||
* restructures what the extraction walks, validation silently degrades to envelope-only. */
|
||||
export function specDispatchMaps(specVersion: SpecVersion): {
|
||||
methodDefs: ReadonlyMap<string, string>;
|
||||
errorDefs: ReadonlyMap<number, string>;
|
||||
resultDefs: ReadonlyMap<string, string>;
|
||||
} {
|
||||
const spec = compileSpec(specVersion);
|
||||
return {
|
||||
methodDefs: spec.methodDefs,
|
||||
errorDefs: spec.errorDefs,
|
||||
resultDefs: spec.resultDefs
|
||||
};
|
||||
}
|
||||
|
||||
function formatErrors(
|
||||
defName: string,
|
||||
errors: ErrorObject[] | null | undefined
|
||||
): string[] {
|
||||
const MAX = 6;
|
||||
const formatted = (errors ?? []).map(
|
||||
(e) =>
|
||||
`${defName}${e.instancePath || ''}: ${e.message ?? 'invalid'}` +
|
||||
(e.keyword === 'const' || e.keyword === 'enum'
|
||||
? ` (${JSON.stringify(e.params)})`
|
||||
: '')
|
||||
);
|
||||
if (formatted.length > MAX) {
|
||||
return [
|
||||
...formatted.slice(0, MAX),
|
||||
`... ${formatted.length - MAX} more error(s)`
|
||||
];
|
||||
}
|
||||
return formatted.length > 0 ? formatted : [`${defName}: invalid`];
|
||||
}
|
||||
|
||||
/** Validate a single JSON-RPC message against the given spec version's schema.
|
||||
* Returns `[]` when the message is valid. Pure — does not touch the recorder. */
|
||||
export function wireSchemaErrors(
|
||||
specVersion: SpecVersion,
|
||||
message: unknown,
|
||||
requestMethod?: string
|
||||
): string[] {
|
||||
const spec = compileSpec(specVersion);
|
||||
const validateAgainst = (defName: string, value: unknown): string[] => {
|
||||
const validate = spec.validatorFor(defName);
|
||||
return validate(value) ? [] : formatErrors(defName, validate.errors);
|
||||
};
|
||||
const firstDef = (...names: string[]): string =>
|
||||
names.find((n) => n in spec.defs) ?? names[names.length - 1];
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
// A batch: only legal where the envelope union admits arrays (2025-03-26).
|
||||
// Limitation: `requestMethod` is forwarded to every element, so batched responses all
|
||||
// validate against one result type — no caller sends batched requests today.
|
||||
const elementErrors = message.flatMap((m, i) =>
|
||||
wireSchemaErrors(specVersion, m, requestMethod).map((e) => `[${i}] ${e}`)
|
||||
);
|
||||
if (elementErrors.length > 0) return elementErrors;
|
||||
if (validateAgainst('JSONRPCMessage', message).length > 0) {
|
||||
return [
|
||||
'JSONRPCMessage: batch arrays are not valid in this spec version'
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const msg = (
|
||||
typeof message === 'object' && message !== null ? message : {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
// Classify the message and validate against the most specific definition
|
||||
// the schema has for it. The typed definitions include the envelope
|
||||
// requirements (jsonrpc, id, method, ...), so they subsume the union check.
|
||||
if (typeof msg.method === 'string') {
|
||||
const defName =
|
||||
spec.methodDefs.get(msg.method) ??
|
||||
('id' in msg ? 'JSONRPCRequest' : 'JSONRPCNotification');
|
||||
return validateAgainst(defName, message);
|
||||
}
|
||||
|
||||
if (msg.error !== undefined && msg.error !== null) {
|
||||
const code = (msg.error as Record<string, unknown>).code;
|
||||
const defName =
|
||||
(typeof code === 'number' ? spec.errorDefs.get(code) : undefined) ??
|
||||
firstDef('JSONRPCErrorResponse', 'JSONRPCError');
|
||||
return validateAgainst(defName, message);
|
||||
}
|
||||
|
||||
if (msg.result !== undefined) {
|
||||
// SEP-2322 (MRTR): any request may be answered with an InputRequiredResult
|
||||
// instead of its method's result type; discriminate on resultType.
|
||||
const inputRequired =
|
||||
(msg.result as Record<string, unknown> | null)?.resultType ===
|
||||
'input_required' && 'InputRequiredResult' in spec.defs;
|
||||
const resultDefName = inputRequired
|
||||
? 'InputRequiredResult'
|
||||
: requestMethod !== undefined
|
||||
? spec.resultDefs.get(requestMethod)
|
||||
: undefined;
|
||||
if (resultDefName) {
|
||||
const typed = validateAgainst(resultDefName, msg.result).map(
|
||||
(e) => `${e} (result of '${requestMethod}')`
|
||||
);
|
||||
if (typed.length > 0) return typed;
|
||||
}
|
||||
return validateAgainst(
|
||||
firstDef('JSONRPCResultResponse', 'JSONRPCResponse'),
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'JSONRPCMessage: not a valid JSON-RPC request, notification, or response'
|
||||
];
|
||||
}
|
||||
|
||||
// Recorder: choke points record every message; runners (and the vitest hook)
|
||||
// drain the accumulated violations per scenario / test. Suite runs execute
|
||||
// scenarios concurrently, so each scenario gets its own recorder via
|
||||
// AsyncLocalStorage; code outside any scope falls back to the global recorder.
|
||||
|
||||
interface RecorderState {
|
||||
violations: WireSchemaViolation[];
|
||||
observed: number;
|
||||
}
|
||||
|
||||
const recorderStorage = new AsyncLocalStorage<RecorderState>();
|
||||
const globalRecorder: RecorderState = { violations: [], observed: 0 };
|
||||
|
||||
function recorder(): RecorderState {
|
||||
return recorderStorage.getStore() ?? globalRecorder;
|
||||
}
|
||||
|
||||
/** Run `fn` with its own isolated wire recorder. Async work started inside
|
||||
* `fn` (mock servers, transport hooks) records into that recorder, so
|
||||
* concurrent scenarios in a suite run cannot steal or wipe each other's
|
||||
* violations. */
|
||||
export function withWireRecorder<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return recorderStorage.run({ violations: [], observed: 0 }, fn);
|
||||
}
|
||||
|
||||
/** Validate a wire message and record any violation. Called by choke points. */
|
||||
export function validateWireMessage(
|
||||
specVersion: SpecVersion,
|
||||
message: unknown,
|
||||
info: WireMessageInfo
|
||||
): void {
|
||||
const state = recorder();
|
||||
state.observed++;
|
||||
const errors = wireSchemaErrors(specVersion, message, info.requestMethod);
|
||||
if (errors.length > 0) {
|
||||
state.violations.push({
|
||||
origin: info.origin,
|
||||
specVersion,
|
||||
context: info.context,
|
||||
errors,
|
||||
message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function resetWireValidation(): void {
|
||||
const state = recorder();
|
||||
state.violations = [];
|
||||
state.observed = 0;
|
||||
}
|
||||
|
||||
/** Return everything recorded since the last reset, and reset. */
|
||||
export function takeWireViolations(): {
|
||||
violations: WireSchemaViolation[];
|
||||
observed: number;
|
||||
} {
|
||||
const state = recorder();
|
||||
const result = { violations: state.violations, observed: state.observed };
|
||||
resetWireValidation();
|
||||
return result;
|
||||
}
|
||||
|
||||
function violationDetails(v: WireSchemaViolation): Record<string, unknown> {
|
||||
return {
|
||||
origin: v.origin,
|
||||
specVersion: v.specVersion,
|
||||
context: v.context,
|
||||
errors: v.errors,
|
||||
message: v.message
|
||||
};
|
||||
}
|
||||
|
||||
export function formatWireViolation(v: WireSchemaViolation): string {
|
||||
return (
|
||||
`[${v.origin}] ${v.context} (spec ${v.specVersion}): ` +
|
||||
`${v.errors.join('; ')} — message: ${JSON.stringify(v.message)}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Drain the recorder and synthesize the per-scenario conformance checks. Returns `[]`
|
||||
* when no wire traffic was observed (e.g. a scenario asserting only on raw HTTP). */
|
||||
export function wireSchemaChecks(specVersion: SpecVersion): ConformanceCheck[] {
|
||||
const { violations: all, observed: count } = takeWireViolations();
|
||||
if (count === 0 && all.length === 0) return [];
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const specReferences = [
|
||||
{
|
||||
id: 'MCP-Schema',
|
||||
url: `https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/${schemaDirFor(specVersion)}/schema.json`
|
||||
}
|
||||
];
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
const implViolations = all.filter((v) => v.origin === 'implementation');
|
||||
checks.push({
|
||||
id: 'wire-schema-valid',
|
||||
name: 'WireSchemaValid',
|
||||
description:
|
||||
'Every JSON-RPC message the implementation sent is valid per the spec JSON schema for the negotiated spec version',
|
||||
status: implViolations.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||||
timestamp,
|
||||
specReferences,
|
||||
details: {
|
||||
messagesValidated: count,
|
||||
violations: implViolations.map(violationDetails)
|
||||
},
|
||||
errorMessage:
|
||||
implViolations.length > 0
|
||||
? implViolations.map(formatWireViolation).join('\n')
|
||||
: undefined
|
||||
});
|
||||
|
||||
const harnessViolations = all.filter((v) => v.origin === 'harness');
|
||||
if (harnessViolations.length > 0) {
|
||||
checks.push({
|
||||
id: 'wire-schema-harness-error',
|
||||
name: 'WireSchemaHarnessError',
|
||||
description:
|
||||
'HARNESS ERROR: the conformance harness itself sent a JSON-RPC message the spec JSON schema forbids. This is a bug in the harness or a scenario fixture, not in the implementation under test.',
|
||||
status: 'FAILURE',
|
||||
timestamp,
|
||||
specReferences,
|
||||
details: { violations: harnessViolations.map(violationDetails) },
|
||||
errorMessage:
|
||||
'HARNESS ERROR: ' +
|
||||
harnessViolations.map(formatWireViolation).join('\n')
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
Loading…
Reference in a new issue