@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/dist/mcp-auth.js
ADDED
|
@@ -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;
|