pi-code 1.0.13 → 1.0.14

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/mcp.ts DELETED
@@ -1,1358 +0,0 @@
1
- /**
2
- * MCP Adapter Extension
3
- *
4
- * Connects MCP (Model Context Protocol) servers and registers their tools in pi
5
- * as `<server>_<tool>`. Connects on `session_start`, not from the factory (pi runs
6
- * the factory for invocations that never start a session); per-server timeout,
7
- * failures skip with a notice; stdio and HTTP (streamable with SSE fallback)
8
- * transports; /mcp shows status.
9
- *
10
- * Reads Claude Code's MCP config too. User config (~/.claude.json top-level plus its
11
- * per-project `projects[cwd].mcpServers` local scope, and ~/.pi/agent/mcp.json) is the
12
- * user's own and loads on the first session. Project config (.mcp.json, .pi/mcp.json)
13
- * can run arbitrary commands on connect, so it loads only once the project is approved
14
- * (see project-approval). The two scopes are loaded separately, not merged. Claude's
15
- * precedence is project over user for a duplicate name, so a project server the user has
16
- * consented to (or an approved project's) wins; a merely-present untrusted project entry
17
- * cannot shadow a user server, and a gated project server does not preempt it.
18
- * Values support ${VAR} / ${VAR:-default} interpolation, connect and per-call timeouts
19
- * honor MCP_TIMEOUT / MCP_TOOL_TIMEOUT, and a stdio server receives only the SDK's default
20
- * environment plus its own `env` block, not the whole process environment.
21
- *
22
- * Servers advertising the `prompts` capability get their prompts registered as Claude's
23
- * /mcp__<server>__<prompt> slash commands (names normalized dashes/spaces to underscores,
24
- * args space-separated and mapped positionally); the prompt result drives a turn via
25
- * sendUserMessage, exactly how custom slash commands do. Servers advertising `resources`
26
- * make the global list_mcp_resources / read_mcp_resource tools available, mirroring
27
- * Claude's automatic resource tools. Resource and prompt output rides the same
28
- * mapContent/capForContext budget as tool output. That budget is byte/line based
29
- * (pi's DEFAULT_MAX_BYTES in the shared output guard); Claude's MAX_MCP_OUTPUT_TOKENS
30
- * is a token budget and cannot be folded into it without making the guard token-aware,
31
- * so the byte cap stands in for it.
32
- */
33
-
34
- import { execFile } from 'node:child_process'
35
- import * as fs from 'node:fs'
36
- import * as os from 'node:os'
37
- import * as path from 'node:path'
38
- import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
39
- import { DEFAULT_MAX_BYTES } from '@earendil-works/pi-coding-agent'
40
- // SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
41
- // the old spec exist, so this stays as a fallback for the migration period.
42
- import { type OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
43
- import { Client } from '@modelcontextprotocol/sdk/client/index.js'
44
- import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' // NOSONAR
45
- import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
46
- import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
47
- import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'
48
- import { PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
49
- import { Type } from 'typebox'
50
- import { splitArgs } from './internal/command-file.js'
51
- import { claudeConfigDir } from './internal/config-dir.js'
52
- import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
53
- import { setMcpToolCaller } from './internal/mcp-call.js'
54
- import { FileOAuthProvider, openBrowser, startCallbackServer, waitForAuthCode } from './internal/mcp-oauth.js'
55
- import { capForContext } from './internal/output-guard.js'
56
- import { type InstalledPlugin, installedPlugins, substitutePluginVars } from './internal/plugins.js'
57
- import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
58
- import { findNearestFile } from './internal/project-root.js'
59
-
60
- const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
61
- // Claude's MCP_TOOL_TIMEOUT default is effectively hours: the per-call wall-clock budget
62
- // is only a ceiling, and the idle timeout below is the real guard. 4h matches that model,
63
- // so a legitimately slow-but-progressing tool is not killed at the old 2 minutes.
64
- const DEFAULT_CALL_TIMEOUT_MS = 14_400_000
65
- // The idle timeout: the longest a call may go with no response or progress before it is
66
- // abandoned. Claude uses a separate idle guard (minutes) rather than the hours-long
67
- // wall-clock budget; the SDK resets this window on every progress notification.
68
- const DEFAULT_CALL_IDLE_TIMEOUT_MS = 300_000
69
-
70
- /** A positive-integer env override, or the default when unset or unparseable. */
71
- function envTimeout(name: string, fallback: number): number {
72
- const raw = process.env[name]
73
- if (raw === undefined) return fallback
74
- const value = Number.parseInt(raw, 10)
75
- return Number.isInteger(value) && value > 0 ? value : fallback
76
- }
77
-
78
- // Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
79
- const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
80
- const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
81
-
82
- /** The idle timeout in ms: the longest a call may go with no response or progress before
83
- * it is abandoned, overridable by CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT, with 0 disabling it
84
- * (leaving only the wall-clock budget). Unlike envTimeout, an explicit 0 is honored as
85
- * "disabled" rather than falling back to the default. */
86
- function idleTimeoutMs(): number {
87
- const raw = process.env.CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT
88
- if (raw === undefined) return DEFAULT_CALL_IDLE_TIMEOUT_MS
89
- const value = Number.parseInt(raw, 10)
90
- if (value === 0) return 0
91
- return Number.isInteger(value) && value > 0 ? value : DEFAULT_CALL_IDLE_TIMEOUT_MS
92
- }
93
-
94
- /** The SDK RequestOptions for a call under pi's two-tier timeout: a wall-clock ceiling and,
95
- * under it, an idle timeout the SDK resets on every progress notification. When the idle
96
- * window is enabled and tighter than the wall budget, `timeout` is that per-quiet-period
97
- * deadline (resetTimeoutOnProgress), maxTotalTimeout caps the wall clock, and an onprogress
98
- * handler is required: it makes the server address progress to this request and lets the
99
- * SDK reset the timer on it. When the idle timeout is disabled, or already looser than the
100
- * wall budget, only the wall budget applies. The outer withTimeout race is a wall-clock
101
- * backstop and must be raced against `wall`, never the idle window, so a legitimately
102
- * progressing call is not cut off. */
103
- function callRequestOptions(wall: number): { timeout: number; resetTimeoutOnProgress?: boolean; maxTotalTimeout?: number; onprogress?: () => void } {
104
- const idle = idleTimeoutMs()
105
- if (idle === 0 || idle >= wall) return { timeout: wall }
106
- return { timeout: idle, resetTimeoutOnProgress: true, maxTotalTimeout: wall, onprogress: () => {} }
107
- }
108
- // Tool names an MCP server must never take over. formatToolName always emits
109
- // `<server>_<tool>`, so only names containing an underscore are actually reachable:
110
- // pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
111
- // These are pi-code's own tools, and mcp.ts registers before the extensions owning
112
- // them, so without this guard a server named `web` would replace the SSRF-checked fetch.
113
- // The resource tools are this extension's own globals; a server named `list` or `read`
114
- // must not take their names either.
115
- const RESERVED_NAMES = new Set(['web_fetch', 'web_search', 'plan_mode_complete', 'list_mcp_resources', 'read_mcp_resource'])
116
-
117
- export interface StdioServerConfig {
118
- type?: 'stdio'
119
- command: string
120
- args?: string[]
121
- env?: Record<string, string>
122
- cwd?: string
123
- /** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
124
- timeout?: number
125
- /** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
126
- aliasPrefix?: string
127
- }
128
-
129
- export interface HttpServerConfig {
130
- type?: 'http' | 'streamable-http' | 'sse' | 'ws' | 'websocket'
131
- url: string
132
- headers?: Record<string, string>
133
- bearerToken?: string
134
- bearerTokenEnv?: string
135
- /** A command whose JSON stdout is merged into the connect headers, for auth
136
- * schemes other than OAuth/static tokens (Claude's headersHelper). */
137
- headersHelper?: string
138
- /** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
139
- timeout?: number
140
- /** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
141
- aliasPrefix?: string
142
- }
143
-
144
- export type ServerConfig = StdioServerConfig | HttpServerConfig
145
-
146
- /** Claude's .mcp.json expansion: ${VAR}, and ${VAR:-default}. The syntax borrows
147
- * shell's `:-`, which substitutes when the variable is unset OR empty. */
148
- export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.env, onMissing?: (name: string) => void): string {
149
- return value.replace(/\$\{(\w+)(:-([^}]*))?\}/g, (fullMatch, name, hasDefault, fallback) => {
150
- const current = env[name]
151
- if (hasDefault !== undefined) return current || fallback
152
- if (current === undefined) {
153
- // A referenced variable with no value and no default: keep the literal ${VAR} and
154
- // report it, matching Claude, rather than silently substituting an empty string that
155
- // turns `Bearer ${TOKEN}` into a confusing `Bearer ` and a mystery 401.
156
- onMissing?.(name)
157
- return fullMatch
158
- }
159
- return current
160
- })
161
- }
162
-
163
- /** The user's ~/.claude.json (top-level mcpServers plus the per-project `projects` map).
164
- * When CLAUDE_CONFIG_DIR is set, Claude relocates .claude.json inside that directory; by
165
- * default it stays at the home root, since .claude.json does NOT live inside ~/.claude. A
166
- * blank value is treated as unset, matching claudeConfigDir. */
167
- function claudeJsonPath(home: string): string {
168
- const override = process.env.CLAUDE_CONFIG_DIR
169
- return override && override.trim().length > 0 ? path.join(claudeConfigDir(home), '.claude.json') : path.join(home, '.claude.json')
170
- }
171
-
172
- /** User-scoped MCP config (the user's own; safe to load without project trust). The .pi
173
- * tree is pi's own and is not relocated by CLAUDE_CONFIG_DIR. */
174
- export function userConfigPaths(home: string): string[] {
175
- return [claudeJsonPath(home), path.join(home, '.pi', 'agent', 'mcp.json')]
176
- }
177
-
178
- /** Project-scoped MCP config, each file the nearest of its name at or above cwd
179
- * (bounded at the repository root, matching the approval walk). Loaded only for
180
- * trusted projects: a server's `command` runs on connect. */
181
- export function projectConfigPaths(cwd: string): string[] {
182
- return ['.mcp.json', path.join('.pi', 'mcp.json')].map((rel) => findNearestFile(cwd, rel) ?? path.join(cwd, rel))
183
- }
184
-
185
- export interface ProjectServerPolicy {
186
- disabled: Set<string>
187
- consented: Set<string>
188
- consentAll: boolean
189
- }
190
-
191
- /** Claude's per-server approvals for project .mcp.json servers.
192
- *
193
- * Consent-granting keys (enabledMcpjsonServers, enableAllProjectMcpServers) count
194
- * from the user's own settings always, and from the project's settings.local.json
195
- * only once the project itself is approved. That file is gitignored by convention,
196
- * not by enforcement: a repository can commit one, and honoring it unconditionally
197
- * let a hostile repo self-approve a server whose `command` runs on connect, even
198
- * after the user declined the trust prompt.
199
- *
200
- * disabledMcpjsonServers counts from every file, including the repo's own, and wins
201
- * over consent: a repo may always restrict itself further, never less. */
202
- export function projectServerPolicy(cwd: string, home: string, projectApproved: boolean): ProjectServerPolicy {
203
- const read = (file: string): Record<string, unknown> => {
204
- try {
205
- return JSON.parse(fs.readFileSync(file, 'utf-8'))
206
- } catch {
207
- return {}
208
- }
209
- }
210
- const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
211
- const userSettings = read(path.join(claudeConfigDir(home), 'settings.json'))
212
- const projectSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json'))
213
- const localSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json'))
214
- const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
215
- const consentSources = projectApproved ? [userSettings, localSettings] : [userSettings]
216
- const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
217
- const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
218
- return { disabled, consented, consentAll }
219
- }
220
-
221
- /** Split project servers by the per-server policy: never-connect, connect without the
222
- * whole-project confirm, and still gated behind it. */
223
- export function splitByPolicy(candidates: Record<string, ServerConfig>, policy: ProjectServerPolicy): { consented: Record<string, ServerConfig>; gated: Record<string, ServerConfig> } {
224
- const consented: Record<string, ServerConfig> = {}
225
- const gated: Record<string, ServerConfig> = {}
226
- for (const [name, config] of Object.entries(candidates)) {
227
- if (policy.disabled.has(name)) continue
228
- if (policy.consentAll || policy.consented.has(name)) consented[name] = config
229
- else gated[name] = config
230
- }
231
- return { consented, gated }
232
- }
233
-
234
- export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
235
- const servers: Record<string, ServerConfig> = {}
236
- for (const file of files) {
237
- try {
238
- const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
239
- Object.assign(servers, parsed.mcpServers ?? {})
240
- } catch {
241
- // missing or invalid file: skip silently, /mcp reports what loaded
242
- }
243
- }
244
- return servers
245
- }
246
-
247
- /**
248
- * All user-owned servers for this session: the global user servers plus Claude's "local"
249
- * scope, the per-project user servers under `projects[cwd].mcpServers` in ~/.claude.json.
250
- * Both are the user's own config, so neither needs project trust; local wins on a name
251
- * clash (Claude's precedence is local over user).
252
- */
253
- export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
254
- const servers = loadConfigFrom(userConfigPaths(home))
255
- try {
256
- const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath(home), 'utf-8'))
257
- Object.assign(servers, claudeJson.projects?.[cwd]?.mcpServers ?? {})
258
- } catch {
259
- // missing or invalid ~/.claude.json: the top-level user servers already loaded
260
- }
261
- return servers
262
- }
263
-
264
- /** The mcpServers one plugin declares: an inline map on the manifest, or the file it
265
- * points to (default .mcp.json at the plugin root), with ${CLAUDE_PLUGIN_*} substituted
266
- * before parsing. Malformed or missing JSON yields no entries. */
267
- function pluginServerEntries(plugin: InstalledPlugin): Record<string, ServerConfig> {
268
- const declared = plugin.manifest.mcpServers
269
- // An inline map of name -> config; an array is not a valid mcpServers map (it
270
- // would register a server named '0'), so it falls through to the path branch.
271
- if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
272
- try {
273
- return JSON.parse(substitutePluginVars(JSON.stringify(declared), plugin))
274
- } catch {
275
- return {}
276
- }
277
- }
278
- const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : '.mcp.json')
279
- try {
280
- const parsed = JSON.parse(substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin))
281
- return parsed.mcpServers ?? {}
282
- } catch {
283
- return {}
284
- }
285
- }
286
-
287
- /** Servers shipped by enabled plugins (.mcp.json or the manifest's `mcpServers`,
288
- * inline or by path), with ${CLAUDE_PLUGIN_*} substituted before parsing. Their
289
- * tools alias as mcp__plugin_<plugin>_<server>__<tool> for hook matchers, as
290
- * Claude scopes them. */
291
- export function loadPluginServers(plugins: InstalledPlugin[]): Record<string, ServerConfig> {
292
- const fold = (name: string): string => name.replaceAll('-', '_')
293
- const servers: Record<string, ServerConfig> = {}
294
- for (const plugin of plugins) {
295
- for (const [name, config] of Object.entries(pluginServerEntries(plugin))) {
296
- servers[name] = { ...config, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__` }
297
- }
298
- }
299
- return servers
300
- }
301
-
302
- /** Claude reports a config entry that has a url but no type as an error; pi-code
303
- * still connects (streamable HTTP with SSE fallback) but says the entry is wrong. */
304
- /** An inline bearerToken (interpolated) wins over bearerTokenEnv, which names an
305
- * environment variable read as-is. */
306
- export function resolveBearerToken(config: { bearerToken?: string; bearerTokenEnv?: string }): string | undefined {
307
- if (config.bearerToken) return interpolateEnv(config.bearerToken)
308
- if (config.bearerTokenEnv) return process.env[config.bearerTokenEnv]
309
- return undefined
310
- }
311
-
312
- /** Claude's `headersHelper` output: a flat JSON object of header name -> string,
313
- * merged into the connect headers. Non-string values and non-object output are
314
- * ignored so a broken helper cannot poison the request. */
315
- export function parseHelperHeaders(stdout: string): Record<string, string> {
316
- let parsed: unknown
317
- try {
318
- parsed = JSON.parse(stdout)
319
- } catch {
320
- return {}
321
- }
322
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
323
- const out: Record<string, string> = {}
324
- for (const [key, value] of Object.entries(parsed)) if (typeof value === 'string') out[key] = value
325
- return out
326
- }
327
-
328
- /** The OS managed-settings.json path, where Claude sources `allowedMcpServers`/
329
- * `deniedMcpServers` (an enterprise policy file deployed by IT, not user- or
330
- * repo-writable in the normal flow). */
331
- export function managedSettingsPath(platform: NodeJS.Platform = process.platform): string {
332
- if (platform === 'darwin') return '/Library/Application Support/ClaudeCode/managed-settings.json'
333
- // The legacy C:\ProgramData\ClaudeCode path was dropped in Claude Code v2.1.75.
334
- if (platform === 'win32') return String.raw`C:\Program Files\ClaudeCode\managed-settings.json`
335
- return '/etc/claude-code/managed-settings.json'
336
- }
337
-
338
- /** Test seam: override the managed-settings.json path the extension reads. */
339
- let managedSettingsFileOverride: string | undefined
340
- export function setManagedSettingsPath(file: string | undefined): void {
341
- managedSettingsFileOverride = file
342
- }
343
-
344
- /** Claude's `allowedMcpServers`/`deniedMcpServers`, read from managed settings only
345
- * (not user or project settings, so a repo can neither widen nor narrow the policy),
346
- * applied globally to every server across scopes. Entries are `{ serverName }` objects
347
- * (bare strings tolerated). `allowed` is null when unset (no restriction); an empty
348
- * set is an explicit lockdown, as Claude documents (empty allow array = deny all). */
349
- export function mcpAllowDeny(managedFile: string = managedSettingsFileOverride ?? managedSettingsPath()): { allowed: Set<string> | null; denied: Set<string> } {
350
- let settings: Record<string, unknown> = {}
351
- try {
352
- const parsed = JSON.parse(fs.readFileSync(managedFile, 'utf-8'))
353
- if (parsed && typeof parsed === 'object') settings = parsed
354
- } catch {
355
- // No managed policy on this machine: no restriction.
356
- }
357
- const entryName = (entry: unknown): string | undefined => {
358
- if (typeof entry === 'string') return entry
359
- const serverName = (entry as { serverName?: unknown })?.serverName
360
- return typeof serverName === 'string' ? serverName : undefined
361
- }
362
- const names = (value: unknown): string[] => (Array.isArray(value) ? value.map(entryName).filter((name): name is string => typeof name === 'string' && name.length > 0) : [])
363
- return {
364
- allowed: Array.isArray(settings.allowedMcpServers) ? new Set(names(settings.allowedMcpServers)) : null,
365
- denied: new Set(names(settings.deniedMcpServers)),
366
- }
367
- }
368
-
369
- /** The managed-mcp.json path: a sibling of managed-settings.json (same directory). Derived
370
- * through the same test seam so a test can write both into one temp dir. */
371
- export function managedMcpPath(managedFile: string = managedSettingsFileOverride ?? managedSettingsPath()): string {
372
- return path.join(path.dirname(managedFile), 'managed-mcp.json')
373
- }
374
-
375
- /** Claude's managed-mcp.json: when it exists beside managed-settings.json it takes
376
- * exclusive control of MCP. Only its `mcpServers` load; user, project, and plugin servers
377
- * are all suppressed (and the project-approval flow with them), and an empty map disables
378
- * MCP entirely. Returns the managed server map (possibly empty) when the file exists and
379
- * parses, or null only when the file is absent, in which case MCP loads from the usual
380
- * scopes exactly as before. A file that parses but carries no `mcpServers` object is an
381
- * empty managed set, so a deployed-but-bodyless policy locks down rather than silently
382
- * reopening the other scopes. A file that is PRESENT but not valid JSON fails closed to
383
- * the same empty set (deny-all) rather than reopening those scopes: the lockdown intent
384
- * means a corrupt or truncated policy file must not become an allow-all. The allow/deny
385
- * lists still filter the returned set. */
386
- export function loadManagedMcpServers(managedFile: string = managedSettingsFileOverride ?? managedSettingsPath()): Record<string, ServerConfig> | null {
387
- const file = managedMcpPath(managedFile)
388
- let raw: string
389
- try {
390
- raw = fs.readFileSync(file, 'utf-8')
391
- } catch {
392
- // Absent (or unreadable) managed-mcp.json: no managed MCP control, load normally.
393
- return null
394
- }
395
- let parsed: unknown
396
- try {
397
- parsed = JSON.parse(raw)
398
- } catch (error) {
399
- // Present but corrupt: fail closed to an empty managed set, exactly like an empty map,
400
- // rather than reopening the user/project/plugin scopes.
401
- console.warn(`pi-code-mcp: managed-mcp.json is present but not valid JSON (${file}); failing closed to no MCP servers: ${error instanceof Error ? error.message : String(error)}`)
402
- return {}
403
- }
404
- if (parsed === null || typeof parsed !== 'object') return {}
405
- const servers = (parsed as { mcpServers?: unknown }).mcpServers
406
- if (servers === null || typeof servers !== 'object' || Array.isArray(servers)) return {}
407
- return servers as Record<string, ServerConfig>
408
- }
409
-
410
- /** Claude's managed allow/deny lists: `allowed` null means no allow list (keep all);
411
- * a set (even empty) is exclusive, so only its members survive; a deny list removes
412
- * servers on top, deny winning over allow. */
413
- export function applyServerPolicy(servers: Record<string, ServerConfig>, allowed: ReadonlySet<string> | null, denied: ReadonlySet<string>): Record<string, ServerConfig> {
414
- const out: Record<string, ServerConfig> = {}
415
- for (const [name, config] of Object.entries(servers)) {
416
- if (denied.has(name)) continue
417
- if (allowed !== null && !allowed.has(name)) continue
418
- out[name] = config
419
- }
420
- return out
421
- }
422
-
423
- /** A server cwd expands ${VAR} then a leading ~, or stays unset. */
424
- export function expandCwd(cwd: string | undefined): string | undefined {
425
- if (!cwd) return undefined
426
- return interpolateEnv(cwd).replace(/^~(?=\/|$)/, os.homedir())
427
- }
428
-
429
- export function warnOnTypelessUrl(name: string, config: ServerConfig): void {
430
- if ('url' in config && config.type === undefined) {
431
- console.warn(`pi-code-mcp: server ${name} declares a url with no "type"; add "type": "http" or "sse"`)
432
- }
433
- }
434
-
435
- export function formatToolName(server: string, tool: string): string {
436
- return `${server}_${tool}`.replaceAll('-', '_')
437
- }
438
-
439
- /** Claude exposes server prompts as /mcp__<server>__<prompt> slash commands. Both
440
- * names normalize like formatToolName, extended to spaces: dashes and spaces each
441
- * become an underscore. */
442
- export function formatPromptCommandName(server: string, prompt: string): string {
443
- const normalize = (name: string): string => name.replace(/[\s-]/g, '_')
444
- return `mcp__${normalize(server)}__${normalize(prompt)}`
445
- }
446
-
447
- export interface McpPromptArgumentInfo {
448
- name: string
449
- description?: string
450
- required?: boolean
451
- }
452
-
453
- export interface McpPromptInfo {
454
- name: string
455
- description?: string
456
- arguments?: McpPromptArgumentInfo[]
457
- }
458
-
459
- /** Claude passes prompt arguments space-separated after the command. Tokens map
460
- * positionally onto the declared arguments, split the way slash-command args are
461
- * (quoted runs stay together); the last declared argument absorbs any trailing
462
- * tokens so free text at the end is not silently dropped. Declared arguments with
463
- * no token are omitted, and the server enforces its own `required`. */
464
- export function mapPromptArguments(declared: ReadonlyArray<{ name: string }> | undefined, args: string): Record<string, string> {
465
- const tokens = splitArgs(args)
466
- const names = (declared ?? []).map((argument) => argument.name)
467
- const mapped: Record<string, string> = {}
468
- for (let index = 0; index < names.length && index < tokens.length; index++) {
469
- mapped[names[index]] = index === names.length - 1 ? tokens.slice(index).join(' ') : tokens[index]
470
- }
471
- return mapped
472
- }
473
-
474
- /** The content blocks a getPrompt result injects. Each message carries one content
475
- * block; the blocks ride the same mapContent budget as tool output, and image blocks
476
- * are carried through rather than dropped, since sendUserMessage accepts them and a
477
- * vision prompt is worthless flattened to text. An empty message list yields no
478
- * blocks, and messages that carry only empty text yield none either, so the caller
479
- * can skip the turn rather than drive it on an empty or sentinel message. */
480
- export function promptMessageContent(messages: ReadonlyArray<{ content: unknown }>): ToolContent[] {
481
- if (messages.length === 0) return []
482
- return mapContent(messages.map((message) => message.content as McpContentBlock)).filter((block) => block.type !== 'text' || block.text.trim() !== '')
483
- }
484
-
485
- /** Merge the `properties` (and, for allOf, the `required`) of a root-level combinator's
486
- * branches into one flat object schema. Without this a tool whose input schema is a bare
487
- * anyOf/oneOf/allOf (no top-level `type`) would present no properties at all, so the model
488
- * would be forced to call it with no arguments. */
489
- function mergeCombinatorBranches(branches: unknown[]): { properties: Record<string, unknown>; required: string[] } {
490
- const properties: Record<string, unknown> = {}
491
- const required = new Set<string>()
492
- for (const branch of branches) {
493
- if (!branch || typeof branch !== 'object') continue
494
- const b = branch as Record<string, unknown>
495
- if (b.properties && typeof b.properties === 'object') Object.assign(properties, b.properties as Record<string, unknown>)
496
- if (Array.isArray(b.required)) for (const name of b.required) if (typeof name === 'string') required.add(name)
497
- }
498
- return { properties, required: [...required] }
499
- }
500
-
501
- export function normalizeSchema(schema: unknown): object {
502
- const base = (schema as Record<string, unknown>) ?? {}
503
- const { $schema: _dropSchema, additionalProperties: _dropAdditional, ...rest } = base
504
- if (rest.type) return rest
505
- // A root-level combinator carries the real parameters in its branches; flatten them
506
- // into one object schema rather than emptying it. allOf means every branch applies, so
507
- // its required union is kept; anyOf/oneOf branches are alternatives, so required is left
508
- // open (the server still enforces its own).
509
- const allOf = Array.isArray(rest.allOf) ? rest.allOf : undefined
510
- let branches = allOf
511
- if (!branches && Array.isArray(rest.anyOf)) branches = rest.anyOf
512
- if (!branches && Array.isArray(rest.oneOf)) branches = rest.oneOf
513
- if (!branches) return { type: 'object', properties: {} }
514
- const { properties, required } = mergeCombinatorBranches(branches)
515
- const merged: Record<string, unknown> = { type: 'object', properties }
516
- if (typeof rest.description === 'string') merged.description = rest.description
517
- if (allOf && required.length > 0) merged.required = required
518
- return merged
519
- }
520
-
521
- interface McpContentBlock {
522
- type: string
523
- text?: string
524
- data?: string
525
- mimeType?: string
526
- resource?: { uri?: string; text?: string }
527
- }
528
-
529
- export type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
530
-
531
- export function mapContent(content: McpContentBlock[] | undefined, structured?: unknown): ToolContent[] {
532
- // capForContext every text output, whatever its source: a server can blow the tool-output
533
- // budget through a resource block, a JSON-stringified block, or the structured fallback,
534
- // not only a text block. The per-block cap alone is not a budget, though: a server
535
- // answering with one block per file multiplies it by the block count, so the blocks
536
- // are capped again as a whole below.
537
- const text = (value: string): ToolContent => ({ type: 'text', text: capForContext(value) })
538
- if (!content || content.length === 0) {
539
- return [text(structured !== undefined ? JSON.stringify(structured, null, 2) : '(empty result)')]
540
- }
541
- const mapped: ToolContent[] = content.map((block): ToolContent => {
542
- if (block.type === 'text') {
543
- return text(block.text ?? '')
544
- }
545
- if (block.type === 'image' && block.data) {
546
- return { type: 'image', data: block.data, mimeType: block.mimeType ?? 'image/png' }
547
- }
548
- if (block.type === 'resource' && block.resource) {
549
- return text(`[Resource: ${block.resource.uri ?? 'unknown'}]\n${block.resource.text ?? ''}`)
550
- }
551
- return text(JSON.stringify(block))
552
- })
553
- return capTotal(mapped)
554
- }
555
-
556
- /**
557
- * Bound a result's text as a whole, not each block. The per-block cap multiplies by
558
- * the block count, so a server answering with one block per file still injects
559
- * megabytes.
560
- *
561
- * Blocks are kept whole. Each has already been capped on its own, so keeping the one
562
- * that crosses the budget bounds the text at roughly a single cap rather than at the
563
- * block count times it, and it preserves that block's own truncation notice, which
564
- * states how much of it was dropped. Blocks after it are omitted rather than skipped
565
- * over, so what reaches the model is a prefix of what the server sent, and the number
566
- * omitted is stated so a truncated set is distinguishable from a complete one.
567
- *
568
- * Images pass through uncut and do not spend the budget: base64 cut short is a broken
569
- * image rather than a smaller one, so nothing here can bound them, and charging the
570
- * budget for one would only delete the caption that accompanies a screenshot.
571
- */
572
- export function capTotal(blocks: ToolContent[]): ToolContent[] {
573
- const kept: ToolContent[] = []
574
- let spent = 0
575
- let full = false
576
- let dropped = 0
577
- for (const block of blocks) {
578
- if (block.type !== 'text') {
579
- kept.push(block)
580
- continue
581
- }
582
- const size = Buffer.byteLength(block.text, 'utf-8')
583
- // The first text block always goes through: a lone oversized one is better read
584
- // truncated, with its own notice, than replaced by a marker saying it existed.
585
- if (full || (spent > 0 && spent + size > DEFAULT_MAX_BYTES)) {
586
- full = true
587
- dropped++
588
- continue
589
- }
590
- kept.push(block)
591
- spent += size
592
- }
593
- if (dropped > 0) {
594
- kept.push({ type: 'text', text: `[${dropped} further content block${dropped === 1 ? '' : 's'} omitted: tool output budget spent]` })
595
- }
596
- return kept
597
- }
598
-
599
- function isStdio(config: ServerConfig): config is StdioServerConfig {
600
- // An explicit type wins; without one, a command field means stdio.
601
- return 'command' in config && (config.type === undefined || config.type === 'stdio')
602
- }
603
-
604
- async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
605
- let timer: ReturnType<typeof setTimeout> | undefined
606
- const timeout = new Promise<never>((_, reject) => {
607
- timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
608
- })
609
- // If the timeout wins, `promise` stays pending; swallow any late rejection so it can never
610
- // surface as an unhandled rejection that crashes the host.
611
- promise.catch(() => {})
612
- try {
613
- return await Promise.race([promise, timeout])
614
- } finally {
615
- clearTimeout(timer)
616
- }
617
- }
618
-
619
- async function connect(name: string, config: ServerConfig, authUi?: AuthUi): Promise<Client> {
620
- const client = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
621
- // Names referenced by ${VAR} with no value and no default, gathered across this
622
- // server's interpolated fields so the connect can warn once rather than fail with a
623
- // mystery 401 or a command that lost an argument.
624
- const missing = new Set<string>()
625
- const fill = (value: string): string => interpolateEnv(value, process.env, (varName) => missing.add(varName))
626
- const warnMissing = (): void => {
627
- if (missing.size > 0) console.warn(`pi-code-mcp: server ${name} references undefined variable(s) ${[...missing].join(', ')}; leaving them unexpanded`)
628
- }
629
- if (isStdio(config)) {
630
- // Start from the SDK's allowlist (PATH, HOME, SHELL, ...) rather than the whole
631
- // process env: a server should not receive ANTHROPIC_API_KEY or GITHUB_TOKEN just
632
- // for being launched. A server that needs a variable names it in its own env block.
633
- const env: Record<string, string> = { ...getDefaultEnvironment() }
634
- for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
635
- const transport = new StdioClientTransport({
636
- command: fill(config.command),
637
- args: (config.args ?? []).map((arg) => fill(arg)),
638
- env,
639
- cwd: expandCwd(config.cwd),
640
- stderr: 'ignore',
641
- })
642
- warnMissing()
643
- await connectWithTimeout(client, transport, `connect ${name}`)
644
- return client
645
- }
646
- const url = new URL(fill(config.url))
647
- if (config.type === 'ws' || config.type === 'websocket') {
648
- // The SDK's WebSocket transport takes only a url: it carries no headers, bearer
649
- // token, or headersHelper output. Warn rather than silently dropping configured
650
- // auth, and skip the helper entirely (running it would block the connect for up to
651
- // 10s while contributing nothing). A ws server must be reachable without auth.
652
- if (config.headers || config.bearerToken || config.bearerTokenEnv || config.headersHelper) {
653
- console.warn(`pi-code-mcp: server ${name} is a WebSocket server; the SDK ws transport is url-only, so its headers/bearerToken/headersHelper are ignored`)
654
- }
655
- const transport = new WebSocketClientTransport(url)
656
- warnMissing()
657
- await connectWithTimeout(client, transport, `connect ${name} (ws)`)
658
- return client
659
- }
660
- const headers: Record<string, string> = {}
661
- for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = fill(value)
662
- const token = resolveBearerToken(config)
663
- if (token) headers.Authorization = `Bearer ${token}`
664
- // A headersHelper generates connect-time headers for non-OAuth auth schemes; its
665
- // JSON stdout merges over the static headers.
666
- if (config.headersHelper) Object.assign(headers, await runHeadersHelper(fill(config.headersHelper)))
667
- warnMissing()
668
- const sseTransport = (authProvider?: OAuthClientProvider) => new SSEClientTransport(url, { requestInit: { headers }, authProvider }) // NOSONAR: explicitly declared or deliberate legacy transport
669
- if (config.type === 'sse') {
670
- return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
671
- }
672
- try {
673
- return await connectHttpFamily(name, config, (authProvider) => new StreamableHTTPClientTransport(url, { requestInit: { headers }, authProvider }), `connect ${name}`, token, authUi)
674
- } catch (error) {
675
- // An explicitly declared streamable transport must not silently degrade to SSE.
676
- if (config.type !== undefined || isUnauthorized(error)) throw error
677
- return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
678
- }
679
- }
680
-
681
- /** Run a headersHelper command and parse its JSON stdout into headers. A failure or
682
- * a 10s timeout yields no extra headers rather than blocking the connection. */
683
- function runHeadersHelper(command: string): Promise<Record<string, string>> {
684
- return new Promise((resolve) => {
685
- execFile('/bin/sh', ['-c', command], { timeout: 10_000 }, (error, stdout) => {
686
- resolve(error ? {} : parseHelperHeaders(stdout))
687
- })
688
- })
689
- }
690
-
691
- /** UI seams the OAuth flow needs; absent in headless runs, which fail with advice. */
692
- export interface AuthUi {
693
- confirm: (title: string, body: string) => Promise<boolean>
694
- notify: (message: string, level: 'info' | 'warning' | 'error') => void
695
- }
696
-
697
- /** The OAuth flow's UI seams, absent in headless runs. */
698
- function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
699
- if (!ctx.hasUI) return undefined
700
- return {
701
- confirm: (title, body) => ctx.ui.confirm(title, body),
702
- notify: (message, level) => ctx.ui.notify(message, level),
703
- }
704
- }
705
-
706
- /** Browser logins are human-paced; a connect-sized timeout would cut them off. */
707
- const OAUTH_FLOW_TIMEOUT_MS = 180_000
708
-
709
- /** A server needs OAuth pi could not complete (headless, declined, or the flow
710
- * failed). A typed marker so the SSE-fallback caller can tell an auth failure
711
- * from a transport mismatch without matching on message text. */
712
- class OAuthRequiredError extends Error {}
713
-
714
- /** Wrap a login-flow failure as OAuthRequiredError, passing an existing one through
715
- * unchanged so its message is not doubled. */
716
- function asOAuthRequiredError(name: string, error: unknown): OAuthRequiredError {
717
- if (error instanceof OAuthRequiredError) return error
718
- const detail = error instanceof Error ? error.message : String(error)
719
- return new OAuthRequiredError(`login for ${name} failed: ${detail}`)
720
- }
721
-
722
- /** Whether a connect failure is an authentication problem: the SDK's own
723
- * UnauthorizedError, a transport error carrying HTTP 401 (which is what a 401
724
- * throws when no authProvider was attached, so a first-time login is detected),
725
- * or our own marker. */
726
- function isUnauthorized(error: unknown): boolean {
727
- if (error instanceof UnauthorizedError || error instanceof OAuthRequiredError) return true
728
- return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 401
729
- }
730
-
731
- // SSEClientTransport is deprecated in favour of Streamable HTTP, but both concrete
732
- // transports expose finishAuth (the base Transport interface does not), so the union
733
- // stays as the http-family fallback type through the migration period.
734
- type HttpFamilyTransport = SSEClientTransport | StreamableHTTPClientTransport // NOSONAR typescript:S1874 - SSE fallback still required by the MCP SDK
735
-
736
- type MakeTransport = (authProvider?: OAuthClientProvider) => HttpFamilyTransport
737
-
738
- /** Interactive OAuth logins block on a confirm dialog and open a browser tab, so two
739
- * at once (a user-scope and a consented project-scope server both 401ing, connecting in
740
- * parallel) would stack dialogs and browser tabs. This chains them so a second
741
- * interactive login waits for the first to settle; the tail is reset to a resolved
742
- * promise regardless of outcome, so a failed login never poisons the queue. Silent
743
- * (stored-token) connects do not pass through here and stay fully parallel. */
744
- let oauthQueue: Promise<unknown> = Promise.resolve()
745
-
746
- function serializeInteractiveOAuth<T>(run: () => Promise<T>): Promise<T> {
747
- const result = oauthQueue.then(run, run)
748
- oauthQueue = result.then(
749
- () => {},
750
- () => {},
751
- )
752
- return result
753
- }
754
-
755
- /**
756
- * Connect an http-family server, running Claude's OAuth login when the server
757
- * demands one. Stored tokens ride the first attempt so the SDK refreshes
758
- * silently; a 401 without tokens asks the user, opens the browser, catches the
759
- * loopback redirect, and exchanges the code via the SDK's finishAuth.
760
- * Bearer-token servers never enter the OAuth path: an explicit token is the
761
- * user saying how auth works.
762
- */
763
- async function connectHttpFamily(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, bearerToken: string | undefined, authUi: AuthUi | undefined): Promise<Client> {
764
- const newClient = () => new Client({ name: 'pi-code-mcp', version: '0.1.0' })
765
- // Stored tokens ride the first attempt so the SDK refreshes them; with none, no
766
- // provider is attached, so a 401 surfaces as a transport error carrying code 401
767
- // (isUnauthorized detects it) and only the interactive provider below ever runs
768
- // dynamic registration, keeping it bound to the real callback port.
769
- const silent = bearerToken ? undefined : new FileOAuthProvider(name, () => {})
770
- try {
771
- const client = newClient()
772
- await connectWithTimeout(client, makeTransport(silent?.hasTokens() ? silent : undefined), label)
773
- return client
774
- } catch (error) {
775
- if (bearerToken || !isUnauthorized(error)) throw error
776
- if (!authUi) throw new OAuthRequiredError(`${name} requires a login; run pi interactively to authenticate`)
777
- return await serializeInteractiveOAuth(() => runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient))
778
- }
779
- }
780
-
781
- /**
782
- * The interactive half of the OAuth login, reached only once a silent connect has
783
- * failed with a 401 and a UI is present: confirm, open the browser, catch the loopback
784
- * redirect, and exchange the code via the SDK's finishAuth. Past the confirm the server
785
- * is known to need OAuth, so any failure here (a denied consent page, the 180s wait, a
786
- * token exchange error) is wrapped as an auth failure, not a transport mismatch: that
787
- * keeps the typeless-url caller from retrying over SSE and prompting for a second login.
788
- */
789
- async function runInteractiveOAuth(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, authUi: AuthUi, newClient: () => Client): Promise<Client> {
790
- const approved = await authUi.confirm(`MCP server "${name}" requires login`, `Open your browser to authorize ${config.url}?`)
791
- if (!approved) throw new OAuthRequiredError(`login declined for ${name}`)
792
- const provider = new FileOAuthProvider(name, (authorizationUrl) => {
793
- openBrowser(String(authorizationUrl))
794
- authUi.notify(`Authorize "${name}" in the browser. If it did not open: ${authorizationUrl}`, 'info')
795
- })
796
- const { server, port } = await startCallbackServer(provider.savedRedirectPort())
797
- provider.bindRedirectPort(port)
798
- try {
799
- const transport = makeTransport(provider)
800
- // Verify the redirect echoes this login's state, so a stray or forged callback to the
801
- // loopback port cannot inject a code or abort the login (see waitForAuthCode).
802
- const pendingCode = waitForAuthCode(server, OAUTH_FLOW_TIMEOUT_MS, provider.state())
803
- pendingCode.catch(() => {}) // consumed below; an abandoned login must not surface as unhandled
804
- const client = newClient()
805
- try {
806
- await connectWithTimeout(client, transport, label)
807
- return client // authorized between attempts; nothing left to exchange
808
- } catch (retryError) {
809
- if (!isUnauthorized(retryError)) throw retryError
810
- const code = await pendingCode
811
- await transport.finishAuth(code)
812
- const authed = newClient()
813
- await connectWithTimeout(authed, makeTransport(provider), label)
814
- return authed
815
- }
816
- } catch (flowError) {
817
- throw asOAuthRequiredError(name, flowError)
818
- } finally {
819
- server.close()
820
- }
821
- }
822
-
823
- /**
824
- * Connect with a deadline, closing the client if the deadline (not a connect error) wins.
825
- * Without this, a slow-but-successful server finishes connecting after the race is lost and
826
- * lingers unreferenced: process/socket alive, never in `clients`, invisible to shutdown.
827
- */
828
- async function connectWithTimeout(client: Client, transport: Parameters<Client['connect']>[0], label: string): Promise<void> {
829
- const connecting = client.connect(transport)
830
- try {
831
- await withTimeout(connecting, connectTimeoutMs(), label)
832
- } catch (error) {
833
- // Only a timeout can orphan a still-opening transport; a connect rejection means the
834
- // SDK already tore it down, so closing again would be redundant.
835
- if (String(error).includes('timed out after')) {
836
- connecting.catch(() => {}) // a late rejection must not surface as unhandled
837
- void client.close().catch(() => {})
838
- }
839
- throw error
840
- }
841
- }
842
-
843
- export interface McpToolInfo {
844
- name: string
845
- description?: string
846
- inputSchema?: unknown
847
- }
848
-
849
- async function listAllTools(client: Client): Promise<McpToolInfo[]> {
850
- const tools: McpToolInfo[] = []
851
- let cursor: string | undefined
852
- do {
853
- const page = await client.listTools({ cursor })
854
- tools.push(...page.tools)
855
- cursor = page.nextCursor
856
- } while (cursor)
857
- return tools
858
- }
859
-
860
- async function listAllPrompts(client: Client): Promise<McpPromptInfo[]> {
861
- const prompts: McpPromptInfo[] = []
862
- let cursor: string | undefined
863
- do {
864
- const page = await client.listPrompts({ cursor })
865
- prompts.push(...page.prompts)
866
- cursor = page.nextCursor
867
- } while (cursor)
868
- return prompts
869
- }
870
-
871
- /** One resource's flat record for the list_mcp_resources output, dropping the optional
872
- * description/mimeType when the server omits them. */
873
- function resourceEntry(server: string, resource: { uri: string; name: string; description?: string; mimeType?: string }): Record<string, unknown> {
874
- return { server, uri: resource.uri, name: resource.name, ...(resource.description ? { description: resource.description } : {}), ...(resource.mimeType ? { mimeType: resource.mimeType } : {}) }
875
- }
876
-
877
- /** One resource template's flat record, likewise dropping absent optional fields. */
878
- function resourceTemplateEntry(server: string, template: { uriTemplate: string; name: string; description?: string; mimeType?: string }): Record<string, unknown> {
879
- return { server, uriTemplate: template.uriTemplate, name: template.name, ...(template.description ? { description: template.description } : {}), ...(template.mimeType ? { mimeType: template.mimeType } : {}) }
880
- }
881
-
882
- /** Page a server's resources to exhaustion under the call budget, appending each as a
883
- * flat record. Pushed into the caller's array incrementally so a mid-pagination failure
884
- * still leaves the earlier pages in place. */
885
- async function collectResources(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
886
- let cursor: string | undefined
887
- do {
888
- const page = await withTimeout(client.listResources({ cursor }, callRequestOptions(budget)), budget, `list resources ${name}`)
889
- for (const resource of page.resources) entries.push(resourceEntry(name, resource))
890
- cursor = page.nextCursor
891
- } while (cursor)
892
- }
893
-
894
- /** Page a server's resource templates to exhaustion under the call budget. */
895
- async function collectResourceTemplates(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
896
- let cursor: string | undefined
897
- do {
898
- const page = await withTimeout(client.listResourceTemplates({ cursor }, callRequestOptions(budget)), budget, `list resource templates ${name}`)
899
- for (const template of page.resourceTemplates) entries.push(resourceTemplateEntry(name, template))
900
- cursor = page.nextCursor
901
- } while (cursor)
902
- }
903
-
904
- /** Append every resource and template one server exposes. A resource-listing failure
905
- * surfaces inline as an error record, so one server cannot empty the whole listing; a
906
- * template-listing failure is silent, templates being optional (a server with the
907
- * resources capability but no templates answers method-not-found). */
908
- async function collectServerResourceEntries(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
909
- try {
910
- await collectResources(entries, name, client, budget)
911
- } catch (error) {
912
- entries.push({ server: name, error: error instanceof Error ? error.message : String(error) })
913
- }
914
- try {
915
- await collectResourceTemplates(entries, name, client, budget)
916
- } catch {
917
- // Templates are optional: a method-not-found here is not worth reporting.
918
- }
919
- }
920
-
921
- /** The optional server-name filter for list_mcp_resources: a non-empty string, else undefined. */
922
- function resourceServerFilter(params: unknown): string | undefined {
923
- const server = (params as { server?: unknown }).server
924
- return typeof server === 'string' && server.length > 0 ? server : undefined
925
- }
926
-
927
- export default async function mcpExtension(pi: ExtensionAPI) {
928
- const clients = new Map<string, Client>()
929
- const status = new Map<string, { state: string; tools: number }>()
930
- // Let other extensions (hooks' mcp_tool type) call a connected server's tool.
931
- setMcpToolCaller(async (server, tool, input) => {
932
- const client = clients.get(server)
933
- if (!client) throw new Error(`MCP server "${server}" is not connected`)
934
- const result = await client.callTool({ name: tool, arguments: input }, undefined, callRequestOptions(callTimeoutMs()))
935
- const text = mapContent(result.content as McpContentBlock[], result.structuredContent)
936
- .filter((part): part is { type: 'text'; text: string } => part.type === 'text')
937
- .map((part) => part.text)
938
- .join('\n')
939
- return { text, isError: result.isError === true }
940
- })
941
- // pi tool name -> owning server, so a refresh can tell its own tools from a conflict.
942
- const registered = new Map<string, string>()
943
- // Original server/tool names per registered pi name, for Claude-style hook matchers.
944
- const aliases: McpToolAlias[] = []
945
-
946
- /** How many tools a server actually has registered. Counted from `registered` (the
947
- * durable owner map) rather than registerTools' return, so a reconnect on a second
948
- * session, where every tool is already registered and registerTools adds 0, still
949
- * reports the true count in /mcp and the startup banner instead of zero. */
950
- const serverToolCount = (name: string): number => [...registered.values()].filter((owner) => owner === name).length
951
-
952
- /** Register every not-yet-registered tool of a server; returns how many were added. */
953
- function registerTools(name: string, config: ServerConfig, tools: McpToolInfo[]): number {
954
- let count = 0
955
- for (const tool of tools) {
956
- const toolName = formatToolName(name, tool.name)
957
- const owner = registered.get(toolName)
958
- if (owner === name) continue // already registered for this server: a refresh re-listing it
959
- if (RESERVED_NAMES.has(toolName) || owner !== undefined) {
960
- console.warn(`pi-code-mcp: skipping colliding tool name ${toolName}`)
961
- continue
962
- }
963
- registered.set(toolName, name)
964
- aliases.push({ pi: toolName, claude: config.aliasPrefix ? `${config.aliasPrefix}${tool.name}` : `mcp__${name}__${tool.name}` })
965
- count++
966
- pi.registerTool({
967
- name: toolName,
968
- label: `${name}: ${tool.name}`,
969
- description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
970
- parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
971
- async execute(_id, params) {
972
- // Resolve the live client by name at call time rather than capturing the one
973
- // present at registration: pi has no tool unregister, so after a server drops
974
- // and a later session_start reconnects it, registerTools skips re-registration
975
- // and this closure would otherwise keep calling the old, closed client.
976
- const current = clients.get(name)
977
- if (!current) throw new Error(`MCP server "${name}" is not connected`)
978
- // The per-server timeout (Claude's, 1s floor) or MCP_TOOL_TIMEOUT is the
979
- // wall-clock ceiling; callRequestOptions layers the idle timeout under it, which
980
- // the SDK enforces (resetting on progress). Pass the options to the SDK too: its
981
- // own default request timeout is 60s and would otherwise reject first. The outer
982
- // race uses the wall budget, never the idle window, so a progressing call is not
983
- // cut off at the idle timeout.
984
- const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
985
- const wall = declared ?? callTimeoutMs()
986
- const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, callRequestOptions(wall)), wall, toolName)
987
- const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
988
- const details: { error?: string } = {}
989
- if (result.isError) {
990
- details.error = 'tool_error'
991
- const hint = JSON.stringify(normalizeSchema(tool.inputSchema))
992
- content.push({ type: 'text', text: capForContext(`Tool reported an error. Expected input schema: ${hint}`) })
993
- }
994
- return { content, details }
995
- },
996
- })
997
- }
998
- return count
999
- }
1000
-
1001
- // Prompt command name -> the server and prompt that own it, so a refresh re-listing
1002
- // the same prompt is told apart both from a cross-server collision and from a second
1003
- // prompt on the same server whose name normalizes to the one already taken (e.g.
1004
- // `deploy-prod` and `deploy_prod`), mirroring `registered` for tools.
1005
- const registeredPrompts = new Map<string, { server: string; prompt: string }>()
1006
-
1007
- /** Register a slash command for every not-yet-registered prompt of a server. pi has
1008
- * no command unregister, so, like tools, a withdrawn prompt keeps its registration
1009
- * and surfaces the server's own error when invoked; an edit to a prompt's declared
1010
- * arguments only lands on new names, since an existing command keeps its binding. */
1011
- function registerPrompts(name: string, prompts: McpPromptInfo[]): void {
1012
- for (const prompt of prompts) {
1013
- const commandName = formatPromptCommandName(name, prompt.name)
1014
- const owner = registeredPrompts.get(commandName)
1015
- if (owner) {
1016
- if (owner.server === name && owner.prompt === prompt.name) continue // a refresh re-listing the same prompt
1017
- console.warn(`pi-code-mcp: skipping colliding prompt command ${commandName}`)
1018
- continue
1019
- }
1020
- registeredPrompts.set(commandName, { server: name, prompt: prompt.name })
1021
- const hint = (prompt.arguments ?? []).map((argument) => (argument.required ? `<${argument.name}>` : `[${argument.name}]`)).join(' ')
1022
- const base = prompt.description ?? `MCP prompt ${prompt.name} from ${name}`
1023
- pi.registerCommand(commandName, {
1024
- description: hint ? `${base} ${hint}` : base,
1025
- handler: async (args, ctx) => {
1026
- try {
1027
- // Resolve the live client at call time, not the one captured at registration:
1028
- // pi has no command unregister, so after a reconnect this closure must not keep
1029
- // calling the old, closed client (see registerTools for the same reason).
1030
- const current = clients.get(name)
1031
- if (!current) {
1032
- ctx.ui.notify(`${commandName}: MCP server "${name}" is not connected`, 'error')
1033
- return
1034
- }
1035
- const promptArgs = mapPromptArguments(prompt.arguments, args)
1036
- const params: { name: string; arguments?: Record<string, string> } = { name: prompt.name }
1037
- if (Object.keys(promptArgs).length > 0) params.arguments = promptArgs
1038
- const wall = callTimeoutMs()
1039
- const result = await withTimeout(current.getPrompt(params, callRequestOptions(wall)), wall, commandName)
1040
- // The prompt drives a turn exactly the way a custom slash command does
1041
- // (see commands.ts), carrying its image blocks through. A prompt that
1042
- // yields no content is reported rather than sent as an empty turn.
1043
- const content = promptMessageContent(result.messages)
1044
- if (content.length === 0) {
1045
- ctx.ui.notify(`${commandName}: prompt returned no content`, 'info')
1046
- return
1047
- }
1048
- // A bare send throws (and is silently swallowed) while the agent is
1049
- // streaming, so mid-stream invocations queue as a follow-up turn.
1050
- pi.sendUserMessage(content, ctx.isIdle() ? {} : { deliverAs: 'followUp' })
1051
- } catch (error) {
1052
- ctx.ui.notify(`${commandName}: ${error instanceof Error ? error.message : String(error)}`, 'error')
1053
- }
1054
- },
1055
- })
1056
- }
1057
- }
1058
-
1059
- /** Claude exposes prompts as slash commands only for servers advertising the
1060
- * prompts capability; a listing failure loses the prompts, not the server. */
1061
- async function connectPrompts(name: string, client: Client): Promise<void> {
1062
- if (!client.getServerCapabilities()?.prompts) return
1063
- try {
1064
- registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
1065
- } catch (error) {
1066
- console.warn(`pi-code-mcp: prompt listing failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
1067
- }
1068
- }
1069
-
1070
- /** Mirror of subscribeToToolChanges for the prompt list: a newly announced prompt
1071
- * registers without a restart, a withdrawn one keeps its registration. */
1072
- function subscribeToPromptChanges(name: string, client: Client): void {
1073
- try {
1074
- client.setNotificationHandler(PromptListChangedNotificationSchema, async () => {
1075
- try {
1076
- registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
1077
- } catch (error) {
1078
- console.warn(`pi-code-mcp: prompt refresh failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
1079
- }
1080
- })
1081
- } catch {
1082
- // a transport or client without notification support simply never refreshes
1083
- }
1084
- }
1085
-
1086
- /** Servers currently connected that advertise the resources capability. */
1087
- const resourceServers = (): Array<[string, Client]> => [...clients.entries()].filter(([, client]) => Boolean(client.getServerCapabilities()?.resources))
1088
-
1089
- let resourceToolsRegistered = false
1090
-
1091
- /** Claude auto-provides tools to list and read MCP resources when servers support
1092
- * them. Registered once, globally, the first time a connected server advertises the
1093
- * resources capability: the tools span servers, taking the server name as an
1094
- * argument, so per-server registration would only produce duplicates. Listings are
1095
- * fetched live on every call, so a resources list_changed needs no cache
1096
- * invalidation; its handler only re-checks this gate (see subscribeToResourceChanges). */
1097
- function ensureResourceTools(): void {
1098
- if (resourceToolsRegistered || resourceServers().length === 0) return
1099
- resourceToolsRegistered = true
1100
- pi.registerTool({
1101
- name: 'list_mcp_resources',
1102
- label: 'List MCP resources',
1103
- description: 'List available resources and resource templates from connected MCP servers. Optionally filter to a single server by name.',
1104
- parameters: Type.Object({ server: Type.Optional(Type.String({ description: 'Only list resources from this server' })) }),
1105
- async execute(_id, params) {
1106
- const filter = resourceServerFilter(params)
1107
- if (filter && !clients.has(filter)) throw new Error(`MCP server "${filter}" is not connected`)
1108
- const entries: Array<Record<string, unknown>> = []
1109
- for (const [name, client] of resourceServers()) {
1110
- if (filter && name !== filter) continue
1111
- await collectServerResourceEntries(entries, name, client, callTimeoutMs())
1112
- }
1113
- return { content: mapContent([{ type: 'text', text: JSON.stringify(entries, null, 2) }]), details: {} }
1114
- },
1115
- })
1116
- pi.registerTool({
1117
- name: 'read_mcp_resource',
1118
- label: 'Read MCP resource',
1119
- description: 'Read a resource from a connected MCP server by URI.',
1120
- parameters: Type.Object({ server: Type.String({ description: 'The MCP server name' }), uri: Type.String({ description: 'The resource URI to read' }) }),
1121
- async execute(_id, params) {
1122
- const { server, uri } = params as { server: string; uri: string }
1123
- const client = clients.get(server)
1124
- if (!client) throw new Error(`MCP server "${server}" is not connected`)
1125
- const wall = callTimeoutMs()
1126
- const result = await withTimeout(client.readResource({ uri }, callRequestOptions(wall)), wall, `read ${uri}`)
1127
- const blocks = (result.contents as Array<{ uri: string; text?: string; blob?: string; mimeType?: string }>).map((entry): McpContentBlock => {
1128
- if (typeof entry.text === 'string') return { type: 'resource', resource: { uri: entry.uri, text: entry.text } }
1129
- if (entry.blob && entry.mimeType?.startsWith('image/')) return { type: 'image', data: entry.blob, mimeType: entry.mimeType }
1130
- // Non-image binary has no useful text form; a placeholder beats megabytes
1131
- // of base64 reaching the model as JSON.
1132
- return { type: 'text', text: `[Binary resource ${entry.uri} (${entry.mimeType ?? 'unknown type'})]` }
1133
- })
1134
- return { content: mapContent(blocks), details: {} }
1135
- },
1136
- })
1137
- }
1138
-
1139
- /** Resource listings are fetched live per call, so the notification has no cache to
1140
- * invalidate; re-checking the registration gate covers a server whose capabilities
1141
- * settled after the connect-time check. */
1142
- function subscribeToResourceChanges(client: Client): void {
1143
- try {
1144
- client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
1145
- ensureResourceTools()
1146
- })
1147
- } catch {
1148
- // a transport or client without notification support simply never refreshes
1149
- }
1150
- }
1151
-
1152
- /** Claude refreshes tools on a server's list_changed notification. pi has no
1153
- * unregister, so a withdrawn tool keeps its registration and surfaces the server's
1154
- * own error when called; a newly announced one is registered without a restart. */
1155
- function subscribeToToolChanges(name: string, config: ServerConfig, client: Client): void {
1156
- try {
1157
- client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
1158
- try {
1159
- const refreshed = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
1160
- const added = registerTools(name, config, refreshed)
1161
- if (added === 0) return
1162
- const current = status.get(name)
1163
- status.set(name, { state: current?.state ?? 'connected', tools: serverToolCount(name) })
1164
- pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
1165
- } catch (error) {
1166
- console.warn(`pi-code-mcp: tool refresh failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
1167
- }
1168
- })
1169
- } catch {
1170
- // a transport or client without notification support simply never refreshes
1171
- }
1172
- }
1173
-
1174
- async function connectServers(servers: Record<string, ServerConfig>, authUi?: AuthUi): Promise<void> {
1175
- const pending: [string, ServerConfig][] = []
1176
- for (const [name, config] of Object.entries(servers)) {
1177
- // A later scope must not take the name of a server that already connected: it
1178
- // would evict that client from the map, leaking it at shutdown, and misreport
1179
- // the earlier server's status.
1180
- if (clients.has(name)) {
1181
- console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
1182
- continue
1183
- }
1184
- // Seed in config order before connecting: parallel connects settle in completion
1185
- // order, and /mcp plus the session summary iterate the map's insertion order.
1186
- status.set(name, { state: 'connecting', tools: 0 })
1187
- pending.push([name, config])
1188
- }
1189
- await Promise.all(
1190
- pending.map(async ([name, config]) => {
1191
- warnOnTypelessUrl(name, config)
1192
- try {
1193
- const client = await connect(name, config, authUi)
1194
- clients.set(name, client)
1195
- const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
1196
- registerTools(name, config, tools)
1197
- subscribeToToolChanges(name, config, client)
1198
- // Prompts and resources are additive surfaces: their failures warn (inside
1199
- // connectPrompts) rather than flipping a tool-serving server to failed.
1200
- await connectPrompts(name, client)
1201
- subscribeToPromptChanges(name, client)
1202
- ensureResourceTools()
1203
- subscribeToResourceChanges(client)
1204
- // Count from `registered`, not registerTools' return: a reconnect re-lists tools
1205
- // that are already registered (return 0) but still serves them, so the banner
1206
- // must reflect the true count.
1207
- status.set(name, { state: 'connected', tools: serverToolCount(name) })
1208
- // A server that dies mid-session would otherwise stay "connected" in /mcp
1209
- // while every call fails with the SDK's bare "Not connected"; flip the
1210
- // status and free the name so a later session start can reconnect it.
1211
- client.onclose = () => {
1212
- if (clients.get(name) !== client) return
1213
- clients.delete(name)
1214
- status.set(name, { state: 'disconnected', tools: 0 })
1215
- }
1216
- } catch (error) {
1217
- status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
1218
- // Connected but failed after (tool listing hung or errored): left in the
1219
- // map, the client idles its process for the whole session and the
1220
- // duplicate-name guard blocks the name for every later attempt.
1221
- const leaked = clients.get(name)
1222
- if (leaked) {
1223
- clients.delete(name)
1224
- void leaked.close().catch(() => {})
1225
- }
1226
- }
1227
- }),
1228
- )
1229
- }
1230
-
1231
- /** Connect the approval-gated project servers, behind the whole-project confirm.
1232
- * Returns whether the scope is settled, so a refused confirm can be retried on a
1233
- * later session start. The consented half of the project scope connects earlier,
1234
- * concurrently with the user scope, from session_start itself. */
1235
- async function connectGatedProjectServers(ctx: ExtensionContext, gated: Record<string, ServerConfig>, authUi?: AuthUi): Promise<boolean> {
1236
- if (Object.keys(gated).length === 0) return true
1237
- if (!(await isProjectApproved(ctx))) return false
1238
- await connectServers(gated, authUi)
1239
- return true
1240
- }
1241
-
1242
- let projectConnected = false
1243
-
1244
- /** managed-mcp.json exclusive mode: a policy deployed mid-process must not leave
1245
- * already-connected user/project servers running alongside the managed set. Evict every
1246
- * connected client not in the managed set (delete it from the map first so the onclose
1247
- * handler's guard sees it gone and does not overwrite the status, then close it
1248
- * best-effort and mark it disabled), then connect only the managed servers. */
1249
- async function connectManagedExclusive(managed: Record<string, ServerConfig>, allowed: Set<string> | null, denied: Set<string>, authUi?: AuthUi): Promise<void> {
1250
- const managedServers = applyServerPolicy(managed, allowed, denied)
1251
- const managedNames = new Set(Object.keys(managedServers))
1252
- for (const [name, client] of Array.from(clients.entries())) {
1253
- if (managedNames.has(name)) continue
1254
- clients.delete(name)
1255
- // Bound the close like session_shutdown does: a hung server must not stall the new
1256
- // session start, which awaits this eviction before connecting the managed set.
1257
- await withTimeout(client.close(), 3000, 'close').catch(() => {})
1258
- status.set(name, { state: 'disabled by managed policy', tools: 0 })
1259
- }
1260
- await connectServers(managedServers, authUi)
1261
- }
1262
-
1263
- /** The normal user + plugin + project scopes, when no managed-mcp.json is present.
1264
- * Connecting spawns processes and opens sockets, so it belongs here rather than in the
1265
- * factory: pi runs the factory for invocations that never start a session. Names still
1266
- * connected are filtered out, so a later session start only retries servers that failed
1267
- * or whose transport dropped, without duplicate-name warnings. */
1268
- async function connectNormalScopes(ctx: ExtensionContext, allowed: Set<string> | null, denied: Set<string>, authUi?: AuthUi): Promise<void> {
1269
- // Plugin servers merge under the user scope (plugins are user-installed);
1270
- // the user's own entry wins a name clash with a plugin's.
1271
- const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
1272
- const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, allowed, denied)
1273
- // Claude's precedence is project over user for a duplicate name. A project .mcp.json
1274
- // server only outranks the user's own when it will actually connect (the user already
1275
- // consented to it, or an approved project's), so a merely-present untrusted project
1276
- // entry cannot shadow a trusted user server by reusing its name. A gated project
1277
- // server still awaiting the approval prompt does not preempt the user server: that is
1278
- // a deliberate narrowing of Claude's rule to keep the safe default.
1279
- // The stored project decision, read without prompting: consent recorded inside
1280
- // the project only counts once the project itself has been approved.
1281
- const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
1282
- const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy)
1283
- const projectWinners = new Set(Object.keys(consented))
1284
- const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
1285
- // The consented project servers carry no ordering dependency on the user scope:
1286
- // projectWinners already excludes their names from userServers, so the two batches
1287
- // are disjoint and connect concurrently, and startup pays the slower scope rather
1288
- // than the sum of both. Reconnect attempts after a refused confirm are safe:
1289
- // connectServers skips names that already connected.
1290
- const connects: Promise<void>[] = []
1291
- if (Object.keys(userServers).length > 0) connects.push(connectServers(userServers, authUi))
1292
- if (!projectConnected && Object.keys(consented).length > 0) connects.push(connectServers(consented, authUi))
1293
- await Promise.all(connects)
1294
- // A project .mcp.json can run arbitrary commands on connect, so only honor it once
1295
- // the project is trusted. Per-server settings refine that: disabled servers never
1296
- // connect, servers the user consented to individually connected above without the
1297
- // whole-project confirm, and the rest stay behind it, sequentially after both
1298
- // scopes so the confirm dialog never races a connect.
1299
- if (!projectConnected) projectConnected = await connectGatedProjectServers(ctx, gated, authUi)
1300
- }
1301
-
1302
- pi.on('session_start', async (_event, ctx) => {
1303
- // Reset the status map so /mcp and the banner reflect only this session's config: a
1304
- // server present last session but not this one must not linger as "connected". The
1305
- // registered tools, aliases, and prompt commands stay: pi has no unregister (a
1306
- // withdrawn tool keeps its registration and surfaces the server's own error), which
1307
- // is why serverToolCount reads from `registered` to recover the true count here.
1308
- status.clear()
1309
- const authUi = authUiFor(ctx)
1310
- // The managed allow/deny lists filter every scope, including a managed-mcp.json set.
1311
- const { allowed, denied } = mcpAllowDeny()
1312
- // managed-mcp.json (beside managed-settings.json) takes exclusive control when present:
1313
- // only its servers load, and the user, project, and plugin scopes plus the whole
1314
- // project-approval flow below are skipped. An empty map disables MCP entirely. An absent
1315
- // file leaves the normal scopes untouched; a present but corrupt file fails closed to an
1316
- // empty set (see loadManagedMcpServers).
1317
- const managed = loadManagedMcpServers()
1318
- if (managed !== null) {
1319
- await connectManagedExclusive(managed, allowed, denied, authUi)
1320
- } else {
1321
- await connectNormalScopes(ctx, allowed, denied, authUi)
1322
- }
1323
-
1324
- pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
1325
-
1326
- const connected = [...status.values()].filter((s) => s.state === 'connected')
1327
- const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected')
1328
- if (connected.length > 0 || failed.length > 0) {
1329
- const total = connected.reduce((sum, s) => sum + s.tools, 0)
1330
- const failNote = failed.length > 0 ? `, ${failed.length} failed` : ''
1331
- ctx.ui.notify(`MCP: ${total} tools from ${connected.length} servers${failNote}`, failed.length > 0 ? 'warning' : 'info')
1332
- }
1333
- })
1334
-
1335
- pi.on('session_shutdown', async () => {
1336
- // Close in parallel with a per-client timeout so one hung server can't stall pi's exit.
1337
- await Promise.all([...clients.values()].map((client) => withTimeout(client.close(), 3000, 'close').catch(() => {})))
1338
- // Drop the closed clients and their status now rather than waiting on each client's
1339
- // onclose, which the SDK fires late: a same-process session switch (/new, /resume,
1340
- // /fork) runs the next session_start right after this, and a lingering dead client
1341
- // there would make connectServers skip reconnecting the name, stranding every tool
1342
- // closure on a closed client. session_start resets status too, so a switch rebuilds it.
1343
- clients.clear()
1344
- status.clear()
1345
- })
1346
-
1347
- pi.registerCommand('mcp', {
1348
- description: 'Show MCP server status and tools',
1349
- handler: async (_args, ctx) => {
1350
- if (status.size === 0) {
1351
- ctx.ui.notify('No MCP servers configured. Add them to .mcp.json, .pi/mcp.json, ~/.claude.json, or ~/.pi/agent/mcp.json', 'info')
1352
- return
1353
- }
1354
- const lines = [...status.entries()].map(([name, s]) => `${name}: ${s.state} (${s.tools} tools)`)
1355
- ctx.ui.notify(lines.join('\n'), 'info')
1356
- },
1357
- })
1358
- }