pi-code 1.0.27 → 1.0.29
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/hooks/config.ts +15 -3
- package/extensions/hooks/index.ts +64 -4
- package/extensions/hooks/matcher.ts +6 -3
- package/extensions/hooks/runners.ts +30 -1
- package/extensions/internal/mcp-oauth.ts +32 -4
- package/extensions/mcp/config.ts +3 -0
- package/extensions/mcp/index.ts +81 -16
- package/extensions/mcp/oauth-flow.ts +10 -6
- package/extensions/mcp/transport.ts +38 -4
- package/package.json +1 -1
|
@@ -41,6 +41,9 @@ export interface HookCommand {
|
|
|
41
41
|
/** prompt/agent entries: an optional model override; agent adds a system prompt. */
|
|
42
42
|
model?: string
|
|
43
43
|
systemPrompt?: string
|
|
44
|
+
/** Dedup scope: unset for settings files (identical handlers collapse across
|
|
45
|
+
* them); a plugin's or skill's copy carries its origin and stays separate. */
|
|
46
|
+
origin?: string
|
|
44
47
|
}
|
|
45
48
|
export interface HookMatcher {
|
|
46
49
|
matcher?: string
|
|
@@ -135,7 +138,15 @@ export function loadHooks(files: string[], sources?: Map<HookMatcher, string>):
|
|
|
135
138
|
return config
|
|
136
139
|
}
|
|
137
140
|
|
|
138
|
-
|
|
141
|
+
/** Claude dedups identical handlers across settings files only; a plugin's copy
|
|
142
|
+
* stays separate, so plugin entries carry their origin into the dedup key. */
|
|
143
|
+
function stampOrigin(entries: HookMatcher[], origin: string): void {
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
for (const hook of entry.hooks ?? []) hook.origin = origin
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>, origin?: string): void {
|
|
139
150
|
let parsed: { hooks?: HooksConfig }
|
|
140
151
|
try {
|
|
141
152
|
parsed = JSON.parse(raw)
|
|
@@ -150,6 +161,7 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
|
|
|
150
161
|
// call for the rest of the session failed with an opaque type error.
|
|
151
162
|
const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
|
|
152
163
|
if (usable.length === 0) continue
|
|
164
|
+
if (origin !== undefined) stampOrigin(usable, origin)
|
|
153
165
|
config[event] = [...(config[event] ?? []), ...usable]
|
|
154
166
|
// Each parse produces fresh entry objects, so object identity keys the /hooks
|
|
155
167
|
// viewer's source attribution without touching the entries themselves.
|
|
@@ -167,12 +179,12 @@ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[],
|
|
|
167
179
|
// numeric event keys), so it falls through to the default path rather than
|
|
168
180
|
// silently registering nothing.
|
|
169
181
|
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
170
|
-
mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
|
|
182
|
+
mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources, `plugin:${plugin.name}`)
|
|
171
183
|
continue
|
|
172
184
|
}
|
|
173
185
|
const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
|
|
174
186
|
try {
|
|
175
|
-
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
|
|
187
|
+
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources, `plugin:${plugin.name}`)
|
|
176
188
|
} catch {
|
|
177
189
|
// a plugin without hooks contributes nothing
|
|
178
190
|
}
|
|
@@ -20,7 +20,12 @@
|
|
|
20
20
|
* - Stop -> pi `agent_end` (a block feeds its reason back as a new turn,
|
|
21
21
|
* with stop_hook_active as the loop guard)
|
|
22
22
|
* - PreCompact -> pi `session_before_compact` (fire-and-forget)
|
|
23
|
-
* - PostCompact -> pi `session_compact` (fire-and-forget)
|
|
23
|
+
* - PostCompact -> pi `session_compact` (fire-and-forget); the same event also
|
|
24
|
+
* fires SessionStart with source "compact", as Claude does
|
|
25
|
+
* when a session continues after compaction
|
|
26
|
+
* - PostModelSwitch -> pi `model_select` (after the change, matched against the new
|
|
27
|
+
* model id; stdout context rides the next agent start;
|
|
28
|
+
* PreModelSwitch stays unbridged, pi has no veto seam)
|
|
24
29
|
* - PostToolUseFailure -> pi `tool_result` error branch (stderr/additionalContext
|
|
25
30
|
* appended to the failed result; it cannot block, the tool failed)
|
|
26
31
|
* - SessionEnd -> pi `session_shutdown` (fire-and-forget)
|
|
@@ -160,6 +165,15 @@ function claudeSpelling(map: Record<string, string>, raw: string): { names: stri
|
|
|
160
165
|
return { names: value === raw ? [raw] : [raw, value], value }
|
|
161
166
|
}
|
|
162
167
|
|
|
168
|
+
/** Claude: idle_prompt fires when "Claude finished responding about 60 seconds ago
|
|
169
|
+
* and you haven't typed since". */
|
|
170
|
+
const IDLE_PROMPT_DELAY_MS = 60_000
|
|
171
|
+
|
|
172
|
+
/** pi's model_select sources in Claude's PostModelSwitch vocabulary: an explicit
|
|
173
|
+
* set is a command-style request, cycling is the picker, and restore is the model
|
|
174
|
+
* Claude Code restores on resume. */
|
|
175
|
+
const MODEL_SELECT_SOURCE: Record<string, string> = { set: 'command', cycle: 'picker', restore: 'resume' }
|
|
176
|
+
|
|
163
177
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
164
178
|
let config: HooksConfig = {}
|
|
165
179
|
let projectDir = ''
|
|
@@ -170,6 +184,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
170
184
|
/** Consecutive Stop-hook blocks with no user progress between them. Reset on user input
|
|
171
185
|
* and on a non-blocking Stop; at the cap the continuation is suppressed and the turn ends. */
|
|
172
186
|
let stopHookBlockCount = 0
|
|
187
|
+
/** The pending idle_prompt notification: Claude fires it when the turn ended about
|
|
188
|
+
* 60 seconds ago and the user hasn't typed since, so it arms on agent_end and is
|
|
189
|
+
* canceled by input or the next turn. */
|
|
190
|
+
let idlePromptTimer: ReturnType<typeof setTimeout> | undefined
|
|
191
|
+
const cancelIdlePrompt = (): void => {
|
|
192
|
+
clearTimeout(idlePromptTimer)
|
|
193
|
+
idlePromptTimer = undefined
|
|
194
|
+
}
|
|
173
195
|
let sessionCtx: ExtensionContext | undefined
|
|
174
196
|
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
175
197
|
let hooksDisabled = false
|
|
@@ -353,6 +375,8 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
353
375
|
// over the bus from context-imports, which owns claudeMdExcludes; announcing
|
|
354
376
|
// the raw contextFiles here would fire for a file the exclusion removed.
|
|
355
377
|
pi.on('before_agent_start', async () => {
|
|
378
|
+
// A new turn is beginning, so the session is no longer idle.
|
|
379
|
+
cancelIdlePrompt()
|
|
356
380
|
if (pendingSessionContext.length === 0) return
|
|
357
381
|
const content = pendingSessionContext.join('\n')
|
|
358
382
|
pendingSessionContext = []
|
|
@@ -452,6 +476,8 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
452
476
|
// Only genuine user input; extension-injected messages (plan-mode, subagent) are not
|
|
453
477
|
// prompts the user submitted.
|
|
454
478
|
if (event.source === 'extension') return { action: 'continue' }
|
|
479
|
+
// The user typed, so the pending idle_prompt no longer applies.
|
|
480
|
+
cancelIdlePrompt()
|
|
455
481
|
// Genuine user input is progress, so it breaks a Stop-hook continuation streak: the
|
|
456
482
|
// block cap counts only consecutive blocks with nothing from the user in between.
|
|
457
483
|
stopHookBlockCount = 0
|
|
@@ -479,11 +505,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
479
505
|
// automatic retry or compaction; that is the better tradeoff.
|
|
480
506
|
pi.on('agent_end', async (event, ctx) => {
|
|
481
507
|
// Claude's Notification event, for the one type pi can honestly source: the
|
|
482
|
-
// agent finished and is waiting for input
|
|
483
|
-
//
|
|
508
|
+
// agent finished and is waiting for input. Per Claude, idle_prompt fires when
|
|
509
|
+
// the turn ended about 60 seconds ago and the user hasn't typed since, so it
|
|
510
|
+
// arms here and input or the next turn cancels it. Observational only; exit
|
|
511
|
+
// codes and JSON output are ignored, as Claude documents for this event.
|
|
512
|
+
cancelIdlePrompt()
|
|
484
513
|
const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
|
|
485
514
|
if (notifyCommands.length > 0) {
|
|
486
|
-
|
|
515
|
+
const runner = boundRunner(ctx)
|
|
516
|
+
idlePromptTimer = setTimeout(() => {
|
|
517
|
+
void runNotifyHooks(notifyCommands, { hook_event_name: 'Notification', notification_type: 'idle_prompt', message: 'pi is waiting for your input' }, runner).catch(() => {})
|
|
518
|
+
}, IDLE_PROMPT_DELAY_MS)
|
|
519
|
+
idlePromptTimer.unref?.()
|
|
487
520
|
}
|
|
488
521
|
|
|
489
522
|
// Stop has no matcher support (a stray matcher is ignored, as Claude documents)
|
|
@@ -557,6 +590,33 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
557
590
|
const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
|
|
558
591
|
const results = await runNotifyHooks(matchingCommands(config.PostCompact, trigger.names), { hook_event_name: 'PostCompact', trigger: trigger.value }, boundRunner(ctx))
|
|
559
592
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
593
|
+
// Claude also fires SessionStart with source "compact" when the session
|
|
594
|
+
// continues after compaction; its stdout context rides the next agent start,
|
|
595
|
+
// the same as any other SessionStart context.
|
|
596
|
+
const sessionStart = matchingCommands(config.SessionStart, 'compact')
|
|
597
|
+
if (sessionStart.length > 0) {
|
|
598
|
+
const startResults = await runNotifyHooks(sessionStart, { hook_event_name: 'SessionStart', source: 'compact' }, boundRunner(ctx))
|
|
599
|
+
surfaceSystemMessages(startResults, (message) => ctx.ui.notify(message, 'warning'))
|
|
600
|
+
pendingSessionContext.push(...startResults.map((result) => promptContext(result.stdout)).filter(Boolean))
|
|
601
|
+
}
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
pi.on('model_select', async (event, ctx) => {
|
|
605
|
+
// Claude's PostModelSwitch: runs after the session's model changes, matched
|
|
606
|
+
// against the model switched to; it can't block. PreModelSwitch stays
|
|
607
|
+
// unbridged: pi's model_select has no veto seam, and a "Pre" hook whose block
|
|
608
|
+
// decision is silently ignored would be worse than an absent event.
|
|
609
|
+
const { model, previousModel, source } = event as { model: { id: string }; previousModel?: { id: string }; source: string }
|
|
610
|
+
if (!previousModel || previousModel.id === model.id) return
|
|
611
|
+
const commands = matchingCommands(config.PostModelSwitch, model.id)
|
|
612
|
+
if (commands.length === 0) return
|
|
613
|
+
// requested_model is null: pi does not carry the alias the request named.
|
|
614
|
+
const payload = { hook_event_name: 'PostModelSwitch', from_model: previousModel.id, to_model: model.id, requested_model: null, source: MODEL_SELECT_SOURCE[source] ?? 'command' }
|
|
615
|
+
const results = await runNotifyHooks(commands, payload, boundRunner(ctx))
|
|
616
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
617
|
+
// Claude delivers the hook's stdout (or additionalContext) to Claude with the
|
|
618
|
+
// next request after the switch; pi's seam for that is the next agent start.
|
|
619
|
+
pendingSessionContext.push(...results.map((result) => promptContext(result.stdout)).filter(Boolean))
|
|
560
620
|
})
|
|
561
621
|
|
|
562
622
|
pi.on('session_shutdown', async (event, ctx) => {
|
|
@@ -115,9 +115,12 @@ function collectCommands(matchers: HookMatcher[] | undefined, applies: (entry: H
|
|
|
115
115
|
if (!applies(entry)) continue
|
|
116
116
|
for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
|
|
117
117
|
const hook = withCommand(raw)
|
|
118
|
-
// Claude runs a handler defined in more than one settings file once
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
// Claude runs a handler defined in more than one settings file once; a
|
|
119
|
+
// plugin's or skill's copy of the same handler stays separate, and http
|
|
120
|
+
// handlers with the same URL but different headers are distinct.
|
|
121
|
+
const key = `${hook.origin ?? 'settings'}\n${hook.command}\n${hook.headers ? JSON.stringify(hook.headers) : ''}`
|
|
122
|
+
if (seen.has(key)) continue
|
|
123
|
+
seen.add(key)
|
|
121
124
|
result.push(hook)
|
|
122
125
|
}
|
|
123
126
|
}
|
|
@@ -247,6 +247,33 @@ export async function runPromptHook(hook: HookCommand, payload: unknown, model:
|
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
/** A dotted path into the hook's JSON input, or undefined when any step is missing. */
|
|
251
|
+
function lookupPath(payload: unknown, dotted: string): unknown {
|
|
252
|
+
let current: unknown = payload
|
|
253
|
+
for (const key of dotted.split('.')) {
|
|
254
|
+
if (current === null || typeof current !== 'object') return undefined
|
|
255
|
+
current = (current as Record<string, unknown>)[key]
|
|
256
|
+
}
|
|
257
|
+
return current
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Claude's ${path} substitution for mcp_tool input: string values may reference the
|
|
261
|
+
* hook's JSON input, such as ${tool_input.file_path}. Arrays and nested objects are
|
|
262
|
+
* walked; an unresolvable path stays literal; non-string looked-up values are
|
|
263
|
+
* JSON-encoded into the string. */
|
|
264
|
+
function substituteInputPaths(value: unknown, payload: unknown): unknown {
|
|
265
|
+
if (typeof value === 'string') {
|
|
266
|
+
return value.replace(/\$\{([\w.]+)\}/g, (matchText, dotted: string) => {
|
|
267
|
+
const found = lookupPath(payload, dotted)
|
|
268
|
+
if (found === undefined) return matchText
|
|
269
|
+
return typeof found === 'string' ? found : JSON.stringify(found)
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
if (Array.isArray(value)) return value.map((entry) => substituteInputPaths(entry, payload))
|
|
273
|
+
if (value !== null && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, substituteInputPaths(entry, payload)]))
|
|
274
|
+
return value
|
|
275
|
+
}
|
|
276
|
+
|
|
250
277
|
/**
|
|
251
278
|
* Claude's `type: "mcp_tool"` hook: call a tool on an already-connected MCP server
|
|
252
279
|
* and treat its text output like command stdout. pi reaches the server through the
|
|
@@ -255,7 +282,9 @@ export async function runPromptHook(hook: HookCommand, payload: unknown, model:
|
|
|
255
282
|
*/
|
|
256
283
|
export async function runMcpToolHook(hook: HookCommand, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
|
|
257
284
|
if (!hook.server || !hook.tool) return { code: 1, stdout: '', stderr: 'mcp_tool hook needs server and tool', timedOut: false }
|
|
258
|
-
|
|
285
|
+
// Claude: `input` is the arguments passed to the tool; without it the tool is
|
|
286
|
+
// called with no arguments, never handed the whole event payload.
|
|
287
|
+
const input = hook.input && typeof hook.input === 'object' ? (substituteInputPaths(hook.input, payload) as Record<string, unknown>) : {}
|
|
259
288
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
260
289
|
const deadline = new Promise<HookRunResult>((resolve) => {
|
|
261
290
|
timer = setTimeout(() => resolve({ code: 1, stdout: '', stderr: `mcp_tool hook timed out after ${timeoutMs}ms`, timedOut: false }), timeoutMs)
|
|
@@ -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.29",
|
|
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",
|