From 7d4b97d25ba75cccbf4c55d97696fb031173d6fc Mon Sep 17 00:00:00 2001 From: olaservo Date: Fri, 22 May 2026 19:54:34 -0700 Subject: [PATCH] feat: add SEP-2106 structuredContent wire-shape scenario (complements #295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #295 added SEP-2106 conformance checks for JSON Schema 2020-12 keyword preservation in inputSchema (composition/conditional/$anchor surviving tools/list), folded into the existing JsonSchema2020_12Scenario. This PR covers the other half of SEP-2106: the loosened wire format for outputSchema and structuredContent. Sep2106StructuredContentScenario emits six checks across two test tools: - sep_2106_array_output_tool: outputSchema.type === 'array' at the root advertised in tools/list; tools/call returns a JSON array directly in structuredContent. - sep_2106_primitive_output_tool: outputSchema.type === 'number' at the root; tools/call returns a raw number in structuredContent. Both shapes are what SEP-2106's motivation section uses as the worked examples (weather forecast / get-count) -- the wire-side behaviour the SEP exists to enable -- and neither is exercised by the keyword-preservation checks. The scenario uses raw HTTP because both the SDK Client and SDK Server validators currently reject non-object outputSchema/structuredContent: the SDK Client refuses to parse the list response, and the SDK Server returns JSON-RPC -32602 instead of emitting the call result. Raw HTTP bypasses both so the scenario can inspect what is actually on the wire. The scenario is registered in pendingClientScenariosList (and the all list, mirroring SEP-1613) so it does not run in the active suite against the in-repo everything-server, which cannot satisfy these checks until the SDK widens CallToolResultSchema.structuredContent to unknown. Once that lands, the pending entry can be removed. Positive verification target ships as examples/servers/typescript/sep-2106-compliant-server.ts: a bare-bones raw-Express server that speaks the SEP-2106 wire format end-to-end without an SDK in the path. A vitest in negative.test.ts spawns it and asserts every check is SUCCESS, which is what proves the scenario is not just emitting FAILURE everywhere. Assisted by Claude Code 🦉 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../typescript/sep-2106-compliant-server.ts | 168 +++++++ src/scenarios/index.ts | 11 + src/scenarios/server/negative.test.ts | 53 +++ .../server/sep-2106-structured-content.ts | 447 ++++++++++++++++++ 4 files changed, 679 insertions(+) create mode 100644 examples/servers/typescript/sep-2106-compliant-server.ts create mode 100644 src/scenarios/server/sep-2106-structured-content.ts diff --git a/examples/servers/typescript/sep-2106-compliant-server.ts b/examples/servers/typescript/sep-2106-compliant-server.ts new file mode 100644 index 00000000..84f1d372 --- /dev/null +++ b/examples/servers/typescript/sep-2106-compliant-server.ts @@ -0,0 +1,168 @@ +#!/usr/bin/env node + +/** + * SEP-2106 Compliant Reference Server (structuredContent wire-shape) + * + * A bare-bones Streamable-HTTP MCP server, implemented in raw Express, that + * speaks the SEP-2106 wire format for the parts SEP-1613 + #295 don't reach: + * + * - `tools/list` advertises a tool with `outputSchema.type === "array"` + * at the root, and another with `outputSchema.type === "number"` at the + * root (i.e. neither wrapped in `{type:"object"}`). + * - `tools/call` on those tools returns a JSON array (resp. raw number) + * directly in `structuredContent` — exactly the shape SEP-2106 permits + * and the MCP SDK Server (as of the version pinned in this repo) refuses + * to emit because `CallToolResultSchema.structuredContent` is still typed + * `Record`. + * + * Why no SDK: until the SDK ships SEP-2106's widening of + * `CallToolResultSchema.structuredContent` to `unknown`, the SDK Server + * validates outgoing responses and rejects array/primitive structuredContent + * with a JSON-RPC -32602. Writing JSON-RPC directly is the only way to + * demonstrate a SEP-2106-compliant server today and gives a clean positive- + * test target for the `sep-2106-structured-content` scenario. + * + * Used by `src/scenarios/server/negative.test.ts` as the positive case for + * `Sep2106StructuredContentScenario`; all of its checks should succeed + * against this server. + */ + +import express from 'express'; + +const PROTOCOL_VERSION = 'DRAFT-2026-v1'; + +const FORECAST_PAYLOAD = [ + { hour: '09:00', temp: 68, conditions: 'sunny' }, + { hour: '10:00', temp: 72, conditions: 'partly cloudy' }, + { hour: '11:00', temp: 75, conditions: 'cloudy' } +]; + +const COUNT_PAYLOAD = 42; + +const ARRAY_OUTPUT_SCHEMA = { + type: 'array', + items: { + type: 'object', + properties: { + hour: { type: 'string' }, + temp: { type: 'number' }, + conditions: { type: 'string' } + }, + required: ['hour', 'temp', 'conditions'] + } +}; + +const PRIMITIVE_OUTPUT_SCHEMA = { type: 'number' }; + +const TOOLS = [ + { + name: 'sep_2106_array_output_tool', + description: + 'Returns an array of hourly forecasts directly in structuredContent (SEP-2106 wire shape)', + inputSchema: { type: 'object', properties: {} }, + outputSchema: ARRAY_OUTPUT_SCHEMA + }, + { + name: 'sep_2106_primitive_output_tool', + description: + 'Returns a raw number directly in structuredContent (SEP-2106 wire shape)', + inputSchema: { type: 'object', properties: {} }, + outputSchema: PRIMITIVE_OUTPUT_SCHEMA + } +]; + +function handleRequest(body: any): { status: number; payload: unknown } { + const { method, id, params } = body ?? {}; + + if (method === 'initialize') { + return { + status: 200, + payload: { + jsonrpc: '2.0', + id, + result: { + protocolVersion: PROTOCOL_VERSION, + serverInfo: { name: 'sep-2106-compliant', version: '1.0.0' }, + capabilities: { tools: {} } + } + } + }; + } + + if (method === 'notifications/initialized') { + return { status: 202, payload: null }; + } + + if (method === 'tools/list') { + return { + status: 200, + payload: { jsonrpc: '2.0', id, result: { tools: TOOLS } } + }; + } + + if (method === 'tools/call') { + const name = params?.name; + if (name === 'sep_2106_array_output_tool') { + return { + status: 200, + payload: { + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: JSON.stringify(FORECAST_PAYLOAD) }], + structuredContent: FORECAST_PAYLOAD + } + } + }; + } + if (name === 'sep_2106_primitive_output_tool') { + return { + status: 200, + payload: { + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: String(COUNT_PAYLOAD) }], + structuredContent: COUNT_PAYLOAD + } + } + }; + } + return { + status: 200, + payload: { + jsonrpc: '2.0', + id, + error: { code: -32602, message: `Unknown tool: ${name}` } + } + }; + } + + return { + status: 200, + payload: { + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` } + } + }; +} + +const app = express(); +app.use(express.json()); + +app.post('/mcp', (req, res) => { + const { status, payload } = handleRequest(req.body); + if (payload === null) { + res.status(status).end(); + return; + } + res.status(status).json(payload); +}); + +const PORT = parseInt(process.env.PORT || '3009', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log( + `SEP-2106 compliant test server running on http://localhost:${PORT}/mcp` + ); +}); diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 9b54e819..d8e5b717 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -41,6 +41,7 @@ import { } from './server/tools'; import { JsonSchema2020_12Scenario } from './server/json-schema-2020-12'; +import { Sep2106StructuredContentScenario } from './server/sep-2106-structured-content'; import { ElicitationDefaultsScenario } from './server/elicitation-defaults'; import { ElicitationEnumsScenario } from './server/elicitation-enums'; @@ -114,6 +115,13 @@ const pendingClientScenariosList: ClientScenario[] = [ // $schema, $defs, and additionalProperties fields in tool schemas. new JsonSchema2020_12Scenario(), + // SEP-2106: structuredContent wire shape (array / primitive at root). + // Pending until the SDK widens CallToolResultSchema.structuredContent to + // `unknown`; until then the in-repo everything-server cannot emit non-object + // structuredContent (the SDK Server's outgoing validator rejects it). The + // compliant reference target is examples/servers/typescript/sep-2106-compliant-server.ts. + new Sep2106StructuredContentScenario(), + // On hold until server-side SSE improvements are made // https://github.com/modelcontextprotocol/typescript-sdk/pull/1129 new ServerSSEPollingScenario(), @@ -152,6 +160,9 @@ const allClientScenariosList: ClientScenario[] = [ // JSON Schema 2020-12 support (SEP-1613) new JsonSchema2020_12Scenario(), + // SEP-2106 structuredContent wire shape (pending) + new Sep2106StructuredContentScenario(), + // Elicitation scenarios (SEP-1034) new ElicitationDefaultsScenario(), diff --git a/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts index d602d643..b26045ab 100644 --- a/src/scenarios/server/negative.test.ts +++ b/src/scenarios/server/negative.test.ts @@ -7,6 +7,7 @@ import { JsonSchema2020_12Scenario, sep2106KeywordCheckStatus } from './json-schema-2020-12'; +import { Sep2106StructuredContentScenario } from './sep-2106-structured-content'; import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../../types'; function startServer(scriptPath: string, port: number): Promise { @@ -224,4 +225,56 @@ describe('Server scenario negative tests', () => { expect(sep2106KeywordCheckStatus(false, undefined)).toBe('SKIPPED'); }); }); + + describe('sep-2106-structured-content (positive against compliant server)', () => { + let serverProcess: ChildProcess | null = null; + const PORT = 3009; + + beforeAll(async () => { + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/sep-2106-compliant-server.ts' + ), + PORT + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('emits all SUCCESS against the SEP-2106-compliant reference server', async () => { + const scenario = new Sep2106StructuredContentScenario(); + const checks = await scenario.run(`http://localhost:${PORT}/mcp`); + + // Every check should be SUCCESS — no FAILURE, no WARNING. + const failures = checks.filter((c) => c.status === 'FAILURE'); + const warnings = checks.filter((c) => c.status === 'WARNING'); + expect( + failures, + `unexpected failures: ${failures.map((f) => `${f.id}: ${f.errorMessage}`).join('; ')}` + ).toHaveLength(0); + expect( + warnings, + `unexpected warnings: ${warnings.map((w) => `${w.id}: ${w.errorMessage}`).join('; ')}` + ).toHaveLength(0); + + // Sanity: all expected check IDs are present. + const expectedIds = [ + 'sep-2106-array-output-tool-found', + 'sep-2106-array-output-schema-preserved', + 'sep-2106-array-structured-content', + 'sep-2106-primitive-output-tool-found', + 'sep-2106-primitive-output-schema-preserved', + 'sep-2106-primitive-structured-content' + ]; + for (const id of expectedIds) { + expect( + checks.find((c) => c.id === id), + `${id} missing` + ).toBeDefined(); + } + }, 15000); + }); }); diff --git a/src/scenarios/server/sep-2106-structured-content.ts b/src/scenarios/server/sep-2106-structured-content.ts new file mode 100644 index 00000000..076fddd1 --- /dev/null +++ b/src/scenarios/server/sep-2106-structured-content.ts @@ -0,0 +1,447 @@ +/** + * SEP-2106 structuredContent wire-shape scenario + * + * Complements the SEP-2106 keyword-preservation checks that #295 added to + * `JsonSchema2020_12Scenario` by exercising the other half of SEP-2106: + * the loosened wire-format for `outputSchema` and `structuredContent`. + * + * - `outputSchema` may be any JSON Schema 2020-12 (not just `type: "object"`), + * so arrays and primitives at the root are now valid. + * - `structuredContent` widens from `{[key: string]: unknown}` to `unknown`, + * so a tool can return a raw array, number, string, etc. directly. + * + * SEP-2106's motivation section is largely about this wire shape (the + * weather-forecast and get-count examples), so it warrants its own checks + * even though the spec diff added no new RFC 2119 sentences — this is a + * capability test, same pattern as SEP-1613 / the existing + * `JsonSchema2020_12Scenario`. + * + * Why raw HTTP: the MCP SDK Client's response validator rejects non-object + * `outputSchema` and non-object `structuredContent` (the very shapes this + * scenario inspects), and the SDK Server refuses to emit them. Raw HTTP + * bypasses both validators so the scenario can see what the server actually + * put on the wire. This is also why the scenario lives in + * `pendingClientScenariosList` only — until the SDK ships SEP-2106 support, + * the in-repo everything-server cannot satisfy these checks. The compliant + * reference target is `examples/servers/typescript/sep-2106-compliant-server.ts`. + */ + +import http from 'http'; +import { DRAFT_PROTOCOL_VERSION } from '../../types.js'; +import type { ClientScenario, ConformanceCheck } from '../../types.js'; + +const ARRAY_TOOL = 'sep_2106_array_output_tool'; +const PRIMITIVE_TOOL = 'sep_2106_primitive_output_tool'; + +const SPEC_REFERENCES = [ + { + id: 'SEP-2106', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2106' + }, + { + id: 'tools#structured-content', + url: 'https://modelcontextprotocol.io/specification/draft/server/tools#structured-content' + } +]; + +function now(): string { + return new Date().toISOString(); +} + +interface RawResponse { + status: number; + headers: http.IncomingHttpHeaders; + body: unknown; +} + +/** + * POST a JSON-RPC request to a Streamable-HTTP MCP endpoint. Handles both + * application/json and text/event-stream responses (the transport may pick + * either; SSE is parsed back to the concatenated `data:` JSON payload). + */ +function postJsonRpc( + serverUrl: string, + body: object, + headers: Record = {} +): Promise { + const url = new URL(serverUrl); + const bodyStr = JSON.stringify(body); + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Content-Length': Buffer.byteLength(bodyStr), + ...headers + } + }, + (res) => { + res.setEncoding('utf8'); + let raw = ''; + res.on('data', (chunk) => { + raw += chunk; + }); + res.on('end', () => { + const contentType = (res.headers['content-type'] ?? '').toLowerCase(); + let parsed: unknown = raw; + if (raw.length === 0) { + parsed = null; + } else if (contentType.includes('text/event-stream')) { + const dataLines = raw + .split(/\r?\n/) + .filter((l) => l.startsWith('data:')) + .map((l) => l.slice(5).trim()) + .filter((l) => l.length > 0); + const joined = dataLines.join(''); + try { + parsed = joined ? JSON.parse(joined) : null; + } catch { + parsed = raw; + } + } else if (contentType.includes('application/json')) { + try { + parsed = JSON.parse(raw); + } catch { + parsed = raw; + } + } + resolve({ + status: res.statusCode || 0, + headers: res.headers, + body: parsed + }); + }); + } + ); + req.on('error', reject); + req.write(bodyStr); + req.end(); + }); +} + +function extractSessionId(res: RawResponse): string | undefined { + const raw = res.headers['mcp-session-id']; + if (!raw) return undefined; + return Array.isArray(raw) ? raw[0] : raw; +} + +async function initSession(serverUrl: string): Promise { + const initRes = await postJsonRpc(serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: DRAFT_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: 'sep-2106-structured-content-client', + version: '1.0.0' + } + } + }); + if (initRes.status < 200 || initRes.status >= 300) { + throw new Error( + `initialize failed with HTTP ${initRes.status}: ${JSON.stringify(initRes.body)}` + ); + } + const initBody = initRes.body as { error?: unknown } | null; + if (initBody && typeof initBody === 'object' && 'error' in initBody) { + throw new Error( + `initialize returned JSON-RPC error: ${JSON.stringify(initBody.error)}` + ); + } + const sessionId = extractSessionId(initRes); + const notifHeaders: Record = {}; + if (sessionId) notifHeaders['mcp-session-id'] = sessionId; + try { + await postJsonRpc( + serverUrl, + { jsonrpc: '2.0', method: 'notifications/initialized' }, + notifHeaders + ); + } catch { + // Notification ack is best-effort. + } + return sessionId; +} + +interface ToolRecord { + name: string; + description?: string; + inputSchema?: Record; + outputSchema?: Record; + [key: string]: unknown; +} + +async function rawListTools( + serverUrl: string, + sessionId: string | undefined, + id: number +): Promise { + const headers: Record = {}; + if (sessionId) headers['mcp-session-id'] = sessionId; + const res = await postJsonRpc( + serverUrl, + { jsonrpc: '2.0', id, method: 'tools/list', params: {} }, + headers + ); + if (res.status < 200 || res.status >= 300) { + throw new Error( + `tools/list failed with HTTP ${res.status}: ${JSON.stringify(res.body)}` + ); + } + const body = res.body as { + result?: { tools?: ToolRecord[] }; + error?: unknown; + } | null; + if (body && typeof body === 'object' && 'error' in body) { + throw new Error( + `tools/list returned JSON-RPC error: ${JSON.stringify(body.error)}` + ); + } + return body?.result?.tools ?? []; +} + +interface CallResult { + content?: Array>; + structuredContent?: unknown; + isError?: boolean; + [key: string]: unknown; +} + +async function rawCallTool( + serverUrl: string, + sessionId: string | undefined, + id: number, + name: string +): Promise { + const headers: Record = {}; + if (sessionId) headers['mcp-session-id'] = sessionId; + const res = await postJsonRpc( + serverUrl, + { + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name, arguments: {} } + }, + headers + ); + if (res.status < 200 || res.status >= 300) { + throw new Error( + `tools/call(${name}) failed with HTTP ${res.status}: ${JSON.stringify(res.body)}` + ); + } + const body = res.body as { result?: CallResult; error?: unknown } | null; + if (body && typeof body === 'object' && 'error' in body) { + throw new Error( + `tools/call(${name}) returned JSON-RPC error: ${JSON.stringify(body.error)}` + ); + } + return body?.result ?? {}; +} + +export class Sep2106StructuredContentScenario implements ClientScenario { + name = 'sep-2106-structured-content'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Validates SEP-2106's loosened outputSchema + structuredContent wire shape. + +This scenario complements the SEP-2106 keyword-preservation checks in the json-schema-2020-12 scenario by exercising the call-time wire shape SEP-2106 enables. + +**Server Implementation Requirements:** + +The server MUST advertise two tools whose schemas exercise the loosened outputSchema / structuredContent rules: + +1. \`${ARRAY_TOOL}\` — \`outputSchema\` is a JSON Schema with \`type: "array"\` at the root (no \`{type: "object"}\` wrapper). The \`tools/call\` response MUST place a JSON array directly in \`structuredContent\`. + +2. \`${PRIMITIVE_TOOL}\` — \`outputSchema\` is a JSON Schema with a primitive type at the root (e.g. \`{ type: "number" }\`). The \`tools/call\` response MUST place a raw number directly in \`structuredContent\`. + +**Verification**: The scenario lists tools, calls each, and checks that the new schema shapes survive the round-trip without being stripped or rewrapped. Uses raw HTTP rather than the SDK Client because the SDK's response validator (pre-SEP-2106) rejects non-object outputSchema/structuredContent.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + let sessionId: string | undefined; + try { + sessionId = await initSession(serverUrl); + } catch (error) { + checks.push({ + id: 'sep-2106-structured-content-error', + name: 'Sep2106StructuredContentError', + description: 'SEP-2106 structuredContent test setup', + status: 'FAILURE', + timestamp: now(), + errorMessage: `Failed to initialize session: ${error instanceof Error ? error.message : String(error)}`, + specReferences: SPEC_REFERENCES + }); + return checks; + } + + let tools: ToolRecord[]; + try { + tools = await rawListTools(serverUrl, sessionId, 2); + } catch (error) { + checks.push({ + id: 'sep-2106-structured-content-error', + name: 'Sep2106StructuredContentError', + description: 'SEP-2106 structuredContent test tools/list', + status: 'FAILURE', + timestamp: now(), + errorMessage: `Failed to list tools: ${error instanceof Error ? error.message : String(error)}`, + specReferences: SPEC_REFERENCES + }); + return checks; + } + + const advertised = tools.map((t) => t.name); + const arrayTool = tools.find((t) => t.name === ARRAY_TOOL); + const primitiveTool = tools.find((t) => t.name === PRIMITIVE_TOOL); + + // ─── Array-output tool ─────────────────────────────────────────────── + checks.push({ + id: 'sep-2106-array-output-tool-found', + name: 'Sep2106ArrayOutputToolFound', + description: `Server advertises tool '${ARRAY_TOOL}'`, + status: arrayTool ? 'SUCCESS' : 'FAILURE', + timestamp: now(), + errorMessage: arrayTool + ? undefined + : `Tool '${ARRAY_TOOL}' not found. Available tools: ${advertised.join(', ') || 'none'}`, + specReferences: SPEC_REFERENCES, + details: { advertised } + }); + + if (arrayTool) { + const output = arrayTool.outputSchema; + const isArrayRoot = !!output && output.type === 'array'; + + checks.push({ + id: 'sep-2106-array-output-schema-preserved', + name: 'Sep2106ArrayOutputSchemaPreserved', + description: `${ARRAY_TOOL} advertises an array-at-root outputSchema (SEP-2106 loosened outputSchema)`, + status: isArrayRoot ? 'SUCCESS' : 'FAILURE', + timestamp: now(), + errorMessage: !output + ? `outputSchema is missing on '${ARRAY_TOOL}'` + : !isArrayRoot + ? `outputSchema.type is ${JSON.stringify(output.type)}, expected 'array'. SDK may have wrapped the array in an object — SEP-2106 removes the type: "object" requirement on outputSchema.` + : undefined, + specReferences: SPEC_REFERENCES, + details: { outputSchema: output } + }); + + try { + const callResult = await rawCallTool( + serverUrl, + sessionId, + 3, + ARRAY_TOOL + ); + const sc = callResult.structuredContent; + const isArraySc = Array.isArray(sc); + + checks.push({ + id: 'sep-2106-array-structured-content', + name: 'Sep2106ArrayStructuredContent', + description: `${ARRAY_TOOL} returns a JSON array directly in structuredContent (SEP-2106 widens structuredContent to any JSON value)`, + status: isArraySc ? 'SUCCESS' : 'FAILURE', + timestamp: now(), + errorMessage: isArraySc + ? undefined + : sc === undefined + ? 'structuredContent is missing from the call result' + : `structuredContent is ${typeof sc}, expected array. SDK may have wrapped the array in an object before sending.`, + specReferences: SPEC_REFERENCES, + details: { structuredContent: sc } + }); + } catch (err) { + checks.push({ + id: 'sep-2106-array-structured-content', + name: 'Sep2106ArrayStructuredContent', + description: `Call to ${ARRAY_TOOL} failed`, + status: 'FAILURE', + timestamp: now(), + errorMessage: `tools/call threw: ${err instanceof Error ? err.message : String(err)}`, + specReferences: SPEC_REFERENCES + }); + } + } + + // ─── Primitive-output tool ─────────────────────────────────────────── + checks.push({ + id: 'sep-2106-primitive-output-tool-found', + name: 'Sep2106PrimitiveOutputToolFound', + description: `Server advertises tool '${PRIMITIVE_TOOL}'`, + status: primitiveTool ? 'SUCCESS' : 'FAILURE', + timestamp: now(), + errorMessage: primitiveTool + ? undefined + : `Tool '${PRIMITIVE_TOOL}' not found. Available tools: ${advertised.join(', ') || 'none'}`, + specReferences: SPEC_REFERENCES, + details: { advertised } + }); + + if (primitiveTool) { + const output = primitiveTool.outputSchema; + const isPrimitiveRoot = !!output && output.type === 'number'; + + checks.push({ + id: 'sep-2106-primitive-output-schema-preserved', + name: 'Sep2106PrimitiveOutputSchemaPreserved', + description: `${PRIMITIVE_TOOL} advertises a primitive outputSchema (SEP-2106 loosened outputSchema)`, + status: isPrimitiveRoot ? 'SUCCESS' : 'FAILURE', + timestamp: now(), + errorMessage: !output + ? `outputSchema is missing on '${PRIMITIVE_TOOL}'` + : !isPrimitiveRoot + ? `outputSchema.type is ${JSON.stringify(output.type)}, expected 'number'. SDK may have wrapped the primitive in an object.` + : undefined, + specReferences: SPEC_REFERENCES, + details: { outputSchema: output } + }); + + try { + const callResult = await rawCallTool( + serverUrl, + sessionId, + 4, + PRIMITIVE_TOOL + ); + const sc = callResult.structuredContent; + const isNumberSc = typeof sc === 'number'; + + checks.push({ + id: 'sep-2106-primitive-structured-content', + name: 'Sep2106PrimitiveStructuredContent', + description: `${PRIMITIVE_TOOL} returns a raw number in structuredContent (SEP-2106 widens structuredContent)`, + status: isNumberSc ? 'SUCCESS' : 'FAILURE', + timestamp: now(), + errorMessage: isNumberSc + ? undefined + : sc === undefined + ? 'structuredContent is missing from the call result' + : `structuredContent is ${typeof sc} (${JSON.stringify(sc)}), expected number. SDK may have wrapped the primitive in an object.`, + specReferences: SPEC_REFERENCES, + details: { structuredContent: sc } + }); + } catch (err) { + checks.push({ + id: 'sep-2106-primitive-structured-content', + name: 'Sep2106PrimitiveStructuredContent', + description: `Call to ${PRIMITIVE_TOOL} failed`, + status: 'FAILURE', + timestamp: now(), + errorMessage: `tools/call threw: ${err instanceof Error ? err.message : String(err)}`, + specReferences: SPEC_REFERENCES + }); + } + } + + return checks; + } +}