pi-mcp-client 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  getAgentDir
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { Type } from "typebox";
9
- import { Text as Text4 } from "@earendil-works/pi-tui";
9
+ import { Text as Text5 } from "@earendil-works/pi-tui";
10
10
 
11
11
  // src/prompt-command.ts
12
12
  import { BorderedLoader } from "@earendil-works/pi-coding-agent";
@@ -1770,9 +1770,9 @@ var defaultOptions = {
1770
1770
  fields: void 0,
1771
1771
  searchOptions: void 0,
1772
1772
  storeFields: [],
1773
- logger: (level, message2) => {
1773
+ logger: (level, message3) => {
1774
1774
  if (typeof (console === null || console === void 0 ? void 0 : console[level]) === "function")
1775
- console[level](message2);
1775
+ console[level](message3);
1776
1776
  },
1777
1777
  autoVacuum: true
1778
1778
  };
@@ -1845,6 +1845,7 @@ import {
1845
1845
  OAuthError,
1846
1846
  RegistrationRejectedError,
1847
1847
  InsecureTokenEndpointError,
1848
+ InsufficientScopeError,
1848
1849
  ProtocolError,
1849
1850
  ProtocolErrorCode,
1850
1851
  ResourceNotFoundError,
@@ -1887,10 +1888,12 @@ var messages = {
1887
1888
  oauth_client_rejected: "The authorization server rejected the OAuth client or its authentication method.",
1888
1889
  oauth_pkce_unsupported: "The authorization server does not support the required S256 PKCE method.",
1889
1890
  oauth_scope_rejected: "The authorization server rejected the requested OAuth scopes.",
1891
+ oauth_scope_required: "The server requires additional OAuth permissions.",
1890
1892
  oauth_grant_rejected: "The OAuth authorization code or refresh token was rejected.",
1891
1893
  oauth_redirect_rejected: "The authorization server rejected the OAuth callback URL.",
1892
1894
  oauth_endpoint_insecure: "The OAuth token endpoint is not secure.",
1893
1895
  oauth_issuer_changed: "The OAuth authorization server changed.",
1896
+ oauth_dpop_unavailable: "The OAuth DPoP signing key is unavailable or DPoP is disabled.",
1894
1897
  callback_unavailable: "The local OAuth callback port is unavailable.",
1895
1898
  busy: "Discovery is still running.",
1896
1899
  operation_failed: "The operation failed."
@@ -1932,11 +1935,13 @@ function diagnostic(code, context) {
1932
1935
  oauth_registration_rejected: `Check whether the service allows public/native client registration and the callback URL shown by /mcp get ${target}. If registration is restricted, configure an approved public client with oauthClientId, run /mcp reload, then retry /mcp login ${target}.`,
1933
1936
  oauth_client_rejected: `Verify oauthClientId, the app's approval, and support for public-client authentication (token endpoint method none). Clients requiring a client secret are not supported. After configuration changes, run /mcp reload and /mcp login ${target}.`,
1934
1937
  oauth_pkce_unsupported: "Use an authorization server or app configuration that supports authorization-code login with S256 PKCE. This client cannot fall back to login without PKCE.",
1938
+ oauth_scope_required: `Run /mcp login ${target} to review the server's requested scopes. If no review appears, check oauthScopes against the service's requirements. No authorization or replay was attempted; retry only after approval.`,
1935
1939
  oauth_scope_rejected: `Check oauthScopes against the service's allowed scopes and app permissions. Run /mcp reload after changes, then /mcp login ${target}.`,
1936
1940
  oauth_grant_rejected: `Run /mcp login ${target} for a fresh authorization code. If it fails again, verify the registered client and exact callback URL shown by /mcp get ${target}.`,
1937
1941
  oauth_redirect_rejected: `Register the exact callback URL shown by /mcp get ${target}, including its host, port, and /callback path. --no-browser uses the same callback URL and does not bypass redirect validation.`,
1938
1942
  oauth_endpoint_insecure: "Use an authorization server with an HTTPS token endpoint. HTTP is only allowed for loopback endpoints; do not disable TLS verification.",
1939
1943
  oauth_issuer_changed: `Verify the server configuration before running /mcp logout ${target} and /mcp login ${target} to trust the new authorization server.`,
1944
+ oauth_dpop_unavailable: `Enable oauthDpop and run /mcp reload, then /mcp login ${target}. If you intentionally disabled DPoP, or the stored key is corrupt or mismatched, run /mcp logout ${target} before signing in again. Bound access or refresh tokens cannot be used without their signing key.`,
1940
1945
  callback_unavailable: `Free the configured callback port, change oauthCallbackPort, or run /mcp login ${target} --no-browser.`,
1941
1946
  busy: "Wait for the current MCP operation to finish, then retry.",
1942
1947
  operation_failed: "Check /mcp for server status and verify the server configuration."
@@ -1988,6 +1993,8 @@ function diagnose(error, context) {
1988
1993
  for (let depth = 0; depth < 4 && current instanceof Error; depth++) {
1989
1994
  if (current instanceof DiagnosticError)
1990
1995
  return diagnostic(current.diagnostic.code, context);
1996
+ if (current instanceof InsufficientScopeError)
1997
+ return diagnostic("oauth_scope_required", context);
1991
1998
  if (current instanceof UnauthorizedError)
1992
1999
  return diagnostic("authentication_required", context);
1993
2000
  if (current instanceof SdkHttpError) {
@@ -2198,6 +2205,7 @@ var OPTION_FIELDS = [
2198
2205
  "oauthClientId",
2199
2206
  "oauthScopes",
2200
2207
  "oauthCallbackPort",
2208
+ "oauthDpop",
2201
2209
  "disabled",
2202
2210
  "includeTools",
2203
2211
  "excludeTools",
@@ -2219,6 +2227,7 @@ function validateOptions(entry, fail) {
2219
2227
  fail("oauthScopes (requires OAuth and 1\u2013100 unique OAuth scope tokens)");
2220
2228
  if (entry.oauthCallbackPort !== void 0 && (!Number.isInteger(entry.oauthCallbackPort) || Number(entry.oauthCallbackPort) < 1 || Number(entry.oauthCallbackPort) > 65535))
2221
2229
  fail("oauthCallbackPort (requires OAuth and a port from 1\u201365535)");
2230
+ if (entry.oauthDpop !== void 0 && typeof entry.oauthDpop !== "boolean") fail("oauthDpop (requires a boolean)");
2222
2231
  if (entry.disabled !== void 0 && typeof entry.disabled !== "boolean") fail("disabled");
2223
2232
  for (const field of ["timeoutMs", "startupTimeoutMs", "toolTimeoutMs"]) {
2224
2233
  if (entry[field] !== void 0 && (!Number.isInteger(entry[field]) || Number(entry[field]) < 100 || Number(entry[field]) > 6e5))
@@ -2273,7 +2282,7 @@ function parseConnections(value, source) {
2273
2282
  fail("transport (provide exactly one of command or url)");
2274
2283
  if (entry.type === "stdio" && !entry.command || entry.type === "http" && !entry.url)
2275
2284
  fail("type (must match command or url)");
2276
- if (entry.command && (entry.headers || entry.oauthClientId !== void 0 || entry.oauthScopes !== void 0 || entry.oauthCallbackPort !== void 0))
2285
+ if (entry.command && (entry.headers || entry.oauthClientId !== void 0 || entry.oauthScopes !== void 0 || entry.oauthCallbackPort !== void 0 || entry.oauthDpop !== void 0))
2277
2286
  fail("HTTP options on stdio transport");
2278
2287
  if (entry.url && (entry.args || entry.cwd || entry.env))
2279
2288
  fail("stdio options on HTTP transport");
@@ -2444,7 +2453,7 @@ function resolveServer(config, cwd) {
2444
2453
  throw new Error("MCP URLs must use HTTP(S), without credentials or fragments.");
2445
2454
  result.url = url.href;
2446
2455
  }
2447
- if ((result.oauthClientId !== void 0 || result.oauthScopes !== void 0 || result.oauthCallbackPort !== void 0) && Object.keys(result.headers ?? {}).some(
2456
+ if ((result.oauthClientId !== void 0 || result.oauthScopes !== void 0 || result.oauthCallbackPort !== void 0 || result.oauthDpop !== void 0) && Object.keys(result.headers ?? {}).some(
2448
2457
  (key) => key.toLowerCase() === "authorization"
2449
2458
  ))
2450
2459
  throw new Error("Use OAuth or an Authorization header, not both.");
@@ -2581,7 +2590,7 @@ function searchTools(tools, query, server, limit = DEFAULT_SEARCH_LIMIT) {
2581
2590
  }
2582
2591
 
2583
2592
  // src/config-commands.ts
2584
- var ADD_USAGE = "Usage: /mcp add --scope global|project [--replace] [--transport http|stdio] [--header 'Name: value'] [--env KEY=value] [--oauth-client-id ID] [--oauth-scope SCOPE] [--oauth-callback-port PORT] <server> <url> OR <server> -- <command> [args...]. Put options before the server name.";
2593
+ var ADD_USAGE = "Usage: /mcp add --scope global|project [--replace] [--transport http|stdio] [--header 'Name: value'] [--env KEY=value] [--oauth-client-id ID] [--oauth-scope SCOPE] [--oauth-callback-port PORT] [--oauth-dpop] <server> <url> OR <server> -- <command> [args...]. Put options before the server name.";
2585
2594
  var REMOVE_USAGE = "Usage: /mcp remove --scope global|project <server>. Credentials are retained; log out first if you want to remove them.";
2586
2595
  function commandWords(input) {
2587
2596
  const words = [];
@@ -2642,6 +2651,10 @@ function parseConfigCommand(input) {
2642
2651
  replace = true;
2643
2652
  continue;
2644
2653
  }
2654
+ if (flag === "--oauth-dpop" && action === "add") {
2655
+ definition.oauthDpop = true;
2656
+ continue;
2657
+ }
2645
2658
  if (flag === "--oauth")
2646
2659
  throw new ConfigMutationError("Remove --oauth; HTTP authentication is automatic.");
2647
2660
  const value = words[index++];
@@ -2680,7 +2693,7 @@ function parseConfigCommand(input) {
2680
2693
  if (transport === "http") fail();
2681
2694
  definition.command = words[++index];
2682
2695
  definition.args = words.slice(index + 1);
2683
- if (!definition.command || definition.oauthClientId || definition.oauthScopes || definition.oauthCallbackPort || definition.headers) fail();
2696
+ if (!definition.command || definition.oauthClientId || definition.oauthScopes || definition.oauthCallbackPort || definition.oauthDpop || definition.headers) fail();
2684
2697
  } else {
2685
2698
  if (transport === "stdio" || index + 1 !== words.length || definition.env) fail();
2686
2699
  definition.url = words[index];
@@ -2702,7 +2715,7 @@ function configCommandCompletions(input, servers) {
2702
2715
  const args = /^--scope\s+(global|project)\s+([^\s]*)$/u.exec(rest);
2703
2716
  if (!args) return [];
2704
2717
  const [, scope, partial] = args;
2705
- const values = action === "import" ? [] : action === "remove" ? servers.sort() : ["--replace", "--transport", "--header", "--env", "--oauth-client-id", "--oauth-scope", "--oauth-callback-port"];
2718
+ const values = action === "import" ? [] : action === "remove" ? servers.sort() : ["--replace", "--transport", "--header", "--env", "--oauth-client-id", "--oauth-scope", "--oauth-callback-port", "--oauth-dpop"];
2706
2719
  return values.filter((value) => value.startsWith(partial)).map((value) => ({
2707
2720
  value: `${action} --scope ${scope} ${value}`,
2708
2721
  label: value
@@ -2714,13 +2727,13 @@ function identifier(value) {
2714
2727
  return typeof value === "string" && !!value && value.length <= 512 && line(value) === value;
2715
2728
  }
2716
2729
  function preparePrompt(server, identity, value) {
2717
- const invalid3 = () => failure("protocol_error", { server, operation: "search" });
2718
- if (!object(value) || !identifier(value.name) || value.description !== void 0 && typeof value.description !== "string" || value.title !== void 0 && typeof value.title !== "string" || value.arguments !== void 0 && !Array.isArray(value.arguments)) throw invalid3();
2730
+ const invalid4 = () => failure("protocol_error", { server, operation: "search" });
2731
+ if (!object(value) || !identifier(value.name) || value.description !== void 0 && typeof value.description !== "string" || value.title !== void 0 && typeof value.title !== "string" || value.arguments !== void 0 && !Array.isArray(value.arguments)) throw invalid4();
2719
2732
  const args = value.arguments ?? [];
2720
- if (args.length > 100) throw invalid3();
2733
+ if (args.length > 100) throw invalid4();
2721
2734
  const seen = /* @__PURE__ */ new Set();
2722
2735
  const arguments_ = args.map((argument) => {
2723
- if (!object(argument) || !identifier(argument.name) || seen.has(argument.name) || argument.description !== void 0 && typeof argument.description !== "string" || argument.required !== void 0 && typeof argument.required !== "boolean") throw invalid3();
2736
+ if (!object(argument) || !identifier(argument.name) || seen.has(argument.name) || argument.description !== void 0 && typeof argument.description !== "string" || argument.required !== void 0 && typeof argument.required !== "boolean") throw invalid4();
2724
2737
  seen.add(argument.name);
2725
2738
  return { name: argument.name, description: plain(argument.description ?? "").slice(0, 8e3), required: argument.required === true };
2726
2739
  });
@@ -2742,14 +2755,14 @@ function promptCommand(prompt) {
2742
2755
  }
2743
2756
  var promptText = (value) => plain(value).replace(/\r\n?/g, "\n");
2744
2757
  function preparePromptSnapshot(result, server) {
2745
- const invalid3 = () => failure("protocol_error", { server, operation: "prompt" });
2746
- if (!object(result) || !Array.isArray(result.messages) || !result.messages.length) throw invalid3();
2758
+ const invalid4 = () => failure("protocol_error", { server, operation: "prompt" });
2759
+ if (!object(result) || !Array.isArray(result.messages) || !result.messages.length) throw invalid4();
2747
2760
  if (Buffer.byteLength(JSON.stringify(result)) > 50 * 1024) throw failure("prompt_too_large", { server, operation: "prompt" });
2748
2761
  let usable = true;
2749
2762
  const previews = [];
2750
- const messages2 = result.messages.map((message2, index) => {
2751
- if (!["user", "assistant"].includes(message2.role) || !object(message2.content)) throw invalid3();
2752
- const content = message2.content;
2763
+ const messages2 = result.messages.map((message3, index) => {
2764
+ if (!["user", "assistant"].includes(message3.role) || !object(message3.content)) throw invalid4();
2765
+ const content = message3.content;
2753
2766
  let block;
2754
2767
  let preview2;
2755
2768
  if (content.type === "text" && typeof content.text === "string") {
@@ -2769,9 +2782,9 @@ ${promptText(content.resource.text)}`;
2769
2782
  block = { type: line(String(content.type)), unsupported: true, bytes: Buffer.byteLength(JSON.stringify(content)) };
2770
2783
  preview2 = `Unsupported ${line(String(content.type))} content \xB7 ${Buffer.byteLength(JSON.stringify(content))} bytes`;
2771
2784
  }
2772
- previews.push(`Message ${index + 1} \xB7 ${message2.role} (server supplied)
2785
+ previews.push(`Message ${index + 1} \xB7 ${message3.role} (server supplied)
2773
2786
  ${preview2}`);
2774
- return { role: message2.role, content: block };
2787
+ return { role: message3.role, content: block };
2775
2788
  });
2776
2789
  const description = typeof result.description === "string" ? promptText(result.description) : void 0;
2777
2790
  const body = JSON.stringify({ server, ...description === void 0 ? {} : { description }, messages: messages2 }, null, 2);
@@ -3192,10 +3205,10 @@ var TomlError = class extends Error {
3192
3205
  line;
3193
3206
  column;
3194
3207
  codeblock;
3195
- constructor(message2, options) {
3208
+ constructor(message3, options) {
3196
3209
  const [line2, column] = getLineColFromPtr(options.toml, options.ptr);
3197
3210
  const codeblock = makeCodeBlock(options.toml, line2, column);
3198
- super(`Invalid TOML document: ${message2}
3211
+ super(`Invalid TOML document: ${message3}
3199
3212
 
3200
3213
  ${codeblock}`, options);
3201
3214
  this.line = line2;
@@ -3591,8 +3604,8 @@ function parseArray(ctx, integersAsBigInt) {
3591
3604
  }
3592
3605
 
3593
3606
  // node_modules/smol-toml/dist/parse.js
3594
- function peekTable(key, table, meta, type) {
3595
- let t = table;
3607
+ function peekTable(key, table2, meta, type) {
3608
+ let t = table2;
3596
3609
  let m = meta;
3597
3610
  let k;
3598
3611
  let hasOwn = false;
@@ -4128,7 +4141,7 @@ import {
4128
4141
  // src/oauth-page.ts
4129
4142
  function oauthCallbackHtml(status) {
4130
4143
  const heading = status === "received" ? "Authorization response received" : status === "denied" ? "Authorization not granted" : "Invalid OAuth callback";
4131
- const message2 = status === "received" ? "Return to Pi to finish signing in. You can close this tab." : "Return to Pi and try signing in again. You can close this tab.";
4144
+ const message3 = status === "received" ? "Return to Pi to finish signing in. You can close this tab." : "Return to Pi and try signing in again. You can close this tab.";
4132
4145
  return `<!doctype html>
4133
4146
  <html lang="en">
4134
4147
  <head>
@@ -4191,28 +4204,509 @@ function oauthCallbackHtml(status) {
4191
4204
  <div class="logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" aria-hidden="true"><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"/><path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/></svg></div>
4192
4205
  <p class="brand">Pi MCP Client</p>
4193
4206
  <h1>${heading}</h1>
4194
- <p>${message2}</p>
4207
+ <p>${message3}</p>
4195
4208
  </main>
4196
4209
  </body>
4197
4210
  </html>`;
4198
4211
  }
4199
4212
 
4213
+ // src/oauth-fetch.ts
4214
+ import { OAuthErrorCode } from "@modelcontextprotocol/client";
4215
+ var codes = new Set(Object.values(OAuthErrorCode));
4216
+ async function privateOAuthFetch(input, init) {
4217
+ const tokenRequest = init?.body instanceof URLSearchParams && init.body.has("grant_type");
4218
+ if (!tokenRequest) return fetch(input, init);
4219
+ try {
4220
+ const response = await fetch(input, init);
4221
+ const reader = response.body?.getReader();
4222
+ const chunks = [];
4223
+ let size = 0;
4224
+ try {
4225
+ while (reader) {
4226
+ const { done, value } = await reader.read();
4227
+ if (done) break;
4228
+ size += value.byteLength;
4229
+ if (size > 65536) throw new Error("Token response too large");
4230
+ chunks.push(value);
4231
+ }
4232
+ } finally {
4233
+ await reader?.cancel();
4234
+ }
4235
+ const text = new TextDecoder().decode(Buffer.concat(chunks));
4236
+ let body;
4237
+ try {
4238
+ body = JSON.parse(text);
4239
+ } catch {
4240
+ body = void 0;
4241
+ }
4242
+ if (!response.ok || body && typeof body === "object" && "error" in body) {
4243
+ const code = body && typeof body === "object" && "error" in body ? body.error : void 0;
4244
+ const payload = response.ok && body && typeof body === "object" ? { ...body } : {};
4245
+ return Response.json({
4246
+ ...payload,
4247
+ error: typeof code === "string" && codes.has(code) ? code : "server_error",
4248
+ error_description: void 0,
4249
+ error_uri: void 0
4250
+ }, {
4251
+ status: response.status,
4252
+ headers: response.headers
4253
+ });
4254
+ }
4255
+ if (!body || typeof body !== "object") throw new Error("Invalid token response");
4256
+ return new Response(text, { status: response.status, headers: response.headers });
4257
+ } catch {
4258
+ throw failure("oauth_failed", { operation: "auth" });
4259
+ }
4260
+ }
4261
+
4262
+ // src/dpop.ts
4263
+ import { DpopSession, generateDpopKeyPair } from "@modelcontextprotocol/client";
4264
+
4265
+ // node_modules/jose/dist/webapi/lib/buffer_utils.js
4266
+ var encoder = new TextEncoder();
4267
+ var decoder = new TextDecoder();
4268
+ var strictDecoder = new TextDecoder("utf-8", { fatal: true });
4269
+ var MAX_INT32 = 2 ** 32;
4270
+ var NON_ASCII = /[^\x00-\x7f]/;
4271
+ function encode(string) {
4272
+ if (typeof string == "string" && string.length >= 128) {
4273
+ if (NON_ASCII.test(string))
4274
+ throw new TypeError("non-ASCII string encountered in encode()");
4275
+ return encoder.encode(string);
4276
+ }
4277
+ const bytes = new Uint8Array(string.length);
4278
+ for (let i = 0; i < string.length; i++) {
4279
+ const code = string.charCodeAt(i);
4280
+ if (code > 127)
4281
+ throw new TypeError("non-ASCII string encountered in encode()");
4282
+ bytes[i] = code;
4283
+ }
4284
+ return bytes;
4285
+ }
4286
+ function encodeBase64(input, url = false) {
4287
+ if (Uint8Array.prototype.toBase64)
4288
+ return input.toBase64({ alphabet: url ? "base64url" : "base64", omitPadding: url });
4289
+ const CHUNK_SIZE = 32768, arr = [];
4290
+ for (let i = 0; i < input.length; i += CHUNK_SIZE)
4291
+ arr.push(String.fromCharCode.apply(null, input.subarray(i, i + CHUNK_SIZE)));
4292
+ const encoded = btoa(arr.join(""));
4293
+ return url ? encoded.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_") : encoded;
4294
+ }
4295
+ function decodeBase64(encoded, url = false) {
4296
+ if (Uint8Array.fromBase64)
4297
+ return Uint8Array.fromBase64(encoded, { alphabet: url ? "base64url" : "base64" });
4298
+ if (url) {
4299
+ if (encoded.includes("+") || encoded.includes("/"))
4300
+ throw new TypeError("Invalid base64url");
4301
+ encoded = encoded.replace(/-/g, "+").replace(/_/g, "/");
4302
+ }
4303
+ const binary = atob(encoded), bytes = new Uint8Array(binary.length);
4304
+ for (let i = 0; i < binary.length; i++)
4305
+ bytes[i] = binary.charCodeAt(i);
4306
+ return bytes;
4307
+ }
4308
+ async function digest(algorithm, data) {
4309
+ const subtleDigest = `SHA-${algorithm.slice(-3)}`;
4310
+ return new Uint8Array(await crypto.subtle.digest(subtleDigest, data));
4311
+ }
4312
+
4313
+ // node_modules/jose/dist/webapi/util/errors.js
4314
+ var JOSEError = class extends Error {
4315
+ static code = "ERR_JOSE_GENERIC";
4316
+ code = "ERR_JOSE_GENERIC";
4317
+ constructor(message3, options) {
4318
+ super(message3, options), this.name = this.constructor.name, Error.captureStackTrace?.(this, this.constructor);
4319
+ }
4320
+ };
4321
+ var JOSENotSupported = class extends JOSEError {
4322
+ static code = "ERR_JOSE_NOT_SUPPORTED";
4323
+ code = "ERR_JOSE_NOT_SUPPORTED";
4324
+ };
4325
+ var JWKInvalid = class extends JOSEError {
4326
+ static code = "ERR_JWK_INVALID";
4327
+ code = "ERR_JWK_INVALID";
4328
+ };
4329
+
4330
+ // node_modules/jose/dist/webapi/util/base64url.js
4331
+ var invalid2 = "The input to be decoded is not correctly encoded.";
4332
+ function decode(input) {
4333
+ try {
4334
+ return decodeBase64(typeof input == "string" ? input : decoder.decode(input), true);
4335
+ } catch (cause) {
4336
+ throw new TypeError(invalid2, { cause });
4337
+ }
4338
+ }
4339
+ function encode2(input) {
4340
+ return encodeBase64(typeof input == "string" ? encoder.encode(input) : input, true);
4341
+ }
4342
+
4343
+ // node_modules/jose/dist/webapi/lib/validate.js
4344
+ function isObject(input) {
4345
+ if (typeof input != "object" || input === null || Object.prototype.toString.call(input) !== "[object Object]")
4346
+ return false;
4347
+ const prototype = Object.getPrototypeOf(input);
4348
+ return prototype === null || Object.getPrototypeOf(prototype) === null;
4349
+ }
4350
+
4351
+ // node_modules/jose/dist/webapi/lib/key.js
4352
+ var isCryptoKey = (key) => {
4353
+ if (key?.[Symbol.toStringTag] === "CryptoKey")
4354
+ return true;
4355
+ try {
4356
+ return key instanceof CryptoKey;
4357
+ } catch {
4358
+ return false;
4359
+ }
4360
+ };
4361
+ var isKeyObject = (key) => key?.[Symbol.toStringTag] === "KeyObject";
4362
+ var isKeyLike = (key) => isCryptoKey(key) || isKeyObject(key);
4363
+ function message(msg, actual, ...types) {
4364
+ if (types.length > 2) {
4365
+ const last2 = types.pop();
4366
+ msg += `one of type ${types.join(", ")}, or ${last2}.`;
4367
+ } else types.length === 2 ? msg += `one of type ${types[0]} or ${types[1]}.` : msg += `of type ${types[0]}.`;
4368
+ return actual == null ? msg += ` Received ${actual}` : typeof actual == "function" && actual.name ? msg += ` Received function ${actual.name}` : typeof actual == "object" && actual != null && actual.constructor?.name && (msg += ` Received an instance of ${actual.constructor.name}`), msg;
4369
+ }
4370
+ var invalidKeyInput = (actual, ...types) => message("Key must be ", actual, ...types);
4371
+ function snapshotJwk(jwk) {
4372
+ return { __proto__: null, ...jwk };
4373
+ }
4374
+ function normalizeJwk(jwk) {
4375
+ const normalized = snapshotJwk(jwk);
4376
+ if (normalized.ext !== void 0 && typeof normalized.ext != "boolean")
4377
+ throw new TypeError('"ext" (Extractable) Parameter must be a boolean');
4378
+ if (normalized.key_ops !== void 0) {
4379
+ const value = normalized.key_ops, keyOps = Array.isArray(value) ? [...value] : void 0;
4380
+ if (!keyOps || keyOps.some((operation) => typeof operation != "string") || new Set(keyOps).size !== keyOps.length)
4381
+ throw new TypeError('"key_ops" (Key Operations) Parameter must be an array of unique strings');
4382
+ normalized.key_ops = keyOps;
4383
+ }
4384
+ return normalized;
4385
+ }
4386
+ function validateExtractableOption(extractable) {
4387
+ if (extractable !== void 0 && typeof extractable != "boolean")
4388
+ throw new TypeError('"extractable" option must be a boolean');
4389
+ return extractable;
4390
+ }
4391
+ async function jwkToKey(entry, jwk, extractable) {
4392
+ if (!entry.kty.includes(jwk.kty))
4393
+ throw new JOSENotSupported('Invalid or unsupported JWK "alg" (Algorithm) Parameter value');
4394
+ const algorithm = entry.resolve?.({ kty: jwk.kty, crv: jwk.crv }) ?? entry.subtle, isPrivate = !!(jwk.d || jwk.priv), keyData = { ...jwk, ext: extractable ?? jwk.ext };
4395
+ return keyData.kty !== "AKP" && delete keyData.alg, delete keyData.use, crypto.subtle.importKey("jwk", keyData, algorithm, keyData.ext ?? !isPrivate, jwk.key_ops ?? entry.usages[isPrivate ? 1 : 0]);
4396
+ }
4397
+
4398
+ // node_modules/jose/dist/webapi/lib/key_descriptor.js
4399
+ function table(entries) {
4400
+ const out = { __proto__: null };
4401
+ for (const alg in entries)
4402
+ out[alg] = { ...entries[alg], alg };
4403
+ return out;
4404
+ }
4405
+
4406
+ // node_modules/jose/dist/webapi/lib/jwe_algorithms.js
4407
+ var wrap = [
4408
+ ["encrypt", "wrapKey"],
4409
+ ["decrypt", "unwrapKey"]
4410
+ ];
4411
+ var derive = [[], ["deriveBits"]];
4412
+ var none = [[], []];
4413
+ function rsaes(bits) {
4414
+ return {
4415
+ kty: ["RSA"],
4416
+ mode: "key-encryption",
4417
+ subtle: { name: "RSA-OAEP", hash: `SHA-${bits}` },
4418
+ usages: wrap,
4419
+ ops: ["wrapKey", "unwrapKey"]
4420
+ };
4421
+ }
4422
+ function ecdh(mode) {
4423
+ return {
4424
+ kty: ["EC", "OKP"],
4425
+ mode,
4426
+ subtle: { name: "ECDH" },
4427
+ resolve: ({ kty, crv, asymmetricKeyType }) => {
4428
+ if (crv === "X25519" || asymmetricKeyType === "x25519")
4429
+ return { name: "X25519" };
4430
+ if (kty === "OKP")
4431
+ throw new JOSENotSupported('Invalid or unsupported JWK "alg" (Algorithm) Parameter value');
4432
+ return { name: "ECDH", namedCurve: crv };
4433
+ },
4434
+ usages: derive,
4435
+ ops: [void 0, "deriveBits"]
4436
+ };
4437
+ }
4438
+ function aeskw(bits, gcm = false) {
4439
+ return {
4440
+ kty: ["oct"],
4441
+ mode: "key-wrapping",
4442
+ secret: true,
4443
+ subtle: { name: gcm ? "AES-GCM" : "AES-KW", length: bits },
4444
+ usages: none,
4445
+ ops: gcm ? ["encrypt", "decrypt"] : ["wrapKey", "unwrapKey"]
4446
+ };
4447
+ }
4448
+ function pbes2() {
4449
+ return {
4450
+ kty: ["oct"],
4451
+ mode: "key-wrapping",
4452
+ secret: true,
4453
+ subtle: { name: "PBKDF2" },
4454
+ usages: none,
4455
+ ops: ["deriveBits", "deriveBits"]
4456
+ };
4457
+ }
4458
+ var JWE = table({
4459
+ dir: {
4460
+ kty: ["oct"],
4461
+ mode: "direct-encryption",
4462
+ secret: true,
4463
+ subtle: { name: "AES-GCM" },
4464
+ usages: none,
4465
+ ops: ["encrypt", "decrypt"]
4466
+ },
4467
+ "RSA-OAEP": rsaes(1),
4468
+ "RSA-OAEP-256": rsaes(256),
4469
+ "RSA-OAEP-384": rsaes(384),
4470
+ "RSA-OAEP-512": rsaes(512),
4471
+ "ECDH-ES": ecdh("direct-key-agreement"),
4472
+ "ECDH-ES+A128KW": ecdh("key-agreement-with-key-wrapping"),
4473
+ "ECDH-ES+A192KW": ecdh("key-agreement-with-key-wrapping"),
4474
+ "ECDH-ES+A256KW": ecdh("key-agreement-with-key-wrapping"),
4475
+ A128KW: aeskw(128),
4476
+ A192KW: aeskw(192),
4477
+ A256KW: aeskw(256),
4478
+ A128GCMKW: aeskw(128, true),
4479
+ A192GCMKW: aeskw(192, true),
4480
+ A256GCMKW: aeskw(256, true),
4481
+ "PBES2-HS256+A128KW": pbes2(),
4482
+ "PBES2-HS384+A192KW": pbes2(),
4483
+ "PBES2-HS512+A256KW": pbes2()
4484
+ });
4485
+ var contentOps = ["encrypt", "decrypt"];
4486
+ function contentEncryption(bits, cbc = false) {
4487
+ return {
4488
+ kty: ["oct"],
4489
+ secret: true,
4490
+ subtle: { name: cbc ? "AES-CBC" : "AES-GCM", length: bits },
4491
+ usages: none,
4492
+ ops: contentOps,
4493
+ cekBits: bits,
4494
+ ivBits: cbc ? 128 : 96,
4495
+ cbc
4496
+ };
4497
+ }
4498
+ var ENC = table({
4499
+ A128GCM: contentEncryption(128),
4500
+ A192GCM: contentEncryption(192),
4501
+ A256GCM: contentEncryption(256),
4502
+ "A128CBC-HS256": contentEncryption(256, true),
4503
+ "A192CBC-HS384": contentEncryption(384, true),
4504
+ "A256CBC-HS512": contentEncryption(512, true)
4505
+ });
4506
+
4507
+ // node_modules/jose/dist/webapi/lib/jws_algorithms.js
4508
+ var sig = [["verify"], ["sign"]];
4509
+ function hmac(bits) {
4510
+ const subtle = { name: "HMAC", hash: `SHA-${bits}` };
4511
+ return { kty: ["oct"], secret: true, subtle, signing: subtle, usages: sig };
4512
+ }
4513
+ function rsa(bits, saltLength) {
4514
+ const subtle = { name: saltLength ? "RSA-PSS" : "RSASSA-PKCS1-v1_5", hash: `SHA-${bits}` };
4515
+ return {
4516
+ kty: ["RSA"],
4517
+ subtle,
4518
+ signing: saltLength ? { ...subtle, saltLength } : subtle,
4519
+ usages: sig,
4520
+ minRsaBits: 2048
4521
+ };
4522
+ }
4523
+ function ecdsa(crv, bits) {
4524
+ return {
4525
+ kty: ["EC"],
4526
+ crv,
4527
+ subtle: { name: "ECDSA", namedCurve: crv },
4528
+ signing: { name: "ECDSA", hash: `SHA-${bits}` },
4529
+ usages: sig
4530
+ };
4531
+ }
4532
+ function eddsa() {
4533
+ const subtle = { name: "Ed25519" };
4534
+ return {
4535
+ kty: ["OKP"],
4536
+ crv: "Ed25519",
4537
+ subtle,
4538
+ signing: subtle,
4539
+ usages: sig
4540
+ };
4541
+ }
4542
+ function mldsa(bits) {
4543
+ const subtle = { name: `ML-DSA-${bits}` };
4544
+ return {
4545
+ kty: ["AKP"],
4546
+ subtle,
4547
+ signing: subtle,
4548
+ usages: sig
4549
+ };
4550
+ }
4551
+ var JWS = table({
4552
+ HS256: hmac(256),
4553
+ HS384: hmac(384),
4554
+ HS512: hmac(512),
4555
+ RS256: rsa(256),
4556
+ RS384: rsa(384),
4557
+ RS512: rsa(512),
4558
+ PS256: rsa(256, 32),
4559
+ PS384: rsa(384, 48),
4560
+ PS512: rsa(512, 64),
4561
+ ES256: ecdsa("P-256", 256),
4562
+ ES384: ecdsa("P-384", 384),
4563
+ ES512: ecdsa("P-521", 512),
4564
+ EdDSA: eddsa(),
4565
+ Ed25519: eddsa(),
4566
+ "ML-DSA-44": mldsa(44),
4567
+ "ML-DSA-65": mldsa(65),
4568
+ "ML-DSA-87": mldsa(87)
4569
+ });
4570
+
4571
+ // node_modules/jose/dist/webapi/lib/key_algorithm.js
4572
+ function unsupportedAlg(source = 'JWK "alg" (Algorithm) Parameter') {
4573
+ throw new JOSENotSupported(`Invalid or unsupported ${source} value`);
4574
+ }
4575
+ function keyAlgorithm(alg, source) {
4576
+ return (typeof alg == "string" ? JWS[alg] ?? JWE[alg] : void 0) ?? unsupportedAlg(source);
4577
+ }
4578
+
4579
+ // node_modules/jose/dist/webapi/key/export.js
4580
+ async function exportJWK(key) {
4581
+ if (isKeyObject(key))
4582
+ if (key.type === "secret")
4583
+ key = key.export();
4584
+ else
4585
+ return key.export({ format: "jwk" });
4586
+ if (key instanceof Uint8Array)
4587
+ return {
4588
+ kty: "oct",
4589
+ k: encode2(key)
4590
+ };
4591
+ if (!isCryptoKey(key))
4592
+ throw new TypeError(invalidKeyInput(key, "CryptoKey", "KeyObject", "Uint8Array"));
4593
+ if (!key.extractable)
4594
+ throw new TypeError("non-extractable CryptoKey cannot be exported as a JWK");
4595
+ const jwk = await crypto.subtle.exportKey("jwk", key);
4596
+ delete jwk.ext, delete jwk.key_ops, delete jwk.use, jwk.kty !== "AKP" && delete jwk.alg;
4597
+ for (const parameter of Object.keys(jwk))
4598
+ jwk[parameter] === void 0 && delete jwk[parameter];
4599
+ return jwk;
4600
+ }
4601
+
4602
+ // node_modules/jose/dist/webapi/jwk/thumbprint.js
4603
+ var check = (value, description) => {
4604
+ if (typeof value != "string" || !value)
4605
+ throw new JWKInvalid(`${description} missing or invalid`);
4606
+ };
4607
+ async function calculateJwkThumbprint(key, digestAlgorithm) {
4608
+ let jwk;
4609
+ if (isObject(key)) {
4610
+ if (jwk = snapshotJwk(key), typeof jwk.kty != "string")
4611
+ throw new TypeError(invalidKeyInput(key, "CryptoKey", "KeyObject", "JSON Web Key"));
4612
+ } else if (isKeyLike(key))
4613
+ jwk = snapshotJwk(await exportJWK(key));
4614
+ else
4615
+ throw new TypeError(invalidKeyInput(key, "CryptoKey", "KeyObject", "JSON Web Key"));
4616
+ if (digestAlgorithm ??= "sha256", digestAlgorithm !== "sha256" && digestAlgorithm !== "sha384" && digestAlgorithm !== "sha512")
4617
+ throw new TypeError('digestAlgorithm must one of "sha256", "sha384", or "sha512"');
4618
+ let components;
4619
+ switch (jwk.kty) {
4620
+ case "AKP":
4621
+ check(jwk.alg, '"alg" (Algorithm) Parameter'), check(jwk.pub, '"pub" (Public key) Parameter'), components = { alg: jwk.alg, kty: jwk.kty, pub: jwk.pub };
4622
+ break;
4623
+ case "EC":
4624
+ check(jwk.crv, '"crv" (Curve) Parameter'), check(jwk.x, '"x" (X Coordinate) Parameter'), check(jwk.y, '"y" (Y Coordinate) Parameter'), components = { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
4625
+ break;
4626
+ case "OKP":
4627
+ check(jwk.crv, '"crv" (Subtype of Key Pair) Parameter'), check(jwk.x, '"x" (Public Key) Parameter'), components = { crv: jwk.crv, kty: jwk.kty, x: jwk.x };
4628
+ break;
4629
+ case "RSA":
4630
+ check(jwk.e, '"e" (Exponent) Parameter'), check(jwk.n, '"n" (Modulus) Parameter'), components = { e: jwk.e, kty: jwk.kty, n: jwk.n };
4631
+ break;
4632
+ case "oct":
4633
+ if (typeof jwk.k != "string")
4634
+ throw new JWKInvalid('"k" (Key Value) Parameter missing or invalid');
4635
+ components = { k: jwk.k, kty: jwk.kty };
4636
+ break;
4637
+ default:
4638
+ throw new JOSENotSupported('"kty" (Key Type) Parameter missing or unsupported');
4639
+ }
4640
+ const data = encode(JSON.stringify(components));
4641
+ return encode2(await digest(digestAlgorithm, data));
4642
+ }
4643
+
4644
+ // node_modules/jose/dist/webapi/key/import.js
4645
+ async function importJWK(jwk, alg, options) {
4646
+ if (!isObject(jwk))
4647
+ throw new TypeError("JWK must be an object");
4648
+ const normalized = normalizeJwk(jwk), extractable = validateExtractableOption(options?.extractable), { alg: jwkAlg } = normalized;
4649
+ if (alg ??= jwkAlg, normalized.kty !== "oct" && !alg)
4650
+ throw new TypeError('"alg" argument is required when "jwk.alg" is not present');
4651
+ switch (normalized.kty) {
4652
+ case "oct":
4653
+ if (typeof normalized.k != "string")
4654
+ throw new TypeError('missing "k" (Key Value) Parameter value');
4655
+ return decode(normalized.k);
4656
+ case "AKP": {
4657
+ if (typeof jwkAlg != "string" || !jwkAlg)
4658
+ throw new TypeError('missing "alg" (Algorithm) Parameter value');
4659
+ if (alg !== jwkAlg)
4660
+ throw new TypeError("JWK alg and alg option value mismatch");
4661
+ return jwkToKey(keyAlgorithm(alg), normalized, extractable);
4662
+ }
4663
+ case "RSA":
4664
+ case "EC":
4665
+ case "OKP":
4666
+ return jwkToKey(keyAlgorithm(alg), normalized, extractable);
4667
+ default:
4668
+ throw new JOSENotSupported('Unsupported "kty" (Key Type) Parameter value');
4669
+ }
4670
+ }
4671
+
4672
+ // src/dpop.ts
4673
+ async function createDpopKey(issuer, clientId) {
4674
+ const pair = await generateDpopKeyPair({ alg: "ES256", extractable: true });
4675
+ return { issuer, clientId, privateJwk: await exportJWK(pair.privateKey) };
4676
+ }
4677
+ async function restoreDpopSession(stored) {
4678
+ const jwk = stored.privateJwk;
4679
+ if (!jwk || jwk.kty !== "EC" || jwk.crv !== "P-256" || ![jwk.x, jwk.y, jwk.d].every((value) => typeof value === "string" && /^[A-Za-z0-9_-]{43}$/u.test(value)))
4680
+ throw new Error("Invalid DPoP key");
4681
+ const publicJwk = { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y };
4682
+ const privateKey = await importJWK({ ...publicJwk, d: jwk.d }, "ES256", { extractable: false });
4683
+ const publicKey = await importJWK(publicJwk, "ES256");
4684
+ if (privateKey instanceof Uint8Array || publicKey instanceof Uint8Array) throw new Error("Invalid DPoP key");
4685
+ return DpopSession.create({ keyPair: {
4686
+ privateKey,
4687
+ publicKey,
4688
+ publicJwk,
4689
+ thumbprint: await calculateJwkThumbprint(publicJwk),
4690
+ alg: "ES256"
4691
+ } });
4692
+ }
4693
+
4200
4694
  // src/auth.ts
4201
4695
  function callbackUrl(options = {}) {
4202
4696
  return `http://127.0.0.1:${options.oauthCallbackPort ?? 19847}/callback`;
4203
4697
  }
4204
4698
  function parseCallback(value, redirect, state) {
4205
- const invalid3 = () => failure("oauth_failed", { operation: "auth" });
4206
- if (value.length > 16384 || /[\u0000-\u0020\u007f]/u.test(value)) throw invalid3();
4699
+ const invalid4 = () => failure("oauth_failed", { operation: "auth" });
4700
+ if (value.length > 16384 || /[\u0000-\u0020\u007f]/u.test(value)) throw invalid4();
4207
4701
  let target;
4208
4702
  try {
4209
4703
  target = new URL(value);
4210
4704
  } catch {
4211
- throw invalid3();
4705
+ throw invalid4();
4212
4706
  }
4213
4707
  const expected = new URL(redirect);
4214
4708
  const params = target.searchParams;
4215
- if (target.origin !== expected.origin || target.pathname !== expected.pathname || target.username || target.password || target.hash || [...params.keys()].some((key) => params.getAll(key).length !== 1) || params.get("state") !== state || params.has("code") === params.has("error") || !(params.get("code") || params.get("error"))) throw invalid3();
4709
+ if (target.origin !== expected.origin || target.pathname !== expected.pathname || target.username || target.password || target.hash || [...params.keys()].some((key) => params.getAll(key).length !== 1) || params.get("state") !== state || params.has("code") === params.has("error") || !(params.get("code") || params.get("error"))) throw invalid4();
4216
4710
  return params;
4217
4711
  }
4218
4712
  function credentialKey({ server, url, clientId }) {
@@ -4256,6 +4750,7 @@ var OAuthProvider = class {
4256
4750
  constructor(identity, store, redirect, options = {}) {
4257
4751
  this.store = store;
4258
4752
  this.redirect = redirect;
4753
+ this.options = options;
4259
4754
  const { server, url, clientId } = identity;
4260
4755
  this.clientId = clientId;
4261
4756
  this.redirectUrl = callbackUrl(options);
@@ -4279,6 +4774,9 @@ var OAuthProvider = class {
4279
4774
  key;
4280
4775
  verifier;
4281
4776
  discovery;
4777
+ requestedScope;
4778
+ dpopSession;
4779
+ resolvedDpopSession;
4282
4780
  expectedState = randomUUID2();
4283
4781
  clientId;
4284
4782
  assertCurrent() {
@@ -4296,7 +4794,13 @@ var OAuthProvider = class {
4296
4794
  }
4297
4795
  clientInformation(ctx) {
4298
4796
  this.assertCurrent();
4797
+ return this.lookupClientInformation(ctx);
4798
+ }
4799
+ lookupClientInformation(ctx) {
4299
4800
  if (!ctx) return void 0;
4801
+ const grantIssuer = this.data.grantIssuer ?? this.data.tokens?.issuer;
4802
+ if (grantIssuer && grantIssuer !== ctx.issuer || this.data.dpopKey && this.data.dpopKey.issuer !== ctx.issuer)
4803
+ throw failure("oauth_issuer_changed", { operation: "auth", oauth: true });
4300
4804
  const stored = Object.hasOwn(this.data.clients, ctx.issuer) ? this.data.clients[ctx.issuer] : void 0;
4301
4805
  if (!this.clientId) {
4302
4806
  const registration = this.data.registrations?.[ctx.issuer];
@@ -4313,8 +4817,12 @@ var OAuthProvider = class {
4313
4817
  if (this.clientId && (info.client_id !== this.clientId || info.client_secret))
4314
4818
  throw new Error("Cannot replace the configured public OAuth client.");
4315
4819
  const previous = this.data.clients[ctx.issuer];
4316
- if (!this.clientId && previous && previous.client_id !== info.client_id && this.data.tokens?.issuer === ctx.issuer)
4317
- delete this.data.tokens;
4820
+ if (!this.clientId && previous && previous.client_id !== info.client_id) {
4821
+ if (this.data.tokens?.issuer === ctx.issuer) delete this.data.tokens;
4822
+ delete this.data.dpopKey;
4823
+ this.dpopSession = void 0;
4824
+ this.resolvedDpopSession = void 0;
4825
+ }
4318
4826
  this.data.clients = { ...this.data.clients, [ctx.issuer]: info };
4319
4827
  if (!this.clientId) this.data.registrations = {
4320
4828
  ...this.data.registrations,
@@ -4328,14 +4836,58 @@ var OAuthProvider = class {
4328
4836
  return !ctx || tokens2?.issuer === ctx.issuer ? tokens2 : void 0;
4329
4837
  }
4330
4838
  saveTokens(tokens2) {
4839
+ if (tokens2.token_type?.toLowerCase() === "dpop" && (!this.options.oauthDpop || !this.data.dpopKey || this.data.dpopKey.issuer !== tokens2.issuer))
4840
+ throw failure("oauth_dpop_unavailable", { operation: "auth" });
4331
4841
  if (this.clientId) {
4332
4842
  if (!tokens2.issuer) throw new Error("OAuth tokens have no issuer.");
4333
4843
  const info = this.clientInformation({ issuer: tokens2.issuer });
4334
4844
  this.data.clients = { ...this.data.clients, [tokens2.issuer]: info };
4335
4845
  }
4336
- this.data.tokens = tokens2;
4846
+ const scope = tokens2.scope ?? this.requestedScope ?? (tokens2.issuer === this.data.tokens?.issuer ? this.data.tokens?.scope : void 0);
4847
+ this.data.dpopRefreshBound = !!tokens2.refresh_token && (!!this.dpopSession || !!this.data.dpopRefreshBound && tokens2.refresh_token === this.data.tokens?.refresh_token);
4848
+ this.data.grantIssuer = tokens2.issuer ?? this.data.grantIssuer;
4849
+ this.data.tokens = { ...tokens2, ...scope !== void 0 ? { scope } : {} };
4337
4850
  this.save();
4338
4851
  }
4852
+ async dpop() {
4853
+ const bound = this.data.tokens?.token_type?.toLowerCase() === "dpop" || !!this.data.tokens?.refresh_token && !!this.data.dpopRefreshBound;
4854
+ if (!this.options.oauthDpop && !bound) return void 0;
4855
+ this.assertCurrent();
4856
+ if (!this.options.oauthDpop) {
4857
+ if (bound) throw failure("oauth_dpop_unavailable", { operation: "auth" });
4858
+ return void 0;
4859
+ }
4860
+ const issuer = this.discovery?.authorizationServerMetadata?.issuer ?? this.discovery?.authorizationServerUrl ?? this.data.tokens?.issuer;
4861
+ const client = issuer ? this.lookupClientInformation({ issuer }) : void 0;
4862
+ if (!issuer || !client) {
4863
+ if (bound) throw failure("oauth_dpop_unavailable", { operation: "auth" });
4864
+ return void 0;
4865
+ }
4866
+ if (!this.data.dpopKey && !this.redirect) {
4867
+ if (bound) throw failure("oauth_dpop_unavailable", { operation: "auth" });
4868
+ return void 0;
4869
+ }
4870
+ if (this.data.dpopKey && (this.data.dpopKey.issuer !== issuer || this.data.dpopKey.clientId !== client.client_id))
4871
+ throw failure("oauth_dpop_unavailable", { operation: "auth" });
4872
+ if (this.resolvedDpopSession) return this.resolvedDpopSession;
4873
+ this.dpopSession ??= (async () => {
4874
+ if (!this.data.dpopKey) {
4875
+ const key = await createDpopKey(issuer, client.client_id);
4876
+ this.assertCurrent();
4877
+ this.data.dpopKey = key;
4878
+ this.save();
4879
+ }
4880
+ try {
4881
+ return await restoreDpopSession(this.data.dpopKey);
4882
+ } catch {
4883
+ throw failure("oauth_dpop_unavailable", { operation: "auth" });
4884
+ }
4885
+ })();
4886
+ const session = await this.dpopSession;
4887
+ this.assertCurrent();
4888
+ this.resolvedDpopSession = session;
4889
+ return session;
4890
+ }
4339
4891
  saveCodeVerifier(value) {
4340
4892
  this.verifier = value;
4341
4893
  }
@@ -4354,12 +4906,17 @@ var OAuthProvider = class {
4354
4906
  throw failure("authentication_required", { operation: "connect", oauth: true });
4355
4907
  if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
4356
4908
  throw new Error("Refusing an insecure authorization URL.");
4909
+ await this.dpop();
4910
+ this.requestedScope = url.searchParams.get("scope") ?? void 0;
4357
4911
  await this.redirect(url);
4358
4912
  }
4359
4913
  invalidateCredentials(scope) {
4360
4914
  if (scope === "all" || scope === "client") {
4361
4915
  this.data.clients = {};
4362
4916
  delete this.data.registrations;
4917
+ delete this.data.dpopKey;
4918
+ this.dpopSession = void 0;
4919
+ this.resolvedDpopSession = void 0;
4363
4920
  }
4364
4921
  if (scope === "all" || scope === "tokens") delete this.data.tokens;
4365
4922
  if (scope === "all" || scope === "verifier") this.verifier = void 0;
@@ -4401,6 +4958,14 @@ async function connectionAuthProvider(server, config, storeFactory = credentialS
4401
4958
  throw error;
4402
4959
  }
4403
4960
  },
4961
+ dpop: async () => {
4962
+ try {
4963
+ return await (await get()).dpop();
4964
+ } catch (error) {
4965
+ if (!initialized && error instanceof DiagnosticError && error.diagnostic.code === "credential_store_unavailable") return void 0;
4966
+ throw error;
4967
+ }
4968
+ },
4404
4969
  saveTokens: async (tokens2) => (await get()).saveTokens(tokens2),
4405
4970
  saveCodeVerifier: async (value) => (await get()).saveCodeVerifier(value),
4406
4971
  codeVerifier: async () => (await get()).codeVerifier(),
@@ -4481,9 +5046,36 @@ async function authenticate(identity, open2, signal, store, options = {}) {
4481
5046
  const callback = new Promise((resolve3) => {
4482
5047
  resolveCallback = resolve3;
4483
5048
  });
5049
+ const backing = store ?? await credentialStore(identity);
5050
+ const original = backing.read();
5051
+ const epoch = credentialEpochs.get(credentialKey(identity)) ?? 0;
5052
+ let staged = original;
5053
+ const guard = () => {
5054
+ deadline.throwIfAborted();
5055
+ if ((credentialEpochs.get(credentialKey(identity)) ?? 0) !== epoch || backing.read() !== original)
5056
+ throw failure("authentication_required", { operation: "auth" });
5057
+ };
5058
+ const pending = {
5059
+ read: () => {
5060
+ guard();
5061
+ return staged;
5062
+ },
5063
+ write: (value) => {
5064
+ guard();
5065
+ staged = value;
5066
+ },
5067
+ remove: () => {
5068
+ guard();
5069
+ staged = null;
5070
+ }
5071
+ };
5072
+ const commit = () => {
5073
+ guard();
5074
+ if (staged !== null) backing.write(staged);
5075
+ };
4484
5076
  const provider = new OAuthProvider(
4485
5077
  identity,
4486
- store ?? await credentialStore(identity),
5078
+ pending,
4487
5079
  async (target) => {
4488
5080
  deadline.throwIfAborted();
4489
5081
  if (!options.handoff) return open2(target.href);
@@ -4513,7 +5105,7 @@ async function authenticate(identity, open2, signal, store, options = {}) {
4513
5105
  res.end(oauthCallbackHtml(params.has("error") ? "denied" : "received"));
4514
5106
  resolveCallback(params);
4515
5107
  });
4516
- const fetchFn = (input, init) => fetch(input, {
5108
+ const fetchFn = (input, init) => privateOAuthFetch(input, {
4517
5109
  ...init,
4518
5110
  signal: AbortSignal.any([deadline, ...init?.signal ? [init.signal] : []])
4519
5111
  });
@@ -4530,7 +5122,10 @@ async function authenticate(identity, open2, signal, store, options = {}) {
4530
5122
  forceReauthorization: true,
4531
5123
  scope: options.oauthScopes?.join(" ")
4532
5124
  });
4533
- if (result === "AUTHORIZED") return;
5125
+ if (result === "AUTHORIZED") {
5126
+ commit();
5127
+ return;
5128
+ }
4534
5129
  const params = await waitFor(callback, deadline);
4535
5130
  if (params.has("error") || !params.get("code"))
4536
5131
  throw new Error("OAuth authorization was not granted.");
@@ -4541,6 +5136,7 @@ async function authenticate(identity, open2, signal, store, options = {}) {
4541
5136
  });
4542
5137
  try {
4543
5138
  await transport.finishAuth(params);
5139
+ commit();
4544
5140
  } finally {
4545
5141
  await transport.close();
4546
5142
  }
@@ -4571,9 +5167,9 @@ function serverMatrix(servers, loaded, width = 80, theme) {
4571
5167
  const fitted = truncateToWidth2(text, width, "\u2026");
4572
5168
  return theme ? fitted : plain(fitted);
4573
5169
  };
4574
- const wrap = (text) => wrapTextWithAnsi(text, width).map(fit);
5170
+ const wrap2 = (text) => wrapTextWithAnsi(text, width).map(fit);
4575
5171
  const rows = [];
4576
- if (!servers.length) return wrap(fg("muted", "No MCP servers configured.")).join("\n");
5172
+ if (!servers.length) return wrap2(fg("muted", "No MCP servers configured.")).join("\n");
4577
5173
  const toolsWidth = Math.max(5, ...servers.map((server) => String(server.catalogSize ?? "\u2014").length));
4578
5174
  const loadedWidth = Math.max(6, ...servers.map((server) => String(loaded.get(server.name) ?? 0).length));
4579
5175
  const fixedWidth = 18 + toolsWidth + loadedWidth;
@@ -4597,17 +5193,17 @@ function serverMatrix(servers, loaded, width = 80, theme) {
4597
5193
  const name = fg(server.state === "disabled" ? "dim" : "text", bold(compact ? line(server.name) : padName(server.name)));
4598
5194
  const glyph = fg(state.color, state.glyph);
4599
5195
  if (compact) {
4600
- rows.push(...wrap(`${glyph} ${name}`));
4601
- rows.push(...wrap(` ${fg(state.color, state.label)} \xB7 ${fg("muted", `${tools} tools \xB7 ${count} loaded`)}`));
5196
+ rows.push(...wrap2(`${glyph} ${name}`));
5197
+ rows.push(...wrap2(` ${fg(state.color, state.label)} \xB7 ${fg("muted", `${tools} tools \xB7 ${count} loaded`)}`));
4602
5198
  } else {
4603
5199
  rows.push(`${glyph} ${name} ${fg(state.color, state.label.padEnd(10))} ` + fg(server.catalogSize === void 0 ? "dim" : "text", tools.padStart(toolsWidth)) + " " + fg(count ? "accent" : "dim", String(count).padStart(loadedWidth)));
4604
5200
  }
4605
5201
  }
4606
- rows.push("", ...wrap(fg("dim", "Connections open on demand. \u2014 = catalog not fetched.")));
5202
+ rows.push("", ...wrap2(fg("dim", "Connections open on demand. \u2014 = catalog not fetched.")));
4607
5203
  const errors = servers.filter((server) => server.state === "failed" && server.error);
4608
5204
  if (errors.length) rows.push("");
4609
5205
  for (const server of errors) {
4610
- rows.push(...wrap(fg("error", `\u2718\uFE0E ${line(server.name)}: [${line(server.error.code)}]`) + fg("muted", ` ${line(server.error.message)}`)));
5206
+ rows.push(...wrap2(fg("error", `\u2718\uFE0E ${line(server.name)}: [${line(server.error.code)}]`) + fg("muted", ` ${line(server.error.message)}`)));
4611
5207
  }
4612
5208
  return rows.map(fit).join("\n");
4613
5209
  }
@@ -4694,7 +5290,8 @@ function inspectServer(name, config, status, authentication) {
4694
5290
  `OAuth: ${usesOAuth(config) ? "automatic" : "not used"}`,
4695
5291
  ...usesOAuth(config) ? [
4696
5292
  `Requested scopes: ${config.oauthScopes?.map(line).join(", ") ?? "SDK/server defaults"}`,
4697
- `OAuth callback: http://127.0.0.1:${config.oauthCallbackPort ?? 19847}/callback`
5293
+ `OAuth callback: http://127.0.0.1:${config.oauthCallbackPort ?? 19847}/callback`,
5294
+ `DPoP: ${config.oauthDpop ? "enabled (ES256; token binding depends on the service)" : "disabled"}`
4698
5295
  ] : [],
4699
5296
  ...authentication ? [`Authentication: ${authentication}`] : [],
4700
5297
  `Timeout: ${config.timeoutMs ? `${config.timeoutMs} ms` : "default"}`,
@@ -4713,6 +5310,111 @@ function inspectServer(name, config, status, authentication) {
4713
5310
  ].join("\n");
4714
5311
  }
4715
5312
 
5313
+ // src/authorization.ts
5314
+ import { computeScopeUnion } from "@modelcontextprotocol/client";
5315
+ function parseScopes(value) {
5316
+ if (!value || value.length > 25699) return void 0;
5317
+ const scopes = [...new Set(value.split(" "))];
5318
+ if (scopes.length > 100 || scopes.some((scope) => scope.length > 256 || !/^[\x21\x23-\x5b\x5d-\x7e]+$/u.test(scope))) return void 0;
5319
+ return scopes;
5320
+ }
5321
+ function authorizationOptions(identity, store, options, required) {
5322
+ const granted = new OAuthProvider(identity, store).tokens()?.scope;
5323
+ const scope = computeScopeUnion(options.oauthScopes?.join(" "), granted, required.join(" "));
5324
+ const scopes = parseScopes(scope);
5325
+ if (!scopes) throw failure("oauth_scope_rejected", { operation: "auth" });
5326
+ return { options: { ...options, oauthScopes: scopes }, grantedScopesKnown: granted !== void 0 };
5327
+ }
5328
+ function loginSummary(options, reviewed) {
5329
+ const scopes = options.oauthScopes;
5330
+ const names = scopes?.join(", ");
5331
+ const summary = scopes && (reviewed || names.length > 80) ? `${scopes.length} ${reviewed ? "reviewed " : "configured "}scopes` : names ?? "SDK/server defaults";
5332
+ return `Requested scopes: ${summary}
5333
+ Callback: http://127.0.0.1:${options.oauthCallbackPort ?? 19847}/callback`;
5334
+ }
5335
+
5336
+ // src/authorization-review.ts
5337
+ import { Text as Text3, truncateToWidth as truncateToWidth3 } from "@earendil-works/pi-tui";
5338
+ async function reviewAuthorization(ctx, server, requested, combined, grantedScopesKnown, signal) {
5339
+ const warning = grantedScopesKnown ? "" : "Previously granted scopes are unknown and may not be retained. Configure oauthScopes with the permissions you need, or continue with the listed scopes.\n\n";
5340
+ const text = `${warning}Server-requested scopes (untrusted data):
5341
+ ${requested.join("\n")}
5342
+
5343
+ Login scopes (configured + known grants + requested):
5344
+ ${combined.join("\n")}
5345
+
5346
+ Approval opens sign-in. The rejected operation is never replayed.`;
5347
+ if (signal.aborted) return false;
5348
+ if (ctx.mode !== "tui") {
5349
+ const lines = new Text3(text, 0, 0).render(70);
5350
+ let page = 0;
5351
+ const pages = Math.ceil(lines.length / 8);
5352
+ while (!signal.aborted) {
5353
+ const choice = await chooseOption(
5354
+ ctx,
5355
+ `Permissions for ${server} \xB7 ${page + 1}/${pages}
5356
+ ${lines.slice(page * 8, page * 8 + 8).join("\n")}`,
5357
+ ["Cancel", ...page + 1 < pages ? ["Next page"] : ["Approve sign-in"], ...page ? ["Previous page"] : []],
5358
+ signal
5359
+ );
5360
+ if (choice === "Next page") page++;
5361
+ else if (choice === "Previous page") page--;
5362
+ else return choice === "Approve sign-in" && !signal.aborted;
5363
+ }
5364
+ return false;
5365
+ }
5366
+ return ctx.ui.custom((tui, theme, keys, done) => {
5367
+ let settled = false;
5368
+ const finish = (approved) => {
5369
+ if (settled) return;
5370
+ settled = true;
5371
+ signal.removeEventListener("abort", abort);
5372
+ done(approved);
5373
+ };
5374
+ const abort = () => finish(false);
5375
+ signal.addEventListener("abort", abort, { once: true });
5376
+ const component = authorizationReview(server, text, () => tui.terminal.rows, theme, keys, finish, () => tui.requestRender());
5377
+ return { ...component, dispose: () => signal.removeEventListener("abort", abort) };
5378
+ });
5379
+ }
5380
+ function authorizationReview(server, text, rows, theme, keys, done, render) {
5381
+ let offset = 0, size = 1, width = 0, height = 0;
5382
+ let lines = [], last2 = false;
5383
+ return {
5384
+ render(columns) {
5385
+ if (columns < 24 || rows() < 14) {
5386
+ last2 = false;
5387
+ return [truncateToWidth3("Enlarge terminal; Esc cancels", columns)];
5388
+ }
5389
+ const available = Math.max(1, rows() - 8);
5390
+ if (columns !== width || available !== height) {
5391
+ width = columns;
5392
+ height = available;
5393
+ offset = 0;
5394
+ lines = new Text3(text, 0, 0).render(Math.max(1, columns));
5395
+ }
5396
+ size = Math.max(1, available - 4);
5397
+ last2 = offset + size >= lines.length;
5398
+ return [
5399
+ truncateToWidth3(theme.fg("accent", `Permissions: ${server}`), columns),
5400
+ ...lines.slice(offset, offset + size),
5401
+ truncateToWidth3(`${offset + 1}\u2013${Math.min(offset + size, lines.length)}/${lines.length}`, columns),
5402
+ truncateToWidth3(last2 ? "a: approve \xB7 Esc: cancel" : "Enter/\u2193: next", columns),
5403
+ truncateToWidth3("\u2191: previous \xB7 Esc cancel", columns)
5404
+ ];
5405
+ },
5406
+ handleInput(data) {
5407
+ if (keys.matches(data, "tui.select.cancel")) done(false);
5408
+ else if (data === "a" && last2) done(true);
5409
+ else if (keys.matches(data, "tui.select.up")) offset = Math.max(0, offset - size);
5410
+ else if (!last2 && (keys.matches(data, "tui.select.confirm") || keys.matches(data, "tui.select.down"))) offset += size;
5411
+ render();
5412
+ },
5413
+ invalidate() {
5414
+ }
5415
+ };
5416
+ }
5417
+
4716
5418
  // src/runtime.ts
4717
5419
  import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
4718
5420
  import { join as join2 } from "node:path";
@@ -4724,13 +5426,15 @@ import {
4724
5426
  SdkError as SdkError2,
4725
5427
  SdkErrorCode,
4726
5428
  StreamableHTTPClientTransport,
5429
+ extractWWWAuthenticateParams,
5430
+ computeScopeUnion as computeScopeUnion2,
4727
5431
  UriTemplate as UriTemplate2
4728
5432
  } from "@modelcontextprotocol/client";
4729
5433
 
4730
5434
  // package.json
4731
5435
  var package_default = {
4732
5436
  name: "pi-mcp-client",
4733
- version: "0.9.0",
5437
+ version: "0.10.0",
4734
5438
  description: "MCP tools for Pi, discovered on demand and called natively through the official SDK.",
4735
5439
  type: "module",
4736
5440
  license: "MIT",
@@ -4769,8 +5473,9 @@ var package_default = {
4769
5473
  prepack: "bun run build"
4770
5474
  },
4771
5475
  dependencies: {
4772
- "@modelcontextprotocol/client": "2.0.0",
5476
+ "@modelcontextprotocol/client": "2.1.0",
4773
5477
  "@napi-rs/keyring": "^1.3.0",
5478
+ jose: "^6.1.3",
4774
5479
  minisearch: "^7.2.0",
4775
5480
  "smol-toml": "^1.8.0"
4776
5481
  },
@@ -4778,7 +5483,7 @@ var package_default = {
4778
5483
  "@earendil-works/pi-ai": "^0.85.1",
4779
5484
  "@earendil-works/pi-coding-agent": "^0.85.1",
4780
5485
  "@earendil-works/pi-tui": "^0.85.1",
4781
- "@modelcontextprotocol/server": "2.0.0",
5486
+ "@modelcontextprotocol/server": "2.1.0",
4782
5487
  "@types/bun": "^1.3.0",
4783
5488
  "@types/node": "^24.0.0",
4784
5489
  esbuild: "^0.25.0",
@@ -4901,21 +5606,21 @@ var MAX_CHOICES = 100;
4901
5606
  var MAX_TEXT = 8192;
4902
5607
  var MAX_CONTENT = 64 * 1024;
4903
5608
  var MAX_REQUIRED = 3;
4904
- var invalid2 = (message2) => new ProtocolError2(ProtocolErrorCode2.InvalidParams, message2);
5609
+ var invalid3 = (message3) => new ProtocolError2(ProtocolErrorCode2.InvalidParams, message3);
4905
5610
  var short = (value, limit) => typeof value === "string" ? line(value).slice(0, limit) : "";
4906
5611
  var number = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
4907
- function message(value) {
5612
+ function message2(value) {
4908
5613
  const text = plain(value).split("\n").map((row) => row.trimEnd()).slice(0, 20).join("\n").trim();
4909
5614
  return text.slice(0, 2e3) || "(No message supplied.)";
4910
5615
  }
4911
5616
  function choices(values) {
4912
5617
  if (!values.length || values.length > MAX_CHOICES || new Set(values.map((choice) => choice.value)).size !== values.length)
4913
- throw invalid2("Elicitation choices are empty, duplicated, or too many.");
5618
+ throw invalid3("Elicitation choices are empty, duplicated, or too many.");
4914
5619
  return values.map(({ value, label: label2 }) => ({ value, label: short(label2, 120) || short(value, 120) || JSON.stringify(value) }));
4915
5620
  }
4916
5621
  function formFields(schema) {
4917
5622
  const entries = Object.entries(schema.properties ?? {});
4918
- if (entries.length > MAX_FIELDS) throw invalid2("Elicitation forms are limited to 50 fields.");
5623
+ if (entries.length > MAX_FIELDS) throw invalid3("Elicitation forms are limited to 50 fields.");
4919
5624
  const required = new Set(Array.isArray(schema.required) ? schema.required : []);
4920
5625
  return entries.map(([name, value]) => {
4921
5626
  const property = value;
@@ -5071,7 +5776,7 @@ async function elicitForm(ui, server, params, signal) {
5071
5776
  for (const field of fields)
5072
5777
  if (field.default !== void 0 && !problem(field, field.default)) values.set(field.name, field.default);
5073
5778
  const title = `${server} requests information
5074
- ${message(params.message)}
5779
+ ${message2(params.message)}
5075
5780
 
5076
5781
  Review the values below. Only what you submit is sent to ${server}. Never enter passwords, API keys, or payment details here.`;
5077
5782
  while (!signal.aborted) {
@@ -5112,13 +5817,13 @@ async function elicitUrl(ui, server, params, signal) {
5112
5817
  const target = safeUrl(params.url);
5113
5818
  if (!target) {
5114
5819
  ui.notify(`${server} asked to open an unsupported URL, so the request was refused. Only HTTPS pages, or HTTP pages on this computer, without embedded credentials can be opened.`, "warning");
5115
- throw invalid2("Unsupported elicitation URL.");
5820
+ throw invalid3("Unsupported elicitation URL.");
5116
5821
  }
5117
5822
  let failed = false;
5118
5823
  while (!signal.aborted) {
5119
5824
  const choice = await ui.select(
5120
5825
  `${server} asks you to open a web page
5121
- ${message(params.message)}
5826
+ ${message2(params.message)}
5122
5827
 
5123
5828
  Site: ${target.host}
5124
5829
  URL: ${target.href}
@@ -5156,7 +5861,7 @@ function requiredElicitations(error) {
5156
5861
  if (!Array.isArray(list) || !list.length || list.length > MAX_REQUIRED) return;
5157
5862
  const valid = list.every((item) => typeof item === "object" && item !== null && item.mode === "url" && typeof item.message === "string" && typeof item.url === "string" && typeof item.elicitationId === "string" && item.elicitationId.length > 0 && item.elicitationId.length <= 256);
5158
5863
  if (!valid || new Set(list.map((item) => item.elicitationId)).size !== list.length) return;
5159
- return list.map(({ mode, message: message2, url, elicitationId }) => ({ mode, message: message2, url, elicitationId }));
5864
+ return list.map(({ mode, message: message3, url, elicitationId }) => ({ mode, message: message3, url, elicitationId }));
5160
5865
  }
5161
5866
 
5162
5867
  // src/runtime.ts
@@ -5252,6 +5957,8 @@ var createSdkConnector = (storeFactory = credentialStore) => async (name, config
5252
5957
  }) : new StreamableHTTPClientTransport(new URL(config.url), {
5253
5958
  requestInit: { headers: config.headers },
5254
5959
  authProvider: await connectionAuthProvider(name, config, storeFactory),
5960
+ // Approval and resubmission belong to the user, not transport retries.
5961
+ onInsufficientScope: "throw",
5255
5962
  // Bound HTTP responses (including OAuth), but not established SSE streams.
5256
5963
  // Accommodate startup/tool overrides; the SDK enforces each request's tighter deadline.
5257
5964
  fetch: async (input, init) => {
@@ -5262,7 +5969,7 @@ var createSdkConnector = (storeFactory = credentialStore) => async (name, config
5262
5969
  );
5263
5970
  timer.unref();
5264
5971
  try {
5265
- const response = await fetch(input, {
5972
+ const response = await privateOAuthFetch(input, {
5266
5973
  ...init,
5267
5974
  signal: AbortSignal.any([
5268
5975
  signal,
@@ -5270,6 +5977,10 @@ var createSdkConnector = (storeFactory = credentialStore) => async (name, config
5270
5977
  ...init?.signal ? [init.signal] : []
5271
5978
  ])
5272
5979
  });
5980
+ if (response.status === 403 && usesOAuth(config)) {
5981
+ const challenge = extractWWWAuthenticateParams(response);
5982
+ if (challenge.error === "insufficient_scope") handlers?.scopeChallenge?.(challenge.scope);
5983
+ }
5273
5984
  if (response.headers.get("content-type")?.split(";")[0].trim() === "text/event-stream")
5274
5985
  clearTimeout(timer);
5275
5986
  return response;
@@ -5308,8 +6019,21 @@ var McpRuntime = class {
5308
6019
  this.options = options;
5309
6020
  }
5310
6021
  states = /* @__PURE__ */ new Map();
6022
+ scopeChallenges = /* @__PURE__ */ new Map();
5311
6023
  lifetime = new AbortController();
5312
6024
  closing;
6025
+ /** Challenges are session-local, expire, and never survive configuration changes. */
6026
+ authorizationChallenge(name) {
6027
+ const challenge = this.scopeChallenges.get(name);
6028
+ if (!challenge || challenge.expires < Date.now() || challenge.identity !== this.identity(name)) {
6029
+ this.scopeChallenges.delete(name);
6030
+ return void 0;
6031
+ }
6032
+ return challenge;
6033
+ }
6034
+ clearAuthorizationChallenge(name, approved) {
6035
+ if (this.scopeChallenges.get(name) === approved) this.scopeChallenges.delete(name);
6036
+ }
5313
6037
  onResourceUpdated;
5314
6038
  subscriptionGeneration = 0;
5315
6039
  subscriptionCleanup = Promise.resolve();
@@ -5374,6 +6098,14 @@ var McpRuntime = class {
5374
6098
  ),
5375
6099
  this.lifetime.signal,
5376
6100
  {
6101
+ scopeChallenge: (scope) => {
6102
+ if (this.closing || this.lifetime.signal.aborted || state.connectionToken !== token) return;
6103
+ const scopes = parseScopes(scope);
6104
+ const previous = this.authorizationChallenge(name);
6105
+ const union = scopes && parseScopes(computeScopeUnion2(previous?.scopes.join(" "), scopes.join(" ")));
6106
+ if (union) this.scopeChallenges.set(name, { scopes: union, identity, expires: Date.now() + 6e5 });
6107
+ else this.scopeChallenges.delete(name);
6108
+ },
5377
6109
  toolsChanged: () => {
5378
6110
  if (this.closing || this.lifetime.signal.aborted || state.connectionToken !== token)
5379
6111
  return;
@@ -5950,6 +6682,7 @@ var McpRuntime = class {
5950
6682
  throw failure("busy", { server: name, operation: "auth" });
5951
6683
  }
5952
6684
  await Promise.all(names.map(async (name) => {
6685
+ this.scopeChallenges.delete(name);
5953
6686
  const state = this.states.get(name);
5954
6687
  if (!state) return;
5955
6688
  state.connectionToken = void 0;
@@ -6005,6 +6738,7 @@ var McpRuntime = class {
6005
6738
  return this.closing ??= this.shutdown();
6006
6739
  }
6007
6740
  async shutdown() {
6741
+ this.scopeChallenges.clear();
6008
6742
  await this.clearResourceSubscriptions();
6009
6743
  await Promise.all(
6010
6744
  [...this.states.values()].map(
@@ -6250,7 +6984,7 @@ Full MCP result: ${path}`;
6250
6984
 
6251
6985
  // src/render.ts
6252
6986
  import { keyText } from "@earendil-works/pi-coding-agent";
6253
- import { Text as Text3, truncateToWidth as truncateToWidth3 } from "@earendil-works/pi-tui";
6987
+ import { Text as Text4, truncateToWidth as truncateToWidth4 } from "@earendil-works/pi-tui";
6254
6988
 
6255
6989
  // src/format.ts
6256
6990
  import {
@@ -6361,10 +7095,10 @@ function renderCall(title, args, theme, expanded) {
6361
7095
  if (width <= 0) return [];
6362
7096
  const text = theme.fg("toolTitle", theme.bold(line(title))) + (preview ? theme.fg("dim", ` ${preview}`) : "");
6363
7097
  if (expanded)
6364
- return new Text3(text, 0, 0).render(width).map((row) => truncateToWidth3(row, width));
7098
+ return new Text4(text, 0, 0).render(width).map((row) => truncateToWidth4(row, width));
6365
7099
  const key = keyText("app.tools.expand");
6366
7100
  return [
6367
- truncateToWidth3(
7101
+ truncateToWidth4(
6368
7102
  text + (key ? theme.fg("muted", ` (${key} to expand)`) : ""),
6369
7103
  width
6370
7104
  )
@@ -6399,30 +7133,30 @@ function renderResult(result, options, theme, isError) {
6399
7133
  const lines = rows.flatMap((row) => {
6400
7134
  const status = states[row.state] ?? states.failed;
6401
7135
  const value = theme.fg(status.color, status.glyph) + " " + theme.fg(status === states.failed ? "error" : "accent", line(row.label)) + (row.inlineDescription ? theme.fg("dim", ` ${line(row.inlineDescription)}`) : "") + (row.inlineAction ? theme.bold(theme.fg("dim", ` ${line(row.inlineAction)}`)) : "");
6402
- const rendered = options.expanded && (!details?.searchNotes || row.state === "failed") ? new Text3(value, 0, 0).render(width).map((x) => truncateToWidth3(x, width)) : [truncateToWidth3(value, width)];
7136
+ const rendered = options.expanded && (!details?.searchNotes || row.state === "failed") ? new Text4(value, 0, 0).render(width).map((x) => truncateToWidth4(x, width)) : [truncateToWidth4(value, width)];
6403
7137
  if (options.expanded && row.description)
6404
- rendered.push(truncateToWidth3(
7138
+ rendered.push(truncateToWidth4(
6405
7139
  theme.fg("dim", ` ${line(row.description)}`),
6406
7140
  width
6407
7141
  ));
6408
7142
  return rendered;
6409
7143
  });
6410
7144
  for (const note of details?.searchNotes ?? [])
6411
- lines.push(...(options.expanded ? new Text3(theme.fg("warning", plain(note)), 0, 0).render(width) : [theme.fg("warning", line(note))]).map((row) => truncateToWidth3(row, width)));
7145
+ lines.push(...(options.expanded ? new Text4(theme.fg("warning", plain(note)), 0, 0).render(width) : [theme.fg("warning", line(note))]).map((row) => truncateToWidth4(row, width)));
6412
7146
  if (options.expanded && !options.isPartial && text && !details?.searchNotes && !bodyInRow)
6413
7147
  lines.push(
6414
- ...new Text3(
7148
+ ...new Text4(
6415
7149
  formattedOutput ??= (() => {
6416
7150
  const output = validation ? formatValidation(body, theme) : formatOutput(body, details?.displayBlocks, theme);
6417
7151
  return isError || details?.failed ? theme.fg("error", output) : output;
6418
7152
  })(),
6419
7153
  0,
6420
7154
  0
6421
- ).render(width).map((x) => truncateToWidth3(x, width))
7155
+ ).render(width).map((x) => truncateToWidth4(x, width))
6422
7156
  );
6423
7157
  if (!options.expanded && details?.fullOutputPath)
6424
7158
  lines.push(
6425
- truncateToWidth3(
7159
+ truncateToWidth4(
6426
7160
  theme.fg("dim", `Full result: ${details.fullOutputPath}`),
6427
7161
  width
6428
7162
  )
@@ -6492,14 +7226,14 @@ var CommandUsageError = class extends Error {
6492
7226
  };
6493
7227
  function errorResult(error, context) {
6494
7228
  const value = error instanceof ToolContractError ? diagnostic("tool_changed", context) : diagnose(error, context);
6495
- const message2 = formatDiagnostic(value) + (context.operation === "call" && !value.code.startsWith("elicitation_") ? " The call was not replayed. The server may already have performed the operation; verify before retrying." : "");
6496
- return textResult(message2, {
7229
+ const message3 = formatDiagnostic(value) + (context.operation === "call" && !value.code.startsWith("elicitation_") ? " The call was not replayed. The server may already have performed the operation; verify before retrying." : "");
7230
+ return textResult(message3, {
6497
7231
  mcpClient: 1,
6498
7232
  failed: true,
6499
7233
  diagnostics: [value],
6500
7234
  rows: [
6501
7235
  {
6502
- label: value.code === "cancelled" ? "Cancelled" : message2,
7236
+ label: value.code === "cancelled" ? "Cancelled" : message3,
6503
7237
  state: value.code === "cancelled" ? "cancelled" : "failed"
6504
7238
  }
6505
7239
  ]
@@ -6521,12 +7255,12 @@ function mcpClient(pi, options = {}) {
6521
7255
  STATUS_ENTRY,
6522
7256
  (entry, _options, theme) => statusPanel(entry.data ?? { servers: [], loaded: [] }, theme)
6523
7257
  );
6524
- pi.registerMessageRenderer("mcp-prompt", (message2, { expanded, outputPad }, theme) => {
6525
- const details = object(message2.details) ? message2.details : {};
7258
+ pi.registerMessageRenderer("mcp-prompt", (message3, { expanded, outputPad }, theme) => {
7259
+ const details = object(message3.details) ? message3.details : {};
6526
7260
  const count = Number(details.count ?? 0);
6527
7261
  const label2 = `${line(String(details.server ?? "MCP"))} \xB7 ${line(String(details.name ?? "prompt"))} \xB7 ${count} ${count === 1 ? "message" : "messages"}`;
6528
- return new Text4(theme.fg("toolTitle", "mcp prompt") + "\n" + theme.fg("success", "\u2714\uFE0E ") + theme.fg("accent", label2) + (expanded && typeof message2.content === "string" ? `
6529
- ${message2.content}` : ""), outputPad, 0);
7262
+ return new Text5(theme.fg("toolTitle", "mcp prompt") + "\n" + theme.fg("success", "\u2714\uFE0E ") + theme.fg("accent", label2) + (expanded && typeof message3.content === "string" ? `
7263
+ ${message3.content}` : ""), outputPad, 0);
6530
7264
  });
6531
7265
  const exposure = new Exposure(pi, registerNative);
6532
7266
  const launchBrowser = async (target) => {
@@ -6537,7 +7271,7 @@ ${message2.content}` : ""), outputPad, 0);
6537
7271
  const elicitationUi = (ctx) => ({
6538
7272
  select: (title, choices2, signal) => chooseOption(ctx, title, choices2, signal),
6539
7273
  editor: (title, value) => ctx.ui.editor(title, value),
6540
- notify: (message2, type) => ctx.ui.notify(message2, type),
7274
+ notify: (message3, type) => ctx.ui.notify(message3, type),
6541
7275
  open: launchBrowser
6542
7276
  });
6543
7277
  const current = () => {
@@ -6564,12 +7298,12 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
6564
7298
  renderResult: (result, options2, theme, context) => renderResult(result, options2, theme, context.isError),
6565
7299
  async execute(_id, args, signal, onUpdate, ctx) {
6566
7300
  const label2 = `${tool.server}.${tool.name}`;
6567
- const emit = (message2) => onUpdate?.(
6568
- textResult(message2, {
7301
+ const emit = (message3) => onUpdate?.(
7302
+ textResult(message3, {
6569
7303
  mcpClient: 1,
6570
7304
  rows: [
6571
7305
  {
6572
- label: `${label2} \xB7 ${line(message2).slice(0, 160)}`,
7306
+ label: `${label2} \xB7 ${line(message3).slice(0, 160)}`,
6573
7307
  state: "running"
6574
7308
  }
6575
7309
  ]
@@ -6684,6 +7418,7 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
6684
7418
  return false;
6685
7419
  }
6686
7420
  });
7421
+ loginController?.abort();
6687
7422
  promptController?.abort();
6688
7423
  if (mutation?.action !== "import") importController?.abort();
6689
7424
  const old = runtime;
@@ -6697,6 +7432,7 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
6697
7432
  }
6698
7433
  pi.on("session_start", async (_event, ctx) => {
6699
7434
  const generation = ++sessionGeneration;
7435
+ loginController?.abort();
6700
7436
  promptController?.abort();
6701
7437
  importController?.abort();
6702
7438
  trustController?.abort();
@@ -6914,7 +7650,7 @@ Variables: ${(candidate.variables ?? []).join(", ") || "none"}. Supply known val
6914
7650
  state: "failed"
6915
7651
  };
6916
7652
  }));
6917
- messages2.push(...discovery.unavailable.map((message2) => `Not searched: ${message2}`), ...discovery.warnings);
7653
+ messages2.push(...discovery.unavailable.map((message3) => `Not searched: ${message3}`), ...discovery.warnings);
6918
7654
  messages2.push("No tools activated or resource or prompt content read. Activate selected tools or read selected resources using their exact next-call arguments. Prompt commands are for the user to run, not the assistant.");
6919
7655
  } else {
6920
7656
  const resolved = resolveTools(discovery.tools, identifiers);
@@ -7037,8 +7773,8 @@ ${snapshot.body}`,
7037
7773
  if (action === "subscriptions") {
7038
7774
  if (server) throw new CommandUsageError("Use /mcp subscriptions without arguments.");
7039
7775
  const watches = current().resourceSubscriptions();
7040
- const message2 = watches.length ? watches.map((watch) => `${watch.changed ? "\u21BB" : "\u2714\uFE0E"} ${watch.server} \xB7 ${line(watch.uri).slice(0, 160)}${watch.changed ? " \xB7 changed" : ""}`).join("\n") : "No active resource subscriptions.";
7041
- if (ctx.hasUI) ctx.ui.notify(message2, "info");
7776
+ const message3 = watches.length ? watches.map((watch) => `${watch.changed ? "\u21BB" : "\u2714\uFE0E"} ${watch.server} \xB7 ${line(watch.uri).slice(0, 160)}${watch.changed ? " \xB7 changed" : ""}`).join("\n") : "No active resource subscriptions.";
7777
+ if (ctx.hasUI) ctx.ui.notify(message3, "info");
7042
7778
  return;
7043
7779
  }
7044
7780
  if (action === "subscribe" || action === "unsubscribe") {
@@ -7057,8 +7793,8 @@ ${snapshot.body}`,
7057
7793
  server = mutation.server;
7058
7794
  await reloadConfiguration(ctx, mutation);
7059
7795
  const remaining = Object.hasOwn(config, server);
7060
- const message2 = mutation.action === "add" ? `\u2714\uFE0E ${server}: saved in ${mutation.scope} configuration. Connections, authentication, and tool discovery remain on demand.` + (mutation.scope === "global" && projectTrusted(ctx) ? " Trusted project definitions take precedence over global definitions." : "") : `\u2714\uFE0E ${server}: removed from ${mutation.scope} configuration. Credentials were retained.` + (remaining ? " A definition from the other scope remains effective; connections reopen on demand." : " Its connection is closed and its tools are no longer active.");
7061
- if (ctx.hasUI) ctx.ui.notify(message2, "info");
7796
+ const message3 = mutation.action === "add" ? `\u2714\uFE0E ${server}: saved in ${mutation.scope} configuration. Connections, authentication, and tool discovery remain on demand.` + (mutation.scope === "global" && projectTrusted(ctx) ? " Trusted project definitions take precedence over global definitions." : "") : `\u2714\uFE0E ${server}: removed from ${mutation.scope} configuration. Credentials were retained.` + (remaining ? " A definition from the other scope remains effective; connections reopen on demand." : " Its connection is closed and its tools are no longer active.");
7797
+ if (ctx.hasUI) ctx.ui.notify(message3, "info");
7062
7798
  return;
7063
7799
  }
7064
7800
  if (action === "reload" && !server) {
@@ -7177,7 +7913,7 @@ ${snapshot.body}`,
7177
7913
  if (Object.keys(config[server].headers ?? {}).some((name) => name.toLowerCase() === "authorization"))
7178
7914
  throw new CommandUsageError("This server uses an Authorization header. Remove it from the server definition before using /mcp login, or keep using header authentication.");
7179
7915
  const identity = oauthSettings(server, config[server], ctx.cwd);
7180
- const options2 = config[server];
7916
+ let options2 = config[server];
7181
7917
  const loginRuntime = current();
7182
7918
  const open2 = async (target) => {
7183
7919
  if (!await launchBrowser(target))
@@ -7192,8 +7928,17 @@ ${snapshot.body}`,
7192
7928
  const store = await storeFactory(identity);
7193
7929
  if (generation !== sessionGeneration || current() !== loginRuntime)
7194
7930
  throw failure("cancelled", { server, operation: "auth" });
7195
- const summary = `Requested scopes: ${options2.oauthScopes?.join(", ") ?? "SDK/server defaults"}
7196
- Callback: http://127.0.0.1:${options2.oauthCallbackPort ?? 19847}/callback`;
7931
+ const challenge = loginRuntime.authorizationChallenge(server);
7932
+ if (challenge) {
7933
+ const plan = authorizationOptions(identity, store, options2, challenge.scopes);
7934
+ options2 = { ...options2, ...plan.options };
7935
+ const approved = await reviewAuthorization(ctx, server, challenge.scopes, options2.oauthScopes, plan.grantedScopesKnown, signal);
7936
+ signal.throwIfAborted();
7937
+ if (!approved) throw failure("cancelled", { server, operation: "auth" });
7938
+ if (generation !== sessionGeneration || current() !== loginRuntime || loginRuntime.authorizationChallenge(server) !== challenge)
7939
+ throw failure("cancelled", { server, operation: "auth" });
7940
+ }
7941
+ const summary = loginSummary(options2, !!challenge);
7197
7942
  if (extra[0] === "--no-browser") {
7198
7943
  await authenticate(identity, open2, signal, store, {
7199
7944
  ...options2,
@@ -7236,6 +7981,7 @@ Opening browser. Esc to cancel.`
7236
7981
  throw authError ?? failure("cancelled", { server, operation: "auth" });
7237
7982
  } else await authenticate(identity, open2, signal, store, options2);
7238
7983
  if (generation !== sessionGeneration || current() !== loginRuntime) throw failure("cancelled", { server, operation: "auth" });
7984
+ if (challenge) loginRuntime.clearAuthorizationChallenge(server, challenge);
7239
7985
  await loginRuntime.reconnect(server);
7240
7986
  } finally {
7241
7987
  if (loginController === controller) loginController = void 0;
@@ -7256,7 +8002,7 @@ Opening browser. Esc to cancel.`
7256
8002
  "info"
7257
8003
  );
7258
8004
  } catch (error) {
7259
- const message2 = error instanceof CommandUsageError || error instanceof ConfigMutationError || error instanceof PromptCommandError ? error.message : formatDiagnostic(
8005
+ const message3 = error instanceof CommandUsageError || error instanceof ConfigMutationError || error instanceof PromptCommandError ? error.message : formatDiagnostic(
7260
8006
  diagnose(error, {
7261
8007
  server,
7262
8008
  operation: ["reload", "get", "enable", "disable", "add", "remove", "import"].includes(action) ? "configuration" : action === "tools" ? "search" : action === "prompt" ? "prompt" : ["login", "logout"].includes(action) ? "auth" : ["subscribe", "unsubscribe"].includes(action) ? "subscribe" : action === "refresh" ? "refresh" : "reconnect",
@@ -7264,8 +8010,8 @@ Opening browser. Esc to cancel.`
7264
8010
  signal: ctx.signal
7265
8011
  })
7266
8012
  );
7267
- if (ctx.hasUI) ctx.ui.notify(message2, "error");
7268
- else throw new Error(message2);
8013
+ if (ctx.hasUI) ctx.ui.notify(message3, "error");
8014
+ else throw new Error(message3);
7269
8015
  }
7270
8016
  }
7271
8017
  });