@specific.dev/spectest 0.55.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
1
+ // Streamable-HTTP transport for the MCP client (`mcp.ts`).
2
+ //
3
+ // This is a hand-written JSON-RPC client for the MCP streamable-HTTP
4
+ // transport. We do not depend on `@modelcontextprotocol/sdk`, because that
5
+ // package pulls in ~90 transitive packages (express, hono, cors, ajv, zod)
6
+ // and ~25 MB, for its SERVER half. The SDK here ships into every project's
7
+ // VM and is installed on every cold start, so a dependency of that size is
8
+ // paid by projects that never test an MCP server. The client half of the
9
+ // protocol is small, and this file is all of it.
10
+ //
11
+ // What it implements (MCP revision 2025-06-18, backward compatible with
12
+ // 2025-03-26):
13
+ // - POST of one JSON-RPC message, with a reply that is either
14
+ // `application/json` or an `text/event-stream` (SSE) stream.
15
+ // - The `initialize` handshake, the `Mcp-Session-Id` header the server
16
+ // may issue, and the `MCP-Protocol-Version` header that every later
17
+ // request must carry.
18
+ // - Server-to-client requests (sampling, roots) that arrive
19
+ // on the SSE stream of the request that caused them. The answer goes
20
+ // back on its own POST, as the spec requires.
21
+ // - `DELETE` to end the session.
22
+ //
23
+ // What it does NOT implement, deliberately: the standalone GET stream, and
24
+ // resumption by `Last-Event-ID`. Both matter to a long-lived desktop
25
+ // client that must survive a dropped connection. A test makes one request
26
+ // at a time and its VM is snapshotted, not disconnected. Add them when a
27
+ // project needs them, not before.
28
+ //
29
+ // Recording lives one layer up. This file is transport only, so `mcp.ts`
30
+ // can record one step per logical operation instead of one per HTTP call.
31
+
32
+ import { rawFetch } from "./harness/raw-fetch.js";
33
+
34
+ /** A JSON-RPC id. The client only ever mints numbers. */
35
+ export type JsonRpcId = number | string;
36
+
37
+ export interface JsonRpcRequest {
38
+ jsonrpc: "2.0";
39
+ id: JsonRpcId;
40
+ method: string;
41
+ params?: unknown;
42
+ }
43
+
44
+ export interface JsonRpcNotification {
45
+ jsonrpc: "2.0";
46
+ method: string;
47
+ params?: unknown;
48
+ }
49
+
50
+ export interface JsonRpcError {
51
+ code: number;
52
+ message: string;
53
+ data?: unknown;
54
+ }
55
+
56
+ export interface JsonRpcResponse {
57
+ jsonrpc: "2.0";
58
+ id: JsonRpcId;
59
+ result?: unknown;
60
+ error?: JsonRpcError;
61
+ }
62
+
63
+ type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
64
+
65
+ /**
66
+ * The `WWW-Authenticate` challenge an MCP server sends with a `401`.
67
+ *
68
+ * `resourceMetadataUrl` is the entry point of the whole OAuth flow
69
+ * (RFC 9728). A server that omits it forces the client to guess the
70
+ * metadata location from its own URL, which `mcp-auth.ts` does.
71
+ */
72
+ export interface McpChallenge {
73
+ /** The HTTP status that carried the challenge. */
74
+ status: number;
75
+ /** Almost always `Bearer`. */
76
+ scheme: string;
77
+ /** `resource_metadata` parameter — where the protected-resource
78
+ * metadata lives. */
79
+ resourceMetadataUrl?: string;
80
+ /** `scope` parameter, when the server names what it wants. */
81
+ scope?: string;
82
+ /** `error` parameter, e.g. `invalid_token` for an expired token. */
83
+ error?: string;
84
+ /** The header, verbatim. Recorded so a test can assert on it. */
85
+ raw: string;
86
+ }
87
+
88
+ /** An HTTP-level failure from the MCP endpoint. A `401` carries the
89
+ * parsed {@link McpChallenge}, which is what makes "the server rejected
90
+ * this call" assertable in a test. */
91
+ export class McpHttpError extends Error {
92
+ readonly status: number;
93
+ readonly challenge?: McpChallenge;
94
+ readonly body?: string;
95
+
96
+ constructor(status: number, message: string, challenge?: McpChallenge, body?: string) {
97
+ super(message);
98
+ this.name = "McpHttpError";
99
+ this.status = status;
100
+ this.challenge = challenge;
101
+ this.body = body;
102
+ }
103
+ }
104
+
105
+ /** A JSON-RPC error result. The request reached the server and the server
106
+ * answered with an error object. */
107
+ export class McpRpcError extends Error {
108
+ readonly code: number;
109
+ readonly data?: unknown;
110
+
111
+ constructor(error: JsonRpcError) {
112
+ super(error.message);
113
+ this.name = "McpRpcError";
114
+ this.code = error.code;
115
+ this.data = error.data;
116
+ }
117
+ }
118
+
119
+ /** Parse a `WWW-Authenticate` header. Returns `undefined` when there is
120
+ * no header, so a bare `401` still reports a status with no challenge. */
121
+ export function parseChallenge(header: string | null, status: number): McpChallenge | undefined {
122
+ if (!header) return undefined;
123
+ const scheme = header.split(/[\s,]/, 1)[0] ?? "Bearer";
124
+ const params: Record<string, string> = {};
125
+ // `name="value"` pairs, or `name=value` for the unquoted form some
126
+ // servers emit. Values may hold commas, so match on the quotes first.
127
+ const re = /([A-Za-z_-]+)\s*=\s*(?:"([^"]*)"|([^\s,]+))/g;
128
+ for (let m = re.exec(header); m !== null; m = re.exec(header)) {
129
+ params[m[1]!.toLowerCase()] = m[2] ?? m[3] ?? "";
130
+ }
131
+ return {
132
+ status,
133
+ scheme,
134
+ resourceMetadataUrl: params["resource_metadata"],
135
+ scope: params["scope"],
136
+ error: params["error"],
137
+ raw: header,
138
+ };
139
+ }
140
+
141
+ /** One HTTP exchange, for the step detail panel. Bodies are the parsed
142
+ * JSON-RPC messages, not raw text. */
143
+ export interface HttpExchange {
144
+ method: string;
145
+ url: string;
146
+ status: number;
147
+ durationMs: number;
148
+ sessionId?: string;
149
+ /** Set when the reply was an SSE stream rather than a JSON body. */
150
+ streamed?: boolean;
151
+ }
152
+
153
+ export interface McpTransportOptions {
154
+ url: string;
155
+ /** Extra headers on every request (a static API key, a tenant id). */
156
+ headers?: Record<string, string>;
157
+ /** Read at call time, so a token minted mid-session applies at once. */
158
+ token?: () => string | undefined;
159
+ /** Default per-request budget. */
160
+ timeoutMs?: number;
161
+ /** Called for every server notification. */
162
+ onNotification?: (n: JsonRpcNotification) => void;
163
+ /** Called for every server-to-client request. Resolve with the result,
164
+ * or throw to answer with a JSON-RPC error. */
165
+ onRequest?: (r: JsonRpcRequest) => Promise<unknown>;
166
+ /** Called after every HTTP exchange, for the recorder. */
167
+ onExchange?: (x: HttpExchange) => void;
168
+ }
169
+
170
+ const DEFAULT_TIMEOUT_MS = 30_000;
171
+
172
+ /** The revision we speak. A server that wants an older one negotiates it
173
+ * in the `initialize` result and we echo whatever it chose. */
174
+ export const PROTOCOL_VERSION = "2025-06-18";
175
+
176
+ export class McpTransport {
177
+ readonly url: string;
178
+ /** Issued by the server on `initialize`, echoed on every later request.
179
+ * Absent for a stateless server, which is legal. */
180
+ sessionId?: string;
181
+ /** Negotiated on `initialize`. Sent on every request after that. */
182
+ protocolVersion?: string;
183
+
184
+ private nextId = 1;
185
+ private readonly opts: McpTransportOptions;
186
+
187
+ constructor(opts: McpTransportOptions) {
188
+ this.opts = opts;
189
+ this.url = opts.url;
190
+ }
191
+
192
+ /** Send a request and resolve with its result. Throws
193
+ * {@link McpHttpError} for a transport failure and {@link McpRpcError}
194
+ * for a JSON-RPC error result. */
195
+ async request<T = unknown>(
196
+ method: string,
197
+ params?: unknown,
198
+ opts?: { timeoutMs?: number },
199
+ ): Promise<T> {
200
+ const id = this.nextId++;
201
+ const message: JsonRpcRequest = { jsonrpc: "2.0", id, method, params };
202
+ const res = await this.post(message, opts?.timeoutMs);
203
+
204
+ const response = await this.readResponse(res, id);
205
+ if (response.error) throw new McpRpcError(response.error);
206
+ return response.result as T;
207
+ }
208
+
209
+ /** Send a notification. Nothing comes back. */
210
+ async notify(method: string, params?: unknown, opts?: { timeoutMs?: number }): Promise<void> {
211
+ const message: JsonRpcNotification = { jsonrpc: "2.0", method, params };
212
+ const res = await this.post(message, opts?.timeoutMs);
213
+ // A notification gets `202 Accepted` with no body. Drain anything the
214
+ // server sent anyway so the connection can be reused.
215
+ await res.text().catch(() => "");
216
+ }
217
+
218
+ /** End the session. Best-effort: a server that does not support
219
+ * `DELETE` answers 405, which is not an error for us. */
220
+ async close(): Promise<void> {
221
+ if (!this.sessionId) return;
222
+ try {
223
+ const res = await rawFetch(this.url, { method: "DELETE", headers: this.headers() });
224
+ await res.text().catch(() => "");
225
+ } catch {
226
+ // The peer is gone. Nothing to end.
227
+ }
228
+ this.sessionId = undefined;
229
+ }
230
+
231
+ private headers(extra?: Record<string, string>): Record<string, string> {
232
+ const h: Record<string, string> = {
233
+ accept: "application/json, text/event-stream",
234
+ ...this.opts.headers,
235
+ ...extra,
236
+ };
237
+ const token = this.opts.token?.();
238
+ if (token) h["authorization"] = `Bearer ${token}`;
239
+ if (this.sessionId) h["mcp-session-id"] = this.sessionId;
240
+ if (this.protocolVersion) h["mcp-protocol-version"] = this.protocolVersion;
241
+ return h;
242
+ }
243
+
244
+ private async post(message: JsonRpcMessage, timeoutMs?: number): Promise<Response> {
245
+ const started = Date.now();
246
+ const budget = timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
247
+ const controller = new AbortController();
248
+ const timer = setTimeout(() => controller.abort(), budget);
249
+
250
+ let res: Response;
251
+ try {
252
+ res = await rawFetch(this.url, {
253
+ method: "POST",
254
+ headers: this.headers({ "content-type": "application/json" }),
255
+ body: JSON.stringify(message),
256
+ signal: controller.signal,
257
+ });
258
+ } catch (err) {
259
+ clearTimeout(timer);
260
+ const method = "method" in message ? message.method : "response";
261
+ if (controller.signal.aborted) {
262
+ throw new Error(`MCP request ${method} to ${this.url} timed out after ${budget} ms`);
263
+ }
264
+ throw err;
265
+ }
266
+ clearTimeout(timer);
267
+
268
+ // The session id is issued on `initialize` and must be echoed from
269
+ // then on. Read it off every reply: a server may rotate it.
270
+ const issued = res.headers.get("mcp-session-id");
271
+ if (issued) this.sessionId = issued;
272
+
273
+ this.opts.onExchange?.({
274
+ method: "POST",
275
+ url: this.url,
276
+ status: res.status,
277
+ durationMs: Date.now() - started,
278
+ sessionId: this.sessionId,
279
+ streamed: (res.headers.get("content-type") ?? "").includes("text/event-stream"),
280
+ });
281
+
282
+ if (!res.ok) {
283
+ const body = await res.text().catch(() => "");
284
+ const challenge = parseChallenge(res.headers.get("www-authenticate"), res.status);
285
+ const method = "method" in message ? message.method : "response";
286
+ throw new McpHttpError(
287
+ res.status,
288
+ `MCP ${method} failed: HTTP ${res.status}${body ? ` — ${truncate(body)}` : ""}`,
289
+ challenge,
290
+ body,
291
+ );
292
+ }
293
+ return res;
294
+ }
295
+
296
+ /** Read the reply to request `id`. The body is either one JSON-RPC
297
+ * response, or an SSE stream that carries it — possibly after
298
+ * server-to-client traffic that belongs to the same request. */
299
+ private async readResponse(res: Response, id: JsonRpcId): Promise<JsonRpcResponse> {
300
+ const contentType = res.headers.get("content-type") ?? "";
301
+ if (!contentType.includes("text/event-stream")) {
302
+ const text = await res.text();
303
+ const parsed = JSON.parse(text) as JsonRpcMessage | JsonRpcMessage[];
304
+ const messages = Array.isArray(parsed) ? parsed : [parsed];
305
+ for (const m of messages) {
306
+ if (isResponse(m) && m.id === id) return m;
307
+ }
308
+ throw new Error(`MCP reply carried no response for request ${id}`);
309
+ }
310
+
311
+ if (!res.body) throw new Error("MCP reply announced an SSE stream but carried no body");
312
+ for await (const event of readSse(res.body)) {
313
+ let message: JsonRpcMessage;
314
+ try {
315
+ message = JSON.parse(event) as JsonRpcMessage;
316
+ } catch {
317
+ // A comment or a keep-alive frame. Not ours to interpret.
318
+ continue;
319
+ }
320
+ if (isResponse(message)) {
321
+ if (message.id === id) return message;
322
+ continue;
323
+ }
324
+ if (isRequest(message)) {
325
+ // Answer on a separate POST, and keep reading. Awaiting the
326
+ // handler here would stall the stream that carries our own reply.
327
+ void this.answer(message);
328
+ continue;
329
+ }
330
+ this.opts.onNotification?.(message);
331
+ }
332
+ throw new Error(`MCP stream ended before the response to request ${id} arrived`);
333
+ }
334
+
335
+ /** Handle one server-to-client request and post the answer back. */
336
+ private async answer(request: JsonRpcRequest): Promise<void> {
337
+ let response: JsonRpcResponse;
338
+ try {
339
+ if (!this.opts.onRequest) {
340
+ // The server asked for something this client never advertised.
341
+ response = {
342
+ jsonrpc: "2.0",
343
+ id: request.id,
344
+ error: { code: -32601, message: `Method not found: ${request.method}` },
345
+ };
346
+ } else {
347
+ response = { jsonrpc: "2.0", id: request.id, result: await this.opts.onRequest(request) };
348
+ }
349
+ } catch (err) {
350
+ response = {
351
+ jsonrpc: "2.0",
352
+ id: request.id,
353
+ error: { code: -32603, message: (err as Error)?.message ?? String(err) },
354
+ };
355
+ }
356
+ try {
357
+ const res = await this.post(response);
358
+ await res.text().catch(() => "");
359
+ } catch {
360
+ // The server gave up on the request, or the stream is closed. There
361
+ // is nowhere left to report this.
362
+ }
363
+ }
364
+ }
365
+
366
+ function isResponse(m: JsonRpcMessage): m is JsonRpcResponse {
367
+ return "id" in m && !("method" in m);
368
+ }
369
+
370
+ function isRequest(m: JsonRpcMessage): m is JsonRpcRequest {
371
+ return "id" in m && "method" in m;
372
+ }
373
+
374
+ function truncate(s: string, max = 300): string {
375
+ return s.length <= max ? s : `${s.slice(0, max)}…`;
376
+ }
377
+
378
+ /**
379
+ * Yield the `data` payload of each SSE event on a byte stream.
380
+ *
381
+ * Frames are separated by a blank line and a frame's data may span
382
+ * several `data:` lines, which are joined with a newline (WHATWG EventSource).
383
+ * `event:` and `id:` are read and dropped: MCP puts the whole JSON-RPC
384
+ * message in `data`, and we do not resume streams.
385
+ */
386
+ async function* readSse(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
387
+ const reader = body.getReader();
388
+ const decoder = new TextDecoder();
389
+ let buffer = "";
390
+ try {
391
+ for (;;) {
392
+ const { done, value } = await reader.read();
393
+ if (done) break;
394
+ buffer += decoder.decode(value, { stream: true });
395
+ // Accept both LF and CRLF frame separators.
396
+ for (;;) {
397
+ const match = /\r?\n\r?\n/.exec(buffer);
398
+ if (!match) break;
399
+ const frame = buffer.slice(0, match.index);
400
+ buffer = buffer.slice(match.index + match[0].length);
401
+ const data = sseData(frame);
402
+ if (data !== undefined) yield data;
403
+ }
404
+ }
405
+ const tail = sseData(buffer);
406
+ if (tail !== undefined) yield tail;
407
+ } finally {
408
+ reader.cancel().catch(() => {});
409
+ }
410
+ }
411
+
412
+ /** The `data` of one SSE frame, or `undefined` when it carries none.
413
+ * Exported for tests. */
414
+ export function sseData(frame: string): string | undefined {
415
+ const lines = frame.split(/\r?\n/);
416
+ const data: string[] = [];
417
+ for (const line of lines) {
418
+ if (line.startsWith(":")) continue;
419
+ const colon = line.indexOf(":");
420
+ const field = colon === -1 ? line : line.slice(0, colon);
421
+ if (field !== "data") continue;
422
+ let value = colon === -1 ? "" : line.slice(colon + 1);
423
+ if (value.startsWith(" ")) value = value.slice(1);
424
+ data.push(value);
425
+ }
426
+ return data.length > 0 ? data.join("\n") : undefined;
427
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { parseChallenge, sseData } from "./mcp-transport.js";
4
+ import { base64url, canonicalResource, wellKnownUrls } from "./mcp-auth.js";
5
+
6
+ describe("parseChallenge", () => {
7
+ test("reads the resource metadata URL", () => {
8
+ const challenge = parseChallenge(
9
+ 'Bearer resource_metadata="https://api.test/.well-known/oauth-protected-resource"',
10
+ 401,
11
+ );
12
+ expect(challenge?.scheme).toBe("Bearer");
13
+ expect(challenge?.resourceMetadataUrl).toBe(
14
+ "https://api.test/.well-known/oauth-protected-resource",
15
+ );
16
+ expect(challenge?.status).toBe(401);
17
+ });
18
+
19
+ test("reads every parameter, quoted or not", () => {
20
+ const challenge = parseChallenge(
21
+ 'Bearer realm="mcp", error=invalid_token, scope="a b", resource_metadata="https://x/y"',
22
+ 401,
23
+ );
24
+ expect(challenge?.error).toBe("invalid_token");
25
+ expect(challenge?.scope).toBe("a b");
26
+ expect(challenge?.resourceMetadataUrl).toBe("https://x/y");
27
+ });
28
+
29
+ test("a bare 401 has no challenge", () => {
30
+ expect(parseChallenge(null, 401)).toBeUndefined();
31
+ });
32
+ });
33
+
34
+ describe("wellKnownUrls", () => {
35
+ test("an issuer with no path has one spelling", () => {
36
+ expect(wellKnownUrls("https://api.test", "oauth-authorization-server")).toEqual([
37
+ "https://api.test/.well-known/oauth-authorization-server",
38
+ ]);
39
+ });
40
+
41
+ test("a path-carrying issuer inserts the segment first (RFC 8414)", () => {
42
+ // The order matters: RFC 8414 inserts, OpenID Connect appends, and
43
+ // servers differ on which they serve. Supabase serves the inserted
44
+ // form only.
45
+ expect(wellKnownUrls("https://api.test/auth/v1", "oauth-authorization-server")).toEqual([
46
+ "https://api.test/.well-known/oauth-authorization-server/auth/v1",
47
+ "https://api.test/auth/v1/.well-known/oauth-authorization-server",
48
+ "https://api.test/.well-known/oauth-authorization-server",
49
+ ]);
50
+ });
51
+
52
+ test("a trailing slash is not a path", () => {
53
+ expect(wellKnownUrls("https://api.test/", "oauth-protected-resource")).toEqual([
54
+ "https://api.test/.well-known/oauth-protected-resource",
55
+ ]);
56
+ });
57
+ });
58
+
59
+ describe("canonicalResource", () => {
60
+ test("drops the fragment and lowercases the host", () => {
61
+ expect(canonicalResource("https://API.Test/mcp#x")).toBe("https://api.test/mcp");
62
+ });
63
+ });
64
+
65
+ describe("sseData", () => {
66
+ test("reads one data line", () => {
67
+ expect(sseData('data: {"jsonrpc":"2.0"}')).toBe('{"jsonrpc":"2.0"}');
68
+ });
69
+
70
+ test("joins several data lines with a newline", () => {
71
+ expect(sseData("data: a\ndata: b")).toBe("a\nb");
72
+ });
73
+
74
+ test("ignores the other fields and comments", () => {
75
+ expect(sseData("event: message\nid: 7\n: keep-alive\ndata: x")).toBe("x");
76
+ });
77
+
78
+ test("a frame with no data yields nothing", () => {
79
+ expect(sseData("event: ping")).toBeUndefined();
80
+ });
81
+
82
+ test("only the first space after the colon is stripped", () => {
83
+ expect(sseData("data: padded")).toBe(" padded");
84
+ });
85
+ });
86
+
87
+ describe("base64url", () => {
88
+ test("has no padding and no unsafe characters", () => {
89
+ const encoded = base64url(new Uint8Array([251, 255, 190, 1]));
90
+ expect(encoded).not.toContain("=");
91
+ expect(encoded).not.toContain("+");
92
+ expect(encoded).not.toContain("/");
93
+ });
94
+ });