pi-mcp-client 0.4.0 → 0.5.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
@@ -1879,7 +1879,7 @@ function diagnostic(code, context) {
1879
1879
  const target = server ?? "<server>";
1880
1880
  const hints = {
1881
1881
  configuration_invalid: "Check mcpServers in mcp.json / .mcp.json, including inline server options and environment variables. Move options from any top-level pi section into mcpServers.<server>. Reload Pi after editing.",
1882
- authentication_required: context.oauth ? `Run /mcp login ${target}.` : "Check the Authorization header, or enable mcpServers.<server>.oauth and authenticate with /mcp login <server>.",
1882
+ authentication_required: context.oauth ? `Run /mcp login ${target}.` : `For OAuth, run /mcp login ${target}. If you use an Authorization header, check its credentials instead.`,
1883
1883
  permission_denied: "Check the account's permissions, OAuth scopes, and service access policy.",
1884
1884
  credential_store_unavailable: "Unlock or enable the OS keyring. Linux requires a Secret Service session; there is no plaintext fallback.",
1885
1885
  secret_lookup_failed: "Check the secret helper's availability, login, exit status, and nonempty stdout. The limit is 64 KiB and at most 10 seconds.",
@@ -2121,7 +2121,6 @@ function object(value) {
2121
2121
  }
2122
2122
  var OPTION_FIELDS = [
2123
2123
  "description",
2124
- "oauth",
2125
2124
  "oauthClientId",
2126
2125
  "oauthScopes",
2127
2126
  "oauthCallbackPort",
@@ -2138,15 +2137,13 @@ function validateOptions(entry, fail) {
2138
2137
  if (entry[key] !== void 0 && (!Array.isArray(entry[key]) || !entry[key].every((x) => typeof x === "string")))
2139
2138
  fail(key);
2140
2139
  }
2141
- if (entry.oauthClientId !== void 0 && (entry.oauth !== true || typeof entry.oauthClientId !== "string" || !entry.oauthClientId.trim() || entry.oauthClientId.length > 4096 || /[\u0000-\u001f\u007f]/u.test(entry.oauthClientId)))
2142
- fail("oauthClientId (requires oauth: true and a nonempty client ID)");
2143
- if (entry.oauthScopes !== void 0 && (entry.oauth !== true || !Array.isArray(entry.oauthScopes) || entry.oauthScopes.length === 0 || entry.oauthScopes.length > 100 || !entry.oauthScopes.every((scope) => typeof scope === "string" && scope.length <= 256 && /^[\x21\x23-\x5b\x5d-\x7e]+$/u.test(scope)) || new Set(entry.oauthScopes).size !== entry.oauthScopes.length))
2144
- fail("oauthScopes (requires oauth: true and 1\u2013100 unique OAuth scope tokens)");
2145
- if (entry.oauthCallbackPort !== void 0 && (entry.oauth !== true || !Number.isInteger(entry.oauthCallbackPort) || Number(entry.oauthCallbackPort) < 1 || Number(entry.oauthCallbackPort) > 65535))
2146
- fail("oauthCallbackPort (requires oauth: true and a port from 1\u201365535)");
2147
- for (const key of ["oauth", "disabled"]) {
2148
- if (entry[key] !== void 0 && typeof entry[key] !== "boolean") fail(key);
2149
- }
2140
+ if (entry.oauthClientId !== void 0 && (typeof entry.oauthClientId !== "string" || !entry.oauthClientId.trim() || entry.oauthClientId.length > 4096 || /[\u0000-\u001f\u007f]/u.test(entry.oauthClientId)))
2141
+ fail("oauthClientId (requires OAuth and a nonempty client ID)");
2142
+ if (entry.oauthScopes !== void 0 && (!Array.isArray(entry.oauthScopes) || entry.oauthScopes.length === 0 || entry.oauthScopes.length > 100 || !entry.oauthScopes.every((scope) => typeof scope === "string" && scope.length <= 256 && /^[\x21\x23-\x5b\x5d-\x7e]+$/u.test(scope)) || new Set(entry.oauthScopes).size !== entry.oauthScopes.length))
2143
+ fail("oauthScopes (requires OAuth and 1\u2013100 unique OAuth scope tokens)");
2144
+ if (entry.oauthCallbackPort !== void 0 && (!Number.isInteger(entry.oauthCallbackPort) || Number(entry.oauthCallbackPort) < 1 || Number(entry.oauthCallbackPort) > 65535))
2145
+ fail("oauthCallbackPort (requires OAuth and a port from 1\u201365535)");
2146
+ if (entry.disabled !== void 0 && typeof entry.disabled !== "boolean") fail("disabled");
2150
2147
  if (entry.timeoutMs !== void 0 && (!Number.isInteger(entry.timeoutMs) || Number(entry.timeoutMs) < 100 || Number(entry.timeoutMs) > 6e5))
2151
2148
  fail("timeoutMs (100\u2013600000)");
2152
2149
  if (entry.protocol !== void 0 && entry.protocol !== "legacy" && entry.protocol !== "auto")
@@ -2176,6 +2173,8 @@ function parseConnections(value, source) {
2176
2173
  const fail = (field) => {
2177
2174
  throw new Error(`${source}: invalid ${field} for server ${name}.`);
2178
2175
  };
2176
+ if (Object.hasOwn(entry, "oauth"))
2177
+ throw new Error(`${source}: remove oauth from server ${name}; HTTP authentication is automatic.`);
2179
2178
  for (const key of Object.keys(entry)) if (!fields.has(key)) fail(key);
2180
2179
  validateOptions(entry, fail);
2181
2180
  if (entry.type !== void 0 && entry.type !== "stdio" && entry.type !== "http")
@@ -2196,7 +2195,7 @@ function parseConnections(value, source) {
2196
2195
  fail("transport (provide exactly one of command or url)");
2197
2196
  if (entry.type === "stdio" && !entry.command || entry.type === "http" && !entry.url)
2198
2197
  fail("type (must match command or url)");
2199
- if (entry.command && (entry.oauth || entry.headers))
2198
+ if (entry.command && (entry.headers || entry.oauthClientId !== void 0 || entry.oauthScopes !== void 0 || entry.oauthCallbackPort !== void 0))
2200
2199
  fail("HTTP options on stdio transport");
2201
2200
  if (entry.url && (entry.args || entry.cwd || entry.env))
2202
2201
  fail("stdio options on HTTP transport");
@@ -2245,11 +2244,12 @@ async function configTarget(path) {
2245
2244
  }
2246
2245
  }
2247
2246
  async function updateServerConfig(agentDir, cwd, trusted, mutation, validate) {
2248
- if (mutation.action !== "toggle" && mutation.scope === "project" && !trusted)
2247
+ const scoped = mutation.action === "add" || mutation.action === "remove";
2248
+ if (scoped && mutation.scope === "project" && !trusted)
2249
2249
  throw new ConfigMutationError("Project configuration requires a trusted project. Use global scope or trust the project first.");
2250
2250
  const paths = [join(agentDir, "mcp.json"), ...trusted ? [join(cwd, ".mcp.json")] : []];
2251
2251
  const targets = await Promise.all(paths.map(configTarget));
2252
- if (mutation.action !== "toggle" && targets.length === 2 && targets[0] === targets[1])
2252
+ if (scoped && targets.length === 2 && targets[0] === targets[1])
2253
2253
  throw new ConfigMutationError("Global and project configuration share a file; separate them before scoped edits.");
2254
2254
  const locks = [...new Set(targets)].sort();
2255
2255
  const locked = async (index) => {
@@ -2258,8 +2258,8 @@ async function updateServerConfig(agentDir, cwd, trusted, mutation, validate) {
2258
2258
  const documents = await Promise.all(targets.map(readJson));
2259
2259
  const parsed = documents.map((document2, index2) => document2 === void 0 ? /* @__PURE__ */ Object.create(null) : parseConfig(document2, paths[index2]));
2260
2260
  const { server } = mutation;
2261
- let source = mutation.action === "toggle" ? -1 : mutation.scope === "global" ? 0 : 1;
2262
- if (mutation.action === "toggle") {
2261
+ let source = scoped ? mutation.scope === "global" ? 0 : 1 : -1;
2262
+ if (!scoped) {
2263
2263
  for (const [index2, config2] of parsed.entries()) if (Object.hasOwn(config2, server)) source = index2;
2264
2264
  if (source < 0) throw new ConfigMutationError("Server is no longer configured. Run /mcp reload.");
2265
2265
  }
@@ -2339,12 +2339,15 @@ function resolveServer(config, cwd) {
2339
2339
  throw new Error("MCP URLs must use HTTP(S), without credentials or fragments.");
2340
2340
  result.url = url.href;
2341
2341
  }
2342
- if (result.oauth && Object.keys(result.headers ?? {}).some(
2342
+ if ((result.oauthClientId !== void 0 || result.oauthScopes !== void 0 || result.oauthCallbackPort !== void 0) && Object.keys(result.headers ?? {}).some(
2343
2343
  (key) => key.toLowerCase() === "authorization"
2344
2344
  ))
2345
2345
  throw new Error("Use OAuth or an Authorization header, not both.");
2346
2346
  return result;
2347
2347
  }
2348
+ function usesOAuth(config) {
2349
+ return Boolean(config?.url) && !Object.keys(config?.headers ?? {}).some((name) => name.toLowerCase() === "authorization");
2350
+ }
2348
2351
  function fingerprint(value) {
2349
2352
  const canonical = (item) => Array.isArray(item) ? item.map(canonical) : object(item) ? Object.fromEntries(
2350
2353
  Object.keys(item).sort().map((key) => [key, canonical(item[key])])
@@ -2566,7 +2569,7 @@ function searchCapabilities(tools, resources, query, server, limit = DEFAULT_SEA
2566
2569
  }
2567
2570
 
2568
2571
  // src/config-commands.ts
2569
- var ADD_USAGE = "Usage: /mcp add --scope global|project [--replace] [--transport http|stdio] [--header 'Name: value'] [--env KEY=value] [--oauth] [--oauth-client-id ID] [--oauth-scope SCOPE] [--oauth-callback-port PORT] <server> <url> OR <server> -- <command> [args...]. Put options before the server name.";
2572
+ 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.";
2570
2573
  var REMOVE_USAGE = "Usage: /mcp remove --scope global|project <server>. Credentials are retained; log out first if you want to remove them.";
2571
2574
  function commandWords(input) {
2572
2575
  const words = [];
@@ -2627,10 +2630,8 @@ function parseConfigCommand(input) {
2627
2630
  replace = true;
2628
2631
  continue;
2629
2632
  }
2630
- if (flag === "--oauth" && action === "add") {
2631
- definition.oauth = true;
2632
- continue;
2633
- }
2633
+ if (flag === "--oauth")
2634
+ throw new ConfigMutationError("Remove --oauth; HTTP authentication is automatic.");
2634
2635
  const value = words[index++];
2635
2636
  if (value === void 0 || value.startsWith("--")) fail();
2636
2637
  if (flag === "--scope") {
@@ -2667,7 +2668,7 @@ function parseConfigCommand(input) {
2667
2668
  if (transport === "http") fail();
2668
2669
  definition.command = words[++index];
2669
2670
  definition.args = words.slice(index + 1);
2670
- if (!definition.command || definition.oauth || definition.oauthClientId || definition.oauthScopes || definition.oauthCallbackPort || definition.headers) fail();
2671
+ if (!definition.command || definition.oauthClientId || definition.oauthScopes || definition.oauthCallbackPort || definition.headers) fail();
2671
2672
  } else {
2672
2673
  if (transport === "stdio" || index + 1 !== words.length || definition.env) fail();
2673
2674
  definition.url = words[index];
@@ -2689,7 +2690,7 @@ function configCommandCompletions(input, servers) {
2689
2690
  const args = /^--scope\s+(global|project)\s+([^\s]*)$/u.exec(rest);
2690
2691
  if (!args) return [];
2691
2692
  const [, scope, partial] = args;
2692
- const values = action === "remove" ? servers.sort() : ["--replace", "--transport", "--header", "--env", "--oauth", "--oauth-client-id", "--oauth-scope", "--oauth-callback-port"];
2693
+ const values = action === "remove" ? servers.sort() : ["--replace", "--transport", "--header", "--env", "--oauth-client-id", "--oauth-scope", "--oauth-callback-port"];
2693
2694
  return values.filter((value) => value.startsWith(partial)).map((value) => ({
2694
2695
  value: `${action} --scope ${scope} ${value}`,
2695
2696
  label: value
@@ -2777,7 +2778,6 @@ function oauthCallbackHtml(status) {
2777
2778
  }
2778
2779
 
2779
2780
  // src/auth.ts
2780
- var REDIRECT = "http://127.0.0.1:19847/callback";
2781
2781
  function callbackUrl(options = {}) {
2782
2782
  return `http://127.0.0.1:${options.oauthCallbackPort ?? 19847}/callback`;
2783
2783
  }
@@ -2796,7 +2796,7 @@ function parseCallback(value, redirect, state) {
2796
2796
  return params;
2797
2797
  }
2798
2798
  function credentialKey(url, clientId) {
2799
- return fingerprint({ url, redirect: REDIRECT, clientId });
2799
+ return fingerprint({ url, clientId });
2800
2800
  }
2801
2801
  var credentialEpochs = /* @__PURE__ */ new Map();
2802
2802
  async function credentialStore(url, clientId) {
@@ -2821,6 +2821,17 @@ async function credentialStore(url, clientId) {
2821
2821
  throw failure("credential_store_unavailable", { operation: "auth" });
2822
2822
  }
2823
2823
  }
2824
+ function clientMetadata(options) {
2825
+ return {
2826
+ client_name: "Pi MCP Client",
2827
+ redirect_uris: [callbackUrl(options)],
2828
+ grant_types: ["authorization_code", "refresh_token"],
2829
+ response_types: ["code"],
2830
+ token_endpoint_auth_method: "none",
2831
+ application_type: "native",
2832
+ ...options.oauthScopes ? { scope: options.oauthScopes.join(" ") } : {}
2833
+ };
2834
+ }
2824
2835
  var OAuthProvider = class {
2825
2836
  constructor(url, store, redirect, clientId, options = {}) {
2826
2837
  this.url = url;
@@ -2828,15 +2839,7 @@ var OAuthProvider = class {
2828
2839
  this.redirect = redirect;
2829
2840
  this.clientId = clientId;
2830
2841
  this.redirectUrl = callbackUrl(options);
2831
- this.clientMetadata = {
2832
- client_name: "Pi MCP Client",
2833
- redirect_uris: [this.redirectUrl],
2834
- grant_types: ["authorization_code", "refresh_token"],
2835
- response_types: ["code"],
2836
- token_endpoint_auth_method: "none",
2837
- application_type: "native",
2838
- ...options.oauthScopes ? { scope: options.oauthScopes.join(" ") } : {}
2839
- };
2842
+ this.clientMetadata = clientMetadata(options);
2840
2843
  this.key = credentialKey(url, clientId);
2841
2844
  this.epoch = credentialEpochs.get(this.key) ?? 0;
2842
2845
  const raw = store.read();
@@ -2876,7 +2879,7 @@ var OAuthProvider = class {
2876
2879
  const stored = Object.hasOwn(this.data.clients, ctx.issuer) ? this.data.clients[ctx.issuer] : void 0;
2877
2880
  if (!this.clientId) {
2878
2881
  const registration = this.data.registrations?.[ctx.issuer];
2879
- if (this.redirect && stored && ((registration?.redirect ?? REDIRECT) !== this.redirectUrl || registration?.scope !== this.clientMetadata.scope))
2882
+ if (this.redirect && stored && (registration?.redirect !== this.redirectUrl || registration?.scope !== this.clientMetadata.scope))
2880
2883
  return void 0;
2881
2884
  return stored;
2882
2885
  }
@@ -2943,6 +2946,49 @@ var OAuthProvider = class {
2943
2946
  this.save();
2944
2947
  }
2945
2948
  };
2949
+ async function connectionAuthProvider(config, storeFactory = credentialStore) {
2950
+ if (!usesOAuth(config)) return void 0;
2951
+ let initialized = false;
2952
+ const create = async () => {
2953
+ const provider = new OAuthProvider(
2954
+ config.url,
2955
+ await storeFactory(config.url, config.oauthClientId),
2956
+ void 0,
2957
+ config.oauthClientId,
2958
+ config
2959
+ );
2960
+ initialized = true;
2961
+ return provider;
2962
+ };
2963
+ let pending;
2964
+ const get = () => pending ??= create();
2965
+ return {
2966
+ redirectUrl: callbackUrl(config),
2967
+ clientMetadata: clientMetadata(config),
2968
+ state: async () => (await get()).state(),
2969
+ clientInformation: async (ctx) => {
2970
+ const info = (await get()).clientInformation(ctx);
2971
+ if (!info) throw failure("authentication_required", { operation: "connect", oauth: true });
2972
+ return info;
2973
+ },
2974
+ saveClientInformation: async (info, ctx) => (await get()).saveClientInformation(info, ctx),
2975
+ tokens: async (ctx) => {
2976
+ try {
2977
+ return (await get()).tokens(ctx);
2978
+ } catch (error) {
2979
+ if (!initialized && !ctx && error instanceof DiagnosticError && error.diagnostic.code === "credential_store_unavailable") return void 0;
2980
+ throw error;
2981
+ }
2982
+ },
2983
+ saveTokens: async (tokens2) => (await get()).saveTokens(tokens2),
2984
+ saveCodeVerifier: async (value) => (await get()).saveCodeVerifier(value),
2985
+ codeVerifier: async () => (await get()).codeVerifier(),
2986
+ redirectToAuthorization: async (url) => (await get()).redirectToAuthorization(url),
2987
+ saveDiscoveryState: async (value) => (await get()).saveDiscoveryState(value),
2988
+ discoveryState: async () => pending ? (await pending).discoveryState() : void 0,
2989
+ invalidateCredentials: async (scope) => (await get()).invalidateCredentials(scope)
2990
+ };
2991
+ }
2946
2992
  async function logout(url, store, signal, clientId) {
2947
2993
  let tokens2;
2948
2994
  let client;
@@ -3175,11 +3221,11 @@ ${parameter.description}` : ""}`
3175
3221
  ].join("\n\n");
3176
3222
  }
3177
3223
  function oauthSettings(config, cwd) {
3178
- const resolved = resolveServer({ url: config.url, oauth: true, oauthClientId: config.oauthClientId }, cwd);
3224
+ const resolved = resolveServer({ url: config.url, oauthClientId: config.oauthClientId }, cwd);
3179
3225
  return { url: resolved.url, clientId: resolved.oauthClientId };
3180
3226
  }
3181
3227
  async function authenticationSummary(config, cwd, storeFactory = credentialStore) {
3182
- if (!config.oauth) return config.command ? "Server-managed (stdio)" : Object.keys(config.headers ?? {}).length ? "Headers (externally managed)" : "None configured";
3228
+ if (!usesOAuth(config)) return config.command ? "Server-managed (stdio)" : "Headers (externally managed)";
3183
3229
  const method = config.oauthClientId === void 0 ? "OAuth" : "OAuth (pre-registered public client)";
3184
3230
  try {
3185
3231
  const { url, clientId } = oauthSettings(config, cwd);
@@ -3195,8 +3241,8 @@ function inspectServer(name, config, status, authentication) {
3195
3241
  `Server: ${name}`,
3196
3242
  `Transport: ${config.command ? "stdio" : "HTTP"}`,
3197
3243
  `Protocol: ${config.protocol ?? "auto"}`,
3198
- `OAuth: ${config.oauth ? "enabled" : "disabled"}`,
3199
- ...config.oauth ? [
3244
+ `OAuth: ${usesOAuth(config) ? "automatic" : "not used"}`,
3245
+ ...usesOAuth(config) ? [
3200
3246
  `Requested scopes: ${config.oauthScopes?.map(line).join(", ") ?? "SDK/server defaults"}`,
3201
3247
  `OAuth callback: http://127.0.0.1:${config.oauthCallbackPort ?? 19847}/callback`
3202
3248
  ] : [],
@@ -3341,7 +3387,7 @@ function waitFor2(promise, signal) {
3341
3387
  if (signal.aborted) abort();
3342
3388
  });
3343
3389
  }
3344
- var connectSdk = async (_name, config, signal, onToolsChanged, onResourcesChanged) => {
3390
+ var createSdkConnector = (storeFactory = credentialStore) => async (_name, config, signal, onToolsChanged, onResourcesChanged) => {
3345
3391
  const timeout = config.timeoutMs ?? 15e3;
3346
3392
  const client = new Client(
3347
3393
  { name: "pi-mcp-client", version: "0.1.0" },
@@ -3372,7 +3418,7 @@ var connectSdk = async (_name, config, signal, onToolsChanged, onResourcesChange
3372
3418
  stderr: "pipe"
3373
3419
  }) : new StreamableHTTPClientTransport(new URL(config.url), {
3374
3420
  requestInit: { headers: config.headers },
3375
- authProvider: config.oauth ? new OAuthProvider(config.url, await credentialStore(config.url, config.oauthClientId), void 0, config.oauthClientId, config) : void 0,
3421
+ authProvider: await connectionAuthProvider(config, storeFactory),
3376
3422
  // Bound HTTP responses (including OAuth), but not established SSE streams.
3377
3423
  // The SDK bounds ordinary MCP requests with their request timeout.
3378
3424
  fetch: async (input, init) => {
@@ -3418,6 +3464,7 @@ var connectSdk = async (_name, config, signal, onToolsChanged, onResourcesChange
3418
3464
  throw error;
3419
3465
  }
3420
3466
  };
3467
+ var connectSdk = createSdkConnector();
3421
3468
  var McpRuntime = class {
3422
3469
  constructor(config, cwd, cacheDir, connect = connectSdk) {
3423
3470
  this.config = config;
@@ -3543,7 +3590,7 @@ var McpRuntime = class {
3543
3590
  diagnose(error, {
3544
3591
  server: name,
3545
3592
  operation: "connect",
3546
- oauth: config.oauth,
3593
+ oauth: usesOAuth(config),
3547
3594
  signal: this.lifetime.signal
3548
3595
  })
3549
3596
  );
@@ -3776,7 +3823,7 @@ var McpRuntime = class {
3776
3823
  this.state(name).error = void 0;
3777
3824
  return result;
3778
3825
  } catch (error) {
3779
- const value = diagnose(error, { server: name, operation: "read", oauth: config.oauth, signal: combined });
3826
+ const value = diagnose(error, { server: name, operation: "read", oauth: usesOAuth(config), signal: combined });
3780
3827
  this.state(name).error = value;
3781
3828
  throw new DiagnosticError(value);
3782
3829
  }
@@ -3876,7 +3923,7 @@ var McpRuntime = class {
3876
3923
  const value = diagnose(error, {
3877
3924
  server: tool.server,
3878
3925
  operation: "call",
3879
- oauth: config.oauth,
3926
+ oauth: usesOAuth(config),
3880
3927
  signal
3881
3928
  });
3882
3929
  this.state(tool.server).error = value;
@@ -3974,7 +4021,7 @@ var McpRuntime = class {
3974
4021
  return error instanceof ToolContractError ? diagnostic("tool_changed", { server: name, operation: "search" }) : diagnose(error, {
3975
4022
  server: name,
3976
4023
  operation: "search",
3977
- oauth: this.config[name]?.oauth,
4024
+ oauth: this.config[name] && usesOAuth(this.config[name]),
3978
4025
  signal: this.lifetime.signal
3979
4026
  });
3980
4027
  }
@@ -4253,7 +4300,7 @@ function formatBlock(text, block, theme) {
4253
4300
  ).join("");
4254
4301
  }
4255
4302
  function formatOutput(text, blocks, theme) {
4256
- if (!blocks) return formatBlock(text, {}, theme);
4303
+ if (!blocks) return plain(text);
4257
4304
  let cursor = 0;
4258
4305
  let extraBytes = 0;
4259
4306
  let extraLines = 0;
@@ -4449,7 +4496,7 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
4449
4496
  return errorResult(error, {
4450
4497
  server: tool.server,
4451
4498
  operation: "call",
4452
- oauth: config[tool.server]?.oauth,
4499
+ oauth: usesOAuth(config[tool.server]),
4453
4500
  signal: ctx.signal?.aborted ? ctx.signal : signal
4454
4501
  });
4455
4502
  }
@@ -4501,7 +4548,8 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
4501
4548
  const next = new McpRuntime(
4502
4549
  nextConfig,
4503
4550
  ctx.cwd,
4504
- join4(agentDir, "cache", "pi-mcp-client")
4551
+ join4(agentDir, "cache", "pi-mcp-client"),
4552
+ createSdkConnector(storeFactory)
4505
4553
  );
4506
4554
  const active = new Set(pi.getActiveTools());
4507
4555
  const retained = [...exposure.definitions.values()].filter((tool) => {
@@ -4528,7 +4576,7 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
4528
4576
  configError = void 0;
4529
4577
  try {
4530
4578
  config = await loadConfig(agentDir, ctx.cwd, ctx.isProjectTrusted());
4531
- runtime = new McpRuntime(config, ctx.cwd, join4(agentDir, "cache", "pi-mcp-client"));
4579
+ runtime = new McpRuntime(config, ctx.cwd, join4(agentDir, "cache", "pi-mcp-client"), createSdkConnector(storeFactory));
4532
4580
  resourceNotifications(runtime, ctx);
4533
4581
  } catch (error) {
4534
4582
  configError = new DiagnosticError(diagnose(error, { operation: "configuration" }));
@@ -4754,7 +4802,7 @@ Variables: ${(candidate.variables ?? []).join(", ") || "none"}. Supply known val
4754
4802
  const result = errorResult(error, {
4755
4803
  server: args.complete?.server ?? args.read?.server ?? args.server,
4756
4804
  operation: hasComplete ? "complete" : hasRead ? "read" : "search",
4757
- oauth: config[args.complete?.server ?? args.read?.server ?? args.server ?? ""]?.oauth,
4805
+ oauth: usesOAuth(config[args.complete?.server ?? args.read?.server ?? args.server ?? ""]),
4758
4806
  signal: ctx.signal?.aborted ? ctx.signal : signal
4759
4807
  });
4760
4808
  if (args.read) {
@@ -4854,7 +4902,7 @@ Variables: ${(candidate.variables ?? []).join(", ") || "none"}. Supply known val
4854
4902
  return;
4855
4903
  }
4856
4904
  if (action === "logout" && server && !extra.length && Object.hasOwn(config, server)) {
4857
- if (!config[server].oauth) {
4905
+ if (!usesOAuth(config[server])) {
4858
4906
  if (ctx.hasUI) ctx.ui.notify(
4859
4907
  `${server}: no managed OAuth credentials. Header and server credentials are externally managed; configuration was not changed.`,
4860
4908
  "info"
@@ -4866,7 +4914,7 @@ Variables: ${(candidate.variables ?? []).join(", ") || "none"}. Supply known val
4866
4914
  if (generation !== sessionGeneration)
4867
4915
  throw new CommandUsageError("The Pi session changed during logout.");
4868
4916
  const related = Object.keys(config).filter((name) => {
4869
- if (!config[name].oauth) return false;
4917
+ if (!usesOAuth(config[name])) return false;
4870
4918
  try {
4871
4919
  const other = oauthSettings(config[name], ctx.cwd);
4872
4920
  return other.url === url && other.clientId === clientId;
@@ -4938,11 +4986,13 @@ Variables: ${(candidate.variables ?? []).join(", ") || "none"}. Supply known val
4938
4986
  throw new CommandUsageError(
4939
4987
  "OAuth requires an interactive session. Use an Authorization header for headless access."
4940
4988
  );
4941
- if (!config[server].oauth || !config[server].url)
4942
- throw new CommandUsageError(
4943
- "Enable oauth in this HTTP server's mcpServers definition in mcp.json first."
4944
- );
4989
+ if (!config[server].url)
4990
+ throw new CommandUsageError("OAuth login requires an HTTP server; stdio authentication is managed by the server.");
4991
+ if (Object.keys(config[server].headers ?? {}).some((name) => name.toLowerCase() === "authorization"))
4992
+ throw new CommandUsageError("This server uses an Authorization header. Remove it from the server definition before using /mcp login, or keep using header authentication.");
4945
4993
  const { url, clientId } = oauthSettings(config[server], ctx.cwd);
4994
+ const options2 = config[server];
4995
+ const loginRuntime = current();
4946
4996
  const open = async (target) => {
4947
4997
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
4948
4998
  const result = await pi.exec(command, [target], { timeout: 5e3 }).catch(() => void 0);
@@ -4954,8 +5004,10 @@ Variables: ${(candidate.variables ?? []).join(", ") || "none"}. Supply known val
4954
5004
  loginController = controller;
4955
5005
  const signal = AbortSignal.any([controller.signal, ...ctx.signal ? [ctx.signal] : []]);
4956
5006
  try {
5007
+ signal.throwIfAborted();
4957
5008
  const store = await storeFactory(url, clientId);
4958
- const options2 = config[server];
5009
+ if (generation !== sessionGeneration || current() !== loginRuntime)
5010
+ throw failure("cancelled", { server, operation: "auth" });
4959
5011
  const summary = `Requested scopes: ${options2.oauthScopes?.join(", ") ?? "SDK/server defaults"}
4960
5012
  Callback: http://127.0.0.1:${options2.oauthCallbackPort ?? 19847}/callback`;
4961
5013
  if (extra[0] === "--no-browser") {
@@ -4999,8 +5051,8 @@ Opening browser. Esc to cancel.`
4999
5051
  if (!ok)
5000
5052
  throw authError ?? failure("cancelled", { server, operation: "auth" });
5001
5053
  } else await authenticate(url, open, signal, store, clientId, options2);
5002
- if (generation !== sessionGeneration) throw failure("cancelled", { server, operation: "auth" });
5003
- await current().reconnect(server);
5054
+ if (generation !== sessionGeneration || current() !== loginRuntime) throw failure("cancelled", { server, operation: "auth" });
5055
+ await loginRuntime.reconnect(server);
5004
5056
  } finally {
5005
5057
  if (loginController === controller) loginController = void 0;
5006
5058
  }
@@ -5023,7 +5075,7 @@ Opening browser. Esc to cancel.`
5023
5075
  diagnose(error, {
5024
5076
  server,
5025
5077
  operation: ["reload", "get", "enable", "disable", "add", "remove"].includes(action) ? "configuration" : action === "tools" ? "search" : ["login", "logout"].includes(action) ? "auth" : ["subscribe", "unsubscribe"].includes(action) ? "subscribe" : action === "refresh" ? "refresh" : "reconnect",
5026
- oauth: config[server]?.oauth,
5078
+ oauth: usesOAuth(config[server]),
5027
5079
  signal: ctx.signal
5028
5080
  })
5029
5081
  );
@@ -0,0 +1,175 @@
1
+ # Authentication
2
+
3
+ [Back to the README](../README.md)
4
+
5
+ You manage authentication through `/mcp login` and `/mcp logout`. The assistant
6
+ can't initiate an OAuth login: only your explicit login command opens the browser.
7
+ For externally managed bearer tokens, use
8
+ [headers and secret commands](configuration.md#secret-commands) instead.
9
+
10
+ ## Sign in with OAuth
11
+
12
+ Add the HTTP server, then log in:
13
+
14
+ ```text
15
+ /mcp add --scope global slack https://mcp.slack.com/mcp
16
+ /mcp login slack
17
+ ```
18
+
19
+ Login uses the effective server definition (the trusted project override, if
20
+ present; otherwise the global definition) and starts the SDK's OAuth discovery
21
+ and authorization flow. It doesn't change configuration files. Adding a server
22
+ still doesn't connect or open a browser.
23
+
24
+ HTTP servers use automatic authentication by default:
25
+
26
+ - Configured Authorization headers take precedence over OAuth.
27
+ - Otherwise, connections reuse stored OAuth tokens when available. The SDK
28
+ handles authentication challenges and refreshes existing grants.
29
+ - Without a grant, an authentication challenge asks you to run `/mcp login`.
30
+ Discovery and tool calls never register a new client or open a browser.
31
+ - A missing or locked keyring doesn't block public servers. If the server
32
+ requires OAuth, an unavailable keyring is an error; no credentials are stored
33
+ outside the OS credential store.
34
+
35
+ The `oauth` configuration field and `--oauth` switch aren't supported. Remove
36
+ these from existing definitions and commands; HTTP authentication is automatic.
37
+
38
+ If the server uses an Authorization header, login asks you to remove that header
39
+ before switching to OAuth; it never replaces existing header credentials.
40
+ Stdio servers manage their own authentication and don't support OAuth login.
41
+
42
+ The extension supports public clients with dynamic registration or a
43
+ pre-registered client ID. Both use PKCE and a loopback callback at
44
+ `http://127.0.0.1:19847/callback` by default. Normal login opens a local listener
45
+ that your browser must be able to reach.
46
+
47
+ Authentication times out after two minutes; you can cancel it with Escape in the
48
+ terminal UI. Explicit login always starts a fresh authorization flow, even if a
49
+ refresh token is already stored. The browser callback page identifies **Pi MCP
50
+ Client** and asks you to return to Pi; receiving a callback doesn't yet mean the
51
+ token exchange succeeded.
52
+
53
+ OAuth tokens and client registrations are stored in the operating system
54
+ credential store, bound to the server URL, configured client ID (if any), and
55
+ authorization-server issuer. There is no plaintext credential fallback. PKCE
56
+ verifiers and callback state stay in memory. Linux requires a working Secret
57
+ Service/keyring session.
58
+
59
+ ### Upgrade from earlier versions
60
+
61
+ Credentials now use an identity based only on the server URL and optional client
62
+ ID. Earlier credential-store entries aren't migrated or deleted. Run
63
+ `/mcp login <server>` again after upgrading; revoke old grants at the service if
64
+ needed. Changing scopes or the callback port doesn't select a different store.
65
+
66
+ ## Use a pre-registered client
67
+
68
+ For a server without dynamic registration, register a **public/native** client
69
+ with the service, using the exact callback URL and token endpoint authentication
70
+ method `none`. Then configure its client ID:
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "example": {
76
+ "url": "https://mcp.example.com/mcp",
77
+ "oauthClientId": "${EXAMPLE_OAUTH_CLIENT_ID}"
78
+ }
79
+ }
80
+ }
81
+ ```
82
+
83
+ Run `/mcp reload`, then `/mcp login example`. The configured ID is used for login,
84
+ token refresh, and revocation; the extension never falls back to dynamic
85
+ registration if it is rejected. `/mcp get example` identifies the client as
86
+ pre-registered without printing the ID.
87
+
88
+ Changing the client ID selects separate credentials and requires a new login.
89
+ Log out before changing or removing the ID if you want to delete its old
90
+ credentials. After the first successful grant, a pre-registered client is pinned
91
+ to its authorization-server issuer. If that issuer changes, verify the server
92
+ configuration before logging out and logging in again to trust the replacement.
93
+
94
+ ## Set scopes and callback ports
95
+
96
+ Configure scopes and a callback port in the server definition:
97
+
98
+ ```json
99
+ {
100
+ "mcpServers": {
101
+ "example": {
102
+ "url": "https://mcp.example.com/mcp",
103
+ "oauthScopes": ["read", "write"],
104
+ "oauthCallbackPort": 19848
105
+ }
106
+ }
107
+ }
108
+ ```
109
+
110
+ Or set them when adding the server:
111
+
112
+ ```text
113
+ /mcp add --scope global --oauth-scope read --oauth-scope write --oauth-callback-port 19848 example https://mcp.example.com/mcp
114
+ ```
115
+
116
+ The callback becomes `http://127.0.0.1:19848/callback`. Pre-registered clients must
117
+ allow that exact URL. The listener stays bound to loopback; arbitrary callback
118
+ hosts and paths aren't supported. If the port is occupied, choose another port or
119
+ use manual login.
120
+
121
+ Scopes are case-sensitive OAuth tokens, each up to 256 characters, without spaces,
122
+ quotes, or backslashes. Omit `oauthScopes` to retain SDK/server-driven selection;
123
+ an empty array is rejected. The SDK may also request `offline_access` when the
124
+ service advertises refresh-token support. Requested scopes aren't a guarantee of
125
+ granted permissions or a per-tool permission policy.
126
+
127
+ After changing these options, run `/mcp reload`, then `/mcp login example`.
128
+ Changing configuration never starts authorization or revokes existing grants.
129
+ Scopes and callback ports don't select separate credential stores: definitions
130
+ sharing a URL and client ID still share credentials. Explicit login renews a
131
+ dynamic registration when its requested options change. `/mcp get example` shows
132
+ the requested scopes and callback address without connecting.
133
+
134
+ ## Sign in remotely or without launching a browser
135
+
136
+ When Pi runs over SSH, or you don't want it to launch a browser, use:
137
+
138
+ ```text
139
+ /mcp login example --no-browser
140
+ ```
141
+
142
+ 1. Open the authorization URL shown in Pi's interactive dialog in your browser.
143
+ 2. Complete sign-in. The browser may show a connection error at the loopback
144
+ callback address; this is expected when the browser and Pi run on different
145
+ machines.
146
+ 3. Copy the full callback URL from the browser's address bar and paste it into
147
+ the **Callback URL** dialog in Pi, not into chat or a slash command.
148
+
149
+ Manual login doesn't open a browser or bind a callback port. The extension
150
+ validates the callback address, state, and authorization response before
151
+ exchanging the code. It doesn't write authorization URLs or pasted callbacks to
152
+ session entries, catalogs, notifications, or logs. Treat the callback URL as
153
+ sensitive; your browser history and clipboard may still contain it.
154
+
155
+ `--no-browser` still requires an interactive UI and an available OS credential
156
+ store. It isn't unattended authentication: print and JSON modes refuse OAuth
157
+ login. Use externally managed bearer headers for unattended access. Confidential
158
+ clients requiring a client secret aren't supported yet.
159
+
160
+ ## Sign out
161
+
162
+ Run `/mcp logout <server>` to remove stored tokens and client registrations. Logout
163
+ also closes connections and deactivates tools for OAuth servers sharing
164
+ the same URL and configured client ID, since they share credentials. Configuration
165
+ and enabled state stay unchanged. Disabled servers accept logout too. Header and
166
+ server-managed credentials remain untouched.
167
+
168
+ Local removal happens before a bounded attempt to revoke tokens at the original
169
+ authorization server. The result distinguishes accepted revocation, unsupported
170
+ revocation, and unconfirmed revocation. When revocation isn't confirmed, remove the
171
+ grant at the service if needed. Repeating logout is safe. Other running Pi sessions
172
+ may need to reconnect; logout cannot recall requests already sent to a server.
173
+
174
+ Removing a server definition doesn't remove its credentials. Log out before
175
+ [removing the definition](commands.md#add-and-remove-servers) if you want both.