@specific.dev/spectest 0.55.0 → 0.56.1

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,337 @@
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
+ // It also does not implement the DEPRECATED HTTP+SSE transport
30
+ // (2024-11-05): a long-lived `GET` stream plus a separate POST endpoint
31
+ // named by its first `endpoint` event. A server that speaks only that one
32
+ // answers our `initialize` POST with a `4xx` and this client gives up
33
+ // there. Adding it is a self-contained change — the fallback trigger is
34
+ // that same `4xx` — but nothing needs it yet.
35
+ //
36
+ // Recording lives one layer up. This file is transport only, so `mcp.ts`
37
+ // can record one step per logical operation instead of one per HTTP call.
38
+ import { rawFetch } from "./harness/raw-fetch.js";
39
+ /** An HTTP-level failure from the MCP endpoint. A `401` carries the
40
+ * parsed {@link McpChallenge}, which is what makes "the server rejected
41
+ * this call" assertable in a test. */
42
+ export class McpHttpError extends Error {
43
+ status;
44
+ challenge;
45
+ body;
46
+ constructor(status, message, challenge, body) {
47
+ super(message);
48
+ this.name = "McpHttpError";
49
+ this.status = status;
50
+ this.challenge = challenge;
51
+ this.body = body;
52
+ }
53
+ }
54
+ /** A JSON-RPC error result. The request reached the server and the server
55
+ * answered with an error object. */
56
+ export class McpRpcError extends Error {
57
+ code;
58
+ data;
59
+ constructor(error) {
60
+ super(error.message);
61
+ this.name = "McpRpcError";
62
+ this.code = error.code;
63
+ this.data = error.data;
64
+ }
65
+ }
66
+ /** Parse a `WWW-Authenticate` header. Returns `undefined` when there is
67
+ * no header, so a bare `401` still reports a status with no challenge. */
68
+ export function parseChallenge(header, status) {
69
+ if (!header)
70
+ return undefined;
71
+ const scheme = header.split(/[\s,]/, 1)[0] ?? "Bearer";
72
+ const params = {};
73
+ // `name="value"` pairs, or `name=value` for the unquoted form some
74
+ // servers emit. Values may hold commas, so match on the quotes first.
75
+ const re = /([A-Za-z_-]+)\s*=\s*(?:"([^"]*)"|([^\s,]+))/g;
76
+ for (let m = re.exec(header); m !== null; m = re.exec(header)) {
77
+ params[m[1].toLowerCase()] = m[2] ?? m[3] ?? "";
78
+ }
79
+ return {
80
+ status,
81
+ scheme,
82
+ resourceMetadataUrl: params["resource_metadata"],
83
+ scope: params["scope"],
84
+ error: params["error"],
85
+ raw: header,
86
+ };
87
+ }
88
+ const DEFAULT_TIMEOUT_MS = 30_000;
89
+ /** The revision we speak. A server that wants an older one negotiates it
90
+ * in the `initialize` result and we echo whatever it chose. */
91
+ export const PROTOCOL_VERSION = "2025-06-18";
92
+ export class McpTransport {
93
+ url;
94
+ /** Issued by the server on `initialize`, echoed on every later request.
95
+ * Absent for a stateless server, which is legal. */
96
+ sessionId;
97
+ /** Negotiated on `initialize`. Sent on every request after that. */
98
+ protocolVersion;
99
+ nextId = 1;
100
+ opts;
101
+ constructor(opts) {
102
+ this.opts = opts;
103
+ this.url = opts.url;
104
+ }
105
+ /** Send a request and resolve with its result. Throws
106
+ * {@link McpHttpError} for a transport failure and {@link McpRpcError}
107
+ * for a JSON-RPC error result. */
108
+ async request(method, params, opts) {
109
+ const id = this.nextId++;
110
+ const message = { jsonrpc: "2.0", id, method, params };
111
+ const res = await this.post(message, opts?.timeoutMs);
112
+ const response = await this.readResponse(res, id);
113
+ if (response.error)
114
+ throw new McpRpcError(response.error);
115
+ return response.result;
116
+ }
117
+ /** Send a notification. Nothing comes back. */
118
+ async notify(method, params, opts) {
119
+ const message = { jsonrpc: "2.0", method, params };
120
+ const res = await this.post(message, opts?.timeoutMs);
121
+ // A notification gets `202 Accepted` with no body. Drain anything the
122
+ // server sent anyway so the connection can be reused.
123
+ await res.text().catch(() => "");
124
+ }
125
+ /** End the session. Best-effort: a server that does not support
126
+ * `DELETE` answers 405, which is not an error for us. */
127
+ async close() {
128
+ if (!this.sessionId)
129
+ return;
130
+ try {
131
+ const res = await rawFetch(this.url, { method: "DELETE", headers: this.headers() });
132
+ await res.text().catch(() => "");
133
+ }
134
+ catch {
135
+ // The peer is gone. Nothing to end.
136
+ }
137
+ this.sessionId = undefined;
138
+ }
139
+ headers(extra) {
140
+ const h = {
141
+ accept: "application/json, text/event-stream",
142
+ ...this.opts.headers,
143
+ ...extra,
144
+ };
145
+ const token = this.opts.token?.();
146
+ if (token)
147
+ h["authorization"] = `Bearer ${token}`;
148
+ if (this.sessionId)
149
+ h["mcp-session-id"] = this.sessionId;
150
+ if (this.protocolVersion)
151
+ h["mcp-protocol-version"] = this.protocolVersion;
152
+ return h;
153
+ }
154
+ async post(message, timeoutMs) {
155
+ const started = Date.now();
156
+ const budget = timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
157
+ const controller = new AbortController();
158
+ const timer = setTimeout(() => controller.abort(), budget);
159
+ let res;
160
+ try {
161
+ res = await rawFetch(this.url, {
162
+ method: "POST",
163
+ headers: this.headers({ "content-type": "application/json" }),
164
+ body: JSON.stringify(message),
165
+ signal: controller.signal,
166
+ });
167
+ }
168
+ catch (err) {
169
+ clearTimeout(timer);
170
+ const method = "method" in message ? message.method : "response";
171
+ if (controller.signal.aborted) {
172
+ throw new Error(`MCP request ${method} to ${this.url} timed out after ${budget} ms`);
173
+ }
174
+ throw err;
175
+ }
176
+ clearTimeout(timer);
177
+ // The session id is issued on `initialize` and must be echoed from
178
+ // then on. Read it off every reply: a server may rotate it.
179
+ const issued = res.headers.get("mcp-session-id");
180
+ if (issued)
181
+ this.sessionId = issued;
182
+ this.opts.onExchange?.({
183
+ method: "POST",
184
+ url: this.url,
185
+ status: res.status,
186
+ durationMs: Date.now() - started,
187
+ sessionId: this.sessionId,
188
+ streamed: (res.headers.get("content-type") ?? "").includes("text/event-stream"),
189
+ });
190
+ if (!res.ok) {
191
+ const body = await res.text().catch(() => "");
192
+ const challenge = parseChallenge(res.headers.get("www-authenticate"), res.status);
193
+ const method = "method" in message ? message.method : "response";
194
+ throw new McpHttpError(res.status, `MCP ${method} failed: HTTP ${res.status}${body ? ` — ${truncate(body)}` : ""}`, challenge, body);
195
+ }
196
+ return res;
197
+ }
198
+ /** Read the reply to request `id`. The body is either one JSON-RPC
199
+ * response, or an SSE stream that carries it — possibly after
200
+ * server-to-client traffic that belongs to the same request. */
201
+ async readResponse(res, id) {
202
+ const contentType = res.headers.get("content-type") ?? "";
203
+ if (!contentType.includes("text/event-stream")) {
204
+ const text = await res.text();
205
+ const parsed = JSON.parse(text);
206
+ const messages = Array.isArray(parsed) ? parsed : [parsed];
207
+ for (const m of messages) {
208
+ if (isResponse(m) && m.id === id)
209
+ return m;
210
+ }
211
+ throw new Error(`MCP reply carried no response for request ${id}`);
212
+ }
213
+ if (!res.body)
214
+ throw new Error("MCP reply announced an SSE stream but carried no body");
215
+ for await (const event of readSse(res.body)) {
216
+ let message;
217
+ try {
218
+ message = JSON.parse(event);
219
+ }
220
+ catch {
221
+ // A comment or a keep-alive frame. Not ours to interpret.
222
+ continue;
223
+ }
224
+ if (isResponse(message)) {
225
+ if (message.id === id)
226
+ return message;
227
+ continue;
228
+ }
229
+ if (isRequest(message)) {
230
+ // Answer on a separate POST, and keep reading. Awaiting the
231
+ // handler here would stall the stream that carries our own reply.
232
+ void this.answer(message);
233
+ continue;
234
+ }
235
+ this.opts.onNotification?.(message);
236
+ }
237
+ throw new Error(`MCP stream ended before the response to request ${id} arrived`);
238
+ }
239
+ /** Handle one server-to-client request and post the answer back. */
240
+ async answer(request) {
241
+ let response;
242
+ try {
243
+ if (!this.opts.onRequest) {
244
+ // The server asked for something this client never advertised.
245
+ response = {
246
+ jsonrpc: "2.0",
247
+ id: request.id,
248
+ error: { code: -32601, message: `Method not found: ${request.method}` },
249
+ };
250
+ }
251
+ else {
252
+ response = { jsonrpc: "2.0", id: request.id, result: await this.opts.onRequest(request) };
253
+ }
254
+ }
255
+ catch (err) {
256
+ response = {
257
+ jsonrpc: "2.0",
258
+ id: request.id,
259
+ error: { code: -32603, message: err?.message ?? String(err) },
260
+ };
261
+ }
262
+ try {
263
+ const res = await this.post(response);
264
+ await res.text().catch(() => "");
265
+ }
266
+ catch {
267
+ // The server gave up on the request, or the stream is closed. There
268
+ // is nowhere left to report this.
269
+ }
270
+ }
271
+ }
272
+ function isResponse(m) {
273
+ return "id" in m && !("method" in m);
274
+ }
275
+ function isRequest(m) {
276
+ return "id" in m && "method" in m;
277
+ }
278
+ function truncate(s, max = 300) {
279
+ return s.length <= max ? s : `${s.slice(0, max)}…`;
280
+ }
281
+ /**
282
+ * Yield the `data` payload of each SSE event on a byte stream.
283
+ *
284
+ * Frames are separated by a blank line and a frame's data may span
285
+ * several `data:` lines, which are joined with a newline (WHATWG EventSource).
286
+ * `event:` and `id:` are read and dropped: MCP puts the whole JSON-RPC
287
+ * message in `data`, and we do not resume streams.
288
+ */
289
+ async function* readSse(body) {
290
+ const reader = body.getReader();
291
+ const decoder = new TextDecoder();
292
+ let buffer = "";
293
+ try {
294
+ for (;;) {
295
+ const { done, value } = await reader.read();
296
+ if (done)
297
+ break;
298
+ buffer += decoder.decode(value, { stream: true });
299
+ // Accept both LF and CRLF frame separators.
300
+ for (;;) {
301
+ const match = /\r?\n\r?\n/.exec(buffer);
302
+ if (!match)
303
+ break;
304
+ const frame = buffer.slice(0, match.index);
305
+ buffer = buffer.slice(match.index + match[0].length);
306
+ const data = sseData(frame);
307
+ if (data !== undefined)
308
+ yield data;
309
+ }
310
+ }
311
+ const tail = sseData(buffer);
312
+ if (tail !== undefined)
313
+ yield tail;
314
+ }
315
+ finally {
316
+ reader.cancel().catch(() => { });
317
+ }
318
+ }
319
+ /** The `data` of one SSE frame, or `undefined` when it carries none.
320
+ * Exported for tests. */
321
+ export function sseData(frame) {
322
+ const lines = frame.split(/\r?\n/);
323
+ const data = [];
324
+ for (const line of lines) {
325
+ if (line.startsWith(":"))
326
+ continue;
327
+ const colon = line.indexOf(":");
328
+ const field = colon === -1 ? line : line.slice(0, colon);
329
+ if (field !== "data")
330
+ continue;
331
+ let value = colon === -1 ? "" : line.slice(colon + 1);
332
+ if (value.startsWith(" "))
333
+ value = value.slice(1);
334
+ data.push(value);
335
+ }
336
+ return data.length > 0 ? data.join("\n") : undefined;
337
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,246 @@
1
+ import { type Wrapped } from "./inspect.js";
2
+ import { type McpChallenge } from "./mcp-transport.js";
3
+ import { type AuthorizationServerInfo, type AuthorizeOptions, type McpIdentity } from "./mcp-auth.js";
4
+ export { McpHttpError, McpRpcError, type McpChallenge } from "./mcp-transport.js";
5
+ export { McpAuthDeniedError, type AuthorizeOptions, type AuthorizationServerInfo, type McpIdentity, } from "./mcp-auth.js";
6
+ /** One part of a tool result or a resource read. The open member keeps a
7
+ * content type this SDK predates from being dropped. */
8
+ export type McpContent = {
9
+ type: "text";
10
+ text: string;
11
+ } | {
12
+ type: "image";
13
+ data: string;
14
+ mimeType: string;
15
+ } | {
16
+ type: "audio";
17
+ data: string;
18
+ mimeType: string;
19
+ } | {
20
+ type: "resource";
21
+ resource: McpResourceContents;
22
+ } | {
23
+ type: "resource_link";
24
+ uri: string;
25
+ name?: string;
26
+ mimeType?: string;
27
+ } | {
28
+ type: string;
29
+ [key: string]: unknown;
30
+ };
31
+ export interface McpResourceContents {
32
+ uri: string;
33
+ mimeType?: string;
34
+ /** Text resources carry `text`; binary ones carry base64 `blob`. */
35
+ text?: string;
36
+ blob?: string;
37
+ }
38
+ export interface McpToolInfo {
39
+ name: string;
40
+ title?: string;
41
+ description?: string;
42
+ inputSchema?: unknown;
43
+ outputSchema?: unknown;
44
+ }
45
+ export interface McpResourceInfo {
46
+ uri: string;
47
+ name?: string;
48
+ title?: string;
49
+ description?: string;
50
+ mimeType?: string;
51
+ }
52
+ export interface McpPromptInfo {
53
+ name: string;
54
+ title?: string;
55
+ description?: string;
56
+ arguments?: {
57
+ name: string;
58
+ description?: string;
59
+ required?: boolean;
60
+ }[];
61
+ }
62
+ export interface McpPromptResult {
63
+ description?: string;
64
+ messages: {
65
+ role: string;
66
+ content: McpContent;
67
+ }[];
68
+ }
69
+ /**
70
+ * What a tool call returned.
71
+ *
72
+ * Every field is provenance-wrapped, like a `ctx.fetch` response, so an
73
+ * assertion on it nests under the call in the timeline. That also means a
74
+ * field is a HANDLE, not the value: `expect(res.isError).toBe(true)` is
75
+ * the way to check it, and code that must branch calls
76
+ * `res.isError.unwrap()`. A bare `if (res.isError)` is always true,
77
+ * because a handle is an object — the same rule `res.ok` follows on a
78
+ * wrapped `fetch` response.
79
+ */
80
+ export interface McpToolResult<T = unknown> {
81
+ /** True when the TOOL failed — an error the model is meant to read. A
82
+ * protocol or transport failure throws instead. */
83
+ isError: Wrapped<boolean>;
84
+ /** Every content part, in order. */
85
+ content: Wrapped<McpContent[]>;
86
+ /** The text parts, joined with a newline. The usual thing to assert on. */
87
+ text: Wrapped<string>;
88
+ /** `structuredContent`, when the tool declares an output schema. */
89
+ structured?: Wrapped<T>;
90
+ /** `structuredContent` if the tool returned it, else the text parsed as
91
+ * JSON. Throws when the text is not JSON. */
92
+ json<U = T>(): Wrapped<U>;
93
+ /** The whole result, with no provenance wrappers. */
94
+ unwrap(): {
95
+ isError: boolean;
96
+ content: McpContent[];
97
+ structuredContent?: T;
98
+ };
99
+ }
100
+ /** A notification the server sent, with the moment it arrived. */
101
+ export interface McpNotification {
102
+ method: string;
103
+ params?: unknown;
104
+ /** Milliseconds since this client was created. */
105
+ atMs: number;
106
+ }
107
+ /** A server-to-client sampling request (`sampling/createMessage`): the
108
+ * server is asking the client's model to generate something. */
109
+ export interface McpSamplingRequest {
110
+ messages: {
111
+ role: string;
112
+ content: McpContent;
113
+ }[];
114
+ systemPrompt?: string;
115
+ maxTokens?: number;
116
+ [key: string]: unknown;
117
+ }
118
+ /** What a test answers a sampling request with. Return a string for the
119
+ * common "the model said this" case. */
120
+ export type McpSamplingResult = string | {
121
+ text?: string;
122
+ content?: McpContent;
123
+ model?: string;
124
+ stopReason?: string;
125
+ role?: string;
126
+ };
127
+ export interface McpOptions {
128
+ /** A static bearer token. For a server that issues API keys rather than
129
+ * running an OAuth flow. */
130
+ bearer?: string;
131
+ /** Extra headers on every request (an API key, a tenant id). */
132
+ headers?: Record<string, string>;
133
+ /** Per-request budget. Default 30 s. */
134
+ timeoutMs?: number;
135
+ /**
136
+ * Prefix for this client's step titles. Display only — it keys nothing.
137
+ * Worth setting when one test drives two clients; otherwise leave it
138
+ * out and the steps read as the tool names they are.
139
+ */
140
+ label?: string;
141
+ /** What this client calls itself in the `initialize` handshake. */
142
+ clientInfo?: {
143
+ name: string;
144
+ version: string;
145
+ };
146
+ /** Roots to advertise, as URIs or `{ uri, name }`. */
147
+ roots?: (string | {
148
+ uri: string;
149
+ name?: string;
150
+ })[];
151
+ /** Answer `sampling/createMessage`. Declaring it advertises the
152
+ * capability, so a server that asks gets a deterministic reply instead
153
+ * of a "method not found". */
154
+ sampling?: (req: McpSamplingRequest) => McpSamplingResult | Promise<McpSamplingResult>;
155
+ }
156
+ /** The in-progress authorization a test drives with its own browser. */
157
+ export interface McpAuthorization {
158
+ /**
159
+ * Send the browser here.
160
+ *
161
+ * This and the other inputs below are raw strings, not wrapped handles:
162
+ * they are arguments to `page.goto(...)` and to your own code, and a
163
+ * handle would break at the call. What the flow FOUND — {@link server},
164
+ * and the {@link McpIdentity} that {@link complete} returns — is wrapped
165
+ * and is what a test asserts on.
166
+ */
167
+ readonly url: string;
168
+ readonly redirectUri: string;
169
+ readonly state: string;
170
+ readonly clientId: string;
171
+ /** What discovery found, wrapped against the `authorize` step. */
172
+ readonly server: Wrapped<AuthorizationServerInfo>;
173
+ readonly scopes: string[];
174
+ readonly resource?: string;
175
+ /**
176
+ * Wait for the redirect, exchange the code, and connect the client.
177
+ *
178
+ * Nothing to pass with the default loopback redirect. Pass `url` when
179
+ * the flow used a custom `redirectUri` that this daemon does not serve:
180
+ * hand back where the browser landed (`page.url()`).
181
+ */
182
+ complete(opts?: {
183
+ url?: string;
184
+ timeoutMs?: number;
185
+ }): Promise<Wrapped<McpIdentity>>;
186
+ /** Give up and release the loopback port. */
187
+ cancel(): void;
188
+ }
189
+ /** How a server names itself in the handshake. */
190
+ export interface McpServerInfo {
191
+ name: string;
192
+ version: string;
193
+ title?: string;
194
+ }
195
+ export interface Mcp {
196
+ readonly url: string;
197
+ /** `serverInfo` from the handshake. Absent until the client connects.
198
+ * Wrapped against the handshake step, so an assertion on it nests
199
+ * there. */
200
+ readonly serverInfo?: Wrapped<McpServerInfo>;
201
+ /** What the server said it can do. */
202
+ readonly capabilities?: Wrapped<Record<string, unknown>>;
203
+ /** The negotiated protocol revision. */
204
+ readonly protocolVersion?: Wrapped<string>;
205
+ /** The server's `Mcp-Session-Id`, when it issues one. */
206
+ readonly sessionId?: Wrapped<string>;
207
+ /** True once a handshake has succeeded. A plain boolean: this is the
208
+ * client's own state, not something a server op produced. */
209
+ readonly connected: boolean;
210
+ /** The last `401` challenge this client saw, parsed. Wrapped against
211
+ * the step that was refused. */
212
+ readonly challenge?: Wrapped<McpChallenge>;
213
+ /** The grant, once an authorization completed. */
214
+ readonly identity?: Wrapped<McpIdentity>;
215
+ /** Instructions the server offers a client, when it does. */
216
+ readonly instructions?: Wrapped<string>;
217
+ /** Begin an OAuth flow. Does discovery, registration and PKCE, and
218
+ * returns the URL to send a browser to. */
219
+ authorize(opts?: AuthorizeOptions): Promise<McpAuthorization>;
220
+ tools(): Promise<Wrapped<McpToolInfo[]>>;
221
+ call<T = unknown>(name: string, args?: Record<string, unknown>, opts?: {
222
+ timeoutMs?: number;
223
+ }): Promise<McpToolResult<T>>;
224
+ resources(): Promise<Wrapped<McpResourceInfo[]>>;
225
+ read(uri: string): Promise<Wrapped<McpResourceContents[]>>;
226
+ prompts(): Promise<Wrapped<McpPromptInfo[]>>;
227
+ prompt(name: string, args?: Record<string, string>): Promise<Wrapped<McpPromptResult>>;
228
+ /**
229
+ * Everything the server has pushed so far, oldest first.
230
+ *
231
+ * Each one is wrapped against its own step, so an assertion on a
232
+ * notification's params nests under the notification that carried them.
233
+ * The array itself is plain, so `.length` is a number.
234
+ */
235
+ notifications(filter?: string | RegExp): Wrapped<McpNotification>[];
236
+ /** End the session (`DELETE`). Tests do not need to call this. */
237
+ close(): Promise<void>;
238
+ }
239
+ /**
240
+ * Connect to an MCP server over streamable HTTP.
241
+ *
242
+ * Does not throw when the server answers the handshake with `401`: that
243
+ * response is the documented start of the OAuth flow, and it is kept on
244
+ * the client as {@link Mcp.challenge}. Any other failure throws.
245
+ */
246
+ export declare function openMcp(url: string, opts?: McpOptions): Promise<Mcp>;