@specific.dev/spectest 0.54.1 → 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.
package/src/mcp.ts ADDED
@@ -0,0 +1,1435 @@
1
+ // `ctx.mcp(url)` — drive a Model Context Protocol server from a test.
2
+ //
3
+ // An MCP server is the interface an application exposes to an AI client:
4
+ // tools it can call, resources it can read, prompts it can fill in. This
5
+ // module is the client half. A test connects to the server under test,
6
+ // calls its tools like a real client would, and asserts on what comes
7
+ // back — and every call renders on the timeline as its own step, with the
8
+ // arguments and the result, instead of as raw HTTP.
9
+ //
10
+ // ## No session registry, and no names
11
+ //
12
+ // `ctx.mcp(url)` connects and returns a NEW client every time. There is no
13
+ // per-name registry like `ctx.browser("alice")` has. A client that has
14
+ // authenticated is passed to the tests that need it, as the test's return
15
+ // value:
16
+ //
17
+ // export const signedIn = env.test("sign in", async (ctx) => {
18
+ // const mcp = await ctx.mcp(URL);
19
+ // …authorize…
20
+ // return mcp; // ctx.parent, for every child
21
+ // });
22
+ //
23
+ // env.test("call a tool", { dependsOn: signedIn }, async (ctx) => {
24
+ // await ctx.parent.call("create_invoice", { amount: 250 });
25
+ // });
26
+ //
27
+ // That works because a test's return value crosses the fork in daemon
28
+ // memory, live connections included. Two identities are two calls to
29
+ // `ctx.mcp`, with no naming concept at all.
30
+ //
31
+ // ## The client does not police the server
32
+ //
33
+ // `call()` sends the request with whatever credentials it holds, including
34
+ // none. A protected server answers `401` and that is what the test sees —
35
+ // an {@link McpHttpError} carrying the parsed challenge. The client never
36
+ // refuses to try, because "the server rejects an unauthenticated call" is
37
+ // a thing tests must be able to prove.
38
+ //
39
+ // ## Authentication
40
+ //
41
+ // The SDK owns the protocol (discovery, registration, PKCE, the RFC 8707
42
+ // `resource` binding, the token exchange, refresh — all in `mcp-auth.ts`).
43
+ // The test owns the human: it drives the login and the consent screen in
44
+ // its own browser. Nothing here guesses at a button.
45
+ //
46
+ // const auth = await mcp.authorize();
47
+ // await page.goto(auth.url);
48
+ // …sign in, click Allow…
49
+ // await auth.complete();
50
+ //
51
+ // ## Recording
52
+ //
53
+ // Each operation records one `mcp` step in the dashboard's generic
54
+ // presentation vocabulary (`crates/control-plane/src/web/blocks.rs`), so
55
+ // no server change was needed to render any of this. HTTP is done with
56
+ // recording PAUSED — otherwise every JSON-RPC round trip would also land
57
+ // as an `http` row and bury the step it belongs to. Tokens are redacted
58
+ // before an event exists.
59
+
60
+ import { pauseRecording, recordStep, reserveEvent, resumeRecording, type StepBlock } from "./recorder.js";
61
+ import { wrap, type Wrapped } from "./inspect.js";
62
+ import {
63
+ McpHttpError,
64
+ McpRpcError,
65
+ McpTransport,
66
+ PROTOCOL_VERSION,
67
+ type HttpExchange,
68
+ type JsonRpcNotification,
69
+ type JsonRpcRequest,
70
+ type McpChallenge,
71
+ } from "./mcp-transport.js";
72
+ import {
73
+ authorize as beginAuthorization,
74
+ refreshIdentity,
75
+ type AuthorizationServerInfo,
76
+ type AuthorizeOptions,
77
+ type McpIdentity,
78
+ } from "./mcp-auth.js";
79
+
80
+ export { McpHttpError, McpRpcError, type McpChallenge } from "./mcp-transport.js";
81
+ export {
82
+ McpAuthDeniedError,
83
+ type AuthorizeOptions,
84
+ type AuthorizationServerInfo,
85
+ type McpIdentity,
86
+ } from "./mcp-auth.js";
87
+
88
+ /** One part of a tool result or a resource read. The open member keeps a
89
+ * content type this SDK predates from being dropped. */
90
+ export type McpContent =
91
+ | { type: "text"; text: string }
92
+ | { type: "image"; data: string; mimeType: string }
93
+ | { type: "audio"; data: string; mimeType: string }
94
+ | { type: "resource"; resource: McpResourceContents }
95
+ | { type: "resource_link"; uri: string; name?: string; mimeType?: string }
96
+ | { type: string; [key: string]: unknown };
97
+
98
+ export interface McpResourceContents {
99
+ uri: string;
100
+ mimeType?: string;
101
+ /** Text resources carry `text`; binary ones carry base64 `blob`. */
102
+ text?: string;
103
+ blob?: string;
104
+ }
105
+
106
+ export interface McpToolInfo {
107
+ name: string;
108
+ title?: string;
109
+ description?: string;
110
+ inputSchema?: unknown;
111
+ outputSchema?: unknown;
112
+ }
113
+
114
+ export interface McpResourceInfo {
115
+ uri: string;
116
+ name?: string;
117
+ title?: string;
118
+ description?: string;
119
+ mimeType?: string;
120
+ }
121
+
122
+ export interface McpPromptInfo {
123
+ name: string;
124
+ title?: string;
125
+ description?: string;
126
+ arguments?: { name: string; description?: string; required?: boolean }[];
127
+ }
128
+
129
+ export interface McpPromptResult {
130
+ description?: string;
131
+ messages: { role: string; content: McpContent }[];
132
+ }
133
+
134
+ /**
135
+ * What a tool call returned.
136
+ *
137
+ * Every field is provenance-wrapped, like a `ctx.fetch` response, so an
138
+ * assertion on it nests under the call in the timeline. That also means a
139
+ * field is a HANDLE, not the value: `expect(res.isError).toBe(true)` is
140
+ * the way to check it, and code that must branch calls
141
+ * `res.isError.unwrap()`. A bare `if (res.isError)` is always true,
142
+ * because a handle is an object — the same rule `res.ok` follows on a
143
+ * wrapped `fetch` response.
144
+ */
145
+ export interface McpToolResult<T = unknown> {
146
+ /** True when the TOOL failed — an error the model is meant to read. A
147
+ * protocol or transport failure throws instead. */
148
+ isError: Wrapped<boolean>;
149
+ /** Every content part, in order. */
150
+ content: Wrapped<McpContent[]>;
151
+ /** The text parts, joined with a newline. The usual thing to assert on. */
152
+ text: Wrapped<string>;
153
+ /** `structuredContent`, when the tool declares an output schema. */
154
+ structured?: Wrapped<T>;
155
+ /** `structuredContent` if the tool returned it, else the text parsed as
156
+ * JSON. Throws when the text is not JSON. */
157
+ json<U = T>(): Wrapped<U>;
158
+ /** The whole result, with no provenance wrappers. */
159
+ unwrap(): { isError: boolean; content: McpContent[]; structuredContent?: T };
160
+ }
161
+
162
+ /** A notification the server sent, with the moment it arrived. */
163
+ export interface McpNotification {
164
+ method: string;
165
+ params?: unknown;
166
+ /** Milliseconds since this client was created. */
167
+ atMs: number;
168
+ }
169
+
170
+ /** A server-to-client sampling request (`sampling/createMessage`): the
171
+ * server is asking the client's model to generate something. */
172
+ export interface McpSamplingRequest {
173
+ messages: { role: string; content: McpContent }[];
174
+ systemPrompt?: string;
175
+ maxTokens?: number;
176
+ [key: string]: unknown;
177
+ }
178
+
179
+ /** What a test answers a sampling request with. Return a string for the
180
+ * common "the model said this" case. */
181
+ export type McpSamplingResult =
182
+ | string
183
+ | { text?: string; content?: McpContent; model?: string; stopReason?: string; role?: string };
184
+
185
+ export interface McpOptions {
186
+ /** A static bearer token. For a server that issues API keys rather than
187
+ * running an OAuth flow. */
188
+ bearer?: string;
189
+ /** Extra headers on every request (an API key, a tenant id). */
190
+ headers?: Record<string, string>;
191
+ /** Per-request budget. Default 30 s. */
192
+ timeoutMs?: number;
193
+ /**
194
+ * Prefix for this client's step titles. Display only — it keys nothing.
195
+ * Worth setting when one test drives two clients; otherwise leave it
196
+ * out and the steps read as the tool names they are.
197
+ */
198
+ label?: string;
199
+ /** What this client calls itself in the `initialize` handshake. */
200
+ clientInfo?: { name: string; version: string };
201
+ /** Roots to advertise, as URIs or `{ uri, name }`. */
202
+ roots?: (string | { uri: string; name?: string })[];
203
+ /** Answer `sampling/createMessage`. Declaring it advertises the
204
+ * capability, so a server that asks gets a deterministic reply instead
205
+ * of a "method not found". */
206
+ sampling?: (req: McpSamplingRequest) => McpSamplingResult | Promise<McpSamplingResult>;
207
+ }
208
+
209
+ /** The in-progress authorization a test drives with its own browser. */
210
+ export interface McpAuthorization {
211
+ /**
212
+ * Send the browser here.
213
+ *
214
+ * This and the other inputs below are raw strings, not wrapped handles:
215
+ * they are arguments to `page.goto(...)` and to your own code, and a
216
+ * handle would break at the call. What the flow FOUND — {@link server},
217
+ * and the {@link McpIdentity} that {@link complete} returns — is wrapped
218
+ * and is what a test asserts on.
219
+ */
220
+ readonly url: string;
221
+ readonly redirectUri: string;
222
+ readonly state: string;
223
+ readonly clientId: string;
224
+ /** What discovery found, wrapped against the `authorize` step. */
225
+ readonly server: Wrapped<AuthorizationServerInfo>;
226
+ readonly scopes: string[];
227
+ readonly resource?: string;
228
+ /**
229
+ * Wait for the redirect, exchange the code, and connect the client.
230
+ *
231
+ * Nothing to pass with the default loopback redirect. Pass `url` when
232
+ * the flow used a custom `redirectUri` that this daemon does not serve:
233
+ * hand back where the browser landed (`page.url()`).
234
+ */
235
+ complete(opts?: { url?: string; timeoutMs?: number }): Promise<Wrapped<McpIdentity>>;
236
+ /** Give up and release the loopback port. */
237
+ cancel(): void;
238
+ }
239
+
240
+ /** How a server names itself in the handshake. */
241
+ export interface McpServerInfo {
242
+ name: string;
243
+ version: string;
244
+ title?: string;
245
+ }
246
+
247
+ export interface Mcp {
248
+ readonly url: string;
249
+ /** `serverInfo` from the handshake. Absent until the client connects.
250
+ * Wrapped against the handshake step, so an assertion on it nests
251
+ * there. */
252
+ readonly serverInfo?: Wrapped<McpServerInfo>;
253
+ /** What the server said it can do. */
254
+ readonly capabilities?: Wrapped<Record<string, unknown>>;
255
+ /** The negotiated protocol revision. */
256
+ readonly protocolVersion?: Wrapped<string>;
257
+ /** The server's `Mcp-Session-Id`, when it issues one. */
258
+ readonly sessionId?: Wrapped<string>;
259
+ /** True once a handshake has succeeded. A plain boolean: this is the
260
+ * client's own state, not something a server op produced. */
261
+ readonly connected: boolean;
262
+ /** The last `401` challenge this client saw, parsed. Wrapped against
263
+ * the step that was refused. */
264
+ readonly challenge?: Wrapped<McpChallenge>;
265
+ /** The grant, once an authorization completed. */
266
+ readonly identity?: Wrapped<McpIdentity>;
267
+ /** Instructions the server offers a client, when it does. */
268
+ readonly instructions?: Wrapped<string>;
269
+
270
+ /** Begin an OAuth flow. Does discovery, registration and PKCE, and
271
+ * returns the URL to send a browser to. */
272
+ authorize(opts?: AuthorizeOptions): Promise<McpAuthorization>;
273
+
274
+ tools(): Promise<Wrapped<McpToolInfo[]>>;
275
+ call<T = unknown>(
276
+ name: string,
277
+ args?: Record<string, unknown>,
278
+ opts?: { timeoutMs?: number },
279
+ ): Promise<McpToolResult<T>>;
280
+ resources(): Promise<Wrapped<McpResourceInfo[]>>;
281
+ read(uri: string): Promise<Wrapped<McpResourceContents[]>>;
282
+ prompts(): Promise<Wrapped<McpPromptInfo[]>>;
283
+ prompt(name: string, args?: Record<string, string>): Promise<Wrapped<McpPromptResult>>;
284
+
285
+ /**
286
+ * Everything the server has pushed so far, oldest first.
287
+ *
288
+ * Each one is wrapped against its own step, so an assertion on a
289
+ * notification's params nests under the notification that carried them.
290
+ * The array itself is plain, so `.length` is a number.
291
+ */
292
+ notifications(filter?: string | RegExp): Wrapped<McpNotification>[];
293
+ /** End the session (`DELETE`). Tests do not need to call this. */
294
+ close(): Promise<void>;
295
+ }
296
+
297
+ const DEFAULT_CLIENT_INFO = { name: "spectest", version: "1.0.0" };
298
+
299
+ /**
300
+ * Connect to an MCP server over streamable HTTP.
301
+ *
302
+ * Does not throw when the server answers the handshake with `401`: that
303
+ * response is the documented start of the OAuth flow, and it is kept on
304
+ * the client as {@link Mcp.challenge}. Any other failure throws.
305
+ */
306
+ export async function openMcp(url: string, opts: McpOptions = {}): Promise<Mcp> {
307
+ const client = new McpClient(url, opts);
308
+ await client.connectQuietly();
309
+ return client;
310
+ }
311
+
312
+ class McpClient implements Mcp {
313
+ readonly url: string;
314
+ connected = false;
315
+
316
+ // Raw state. Everything a test READS comes back through the getters
317
+ // below, wrapped against the step that produced it — so an assertion on
318
+ // `mcp.serverInfo.name` nests under the handshake, and one on
319
+ // `mcp.challenge` nests under the call that was refused. Internal code
320
+ // uses these fields, never the getters: a wrapped value handed to
321
+ // `fetch` or to a header would be an object, not a string.
322
+ private _serverInfo?: McpServerInfo;
323
+ private _capabilities?: Record<string, unknown>;
324
+ private _protocolVersion?: string;
325
+ private _instructions?: string;
326
+ private _challenge?: McpChallenge;
327
+ private _identity?: McpIdentity;
328
+ /** Seq of the handshake step, the refused step, and the step that
329
+ * completed an authorization. */
330
+ private connectSeq?: number;
331
+ private challengeSeq?: number;
332
+ private identitySeq?: number;
333
+ /** Seq of the step this client recorded last. */
334
+ private lastStepSeq?: number;
335
+ /**
336
+ * The credential the server last refused the handshake for.
337
+ *
338
+ * Without this, an unauthenticated `call()` recorded TWO identical
339
+ * failed steps: one for the handshake it retried, one for the call. The
340
+ * handshake cannot succeed with a credential that was just refused, so
341
+ * the call goes straight out and the server refuses the thing the test
342
+ * actually asked for.
343
+ */
344
+ private refusedToken?: string | null;
345
+ /**
346
+ * How many times THIS client has paused the recorder.
347
+ *
348
+ * Recording is paused for the length of an operation so its HTTP does
349
+ * not also land as `http` rows. Two things record from inside that
350
+ * region — the answer to a server-to-client request, and a token
351
+ * refresh — and they must lift the pause to do it. `resumeRecording`
352
+ * clamps at zero, so lifting a pause that is no longer held would leave
353
+ * the recorder paused for the rest of the test after the matching
354
+ * `pauseRecording`. The answer runs on a detached task, so that
355
+ * ordering is reachable. This counter is how we know.
356
+ */
357
+ private pauseDepth = 0;
358
+
359
+ private readonly opts: McpOptions;
360
+ private readonly transport: McpTransport;
361
+ private readonly createdAt = Date.now();
362
+ /** Notifications, each with the seq of the step it was recorded as. */
363
+ private readonly received: (McpNotification & { seq?: number })[] = [];
364
+ /** The step a server-to-client request should nest under: the tool call
365
+ * that provoked it. */
366
+ private activeSeq?: number;
367
+ private token?: string;
368
+ private clientSecret?: string;
369
+ private lastExchange?: HttpExchange;
370
+
371
+ constructor(url: string, opts: McpOptions) {
372
+ this.url = url;
373
+ this.opts = opts;
374
+ this.token = opts.bearer;
375
+ this.transport = new McpTransport({
376
+ url,
377
+ headers: opts.headers,
378
+ token: () => this.token,
379
+ timeoutMs: opts.timeoutMs,
380
+ onNotification: (n) => this.onNotification(n),
381
+ onRequest: (r) => this.onServerRequest(r),
382
+ onExchange: (x) => {
383
+ this.lastExchange = x;
384
+ },
385
+ });
386
+ }
387
+
388
+ get serverInfo(): Wrapped<McpServerInfo> | undefined {
389
+ return wrapped(this._serverInfo, this.connectSeq);
390
+ }
391
+
392
+ get capabilities(): Wrapped<Record<string, unknown>> | undefined {
393
+ return wrapped(this._capabilities, this.connectSeq);
394
+ }
395
+
396
+ get protocolVersion(): Wrapped<string> | undefined {
397
+ return wrapped(this._protocolVersion, this.connectSeq);
398
+ }
399
+
400
+ get instructions(): Wrapped<string> | undefined {
401
+ return wrapped(this._instructions, this.connectSeq);
402
+ }
403
+
404
+ get sessionId(): Wrapped<string> | undefined {
405
+ return wrapped(this.transport.sessionId, this.connectSeq);
406
+ }
407
+
408
+ get challenge(): Wrapped<McpChallenge> | undefined {
409
+ return wrapped(this._challenge, this.challengeSeq);
410
+ }
411
+
412
+ get identity(): Wrapped<McpIdentity> | undefined {
413
+ return wrapped(this._identity, this.identitySeq);
414
+ }
415
+
416
+ /** Handshake, but treat a `401` as information rather than as failure. */
417
+ async connectQuietly(): Promise<void> {
418
+ try {
419
+ const { seq } = await this.runStep("connect", () => this.handshake(), {
420
+ summary: () => this.handshakeBlocks(),
421
+ connection: false,
422
+ });
423
+ this.connectSeq = seq;
424
+ } catch (err) {
425
+ // A refused handshake is the documented start of the OAuth flow,
426
+ // not a failure to report. The challenge it carried is kept, and
427
+ // `authorize()` starts from it.
428
+ if (err instanceof McpHttpError && err.status === 401) {
429
+ this._challenge = err.challenge;
430
+ this.refusedToken = this.token ?? null;
431
+ return;
432
+ }
433
+ throw err;
434
+ }
435
+ }
436
+
437
+ private async handshake(): Promise<void> {
438
+ const result = await this.transport.request<{
439
+ protocolVersion?: string;
440
+ capabilities?: Record<string, unknown>;
441
+ serverInfo?: { name: string; version: string; title?: string };
442
+ instructions?: string;
443
+ }>("initialize", {
444
+ protocolVersion: PROTOCOL_VERSION,
445
+ capabilities: this.clientCapabilities(),
446
+ clientInfo: this.opts.clientInfo ?? DEFAULT_CLIENT_INFO,
447
+ });
448
+
449
+ this.refusedToken = undefined;
450
+ this._protocolVersion = result.protocolVersion ?? PROTOCOL_VERSION;
451
+ this.transport.protocolVersion = this._protocolVersion;
452
+ this._capabilities = result.capabilities;
453
+ this._serverInfo = result.serverInfo;
454
+ this._instructions = result.instructions;
455
+ this.connected = true;
456
+ this._challenge = undefined;
457
+
458
+ await this.transport.notify("notifications/initialized");
459
+ }
460
+
461
+ private clientCapabilities(): Record<string, unknown> {
462
+ const capabilities: Record<string, unknown> = {};
463
+ if (this.opts.sampling) capabilities["sampling"] = {};
464
+ if (this.opts.roots) capabilities["roots"] = { listChanged: false };
465
+ return capabilities;
466
+ }
467
+
468
+ async authorize(opts: AuthorizeOptions = {}): Promise<McpAuthorization> {
469
+ const { value: flow, seq: authorizeSeq } = await this.runStep(
470
+ "authorize",
471
+ () => beginAuthorization(this.url, this._challenge?.resourceMetadataUrl, opts),
472
+ {
473
+ connection: false,
474
+ summary: (auth) => [
475
+ kv([
476
+ ["Issuer", auth.server.issuer],
477
+ ["Client", auth.clientId],
478
+ [
479
+ "Registered",
480
+ auth.server.dynamicallyRegistered ? "dynamically, for this flow" : "already known",
481
+ ],
482
+ ["Redirect", auth.redirectUri],
483
+ ["Scopes", auth.scopes.length > 0 ? auth.scopes.join(" ") : "none requested"],
484
+ ]),
485
+ {
486
+ type: "text",
487
+ text: "Waiting for a browser to sign in and consent.",
488
+ },
489
+ {
490
+ type: "details",
491
+ summary: "Discovery",
492
+ blocks: [
493
+ kv([
494
+ ["Authorization", auth.server.authorizationEndpoint],
495
+ ["Token", auth.server.tokenEndpoint],
496
+ ["Registration", auth.server.registrationEndpoint],
497
+ ["Resource", auth.resource],
498
+ ["PKCE", "S256"],
499
+ ]),
500
+ { type: "code", code: auth.url, label: "Authorization URL" },
501
+ ],
502
+ },
503
+ ],
504
+ },
505
+ );
506
+ this.clientSecret = opts.clientSecret;
507
+
508
+ const client = this;
509
+ return {
510
+ url: flow.url,
511
+ redirectUri: flow.redirectUri,
512
+ state: flow.state,
513
+ clientId: flow.clientId,
514
+ server: wrapped(flow.server, authorizeSeq)!,
515
+ scopes: flow.scopes,
516
+ resource: flow.resource,
517
+ cancel: () => flow.cancel(),
518
+ async complete(completeOpts): Promise<Wrapped<McpIdentity>> {
519
+ const { value: identity, seq } = await client.runStep(
520
+ "complete authorization",
521
+ async () => {
522
+ const identity = await flow.complete(completeOpts);
523
+ client._identity = identity;
524
+ client.token = identity.accessToken;
525
+ // A session opened before the token was issued was refused,
526
+ // so start a clean one rather than reusing its id.
527
+ client.transport.sessionId = undefined;
528
+ await client.handshake();
529
+ return identity;
530
+ },
531
+ {
532
+ connection: false,
533
+ // Every field of the identity is readable — and therefore
534
+ // assertable — so every field is shown. An assertion on
535
+ // `identity.redirectUri` that names a row nobody can see is
536
+ // worse than no assertion at all.
537
+ summary: (identity) => [
538
+ kv([
539
+ ["Granted scopes", identity.scopes.join(" ") || "none"],
540
+ ["Token", redactToken(identity.accessToken)],
541
+ ["Token type", identity.tokenType],
542
+ ["Refresh token", identity.refreshToken ? "issued" : "none"],
543
+ [
544
+ "Expires",
545
+ identity.expiresAt
546
+ ? new Date(identity.expiresAt).toISOString().replace("T", " ").slice(0, 19)
547
+ : "not stated",
548
+ ],
549
+ ["Resource", identity.resource],
550
+ ["Client", identity.clientId],
551
+ ["Issuer", identity.issuer],
552
+ ["Redirect URI", identity.redirectUri],
553
+ ["Token endpoint", identity.tokenEndpoint],
554
+ ]),
555
+ // This step ran the handshake, so the server's own identity
556
+ // was read here too — and `mcp.serverInfo` assertions point
557
+ // at this step.
558
+ ...client.handshakeBlocks("Server"),
559
+ ],
560
+ },
561
+ );
562
+ // Both the grant and the handshake it ran belong to this step, so
563
+ // `mcp.identity` and `mcp.serverInfo` assert against it.
564
+ client.identitySeq = seq;
565
+ client.connectSeq = seq;
566
+ return wrapped(identity, seq)!;
567
+ },
568
+ };
569
+ }
570
+
571
+ async tools(): Promise<Wrapped<McpToolInfo[]>> {
572
+ return this.stepWrapped(
573
+ "list tools",
574
+ async () => {
575
+ const result = await this.send<{ tools?: McpToolInfo[] }>("tools/list", {});
576
+ return result.tools ?? [];
577
+ },
578
+ {
579
+ summary: (tools) => [
580
+ {
581
+ type: "table",
582
+ columns: ["Tool", "Title", "Description"],
583
+ rows: tools.map((t) => [t.name, t.title ?? "", t.description ?? ""]),
584
+ },
585
+ // The table carries three columns; a tool also has its input and
586
+ // output schemas, and a test may assert on either.
587
+ ...rawBlock("Full catalogue", tools),
588
+ ],
589
+ },
590
+ );
591
+ }
592
+
593
+ async call<T = unknown>(
594
+ name: string,
595
+ args: Record<string, unknown> = {},
596
+ opts?: { timeoutMs?: number },
597
+ ): Promise<McpToolResult<T>> {
598
+ const resv = reserveEvent();
599
+ const started = Date.now();
600
+ this.pause();
601
+ let raw: RawToolResult<T> | undefined;
602
+ let failure: unknown;
603
+ try {
604
+ this.activeSeq = resv?.seq;
605
+ raw = await this.send<RawToolResult<T>>("tools/call", { name, arguments: args }, opts);
606
+ } catch (err) {
607
+ failure = err;
608
+ } finally {
609
+ this.activeSeq = undefined;
610
+ this.resume();
611
+ }
612
+
613
+ const durationMs = Date.now() - started;
614
+ if (failure || !raw) {
615
+ const seq = recordStep(
616
+ {
617
+ kind: "mcp",
618
+ title: this.title(name),
619
+ status: "failed",
620
+ durationMs,
621
+ error: errorMessage(failure),
622
+ blocks: [
623
+ ...argumentBlocks(args),
624
+ ...this.failureBlocks(failure),
625
+ ...this.connectionBlock(),
626
+ ],
627
+ },
628
+ resv,
629
+ );
630
+ this.noteChallenge(failure, seq);
631
+ throw failure;
632
+ }
633
+
634
+ const content = raw.content ?? [];
635
+ const text = textOf(content);
636
+ const isError = raw.isError === true;
637
+ const seq = recordStep(
638
+ {
639
+ kind: "mcp",
640
+ title: this.title(name),
641
+ status: isError ? "failed" : "passed",
642
+ durationMs,
643
+ error: isError ? text || "the tool reported an error" : undefined,
644
+ blocks: [
645
+ ...argumentBlocks(args),
646
+ // `res.isError` is assertable, so it is shown rather than left
647
+ // to be inferred from the step's pass/fail chip. It needs its
648
+ // own label: two kv blocks in a row read as one list, and
649
+ // without a heading this looked like a third argument.
650
+ {
651
+ type: "kv",
652
+ label: "Outcome",
653
+ rows: [{ label: "isError", value: String(isError), error: isError }],
654
+ },
655
+ // A failed tool's message IS its text content, and the step
656
+ // already leads with it as the error. Printing it twice reads
657
+ // as two different things having gone wrong.
658
+ ...resultBlocks(content, raw.structuredContent, isError ? text : undefined),
659
+ ...this.connectionBlock(),
660
+ ],
661
+ },
662
+ resv,
663
+ );
664
+
665
+ return makeToolResult<T>(raw, content, text, isError, seq);
666
+ }
667
+
668
+ async resources(): Promise<Wrapped<McpResourceInfo[]>> {
669
+ return this.stepWrapped(
670
+ "list resources",
671
+ async () => {
672
+ const result = await this.send<{ resources?: McpResourceInfo[] }>("resources/list", {});
673
+ return result.resources ?? [];
674
+ },
675
+ {
676
+ summary: (resources) => [
677
+ {
678
+ type: "table",
679
+ columns: ["URI", "Name", "Type"],
680
+ rows: resources.map((r) => [r.uri, r.name ?? r.title ?? "", r.mimeType ?? ""]),
681
+ },
682
+ ...rawBlock("Full listing", resources),
683
+ ],
684
+ },
685
+ );
686
+ }
687
+
688
+ async read(uri: string): Promise<Wrapped<McpResourceContents[]>> {
689
+ return this.stepWrapped(
690
+ `read ${uri}`,
691
+ async () => {
692
+ const result = await this.send<{ contents?: McpResourceContents[] }>("resources/read", { uri });
693
+ return result.contents ?? [];
694
+ },
695
+ {
696
+ summary: (contents) => contents.flatMap((c) => resourceBlocks(c)),
697
+ },
698
+ );
699
+ }
700
+
701
+ async prompts(): Promise<Wrapped<McpPromptInfo[]>> {
702
+ return this.stepWrapped(
703
+ "list prompts",
704
+ async () => {
705
+ const result = await this.send<{ prompts?: McpPromptInfo[] }>("prompts/list", {});
706
+ return result.prompts ?? [];
707
+ },
708
+ {
709
+ summary: (prompts) => [
710
+ {
711
+ type: "table",
712
+ columns: ["Prompt", "Description"],
713
+ rows: prompts.map((p) => [p.name, p.description ?? ""]),
714
+ },
715
+ ...rawBlock("Full listing", prompts),
716
+ ],
717
+ },
718
+ );
719
+ }
720
+
721
+ async prompt(name: string, args?: Record<string, string>): Promise<Wrapped<McpPromptResult>> {
722
+ return this.stepWrapped(
723
+ `prompt ${name}`,
724
+ () => this.send<McpPromptResult>("prompts/get", { name, arguments: args ?? {} }),
725
+ {
726
+ summary: (result) => [
727
+ ...(result.description ? [{ type: "text" as const, text: result.description }] : []),
728
+ {
729
+ type: "chat",
730
+ messages: (result.messages ?? []).map((m) => ({
731
+ side: m.role === "assistant" ? ("other" as const) : ("self" as const),
732
+ text: contentText(m.content),
733
+ })),
734
+ },
735
+ // Bubbles carry the text of each turn. The role names and any
736
+ // non-text content are only in the message objects themselves.
737
+ ...rawBlock("Messages", result.messages ?? []),
738
+ ],
739
+ },
740
+ );
741
+ }
742
+
743
+ notifications(filter?: string | RegExp): Wrapped<McpNotification>[] {
744
+ const matches =
745
+ filter === undefined
746
+ ? this.received
747
+ : this.received.filter((n) =>
748
+ typeof filter === "string" ? n.method === filter : filter.test(n.method),
749
+ );
750
+ // Wrapped per element, not per array: each notification came from a
751
+ // different step, and a whole-array tag would point every assertion at
752
+ // whichever one happened to be last.
753
+ return matches.map(({ seq, ...n }) => wrapped(n, seq) as Wrapped<McpNotification>);
754
+ }
755
+
756
+ async close(): Promise<void> {
757
+ await this.step("close", async () => {
758
+ await this.transport.close();
759
+ this.connected = false;
760
+ });
761
+ }
762
+
763
+ /**
764
+ * Send one request, repairing the two failures a test environment
765
+ * produces by itself.
766
+ *
767
+ * A client is normally handed down from the test that authenticated it,
768
+ * so by the time it is used it lives in a RESTORED fork: the connection
769
+ * it holds was opened before the snapshot and the peer may have reset
770
+ * it. And an access token expires. Both are repaired once, silently,
771
+ * because neither is something a test should have to write.
772
+ */
773
+ private async send<T>(
774
+ method: string,
775
+ params?: unknown,
776
+ opts?: { timeoutMs?: number },
777
+ ): Promise<T> {
778
+ if (!this.connected && this.refusedToken !== (this.token ?? null)) {
779
+ // No session yet — the handshake was refused, or the server dropped
780
+ // it. Open one now and let a `401` propagate: a rejection is the
781
+ // server's answer to this call, not something to hide behind a
782
+ // client-side gate.
783
+ try {
784
+ await this.handshake();
785
+ } catch (err) {
786
+ if (err instanceof McpHttpError && err.status === 401) {
787
+ this._challenge = err.challenge;
788
+ this.refusedToken = this.token ?? null;
789
+ }
790
+ throw err;
791
+ }
792
+ }
793
+ try {
794
+ return await this.transport.request<T>(method, params, opts);
795
+ } catch (err) {
796
+ if (await this.repair(err)) return this.transport.request<T>(method, params, opts);
797
+ throw err;
798
+ }
799
+ }
800
+
801
+ /** Try to make one failure survivable. Returns true when the caller
802
+ * should retry exactly once. */
803
+ private async repair(err: unknown): Promise<boolean> {
804
+ if (err instanceof McpHttpError) {
805
+ if (err.status === 401) {
806
+ this._challenge = err.challenge;
807
+ // An expired token, and a refresh token to spend on it.
808
+ if (this._identity?.refreshToken) {
809
+ const refreshed = await this.refresh();
810
+ if (refreshed) return true;
811
+ }
812
+ return false;
813
+ }
814
+ // The server forgot this session — it restarted, or the session id
815
+ // came from before the fork. A fresh handshake is the whole repair.
816
+ if (err.status === 404 || err.status === 400) {
817
+ return this.reconnect();
818
+ }
819
+ return false;
820
+ }
821
+ // A connection the peer reset. Common in a restored fork: the flow was
822
+ // established before the snapshot.
823
+ if (isConnectionError(err)) return this.reconnect();
824
+ return false;
825
+ }
826
+
827
+ private async reconnect(): Promise<boolean> {
828
+ this.transport.sessionId = undefined;
829
+ this.connected = false;
830
+ try {
831
+ await this.handshake();
832
+ return true;
833
+ } catch {
834
+ return false;
835
+ }
836
+ }
837
+
838
+ private async refresh(): Promise<boolean> {
839
+ if (!this._identity) return false;
840
+ if (!this._identity.tokenEndpoint) return false;
841
+ const started = Date.now();
842
+ let refreshed: McpIdentity | undefined;
843
+ let failure: unknown;
844
+ try {
845
+ refreshed = await refreshIdentity(this._identity, this.clientSecret);
846
+ } catch (err) {
847
+ failure = err;
848
+ }
849
+ if (refreshed) {
850
+ this._identity = refreshed;
851
+ this.token = refreshed.accessToken;
852
+ }
853
+ // The caller paused the recorder for the length of its own step; lift
854
+ // that just long enough to put this repair on the timeline.
855
+ this.recording(() =>
856
+ recordStep({
857
+ kind: "mcp",
858
+ title: this.title("refresh token"),
859
+ status: refreshed ? "passed" : "failed",
860
+ durationMs: Date.now() - started,
861
+ error: refreshed
862
+ ? undefined
863
+ : (errorMessage(failure) ?? "the server issued no refresh token"),
864
+ blocks: refreshed
865
+ ? [
866
+ kv([
867
+ ["Token", redactToken(refreshed.accessToken)],
868
+ [
869
+ "Expires",
870
+ refreshed.expiresAt ? new Date(refreshed.expiresAt).toISOString() : "not stated",
871
+ ],
872
+ ]),
873
+ ]
874
+ : [],
875
+ }),
876
+ );
877
+ return refreshed !== undefined;
878
+ }
879
+
880
+ private onNotification(n: JsonRpcNotification): void {
881
+ // A notification the server pushed is something that HAPPENED, and
882
+ // `mcp.notifications()` can be asserted on, so it gets a step of its
883
+ // own — nested under the call it arrived during, when there is one.
884
+ const seq = this.recording(() =>
885
+ recordStep({
886
+ kind: "mcp",
887
+ title: this.title(`notification ${n.method}`),
888
+ status: "passed",
889
+ parentSeq: this.activeSeq,
890
+ blocks: n.params === undefined ? [] : [{ type: "json", value: n.params, label: "Params" }],
891
+ }),
892
+ );
893
+ this.received.push({
894
+ method: n.method,
895
+ params: n.params,
896
+ atMs: Date.now() - this.createdAt,
897
+ seq,
898
+ });
899
+ }
900
+
901
+ /**
902
+ * Answer a server-to-client request.
903
+ *
904
+ * Recorded as a child of the call that provoked it (`parentSeq`), so a
905
+ * tool that asks the user something renders inside that tool's step
906
+ * rather than as a stray event somewhere below it.
907
+ */
908
+ private async onServerRequest(request: JsonRpcRequest): Promise<unknown> {
909
+ const parentSeq = this.activeSeq;
910
+ const started = Date.now();
911
+ const params = (request.params ?? {}) as Record<string, unknown>;
912
+
913
+ const finish = (title: string, blocks: StepBlock[], error?: string): void => {
914
+ this.recording(() =>
915
+ recordStep({
916
+ kind: "mcp",
917
+ title: this.title(title),
918
+ status: error ? "failed" : "passed",
919
+ durationMs: Date.now() - started,
920
+ error,
921
+ parentSeq,
922
+ blocks,
923
+ }),
924
+ );
925
+ };
926
+
927
+ if (request.method === "sampling/createMessage" && this.opts.sampling) {
928
+ const req = params as unknown as McpSamplingRequest;
929
+ try {
930
+ const answer = await this.opts.sampling(req);
931
+ const result = samplingResult(answer);
932
+ finish("sampling", [
933
+ {
934
+ type: "chat",
935
+ messages: [
936
+ ...(req.messages ?? []).map((m) => ({
937
+ side: (m.role === "assistant" ? "other" : "self") as "self" | "other",
938
+ text: contentText(m.content),
939
+ })),
940
+ { side: "other" as const, text: contentText(result.content), new: true },
941
+ ],
942
+ },
943
+ ]);
944
+ return result;
945
+ } catch (err) {
946
+ finish("sampling", [], errorMessage(err));
947
+ throw err;
948
+ }
949
+ }
950
+
951
+ if (request.method === "roots/list") {
952
+ const roots = (this.opts.roots ?? []).map((r) =>
953
+ typeof r === "string" ? { uri: r } : { uri: r.uri, name: r.name },
954
+ );
955
+ finish("list roots", [{ type: "json", value: roots, label: "Roots" }]);
956
+ return { roots };
957
+ }
958
+
959
+ if (request.method === "ping") return {};
960
+
961
+ throw new Error(
962
+ `the server asked for "${request.method}", which this client did not advertise. ` +
963
+ "Declare it on ctx.mcp(url, { … }) to answer it.",
964
+ );
965
+ }
966
+
967
+ /** Run one operation as a recorded step, with HTTP paused inside it. */
968
+ private async step<T>(
969
+ title: string,
970
+ run: () => Promise<T>,
971
+ opts?: StepOpts<T>,
972
+ ): Promise<T> {
973
+ return (await this.runStep(title, run, opts)).value;
974
+ }
975
+
976
+ /** The same, with the result provenance-wrapped so an assertion on it
977
+ * nests under this step. */
978
+ private async stepWrapped<T>(
979
+ title: string,
980
+ run: () => Promise<T>,
981
+ opts?: StepOpts<T>,
982
+ ): Promise<Wrapped<T>> {
983
+ const { value, seq } = await this.runStep(title, run, opts);
984
+ return wrap(value, seq) as Wrapped<T>;
985
+ }
986
+
987
+ private async runStep<T>(
988
+ title: string,
989
+ run: () => Promise<T>,
990
+ opts?: StepOpts<T>,
991
+ ): Promise<{ value: T; seq: number | undefined }> {
992
+ const resv = reserveEvent();
993
+ const started = Date.now();
994
+ this.pause();
995
+ let value: T;
996
+ try {
997
+ value = await run();
998
+ } catch (err) {
999
+ this.resume();
1000
+ const seq = recordStep(
1001
+ {
1002
+ kind: "mcp",
1003
+ title: this.title(title),
1004
+ status: "failed",
1005
+ durationMs: Date.now() - started,
1006
+ error: errorMessage(err),
1007
+ blocks: [...this.failureBlocks(err), ...this.connectionBlock()],
1008
+ },
1009
+ resv,
1010
+ );
1011
+ this.noteChallenge(err, seq);
1012
+ throw err;
1013
+ }
1014
+ this.resume();
1015
+
1016
+ const seq = recordStep(
1017
+ {
1018
+ kind: "mcp",
1019
+ title: this.title(title),
1020
+ status: "passed",
1021
+ durationMs: Date.now() - started,
1022
+ blocks: [
1023
+ ...(opts?.summary?.(value) ?? []),
1024
+ ...(opts?.connection === false ? [] : this.connectionBlock()),
1025
+ ],
1026
+ },
1027
+ resv,
1028
+ );
1029
+ return { value, seq };
1030
+ }
1031
+
1032
+ /**
1033
+ * Keep the `401` challenge a refused step carried, tagged with that
1034
+ * step.
1035
+ *
1036
+ * This is what lets a test assert that a server really is protected —
1037
+ * `expect(mcp.challenge?.status).toBe(401)` — with the assertion nested
1038
+ * under the call that was refused. The thrown error carries the same
1039
+ * information, but a caught error has no provenance to assert through.
1040
+ */
1041
+ private noteChallenge(err: unknown, seq: number | undefined): void {
1042
+ if (!(err instanceof McpHttpError) || err.status !== 401 || !err.challenge) return;
1043
+ this._challenge = err.challenge;
1044
+ this.challengeSeq = seq;
1045
+ }
1046
+
1047
+ /** Pause the recorder for the length of one operation's HTTP. */
1048
+ private pause(): void {
1049
+ this.pauseDepth += 1;
1050
+ pauseRecording();
1051
+ }
1052
+
1053
+ private resume(): void {
1054
+ if (this.pauseDepth === 0) return;
1055
+ this.pauseDepth -= 1;
1056
+ resumeRecording();
1057
+ }
1058
+
1059
+ /** Run `fn` with this client's own pause lifted, then put it back. A
1060
+ * no-op wrapper when we hold no pause. */
1061
+ private recording<T>(fn: () => T): T {
1062
+ if (this.pauseDepth === 0) return fn();
1063
+ this.resume();
1064
+ try {
1065
+ return fn();
1066
+ } finally {
1067
+ this.pause();
1068
+ }
1069
+ }
1070
+
1071
+ private title(op: string): string {
1072
+ return this.opts.label ? `${this.opts.label}: ${op}` : op;
1073
+ }
1074
+
1075
+ /**
1076
+ * Which connection this step ran on — folded away.
1077
+ *
1078
+ * It is the same six rows on every step of a session, and none of them
1079
+ * is why anyone opened the step. Above the content they buried it (the
1080
+ * arguments of a tool call started below the fold); in a disclosure at
1081
+ * the bottom they are one click away when a session id or a token
1082
+ * actually is the question.
1083
+ */
1084
+ private connectionBlock(): StepBlock[] {
1085
+ const rows: [string, string | undefined][] = [["Server", this.url]];
1086
+ if (this._serverInfo) {
1087
+ rows.push(["Implementation", `${this._serverInfo.name} ${this._serverInfo.version}`]);
1088
+ }
1089
+ if (this._protocolVersion) rows.push(["Protocol", this._protocolVersion]);
1090
+ if (this.transport.sessionId) rows.push(["Session", this.transport.sessionId]);
1091
+ if (this._identity) {
1092
+ rows.push(["Token", redactToken(this._identity.accessToken)]);
1093
+ if (this._identity.scopes.length > 0) rows.push(["Scopes", this._identity.scopes.join(" ")]);
1094
+ }
1095
+ return [{ type: "details", summary: "Connection", blocks: [kv(rows)] }];
1096
+ }
1097
+
1098
+ /**
1099
+ * What the handshake established.
1100
+ *
1101
+ * Shown on the two steps that perform one — the plain `connect`, and
1102
+ * the `complete authorization` that re-runs it with the token — because
1103
+ * `mcp.serverInfo` / `capabilities` / `protocolVersion` / `sessionId`
1104
+ * are all wrapped against whichever of those ran, and a value a test can
1105
+ * assert on has to be a value the reader can see.
1106
+ *
1107
+ * `fold` puts it in a disclosure, for the step whose own subject is
1108
+ * something else.
1109
+ */
1110
+ handshakeBlocks(fold?: string): StepBlock[] {
1111
+ const blocks: StepBlock[] = [
1112
+ kv([
1113
+ ["Server", this.url],
1114
+ ["Name", this._serverInfo?.name],
1115
+ ["Version", this._serverInfo?.version],
1116
+ ["Title", this._serverInfo?.title],
1117
+ ["Protocol", this._protocolVersion],
1118
+ ["Session", this.transport.sessionId],
1119
+ ["Capabilities", Object.keys(this._capabilities ?? {}).join(", ") || "none"],
1120
+ ]),
1121
+ ...(this._instructions
1122
+ ? [{ type: "text" as const, text: this._instructions }]
1123
+ : []),
1124
+ // The capability map is nested, so a row cannot carry it — and a
1125
+ // test may assert on any leaf of it.
1126
+ ...(this._capabilities && Object.keys(this._capabilities).length > 0
1127
+ ? [{ type: "json" as const, value: this._capabilities, label: "Capabilities" }]
1128
+ : []),
1129
+ ];
1130
+ return fold ? [{ type: "details", summary: fold, blocks }] : blocks;
1131
+ }
1132
+
1133
+ private failureBlocks(err: unknown): StepBlock[] {
1134
+ const blocks: StepBlock[] = [];
1135
+ if (err instanceof McpHttpError) {
1136
+ blocks.push(
1137
+ kv([
1138
+ ["HTTP status", String(err.status)],
1139
+ ["Scheme", err.challenge?.scheme],
1140
+ ["Error", err.challenge?.error],
1141
+ ["Scope", err.challenge?.scope],
1142
+ ["Resource metadata", err.challenge?.resourceMetadataUrl],
1143
+ ["WWW-Authenticate", err.challenge?.raw],
1144
+ ]),
1145
+ );
1146
+ if (err.body) {
1147
+ const body = err.body.slice(0, 4000);
1148
+ const json = tryParse(body);
1149
+ blocks.push(
1150
+ json === undefined
1151
+ ? { type: "code", code: body, label: "Response" }
1152
+ : { type: "json", value: json, label: "Response" },
1153
+ );
1154
+ }
1155
+ } else if (err instanceof McpRpcError) {
1156
+ blocks.push(kv([["JSON-RPC error", String(err.code)]]));
1157
+ if (err.data !== undefined) blocks.push({ type: "json", value: err.data, label: "Error data" });
1158
+ } else if (this.lastExchange) {
1159
+ blocks.push(kv([["Last HTTP status", String(this.lastExchange.status)]]));
1160
+ }
1161
+ return blocks;
1162
+ }
1163
+ }
1164
+
1165
+ /** How one recorded step is built. */
1166
+ interface StepOpts<T> {
1167
+ /** Blocks describing what the step did, from its result. */
1168
+ summary?: (value: T) => StepBlock[];
1169
+ /** Append the folded "Connection" disclosure. Off for the steps where
1170
+ * the connection is itself the subject (the handshake, the grant). */
1171
+ connection?: boolean;
1172
+ }
1173
+
1174
+ /**
1175
+ * A tool call's arguments.
1176
+ *
1177
+ * A flat object — which is what most tool calls take — reads far better as
1178
+ * a definition list than as a JSON blob, and it lines up with the rest of
1179
+ * the panel. Anything nested keeps its JSON, where the shape matters.
1180
+ */
1181
+ function argumentBlocks(args: Record<string, unknown>): StepBlock[] {
1182
+ const entries = Object.entries(args);
1183
+ if (entries.length === 0) return [];
1184
+ const flat = entries.every(([, v]) => v === null || typeof v !== "object");
1185
+ if (!flat) return [{ type: "json", value: args, label: "Arguments" }];
1186
+ return [
1187
+ {
1188
+ type: "kv",
1189
+ label: "Arguments",
1190
+ rows: entries.map(([label, value]) => ({ label, value: String(value) })),
1191
+ },
1192
+ ];
1193
+ }
1194
+
1195
+ interface RawToolResult<T> {
1196
+ content?: McpContent[];
1197
+ structuredContent?: T;
1198
+ isError?: boolean;
1199
+ }
1200
+
1201
+ function makeToolResult<T>(
1202
+ raw: RawToolResult<T>,
1203
+ content: McpContent[],
1204
+ text: string,
1205
+ isError: boolean,
1206
+ seq: number | undefined,
1207
+ ): McpToolResult<T> {
1208
+ return {
1209
+ isError: wrap(isError, seq, ["isError"]) as unknown as Wrapped<boolean>,
1210
+ content: wrap(content, seq, ["content"]) as unknown as Wrapped<McpContent[]>,
1211
+ text: wrap(text, seq, ["text"]) as unknown as Wrapped<string>,
1212
+ structured:
1213
+ raw.structuredContent === undefined
1214
+ ? undefined
1215
+ : (wrap(raw.structuredContent, seq, ["structured"]) as unknown as Wrapped<T>),
1216
+ json<U = T>(): Wrapped<U> {
1217
+ if (raw.structuredContent !== undefined) {
1218
+ return wrap(raw.structuredContent, seq, ["json()"]) as unknown as Wrapped<U>;
1219
+ }
1220
+ let parsed: unknown;
1221
+ try {
1222
+ parsed = JSON.parse(text);
1223
+ } catch {
1224
+ throw new Error(
1225
+ "the tool returned no structuredContent and its text is not JSON. " +
1226
+ "Assert on res.text, or read res.content.",
1227
+ );
1228
+ }
1229
+ return wrap(parsed, seq, ["json()"]) as Wrapped<U>;
1230
+ },
1231
+ unwrap: () => ({ isError, content, structuredContent: raw.structuredContent }),
1232
+ };
1233
+ }
1234
+
1235
+ /** Join the text parts. The usual thing a test asserts on. */
1236
+ function textOf(content: McpContent[]): string {
1237
+ return content
1238
+ .filter((c) => c.type === "text")
1239
+ .map((c) => (c as { text?: string }).text ?? "")
1240
+ .join("\n");
1241
+ }
1242
+
1243
+ function contentText(content: McpContent | undefined): string {
1244
+ if (!content) return "";
1245
+ if (content.type === "text") return (content as { text?: string }).text ?? "";
1246
+ if (content.type === "image" || content.type === "audio") {
1247
+ return `[${content.type} ${(content as { mimeType?: string }).mimeType ?? ""}]`;
1248
+ }
1249
+ return JSON.stringify(content);
1250
+ }
1251
+
1252
+ /**
1253
+ * Render a tool result.
1254
+ *
1255
+ * A tool that declares an output schema usually returns the SAME value
1256
+ * twice — once as `structuredContent` and once as a JSON text part, since
1257
+ * a client that does not read structured output still has to see
1258
+ * something. Rendering both is noise, so a text part that parses to the
1259
+ * structured value is dropped.
1260
+ *
1261
+ * An image renders as a line of metadata rather than a picture:
1262
+ * `blocks.rs` has no image block yet. When one is added, this is the only
1263
+ * place that changes.
1264
+ */
1265
+ function resultBlocks(
1266
+ content: McpContent[],
1267
+ structured: unknown,
1268
+ shownAsError?: string,
1269
+ ): StepBlock[] {
1270
+ const blocks: StepBlock[] = [];
1271
+ // Text parts a reader does not need when `structuredContent` already
1272
+ // answers the question. Folded, not dropped: what the model would have
1273
+ // read is still one click away.
1274
+ const folded: StepBlock[] = [];
1275
+ if (structured !== undefined) blocks.push({ type: "json", value: structured, label: "Result" });
1276
+
1277
+ const textCount = content.filter((c) => c.type === "text").length;
1278
+ const label = (index: number): string => (textCount > 1 ? `Text ${index + 1}` : "Result");
1279
+
1280
+ let textIndex = 0;
1281
+ for (const part of content) {
1282
+ if (part.type === "text") {
1283
+ const text = (part as { text?: string }).text ?? "";
1284
+ const json = tryParse(text);
1285
+ const index = textIndex++;
1286
+ // The same value twice — a tool with an output schema usually
1287
+ // returns both forms. Once is enough.
1288
+ if (json !== undefined && structured !== undefined && sameJson(json, structured)) continue;
1289
+ // Already the step's error line.
1290
+ if (shownAsError !== undefined && text === shownAsError) continue;
1291
+ const block: StepBlock =
1292
+ json === undefined
1293
+ ? { type: "code", code: text, label: label(index) }
1294
+ : { type: "json", value: json, label: label(index) };
1295
+ (structured === undefined ? blocks : folded).push(block);
1296
+ continue;
1297
+ }
1298
+ if (part.type === "image" || part.type === "audio") {
1299
+ const p = part as { mimeType?: string; data?: string };
1300
+ blocks.push({
1301
+ type: "kv",
1302
+ rows: [{ label: part.type, value: `${p.mimeType ?? "unknown type"}, ${byteSize(p.data)}` }],
1303
+ });
1304
+ continue;
1305
+ }
1306
+ if (part.type === "resource") {
1307
+ blocks.push(...resourceBlocks((part as { resource: McpResourceContents }).resource));
1308
+ continue;
1309
+ }
1310
+ blocks.push({ type: "json", value: part, label: part.type });
1311
+ }
1312
+ if (folded.length > 0) {
1313
+ blocks.push({ type: "details", summary: "Text content", blocks: folded });
1314
+ }
1315
+ return blocks;
1316
+ }
1317
+
1318
+ /**
1319
+ * A folded block carrying a value in full.
1320
+ *
1321
+ * The rule this serves: a test can assert on any field of what a call
1322
+ * returned, so every field has to be somewhere a reader can reach. A
1323
+ * table or a set of bubbles shows what matters; this keeps the rest one
1324
+ * click away instead of nowhere.
1325
+ */
1326
+ function rawBlock(summary: string, value: unknown): StepBlock[] {
1327
+ if (Array.isArray(value) && value.length === 0) return [];
1328
+ return [{ type: "details", summary, blocks: [{ type: "json", value }] }];
1329
+ }
1330
+
1331
+ /** Structural equality, for the duplicate-result check above. */
1332
+ function sameJson(a: unknown, b: unknown): boolean {
1333
+ try {
1334
+ return JSON.stringify(a) === JSON.stringify(b);
1335
+ } catch {
1336
+ return false;
1337
+ }
1338
+ }
1339
+
1340
+ function resourceBlocks(contents: McpResourceContents): StepBlock[] {
1341
+ const blocks: StepBlock[] = [
1342
+ kv([
1343
+ ["URI", contents.uri],
1344
+ ["Type", contents.mimeType],
1345
+ ]),
1346
+ ];
1347
+ if (contents.text !== undefined) {
1348
+ const json = tryParse(contents.text);
1349
+ blocks.push(
1350
+ json === undefined
1351
+ ? { type: "code", code: contents.text, lang: langOf(contents.mimeType), label: "Contents" }
1352
+ : { type: "json", value: json, label: "Contents" },
1353
+ );
1354
+ } else if (contents.blob !== undefined) {
1355
+ blocks.push({ type: "text", text: `binary contents, ${byteSize(contents.blob)}` });
1356
+ }
1357
+ return blocks;
1358
+ }
1359
+
1360
+ function samplingResult(answer: McpSamplingResult): {
1361
+ role: string;
1362
+ content: McpContent;
1363
+ model: string;
1364
+ stopReason?: string;
1365
+ } {
1366
+ if (typeof answer === "string") {
1367
+ return { role: "assistant", content: { type: "text", text: answer }, model: "spectest" };
1368
+ }
1369
+ const content: McpContent = answer.content ?? { type: "text", text: answer.text ?? "" };
1370
+ return {
1371
+ role: answer.role ?? "assistant",
1372
+ content,
1373
+ model: answer.model ?? "spectest",
1374
+ stopReason: answer.stopReason,
1375
+ };
1376
+ }
1377
+
1378
+ function isConnectionError(err: unknown): boolean {
1379
+ const message = (err as Error)?.message ?? String(err);
1380
+ return /ECONNRESET|ECONNREFUSED|EPIPE|socket|fetch failed|Unable to connect|closed/i.test(message);
1381
+ }
1382
+
1383
+ function errorMessage(err: unknown): string | undefined {
1384
+ if (err === undefined || err === null) return undefined;
1385
+ return (err as Error)?.message ?? String(err);
1386
+ }
1387
+
1388
+ /** Show that a token exists and let two tokens be told apart, without
1389
+ * putting a credential in a run that is stored forever. */
1390
+ function redactToken(token: string): string {
1391
+ return token.length <= 8 ? "••••" : `••••${token.slice(-4)}`;
1392
+ }
1393
+
1394
+ /**
1395
+ * A wrapped view of a value this client read, tagged with the step that
1396
+ * produced it. `wrap` is typed as the identity function (it returns a
1397
+ * proxy that behaves like the value), so the wrapped TYPE is asserted
1398
+ * here — in one place, rather than at every getter.
1399
+ */
1400
+ function wrapped<T>(value: T | undefined, seq: number | undefined): Wrapped<T> | undefined {
1401
+ return value === undefined ? undefined : (wrap(value, seq) as unknown as Wrapped<T>);
1402
+ }
1403
+
1404
+ function kv(rows: [string, string | undefined][], label?: string): StepBlock {
1405
+ return {
1406
+ type: "kv",
1407
+ label,
1408
+ rows: rows.filter(([, value]) => value !== undefined).map(([label, value]) => ({ label, value })),
1409
+ };
1410
+ }
1411
+
1412
+ function tryParse(text: string): unknown {
1413
+ const trimmed = text.trim();
1414
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return undefined;
1415
+ try {
1416
+ return JSON.parse(trimmed);
1417
+ } catch {
1418
+ return undefined;
1419
+ }
1420
+ }
1421
+
1422
+ function langOf(mimeType?: string): string | undefined {
1423
+ if (!mimeType) return undefined;
1424
+ if (mimeType.includes("json")) return "json";
1425
+ if (mimeType.includes("html")) return "html";
1426
+ if (mimeType.includes("markdown")) return "markdown";
1427
+ if (mimeType.includes("yaml")) return "yaml";
1428
+ return undefined;
1429
+ }
1430
+
1431
+ function byteSize(base64?: string): string {
1432
+ if (!base64) return "0 bytes";
1433
+ const bytes = Math.floor((base64.length * 3) / 4);
1434
+ return bytes < 1024 ? `${bytes} bytes` : `${(bytes / 1024).toFixed(1)} kB`;
1435
+ }