pi-code 1.0.26 → 1.0.28

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.
@@ -28,6 +28,17 @@ interface StoredAuth {
28
28
  redirectPort?: number
29
29
  }
30
30
 
31
+ /** Claude's per-server `oauth` config object: a pre-registered client (secret via
32
+ * MCP_CLIENT_SECRET), a fixed callback port for pre-registered redirect URIs, and
33
+ * pinned scopes. authServerMetadataUrl is accepted but unsupported (the MCP SDK
34
+ * offers no discovery override); the connect path warns when it is set. */
35
+ export interface OAuthServerConfig {
36
+ clientId?: string
37
+ callbackPort?: number
38
+ scopes?: string
39
+ authServerMetadataUrl?: string
40
+ }
41
+
31
42
  /** A server name is config-controlled text; the digest keeps hostile names inside
32
43
  * the store directory and distinct names from colliding after sanitization. */
33
44
  function storeFileFor(serverName: string): string {
@@ -55,9 +66,10 @@ export class FileOAuthProvider implements OAuthClientProvider {
55
66
  // page cannot inject an authorization code into this login (RFC 8252 8.9).
56
67
  private readonly loginState = crypto.randomBytes(16).toString('hex')
57
68
 
58
- constructor(serverName: string, onRedirect: (authorizationUrl: URL) => void) {
69
+ constructor(serverName: string, onRedirect: (authorizationUrl: URL) => void, oauth?: OAuthServerConfig) {
59
70
  this.storePath = storeFileFor(serverName)
60
71
  this.onRedirect = onRedirect
72
+ this.oauth = oauth
61
73
  try {
62
74
  this.data = JSON.parse(fs.readFileSync(this.storePath, 'utf-8'))
63
75
  } catch {
@@ -65,14 +77,22 @@ export class FileOAuthProvider implements OAuthClientProvider {
65
77
  }
66
78
  }
67
79
 
80
+ private readonly oauth?: OAuthServerConfig
81
+
82
+ /** The client secret for a pre-configured client, from Claude's MCP_CLIENT_SECRET. */
83
+ private clientSecret(): string | undefined {
84
+ return this.oauth?.clientId ? process.env.MCP_CLIENT_SECRET : undefined
85
+ }
86
+
68
87
  private persist(): void {
69
88
  fs.mkdirSync(path.dirname(this.storePath), { recursive: true })
70
89
  fs.writeFileSync(this.storePath, JSON.stringify(this.data), { mode: 0o600 })
71
90
  }
72
91
 
73
- /** The port a prior login registered, so a re-login can bind the same one. */
92
+ /** The configured callbackPort (Claude: for pre-registered redirect URIs), else
93
+ * the port a prior login registered, so a re-login can bind the same one. */
74
94
  savedRedirectPort(): number | undefined {
75
- return this.data.redirectPort
95
+ return this.oauth?.callbackPort ?? this.data.redirectPort
76
96
  }
77
97
 
78
98
  /** Record the loopback port the callback server actually bound; the redirect
@@ -96,11 +116,19 @@ export class FileOAuthProvider implements OAuthClientProvider {
96
116
  grant_types: ['authorization_code', 'refresh_token'],
97
117
  response_types: ['code'],
98
118
  // A local CLI is a public client; PKCE carries the proof instead of a secret.
99
- token_endpoint_auth_method: 'none',
119
+ // A pre-configured client with an MCP_CLIENT_SECRET authenticates with it.
120
+ token_endpoint_auth_method: this.clientSecret() ? 'client_secret_post' : 'none',
121
+ // Claude: oauth.scopes pins the scopes requested during authorization.
122
+ ...(this.oauth?.scopes ? { scope: this.oauth.scopes } : {}),
100
123
  }
101
124
  }
102
125
 
103
126
  clientInformation(): OAuthClientInformationMixed | undefined {
127
+ // A pre-configured clientId replaces dynamic registration entirely.
128
+ if (this.oauth?.clientId) {
129
+ const secret = this.clientSecret()
130
+ return { client_id: this.oauth.clientId, ...(secret ? { client_secret: secret } : {}) }
131
+ }
104
132
  return this.data.client
105
133
  }
106
134
 
@@ -7,6 +7,7 @@ import * as fs from 'node:fs'
7
7
  import * as os from 'node:os'
8
8
  import * as path from 'node:path'
9
9
  import { claudeConfigDir } from '../internal/config-dir.js'
10
+ import type { OAuthServerConfig } from '../internal/mcp-oauth.js'
10
11
  import type { InstalledPlugin } from '../internal/plugins.js'
11
12
  import { findNearestFile } from '../internal/project-root.js'
12
13
 
@@ -20,6 +21,10 @@ export interface StdioServerConfig {
20
21
  timeout?: number
21
22
  /** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
22
23
  aliasPrefix?: string
24
+ /** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
25
+ pluginRoot?: string
26
+ /** Loaded from the project scope, whose helpers run credential-stripped. */
27
+ projectScope?: boolean
23
28
  }
24
29
 
25
30
  export interface HttpServerConfig {
@@ -28,6 +33,8 @@ export interface HttpServerConfig {
28
33
  headers?: Record<string, string>
29
34
  bearerToken?: string
30
35
  bearerTokenEnv?: string
36
+ /** Claude's oauth object: pre-registered client, fixed callback port, pinned scopes. */
37
+ oauth?: OAuthServerConfig
31
38
  /** A command whose JSON stdout is merged into the connect headers, for auth
32
39
  * schemes other than OAuth/static tokens (Claude's headersHelper). */
33
40
  headersHelper?: string
@@ -35,6 +42,10 @@ export interface HttpServerConfig {
35
42
  timeout?: number
36
43
  /** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
37
44
  aliasPrefix?: string
45
+ /** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
46
+ pluginRoot?: string
47
+ /** Loaded from the project scope, whose helpers run credential-stripped. */
48
+ projectScope?: boolean
38
49
  }
39
50
 
40
51
  export type ServerConfig = StdioServerConfig | HttpServerConfig
@@ -200,7 +211,7 @@ export function loadPluginServers(plugins: InstalledPlugin[], projectDir?: strin
200
211
  for (const plugin of plugins) {
201
212
  for (const [name, config] of Object.entries(rawPluginServerEntries(plugin))) {
202
213
  const substituted = substitutedPluginServer(plugin, name, config, projectDir)
203
- if (substituted) servers[name] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__` }
214
+ if (substituted) servers[name] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__`, pluginRoot: plugin.root }
204
215
  }
205
216
  }
206
217
  return servers
@@ -20,8 +20,9 @@
20
20
  * environment plus its own `env` block, not the whole process environment.
21
21
  *
22
22
  * Servers advertising the `prompts` capability get their prompts registered as Claude's
23
- * /mcp__<server>__<prompt> slash commands (names normalized dashes/spaces to underscores,
24
- * args space-separated and mapped positionally); the prompt result drives a turn via
23
+ * /mcp__<server>__<prompt> slash commands (server-name characters outside A-Za-z0-9_-
24
+ * fold to underscores, args split on whitespace and mapped positionally, one token
25
+ * per declared argument); the prompt result drives a turn via
25
26
  * sendUserMessage, exactly how custom slash commands do. Servers advertising `resources`
26
27
  * make the global list_mcp_resources / read_mcp_resource tools available, mirroring
27
28
  * Claude's automatic resource tools. Resource and prompt output rides the same
@@ -47,7 +48,7 @@ import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope,
47
48
  import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
48
49
  import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
49
50
  import { applyServerPolicy, loadManagedMcpServers, type McpPolicy, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
50
- import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, type ServerCallTuning, serverCallTuning, withTimeout } from './transport.js'
51
+ import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, connectWithRetries, isUnauthorized, type ServerCallTuning, type SessionDirs, serverCallTuning, withTimeout } from './transport.js'
51
52
 
52
53
  export { managedSettingsPath, setManagedSettingsPath } from '../internal/managed-settings.js'
53
54
  // Re-exports for consumers: the module split keeps the extension's public surface
@@ -87,16 +88,28 @@ export default async function mcpExtension(pi: ExtensionAPI) {
87
88
  // Config per server name, kept for call-time timeout tuning: the idle tier follows
88
89
  // the transport kind, and a declared per-server timeout governs the wall budget.
89
90
  const serverConfigs = new Map<string, ServerConfig>()
91
+ // The session's directories, set at session_start before any connect: the launch
92
+ // directory answers roots/list, the project root feeds CLAUDE_PROJECT_DIR.
93
+ let sessionDirs: SessionDirs | undefined
94
+ // Shutdown closes clients while they are still in the map; the onclose handlers
95
+ // must not schedule reconnects for that deliberate teardown.
96
+ let shuttingDown = false
90
97
  const callTuning = (name: string): ServerCallTuning => {
91
98
  const config = serverConfigs.get(name)
92
99
  return config ? serverCallTuning(config) : {}
93
100
  }
94
101
  // Let other extensions (hooks' mcp_tool type) call a connected server's tool.
102
+ // The same 401/403 reconnect-and-retry-once as registered tools, when the
103
+ // server's config is known; a name with no stored config calls through once.
95
104
  setMcpToolCaller(async (server, tool, input) => {
96
- const client = clients.get(server)
97
- if (!client) throw new Error(`MCP server "${server}" is not connected`)
98
- const tuning = callTuning(server)
99
- const result = await client.callTool({ name: tool, arguments: input }, undefined, callRequestOptions(tuning.serverTimeoutMs ?? callTimeoutMs(), tuning))
105
+ const config = serverConfigs.get(server)
106
+ const result = config
107
+ ? await callToolWithAuthRetry(server, config, { name: tool, arguments: input }, `${server}: ${tool}`)
108
+ : await (async () => {
109
+ const client = clients.get(server)
110
+ if (!client) throw new Error(`MCP server "${server}" is not connected`)
111
+ return await client.callTool({ name: tool, arguments: input }, undefined, callRequestOptions(callTimeoutMs()))
112
+ })()
100
113
  const text = mapContent(result.content as McpContentBlock[], result.structuredContent)
101
114
  .filter((part): part is { type: 'text'; text: string } => part.type === 'text')
102
115
  .map((part) => part.text)
@@ -138,17 +151,13 @@ export default async function mcpExtension(pi: ExtensionAPI) {
138
151
  // present at registration: pi has no tool unregister, so after a server drops
139
152
  // and a later session_start reconnects it, registerTools skips re-registration
140
153
  // and this closure would otherwise keep calling the old, closed client.
141
- const current = clients.get(name)
142
- if (!current) throw new Error(`MCP server "${name}" is not connected`)
143
154
  // The per-server timeout (Claude's, 1s floor) or MCP_TOOL_TIMEOUT is the
144
155
  // wall-clock ceiling; callRequestOptions layers the idle timeout under it, which
145
- // the SDK enforces (resetting on progress). Pass the options to the SDK too: its
146
- // own default request timeout is 60s and would otherwise reject first. The outer
147
- // race uses the wall budget, never the idle window, so a progressing call is not
148
- // cut off at the idle timeout.
149
- const tuning = serverCallTuning(config)
150
- const wall = tuning.serverTimeoutMs ?? callTimeoutMs()
151
- const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, callRequestOptions(wall, tuning)), wall, toolName)
156
+ // the SDK enforces (resetting on progress). The outer race uses the wall
157
+ // budget, never the idle window, so a progressing call is not cut off at the
158
+ // idle timeout. A 401/403 reconnects once (fresh helper headers or refreshed
159
+ // OAuth tokens) and retries once.
160
+ const result = await callToolWithAuthRetry(name, config, { name: tool.name, arguments: params as Record<string, unknown> }, toolName)
152
161
  const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
153
162
  const details: { error?: string } = {}
154
163
  if (result.isError) {
@@ -163,6 +172,53 @@ export default async function mcpExtension(pi: ExtensionAPI) {
163
172
  return count
164
173
  }
165
174
 
175
+ /** Claude's mid-session reconnect for a dropped remote server: five attempts with
176
+ * a delay doubling from one second. connectServers redoes the full bring-up
177
+ * (tools, prompts, subscriptions, a fresh onclose) and its duplicate guard skips
178
+ * out if another path already reconnected the name. Runs without authUi: a server
179
+ * that now needs a login ends failed, and after the fifth failure the last
180
+ * attempt's failed status stands, with a session restart as the manual retry. */
181
+ async function reconnectWithBackoff(name: string, config: ServerConfig): Promise<void> {
182
+ for (let attempt = 0; attempt < 5; attempt++) {
183
+ await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt))
184
+ if (shuttingDown || clients.has(name)) return
185
+ await connectServers({ [name]: config }, undefined, true)
186
+ if (clients.has(name)) return
187
+ }
188
+ }
189
+
190
+ /** Claude's 401/403 tool-call recovery: drop the client and reconnect once, so the
191
+ * headersHelper re-runs (fresh credential) or the OAuth tokens refresh, then the
192
+ * caller retries the call once. The map delete precedes the close so the onclose
193
+ * guard does not also schedule a backoff reconnect. */
194
+ async function reconnectForAuth(name: string, config: ServerConfig): Promise<void> {
195
+ const old = clients.get(name)
196
+ if (old) {
197
+ clients.delete(name)
198
+ await withTimeout(old.close(), 3000, 'close').catch(() => {})
199
+ }
200
+ await connectServers({ [name]: config }, undefined, true)
201
+ }
202
+
203
+ /** A tool call with the auth retry: on a 401/403 rejection, reconnect once and
204
+ * retry once; a second auth failure surfaces to the caller. */
205
+ async function callToolWithAuthRetry(name: string, config: ServerConfig, args: { name: string; arguments: Record<string, unknown> }, label: string): Promise<Awaited<ReturnType<Client['callTool']>>> {
206
+ const tuning = serverCallTuning(config)
207
+ const wall = tuning.serverTimeoutMs ?? callTimeoutMs()
208
+ const callOnce = async () => {
209
+ const current = clients.get(name)
210
+ if (!current) throw new Error(`MCP server "${name}" is not connected`)
211
+ return await withTimeout(current.callTool(args, undefined, callRequestOptions(wall, tuning)), wall, label)
212
+ }
213
+ try {
214
+ return await callOnce()
215
+ } catch (error) {
216
+ if (!isUnauthorized(error)) throw error
217
+ await reconnectForAuth(name, config)
218
+ return await callOnce()
219
+ }
220
+ }
221
+
166
222
  // Prompt command name -> the server and prompt that own it, so a refresh re-listing
167
223
  // the same prompt is told apart both from a cross-server collision and from a second
168
224
  // prompt on the same server whose name normalizes to the one already taken (e.g.
@@ -336,7 +392,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
336
392
  }
337
393
  }
338
394
 
339
- async function connectServers(servers: Record<string, ServerConfig>, authUi?: AuthUi): Promise<void> {
395
+ async function connectServers(servers: Record<string, ServerConfig>, authUi?: AuthUi, noRetry = false): Promise<void> {
340
396
  const pending: [string, ServerConfig][] = []
341
397
  for (const [name, config] of Object.entries(servers)) {
342
398
  // A later scope must not take the name of a server that already connected: it
@@ -356,7 +412,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
356
412
  pending.map(async ([name, config]) => {
357
413
  warnOnTypelessUrl(name, config)
358
414
  try {
359
- const client = await connect(name, config, authUi)
415
+ // First connections retry transient failures (Claude: up to three times for
416
+ // HTTP/SSE); the backoff reconnect below carries its own schedule instead.
417
+ const client = noRetry ? await connect(name, config, authUi, sessionDirs) : await connectWithRetries(name, config, authUi, sessionDirs)
360
418
  clients.set(name, client)
361
419
  const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
362
420
  registerTools(name, config, tools)
@@ -378,6 +436,11 @@ export default async function mcpExtension(pi: ExtensionAPI) {
378
436
  if (clients.get(name) !== client) return
379
437
  clients.delete(name)
380
438
  status.set(name, { state: 'disconnected', tools: 0 })
439
+ // Claude reconnects a dropped remote server with exponential backoff;
440
+ // stdio servers are local processes and are not reconnected. Shutdown
441
+ // closes clients while they are still in the map, so the flag guards
442
+ // against scheduling a reconnect for a deliberate teardown.
443
+ if (!shuttingDown && !serverCallTuning(config).stdio) void reconnectWithBackoff(name, config)
381
444
  }
382
445
  } catch (error) {
383
446
  status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
@@ -448,7 +511,10 @@ export default async function mcpExtension(pi: ExtensionAPI) {
448
511
  // The stored project decision, read without prompting: consent recorded inside
449
512
  // the project only counts once the project itself has been approved.
450
513
  const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
451
- const { consented: consentedRaw, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), policy), projectPolicy)
514
+ // Tag the scope on each project server: a repository-supplied headersHelper runs
515
+ // with credential variables stripped, unlike a user-scope one.
516
+ const projectServers = Object.fromEntries(Object.entries(loadConfigFrom(projectConfigPaths(ctx.cwd))).map(([name, config]) => [name, { ...config, projectScope: true }]))
517
+ const { consented: consentedRaw, gated } = splitByPolicy(applyServerPolicy(projectServers, policy), projectPolicy)
452
518
  // Claude's scope precedence is local over project: a name the local scope defines
453
519
  // stays with the local (user-side) definition, so the project's entry is dropped
454
520
  // here rather than allowed to shadow it.
@@ -480,6 +546,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
480
546
  // withdrawn tool keeps its registration and surfaces the server's own error), which
481
547
  // is why serverToolCount reads from `registered` to recover the true count here.
482
548
  status.clear()
549
+ // A same-process session switch (/new, /resume) shut the last session down;
550
+ // this one may reconnect again.
551
+ shuttingDown = false
552
+ // Claude answers roots/list with the session's launch directory and exports the
553
+ // project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
554
+ sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd }
483
555
  const authUi = authUiFor(ctx)
484
556
  // The allow/deny lists filter every scope, including a managed-mcp.json set. They
485
557
  // merge from managed settings plus the trust-gated settings chain, as Claude
@@ -509,6 +581,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
509
581
  })
510
582
 
511
583
  pi.on('session_shutdown', async () => {
584
+ // Closing fires each client's onclose while it is still in the map; the flag
585
+ // stops those handlers (and any in-flight backoff loop) from reconnecting.
586
+ shuttingDown = true
512
587
  // Close in parallel with a per-client timeout so one hung server can't stall pi's exit.
513
588
  await Promise.all([...clients.values()].map((client) => withTimeout(client.close(), 3000, 'close').catch(() => {})))
514
589
  // Drop the closed clients and their status now rather than waiting on each client's
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
9
- import { FileOAuthProvider, openBrowser, startCallbackServer, waitForAuthCode } from '../internal/mcp-oauth.js'
9
+ import { FileOAuthProvider, type OAuthServerConfig, openBrowser, startCallbackServer, waitForAuthCode } from '../internal/mcp-oauth.js'
10
10
  import { type AuthUi, connectWithTimeout, isUnauthorized, type MakeTransport, OAuthRequiredError } from './transport.js'
11
11
 
12
12
  /** Browser logins are human-paced; a connect-sized timeout would cut them off. */
@@ -45,13 +45,17 @@ export function serializeInteractiveOAuth<T>(run: () => Promise<T>): Promise<T>
45
45
  * token exchange error) is wrapped as an auth failure, not a transport mismatch: that
46
46
  * keeps the typeless-url caller from retrying over SSE and prompting for a second login.
47
47
  */
48
- export async function runInteractiveOAuth(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, authUi: AuthUi, newClient: () => Client): Promise<Client> {
48
+ export async function runInteractiveOAuth(name: string, config: { url: string; oauth?: OAuthServerConfig }, makeTransport: MakeTransport, label: string, authUi: AuthUi, newClient: () => Client): Promise<Client> {
49
49
  const approved = await authUi.confirm(`MCP server "${name}" requires login`, `Open your browser to authorize ${config.url}?`)
50
50
  if (!approved) throw new OAuthRequiredError(`login declined for ${name}`)
51
- const provider = new FileOAuthProvider(name, (authorizationUrl) => {
52
- openBrowser(String(authorizationUrl))
53
- authUi.notify(`Authorize "${name}" in the browser. If it did not open: ${authorizationUrl}`, 'info')
54
- })
51
+ const provider = new FileOAuthProvider(
52
+ name,
53
+ (authorizationUrl) => {
54
+ openBrowser(String(authorizationUrl))
55
+ authUi.notify(`Authorize "${name}" in the browser. If it did not open: ${authorizationUrl}`, 'info')
56
+ },
57
+ config.oauth,
58
+ )
55
59
  const { server, port } = await startCallbackServer(provider.savedRedirectPort())
56
60
  provider.bindRedirectPort(port)
57
61
  try {
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { execFile } from 'node:child_process'
8
+ import { pathToFileURL } from 'node:url'
8
9
  // SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
9
10
  // the old spec exist, so this stays as a fallback for the migration period.
10
11
  import { type OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
@@ -13,8 +14,9 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' //
13
14
  import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
14
15
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
15
16
  import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'
16
- import { FileOAuthProvider } from '../internal/mcp-oauth.js'
17
- import { expandCwd, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
17
+ import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
18
+ import { FileOAuthProvider, type OAuthServerConfig } from '../internal/mcp-oauth.js'
19
+ import { expandCwd, type HttpServerConfig, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
18
20
  import { runInteractiveOAuth, serializeInteractiveOAuth } from './oauth-flow.js'
19
21
 
20
22
  // Claude's MCP_TIMEOUT default: 30 seconds per connect attempt.
@@ -128,6 +130,67 @@ function isStdio(config: ServerConfig): config is StdioServerConfig {
128
130
  return 'command' in config && (config.type === undefined || config.type === 'stdio')
129
131
  }
130
132
 
133
+ /** The session's directories: the launch directory answers roots/list, and the
134
+ * project root becomes CLAUDE_PROJECT_DIR in a stdio server's environment. */
135
+ export interface SessionDirs {
136
+ projectDir: string
137
+ launchDir: string
138
+ }
139
+
140
+ /** A client that, like Claude, declares the roots capability and answers roots/list
141
+ * with the session's launch directory. pi's directory set is static, so no
142
+ * roots/list_changed notification is ever sent. */
143
+ function makeClient(session?: SessionDirs): Client {
144
+ if (!session) return new Client({ name: 'pi-code-mcp', version: '0.1.0' })
145
+ const client = new Client({ name: 'pi-code-mcp', version: '0.1.0' }, { capabilities: { roots: {} } })
146
+ client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: [{ uri: pathToFileURL(session.launchDir).href }] }))
147
+ return client
148
+ }
149
+
150
+ /** Claude's credential heuristic for helper environments: any name with TOKEN,
151
+ * SECRET, PASSWORD, KEY, or AUTH in it in either case (Git's GIT_CONFIG_KEY_<n>
152
+ * excepted), plus a fixed list of credential names outside the pattern. */
153
+ const CREDENTIAL_NAME_EXTRAS = new Set(['ANTHROPIC_CUSTOM_HEADERS'])
154
+ function isCredentialEnvName(name: string): boolean {
155
+ if (/^GIT_CONFIG_KEY_\d+$/.test(name)) return false
156
+ return /TOKEN|SECRET|PASSWORD|KEY|AUTH/i.test(name) || CREDENTIAL_NAME_EXTRAS.has(name)
157
+ }
158
+
159
+ /** process.env with every credential-named value replaced by REDACTED, used to
160
+ * expand the url a helper is shown without handing it the credential. */
161
+ function redactedEnv(): NodeJS.ProcessEnv {
162
+ return Object.fromEntries(Object.entries(process.env).map(([key, value]) => [key, isCredentialEnvName(key) ? 'REDACTED' : value]))
163
+ }
164
+
165
+ /** The environment a headersHelper runs with: Claude's CLAUDE_CODE_MCP_SERVER_NAME
166
+ * and CLAUDE_CODE_MCP_SERVER_URL (credential-expanded url parts REDACTED), plus
167
+ * CLAUDE_PLUGIN_ROOT for a plugin's server. A helper a repository or plugin
168
+ * supplies is a command the user did not write, so it runs without the
169
+ * credential-named variables; a user-scope helper keeps them. */
170
+ function helperEnv(name: string, config: HttpServerConfig): NodeJS.ProcessEnv {
171
+ const stripped = config.projectScope === true || config.pluginRoot !== undefined
172
+ const env: NodeJS.ProcessEnv = {}
173
+ for (const [key, value] of Object.entries(process.env)) {
174
+ if (stripped && isCredentialEnvName(key)) continue
175
+ env[key] = value
176
+ }
177
+ env.CLAUDE_CODE_MCP_SERVER_NAME = name
178
+ env.CLAUDE_CODE_MCP_SERVER_URL = interpolateEnv(config.url, redactedEnv())
179
+ if (config.pluginRoot !== undefined) env.CLAUDE_PLUGIN_ROOT = config.pluginRoot
180
+ return env
181
+ }
182
+
183
+ /** The env a stdio server process starts with: the SDK allowlist, the config's own
184
+ * env block, and Claude's path variables (CLAUDE_PROJECT_DIR, and CLAUDE_PLUGIN_ROOT
185
+ * for a plugin's server). */
186
+ function stdioEnv(config: StdioServerConfig, fill: (value: string) => string, session?: SessionDirs): Record<string, string> {
187
+ const env: Record<string, string> = { ...getDefaultEnvironment() }
188
+ for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
189
+ if (session) env.CLAUDE_PROJECT_DIR = session.projectDir
190
+ if (config.pluginRoot !== undefined) env.CLAUDE_PLUGIN_ROOT = config.pluginRoot
191
+ return env
192
+ }
193
+
131
194
  export async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
132
195
  let timer: ReturnType<typeof setTimeout> | undefined
133
196
  const timeout = new Promise<never>((_, reject) => {
@@ -143,8 +206,8 @@ export async function withTimeout<T>(promise: Promise<T>, ms: number, label: str
143
206
  }
144
207
  }
145
208
 
146
- export async function connect(name: string, config: ServerConfig, authUi?: AuthUi): Promise<Client> {
147
- const client = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
209
+ export async function connect(name: string, config: ServerConfig, authUi?: AuthUi, session?: SessionDirs): Promise<Client> {
210
+ const client = makeClient(session)
148
211
  // Names referenced by ${VAR} with no value and no default, gathered across this
149
212
  // server's interpolated fields so the connect can warn once rather than fail with a
150
213
  // mystery 401 or a command that lost an argument.
@@ -157,12 +220,10 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
157
220
  // Start from the SDK's allowlist (PATH, HOME, SHELL, ...) rather than the whole
158
221
  // process env: a server should not receive ANTHROPIC_API_KEY or GITHUB_TOKEN just
159
222
  // for being launched. A server that needs a variable names it in its own env block.
160
- const env: Record<string, string> = { ...getDefaultEnvironment() }
161
- for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
162
223
  const transport = new StdioClientTransport({
163
224
  command: fill(config.command),
164
225
  args: (config.args ?? []).map((arg) => fill(arg)),
165
- env,
226
+ env: stdioEnv(config, fill, session),
166
227
  cwd: expandCwd(config.cwd),
167
228
  stderr: 'ignore',
168
229
  })
@@ -186,32 +247,68 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
186
247
  await connectWithTimeout(client, transport, `connect ${name} (ws)`)
187
248
  return client
188
249
  }
250
+ // The SDK's OAuth discovery has no override seam, so a configured metadata URL
251
+ // cannot be honored; say so instead of silently using standard discovery.
252
+ if (config.oauth?.authServerMetadataUrl) {
253
+ console.warn(`pi-code-mcp: server ${name} sets oauth.authServerMetadataUrl, which the MCP SDK cannot override; using standard discovery`)
254
+ }
189
255
  const headers: Record<string, string> = {}
190
256
  for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = fill(value)
191
257
  const token = resolveBearerToken(config)
192
258
  if (token) headers.Authorization = `Bearer ${token}`
193
259
  // A headersHelper generates connect-time headers for non-OAuth auth schemes; its
194
260
  // JSON stdout merges over the static headers.
195
- if (config.headersHelper) Object.assign(headers, await runHeadersHelper(fill(config.headersHelper)))
261
+ if (config.headersHelper) Object.assign(headers, await runHeadersHelper(fill(config.headersHelper), helperEnv(name, config)))
196
262
  warnMissing()
263
+ // Claude: a configured Authorization header, whether static, a bearer token, or
264
+ // helper output, is the server's authentication; there is no OAuth fallback for it.
265
+ const configuredAuth = Boolean(token) || Object.keys(headers).some((header) => header.toLowerCase() === 'authorization')
197
266
  const sseTransport = (authProvider?: OAuthClientProvider) => new SSEClientTransport(url, { requestInit: { headers }, authProvider }) // NOSONAR: explicitly declared or deliberate legacy transport
198
267
  if (config.type === 'sse') {
199
- return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
268
+ return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, configuredAuth, authUi, session)
200
269
  }
201
270
  try {
202
- return await connectHttpFamily(name, config, (authProvider) => new StreamableHTTPClientTransport(url, { requestInit: { headers }, authProvider }), `connect ${name}`, token, authUi)
271
+ return await connectHttpFamily(name, config, (authProvider) => new StreamableHTTPClientTransport(url, { requestInit: { headers }, authProvider }), `connect ${name}`, configuredAuth, authUi, session)
203
272
  } catch (error) {
204
273
  // An explicitly declared streamable transport must not silently degrade to SSE.
205
274
  if (config.type !== undefined || isUnauthorized(error)) throw error
206
- return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
275
+ return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, configuredAuth, authUi, session)
276
+ }
277
+ }
278
+
279
+ /** Whether a connect failure is worth retrying: a 5xx response, a refused or reset
280
+ * connection, or a timeout. Auth and not-found errors need a configuration change. */
281
+ function isTransientConnectError(error: unknown): boolean {
282
+ if (isUnauthorized(error)) return false
283
+ const code = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined
284
+ if (typeof code === 'number') return code >= 500
285
+ return /ECONNREFUSED|ECONNRESET|ETIMEDOUT|timed out after/.test(String(error))
286
+ }
287
+
288
+ const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
289
+
290
+ /** Connect with Claude's first-connection retry: an HTTP or SSE server's transient
291
+ * failure (5xx, connection refused, timeout) is retried up to three times with a
292
+ * 1s-doubling delay. Stdio and WebSocket connects are attempted once, as are auth
293
+ * and not-found failures. */
294
+ export async function connectWithRetries(name: string, config: ServerConfig, authUi?: AuthUi, session?: SessionDirs): Promise<Client> {
295
+ const retriable = !isStdio(config) && config.type !== 'ws' && config.type !== 'websocket'
296
+ for (let attempt = 0; ; attempt++) {
297
+ try {
298
+ return await connect(name, config, authUi, session)
299
+ } catch (error) {
300
+ if (!retriable || attempt >= 3 || !isTransientConnectError(error)) throw error
301
+ await delay(1000 * 2 ** attempt)
302
+ }
207
303
  }
208
304
  }
209
305
 
210
- /** Run a headersHelper command and parse its JSON stdout into headers. A failure or
211
- * a 10s timeout yields no extra headers rather than blocking the connection. */
212
- function runHeadersHelper(command: string): Promise<Record<string, string>> {
306
+ /** Run a headersHelper command and parse its JSON stdout into headers, under the
307
+ * environment helperEnv built. A failure or a 10s timeout yields no extra headers
308
+ * rather than blocking the connection. */
309
+ function runHeadersHelper(command: string, env: NodeJS.ProcessEnv): Promise<Record<string, string>> {
213
310
  return new Promise((resolve) => {
214
- execFile('/bin/sh', ['-c', command], { timeout: 10_000 }, (error, stdout) => {
311
+ execFile('/bin/sh', ['-c', command], { timeout: 10_000, env }, (error, stdout) => {
215
312
  resolve(error ? {} : parseHelperHeaders(stdout))
216
313
  })
217
314
  })
@@ -229,12 +326,12 @@ export interface AuthUi {
229
326
  export class OAuthRequiredError extends Error {}
230
327
 
231
328
  /** Whether a connect failure is an authentication problem: the SDK's own
232
- * UnauthorizedError, a transport error carrying HTTP 401 (which is what a 401
233
- * throws when no authProvider was attached, so a first-time login is detected),
234
- * or our own marker. */
329
+ * UnauthorizedError, a transport error carrying HTTP 401 or 403 (Claude: "either
330
+ * status code flags it" for OAuth), or our own marker. */
235
331
  export function isUnauthorized(error: unknown): boolean {
236
332
  if (error instanceof UnauthorizedError || error instanceof OAuthRequiredError) return true
237
- return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 401
333
+ const code = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined
334
+ return code === 401 || code === 403
238
335
  }
239
336
 
240
337
  // SSEClientTransport is deprecated in favour of Streamable HTTP, but both concrete
@@ -249,22 +346,25 @@ export type MakeTransport = (authProvider?: OAuthClientProvider) => HttpFamilyTr
249
346
  * demands one. Stored tokens ride the first attempt so the SDK refreshes
250
347
  * silently; a 401 without tokens asks the user, opens the browser, catches the
251
348
  * loopback redirect, and exchanges the code via the SDK's finishAuth.
252
- * Bearer-token servers never enter the OAuth path: an explicit token is the
253
- * user saying how auth works.
349
+ * A server with configured authentication (a bearer token, a static Authorization
350
+ * header, or one from a headersHelper) never enters the OAuth path: the credential
351
+ * to fix is the configured one, so an auth failure reports as a failed connection.
254
352
  */
255
- async function connectHttpFamily(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, bearerToken: string | undefined, authUi: AuthUi | undefined): Promise<Client> {
256
- const newClient = () => new Client({ name: 'pi-code-mcp', version: '0.1.0' })
353
+ async function connectHttpFamily(name: string, config: { url: string; oauth?: OAuthServerConfig }, makeTransport: MakeTransport, label: string, hasConfiguredAuth: boolean, authUi: AuthUi | undefined, session?: SessionDirs): Promise<Client> {
354
+ const newClient = () => makeClient(session)
257
355
  // Stored tokens ride the first attempt so the SDK refreshes them; with none, no
258
356
  // provider is attached, so a 401 surfaces as a transport error carrying code 401
259
357
  // (isUnauthorized detects it) and only the interactive provider below ever runs
260
- // dynamic registration, keeping it bound to the real callback port.
261
- const silent = bearerToken ? undefined : new FileOAuthProvider(name, () => {})
358
+ // dynamic registration, keeping it bound to the real callback port. A
359
+ // pre-configured client (oauth.clientId) rides the silent provider too, so its
360
+ // stored tokens refresh with the configured credentials.
361
+ const silent = hasConfiguredAuth ? undefined : new FileOAuthProvider(name, () => {}, config.oauth)
262
362
  try {
263
363
  const client = newClient()
264
364
  await connectWithTimeout(client, makeTransport(silent?.hasTokens() ? silent : undefined), label)
265
365
  return client
266
366
  } catch (error) {
267
- if (bearerToken || !isUnauthorized(error)) throw error
367
+ if (hasConfiguredAuth || !isUnauthorized(error)) throw error
268
368
  if (!authUi) throw new OAuthRequiredError(`${name} requires a login; run pi interactively to authenticate`)
269
369
  return await serializeInteractiveOAuth(() => runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient))
270
370
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.26",
3
+ "version": "1.0.28",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi",