SEP-2106: traceability YAML + JSON Schema 2020-12 conformance checks (#295)
* chore: add SEP-2106 requirement-traceability YAML (tools JSON Schema 2020-12) * Drop structured-content text-fallback row from SEP-2106 That SHOULD predates SEP-2106 (pre-existing context line in tools.mdx); 2106 only relaxes structuredContent to allow non-object values (permissive). Point spec_url at the SEP doc security section where the remaining obligations live. * feat: SEP-2106 JSON Schema 2020-12 vocabulary preservation checks Extend the json-schema-2020-12 server scenario (previously SEP-1613 only) with three checks that verify the broader JSON Schema 2020-12 vocabulary SEP-2106 permits in a tool inputSchema survives tools/list rather than being stripped: composition (allOf/anyOf), conditional (if/then/else), and reference ($anchor) keywords. - everything-server: add these keywords to json_schema_2020_12_tool's raw schema - add a negative-test server (sep-2106-stripped-schema.ts) plus a negative test asserting the checks emit FAILURE when the vocabulary is stripped * feat: SEP-2106 client scenario for network $ref dereferencing Add json-schema-ref-no-deref, a client-suite scenario that advertises a tool whose inputSchema contains a $ref pointing at a canary URL hosted by the scenario. The sep-2106-no-network-ref-deref check fails if the client fetches the canary while processing the schema, covering the SEP-2106 requirement that implementations MUST NOT automatically dereference network-URI $ref values. - register the scenario in the client scenarios list - everything-client: handle the scenario with the basic connect+listTools flow - tests: compliant client passes, a deliberately dereferencing client is caught, and a client that never lists tools cannot pass * Address review findings in SEP-2106 scenarios - json-schema-ref-deref: make getChecks() idempotent (fresh array per call, matching the http-standard-headers convention) and guard the async /mcp handler like the example servers do - json-schema-2020-12: include the SEP-2106 keywords ($anchor, allOf/anyOf, if/then/else) in the description's example inputSchema so a server that copies it verbatim passes the new checks - sep-2106-stripped-schema: default to port 3007 to match negative.test.ts and avoid sharing a default with sep-2549-no-caching-hints * Soft-gate SEP-2106 keyword checks on the negotiated protocol version The json-schema-2020-12 scenario is introducedIn 2025-11-25 (SEP-1613) but the three SEP-2106 keyword-preservation checks test draft-era behavior. Rather than failing servers that only negotiated 2025-11-25, gate the failure on the wire protocolVersion from the initialize handshake: preserved keywords are SUCCESS at any version, stripped keywords are FAILURE only when the server negotiated the draft version and SKIPPED otherwise. The published SDK does not advertise the draft version yet, so the negative test asserts SKIPPED for the stripped-schema server; the FAILURE branch is covered by unit tests on sep2106KeywordCheckStatus.
This commit is contained in:
parent
f12baed382
commit
ed1ff0ca03
2 changed files with 680 additions and 9 deletions
106
src/scenarios/client/json-schema-ref-deref.test.ts
Normal file
106
src/scenarios/client/json-schema-ref-deref.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, test, expect } from 'vitest';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import {
|
||||
runClientAgainstScenario,
|
||||
InlineClientRunner
|
||||
} from './auth/test_helpers/testClient';
|
||||
import { JsonSchemaRefDerefScenario } from './json-schema-ref-deref';
|
||||
import { getScenario } from '../index';
|
||||
|
||||
/**
|
||||
* SEP-2106: implementations MUST NOT automatically dereference $ref values
|
||||
* that resolve to a network URI.
|
||||
*
|
||||
* Positive: a compliant client lists tools and never touches the canary URL.
|
||||
* Negative: a client that walks tool schemas and fetches network $refs must
|
||||
* produce a FAILURE for sep-2106-no-network-ref-deref.
|
||||
*/
|
||||
|
||||
/** Recursively collect string `$ref` values that look like network URIs. */
|
||||
function collectNetworkRefs(node: unknown, out: string[] = []): string[] {
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) collectNetworkRefs(item, out);
|
||||
return out;
|
||||
}
|
||||
if (node && typeof node === 'object') {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (
|
||||
key === '$ref' &&
|
||||
typeof value === 'string' &&
|
||||
/^https?:/.test(value)
|
||||
) {
|
||||
out.push(value);
|
||||
} else {
|
||||
collectNetworkRefs(value, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function compliantClient(serverUrl: string): Promise<void> {
|
||||
const client = new Client(
|
||||
{ name: 'test-client', version: '1.0.0' },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
await client.connect(transport);
|
||||
await client.listTools();
|
||||
await transport.close();
|
||||
}
|
||||
|
||||
async function dereferencingClient(serverUrl: string): Promise<void> {
|
||||
const client = new Client(
|
||||
{ name: 'deref-client', version: '1.0.0' },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
await client.connect(transport);
|
||||
const result = await client.listTools();
|
||||
|
||||
// Naive schema processing: resolve every $ref, including network URIs.
|
||||
// This is exactly the behavior SEP-2106 forbids.
|
||||
for (const tool of result.tools ?? []) {
|
||||
for (const ref of collectNetworkRefs(tool.inputSchema)) {
|
||||
await fetch(ref);
|
||||
}
|
||||
}
|
||||
|
||||
await transport.close();
|
||||
}
|
||||
|
||||
describe('json-schema-ref-no-deref (SEP-2106)', () => {
|
||||
test('scenario is registered', () => {
|
||||
expect(getScenario('json-schema-ref-no-deref')).toBeDefined();
|
||||
});
|
||||
|
||||
test('compliant client passes: network $ref is not fetched', async () => {
|
||||
await runClientAgainstScenario(
|
||||
new InlineClientRunner(compliantClient),
|
||||
'json-schema-ref-no-deref'
|
||||
);
|
||||
});
|
||||
|
||||
test('dereferencing client fails: canary fetch is detected', async () => {
|
||||
await runClientAgainstScenario(
|
||||
new InlineClientRunner(dereferencingClient),
|
||||
'json-schema-ref-no-deref',
|
||||
{ expectedFailureSlugs: ['sep-2106-no-network-ref-deref'] }
|
||||
);
|
||||
});
|
||||
|
||||
test('client that never lists tools fails: requirement cannot be evaluated', async () => {
|
||||
const scenario = new JsonSchemaRefDerefScenario();
|
||||
await scenario.start();
|
||||
try {
|
||||
const checks = scenario.getChecks();
|
||||
const check = checks.find(
|
||||
(c) => c.id === 'sep-2106-no-network-ref-deref'
|
||||
);
|
||||
expect(check?.status).toBe('FAILURE');
|
||||
} finally {
|
||||
await scenario.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
201
src/scenarios/client/json-schema-ref-deref.ts
Normal file
201
src/scenarios/client/json-schema-ref-deref.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Scenario, ConformanceCheck } from '../../types';
|
||||
import express, { Request, Response } from 'express';
|
||||
import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../types';
|
||||
|
||||
/**
|
||||
* Scenario: JSON Schema network $ref dereferencing (SEP-2106)
|
||||
*
|
||||
* SEP-2106 permits the full JSON Schema 2020-12 vocabulary in tool schemas,
|
||||
* including `$ref`. A `$ref` that resolves to a network URI is an SSRF /
|
||||
* fetch-DoS vector: a malicious tool definition could point at internal
|
||||
* infrastructure and have the *client* fetch it during schema processing.
|
||||
*
|
||||
* Per SEP-2106, implementations MUST NOT automatically dereference `$ref`
|
||||
* values that resolve to a network URI (anything that is not a same-document
|
||||
* JSON Pointer such as `#/$defs/Foo` or an internal `$anchor`).
|
||||
*
|
||||
* This scenario advertises a tool whose inputSchema contains both a
|
||||
* same-document `$ref` and a `$ref` to a canary URL hosted by the scenario
|
||||
* itself. If the client fetches the canary URL at any point before the
|
||||
* scenario stops, the check fails.
|
||||
*/
|
||||
|
||||
const TOOL_NAME = 'lookup_user';
|
||||
const CANARY_PATH = '/canary/profile-schema.json';
|
||||
const CHECK_ID = 'sep-2106-no-network-ref-deref';
|
||||
|
||||
const SPEC_REFERENCES = [
|
||||
{
|
||||
id: 'SEP-2106',
|
||||
url: 'https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#security-implications'
|
||||
}
|
||||
];
|
||||
|
||||
function createMcpServer(canaryUrl: string, onToolsListed: () => void): Server {
|
||||
const server = new Server(
|
||||
{ name: 'json-schema-ref-deref-server', version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
onToolsListed();
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: TOOL_NAME,
|
||||
description: 'Look up a user profile by id',
|
||||
inputSchema: {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'object',
|
||||
$defs: {
|
||||
userId: { type: 'string', pattern: '^[a-z0-9-]+$' }
|
||||
},
|
||||
properties: {
|
||||
// Same-document $ref: safe, expected to be resolvable locally.
|
||||
id: { $ref: '#/$defs/userId' },
|
||||
// Network $ref: the canary. Implementations MUST NOT fetch this
|
||||
// automatically while processing the schema.
|
||||
profile: { $ref: canaryUrl }
|
||||
},
|
||||
required: ['id']
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export class JsonSchemaRefDerefScenario implements Scenario {
|
||||
name = 'json-schema-ref-no-deref';
|
||||
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
|
||||
description = `Tests that a client does not automatically dereference a network-URI \`$ref\` in a tool's inputSchema (SEP-2106).
|
||||
|
||||
The scenario advertises a tool whose inputSchema contains a \`$ref\` pointing at a canary URL. The client should list tools (and may otherwise process the schema), but must not fetch the canary URL. Same-document refs (\`#/$defs/...\`) remain safe to resolve.`;
|
||||
|
||||
private app: express.Application | null = null;
|
||||
private httpServer: ReturnType<express.Application['listen']> | null = null;
|
||||
private canaryRequests: Array<{ method: string; userAgent?: string }> = [];
|
||||
private toolsListed = false;
|
||||
|
||||
async start(): Promise<ScenarioUrls> {
|
||||
this.canaryRequests = [];
|
||||
this.toolsListed = false;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Canary endpoint: any request here means the client dereferenced the
|
||||
// network $ref. Return a valid schema so a dereferencing client gets a
|
||||
// realistic response rather than an error it might silently swallow.
|
||||
app.all(CANARY_PATH, (req: Request, res: Response) => {
|
||||
this.canaryRequests.push({
|
||||
method: req.method,
|
||||
userAgent: req.headers['user-agent']
|
||||
});
|
||||
res.json({
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'object',
|
||||
properties: { displayName: { type: 'string' } }
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/mcp', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Stateless: fresh server and transport per request
|
||||
const server = createMcpServer(this.canaryUrl(), () => {
|
||||
this.toolsListed = true;
|
||||
});
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined
|
||||
});
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Internal error: ${error instanceof Error ? error.message : String(error)}`
|
||||
},
|
||||
id: null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.app = app;
|
||||
this.httpServer = app.listen(0);
|
||||
return { serverUrl: `${this.baseUrl()}/mcp` };
|
||||
}
|
||||
|
||||
private baseUrl(): string {
|
||||
const address = this.httpServer?.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Scenario server is not listening');
|
||||
}
|
||||
return `http://localhost:${address.port}`;
|
||||
}
|
||||
|
||||
private canaryUrl(): string {
|
||||
return `${this.baseUrl()}${CANARY_PATH}`;
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.httpServer) {
|
||||
await new Promise((resolve) => this.httpServer!.close(resolve));
|
||||
this.httpServer = null;
|
||||
}
|
||||
this.app = 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 fetched = this.canaryRequests.length > 0;
|
||||
|
||||
if (!this.toolsListed) {
|
||||
return [
|
||||
{
|
||||
id: CHECK_ID,
|
||||
name: 'NoNetworkRefDereference',
|
||||
description:
|
||||
'Client never requested tools/list, so $ref handling could not be evaluated',
|
||||
status: 'FAILURE',
|
||||
timestamp,
|
||||
errorMessage:
|
||||
'Client did not call tools/list against a server advertising a tool with a network $ref',
|
||||
specReferences: SPEC_REFERENCES,
|
||||
details: { toolsListed: false }
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: CHECK_ID,
|
||||
name: 'NoNetworkRefDereference',
|
||||
description: fetched
|
||||
? 'Client automatically dereferenced a network-URI $ref in a tool inputSchema. Implementations MUST NOT automatically dereference $ref values that resolve to a network URI (SEP-2106).'
|
||||
: 'Client did not dereference the network-URI $ref in the tool inputSchema',
|
||||
status: fetched ? 'FAILURE' : 'SUCCESS',
|
||||
timestamp,
|
||||
errorMessage: fetched
|
||||
? `Canary URL ${CANARY_PATH} was fetched ${this.canaryRequests.length} time(s)`
|
||||
: undefined,
|
||||
specReferences: SPEC_REFERENCES,
|
||||
details: {
|
||||
toolsListed: true,
|
||||
canaryRequestCount: this.canaryRequests.length,
|
||||
canaryRequests: this.canaryRequests
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue