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.
- package/extensions/internal/mcp-oauth.ts +32 -4
- package/extensions/mcp/config.ts +12 -1
- package/extensions/mcp/index.ts +94 -19
- package/extensions/mcp/oauth-flow.ts +10 -6
- package/extensions/mcp/transport.ts +126 -26
- package/package.json +1 -1
|
@@ -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
|
|
|
@@ -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
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -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 (
|
|
24
|
-
*
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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).
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
@@ -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 {
|
|
17
|
-
import {
|
|
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 =
|
|
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)`,
|
|
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}`,
|
|
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)`,
|
|
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
|
|
211
|
-
* a 10s timeout yields no extra headers
|
|
212
|
-
|
|
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
|
|
233
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
253
|
-
*
|
|
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,
|
|
256
|
-
const newClient = () =>
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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",
|