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
|
|
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
|
-
|
|
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
|
|
package/extensions/mcp/config.ts
CHANGED
|
@@ -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
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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).
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
const
|
|
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
|
-
|
|
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(
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|