@specific.dev/spectest 0.55.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,455 @@
1
+ // OAuth 2.1 for the MCP client (`mcp.ts`).
2
+ //
3
+ // An MCP server is an OAuth *resource server*. It rejects an unauthorized
4
+ // request with `401` and a `WWW-Authenticate` header, and that header is
5
+ // the entry point of this file. From there the flow is:
6
+ //
7
+ // 1. Read the protected-resource metadata (RFC 9728). It names the
8
+ // authorization server and the canonical `resource` identifier.
9
+ // 2. Read the authorization-server metadata (RFC 8414). Note the
10
+ // path-insertion rule: an issuer with a path is discovered at
11
+ // `https://host/.well-known/oauth-authorization-server/<path>`, not
12
+ // at `<issuer>/.well-known/...`.
13
+ // 3. Register the client dynamically (RFC 7591) when the server offers
14
+ // it. This is also what lets us declare a redirect URI on a port we
15
+ // only chose a moment ago.
16
+ // 4. Authorization code with PKCE (S256, mandatory) and the `resource`
17
+ // parameter (RFC 8707, required by MCP — it binds the token to this
18
+ // one server).
19
+ // 5. Exchange the code, then refresh the token when it expires.
20
+ //
21
+ // The SDK owns all of that. It does NOT own the browser. A test drives the
22
+ // login and the consent screen itself, because that screen belongs to the
23
+ // application under test and a framework that clicks it by guesswork turns
24
+ // a broken consent page into a passing test. See `mcp.ts` for the seam.
25
+ //
26
+ // WHY LOOPBACK. The default redirect URI is `http://127.0.0.1:<port>/callback`
27
+ // on an ephemeral port. That is what a desktop MCP client does — it is
28
+ // OAuth 2.0 for Native Apps (RFC 8252 §7.3), and §8.3 prefers the IP
29
+ // literal over the name `localhost`. It works here because Chromium is the
30
+ // guest's own browser, in the same VM and the same network namespace as
31
+ // this daemon, so the browser's loopback IS our loopback. The
32
+ // authorization server never touches the address: a redirect is a browser
33
+ // navigation, not a server-to-server call.
34
+ //
35
+ // LANDMINE — GoTrue (Supabase Auth) refuses a plain-http redirect URI
36
+ // unless it is localhost, and it recognises the NAME. So a Supabase-backed
37
+ // project must use `localhost`, not `127.0.0.1`. `loopbackHost()` below
38
+ // keeps the RFC's preference as the default and the name as the documented
39
+ // override; do not "correct" one into the other without testing both.
40
+ import { secureRandomBytes } from "./ids.js";
41
+ import { rawFetch } from "./harness/raw-fetch.js";
42
+ import { createHash } from "node:crypto";
43
+ /** The user (or the authorization server) refused the grant. */
44
+ export class McpAuthDeniedError extends Error {
45
+ code;
46
+ description;
47
+ constructor(code, description) {
48
+ super(`authorization denied: ${code}${description ? ` — ${description}` : ""}`);
49
+ this.name = "McpAuthDeniedError";
50
+ this.code = code;
51
+ this.description = description;
52
+ }
53
+ }
54
+ const DEFAULT_COMPLETE_TIMEOUT_MS = 120_000;
55
+ /**
56
+ * One authorization attempt, in progress.
57
+ *
58
+ * `authorize()` has already done discovery, registration and PKCE, and has
59
+ * bound the loopback listener. All that is left is the part with a human
60
+ * in it: the test navigates a browser to {@link url}, and then calls
61
+ * {@link complete}.
62
+ */
63
+ export class Authorization {
64
+ /** Send the browser here. */
65
+ url;
66
+ redirectUri;
67
+ state;
68
+ clientId;
69
+ server;
70
+ /** Scopes requested, which may be empty — see {@link AuthorizeOptions.scopes}. */
71
+ scopes;
72
+ resource;
73
+ verifier;
74
+ clientSecret;
75
+ timeoutMs;
76
+ listener;
77
+ settled = false;
78
+ constructor(init) {
79
+ this.url = init.url;
80
+ this.redirectUri = init.redirectUri;
81
+ this.state = init.state;
82
+ this.clientId = init.clientId;
83
+ this.clientSecret = init.clientSecret;
84
+ this.server = init.server;
85
+ this.scopes = init.scopes;
86
+ this.resource = init.resource;
87
+ this.verifier = init.verifier;
88
+ this.timeoutMs = init.timeoutMs;
89
+ this.listener = init.listener;
90
+ }
91
+ /**
92
+ * Wait for the redirect, then exchange the code for a token.
93
+ *
94
+ * With the default loopback redirect there is nothing to pass: the
95
+ * listener already holds the code, and this returns at once when the
96
+ * browser has landed. Pass `url` when the flow used a redirect URI this
97
+ * daemon does not serve — hand back where the browser ended up
98
+ * (`page.url()`).
99
+ */
100
+ async complete(opts) {
101
+ try {
102
+ const params = opts?.url
103
+ ? new URL(opts.url).searchParams
104
+ : await this.waitForRedirect(opts?.timeoutMs ?? this.timeoutMs);
105
+ const error = params.get("error");
106
+ if (error) {
107
+ throw new McpAuthDeniedError(error, params.get("error_description") ?? undefined);
108
+ }
109
+ const returnedState = params.get("state");
110
+ if (returnedState !== this.state) {
111
+ throw new Error(`authorization state mismatch: expected ${this.state}, got ${returnedState ?? "none"}`);
112
+ }
113
+ const code = params.get("code");
114
+ if (!code)
115
+ throw new Error("the redirect carried no authorization code");
116
+ return await this.exchange(code);
117
+ }
118
+ finally {
119
+ this.settled = true;
120
+ this.listener?.stop();
121
+ }
122
+ }
123
+ /** Release the loopback port without finishing the flow. */
124
+ cancel() {
125
+ if (this.settled)
126
+ return;
127
+ this.settled = true;
128
+ this.listener?.stop();
129
+ }
130
+ async waitForRedirect(timeoutMs) {
131
+ if (!this.listener) {
132
+ throw new Error("this authorization used a custom redirectUri, which spectest does not serve. " +
133
+ "Pass where the browser landed: await auth.complete({ url: page.url() })");
134
+ }
135
+ return this.listener.wait(timeoutMs);
136
+ }
137
+ async exchange(code) {
138
+ const body = new URLSearchParams({
139
+ grant_type: "authorization_code",
140
+ code,
141
+ redirect_uri: this.redirectUri,
142
+ client_id: this.clientId,
143
+ code_verifier: this.verifier,
144
+ });
145
+ // RFC 8707. MCP requires it on the token request too, not only on the
146
+ // authorize request — it is what binds the token to this one server.
147
+ if (this.resource)
148
+ body.set("resource", this.resource);
149
+ if (this.clientSecret)
150
+ body.set("client_secret", this.clientSecret);
151
+ const token = await postToken(this.server.tokenEndpoint, body);
152
+ return {
153
+ accessToken: token.access_token,
154
+ refreshToken: token.refresh_token,
155
+ tokenType: token.token_type ?? "Bearer",
156
+ scopes: splitScope(token.scope) ?? this.scopes,
157
+ resource: this.resource,
158
+ clientId: this.clientId,
159
+ issuer: this.server.issuer,
160
+ redirectUri: this.redirectUri,
161
+ tokenEndpoint: this.server.tokenEndpoint,
162
+ expiresAt: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
163
+ };
164
+ }
165
+ }
166
+ /**
167
+ * Do everything up to the browser: discovery, registration, PKCE, and the
168
+ * loopback listener. Returns the URL to send the user to.
169
+ */
170
+ export async function authorize(serverUrl, resourceMetadataUrl, opts = {}) {
171
+ const prm = await fetchProtectedResourceMetadata(serverUrl, resourceMetadataUrl);
172
+ const issuer = prm?.authorization_servers?.[0] ?? new URL(serverUrl).origin;
173
+ const metadata = await fetchAuthServerMetadata(issuer);
174
+ const resource = opts.resource ?? prm?.resource ?? canonicalResource(serverUrl);
175
+ // A real MCP client does not ask its user for scopes: it uses what the
176
+ // resource advertises, and otherwise sends none and lets the server
177
+ // decide.
178
+ const scopes = opts.scopes ?? prm?.scopes_supported ?? [];
179
+ let listener;
180
+ let redirectUri = opts.redirectUri;
181
+ if (!redirectUri) {
182
+ listener = await startLoopbackListener(opts.loopbackHost ?? "127.0.0.1");
183
+ redirectUri = listener.redirectUri;
184
+ }
185
+ let clientId = opts.clientId;
186
+ let clientSecret = opts.clientSecret;
187
+ let dynamicallyRegistered = false;
188
+ if (!clientId) {
189
+ if (!metadata.registration_endpoint) {
190
+ listener?.stop();
191
+ throw new Error(`${issuer} does not offer dynamic client registration. ` +
192
+ "Pass an existing client: mcp.authorize({ clientId, redirectUri }).");
193
+ }
194
+ const registered = await registerClient(metadata.registration_endpoint, {
195
+ redirectUri,
196
+ clientName: opts.clientName ?? "spectest",
197
+ scopes,
198
+ });
199
+ clientId = registered.client_id;
200
+ clientSecret = registered.client_secret;
201
+ dynamicallyRegistered = true;
202
+ }
203
+ const verifier = base64url(secureRandomBytes(32));
204
+ const challenge = base64url(createHash("sha256").update(verifier).digest());
205
+ const state = base64url(secureRandomBytes(16));
206
+ const url = new URL(metadata.authorization_endpoint);
207
+ url.searchParams.set("response_type", "code");
208
+ url.searchParams.set("client_id", clientId);
209
+ url.searchParams.set("redirect_uri", redirectUri);
210
+ url.searchParams.set("state", state);
211
+ url.searchParams.set("code_challenge", challenge);
212
+ url.searchParams.set("code_challenge_method", "S256");
213
+ if (resource)
214
+ url.searchParams.set("resource", resource);
215
+ if (scopes.length > 0)
216
+ url.searchParams.set("scope", scopes.join(" "));
217
+ return new Authorization({
218
+ url: url.toString(),
219
+ redirectUri,
220
+ state,
221
+ clientId,
222
+ clientSecret,
223
+ server: {
224
+ issuer: metadata.issuer,
225
+ authorizationEndpoint: metadata.authorization_endpoint,
226
+ tokenEndpoint: metadata.token_endpoint,
227
+ registrationEndpoint: metadata.registration_endpoint,
228
+ dynamicallyRegistered,
229
+ },
230
+ scopes,
231
+ resource,
232
+ verifier,
233
+ timeoutMs: opts.timeoutMs ?? DEFAULT_COMPLETE_TIMEOUT_MS,
234
+ listener,
235
+ });
236
+ }
237
+ /** Exchange a refresh token for a new access token. Returns `undefined`
238
+ * when the identity has no refresh token to spend. */
239
+ export async function refreshIdentity(identity, clientSecret) {
240
+ if (!identity.refreshToken)
241
+ return undefined;
242
+ const body = new URLSearchParams({
243
+ grant_type: "refresh_token",
244
+ refresh_token: identity.refreshToken,
245
+ client_id: identity.clientId,
246
+ });
247
+ if (identity.resource)
248
+ body.set("resource", identity.resource);
249
+ if (clientSecret)
250
+ body.set("client_secret", clientSecret);
251
+ const token = await postToken(identity.tokenEndpoint, body);
252
+ return {
253
+ ...identity,
254
+ accessToken: token.access_token,
255
+ // A server that rotates refresh tokens returns a new one; one that
256
+ // does not expects the old one to be reused.
257
+ refreshToken: token.refresh_token ?? identity.refreshToken,
258
+ scopes: splitScope(token.scope) ?? identity.scopes,
259
+ expiresAt: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
260
+ };
261
+ }
262
+ async function postToken(endpoint, body) {
263
+ const res = await rawFetch(endpoint, {
264
+ method: "POST",
265
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
266
+ body: body.toString(),
267
+ });
268
+ const text = await res.text();
269
+ if (!res.ok) {
270
+ // The error body is a JSON object with `error` / `error_description`
271
+ // (RFC 6749 §5.2). Surface both; the code is what a test asserts on.
272
+ let code = `HTTP ${res.status}`;
273
+ let description = text.slice(0, 300);
274
+ try {
275
+ const parsed = JSON.parse(text);
276
+ if (parsed.error)
277
+ code = parsed.error;
278
+ description = parsed.error_description ?? description;
279
+ }
280
+ catch {
281
+ // Not JSON. The raw body is the best description available.
282
+ }
283
+ throw new McpAuthDeniedError(code, description);
284
+ }
285
+ const token = JSON.parse(text);
286
+ if (!token.access_token)
287
+ throw new Error(`token endpoint returned no access_token: ${text}`);
288
+ return token;
289
+ }
290
+ async function registerClient(endpoint, init) {
291
+ const body = {
292
+ client_name: init.clientName,
293
+ redirect_uris: [init.redirectUri],
294
+ grant_types: ["authorization_code", "refresh_token"],
295
+ response_types: ["code"],
296
+ // A loopback client cannot keep a secret (RFC 8252 §8.4).
297
+ token_endpoint_auth_method: "none",
298
+ };
299
+ if (init.scopes.length > 0)
300
+ body["scope"] = init.scopes.join(" ");
301
+ const res = await rawFetch(endpoint, {
302
+ method: "POST",
303
+ headers: { "content-type": "application/json", accept: "application/json" },
304
+ body: JSON.stringify(body),
305
+ });
306
+ const text = await res.text();
307
+ if (!res.ok) {
308
+ throw new Error(`dynamic client registration failed: HTTP ${res.status} — ${text.slice(0, 300)}`);
309
+ }
310
+ const parsed = JSON.parse(text);
311
+ if (!parsed.client_id)
312
+ throw new Error(`registration returned no client_id: ${text}`);
313
+ return parsed;
314
+ }
315
+ async function fetchProtectedResourceMetadata(serverUrl, resourceMetadataUrl) {
316
+ const candidates = resourceMetadataUrl
317
+ ? [resourceMetadataUrl]
318
+ : wellKnownUrls(serverUrl, "oauth-protected-resource");
319
+ for (const url of candidates) {
320
+ const found = await fetchJson(url);
321
+ if (found)
322
+ return found;
323
+ }
324
+ // Legal: a server may protect itself without publishing metadata. The
325
+ // caller then falls back to the server's own origin as the issuer.
326
+ return undefined;
327
+ }
328
+ async function fetchAuthServerMetadata(issuer) {
329
+ for (const url of [
330
+ ...wellKnownUrls(issuer, "oauth-authorization-server"),
331
+ ...wellKnownUrls(issuer, "openid-configuration"),
332
+ ]) {
333
+ const found = await fetchJson(url);
334
+ if (found?.authorization_endpoint && found?.token_endpoint) {
335
+ return { ...found, issuer: found.issuer ?? issuer };
336
+ }
337
+ }
338
+ // MCP's fallback for a server that publishes nothing.
339
+ const base = issuer.replace(/\/$/, "");
340
+ return {
341
+ issuer,
342
+ authorization_endpoint: `${base}/authorize`,
343
+ token_endpoint: `${base}/token`,
344
+ registration_endpoint: `${base}/register`,
345
+ };
346
+ }
347
+ /**
348
+ * Candidate well-known URLs for an issuer, in the order to try them.
349
+ *
350
+ * RFC 8414 inserts the well-known segment BEFORE the issuer's path:
351
+ * `https://host/tenant` is discovered at
352
+ * `https://host/.well-known/oauth-authorization-server/tenant`. OpenID
353
+ * Connect appends instead. A path-carrying issuer therefore has two valid
354
+ * spellings and servers differ on which they serve, so we try the RFC 8414
355
+ * order first and fall back.
356
+ */
357
+ export function wellKnownUrls(issuer, suffix) {
358
+ const url = new URL(issuer);
359
+ const path = url.pathname.replace(/\/$/, "");
360
+ const root = `${url.origin}/.well-known/${suffix}`;
361
+ if (path === "" || path === "/")
362
+ return [root];
363
+ return [`${url.origin}/.well-known/${suffix}${path}`, `${url.origin}${path}/.well-known/${suffix}`, root];
364
+ }
365
+ /** The canonical resource identifier (RFC 8707): the server URL with no
366
+ * fragment, and a lowercase host. */
367
+ export function canonicalResource(serverUrl) {
368
+ const url = new URL(serverUrl);
369
+ url.hash = "";
370
+ url.host = url.host.toLowerCase();
371
+ return url.toString();
372
+ }
373
+ async function fetchJson(url) {
374
+ try {
375
+ const res = await rawFetch(url, { headers: { accept: "application/json" } });
376
+ if (!res.ok)
377
+ return undefined;
378
+ return (await res.json());
379
+ }
380
+ catch {
381
+ // An unreachable or non-JSON endpoint is a miss, not a failure: the
382
+ // caller has other candidates and a documented fallback.
383
+ return undefined;
384
+ }
385
+ }
386
+ function splitScope(scope) {
387
+ if (!scope)
388
+ return undefined;
389
+ const parts = scope.split(/\s+/).filter((s) => s !== "");
390
+ return parts.length > 0 ? parts : undefined;
391
+ }
392
+ export function base64url(bytes) {
393
+ const buf = typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes;
394
+ let binary = "";
395
+ for (const b of buf)
396
+ binary += String.fromCharCode(b);
397
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
398
+ }
399
+ const CALLBACK_PATH = "/callback";
400
+ /** Landing page. The browser is a real browser driven by a test, so this
401
+ * is what a screenshot of the last step will show. */
402
+ const CALLBACK_HTML = `<!doctype html>
403
+ <meta charset="utf-8">
404
+ <title>Authorized</title>
405
+ <body style="font: 16px system-ui; padding: 3rem; color: #222">
406
+ <h1 style="font-size: 1.25rem">Authorized</h1>
407
+ <p>You can close this window.</p>
408
+ `;
409
+ async function startLoopbackListener(host) {
410
+ let resolve;
411
+ const received = new Promise((r) => {
412
+ resolve = r;
413
+ });
414
+ // Port 0 asks the kernel for a free port. It is registered with the
415
+ // authorization server a moment later, which is exactly why dynamic
416
+ // client registration exists (RFC 8252 §7.3 also forbids a server from
417
+ // pinning the port of a loopback redirect).
418
+ const bun = globalThis.Bun;
419
+ if (!bun)
420
+ throw new Error("the OAuth loopback listener needs Bun's HTTP server");
421
+ const server = bun.serve({
422
+ hostname: host,
423
+ port: 0,
424
+ fetch(req) {
425
+ const url = new URL(req.url);
426
+ if (url.pathname !== CALLBACK_PATH)
427
+ return new Response("not found", { status: 404 });
428
+ resolve?.(url.searchParams);
429
+ return new Response(CALLBACK_HTML, {
430
+ status: 200,
431
+ headers: { "content-type": "text/html; charset=utf-8" },
432
+ });
433
+ },
434
+ });
435
+ return {
436
+ redirectUri: `http://${host}:${server.port}${CALLBACK_PATH}`,
437
+ async wait(timeoutMs) {
438
+ let timer;
439
+ const expired = new Promise((_, reject) => {
440
+ timer = setTimeout(() => reject(new Error(`no authorization redirect arrived within ${timeoutMs} ms. ` +
441
+ "Did the test navigate a browser to auth.url and complete the sign-in?")), timeoutMs);
442
+ });
443
+ try {
444
+ return await Promise.race([received, expired]);
445
+ }
446
+ finally {
447
+ if (timer)
448
+ clearTimeout(timer);
449
+ }
450
+ },
451
+ stop() {
452
+ server.stop(true);
453
+ },
454
+ };
455
+ }
@@ -0,0 +1,130 @@
1
+ /** A JSON-RPC id. The client only ever mints numbers. */
2
+ export type JsonRpcId = number | string;
3
+ export interface JsonRpcRequest {
4
+ jsonrpc: "2.0";
5
+ id: JsonRpcId;
6
+ method: string;
7
+ params?: unknown;
8
+ }
9
+ export interface JsonRpcNotification {
10
+ jsonrpc: "2.0";
11
+ method: string;
12
+ params?: unknown;
13
+ }
14
+ export interface JsonRpcError {
15
+ code: number;
16
+ message: string;
17
+ data?: unknown;
18
+ }
19
+ export interface JsonRpcResponse {
20
+ jsonrpc: "2.0";
21
+ id: JsonRpcId;
22
+ result?: unknown;
23
+ error?: JsonRpcError;
24
+ }
25
+ /**
26
+ * The `WWW-Authenticate` challenge an MCP server sends with a `401`.
27
+ *
28
+ * `resourceMetadataUrl` is the entry point of the whole OAuth flow
29
+ * (RFC 9728). A server that omits it forces the client to guess the
30
+ * metadata location from its own URL, which `mcp-auth.ts` does.
31
+ */
32
+ export interface McpChallenge {
33
+ /** The HTTP status that carried the challenge. */
34
+ status: number;
35
+ /** Almost always `Bearer`. */
36
+ scheme: string;
37
+ /** `resource_metadata` parameter — where the protected-resource
38
+ * metadata lives. */
39
+ resourceMetadataUrl?: string;
40
+ /** `scope` parameter, when the server names what it wants. */
41
+ scope?: string;
42
+ /** `error` parameter, e.g. `invalid_token` for an expired token. */
43
+ error?: string;
44
+ /** The header, verbatim. Recorded so a test can assert on it. */
45
+ raw: string;
46
+ }
47
+ /** An HTTP-level failure from the MCP endpoint. A `401` carries the
48
+ * parsed {@link McpChallenge}, which is what makes "the server rejected
49
+ * this call" assertable in a test. */
50
+ export declare class McpHttpError extends Error {
51
+ readonly status: number;
52
+ readonly challenge?: McpChallenge;
53
+ readonly body?: string;
54
+ constructor(status: number, message: string, challenge?: McpChallenge, body?: string);
55
+ }
56
+ /** A JSON-RPC error result. The request reached the server and the server
57
+ * answered with an error object. */
58
+ export declare class McpRpcError extends Error {
59
+ readonly code: number;
60
+ readonly data?: unknown;
61
+ constructor(error: JsonRpcError);
62
+ }
63
+ /** Parse a `WWW-Authenticate` header. Returns `undefined` when there is
64
+ * no header, so a bare `401` still reports a status with no challenge. */
65
+ export declare function parseChallenge(header: string | null, status: number): McpChallenge | undefined;
66
+ /** One HTTP exchange, for the step detail panel. Bodies are the parsed
67
+ * JSON-RPC messages, not raw text. */
68
+ export interface HttpExchange {
69
+ method: string;
70
+ url: string;
71
+ status: number;
72
+ durationMs: number;
73
+ sessionId?: string;
74
+ /** Set when the reply was an SSE stream rather than a JSON body. */
75
+ streamed?: boolean;
76
+ }
77
+ export interface McpTransportOptions {
78
+ url: string;
79
+ /** Extra headers on every request (a static API key, a tenant id). */
80
+ headers?: Record<string, string>;
81
+ /** Read at call time, so a token minted mid-session applies at once. */
82
+ token?: () => string | undefined;
83
+ /** Default per-request budget. */
84
+ timeoutMs?: number;
85
+ /** Called for every server notification. */
86
+ onNotification?: (n: JsonRpcNotification) => void;
87
+ /** Called for every server-to-client request. Resolve with the result,
88
+ * or throw to answer with a JSON-RPC error. */
89
+ onRequest?: (r: JsonRpcRequest) => Promise<unknown>;
90
+ /** Called after every HTTP exchange, for the recorder. */
91
+ onExchange?: (x: HttpExchange) => void;
92
+ }
93
+ /** The revision we speak. A server that wants an older one negotiates it
94
+ * in the `initialize` result and we echo whatever it chose. */
95
+ export declare const PROTOCOL_VERSION = "2025-06-18";
96
+ export declare class McpTransport {
97
+ readonly url: string;
98
+ /** Issued by the server on `initialize`, echoed on every later request.
99
+ * Absent for a stateless server, which is legal. */
100
+ sessionId?: string;
101
+ /** Negotiated on `initialize`. Sent on every request after that. */
102
+ protocolVersion?: string;
103
+ private nextId;
104
+ private readonly opts;
105
+ constructor(opts: McpTransportOptions);
106
+ /** Send a request and resolve with its result. Throws
107
+ * {@link McpHttpError} for a transport failure and {@link McpRpcError}
108
+ * for a JSON-RPC error result. */
109
+ request<T = unknown>(method: string, params?: unknown, opts?: {
110
+ timeoutMs?: number;
111
+ }): Promise<T>;
112
+ /** Send a notification. Nothing comes back. */
113
+ notify(method: string, params?: unknown, opts?: {
114
+ timeoutMs?: number;
115
+ }): Promise<void>;
116
+ /** End the session. Best-effort: a server that does not support
117
+ * `DELETE` answers 405, which is not an error for us. */
118
+ close(): Promise<void>;
119
+ private headers;
120
+ private post;
121
+ /** Read the reply to request `id`. The body is either one JSON-RPC
122
+ * response, or an SSE stream that carries it — possibly after
123
+ * server-to-client traffic that belongs to the same request. */
124
+ private readResponse;
125
+ /** Handle one server-to-client request and post the answer back. */
126
+ private answer;
127
+ }
128
+ /** The `data` of one SSE frame, or `undefined` when it carries none.
129
+ * Exported for tests. */
130
+ export declare function sseData(frame: string): string | undefined;