pi-code 1.0.5 → 1.0.7

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 CHANGED
@@ -11,11 +11,24 @@
11
11
  * per-project `projects[cwd].mcpServers` local scope, and ~/.pi/agent/mcp.json) is the
12
12
  * user's own and loads on the first session. Project config (.mcp.json, .pi/mcp.json)
13
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; user config
15
- * connects first, so a project server cannot take the name of a user server that connected.
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.
16
18
  * Values support ${VAR} / ${VAR:-default} interpolation, connect and per-call timeouts
17
19
  * honor MCP_TIMEOUT / MCP_TOOL_TIMEOUT, and a stdio server receives only the SDK's default
18
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.
19
32
  */
20
33
 
21
34
  import { execFile } from 'node:child_process'
@@ -32,8 +45,9 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' //
32
45
  import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
33
46
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
34
47
  import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'
35
- import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
48
+ import { PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
36
49
  import { Type } from 'typebox'
50
+ import { splitArgs } from './internal/command-file.js'
37
51
  import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
38
52
  import { setMcpToolCaller } from './internal/mcp-call.js'
39
53
  import { FileOAuthProvider, openBrowser, startCallbackServer, waitForAuthCode } from './internal/mcp-oauth.js'
@@ -61,7 +75,9 @@ const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_
61
75
  // pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
62
76
  // These are pi-code's own tools, and mcp.ts registers before the extensions owning
63
77
  // them, so without this guard a server named `web` would replace the SSRF-checked fetch.
64
- const RESERVED_NAMES = new Set(['web_fetch', 'web_search', 'plan_mode_complete'])
78
+ // The resource tools are this extension's own globals; a server named `list` or `read`
79
+ // must not take their names either.
80
+ const RESERVED_NAMES = new Set(['web_fetch', 'web_search', 'plan_mode_complete', 'list_mcp_resources', 'read_mcp_resource'])
65
81
 
66
82
  export interface StdioServerConfig {
67
83
  type?: 'stdio'
@@ -94,11 +110,18 @@ export type ServerConfig = StdioServerConfig | HttpServerConfig
94
110
 
95
111
  /** Claude's .mcp.json expansion: ${VAR}, and ${VAR:-default}. The syntax borrows
96
112
  * shell's `:-`, which substitutes when the variable is unset OR empty. */
97
- export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.env): string {
98
- return value.replace(/\$\{(\w+)(:-([^}]*))?\}/g, (_, name, hasDefault, fallback) => {
113
+ export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.env, onMissing?: (name: string) => void): string {
114
+ return value.replace(/\$\{(\w+)(:-([^}]*))?\}/g, (fullMatch, name, hasDefault, fallback) => {
99
115
  const current = env[name]
100
116
  if (hasDefault !== undefined) return current || fallback
101
- return current ?? ''
117
+ if (current === undefined) {
118
+ // A referenced variable with no value and no default: keep the literal ${VAR} and
119
+ // report it, matching Claude, rather than silently substituting an empty string that
120
+ // turns `Bearer ${TOKEN}` into a confusing `Bearer ` and a mystery 401.
121
+ onMissing?.(name)
122
+ return fullMatch
123
+ }
124
+ return current
102
125
  })
103
126
  }
104
127
 
@@ -193,6 +216,29 @@ export function loadUserScope(home: string, cwd: string): Record<string, ServerC
193
216
  return servers
194
217
  }
195
218
 
219
+ /** The mcpServers one plugin declares: an inline map on the manifest, or the file it
220
+ * points to (default .mcp.json at the plugin root), with ${CLAUDE_PLUGIN_*} substituted
221
+ * before parsing. Malformed or missing JSON yields no entries. */
222
+ function pluginServerEntries(plugin: InstalledPlugin): Record<string, ServerConfig> {
223
+ const declared = plugin.manifest.mcpServers
224
+ // An inline map of name -> config; an array is not a valid mcpServers map (it
225
+ // would register a server named '0'), so it falls through to the path branch.
226
+ if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
227
+ try {
228
+ return JSON.parse(substitutePluginVars(JSON.stringify(declared), plugin))
229
+ } catch {
230
+ return {}
231
+ }
232
+ }
233
+ const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : '.mcp.json')
234
+ try {
235
+ const parsed = JSON.parse(substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin))
236
+ return parsed.mcpServers ?? {}
237
+ } catch {
238
+ return {}
239
+ }
240
+ }
241
+
196
242
  /** Servers shipped by enabled plugins (.mcp.json or the manifest's `mcpServers`,
197
243
  * inline or by path), with ${CLAUDE_PLUGIN_*} substituted before parsing. Their
198
244
  * tools alias as mcp__plugin_<plugin>_<server>__<tool> for hook matchers, as
@@ -201,26 +247,7 @@ export function loadPluginServers(plugins: InstalledPlugin[]): Record<string, Se
201
247
  const fold = (name: string): string => name.replaceAll('-', '_')
202
248
  const servers: Record<string, ServerConfig> = {}
203
249
  for (const plugin of plugins) {
204
- const declared = plugin.manifest.mcpServers
205
- let entries: Record<string, ServerConfig> = {}
206
- // An inline map of name -> config; an array is not a valid mcpServers map (it
207
- // would register a server named '0'), so it falls through to the path branch.
208
- if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
209
- try {
210
- entries = JSON.parse(substitutePluginVars(JSON.stringify(declared), plugin))
211
- } catch {
212
- continue
213
- }
214
- } else {
215
- const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : '.mcp.json')
216
- try {
217
- const parsed = JSON.parse(substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin))
218
- entries = parsed.mcpServers ?? {}
219
- } catch {
220
- continue
221
- }
222
- }
223
- for (const [name, config] of Object.entries(entries)) {
250
+ for (const [name, config] of Object.entries(pluginServerEntries(plugin))) {
224
251
  servers[name] = { ...config, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__` }
225
252
  }
226
253
  }
@@ -259,7 +286,7 @@ export function parseHelperHeaders(stdout: string): Record<string, string> {
259
286
  export function managedSettingsPath(platform: NodeJS.Platform = process.platform): string {
260
287
  if (platform === 'darwin') return '/Library/Application Support/ClaudeCode/managed-settings.json'
261
288
  // The legacy C:\ProgramData\ClaudeCode path was dropped in Claude Code v2.1.75.
262
- if (platform === 'win32') return 'C:\\Program Files\\ClaudeCode\\managed-settings.json'
289
+ if (platform === 'win32') return String.raw`C:\Program Files\ClaudeCode\managed-settings.json`
263
290
  return '/etc/claude-code/managed-settings.json'
264
291
  }
265
292
 
@@ -282,8 +309,12 @@ export function mcpAllowDeny(managedFile: string = managedSettingsFileOverride ?
282
309
  } catch {
283
310
  // No managed policy on this machine: no restriction.
284
311
  }
285
- const names = (value: unknown): string[] =>
286
- Array.isArray(value) ? value.map((entry) => (typeof entry === 'string' ? entry : typeof (entry as { serverName?: unknown })?.serverName === 'string' ? (entry as { serverName: string }).serverName : undefined)).filter((name): name is string => typeof name === 'string' && name.length > 0) : []
312
+ const entryName = (entry: unknown): string | undefined => {
313
+ if (typeof entry === 'string') return entry
314
+ const serverName = (entry as { serverName?: unknown })?.serverName
315
+ return typeof serverName === 'string' ? serverName : undefined
316
+ }
317
+ const names = (value: unknown): string[] => (Array.isArray(value) ? value.map(entryName).filter((name): name is string => typeof name === 'string' && name.length > 0) : [])
287
318
  return {
288
319
  allowed: Array.isArray(settings.allowedMcpServers) ? new Set(names(settings.allowedMcpServers)) : null,
289
320
  denied: new Set(names(settings.deniedMcpServers)),
@@ -319,11 +350,86 @@ export function formatToolName(server: string, tool: string): string {
319
350
  return `${server}_${tool}`.replaceAll('-', '_')
320
351
  }
321
352
 
353
+ /** Claude exposes server prompts as /mcp__<server>__<prompt> slash commands. Both
354
+ * names normalize like formatToolName, extended to spaces: dashes and spaces each
355
+ * become an underscore. */
356
+ export function formatPromptCommandName(server: string, prompt: string): string {
357
+ const normalize = (name: string): string => name.replace(/[\s-]/g, '_')
358
+ return `mcp__${normalize(server)}__${normalize(prompt)}`
359
+ }
360
+
361
+ export interface McpPromptArgumentInfo {
362
+ name: string
363
+ description?: string
364
+ required?: boolean
365
+ }
366
+
367
+ export interface McpPromptInfo {
368
+ name: string
369
+ description?: string
370
+ arguments?: McpPromptArgumentInfo[]
371
+ }
372
+
373
+ /** Claude passes prompt arguments space-separated after the command. Tokens map
374
+ * positionally onto the declared arguments, split the way slash-command args are
375
+ * (quoted runs stay together); the last declared argument absorbs any trailing
376
+ * tokens so free text at the end is not silently dropped. Declared arguments with
377
+ * no token are omitted, and the server enforces its own `required`. */
378
+ export function mapPromptArguments(declared: ReadonlyArray<{ name: string }> | undefined, args: string): Record<string, string> {
379
+ const tokens = splitArgs(args)
380
+ const names = (declared ?? []).map((argument) => argument.name)
381
+ const mapped: Record<string, string> = {}
382
+ for (let index = 0; index < names.length && index < tokens.length; index++) {
383
+ mapped[names[index]] = index === names.length - 1 ? tokens.slice(index).join(' ') : tokens[index]
384
+ }
385
+ return mapped
386
+ }
387
+
388
+ /** The content blocks a getPrompt result injects. Each message carries one content
389
+ * block; the blocks ride the same mapContent budget as tool output, and image blocks
390
+ * are carried through rather than dropped, since sendUserMessage accepts them and a
391
+ * vision prompt is worthless flattened to text. An empty message list yields no
392
+ * blocks, and messages that carry only empty text yield none either, so the caller
393
+ * can skip the turn rather than drive it on an empty or sentinel message. */
394
+ export function promptMessageContent(messages: ReadonlyArray<{ content: unknown }>): ToolContent[] {
395
+ if (messages.length === 0) return []
396
+ return mapContent(messages.map((message) => message.content as McpContentBlock)).filter((block) => block.type !== 'text' || block.text.trim() !== '')
397
+ }
398
+
399
+ /** Merge the `properties` (and, for allOf, the `required`) of a root-level combinator's
400
+ * branches into one flat object schema. Without this a tool whose input schema is a bare
401
+ * anyOf/oneOf/allOf (no top-level `type`) would present no properties at all, so the model
402
+ * would be forced to call it with no arguments. */
403
+ function mergeCombinatorBranches(branches: unknown[]): { properties: Record<string, unknown>; required: string[] } {
404
+ const properties: Record<string, unknown> = {}
405
+ const required = new Set<string>()
406
+ for (const branch of branches) {
407
+ if (!branch || typeof branch !== 'object') continue
408
+ const b = branch as Record<string, unknown>
409
+ if (b.properties && typeof b.properties === 'object') Object.assign(properties, b.properties as Record<string, unknown>)
410
+ if (Array.isArray(b.required)) for (const name of b.required) if (typeof name === 'string') required.add(name)
411
+ }
412
+ return { properties, required: [...required] }
413
+ }
414
+
322
415
  export function normalizeSchema(schema: unknown): object {
323
416
  const base = (schema as Record<string, unknown>) ?? {}
324
417
  const { $schema: _dropSchema, additionalProperties: _dropAdditional, ...rest } = base
325
- if (!rest.type) return { type: 'object', properties: {} }
326
- return rest
418
+ if (rest.type) return rest
419
+ // A root-level combinator carries the real parameters in its branches; flatten them
420
+ // into one object schema rather than emptying it. allOf means every branch applies, so
421
+ // its required union is kept; anyOf/oneOf branches are alternatives, so required is left
422
+ // open (the server still enforces its own).
423
+ const allOf = Array.isArray(rest.allOf) ? rest.allOf : undefined
424
+ let branches = allOf
425
+ if (!branches && Array.isArray(rest.anyOf)) branches = rest.anyOf
426
+ if (!branches && Array.isArray(rest.oneOf)) branches = rest.oneOf
427
+ if (!branches) return { type: 'object', properties: {} }
428
+ const { properties, required } = mergeCombinatorBranches(branches)
429
+ const merged: Record<string, unknown> = { type: 'object', properties }
430
+ if (typeof rest.description === 'string') merged.description = rest.description
431
+ if (allOf && required.length > 0) merged.required = required
432
+ return merged
327
433
  }
328
434
 
329
435
  interface McpContentBlock {
@@ -426,23 +532,32 @@ async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): P
426
532
 
427
533
  async function connect(name: string, config: ServerConfig, authUi?: AuthUi): Promise<Client> {
428
534
  const client = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
535
+ // Names referenced by ${VAR} with no value and no default, gathered across this
536
+ // server's interpolated fields so the connect can warn once rather than fail with a
537
+ // mystery 401 or a command that lost an argument.
538
+ const missing = new Set<string>()
539
+ const fill = (value: string): string => interpolateEnv(value, process.env, (varName) => missing.add(varName))
540
+ const warnMissing = (): void => {
541
+ if (missing.size > 0) console.warn(`pi-code-mcp: server ${name} references undefined variable(s) ${[...missing].join(', ')}; leaving them unexpanded`)
542
+ }
429
543
  if (isStdio(config)) {
430
544
  // Start from the SDK's allowlist (PATH, HOME, SHELL, ...) rather than the whole
431
545
  // process env: a server should not receive ANTHROPIC_API_KEY or GITHUB_TOKEN just
432
546
  // for being launched. A server that needs a variable names it in its own env block.
433
547
  const env: Record<string, string> = { ...getDefaultEnvironment() }
434
- for (const [key, value] of Object.entries(config.env ?? {})) env[key] = interpolateEnv(value)
548
+ for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
435
549
  const transport = new StdioClientTransport({
436
- command: interpolateEnv(config.command),
437
- args: (config.args ?? []).map((arg) => interpolateEnv(arg)),
550
+ command: fill(config.command),
551
+ args: (config.args ?? []).map((arg) => fill(arg)),
438
552
  env,
439
553
  cwd: expandCwd(config.cwd),
440
554
  stderr: 'ignore',
441
555
  })
556
+ warnMissing()
442
557
  await connectWithTimeout(client, transport, `connect ${name}`)
443
558
  return client
444
559
  }
445
- const url = new URL(interpolateEnv(config.url))
560
+ const url = new URL(fill(config.url))
446
561
  if (config.type === 'ws' || config.type === 'websocket') {
447
562
  // The SDK's WebSocket transport takes only a url: it carries no headers, bearer
448
563
  // token, or headersHelper output. Warn rather than silently dropping configured
@@ -452,16 +567,18 @@ async function connect(name: string, config: ServerConfig, authUi?: AuthUi): Pro
452
567
  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`)
453
568
  }
454
569
  const transport = new WebSocketClientTransport(url)
570
+ warnMissing()
455
571
  await connectWithTimeout(client, transport, `connect ${name} (ws)`)
456
572
  return client
457
573
  }
458
574
  const headers: Record<string, string> = {}
459
- for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = interpolateEnv(value)
575
+ for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = fill(value)
460
576
  const token = resolveBearerToken(config)
461
577
  if (token) headers.Authorization = `Bearer ${token}`
462
578
  // A headersHelper generates connect-time headers for non-OAuth auth schemes; its
463
579
  // JSON stdout merges over the static headers.
464
- if (config.headersHelper) Object.assign(headers, await runHeadersHelper(interpolateEnv(config.headersHelper)))
580
+ if (config.headersHelper) Object.assign(headers, await runHeadersHelper(fill(config.headersHelper)))
581
+ warnMissing()
465
582
  const sseTransport = (authProvider?: OAuthClientProvider) => new SSEClientTransport(url, { requestInit: { headers }, authProvider }) // NOSONAR: explicitly declared or deliberate legacy transport
466
583
  if (config.type === 'sse') {
467
584
  return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
@@ -491,6 +608,15 @@ export interface AuthUi {
491
608
  notify: (message: string, level: 'info' | 'warning' | 'error') => void
492
609
  }
493
610
 
611
+ /** The OAuth flow's UI seams, absent in headless runs. */
612
+ function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
613
+ if (!ctx.hasUI) return undefined
614
+ return {
615
+ confirm: (title, body) => ctx.ui.confirm(title, body),
616
+ notify: (message, level) => ctx.ui.notify(message, level),
617
+ }
618
+ }
619
+
494
620
  /** Browser logins are human-paced; a connect-sized timeout would cut them off. */
495
621
  const OAUTH_FLOW_TIMEOUT_MS = 180_000
496
622
 
@@ -499,6 +625,14 @@ const OAUTH_FLOW_TIMEOUT_MS = 180_000
499
625
  * from a transport mismatch without matching on message text. */
500
626
  class OAuthRequiredError extends Error {}
501
627
 
628
+ /** Wrap a login-flow failure as OAuthRequiredError, passing an existing one through
629
+ * unchanged so its message is not doubled. */
630
+ function asOAuthRequiredError(name: string, error: unknown): OAuthRequiredError {
631
+ if (error instanceof OAuthRequiredError) return error
632
+ const detail = error instanceof Error ? error.message : String(error)
633
+ return new OAuthRequiredError(`login for ${name} failed: ${detail}`)
634
+ }
635
+
502
636
  /** Whether a connect failure is an authentication problem: the SDK's own
503
637
  * UnauthorizedError, a transport error carrying HTTP 401 (which is what a 401
504
638
  * throws when no authProvider was attached, so a first-time login is detected),
@@ -508,6 +642,13 @@ function isUnauthorized(error: unknown): boolean {
508
642
  return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 401
509
643
  }
510
644
 
645
+ // SSEClientTransport is deprecated in favour of Streamable HTTP, but both concrete
646
+ // transports expose finishAuth (the base Transport interface does not), so the union
647
+ // stays as the http-family fallback type through the migration period.
648
+ type HttpFamilyTransport = SSEClientTransport | StreamableHTTPClientTransport // NOSONAR typescript:S1874 - SSE fallback still required by the MCP SDK
649
+
650
+ type MakeTransport = (authProvider?: OAuthClientProvider) => HttpFamilyTransport
651
+
511
652
  /**
512
653
  * Connect an http-family server, running Claude's OAuth login when the server
513
654
  * demands one. Stored tokens ride the first attempt so the SDK refreshes
@@ -516,7 +657,7 @@ function isUnauthorized(error: unknown): boolean {
516
657
  * Bearer-token servers never enter the OAuth path: an explicit token is the
517
658
  * user saying how auth works.
518
659
  */
519
- async function connectHttpFamily(name: string, config: { url: string }, makeTransport: (authProvider?: OAuthClientProvider) => SSEClientTransport | StreamableHTTPClientTransport, label: string, bearerToken: string | undefined, authUi: AuthUi | undefined): Promise<Client> {
660
+ async function connectHttpFamily(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, bearerToken: string | undefined, authUi: AuthUi | undefined): Promise<Client> {
520
661
  const newClient = () => new Client({ name: 'pi-code-mcp', version: '0.1.0' })
521
662
  // Stored tokens ride the first attempt so the SDK refreshes them; with none, no
522
663
  // provider is attached, so a 401 surfaces as a transport error carrying code 401
@@ -530,39 +671,49 @@ async function connectHttpFamily(name: string, config: { url: string }, makeTran
530
671
  } catch (error) {
531
672
  if (bearerToken || !isUnauthorized(error)) throw error
532
673
  if (!authUi) throw new OAuthRequiredError(`${name} requires a login; run pi interactively to authenticate`)
533
- const approved = await authUi.confirm(`MCP server "${name}" requires login`, `Open your browser to authorize ${config.url}?`)
534
- if (!approved) throw new OAuthRequiredError(`login declined for ${name}`)
535
- const provider = new FileOAuthProvider(name, (authorizationUrl) => {
536
- openBrowser(String(authorizationUrl))
537
- authUi.notify(`Authorize "${name}" in the browser. If it did not open: ${authorizationUrl}`, 'info')
538
- })
539
- const { server, port } = await startCallbackServer(provider.savedRedirectPort())
540
- provider.bindRedirectPort(port)
674
+ return await runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient)
675
+ }
676
+ }
677
+
678
+ /**
679
+ * The interactive half of the OAuth login, reached only once a silent connect has
680
+ * failed with a 401 and a UI is present: confirm, open the browser, catch the loopback
681
+ * redirect, and exchange the code via the SDK's finishAuth. Past the confirm the server
682
+ * is known to need OAuth, so any failure here (a denied consent page, the 180s wait, a
683
+ * token exchange error) is wrapped as an auth failure, not a transport mismatch: that
684
+ * keeps the typeless-url caller from retrying over SSE and prompting for a second login.
685
+ */
686
+ async function runInteractiveOAuth(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, authUi: AuthUi, newClient: () => Client): Promise<Client> {
687
+ const approved = await authUi.confirm(`MCP server "${name}" requires login`, `Open your browser to authorize ${config.url}?`)
688
+ if (!approved) throw new OAuthRequiredError(`login declined for ${name}`)
689
+ const provider = new FileOAuthProvider(name, (authorizationUrl) => {
690
+ openBrowser(String(authorizationUrl))
691
+ authUi.notify(`Authorize "${name}" in the browser. If it did not open: ${authorizationUrl}`, 'info')
692
+ })
693
+ const { server, port } = await startCallbackServer(provider.savedRedirectPort())
694
+ provider.bindRedirectPort(port)
695
+ try {
696
+ const transport = makeTransport(provider)
697
+ // Verify the redirect echoes this login's state, so a stray or forged callback to the
698
+ // loopback port cannot inject a code or abort the login (see waitForAuthCode).
699
+ const pendingCode = waitForAuthCode(server, OAUTH_FLOW_TIMEOUT_MS, provider.state())
700
+ pendingCode.catch(() => {}) // consumed below; an abandoned login must not surface as unhandled
701
+ const client = newClient()
541
702
  try {
542
- const transport = makeTransport(provider)
543
- const pendingCode = waitForAuthCode(server, OAUTH_FLOW_TIMEOUT_MS)
544
- pendingCode.catch(() => {}) // consumed below; an abandoned login must not surface as unhandled
545
- const client = newClient()
546
- try {
547
- await connectWithTimeout(client, transport, label)
548
- return client // authorized between attempts; nothing left to exchange
549
- } catch (retryError) {
550
- if (!isUnauthorized(retryError)) throw retryError
551
- const code = await pendingCode
552
- await transport.finishAuth(code)
553
- const authed = newClient()
554
- await connectWithTimeout(authed, makeTransport(provider), label)
555
- return authed
556
- }
557
- } catch (flowError) {
558
- // Past the confirm the server is known to need OAuth, so any failure here (a
559
- // denied consent page, the 180s wait, a token exchange error) is an auth
560
- // failure, not a transport mismatch. Marking it keeps the typeless-url caller
561
- // from retrying over SSE and prompting the user to log in a second time.
562
- throw flowError instanceof OAuthRequiredError ? flowError : new OAuthRequiredError(`login for ${name} failed: ${flowError instanceof Error ? flowError.message : String(flowError)}`)
563
- } finally {
564
- server.close()
703
+ await connectWithTimeout(client, transport, label)
704
+ return client // authorized between attempts; nothing left to exchange
705
+ } catch (retryError) {
706
+ if (!isUnauthorized(retryError)) throw retryError
707
+ const code = await pendingCode
708
+ await transport.finishAuth(code)
709
+ const authed = newClient()
710
+ await connectWithTimeout(authed, makeTransport(provider), label)
711
+ return authed
565
712
  }
713
+ } catch (flowError) {
714
+ throw asOAuthRequiredError(name, flowError)
715
+ } finally {
716
+ server.close()
566
717
  }
567
718
  }
568
719
 
@@ -603,6 +754,73 @@ async function listAllTools(client: Client): Promise<McpToolInfo[]> {
603
754
  return tools
604
755
  }
605
756
 
757
+ async function listAllPrompts(client: Client): Promise<McpPromptInfo[]> {
758
+ const prompts: McpPromptInfo[] = []
759
+ let cursor: string | undefined
760
+ do {
761
+ const page = await client.listPrompts({ cursor })
762
+ prompts.push(...page.prompts)
763
+ cursor = page.nextCursor
764
+ } while (cursor)
765
+ return prompts
766
+ }
767
+
768
+ /** One resource's flat record for the list_mcp_resources output, dropping the optional
769
+ * description/mimeType when the server omits them. */
770
+ function resourceEntry(server: string, resource: { uri: string; name: string; description?: string; mimeType?: string }): Record<string, unknown> {
771
+ return { server, uri: resource.uri, name: resource.name, ...(resource.description ? { description: resource.description } : {}), ...(resource.mimeType ? { mimeType: resource.mimeType } : {}) }
772
+ }
773
+
774
+ /** One resource template's flat record, likewise dropping absent optional fields. */
775
+ function resourceTemplateEntry(server: string, template: { uriTemplate: string; name: string; description?: string; mimeType?: string }): Record<string, unknown> {
776
+ return { server, uriTemplate: template.uriTemplate, name: template.name, ...(template.description ? { description: template.description } : {}), ...(template.mimeType ? { mimeType: template.mimeType } : {}) }
777
+ }
778
+
779
+ /** Page a server's resources to exhaustion under the call budget, appending each as a
780
+ * flat record. Pushed into the caller's array incrementally so a mid-pagination failure
781
+ * still leaves the earlier pages in place. */
782
+ async function collectResources(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
783
+ let cursor: string | undefined
784
+ do {
785
+ const page = await withTimeout(client.listResources({ cursor }, { timeout: budget }), budget, `list resources ${name}`)
786
+ for (const resource of page.resources) entries.push(resourceEntry(name, resource))
787
+ cursor = page.nextCursor
788
+ } while (cursor)
789
+ }
790
+
791
+ /** Page a server's resource templates to exhaustion under the call budget. */
792
+ async function collectResourceTemplates(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
793
+ let cursor: string | undefined
794
+ do {
795
+ const page = await withTimeout(client.listResourceTemplates({ cursor }, { timeout: budget }), budget, `list resource templates ${name}`)
796
+ for (const template of page.resourceTemplates) entries.push(resourceTemplateEntry(name, template))
797
+ cursor = page.nextCursor
798
+ } while (cursor)
799
+ }
800
+
801
+ /** Append every resource and template one server exposes. A resource-listing failure
802
+ * surfaces inline as an error record, so one server cannot empty the whole listing; a
803
+ * template-listing failure is silent, templates being optional (a server with the
804
+ * resources capability but no templates answers method-not-found). */
805
+ async function collectServerResourceEntries(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
806
+ try {
807
+ await collectResources(entries, name, client, budget)
808
+ } catch (error) {
809
+ entries.push({ server: name, error: error instanceof Error ? error.message : String(error) })
810
+ }
811
+ try {
812
+ await collectResourceTemplates(entries, name, client, budget)
813
+ } catch {
814
+ // Templates are optional: a method-not-found here is not worth reporting.
815
+ }
816
+ }
817
+
818
+ /** The optional server-name filter for list_mcp_resources: a non-empty string, else undefined. */
819
+ function resourceServerFilter(params: unknown): string | undefined {
820
+ const server = (params as { server?: unknown }).server
821
+ return typeof server === 'string' && server.length > 0 ? server : undefined
822
+ }
823
+
606
824
  export default async function mcpExtension(pi: ExtensionAPI) {
607
825
  const clients = new Map<string, Client>()
608
826
  const status = new Map<string, { state: string; tools: number }>()
@@ -623,7 +841,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
623
841
  const aliases: McpToolAlias[] = []
624
842
 
625
843
  /** Register every not-yet-registered tool of a server; returns how many were added. */
626
- function registerTools(name: string, config: ServerConfig, client: Client, tools: McpToolInfo[]): number {
844
+ function registerTools(name: string, config: ServerConfig, tools: McpToolInfo[]): number {
627
845
  let count = 0
628
846
  for (const tool of tools) {
629
847
  const toolName = formatToolName(name, tool.name)
@@ -642,12 +860,18 @@ export default async function mcpExtension(pi: ExtensionAPI) {
642
860
  description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
643
861
  parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
644
862
  async execute(_id, params) {
863
+ // Resolve the live client by name at call time rather than capturing the one
864
+ // present at registration: pi has no tool unregister, so after a server drops
865
+ // and a later session_start reconnects it, registerTools skips re-registration
866
+ // and this closure would otherwise keep calling the old, closed client.
867
+ const current = clients.get(name)
868
+ if (!current) throw new Error(`MCP server "${name}" is not connected`)
645
869
  // Pass the timeout to the SDK too: its own default request timeout is 60s and
646
870
  // would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
647
871
  // Claude's per-server timeout wins over MCP_TOOL_TIMEOUT, with a 1s floor.
648
872
  const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
649
873
  const budget = declared ?? callTimeoutMs()
650
- const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: budget }), budget, toolName)
874
+ const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: budget }), budget, toolName)
651
875
  const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
652
876
  const details: { error?: string } = {}
653
877
  if (result.isError) {
@@ -662,6 +886,155 @@ export default async function mcpExtension(pi: ExtensionAPI) {
662
886
  return count
663
887
  }
664
888
 
889
+ // Prompt command name -> the server and prompt that own it, so a refresh re-listing
890
+ // the same prompt is told apart both from a cross-server collision and from a second
891
+ // prompt on the same server whose name normalizes to the one already taken (e.g.
892
+ // `deploy-prod` and `deploy_prod`), mirroring `registered` for tools.
893
+ const registeredPrompts = new Map<string, { server: string; prompt: string }>()
894
+
895
+ /** Register a slash command for every not-yet-registered prompt of a server. pi has
896
+ * no command unregister, so, like tools, a withdrawn prompt keeps its registration
897
+ * and surfaces the server's own error when invoked; an edit to a prompt's declared
898
+ * arguments only lands on new names, since an existing command keeps its binding. */
899
+ function registerPrompts(name: string, prompts: McpPromptInfo[]): void {
900
+ for (const prompt of prompts) {
901
+ const commandName = formatPromptCommandName(name, prompt.name)
902
+ const owner = registeredPrompts.get(commandName)
903
+ if (owner) {
904
+ if (owner.server === name && owner.prompt === prompt.name) continue // a refresh re-listing the same prompt
905
+ console.warn(`pi-code-mcp: skipping colliding prompt command ${commandName}`)
906
+ continue
907
+ }
908
+ registeredPrompts.set(commandName, { server: name, prompt: prompt.name })
909
+ const hint = (prompt.arguments ?? []).map((argument) => (argument.required ? `<${argument.name}>` : `[${argument.name}]`)).join(' ')
910
+ const base = prompt.description ?? `MCP prompt ${prompt.name} from ${name}`
911
+ pi.registerCommand(commandName, {
912
+ description: hint ? `${base} ${hint}` : base,
913
+ handler: async (args, ctx) => {
914
+ try {
915
+ // Resolve the live client at call time, not the one captured at registration:
916
+ // pi has no command unregister, so after a reconnect this closure must not keep
917
+ // calling the old, closed client (see registerTools for the same reason).
918
+ const current = clients.get(name)
919
+ if (!current) {
920
+ ctx.ui.notify(`${commandName}: MCP server "${name}" is not connected`, 'error')
921
+ return
922
+ }
923
+ const promptArgs = mapPromptArguments(prompt.arguments, args)
924
+ const params: { name: string; arguments?: Record<string, string> } = { name: prompt.name }
925
+ if (Object.keys(promptArgs).length > 0) params.arguments = promptArgs
926
+ const budget = callTimeoutMs()
927
+ const result = await withTimeout(current.getPrompt(params, { timeout: budget }), budget, commandName)
928
+ // The prompt drives a turn exactly the way a custom slash command does
929
+ // (see commands.ts), carrying its image blocks through. A prompt that
930
+ // yields no content is reported rather than sent as an empty turn.
931
+ const content = promptMessageContent(result.messages)
932
+ if (content.length === 0) {
933
+ ctx.ui.notify(`${commandName}: prompt returned no content`, 'info')
934
+ return
935
+ }
936
+ pi.sendUserMessage(content)
937
+ } catch (error) {
938
+ ctx.ui.notify(`${commandName}: ${error instanceof Error ? error.message : String(error)}`, 'error')
939
+ }
940
+ },
941
+ })
942
+ }
943
+ }
944
+
945
+ /** Claude exposes prompts as slash commands only for servers advertising the
946
+ * prompts capability; a listing failure loses the prompts, not the server. */
947
+ async function connectPrompts(name: string, client: Client): Promise<void> {
948
+ if (!client.getServerCapabilities()?.prompts) return
949
+ try {
950
+ registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
951
+ } catch (error) {
952
+ console.warn(`pi-code-mcp: prompt listing failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
953
+ }
954
+ }
955
+
956
+ /** Mirror of subscribeToToolChanges for the prompt list: a newly announced prompt
957
+ * registers without a restart, a withdrawn one keeps its registration. */
958
+ function subscribeToPromptChanges(name: string, client: Client): void {
959
+ try {
960
+ client.setNotificationHandler(PromptListChangedNotificationSchema, async () => {
961
+ try {
962
+ registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
963
+ } catch (error) {
964
+ console.warn(`pi-code-mcp: prompt refresh failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
965
+ }
966
+ })
967
+ } catch {
968
+ // a transport or client without notification support simply never refreshes
969
+ }
970
+ }
971
+
972
+ /** Servers currently connected that advertise the resources capability. */
973
+ const resourceServers = (): Array<[string, Client]> => [...clients.entries()].filter(([, client]) => Boolean(client.getServerCapabilities()?.resources))
974
+
975
+ let resourceToolsRegistered = false
976
+
977
+ /** Claude auto-provides tools to list and read MCP resources when servers support
978
+ * them. Registered once, globally, the first time a connected server advertises the
979
+ * resources capability: the tools span servers, taking the server name as an
980
+ * argument, so per-server registration would only produce duplicates. Listings are
981
+ * fetched live on every call, so a resources list_changed needs no cache
982
+ * invalidation; its handler only re-checks this gate (see subscribeToResourceChanges). */
983
+ function ensureResourceTools(): void {
984
+ if (resourceToolsRegistered || resourceServers().length === 0) return
985
+ resourceToolsRegistered = true
986
+ pi.registerTool({
987
+ name: 'list_mcp_resources',
988
+ label: 'List MCP resources',
989
+ description: 'List available resources and resource templates from connected MCP servers. Optionally filter to a single server by name.',
990
+ parameters: Type.Object({ server: Type.Optional(Type.String({ description: 'Only list resources from this server' })) }),
991
+ async execute(_id, params) {
992
+ const filter = resourceServerFilter(params)
993
+ if (filter && !clients.has(filter)) throw new Error(`MCP server "${filter}" is not connected`)
994
+ const entries: Array<Record<string, unknown>> = []
995
+ for (const [name, client] of resourceServers()) {
996
+ if (filter && name !== filter) continue
997
+ await collectServerResourceEntries(entries, name, client, callTimeoutMs())
998
+ }
999
+ return { content: mapContent([{ type: 'text', text: JSON.stringify(entries, null, 2) }]), details: {} }
1000
+ },
1001
+ })
1002
+ pi.registerTool({
1003
+ name: 'read_mcp_resource',
1004
+ label: 'Read MCP resource',
1005
+ description: 'Read a resource from a connected MCP server by URI.',
1006
+ parameters: Type.Object({ server: Type.String({ description: 'The MCP server name' }), uri: Type.String({ description: 'The resource URI to read' }) }),
1007
+ async execute(_id, params) {
1008
+ const { server, uri } = params as { server: string; uri: string }
1009
+ const client = clients.get(server)
1010
+ if (!client) throw new Error(`MCP server "${server}" is not connected`)
1011
+ const budget = callTimeoutMs()
1012
+ const result = await withTimeout(client.readResource({ uri }, { timeout: budget }), budget, `read ${uri}`)
1013
+ const blocks = (result.contents as Array<{ uri: string; text?: string; blob?: string; mimeType?: string }>).map((entry): McpContentBlock => {
1014
+ if (typeof entry.text === 'string') return { type: 'resource', resource: { uri: entry.uri, text: entry.text } }
1015
+ if (entry.blob && entry.mimeType?.startsWith('image/')) return { type: 'image', data: entry.blob, mimeType: entry.mimeType }
1016
+ // Non-image binary has no useful text form; a placeholder beats megabytes
1017
+ // of base64 reaching the model as JSON.
1018
+ return { type: 'text', text: `[Binary resource ${entry.uri} (${entry.mimeType ?? 'unknown type'})]` }
1019
+ })
1020
+ return { content: mapContent(blocks), details: {} }
1021
+ },
1022
+ })
1023
+ }
1024
+
1025
+ /** Resource listings are fetched live per call, so the notification has no cache to
1026
+ * invalidate; re-checking the registration gate covers a server whose capabilities
1027
+ * settled after the connect-time check. */
1028
+ function subscribeToResourceChanges(client: Client): void {
1029
+ try {
1030
+ client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
1031
+ ensureResourceTools()
1032
+ })
1033
+ } catch {
1034
+ // a transport or client without notification support simply never refreshes
1035
+ }
1036
+ }
1037
+
665
1038
  /** Claude refreshes tools on a server's list_changed notification. pi has no
666
1039
  * unregister, so a withdrawn tool keeps its registration and surfaces the server's
667
1040
  * own error when called; a newly announced one is registered without a restart. */
@@ -670,7 +1043,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
670
1043
  client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
671
1044
  try {
672
1045
  const refreshed = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
673
- const added = registerTools(name, config, client, refreshed)
1046
+ const added = registerTools(name, config, refreshed)
674
1047
  if (added === 0) return
675
1048
  const current = status.get(name)
676
1049
  status.set(name, { state: current?.state ?? 'connected', tools: (current?.tools ?? 0) + added })
@@ -706,8 +1079,14 @@ export default async function mcpExtension(pi: ExtensionAPI) {
706
1079
  const client = await connect(name, config, authUi)
707
1080
  clients.set(name, client)
708
1081
  const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
709
- const count = registerTools(name, config, client, tools)
1082
+ const count = registerTools(name, config, tools)
710
1083
  subscribeToToolChanges(name, config, client)
1084
+ // Prompts and resources are additive surfaces: their failures warn (inside
1085
+ // connectPrompts) rather than flipping a tool-serving server to failed.
1086
+ await connectPrompts(name, client)
1087
+ subscribeToPromptChanges(name, client)
1088
+ ensureResourceTools()
1089
+ subscribeToResourceChanges(client)
711
1090
  status.set(name, { state: 'connected', tools: count })
712
1091
  // A server that dies mid-session would otherwise stay "connected" in /mcp
713
1092
  // while every call fails with the SDK's bare "Not connected"; flip the
@@ -749,15 +1128,6 @@ export default async function mcpExtension(pi: ExtensionAPI) {
749
1128
  return true
750
1129
  }
751
1130
 
752
- /** The OAuth flow's UI seams, absent in headless runs. */
753
- function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
754
- if (!ctx.hasUI) return undefined
755
- return {
756
- confirm: (title, body) => ctx.ui.confirm(title, body),
757
- notify: (message, level) => ctx.ui.notify(message, level),
758
- }
759
- }
760
-
761
1131
  let projectConnected = false
762
1132
 
763
1133
  pi.on('session_start', async (_event, ctx) => {
@@ -770,7 +1140,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
770
1140
  const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
771
1141
  const { allowed, denied } = mcpAllowDeny()
772
1142
  const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, allowed, denied)
773
- const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name)))
1143
+ // Claude's precedence is project over user for a duplicate name. A project .mcp.json
1144
+ // server only outranks the user's own when it will actually connect (the user already
1145
+ // consented to it, or an approved project's), so a merely-present untrusted project
1146
+ // entry cannot shadow a trusted user server by reusing its name. A gated project
1147
+ // server still awaiting the approval prompt does not preempt the user server: that is
1148
+ // a deliberate narrowing of Claude's rule to keep the safe default.
1149
+ const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
1150
+ const projectWinners = new Set(Object.keys(splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy).consented))
1151
+ const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
774
1152
  if (Object.keys(userServers).length > 0) await connectServers(userServers, authUiFor(ctx))
775
1153
  // A project .mcp.json can run arbitrary commands on connect, so only honor it once
776
1154
  // the project is trusted. Per-server settings refine that: disabled servers never