@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.
- package/dist/daemon.js +10 -0
- package/dist/harness/raw-fetch.d.ts +7 -0
- package/dist/harness/raw-fetch.js +33 -0
- package/dist/ids.d.ts +9 -0
- package/dist/ids.js +11 -1
- package/dist/index.d.ts +43 -0
- package/dist/index.js +4 -0
- package/dist/mcp-auth.d.ts +176 -0
- package/dist/mcp-auth.js +455 -0
- package/dist/mcp-transport.d.ts +130 -0
- package/dist/mcp-transport.js +337 -0
- package/dist/mcp.d.ts +246 -0
- package/dist/mcp.js +1060 -0
- package/dist/recorder.d.ts +5 -0
- package/package.json +1 -1
- package/src/daemon.ts +10 -0
- package/src/harness/raw-fetch.ts +36 -0
- package/src/ids.ts +12 -1
- package/src/index.ts +69 -0
- package/src/mcp-auth.ts +626 -0
- package/src/mcp-transport.ts +434 -0
- package/src/mcp.test.ts +94 -0
- package/src/mcp.ts +1435 -0
- package/src/recorder.ts +5 -2
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
|
+
}
|