Port the end-to-end test suite to v2 (#2179)
This commit is contained in:
parent
5fc42e9be1
commit
1998a186ee
5 changed files with 20108 additions and 1 deletions
64
test/e2e/helpers/express.ts
Normal file
64
test/e2e/helpers/express.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Express helper for hosting-related e2e tests.
|
||||
*
|
||||
* Builds real Express apps with SDK middleware, listens on ephemeral ports,
|
||||
* closes cleanly. Used by hosting-express.ts scenarios.
|
||||
*/
|
||||
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
|
||||
import { hostHeaderValidation } from '@modelcontextprotocol/express';
|
||||
import type { Express, RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
|
||||
export interface ExpressHost extends AsyncDisposable {
|
||||
readonly baseUrl: URL;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function listen(app: Express, host = '127.0.0.1'): Promise<{ baseUrl: URL; close: () => Promise<void> }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: HttpServer = app.listen(0, host, () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
reject(new Error(`listen failed: ${addr}`));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
baseUrl: new URL(`http://${host}:${addr.port}`),
|
||||
close: () =>
|
||||
new Promise((res, rej) => {
|
||||
server.close(err => (err ? rej(err) : res()));
|
||||
})
|
||||
});
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function startExpressMinimal(handler: RequestHandler): Promise<ExpressHost> {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(handler);
|
||||
|
||||
const { baseUrl, close } = await listen(app);
|
||||
return {
|
||||
baseUrl,
|
||||
close,
|
||||
[Symbol.asyncDispose]: close
|
||||
};
|
||||
}
|
||||
|
||||
export async function startExpressWithHostValidation(allowedHosts: string[], handler: RequestHandler): Promise<ExpressHost> {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(hostHeaderValidation(allowedHosts));
|
||||
app.use(handler);
|
||||
|
||||
const { baseUrl, close } = await listen(app);
|
||||
return {
|
||||
baseUrl,
|
||||
close,
|
||||
[Symbol.asyncDispose]: close
|
||||
};
|
||||
}
|
||||
229
test/e2e/helpers/index.ts
Normal file
229
test/e2e/helpers/index.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Test wiring helpers.
|
||||
*
|
||||
* `wire(transport, makeServer, client)` connects a server (built per call by
|
||||
* `makeServer`) and a client over the named transport, returning an
|
||||
* `AsyncDisposable` for `await using` teardown. All wiring is in-process —
|
||||
* no real sockets, no child processes.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/client';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import type { EventStore, JSONRPCMessage, McpServer, Server } from '@modelcontextprotocol/server';
|
||||
import { InMemoryTransport, ReadBuffer, serializeMessage, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
||||
|
||||
import type { Transport } from '../types.js';
|
||||
import type { SnifferOptions } from './wire-sniffer.js';
|
||||
import { sniffTransport } from './wire-sniffer.js';
|
||||
|
||||
export type ServerFactory = () => McpServer | Server;
|
||||
|
||||
export interface Wired extends AsyncDisposable {
|
||||
readonly fetch?: (url: URL | string, init?: RequestInit) => Promise<Response>;
|
||||
readonly url?: URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fourth argument controls the wire-format sniffer (see wire-sniffer.ts):
|
||||
* every message the client sends or receives is validated against the SDK's
|
||||
* spec-anchored Zod schemas. Tests that intentionally use vendor-extension
|
||||
* methods pass `{ allowCustomMethods: true }`; tests that deliberately put
|
||||
* malformed MCP on the wire pass `{ strictValidation: false }`.
|
||||
*/
|
||||
export async function wire(transport: Transport, makeServer: ServerFactory, client: Client, sniff: SnifferOptions = {}): Promise<Wired> {
|
||||
switch (transport) {
|
||||
case 'inMemory': {
|
||||
const server = makeServer();
|
||||
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTx);
|
||||
await client.connect(sniffTransport(clientTx, 'client', sniff));
|
||||
return { [Symbol.asyncDispose]: () => Promise.all([client.close(), server.close()]).then(() => {}) };
|
||||
}
|
||||
case 'stdio': {
|
||||
const server = makeServer();
|
||||
const c2s = new PassThrough();
|
||||
const s2c = new PassThrough();
|
||||
await server.connect(new StdioServerTransport(c2s, s2c));
|
||||
await client.connect(sniffTransport(stdioClientOverPipes(s2c, c2s), 'client', sniff));
|
||||
return { [Symbol.asyncDispose]: () => Promise.all([client.close(), server.close()]).then(() => {}) };
|
||||
}
|
||||
case 'streamableHttp':
|
||||
case 'streamableHttpStateless': {
|
||||
const handle = transport === 'streamableHttpStateless' ? hostStateless(makeServer) : hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
await client.connect(sniffTransport(new StreamableHTTPClientTransport(url, { fetch }), 'client', sniff));
|
||||
return {
|
||||
fetch,
|
||||
url,
|
||||
[Symbol.asyncDispose]: () => Promise.all([client.close(), handle.close()]).then(() => {})
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tap a connected client's transport so every JSON-RPC message crossing the
|
||||
* wire is recorded. `sent` = client→server, `received` = server→client.
|
||||
* Call after `wire()` so `client.transport` is set. The transport is
|
||||
* monkey-patched in place; teardown via `await using` on `wire()` discards it.
|
||||
*/
|
||||
export function tapWire(client: Client): { sent: JSONRPCMessage[]; received: JSONRPCMessage[] } {
|
||||
const tx = client.transport;
|
||||
if (!tx) throw new Error('tapWire: client not connected');
|
||||
const sent: JSONRPCMessage[] = [];
|
||||
const received: JSONRPCMessage[] = [];
|
||||
const origSend = tx.send.bind(tx);
|
||||
const origOnMessage = tx.onmessage;
|
||||
tx.send = async (m, opts) => {
|
||||
sent.push(m);
|
||||
return origSend(m, opts);
|
||||
};
|
||||
tx.onmessage = (m, extra) => {
|
||||
received.push(m);
|
||||
origOnMessage?.(m, extra);
|
||||
};
|
||||
return { sent, received };
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP hosting (the two production patterns)
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type HttpHandler = (req: Request) => Promise<Response>;
|
||||
|
||||
export function hostPerSession(makeServer: ServerFactory): { handleRequest: HttpHandler; close(): Promise<void> } {
|
||||
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
||||
return {
|
||||
handleRequest: async req => {
|
||||
const sid = req.headers.get('mcp-session-id') ?? undefined;
|
||||
const existing = sid ? sessions.get(sid) : undefined;
|
||||
if (existing) return existing.handleRequest(req);
|
||||
if (sid !== undefined) {
|
||||
// Mirror the SDK's documented hosting pattern: an unrecognized session id is
|
||||
// rejected at the app level, so the transport's own 404 is never reached.
|
||||
return Response.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_000, message: 'Bad Request: No valid session ID provided' },
|
||||
id: null
|
||||
},
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
onsessioninitialized: id => void sessions.set(id, tx),
|
||||
onsessionclosed: id => void sessions.delete(id)
|
||||
});
|
||||
await makeServer().connect(tx);
|
||||
return tx.handleRequest(req);
|
||||
},
|
||||
close: async () => {
|
||||
for (const t of sessions.values()) await t.close();
|
||||
sessions.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResumeHostOptions {
|
||||
eventStore: EventStore;
|
||||
retryInterval?: number;
|
||||
}
|
||||
|
||||
export function hostResumable(makeServer: ServerFactory, opts: ResumeHostOptions): { handleRequest: HttpHandler; close(): Promise<void> } {
|
||||
const sessions = new Map<string, { tx: WebStandardStreamableHTTPServerTransport; server: McpServer | Server }>();
|
||||
|
||||
return {
|
||||
handleRequest: async req => {
|
||||
const sid = req.headers.get('mcp-session-id') ?? undefined;
|
||||
const existing = sid ? sessions.get(sid) : undefined;
|
||||
if (existing) return existing.tx.handleRequest(req);
|
||||
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
eventStore: opts.eventStore,
|
||||
retryInterval: opts.retryInterval,
|
||||
onsessioninitialized: id => void sessions.set(id, { tx, server }),
|
||||
onsessionclosed: id => void sessions.delete(id)
|
||||
});
|
||||
const server = makeServer();
|
||||
await server.connect(tx);
|
||||
return tx.handleRequest(req);
|
||||
},
|
||||
close: async () => {
|
||||
for (const { tx, server } of sessions.values()) {
|
||||
await server.close();
|
||||
await tx.close();
|
||||
}
|
||||
sessions.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function hostStateless(makeServer: ServerFactory): { handleRequest: HttpHandler; close(): Promise<void> } {
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
return {
|
||||
handleRequest: async req => {
|
||||
if (req.method !== 'POST') {
|
||||
return Response.json(
|
||||
{ jsonrpc: '2.0', error: { code: -32_000, message: 'Method not allowed.' }, id: null },
|
||||
{
|
||||
status: 405,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
const server = makeServer();
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
await server.connect(tx);
|
||||
cleanups.push(async () => {
|
||||
await server.close();
|
||||
await tx.close();
|
||||
});
|
||||
return tx.handleRequest(req);
|
||||
},
|
||||
close: async () => {
|
||||
for (const c of cleanups) await c();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
// In-process stdio client — TEST-ONLY
|
||||
//
|
||||
// Production stdio uses `StdioClientTransport`, which spawns a child process.
|
||||
// This is the in-process equivalent for tests: same newline-framed JSON wire
|
||||
// format (uses the SDK's `serializeMessage`/`ReadBuffer`), but over PassThrough
|
||||
// streams instead of a spawned process. Tests that specifically exercise spawn,
|
||||
// env, signals, or stderr must use the real `StdioClientTransport`.
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function stdioClientOverPipes(serverStdout: NodeJS.ReadableStream, serverStdin: NodeJS.WritableStream) {
|
||||
const buf = new ReadBuffer();
|
||||
return {
|
||||
onmessage: undefined as ((m: JSONRPCMessage) => void) | undefined,
|
||||
onerror: undefined as ((e: Error) => void) | undefined,
|
||||
onclose: undefined as (() => void) | undefined,
|
||||
async start() {
|
||||
serverStdout.on('data', chunk => {
|
||||
buf.append(chunk);
|
||||
let m: JSONRPCMessage | null;
|
||||
while ((m = buf.readMessage())) this.onmessage?.(m);
|
||||
});
|
||||
serverStdout.on('error', e => this.onerror?.(e));
|
||||
serverStdout.on('close', () => this.onclose?.());
|
||||
},
|
||||
async send(m: JSONRPCMessage) {
|
||||
serverStdin.write(serializeMessage(m));
|
||||
},
|
||||
async close() {
|
||||
serverStdin.end();
|
||||
}
|
||||
};
|
||||
}
|
||||
54
test/e2e/helpers/verifies.ts
Normal file
54
test/e2e/helpers/verifies.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Inverted manifest linkage: tests declare which requirement(s) they satisfy.
|
||||
* (Staged here in the harness; the restructure codemod copies this file to
|
||||
* sdk/test/e2e/helpers/verifies.ts when the inversion is executed.)
|
||||
*
|
||||
* `verifies(id, fn, opts?)` looks the requirement up in the pure-data manifest
|
||||
* and registers one cell per applicable (transport, spec version) — the same
|
||||
* names and semantics the old matrix runner produced — applying `test.fails`
|
||||
* for knownFailures and the global 15s timeout. Unknown or deferred ids throw
|
||||
* at registration time, so a typo can never silently drop coverage.
|
||||
*
|
||||
* Bodies are anonymous; the registered test title is `opts?.title ?? 'verifies'`.
|
||||
* An explicit `opts.title` is only needed when a requirement is cited by more
|
||||
* than one body (to tell them apart) — a knownFailure with a `test` string only
|
||||
* applies to the body registered with that exact title.
|
||||
*/
|
||||
|
||||
import { describe, test } from 'vitest';
|
||||
|
||||
import { REQUIREMENTS } from '../requirements.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS } from '../types.js';
|
||||
|
||||
type TestBody = (args: TestArgs) => Promise<void>;
|
||||
|
||||
export function verifies(id: string | readonly string[], fn: TestBody, opts?: { title?: string }): void {
|
||||
const ids = Array.isArray(id) ? id : [id];
|
||||
for (const rid of ids) registerOne(rid, fn, opts);
|
||||
}
|
||||
|
||||
function registerOne(id: string, fn: TestBody, opts?: { title?: string }): void {
|
||||
const req = REQUIREMENTS[id];
|
||||
if (!req) throw new Error(`verifies('${id}'): unknown requirement id`);
|
||||
if (req.deferred) throw new Error(`verifies('${id}'): requirement is deferred — drop the deferral or the test`);
|
||||
|
||||
const transports = req.transports ?? ALL_TRANSPORTS;
|
||||
const versions = ALL_SPEC_VERSIONS.filter(
|
||||
v =>
|
||||
(req.addedInSpecVersion === undefined || v >= req.addedInSpecVersion) &&
|
||||
(req.removedInSpecVersion === undefined || v < req.removedInSpecVersion)
|
||||
);
|
||||
const cells = versions.flatMap(v => transports.map(t => [t, v] as const));
|
||||
|
||||
describe.each(cells)(`${id} [%s %s]`, (transport, protocolVersion) => {
|
||||
const kf = req.knownFailures?.find(
|
||||
k =>
|
||||
(k.test === undefined || k.test === opts?.title) &&
|
||||
(k.transport === undefined || k.transport === transport) &&
|
||||
(k.specVersion === undefined || k.specVersion === protocolVersion)
|
||||
);
|
||||
const run = kf ? test.fails : test;
|
||||
run(opts?.title ?? 'verifies', () => fn({ transport, protocolVersion }), 15_000);
|
||||
});
|
||||
}
|
||||
69
test/e2e/helpers/wire-sniffer.test.ts
Normal file
69
test/e2e/helpers/wire-sniffer.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { assertWireMessage } from './wire-sniffer.js';
|
||||
|
||||
const req = (method: string, params?: unknown, id = 1) => ({
|
||||
jsonrpc: '2.0' as const,
|
||||
id,
|
||||
method,
|
||||
...(params === undefined ? {} : { params })
|
||||
});
|
||||
const notif = (method: string, params?: unknown) => ({ jsonrpc: '2.0' as const, method, ...(params === undefined ? {} : { params }) });
|
||||
const resp = (result: unknown, id = 1) => ({ jsonrpc: '2.0' as const, id, result });
|
||||
|
||||
describe('assertWireMessage', () => {
|
||||
it('accepts a valid client initialize request', () => {
|
||||
expect(() =>
|
||||
assertWireMessage(
|
||||
req('initialize', { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }),
|
||||
'client'
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a valid client tools/list result on the server→client return', () => {
|
||||
// server sends the ListToolsResult back
|
||||
expect(() => assertWireMessage(resp({ tools: [] }), 'server')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a valid client ping and the empty result', () => {
|
||||
expect(() => assertWireMessage(req('ping'), 'client')).not.toThrow();
|
||||
expect(() => assertWireMessage(resp({}), 'server')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a non-JSON-RPC envelope', () => {
|
||||
expect(() => assertWireMessage({ jsonrpc: '1.0', id: 1, method: 'ping' }, 'client')).toThrow(/not a JSON-RPC message/);
|
||||
});
|
||||
|
||||
it('rejects an unknown method without allowCustomMethods', () => {
|
||||
expect(() => assertWireMessage(req('x/custom', { a: 1 }), 'client')).toThrow(/non-spec request method 'x\/custom'/);
|
||||
});
|
||||
|
||||
it('accepts an unknown method with allowCustomMethods', () => {
|
||||
expect(() => assertWireMessage(req('x/custom', { a: 1 }), 'client', { allowCustomMethods: true })).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a spec method with malformed params under strict, accepts with strictValidation:false', () => {
|
||||
const bad = notif('notifications/progress', { progressToken: 1, progress: 'not-a-number' });
|
||||
expect(() => assertWireMessage(bad, 'client')).toThrow(/params do not conform/);
|
||||
expect(() => assertWireMessage(bad, 'client', { strictValidation: false })).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a server-only request sent by the client (direction check)', () => {
|
||||
// sampling/createMessage is a Server→Client request; the client must not originate it
|
||||
expect(() => assertWireMessage(req('sampling/createMessage', { messages: [], maxTokens: 1 }), 'client')).toThrow(
|
||||
/non-spec request method 'sampling\/createMessage'/
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts that same request when the server sends it', () => {
|
||||
expect(() => assertWireMessage(req('sampling/createMessage', { messages: [], maxTokens: 1 }), 'server')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a JSON-RPC error response for either party', () => {
|
||||
const err = { jsonrpc: '2.0' as const, id: 1, error: { code: -32_601, message: 'Method not found' } };
|
||||
expect(() => assertWireMessage(err, 'server')).not.toThrow();
|
||||
expect(() => assertWireMessage(err, 'client')).not.toThrow();
|
||||
});
|
||||
});
|
||||
135
test/e2e/helpers/wire-sniffer.ts
Normal file
135
test/e2e/helpers/wire-sniffer.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import {
|
||||
ClientNotificationSchema,
|
||||
ClientRequestSchema,
|
||||
ClientResultSchema,
|
||||
ServerNotificationSchema,
|
||||
ServerRequestSchema,
|
||||
ServerResultSchema
|
||||
} from '@modelcontextprotocol/core';
|
||||
import type { Transport } from '@modelcontextprotocol/server';
|
||||
import {
|
||||
isJSONRPCErrorResponse,
|
||||
isJSONRPCNotification,
|
||||
isJSONRPCRequest,
|
||||
isJSONRPCResultResponse,
|
||||
isSpecType
|
||||
} from '@modelcontextprotocol/server';
|
||||
|
||||
export type WireParty = 'client' | 'server';
|
||||
|
||||
export interface SnifferOptions {
|
||||
/** Permit non-spec (vendor-extension) method names. Spec methods stay strict. */
|
||||
allowCustomMethods?: boolean;
|
||||
/** `false` → envelope check only (for tests that deliberately send malformed messages). */
|
||||
strictValidation?: boolean;
|
||||
}
|
||||
|
||||
const OUTBOUND = {
|
||||
client: {
|
||||
request: ClientRequestSchema,
|
||||
notification: ClientNotificationSchema,
|
||||
result: ClientResultSchema
|
||||
},
|
||||
server: {
|
||||
request: ServerRequestSchema,
|
||||
notification: ServerNotificationSchema,
|
||||
result: ServerResultSchema
|
||||
}
|
||||
} as const;
|
||||
|
||||
/** Method names valid as an outbound request/notification for each party. */
|
||||
const SPEC_METHODS: Record<WireParty, { request: Set<string>; notification: Set<string> }> = {
|
||||
client: { request: methodSet(ClientRequestSchema), notification: methodSet(ClientNotificationSchema) },
|
||||
server: { request: methodSet(ServerRequestSchema), notification: methodSet(ServerNotificationSchema) }
|
||||
};
|
||||
|
||||
function methodSet(union: { options?: ReadonlyArray<{ shape?: { method?: { values?: ReadonlySet<unknown> } } }> }): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const member of union.options ?? []) {
|
||||
for (const v of member.shape?.method?.values ?? []) {
|
||||
if (typeof v === 'string') out.add(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fail(party: WireParty, reason: string, msg: unknown): never {
|
||||
throw new Error(`[wire] ${party} sent an invalid message: ${reason}\n${JSON.stringify(msg, null, 2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a single message is valid for the given sending party.
|
||||
* @param msg the raw JSON-RPC message
|
||||
* @param party who put it on the wire (`client` outbound = ClientRequest/Notification/Result)
|
||||
*/
|
||||
export function assertWireMessage(msg: unknown, party: WireParty, opts: SnifferOptions = {}): void {
|
||||
if (!isSpecType.JSONRPCMessage(msg)) {
|
||||
fail(party, 'not a JSON-RPC message', msg);
|
||||
}
|
||||
if (opts.strictValidation === false) return;
|
||||
|
||||
const schemas = OUTBOUND[party];
|
||||
|
||||
if (isJSONRPCRequest(msg) || isJSONRPCNotification(msg)) {
|
||||
const kind = isJSONRPCRequest(msg) ? 'request' : 'notification';
|
||||
const method = (msg as { method: string }).method;
|
||||
if (!SPEC_METHODS[party][kind].has(method)) {
|
||||
if (opts.allowCustomMethods) return;
|
||||
fail(party, `non-spec ${kind} method '${method}' (pass { allowCustomMethods: true } if intentional)`, msg);
|
||||
}
|
||||
const params = (msg as { params?: unknown }).params;
|
||||
const r = schemas[kind].safeParse({ method, params });
|
||||
if (!r.success) {
|
||||
fail(party, `spec method '${method}' params do not conform: ${r.error.message}`, msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJSONRPCResultResponse(msg)) {
|
||||
const result = (msg as { result: unknown }).result;
|
||||
const r = schemas.result.safeParse(result);
|
||||
if (!r.success) {
|
||||
// A result for a vendor-extension request legitimately won't match the spec union.
|
||||
if (opts.allowCustomMethods) return;
|
||||
fail(party, `result does not conform to any spec result: ${r.error.message}`, msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJSONRPCErrorResponse(msg)) return; // envelope already validated; error bodies are not method-specific
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a transport so every outbound `send` (validated as `party`) and inbound
|
||||
* `onmessage` (validated as the counterpart) is asserted. Returns the same
|
||||
* transport instance (monkey-patched in place).
|
||||
*/
|
||||
export function sniffTransport<T extends Transport>(transport: T, party: WireParty, opts: SnifferOptions = {}): T {
|
||||
const counterpart: WireParty = party === 'client' ? 'server' : 'client';
|
||||
|
||||
const origSend = transport.send.bind(transport);
|
||||
transport.send = (message, sendOpts) => {
|
||||
assertWireMessage(message, party, opts);
|
||||
return origSend(message, sendOpts);
|
||||
};
|
||||
|
||||
// `onmessage` is assigned by Protocol.connect() after we wrap. Intercept via
|
||||
// an accessor so we wrap whatever handler it installs, validating each
|
||||
// inbound message (sent by the counterpart) before passing it through.
|
||||
let handler: Transport['onmessage'];
|
||||
Object.defineProperty(transport, 'onmessage', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => handler,
|
||||
set: next => {
|
||||
handler = next
|
||||
? (message, extra) => {
|
||||
assertWireMessage(message, counterpart, opts);
|
||||
return next(message, extra);
|
||||
}
|
||||
: next;
|
||||
}
|
||||
});
|
||||
|
||||
return transport;
|
||||
}
|
||||
Loading…
Reference in a new issue