pi-code 1.0.27 → 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
 
@@ -32,6 +33,8 @@ export interface HttpServerConfig {
32
33
  headers?: Record<string, string>
33
34
  bearerToken?: string
34
35
  bearerTokenEnv?: string
36
+ /** Claude's oauth object: pre-registered client, fixed callback port, pinned scopes. */
37
+ oauth?: OAuthServerConfig
35
38
  /** A command whose JSON stdout is merged into the connect headers, for auth
36
39
  * schemes other than OAuth/static tokens (Claude's headersHelper). */
37
40
  headersHelper?: string
@@ -48,7 +48,7 @@ import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope,
48
48
  import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
49
49
  import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
50
50
  import { applyServerPolicy, loadManagedMcpServers, type McpPolicy, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
51
- import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, type ServerCallTuning, type SessionDirs, serverCallTuning, withTimeout } from './transport.js'
51
+ import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, connectWithRetries, isUnauthorized, type ServerCallTuning, type SessionDirs, serverCallTuning, withTimeout } from './transport.js'
52
52
 
53
53
  export { managedSettingsPath, setManagedSettingsPath } from '../internal/managed-settings.js'
54
54
  // Re-exports for consumers: the module split keeps the extension's public surface
@@ -91,16 +91,25 @@ export default async function mcpExtension(pi: ExtensionAPI) {
91
91
  // The session's directories, set at session_start before any connect: the launch
92
92
  // directory answers roots/list, the project root feeds CLAUDE_PROJECT_DIR.
93
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
94
97
  const callTuning = (name: string): ServerCallTuning => {
95
98
  const config = serverConfigs.get(name)
96
99
  return config ? serverCallTuning(config) : {}
97
100
  }
98
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.
99
104
  setMcpToolCaller(async (server, tool, input) => {
100
- const client = clients.get(server)
101
- if (!client) throw new Error(`MCP server "${server}" is not connected`)
102
- const tuning = callTuning(server)
103
- 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
+ })()
104
113
  const text = mapContent(result.content as McpContentBlock[], result.structuredContent)
105
114
  .filter((part): part is { type: 'text'; text: string } => part.type === 'text')
106
115
  .map((part) => part.text)
@@ -142,17 +151,13 @@ export default async function mcpExtension(pi: ExtensionAPI) {
142
151
  // present at registration: pi has no tool unregister, so after a server drops
143
152
  // and a later session_start reconnects it, registerTools skips re-registration
144
153
  // and this closure would otherwise keep calling the old, closed client.
145
- const current = clients.get(name)
146
- if (!current) throw new Error(`MCP server "${name}" is not connected`)
147
154
  // The per-server timeout (Claude's, 1s floor) or MCP_TOOL_TIMEOUT is the
148
155
  // wall-clock ceiling; callRequestOptions layers the idle timeout under it, which
149
- // the SDK enforces (resetting on progress). Pass the options to the SDK too: its
150
- // own default request timeout is 60s and would otherwise reject first. The outer
151
- // race uses the wall budget, never the idle window, so a progressing call is not
152
- // cut off at the idle timeout.
153
- const tuning = serverCallTuning(config)
154
- const wall = tuning.serverTimeoutMs ?? callTimeoutMs()
155
- 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)
156
161
  const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
157
162
  const details: { error?: string } = {}
158
163
  if (result.isError) {
@@ -167,6 +172,53 @@ export default async function mcpExtension(pi: ExtensionAPI) {
167
172
  return count
168
173
  }
169
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
+
170
222
  // Prompt command name -> the server and prompt that own it, so a refresh re-listing
171
223
  // the same prompt is told apart both from a cross-server collision and from a second
172
224
  // prompt on the same server whose name normalizes to the one already taken (e.g.
@@ -340,7 +392,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
340
392
  }
341
393
  }
342
394
 
343
- 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> {
344
396
  const pending: [string, ServerConfig][] = []
345
397
  for (const [name, config] of Object.entries(servers)) {
346
398
  // A later scope must not take the name of a server that already connected: it
@@ -360,7 +412,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
360
412
  pending.map(async ([name, config]) => {
361
413
  warnOnTypelessUrl(name, config)
362
414
  try {
363
- const client = await connect(name, config, authUi, sessionDirs)
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)
364
418
  clients.set(name, client)
365
419
  const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
366
420
  registerTools(name, config, tools)
@@ -382,6 +436,11 @@ export default async function mcpExtension(pi: ExtensionAPI) {
382
436
  if (clients.get(name) !== client) return
383
437
  clients.delete(name)
384
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)
385
444
  }
386
445
  } catch (error) {
387
446
  status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
@@ -487,6 +546,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
487
546
  // withdrawn tool keeps its registration and surfaces the server's own error), which
488
547
  // is why serverToolCount reads from `registered` to recover the true count here.
489
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
490
552
  // Claude answers roots/list with the session's launch directory and exports the
491
553
  // project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
492
554
  sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd }
@@ -519,6 +581,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
519
581
  })
520
582
 
521
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
522
587
  // Close in parallel with a per-client timeout so one hung server can't stall pi's exit.
523
588
  await Promise.all([...clients.values()].map((client) => withTimeout(client.close(), 3000, 'close').catch(() => {})))
524
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 {
@@ -15,7 +15,7 @@ import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotoc
15
15
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
16
16
  import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'
17
17
  import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
18
- import { FileOAuthProvider } from '../internal/mcp-oauth.js'
18
+ import { FileOAuthProvider, type OAuthServerConfig } from '../internal/mcp-oauth.js'
19
19
  import { expandCwd, type HttpServerConfig, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
20
20
  import { runInteractiveOAuth, serializeInteractiveOAuth } from './oauth-flow.js'
21
21
 
@@ -247,6 +247,11 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
247
247
  await connectWithTimeout(client, transport, `connect ${name} (ws)`)
248
248
  return client
249
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
+ }
250
255
  const headers: Record<string, string> = {}
251
256
  for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = fill(value)
252
257
  const token = resolveBearerToken(config)
@@ -271,6 +276,33 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
271
276
  }
272
277
  }
273
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
+ }
303
+ }
304
+ }
305
+
274
306
  /** Run a headersHelper command and parse its JSON stdout into headers, under the
275
307
  * environment helperEnv built. A failure or a 10s timeout yields no extra headers
276
308
  * rather than blocking the connection. */
@@ -318,13 +350,15 @@ export type MakeTransport = (authProvider?: OAuthClientProvider) => HttpFamilyTr
318
350
  * header, or one from a headersHelper) never enters the OAuth path: the credential
319
351
  * to fix is the configured one, so an auth failure reports as a failed connection.
320
352
  */
321
- async function connectHttpFamily(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, hasConfiguredAuth: boolean, authUi: AuthUi | undefined, session?: SessionDirs): Promise<Client> {
353
+ async function connectHttpFamily(name: string, config: { url: string; oauth?: OAuthServerConfig }, makeTransport: MakeTransport, label: string, hasConfiguredAuth: boolean, authUi: AuthUi | undefined, session?: SessionDirs): Promise<Client> {
322
354
  const newClient = () => makeClient(session)
323
355
  // Stored tokens ride the first attempt so the SDK refreshes them; with none, no
324
356
  // provider is attached, so a 401 surfaces as a transport error carrying code 401
325
357
  // (isUnauthorized detects it) and only the interactive provider below ever runs
326
- // dynamic registration, keeping it bound to the real callback port.
327
- const silent = hasConfiguredAuth ? 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)
328
362
  try {
329
363
  const client = newClient()
330
364
  await connectWithTimeout(client, makeTransport(silent?.hasTokens() ? silent : undefined), label)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.27",
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",