@jini-ai/mcp 0.1.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.
Files changed (86) hide show
  1. package/LICENSE +201 -0
  2. package/dist/agent-install/index.d.ts +9 -0
  3. package/dist/agent-install/index.d.ts.map +1 -0
  4. package/dist/agent-install/index.js +9 -0
  5. package/dist/agent-install/index.js.map +1 -0
  6. package/dist/agent-install/install.d.ts +97 -0
  7. package/dist/agent-install/install.d.ts.map +1 -0
  8. package/dist/agent-install/install.js +410 -0
  9. package/dist/agent-install/install.js.map +1 -0
  10. package/dist/bin/serve.d.ts +75 -0
  11. package/dist/bin/serve.d.ts.map +1 -0
  12. package/dist/bin/serve.js +79 -0
  13. package/dist/bin/serve.js.map +1 -0
  14. package/dist/client/client.d.ts +48 -0
  15. package/dist/client/client.d.ts.map +1 -0
  16. package/dist/client/client.js +242 -0
  17. package/dist/client/client.js.map +1 -0
  18. package/dist/client/index.d.ts +8 -0
  19. package/dist/client/index.d.ts.map +1 -0
  20. package/dist/client/index.js +8 -0
  21. package/dist/client/index.js.map +1 -0
  22. package/dist/core/config.d.ts +157 -0
  23. package/dist/core/config.d.ts.map +1 -0
  24. package/dist/core/config.js +475 -0
  25. package/dist/core/config.js.map +1 -0
  26. package/dist/core/index.d.ts +14 -0
  27. package/dist/core/index.d.ts.map +1 -0
  28. package/dist/core/index.js +14 -0
  29. package/dist/core/index.js.map +1 -0
  30. package/dist/core/install-info.d.ts +69 -0
  31. package/dist/core/install-info.d.ts.map +1 -0
  32. package/dist/core/install-info.js +79 -0
  33. package/dist/core/install-info.js.map +1 -0
  34. package/dist/core/oauth.d.ts +274 -0
  35. package/dist/core/oauth.d.ts.map +1 -0
  36. package/dist/core/oauth.js +667 -0
  37. package/dist/core/oauth.js.map +1 -0
  38. package/dist/core/secure-write.d.ts +17 -0
  39. package/dist/core/secure-write.d.ts.map +1 -0
  40. package/dist/core/secure-write.js +72 -0
  41. package/dist/core/secure-write.js.map +1 -0
  42. package/dist/core/tokens.d.ts +72 -0
  43. package/dist/core/tokens.d.ts.map +1 -0
  44. package/dist/core/tokens.js +197 -0
  45. package/dist/core/tokens.js.map +1 -0
  46. package/dist/index.d.ts +28 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +29 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/server/daemon-client.d.ts +21 -0
  51. package/dist/server/daemon-client.d.ts.map +1 -0
  52. package/dist/server/daemon-client.js +176 -0
  53. package/dist/server/daemon-client.js.map +1 -0
  54. package/dist/server/index.d.ts +18 -0
  55. package/dist/server/index.d.ts.map +1 -0
  56. package/dist/server/index.js +18 -0
  57. package/dist/server/index.js.map +1 -0
  58. package/dist/server/resource-protocol.d.ts +38 -0
  59. package/dist/server/resource-protocol.d.ts.map +1 -0
  60. package/dist/server/resource-protocol.js +68 -0
  61. package/dist/server/resource-protocol.js.map +1 -0
  62. package/dist/server/resources/active-resource.d.ts +12 -0
  63. package/dist/server/resources/active-resource.d.ts.map +1 -0
  64. package/dist/server/resources/active-resource.js +46 -0
  65. package/dist/server/resources/active-resource.js.map +1 -0
  66. package/dist/server/tool-protocol.d.ts +45 -0
  67. package/dist/server/tool-protocol.d.ts.map +1 -0
  68. package/dist/server/tool-protocol.js +74 -0
  69. package/dist/server/tool-protocol.js.map +1 -0
  70. package/dist/server/tool-server.d.ts +105 -0
  71. package/dist/server/tool-server.d.ts.map +1 -0
  72. package/dist/server/tool-server.js +96 -0
  73. package/dist/server/tool-server.js.map +1 -0
  74. package/dist/server/tools/delegated-tool.d.ts +15 -0
  75. package/dist/server/tools/delegated-tool.d.ts.map +1 -0
  76. package/dist/server/tools/delegated-tool.js +90 -0
  77. package/dist/server/tools/delegated-tool.js.map +1 -0
  78. package/dist/server/tools/run-tools.d.ts +14 -0
  79. package/dist/server/tools/run-tools.d.ts.map +1 -0
  80. package/dist/server/tools/run-tools.js +142 -0
  81. package/dist/server/tools/run-tools.js.map +1 -0
  82. package/dist/server/tools/tool-catalog-tools.d.ts +8 -0
  83. package/dist/server/tools/tool-catalog-tools.d.ts.map +1 -0
  84. package/dist/server/tools/tool-catalog-tools.js +66 -0
  85. package/dist/server/tools/tool-catalog-tools.js.map +1 -0
  86. package/package.json +50 -0
@@ -0,0 +1,667 @@
1
+ /**
2
+ * @module @jini-ai/mcp/core/oauth
3
+ * Daemon-side OAuth 2.1 / PKCE client for remote (HTTP / SSE) MCP servers:
4
+ * auth-server discovery, dynamic client registration, the authorize/token/refresh
5
+ * exchanges, and the in-memory `PendingAuthCache`. Part of the MCP `core` kernel;
6
+ * depends on no sibling subdirectory.
7
+ */
8
+ // Daemon-side OAuth 2.1 client for HTTP / SSE MCP servers.
9
+ //
10
+ // Replaces the per-agent `mcp-remote` subprocess that bound a transient
11
+ // `localhost:<port>` listener — that pattern can never work for a cloud-
12
+ // deployed daemon (the user's browser can't reach the listener) and it
13
+ // also broke locally because the listener died with the agent turn.
14
+ //
15
+ // What this module owns:
16
+ // - Discovery of the auth server for a given MCP URL
17
+ // (RFC 9728 protected-resource → RFC 8414 authorization-server).
18
+ // - Dynamic Client Registration (RFC 7591) when the server supports it,
19
+ // cached per `(authServerUrl, redirectUri)` in `<dataDir>/mcp-oauth-clients.json`
20
+ // so we register once and reuse forever.
21
+ // - PKCE (RFC 7636) code-verifier / code-challenge generation.
22
+ // - Authorization-code → token exchange and refresh-token rotation.
23
+ // - In-memory state cache keyed by the `state` parameter, used to look
24
+ // up the originating server + verifier when the browser hits our
25
+ // callback endpoint.
26
+ //
27
+ // Token persistence lives in `tokens.ts`. This file is the protocol
28
+ // layer; storage is somebody else's job.
29
+ import { readFile } from 'node:fs/promises';
30
+ import { createHash, randomBytes } from 'node:crypto';
31
+ import path from 'node:path';
32
+ import { Agent } from 'undici';
33
+ import { assertSafePublicUrl, createValidatingLookup } from '@jini-ai/platform';
34
+ import { writeSecretFileAtomic } from './secure-write.js';
35
+ // ───────────────────────────────────────────────────────────────────────
36
+ // Outbound-fetch safety (SEC-RB-001 / CR-005).
37
+ // ───────────────────────────────────────────────────────────────────────
38
+ //
39
+ // Every network hop below — protected-resource discovery, authorization-
40
+ // server discovery, dynamic client registration, and the token endpoint —
41
+ // fetches a URL that is ultimately caller- or metadata-controlled (a
42
+ // user-supplied MCP server URL, or an endpoint a remote server's own
43
+ // discovery document names). A hostile or compromised server can point any
44
+ // of these at an internal service, a cloud-metadata endpoint, or an
45
+ // attacker-controlled host that redirects or replies with an oversized /
46
+ // slow response. `safeOAuthFetch` is the single choke point all of that
47
+ // traffic goes through:
48
+ // - HTTPS-only (no plaintext downgrade).
49
+ // - `@jini-ai/platform`'s `assertSafePublicUrl` rejects embedded credentials,
50
+ // localhost, and literal private/link-local IPs before any socket opens.
51
+ // - A `createValidatingLookup`-wrapped dispatcher re-validates the
52
+ // *actual* resolved address at connection time, closing the
53
+ // DNS-rebinding / TOCTOU gap a one-time pre-check would leave open —
54
+ // mirrors `packages/deploy/src/reachability.ts` and
55
+ // `packages/platform/src/asset-cache.ts`, the two existing SSRF-safe
56
+ // fetch call sites in this repo.
57
+ // - Redirects are refused (`redirect: 'error'` plus an explicit
58
+ // status/type check, so a test double that bypasses real fetch
59
+ // semantics is still caught) rather than followed.
60
+ // - Every response is read through a byte-capped reader and every
61
+ // request carries an `AbortSignal`-based timeout.
62
+ const OAUTH_FETCH_TIMEOUT_MS = 10_000;
63
+ const OAUTH_FETCH_MAX_BYTES = 1_000_000; // 1 MB — generous for OAuth metadata/token JSON
64
+ function assertSafeOAuthUrl(raw) {
65
+ const url = assertSafePublicUrl(raw);
66
+ if (url.protocol !== 'https:') {
67
+ // OAuth authorization servers always serve discovery/DCR/token endpoints
68
+ // over TLS; a plaintext candidate is never legitimate and downgrading a
69
+ // request that may carry client secrets or tokens to http is its own risk.
70
+ throw new Error(`OAuth endpoint must use https: ${raw}`);
71
+ }
72
+ return url;
73
+ }
74
+ // A fresh Agent per call would needlessly discard connection pooling; the
75
+ // common (no test override) case shares one lazily-created dispatcher. A
76
+ // caller-supplied `lookupImpl` (tests only) always gets its own dispatcher
77
+ // instead of touching the shared one — mirrors reachability.ts's own
78
+ // `resolveDispatcher`.
79
+ let defaultOAuthDispatcher = null;
80
+ function resolveOAuthDispatcher(lookupImpl) {
81
+ if (lookupImpl) {
82
+ return new Agent({ connect: { lookup: createValidatingLookup(lookupImpl) } });
83
+ }
84
+ defaultOAuthDispatcher ??= new Agent({ connect: { lookup: createValidatingLookup() } });
85
+ return defaultOAuthDispatcher;
86
+ }
87
+ function isRedirectResponse(res) {
88
+ return (res.status >= 300 && res.status < 400) || res.type === 'opaqueredirect';
89
+ }
90
+ /**
91
+ * Read a response body up to `maxBytes`, aborting `controller` (when given)
92
+ * and throwing the moment the cap is exceeded rather than buffering an
93
+ * unbounded body first. Falls back to a single `res.text()` call (tolerant
94
+ * of a throw, matching the old `safeText`'s forgiving error-path behavior)
95
+ * for response-like test doubles that don't expose a real `ReadableStream`
96
+ * body.
97
+ *
98
+ * `controller` is real, intentional API surface — a caller that already has
99
+ * an in-flight fetch's `AbortController` can pass it so an oversized
100
+ * response stops being pulled from upstream the moment the cap is exceeded,
101
+ * not merely rejected after the fact — but no real call site in this file
102
+ * currently has one in scope to pass (each of the four call sites reads a
103
+ * `Response` it received after its own `safeOAuthFetch` call already
104
+ * returned, by which point that call's own `AbortController` is out of
105
+ * scope). Exported (not just internal) so this real, functioning behavior
106
+ * is directly unit-testable without inventing an unused call site just to
107
+ * reach it — matching this repo's established "extract into a directly-
108
+ * testable pure function" convention.
109
+ */
110
+ export async function readCappedText(res, maxBytes, controller) {
111
+ const body = res.body;
112
+ if (!body || typeof body.getReader !== 'function') {
113
+ try {
114
+ return await res.text();
115
+ }
116
+ catch {
117
+ return '';
118
+ }
119
+ }
120
+ const reader = body.getReader();
121
+ const chunks = [];
122
+ let total = 0;
123
+ try {
124
+ for (;;) {
125
+ const { done, value } = await reader.read();
126
+ if (done)
127
+ break;
128
+ if (!value)
129
+ continue;
130
+ total += value.byteLength;
131
+ if (total > maxBytes) {
132
+ controller?.abort(); // stop pulling more bytes from upstream
133
+ throw new Error(`response body exceeds ${maxBytes} byte limit`);
134
+ }
135
+ chunks.push(value);
136
+ }
137
+ }
138
+ finally {
139
+ try {
140
+ await reader.cancel();
141
+ }
142
+ catch {
143
+ // reader already closed/errored — nothing to release
144
+ }
145
+ }
146
+ return Buffer.concat(chunks).toString('utf8');
147
+ }
148
+ /** Best-effort, size-capped extraction of an error response's body text for
149
+ * inclusion in a thrown error message. Never throws. */
150
+ async function safeErrorText(res) {
151
+ try {
152
+ const text = await readCappedText(res, OAUTH_FETCH_MAX_BYTES);
153
+ return text.slice(0, 500);
154
+ }
155
+ catch {
156
+ return '';
157
+ }
158
+ }
159
+ /**
160
+ * The single outbound-fetch choke point for this file. Validates the URL,
161
+ * attaches the connection-time SSRF guard, refuses redirects, and enforces a
162
+ * request timeout. Callers still own reading/bounding the response body via
163
+ * `readCappedText`.
164
+ */
165
+ async function safeOAuthFetch(rawUrl, init, fetchImpl, lookupImpl) {
166
+ const url = assertSafeOAuthUrl(rawUrl);
167
+ const controller = new AbortController();
168
+ const timer = setTimeout(() => controller.abort(), OAUTH_FETCH_TIMEOUT_MS);
169
+ try {
170
+ const reqInit = {
171
+ method: init.method ?? 'GET',
172
+ headers: init.headers,
173
+ ...(init.body !== undefined ? { body: init.body } : {}),
174
+ redirect: 'error',
175
+ signal: controller.signal,
176
+ };
177
+ // `dispatcher` is an undici extension of RequestInit; attach it at
178
+ // runtime (see asset-cache.ts / reachability.ts for the same pattern) to
179
+ // avoid the undici-types (bundled with @types/node) vs undici@7
180
+ // Dispatcher version skew a typed field would trip over.
181
+ reqInit.dispatcher = resolveOAuthDispatcher(lookupImpl);
182
+ let res;
183
+ try {
184
+ res = await fetchImpl(url.toString(), reqInit);
185
+ }
186
+ catch (err) {
187
+ throw new Error(`request to ${url.origin} failed: ${err instanceof Error ? err.message : String(err)}`);
188
+ }
189
+ if (isRedirectResponse(res)) {
190
+ // Never follow a redirect: the destination has not been validated,
191
+ // and a hostile or rebound server could point it at an internal
192
+ // address. `redirect: 'error'` already makes a *real* fetch reject
193
+ // before returning a Response; this explicit check is defense in
194
+ // depth for response-like test doubles that don't implement real
195
+ // HTTP redirect semantics.
196
+ throw new Error(`refusing to follow a redirect response from ${url.origin}`);
197
+ }
198
+ return res;
199
+ }
200
+ finally {
201
+ clearTimeout(timer);
202
+ }
203
+ }
204
+ // ───────────────────────────────────────────────────────────────────────
205
+ // PKCE + state helpers.
206
+ // ───────────────────────────────────────────────────────────────────────
207
+ const VERIFIER_LEN = 64; // RFC 7636 §4.1: 43–128 chars
208
+ function base64url(buf) {
209
+ return buf
210
+ .toString('base64')
211
+ .replace(/\+/g, '-')
212
+ .replace(/\//g, '_')
213
+ .replace(/=+$/g, '');
214
+ }
215
+ /**
216
+ * Generate a cryptographically random PKCE code verifier (RFC 7636 §4.1).
217
+ * Produces a 64-byte base64url-encoded string, within the 43–128 character range.
218
+ * @returns A fresh code verifier string for use in a single authorization request.
219
+ */
220
+ export function generateCodeVerifier() {
221
+ return base64url(randomBytes(VERIFIER_LEN));
222
+ }
223
+ /**
224
+ * Derive the S256 PKCE code challenge from a code verifier (RFC 7636 §4.2).
225
+ * Computes `BASE64URL(SHA256(ASCII(verifier)))`.
226
+ * @param verifier The code verifier string produced by `generateCodeVerifier`.
227
+ * @returns The base64url-encoded SHA-256 hash to pass as `code_challenge`.
228
+ */
229
+ export function deriveCodeChallenge(verifier) {
230
+ return base64url(createHash('sha256').update(verifier).digest());
231
+ }
232
+ /**
233
+ * Generate a cryptographically random OAuth `state` parameter.
234
+ * Used as the CSRF token for the authorization request; must be unique per flow.
235
+ * @returns A base64url-encoded 32-byte random string.
236
+ */
237
+ export function generateState() {
238
+ return base64url(randomBytes(32));
239
+ }
240
+ // ───────────────────────────────────────────────────────────────────────
241
+ // Discovery.
242
+ // ───────────────────────────────────────────────────────────────────────
243
+ /**
244
+ * Try to fetch the protected-resource metadata for a given MCP URL.
245
+ *
246
+ * Per RFC 9728, the well-known is at the resource origin's
247
+ * `/.well-known/oauth-protected-resource[<path>]`. We try both the
248
+ * path-suffixed form and the bare `/.well-known/...` so servers that
249
+ * only publish at the root still work.
250
+ *
251
+ * @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
252
+ * guard (tests only — see `safeOAuthFetch`).
253
+ */
254
+ export async function discoverProtectedResource(resourceUrl, fetchImpl = fetch, lookupImpl) {
255
+ let parsed;
256
+ try {
257
+ parsed = new URL(resourceUrl);
258
+ }
259
+ catch {
260
+ return null;
261
+ }
262
+ const candidates = [
263
+ new URL(`/.well-known/oauth-protected-resource${parsed.pathname.replace(/\/+$/u, '')}`, `${parsed.protocol}//${parsed.host}`).toString(),
264
+ new URL('/.well-known/oauth-protected-resource', `${parsed.protocol}//${parsed.host}`).toString(),
265
+ ];
266
+ for (const url of candidates) {
267
+ const json = await fetchJson(url, fetchImpl, lookupImpl);
268
+ if (json)
269
+ return json;
270
+ }
271
+ return null;
272
+ }
273
+ /**
274
+ * Fetch the authorization-server metadata for an issuer URL. Tries both
275
+ * the OAuth (RFC 8414) and OIDC layouts (`/.well-known/oauth-authorization-server`
276
+ * and `/.well-known/openid-configuration`); some providers only publish one.
277
+ *
278
+ * Per RFC 8414 §3.3, a discovery document's `issuer` — when present — MUST
279
+ * match the issuer used to construct the request; a mismatched document is
280
+ * rejected rather than trusted (SEC-RB-001 / CR-005). Every endpoint the
281
+ * document names (`authorization_endpoint`, `token_endpoint`,
282
+ * `registration_endpoint`) is additionally required to be an absolute
283
+ * `https:` URL sharing the queried issuer's own origin, so a compromised or
284
+ * hostile document can't redirect DCR or token exchange at an unrelated
285
+ * origin.
286
+ *
287
+ * @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
288
+ * guard (tests only — see `safeOAuthFetch`).
289
+ */
290
+ export async function discoverAuthServer(issuer, fetchImpl = fetch, lookupImpl) {
291
+ let parsedIssuer;
292
+ try {
293
+ parsedIssuer = new URL(issuer);
294
+ }
295
+ catch {
296
+ return null;
297
+ }
298
+ const trimmed = parsedIssuer.pathname.replace(/\/+$/u, '');
299
+ const base = `${parsedIssuer.protocol}//${parsedIssuer.host}`;
300
+ const candidates = [
301
+ `${base}/.well-known/oauth-authorization-server${trimmed}`,
302
+ `${base}/.well-known/openid-configuration${trimmed}`,
303
+ `${base}/.well-known/oauth-authorization-server`,
304
+ `${base}/.well-known/openid-configuration`,
305
+ ];
306
+ for (const url of candidates) {
307
+ const json = await fetchJson(url, fetchImpl, lookupImpl);
308
+ if (json && typeof json.authorization_endpoint === 'string' && typeof json.token_endpoint === 'string') {
309
+ if (typeof json.issuer === 'string' && json.issuer !== issuer)
310
+ continue; // RFC 8414 §3.3 issuer mismatch
311
+ if (!endpointsShareIssuerOrigin(parsedIssuer, json))
312
+ continue;
313
+ // Spread first so the explicit issuer wins (otherwise duplicate-key
314
+ // assignments under exactOptionalPropertyTypes complain).
315
+ return { ...json, issuer: json.issuer ?? issuer };
316
+ }
317
+ }
318
+ return null;
319
+ }
320
+ function endpointsShareIssuerOrigin(issuerUrl, meta) {
321
+ const endpoints = [meta.authorization_endpoint, meta.token_endpoint, meta.registration_endpoint].filter((v) => typeof v === 'string');
322
+ for (const endpoint of endpoints) {
323
+ let parsed;
324
+ try {
325
+ parsed = new URL(endpoint);
326
+ }
327
+ catch {
328
+ return false;
329
+ }
330
+ if (parsed.protocol !== 'https:' || parsed.origin !== issuerUrl.origin)
331
+ return false;
332
+ }
333
+ return true;
334
+ }
335
+ async function fetchJson(url, fetchImpl, lookupImpl) {
336
+ try {
337
+ const res = await safeOAuthFetch(url, { headers: { accept: 'application/json' } }, fetchImpl, lookupImpl);
338
+ if (!res.ok)
339
+ return null;
340
+ const text = await readCappedText(res, OAUTH_FETCH_MAX_BYTES);
341
+ return JSON.parse(text);
342
+ }
343
+ catch {
344
+ return null;
345
+ }
346
+ }
347
+ function clientsFile(dataDir) {
348
+ return path.join(dataDir, 'mcp-oauth-clients.json');
349
+ }
350
+ async function readClientCache(dataDir) {
351
+ try {
352
+ const raw = await readFile(clientsFile(dataDir), 'utf8');
353
+ const parsed = JSON.parse(raw);
354
+ if (!parsed || !Array.isArray(parsed.clients))
355
+ return { clients: [] };
356
+ return { clients: parsed.clients.filter(isRegisteredClient) };
357
+ }
358
+ catch (err) {
359
+ const e = err;
360
+ if (e.code === 'ENOENT')
361
+ return { clients: [] };
362
+ throw err;
363
+ }
364
+ }
365
+ function isRegisteredClient(v) {
366
+ if (!v || typeof v !== 'object')
367
+ return false;
368
+ const r = v;
369
+ return (typeof r.authServerIssuer === 'string' &&
370
+ typeof r.redirectUri === 'string' &&
371
+ typeof r.clientId === 'string');
372
+ }
373
+ /**
374
+ * Persist the client cache to `<dataDir>/mcp-oauth-clients.json`. May
375
+ * contain a confidential client's `clientSecret` (CR-006 / SEC-RB-002), so
376
+ * the file is created with owner-only (0600) permissions from the very
377
+ * first byte via `writeSecretFileAtomic` rather than a post-rename chmod —
378
+ * see that module for why.
379
+ */
380
+ async function writeClientCache(dataDir, next) {
381
+ await writeSecretFileAtomic(clientsFile(dataDir), JSON.stringify(next, null, 2));
382
+ }
383
+ /**
384
+ * POST to the auth server's `registration_endpoint` per RFC 7591. Returns
385
+ * a freshly issued client_id (and optional client_secret). Caller is
386
+ * responsible for caching the result.
387
+ * @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
388
+ * guard (tests only — see `safeOAuthFetch`).
389
+ */
390
+ export async function registerClient(registrationEndpoint, redirectUri, fetchImpl = fetch, lookupImpl) {
391
+ const body = {
392
+ redirect_uris: [redirectUri],
393
+ token_endpoint_auth_method: 'none',
394
+ grant_types: ['authorization_code', 'refresh_token'],
395
+ response_types: ['code'],
396
+ client_name: 'Jini',
397
+ application_type: 'web',
398
+ };
399
+ const res = await safeOAuthFetch(registrationEndpoint, {
400
+ method: 'POST',
401
+ headers: {
402
+ 'content-type': 'application/json',
403
+ accept: 'application/json',
404
+ },
405
+ body: JSON.stringify(body),
406
+ }, fetchImpl, lookupImpl);
407
+ if (!res.ok) {
408
+ const txt = await safeErrorText(res);
409
+ throw new Error(`dynamic client registration failed: HTTP ${res.status} ${res.statusText} ${txt}`);
410
+ }
411
+ const json = JSON.parse(await readCappedText(res, OAUTH_FETCH_MAX_BYTES));
412
+ if (!json.client_id) {
413
+ throw new Error('dynamic client registration response missing client_id');
414
+ }
415
+ const out = { clientId: json.client_id };
416
+ if (json.client_secret)
417
+ out.clientSecret = json.client_secret;
418
+ return out;
419
+ }
420
+ /**
421
+ * Cached version of `registerClient`. Looks up `(authServerIssuer, redirectUri)`
422
+ * in the cache file and re-uses the existing client; falls back to a fresh
423
+ * DCR call when nothing is cached.
424
+ * @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
425
+ * guard (tests only — see `safeOAuthFetch`).
426
+ */
427
+ export async function getOrRegisterClient(dataDir, authServer, redirectUri, fetchImpl = fetch, lookupImpl) {
428
+ const cache = await readClientCache(dataDir);
429
+ const cached = cache.clients.find((c) => c.authServerIssuer === authServer.issuer && c.redirectUri === redirectUri);
430
+ if (cached)
431
+ return cached;
432
+ if (!authServer.registration_endpoint) {
433
+ throw new Error(`auth server ${authServer.issuer} does not advertise a registration_endpoint and no client is pre-registered`);
434
+ }
435
+ const reg = await registerClient(authServer.registration_endpoint, redirectUri, fetchImpl, lookupImpl);
436
+ const next = {
437
+ authServerIssuer: authServer.issuer,
438
+ redirectUri,
439
+ clientId: reg.clientId,
440
+ registeredAt: Date.now(),
441
+ };
442
+ if (reg.clientSecret)
443
+ next.clientSecret = reg.clientSecret;
444
+ cache.clients.push(next);
445
+ await writeClientCache(dataDir, cache);
446
+ return next;
447
+ }
448
+ /**
449
+ * Assemble the authorization endpoint URL the user's browser must be directed to.
450
+ * Sets PKCE parameters (`code_challenge`, `code_challenge_method=S256`), the `state`
451
+ * CSRF token, and optionally the RFC 8707 `resource` indicator.
452
+ * @param input All parameters needed to build the URL.
453
+ * @returns The fully-qualified authorization URL as a string.
454
+ */
455
+ export function buildAuthorizeUrl(input) {
456
+ const u = new URL(input.authServer.authorization_endpoint);
457
+ u.searchParams.set('response_type', 'code');
458
+ u.searchParams.set('client_id', input.clientId);
459
+ u.searchParams.set('redirect_uri', input.redirectUri);
460
+ u.searchParams.set('state', input.state);
461
+ u.searchParams.set('code_challenge', input.codeChallenge);
462
+ u.searchParams.set('code_challenge_method', 'S256');
463
+ if (input.scope)
464
+ u.searchParams.set('scope', input.scope);
465
+ // RFC 8707 resource indicator — narrows the issued token to the MCP
466
+ // resource we actually care about. Most authoritative MCP servers
467
+ // require it; harmless when ignored.
468
+ if (input.resource)
469
+ u.searchParams.set('resource', input.resource);
470
+ return u.toString();
471
+ }
472
+ /**
473
+ * Exchange an authorization code for access and refresh tokens (RFC 6749 §4.1.3).
474
+ * Includes the PKCE `code_verifier` and, when supplied, the RFC 8707 `resource`
475
+ * indicator. Throws when the token endpoint returns a non-2xx status.
476
+ * @param input The code-exchange parameters.
477
+ * @param fetchImpl Injectable fetch, defaults to the global `fetch`.
478
+ * @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
479
+ * guard (tests only — see `safeOAuthFetch`).
480
+ * @returns The token endpoint response containing at least an `access_token`.
481
+ */
482
+ export async function exchangeCodeForToken(input, fetchImpl = fetch, lookupImpl) {
483
+ const form = new URLSearchParams();
484
+ form.set('grant_type', 'authorization_code');
485
+ form.set('code', input.code);
486
+ form.set('redirect_uri', input.redirectUri);
487
+ form.set('client_id', input.clientId);
488
+ form.set('code_verifier', input.codeVerifier);
489
+ if (input.resource)
490
+ form.set('resource', input.resource);
491
+ return tokenRequest(input.tokenEndpoint, form, input.clientSecret, fetchImpl, lookupImpl);
492
+ }
493
+ /**
494
+ * Exchange a refresh token for a new access token (RFC 6749 §6).
495
+ * Preserves scope and resource binding from the original authorization.
496
+ * Throws when the token endpoint returns a non-2xx status.
497
+ * @param input The refresh parameters.
498
+ * @param fetchImpl Injectable fetch, defaults to the global `fetch`.
499
+ * @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
500
+ * guard (tests only — see `safeOAuthFetch`).
501
+ * @returns A fresh `OAuthTokenResponse`; the server may issue a new refresh token.
502
+ */
503
+ export async function refreshAccessToken(input, fetchImpl = fetch, lookupImpl) {
504
+ const form = new URLSearchParams();
505
+ form.set('grant_type', 'refresh_token');
506
+ form.set('refresh_token', input.refreshToken);
507
+ form.set('client_id', input.clientId);
508
+ if (input.scope)
509
+ form.set('scope', input.scope);
510
+ if (input.resource)
511
+ form.set('resource', input.resource);
512
+ return tokenRequest(input.tokenEndpoint, form, input.clientSecret, fetchImpl, lookupImpl);
513
+ }
514
+ async function tokenRequest(tokenEndpoint, form, clientSecret, fetchImpl, lookupImpl) {
515
+ const headers = {
516
+ 'content-type': 'application/x-www-form-urlencoded',
517
+ accept: 'application/json',
518
+ };
519
+ if (clientSecret) {
520
+ // RFC 6749 §2.3.1 — confidential clients use HTTP Basic with the
521
+ // client_id we already put in the form. Public clients (PKCE-only)
522
+ // skip this branch.
523
+ const basic = Buffer.from(`${form.get('client_id')}:${clientSecret}`).toString('base64');
524
+ headers['authorization'] = `Basic ${basic}`;
525
+ }
526
+ const res = await safeOAuthFetch(tokenEndpoint, { method: 'POST', headers, body: form.toString() }, fetchImpl, lookupImpl);
527
+ if (!res.ok) {
528
+ const txt = await safeErrorText(res);
529
+ throw new Error(`token endpoint rejected request: HTTP ${res.status} ${res.statusText} ${txt}`);
530
+ }
531
+ const json = JSON.parse(await readCappedText(res, OAUTH_FETCH_MAX_BYTES));
532
+ if (!json.access_token) {
533
+ throw new Error('token endpoint response missing access_token');
534
+ }
535
+ return json;
536
+ }
537
+ // ───────────────────────────────────────────────────────────────────────
538
+ // In-memory pending-state cache.
539
+ // ───────────────────────────────────────────────────────────────────────
540
+ /**
541
+ * The OAuth dance is split across two HTTP requests on our side:
542
+ * 1. the "start" request — we mint state + verifier
543
+ * 2. the "callback" request — browser returns code + state
544
+ * State has to survive between (1) and (2) on the daemon. We keep it in a
545
+ * Map with a TTL sweeper; persistence isn't needed because the user has
546
+ * to complete auth in the same daemon process anyway (state is single-use).
547
+ */
548
+ export class PendingAuthCache {
549
+ ttlMs;
550
+ store = new Map();
551
+ timer = null;
552
+ constructor(ttlMs = 10 * 60 * 1000) {
553
+ this.ttlMs = ttlMs;
554
+ }
555
+ /**
556
+ * Store a pending auth state keyed by the OAuth `state` parameter.
557
+ * Starts the TTL sweeper if it is not already running.
558
+ * @param state The random `state` string from the authorization request.
559
+ * @param value The associated pending auth metadata to stash.
560
+ */
561
+ put(state, value) {
562
+ this.store.set(state, value);
563
+ this.startSweeper();
564
+ }
565
+ /** One-shot consume — any successful callback removes the state so a
566
+ * replay can't reuse it. */
567
+ consume(state) {
568
+ const v = this.store.get(state);
569
+ if (!v)
570
+ return null;
571
+ this.store.delete(state);
572
+ if (Date.now() - v.createdAt > this.ttlMs)
573
+ return null;
574
+ return v;
575
+ }
576
+ /** Return the number of pending auth states currently held in the cache. */
577
+ size() {
578
+ return this.store.size;
579
+ }
580
+ /** Stop the background sweeper. Used by tests; production lets the
581
+ * timer ride on the process lifetime. */
582
+ stop() {
583
+ if (this.timer) {
584
+ clearInterval(this.timer);
585
+ this.timer = null;
586
+ }
587
+ }
588
+ startSweeper() {
589
+ if (this.timer)
590
+ return;
591
+ this.timer = setInterval(() => this.sweep(), Math.min(this.ttlMs, 60_000));
592
+ // unref so the cache doesn't keep the event loop alive in tests
593
+ this.timer.unref();
594
+ }
595
+ sweep() {
596
+ const now = Date.now();
597
+ for (const [k, v] of this.store) {
598
+ if (now - v.createdAt > this.ttlMs)
599
+ this.store.delete(k);
600
+ }
601
+ if (this.store.size === 0 && this.timer) {
602
+ clearInterval(this.timer);
603
+ this.timer = null;
604
+ }
605
+ }
606
+ }
607
+ /**
608
+ * Run the entire pre-redirect half of the OAuth dance:
609
+ * discovery → DCR (cached) → PKCE → authorize URL.
610
+ *
611
+ * Returns everything the caller needs to (a) push the user's browser at the
612
+ * correct authorize URL, and (b) finish the flow when the callback hits.
613
+ */
614
+ export async function beginAuth(input) {
615
+ const fetchImpl = input.fetchImpl ?? fetch;
616
+ const lookupImpl = input.lookupImpl;
617
+ // Step 1: ask the MCP server who its auth server is. If the server
618
+ // doesn't publish protected-resource metadata, fall back to assuming
619
+ // the resource origin IS the auth server — most "stand-alone" MCP
620
+ // providers host both at the same host.
621
+ const prm = await discoverProtectedResource(input.serverUrl, fetchImpl, lookupImpl);
622
+ const issuerHint = prm?.authorization_servers?.[0];
623
+ const issuer = issuerHint ?? new URL(input.serverUrl).origin;
624
+ // Step 2: discovery on the auth server.
625
+ const authServer = await discoverAuthServer(issuer, fetchImpl, lookupImpl);
626
+ if (!authServer) {
627
+ throw new Error(`could not discover OAuth metadata for ${issuer}`);
628
+ }
629
+ // Step 3: ensure we have a registered client_id (DCR if missing).
630
+ const client = await getOrRegisterClient(input.dataDir, authServer, input.redirectUri, fetchImpl, lookupImpl);
631
+ // Step 4: PKCE + state.
632
+ const codeVerifier = generateCodeVerifier();
633
+ const codeChallenge = deriveCodeChallenge(codeVerifier);
634
+ const state = generateState();
635
+ const scope = input.scope ??
636
+ (Array.isArray(prm?.scopes_supported) && prm.scopes_supported.length > 0
637
+ ? prm.scopes_supported.join(' ')
638
+ : authServer.scopes_supported?.join(' '));
639
+ const resource = prm?.resource ?? input.serverUrl;
640
+ const authUrlInput = {
641
+ authServer,
642
+ clientId: client.clientId,
643
+ redirectUri: input.redirectUri,
644
+ state,
645
+ codeChallenge,
646
+ resource,
647
+ };
648
+ if (scope)
649
+ authUrlInput.scope = scope;
650
+ const authorizeUrl = buildAuthorizeUrl(authUrlInput);
651
+ const pending = {
652
+ serverId: input.serverId,
653
+ authServerIssuer: authServer.issuer,
654
+ tokenEndpoint: authServer.token_endpoint,
655
+ clientId: client.clientId,
656
+ redirectUri: input.redirectUri,
657
+ codeVerifier,
658
+ resourceUrl: resource,
659
+ createdAt: Date.now(),
660
+ };
661
+ if (client.clientSecret)
662
+ pending.clientSecret = client.clientSecret;
663
+ if (scope)
664
+ pending.scope = scope;
665
+ return { authorizeUrl, state, pending };
666
+ }
667
+ //# sourceMappingURL=oauth.js.map