pi-code 1.0.13 → 1.0.15

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.
@@ -0,0 +1,513 @@
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 * as os from 'node:os'
35
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
36
+ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
37
+ import { PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
38
+ import { Type } from 'typebox'
39
+ import { MCP_TOOLS_CHANNEL, type McpToolAlias } from '../internal/mcp-alias.js'
40
+ import { setMcpToolCaller } from '../internal/mcp-call.js'
41
+ import { capForContext } from '../internal/output-guard.js'
42
+ import { installedPlugins } from '../internal/plugins.js'
43
+ import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
44
+ import { loadConfigFrom, loadPluginServers, loadUserScope, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
45
+ import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
46
+ import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
47
+ import { applyServerPolicy, loadManagedMcpServers, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
48
+ import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, withTimeout } from './transport.js'
49
+
50
+ export { managedSettingsPath, setManagedSettingsPath } from '../internal/managed-settings.js'
51
+ // Re-exports for consumers: the module split keeps the extension's public surface
52
+ // (imported by the test suite) reachable from this entry point unchanged. The managed
53
+ // settings path helpers now live in the shared internal module.
54
+ export type { HttpServerConfig, ServerConfig, StdioServerConfig } from './config.js'
55
+ export { expandCwd, interpolateEnv, loadConfigFrom, loadPluginServers, loadUserScope, projectConfigPaths, userConfigPaths, warnOnTypelessUrl } from './config.js'
56
+ export type { McpToolInfo } from './listing.js'
57
+ export type { McpPromptArgumentInfo, McpPromptInfo, ToolContent } from './mapping.js'
58
+ export { capTotal, formatPromptCommandName, formatToolName, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
59
+ export type { ProjectServerPolicy } from './policy.js'
60
+ export { applyServerPolicy, loadManagedMcpServers, managedMcpPath, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
61
+ export type { AuthUi } from './transport.js'
62
+ export { parseHelperHeaders, resolveBearerToken } from './transport.js'
63
+
64
+ // Tool names an MCP server must never take over. formatToolName always emits
65
+ // `<server>_<tool>`, so only names containing an underscore are actually reachable:
66
+ // pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
67
+ // These are pi-code's own tools, and mcp.ts registers before the extensions owning
68
+ // them, so without this guard a server named `web` would replace the SSRF-checked fetch.
69
+ // The resource tools are this extension's own globals; a server named `list` or `read`
70
+ // must not take their names either.
71
+ const RESERVED_NAMES = new Set(['web_fetch', 'web_search', 'plan_mode_complete', 'list_mcp_resources', 'read_mcp_resource'])
72
+
73
+ /** The OAuth flow's UI seams, absent in headless runs. */
74
+ function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
75
+ if (!ctx.hasUI) return undefined
76
+ return {
77
+ confirm: (title, body) => ctx.ui.confirm(title, body),
78
+ notify: (message, level) => ctx.ui.notify(message, level),
79
+ }
80
+ }
81
+
82
+ export default async function mcpExtension(pi: ExtensionAPI) {
83
+ const clients = new Map<string, Client>()
84
+ const status = new Map<string, { state: string; tools: number }>()
85
+ // Let other extensions (hooks' mcp_tool type) call a connected server's tool.
86
+ setMcpToolCaller(async (server, tool, input) => {
87
+ const client = clients.get(server)
88
+ if (!client) throw new Error(`MCP server "${server}" is not connected`)
89
+ const result = await client.callTool({ name: tool, arguments: input }, undefined, callRequestOptions(callTimeoutMs()))
90
+ const text = mapContent(result.content as McpContentBlock[], result.structuredContent)
91
+ .filter((part): part is { type: 'text'; text: string } => part.type === 'text')
92
+ .map((part) => part.text)
93
+ .join('\n')
94
+ return { text, isError: result.isError === true }
95
+ })
96
+ // pi tool name -> owning server, so a refresh can tell its own tools from a conflict.
97
+ const registered = new Map<string, string>()
98
+ // Original server/tool names per registered pi name, for Claude-style hook matchers.
99
+ const aliases: McpToolAlias[] = []
100
+
101
+ /** How many tools a server actually has registered. Counted from `registered` (the
102
+ * durable owner map) rather than registerTools' return, so a reconnect on a second
103
+ * session, where every tool is already registered and registerTools adds 0, still
104
+ * reports the true count in /mcp and the startup banner instead of zero. */
105
+ const serverToolCount = (name: string): number => [...registered.values()].filter((owner) => owner === name).length
106
+
107
+ /** Register every not-yet-registered tool of a server; returns how many were added. */
108
+ function registerTools(name: string, config: ServerConfig, tools: McpToolInfo[]): number {
109
+ let count = 0
110
+ for (const tool of tools) {
111
+ const toolName = formatToolName(name, tool.name)
112
+ const owner = registered.get(toolName)
113
+ if (owner === name) continue // already registered for this server: a refresh re-listing it
114
+ if (RESERVED_NAMES.has(toolName) || owner !== undefined) {
115
+ console.warn(`pi-code-mcp: skipping colliding tool name ${toolName}`)
116
+ continue
117
+ }
118
+ registered.set(toolName, name)
119
+ aliases.push({ pi: toolName, claude: config.aliasPrefix ? `${config.aliasPrefix}${tool.name}` : `mcp__${name}__${tool.name}` })
120
+ count++
121
+ pi.registerTool({
122
+ name: toolName,
123
+ label: `${name}: ${tool.name}`,
124
+ description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
125
+ parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
126
+ async execute(_id, params) {
127
+ // Resolve the live client by name at call time rather than capturing the one
128
+ // present at registration: pi has no tool unregister, so after a server drops
129
+ // and a later session_start reconnects it, registerTools skips re-registration
130
+ // and this closure would otherwise keep calling the old, closed client.
131
+ const current = clients.get(name)
132
+ if (!current) throw new Error(`MCP server "${name}" is not connected`)
133
+ // The per-server timeout (Claude's, 1s floor) or MCP_TOOL_TIMEOUT is the
134
+ // wall-clock ceiling; callRequestOptions layers the idle timeout under it, which
135
+ // the SDK enforces (resetting on progress). Pass the options to the SDK too: its
136
+ // own default request timeout is 60s and would otherwise reject first. The outer
137
+ // race uses the wall budget, never the idle window, so a progressing call is not
138
+ // cut off at the idle timeout.
139
+ const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
140
+ const wall = declared ?? callTimeoutMs()
141
+ const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, callRequestOptions(wall)), wall, toolName)
142
+ const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
143
+ const details: { error?: string } = {}
144
+ if (result.isError) {
145
+ details.error = 'tool_error'
146
+ const hint = JSON.stringify(normalizeSchema(tool.inputSchema))
147
+ content.push({ type: 'text', text: capForContext(`Tool reported an error. Expected input schema: ${hint}`) })
148
+ }
149
+ return { content, details }
150
+ },
151
+ })
152
+ }
153
+ return count
154
+ }
155
+
156
+ // Prompt command name -> the server and prompt that own it, so a refresh re-listing
157
+ // the same prompt is told apart both from a cross-server collision and from a second
158
+ // prompt on the same server whose name normalizes to the one already taken (e.g.
159
+ // `deploy-prod` and `deploy_prod`), mirroring `registered` for tools.
160
+ const registeredPrompts = new Map<string, { server: string; prompt: string }>()
161
+
162
+ /** Register a slash command for every not-yet-registered prompt of a server. pi has
163
+ * no command unregister, so, like tools, a withdrawn prompt keeps its registration
164
+ * and surfaces the server's own error when invoked; an edit to a prompt's declared
165
+ * arguments only lands on new names, since an existing command keeps its binding. */
166
+ function registerPrompts(name: string, prompts: McpPromptInfo[]): void {
167
+ for (const prompt of prompts) {
168
+ const commandName = formatPromptCommandName(name, prompt.name)
169
+ const owner = registeredPrompts.get(commandName)
170
+ if (owner) {
171
+ if (owner.server === name && owner.prompt === prompt.name) continue // a refresh re-listing the same prompt
172
+ console.warn(`pi-code-mcp: skipping colliding prompt command ${commandName}`)
173
+ continue
174
+ }
175
+ registeredPrompts.set(commandName, { server: name, prompt: prompt.name })
176
+ const hint = (prompt.arguments ?? []).map((argument) => (argument.required ? `<${argument.name}>` : `[${argument.name}]`)).join(' ')
177
+ const base = prompt.description ?? `MCP prompt ${prompt.name} from ${name}`
178
+ pi.registerCommand(commandName, {
179
+ description: hint ? `${base} ${hint}` : base,
180
+ handler: async (args, ctx) => {
181
+ try {
182
+ // Resolve the live client at call time, not the one captured at registration:
183
+ // pi has no command unregister, so after a reconnect this closure must not keep
184
+ // calling the old, closed client (see registerTools for the same reason).
185
+ const current = clients.get(name)
186
+ if (!current) {
187
+ ctx.ui.notify(`${commandName}: MCP server "${name}" is not connected`, 'error')
188
+ return
189
+ }
190
+ const promptArgs = mapPromptArguments(prompt.arguments, args)
191
+ const params: { name: string; arguments?: Record<string, string> } = { name: prompt.name }
192
+ if (Object.keys(promptArgs).length > 0) params.arguments = promptArgs
193
+ const wall = callTimeoutMs()
194
+ const result = await withTimeout(current.getPrompt(params, callRequestOptions(wall)), wall, commandName)
195
+ // The prompt drives a turn exactly the way a custom slash command does
196
+ // (see commands.ts), carrying its image blocks through. A prompt that
197
+ // yields no content is reported rather than sent as an empty turn.
198
+ const content = promptMessageContent(result.messages)
199
+ if (content.length === 0) {
200
+ ctx.ui.notify(`${commandName}: prompt returned no content`, 'info')
201
+ return
202
+ }
203
+ // A bare send throws (and is silently swallowed) while the agent is
204
+ // streaming, so mid-stream invocations queue as a follow-up turn.
205
+ pi.sendUserMessage(content, ctx.isIdle() ? {} : { deliverAs: 'followUp' })
206
+ } catch (error) {
207
+ ctx.ui.notify(`${commandName}: ${error instanceof Error ? error.message : String(error)}`, 'error')
208
+ }
209
+ },
210
+ })
211
+ }
212
+ }
213
+
214
+ /** Claude exposes prompts as slash commands only for servers advertising the
215
+ * prompts capability; a listing failure loses the prompts, not the server. */
216
+ async function connectPrompts(name: string, client: Client): Promise<void> {
217
+ if (!client.getServerCapabilities()?.prompts) return
218
+ try {
219
+ registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
220
+ } catch (error) {
221
+ console.warn(`pi-code-mcp: prompt listing failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
222
+ }
223
+ }
224
+
225
+ /** Mirror of subscribeToToolChanges for the prompt list: a newly announced prompt
226
+ * registers without a restart, a withdrawn one keeps its registration. */
227
+ function subscribeToPromptChanges(name: string, client: Client): void {
228
+ try {
229
+ client.setNotificationHandler(PromptListChangedNotificationSchema, async () => {
230
+ try {
231
+ registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
232
+ } catch (error) {
233
+ console.warn(`pi-code-mcp: prompt refresh failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
234
+ }
235
+ })
236
+ } catch {
237
+ // a transport or client without notification support simply never refreshes
238
+ }
239
+ }
240
+
241
+ /** Servers currently connected that advertise the resources capability. */
242
+ const resourceServers = (): Array<[string, Client]> => [...clients.entries()].filter(([, client]) => Boolean(client.getServerCapabilities()?.resources))
243
+
244
+ let resourceToolsRegistered = false
245
+
246
+ /** Claude auto-provides tools to list and read MCP resources when servers support
247
+ * them. Registered once, globally, the first time a connected server advertises the
248
+ * resources capability: the tools span servers, taking the server name as an
249
+ * argument, so per-server registration would only produce duplicates. Listings are
250
+ * fetched live on every call, so a resources list_changed needs no cache
251
+ * invalidation; its handler only re-checks this gate (see subscribeToResourceChanges). */
252
+ function ensureResourceTools(): void {
253
+ if (resourceToolsRegistered || resourceServers().length === 0) return
254
+ resourceToolsRegistered = true
255
+ pi.registerTool({
256
+ name: 'list_mcp_resources',
257
+ label: 'List MCP resources',
258
+ description: 'List available resources and resource templates from connected MCP servers. Optionally filter to a single server by name.',
259
+ parameters: Type.Object({ server: Type.Optional(Type.String({ description: 'Only list resources from this server' })) }),
260
+ async execute(_id, params) {
261
+ const filter = resourceServerFilter(params)
262
+ if (filter && !clients.has(filter)) throw new Error(`MCP server "${filter}" is not connected`)
263
+ const entries: Array<Record<string, unknown>> = []
264
+ for (const [name, client] of resourceServers()) {
265
+ if (filter && name !== filter) continue
266
+ await collectServerResourceEntries(entries, name, client, callTimeoutMs())
267
+ }
268
+ return { content: mapContent([{ type: 'text', text: JSON.stringify(entries, null, 2) }]), details: {} }
269
+ },
270
+ })
271
+ pi.registerTool({
272
+ name: 'read_mcp_resource',
273
+ label: 'Read MCP resource',
274
+ description: 'Read a resource from a connected MCP server by URI.',
275
+ parameters: Type.Object({ server: Type.String({ description: 'The MCP server name' }), uri: Type.String({ description: 'The resource URI to read' }) }),
276
+ async execute(_id, params) {
277
+ const { server, uri } = params as { server: string; uri: string }
278
+ const client = clients.get(server)
279
+ if (!client) throw new Error(`MCP server "${server}" is not connected`)
280
+ const wall = callTimeoutMs()
281
+ const result = await withTimeout(client.readResource({ uri }, callRequestOptions(wall)), wall, `read ${uri}`)
282
+ const blocks = (result.contents as Array<{ uri: string; text?: string; blob?: string; mimeType?: string }>).map((entry): McpContentBlock => {
283
+ if (typeof entry.text === 'string') return { type: 'resource', resource: { uri: entry.uri, text: entry.text } }
284
+ if (entry.blob && entry.mimeType?.startsWith('image/')) return { type: 'image', data: entry.blob, mimeType: entry.mimeType }
285
+ // Non-image binary has no useful text form; a placeholder beats megabytes
286
+ // of base64 reaching the model as JSON.
287
+ return { type: 'text', text: `[Binary resource ${entry.uri} (${entry.mimeType ?? 'unknown type'})]` }
288
+ })
289
+ return { content: mapContent(blocks), details: {} }
290
+ },
291
+ })
292
+ }
293
+
294
+ /** Resource listings are fetched live per call, so the notification has no cache to
295
+ * invalidate; re-checking the registration gate covers a server whose capabilities
296
+ * settled after the connect-time check. */
297
+ function subscribeToResourceChanges(client: Client): void {
298
+ try {
299
+ client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
300
+ ensureResourceTools()
301
+ })
302
+ } catch {
303
+ // a transport or client without notification support simply never refreshes
304
+ }
305
+ }
306
+
307
+ /** Claude refreshes tools on a server's list_changed notification. pi has no
308
+ * unregister, so a withdrawn tool keeps its registration and surfaces the server's
309
+ * own error when called; a newly announced one is registered without a restart. */
310
+ function subscribeToToolChanges(name: string, config: ServerConfig, client: Client): void {
311
+ try {
312
+ client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
313
+ try {
314
+ const refreshed = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
315
+ const added = registerTools(name, config, refreshed)
316
+ if (added === 0) return
317
+ const current = status.get(name)
318
+ status.set(name, { state: current?.state ?? 'connected', tools: serverToolCount(name) })
319
+ pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
320
+ } catch (error) {
321
+ console.warn(`pi-code-mcp: tool refresh failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
322
+ }
323
+ })
324
+ } catch {
325
+ // a transport or client without notification support simply never refreshes
326
+ }
327
+ }
328
+
329
+ async function connectServers(servers: Record<string, ServerConfig>, authUi?: AuthUi): Promise<void> {
330
+ const pending: [string, ServerConfig][] = []
331
+ for (const [name, config] of Object.entries(servers)) {
332
+ // A later scope must not take the name of a server that already connected: it
333
+ // would evict that client from the map, leaking it at shutdown, and misreport
334
+ // the earlier server's status.
335
+ if (clients.has(name)) {
336
+ console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
337
+ continue
338
+ }
339
+ // Seed in config order before connecting: parallel connects settle in completion
340
+ // order, and /mcp plus the session summary iterate the map's insertion order.
341
+ status.set(name, { state: 'connecting', tools: 0 })
342
+ pending.push([name, config])
343
+ }
344
+ await Promise.all(
345
+ pending.map(async ([name, config]) => {
346
+ warnOnTypelessUrl(name, config)
347
+ try {
348
+ const client = await connect(name, config, authUi)
349
+ clients.set(name, client)
350
+ const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
351
+ registerTools(name, config, tools)
352
+ subscribeToToolChanges(name, config, client)
353
+ // Prompts and resources are additive surfaces: their failures warn (inside
354
+ // connectPrompts) rather than flipping a tool-serving server to failed.
355
+ await connectPrompts(name, client)
356
+ subscribeToPromptChanges(name, client)
357
+ ensureResourceTools()
358
+ subscribeToResourceChanges(client)
359
+ // Count from `registered`, not registerTools' return: a reconnect re-lists tools
360
+ // that are already registered (return 0) but still serves them, so the banner
361
+ // must reflect the true count.
362
+ status.set(name, { state: 'connected', tools: serverToolCount(name) })
363
+ // A server that dies mid-session would otherwise stay "connected" in /mcp
364
+ // while every call fails with the SDK's bare "Not connected"; flip the
365
+ // status and free the name so a later session start can reconnect it.
366
+ client.onclose = () => {
367
+ if (clients.get(name) !== client) return
368
+ clients.delete(name)
369
+ status.set(name, { state: 'disconnected', tools: 0 })
370
+ }
371
+ } catch (error) {
372
+ status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
373
+ // Connected but failed after (tool listing hung or errored): left in the
374
+ // map, the client idles its process for the whole session and the
375
+ // duplicate-name guard blocks the name for every later attempt.
376
+ const leaked = clients.get(name)
377
+ if (leaked) {
378
+ clients.delete(name)
379
+ void leaked.close().catch(() => {})
380
+ }
381
+ }
382
+ }),
383
+ )
384
+ }
385
+
386
+ /** Connect the approval-gated project servers, behind the whole-project confirm.
387
+ * Returns whether the scope is settled, so a refused confirm can be retried on a
388
+ * later session start. The consented half of the project scope connects earlier,
389
+ * concurrently with the user scope, from session_start itself. */
390
+ async function connectGatedProjectServers(ctx: ExtensionContext, gated: Record<string, ServerConfig>, authUi?: AuthUi): Promise<boolean> {
391
+ if (Object.keys(gated).length === 0) return true
392
+ if (!(await isProjectApproved(ctx))) return false
393
+ await connectServers(gated, authUi)
394
+ return true
395
+ }
396
+
397
+ let projectConnected = false
398
+
399
+ /** managed-mcp.json exclusive mode: a policy deployed mid-process must not leave
400
+ * already-connected user/project servers running alongside the managed set. Evict every
401
+ * connected client not in the managed set (delete it from the map first so the onclose
402
+ * handler's guard sees it gone and does not overwrite the status, then close it
403
+ * best-effort and mark it disabled), then connect only the managed servers. */
404
+ async function connectManagedExclusive(managed: Record<string, ServerConfig>, allowed: Set<string> | null, denied: Set<string>, authUi?: AuthUi): Promise<void> {
405
+ const managedServers = applyServerPolicy(managed, allowed, denied)
406
+ const managedNames = new Set(Object.keys(managedServers))
407
+ for (const [name, client] of Array.from(clients.entries())) {
408
+ if (managedNames.has(name)) continue
409
+ clients.delete(name)
410
+ // Bound the close like session_shutdown does: a hung server must not stall the new
411
+ // session start, which awaits this eviction before connecting the managed set.
412
+ await withTimeout(client.close(), 3000, 'close').catch(() => {})
413
+ status.set(name, { state: 'disabled by managed policy', tools: 0 })
414
+ }
415
+ await connectServers(managedServers, authUi)
416
+ }
417
+
418
+ /** The normal user + plugin + project scopes, when no managed-mcp.json is present.
419
+ * Connecting spawns processes and opens sockets, so it belongs here rather than in the
420
+ * factory: pi runs the factory for invocations that never start a session. Names still
421
+ * connected are filtered out, so a later session start only retries servers that failed
422
+ * or whose transport dropped, without duplicate-name warnings. */
423
+ async function connectNormalScopes(ctx: ExtensionContext, allowed: Set<string> | null, denied: Set<string>, authUi?: AuthUi): Promise<void> {
424
+ // Plugin servers merge under the user scope (plugins are user-installed);
425
+ // the user's own entry wins a name clash with a plugin's.
426
+ const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
427
+ const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, allowed, denied)
428
+ // Claude's precedence is project over user for a duplicate name. A project .mcp.json
429
+ // server only outranks the user's own when it will actually connect (the user already
430
+ // consented to it, or an approved project's), so a merely-present untrusted project
431
+ // entry cannot shadow a trusted user server by reusing its name. A gated project
432
+ // server still awaiting the approval prompt does not preempt the user server: that is
433
+ // a deliberate narrowing of Claude's rule to keep the safe default.
434
+ // The stored project decision, read without prompting: consent recorded inside
435
+ // the project only counts once the project itself has been approved.
436
+ const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
437
+ const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy)
438
+ const projectWinners = new Set(Object.keys(consented))
439
+ const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
440
+ // The consented project servers carry no ordering dependency on the user scope:
441
+ // projectWinners already excludes their names from userServers, so the two batches
442
+ // are disjoint and connect concurrently, and startup pays the slower scope rather
443
+ // than the sum of both. Reconnect attempts after a refused confirm are safe:
444
+ // connectServers skips names that already connected.
445
+ const connects: Promise<void>[] = []
446
+ if (Object.keys(userServers).length > 0) connects.push(connectServers(userServers, authUi))
447
+ if (!projectConnected && Object.keys(consented).length > 0) connects.push(connectServers(consented, authUi))
448
+ await Promise.all(connects)
449
+ // A project .mcp.json can run arbitrary commands on connect, so only honor it once
450
+ // the project is trusted. Per-server settings refine that: disabled servers never
451
+ // connect, servers the user consented to individually connected above without the
452
+ // whole-project confirm, and the rest stay behind it, sequentially after both
453
+ // scopes so the confirm dialog never races a connect.
454
+ if (!projectConnected) projectConnected = await connectGatedProjectServers(ctx, gated, authUi)
455
+ }
456
+
457
+ pi.on('session_start', async (_event, ctx) => {
458
+ // Reset the status map so /mcp and the banner reflect only this session's config: a
459
+ // server present last session but not this one must not linger as "connected". The
460
+ // registered tools, aliases, and prompt commands stay: pi has no unregister (a
461
+ // withdrawn tool keeps its registration and surfaces the server's own error), which
462
+ // is why serverToolCount reads from `registered` to recover the true count here.
463
+ status.clear()
464
+ const authUi = authUiFor(ctx)
465
+ // The managed allow/deny lists filter every scope, including a managed-mcp.json set.
466
+ const { allowed, denied } = mcpAllowDeny()
467
+ // managed-mcp.json (beside managed-settings.json) takes exclusive control when present:
468
+ // only its servers load, and the user, project, and plugin scopes plus the whole
469
+ // project-approval flow below are skipped. An empty map disables MCP entirely. An absent
470
+ // file leaves the normal scopes untouched; a present but corrupt file fails closed to an
471
+ // empty set (see loadManagedMcpServers).
472
+ const managed = loadManagedMcpServers()
473
+ if (managed !== null) {
474
+ await connectManagedExclusive(managed, allowed, denied, authUi)
475
+ } else {
476
+ await connectNormalScopes(ctx, allowed, denied, authUi)
477
+ }
478
+
479
+ pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
480
+
481
+ const connected = [...status.values()].filter((s) => s.state === 'connected')
482
+ const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected')
483
+ if (connected.length > 0 || failed.length > 0) {
484
+ const total = connected.reduce((sum, s) => sum + s.tools, 0)
485
+ const failNote = failed.length > 0 ? `, ${failed.length} failed` : ''
486
+ ctx.ui.notify(`MCP: ${total} tools from ${connected.length} servers${failNote}`, failed.length > 0 ? 'warning' : 'info')
487
+ }
488
+ })
489
+
490
+ pi.on('session_shutdown', async () => {
491
+ // Close in parallel with a per-client timeout so one hung server can't stall pi's exit.
492
+ await Promise.all([...clients.values()].map((client) => withTimeout(client.close(), 3000, 'close').catch(() => {})))
493
+ // Drop the closed clients and their status now rather than waiting on each client's
494
+ // onclose, which the SDK fires late: a same-process session switch (/new, /resume,
495
+ // /fork) runs the next session_start right after this, and a lingering dead client
496
+ // there would make connectServers skip reconnecting the name, stranding every tool
497
+ // closure on a closed client. session_start resets status too, so a switch rebuilds it.
498
+ clients.clear()
499
+ status.clear()
500
+ })
501
+
502
+ pi.registerCommand('mcp', {
503
+ description: 'Show MCP server status and tools',
504
+ handler: async (_args, ctx) => {
505
+ if (status.size === 0) {
506
+ ctx.ui.notify('No MCP servers configured. Add them to .mcp.json, .pi/mcp.json, ~/.claude.json, or ~/.pi/agent/mcp.json', 'info')
507
+ return
508
+ }
509
+ const lines = [...status.entries()].map(([name, s]) => `${name}: ${s.state} (${s.tools} tools)`)
510
+ ctx.ui.notify(lines.join('\n'), 'info')
511
+ },
512
+ })
513
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * MCP listing: the paginated tool and prompt listers, and the resource/template
3
+ * collectors that back the global list_mcp_resources tool.
4
+ */
5
+
6
+ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
7
+ import type { McpPromptInfo } from './mapping.js'
8
+ import { callRequestOptions, withTimeout } from './transport.js'
9
+
10
+ export interface McpToolInfo {
11
+ name: string
12
+ description?: string
13
+ inputSchema?: unknown
14
+ }
15
+
16
+ export async function listAllTools(client: Client): Promise<McpToolInfo[]> {
17
+ const tools: McpToolInfo[] = []
18
+ let cursor: string | undefined
19
+ do {
20
+ const page = await client.listTools({ cursor })
21
+ tools.push(...page.tools)
22
+ cursor = page.nextCursor
23
+ } while (cursor)
24
+ return tools
25
+ }
26
+
27
+ export async function listAllPrompts(client: Client): Promise<McpPromptInfo[]> {
28
+ const prompts: McpPromptInfo[] = []
29
+ let cursor: string | undefined
30
+ do {
31
+ const page = await client.listPrompts({ cursor })
32
+ prompts.push(...page.prompts)
33
+ cursor = page.nextCursor
34
+ } while (cursor)
35
+ return prompts
36
+ }
37
+
38
+ /** One resource's flat record for the list_mcp_resources output, dropping the optional
39
+ * description/mimeType when the server omits them. */
40
+ function resourceEntry(server: string, resource: { uri: string; name: string; description?: string; mimeType?: string }): Record<string, unknown> {
41
+ return { server, uri: resource.uri, name: resource.name, ...(resource.description ? { description: resource.description } : {}), ...(resource.mimeType ? { mimeType: resource.mimeType } : {}) }
42
+ }
43
+
44
+ /** One resource template's flat record, likewise dropping absent optional fields. */
45
+ function resourceTemplateEntry(server: string, template: { uriTemplate: string; name: string; description?: string; mimeType?: string }): Record<string, unknown> {
46
+ return { server, uriTemplate: template.uriTemplate, name: template.name, ...(template.description ? { description: template.description } : {}), ...(template.mimeType ? { mimeType: template.mimeType } : {}) }
47
+ }
48
+
49
+ /** Page a server's resources to exhaustion under the call budget, appending each as a
50
+ * flat record. Pushed into the caller's array incrementally so a mid-pagination failure
51
+ * still leaves the earlier pages in place. */
52
+ async function collectResources(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
53
+ let cursor: string | undefined
54
+ do {
55
+ const page = await withTimeout(client.listResources({ cursor }, callRequestOptions(budget)), budget, `list resources ${name}`)
56
+ for (const resource of page.resources) entries.push(resourceEntry(name, resource))
57
+ cursor = page.nextCursor
58
+ } while (cursor)
59
+ }
60
+
61
+ /** Page a server's resource templates to exhaustion under the call budget. */
62
+ async function collectResourceTemplates(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
63
+ let cursor: string | undefined
64
+ do {
65
+ const page = await withTimeout(client.listResourceTemplates({ cursor }, callRequestOptions(budget)), budget, `list resource templates ${name}`)
66
+ for (const template of page.resourceTemplates) entries.push(resourceTemplateEntry(name, template))
67
+ cursor = page.nextCursor
68
+ } while (cursor)
69
+ }
70
+
71
+ /** Append every resource and template one server exposes. A resource-listing failure
72
+ * surfaces inline as an error record, so one server cannot empty the whole listing; a
73
+ * template-listing failure is silent, templates being optional (a server with the
74
+ * resources capability but no templates answers method-not-found). */
75
+ export async function collectServerResourceEntries(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
76
+ try {
77
+ await collectResources(entries, name, client, budget)
78
+ } catch (error) {
79
+ entries.push({ server: name, error: error instanceof Error ? error.message : String(error) })
80
+ }
81
+ try {
82
+ await collectResourceTemplates(entries, name, client, budget)
83
+ } catch {
84
+ // Templates are optional: a method-not-found here is not worth reporting.
85
+ }
86
+ }
87
+
88
+ /** The optional server-name filter for list_mcp_resources: a non-empty string, else undefined. */
89
+ export function resourceServerFilter(params: unknown): string | undefined {
90
+ const server = (params as { server?: unknown }).server
91
+ return typeof server === 'string' && server.length > 0 ? server : undefined
92
+ }