pi-code 1.0.5 → 1.0.6

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
@@ -16,6 +16,17 @@
16
16
  * Values support ${VAR} / ${VAR:-default} interpolation, connect and per-call timeouts
17
17
  * honor MCP_TIMEOUT / MCP_TOOL_TIMEOUT, and a stdio server receives only the SDK's default
18
18
  * environment plus its own `env` block, not the whole process environment.
19
+ *
20
+ * Servers advertising the `prompts` capability get their prompts registered as Claude's
21
+ * /mcp__<server>__<prompt> slash commands (names normalized dashes/spaces to underscores,
22
+ * args space-separated and mapped positionally); the prompt result drives a turn via
23
+ * sendUserMessage, exactly how custom slash commands do. Servers advertising `resources`
24
+ * make the global list_mcp_resources / read_mcp_resource tools available, mirroring
25
+ * Claude's automatic resource tools. Resource and prompt output rides the same
26
+ * mapContent/capForContext budget as tool output. That budget is byte/line based
27
+ * (pi's DEFAULT_MAX_BYTES in the shared output guard); Claude's MAX_MCP_OUTPUT_TOKENS
28
+ * is a token budget and cannot be folded into it without making the guard token-aware,
29
+ * so the byte cap stands in for it.
19
30
  */
20
31
 
21
32
  import { execFile } from 'node:child_process'
@@ -32,8 +43,9 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' //
32
43
  import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
33
44
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
34
45
  import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'
35
- import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
46
+ import { PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
36
47
  import { Type } from 'typebox'
48
+ import { splitArgs } from './internal/command-file.js'
37
49
  import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
38
50
  import { setMcpToolCaller } from './internal/mcp-call.js'
39
51
  import { FileOAuthProvider, openBrowser, startCallbackServer, waitForAuthCode } from './internal/mcp-oauth.js'
@@ -61,7 +73,9 @@ const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_
61
73
  // pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
62
74
  // These are pi-code's own tools, and mcp.ts registers before the extensions owning
63
75
  // 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'])
76
+ // The resource tools are this extension's own globals; a server named `list` or `read`
77
+ // must not take their names either.
78
+ const RESERVED_NAMES = new Set(['web_fetch', 'web_search', 'plan_mode_complete', 'list_mcp_resources', 'read_mcp_resource'])
65
79
 
66
80
  export interface StdioServerConfig {
67
81
  type?: 'stdio'
@@ -193,6 +207,29 @@ export function loadUserScope(home: string, cwd: string): Record<string, ServerC
193
207
  return servers
194
208
  }
195
209
 
210
+ /** The mcpServers one plugin declares: an inline map on the manifest, or the file it
211
+ * points to (default .mcp.json at the plugin root), with ${CLAUDE_PLUGIN_*} substituted
212
+ * before parsing. Malformed or missing JSON yields no entries. */
213
+ function pluginServerEntries(plugin: InstalledPlugin): Record<string, ServerConfig> {
214
+ const declared = plugin.manifest.mcpServers
215
+ // An inline map of name -> config; an array is not a valid mcpServers map (it
216
+ // would register a server named '0'), so it falls through to the path branch.
217
+ if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
218
+ try {
219
+ return JSON.parse(substitutePluginVars(JSON.stringify(declared), plugin))
220
+ } catch {
221
+ return {}
222
+ }
223
+ }
224
+ const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : '.mcp.json')
225
+ try {
226
+ const parsed = JSON.parse(substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin))
227
+ return parsed.mcpServers ?? {}
228
+ } catch {
229
+ return {}
230
+ }
231
+ }
232
+
196
233
  /** Servers shipped by enabled plugins (.mcp.json or the manifest's `mcpServers`,
197
234
  * inline or by path), with ${CLAUDE_PLUGIN_*} substituted before parsing. Their
198
235
  * tools alias as mcp__plugin_<plugin>_<server>__<tool> for hook matchers, as
@@ -201,26 +238,7 @@ export function loadPluginServers(plugins: InstalledPlugin[]): Record<string, Se
201
238
  const fold = (name: string): string => name.replaceAll('-', '_')
202
239
  const servers: Record<string, ServerConfig> = {}
203
240
  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)) {
241
+ for (const [name, config] of Object.entries(pluginServerEntries(plugin))) {
224
242
  servers[name] = { ...config, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__` }
225
243
  }
226
244
  }
@@ -259,7 +277,7 @@ export function parseHelperHeaders(stdout: string): Record<string, string> {
259
277
  export function managedSettingsPath(platform: NodeJS.Platform = process.platform): string {
260
278
  if (platform === 'darwin') return '/Library/Application Support/ClaudeCode/managed-settings.json'
261
279
  // 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'
280
+ if (platform === 'win32') return String.raw`C:\Program Files\ClaudeCode\managed-settings.json`
263
281
  return '/etc/claude-code/managed-settings.json'
264
282
  }
265
283
 
@@ -282,8 +300,12 @@ export function mcpAllowDeny(managedFile: string = managedSettingsFileOverride ?
282
300
  } catch {
283
301
  // No managed policy on this machine: no restriction.
284
302
  }
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) : []
303
+ const entryName = (entry: unknown): string | undefined => {
304
+ if (typeof entry === 'string') return entry
305
+ const serverName = (entry as { serverName?: unknown })?.serverName
306
+ return typeof serverName === 'string' ? serverName : undefined
307
+ }
308
+ const names = (value: unknown): string[] => (Array.isArray(value) ? value.map(entryName).filter((name): name is string => typeof name === 'string' && name.length > 0) : [])
287
309
  return {
288
310
  allowed: Array.isArray(settings.allowedMcpServers) ? new Set(names(settings.allowedMcpServers)) : null,
289
311
  denied: new Set(names(settings.deniedMcpServers)),
@@ -319,6 +341,52 @@ export function formatToolName(server: string, tool: string): string {
319
341
  return `${server}_${tool}`.replaceAll('-', '_')
320
342
  }
321
343
 
344
+ /** Claude exposes server prompts as /mcp__<server>__<prompt> slash commands. Both
345
+ * names normalize like formatToolName, extended to spaces: dashes and spaces each
346
+ * become an underscore. */
347
+ export function formatPromptCommandName(server: string, prompt: string): string {
348
+ const normalize = (name: string): string => name.replace(/[\s-]/g, '_')
349
+ return `mcp__${normalize(server)}__${normalize(prompt)}`
350
+ }
351
+
352
+ export interface McpPromptArgumentInfo {
353
+ name: string
354
+ description?: string
355
+ required?: boolean
356
+ }
357
+
358
+ export interface McpPromptInfo {
359
+ name: string
360
+ description?: string
361
+ arguments?: McpPromptArgumentInfo[]
362
+ }
363
+
364
+ /** Claude passes prompt arguments space-separated after the command. Tokens map
365
+ * positionally onto the declared arguments, split the way slash-command args are
366
+ * (quoted runs stay together); the last declared argument absorbs any trailing
367
+ * tokens so free text at the end is not silently dropped. Declared arguments with
368
+ * no token are omitted, and the server enforces its own `required`. */
369
+ export function mapPromptArguments(declared: ReadonlyArray<{ name: string }> | undefined, args: string): Record<string, string> {
370
+ const tokens = splitArgs(args)
371
+ const names = (declared ?? []).map((argument) => argument.name)
372
+ const mapped: Record<string, string> = {}
373
+ for (let index = 0; index < names.length && index < tokens.length; index++) {
374
+ mapped[names[index]] = index === names.length - 1 ? tokens.slice(index).join(' ') : tokens[index]
375
+ }
376
+ return mapped
377
+ }
378
+
379
+ /** The content blocks a getPrompt result injects. Each message carries one content
380
+ * block; the blocks ride the same mapContent budget as tool output, and image blocks
381
+ * are carried through rather than dropped, since sendUserMessage accepts them and a
382
+ * vision prompt is worthless flattened to text. An empty message list yields no
383
+ * blocks, and messages that carry only empty text yield none either, so the caller
384
+ * can skip the turn rather than drive it on an empty or sentinel message. */
385
+ export function promptMessageContent(messages: ReadonlyArray<{ content: unknown }>): ToolContent[] {
386
+ if (messages.length === 0) return []
387
+ return mapContent(messages.map((message) => message.content as McpContentBlock)).filter((block) => block.type !== 'text' || block.text.trim() !== '')
388
+ }
389
+
322
390
  export function normalizeSchema(schema: unknown): object {
323
391
  const base = (schema as Record<string, unknown>) ?? {}
324
392
  const { $schema: _dropSchema, additionalProperties: _dropAdditional, ...rest } = base
@@ -491,6 +559,15 @@ export interface AuthUi {
491
559
  notify: (message: string, level: 'info' | 'warning' | 'error') => void
492
560
  }
493
561
 
562
+ /** The OAuth flow's UI seams, absent in headless runs. */
563
+ function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
564
+ if (!ctx.hasUI) return undefined
565
+ return {
566
+ confirm: (title, body) => ctx.ui.confirm(title, body),
567
+ notify: (message, level) => ctx.ui.notify(message, level),
568
+ }
569
+ }
570
+
494
571
  /** Browser logins are human-paced; a connect-sized timeout would cut them off. */
495
572
  const OAUTH_FLOW_TIMEOUT_MS = 180_000
496
573
 
@@ -499,6 +576,14 @@ const OAUTH_FLOW_TIMEOUT_MS = 180_000
499
576
  * from a transport mismatch without matching on message text. */
500
577
  class OAuthRequiredError extends Error {}
501
578
 
579
+ /** Wrap a login-flow failure as OAuthRequiredError, passing an existing one through
580
+ * unchanged so its message is not doubled. */
581
+ function asOAuthRequiredError(name: string, error: unknown): OAuthRequiredError {
582
+ if (error instanceof OAuthRequiredError) return error
583
+ const detail = error instanceof Error ? error.message : String(error)
584
+ return new OAuthRequiredError(`login for ${name} failed: ${detail}`)
585
+ }
586
+
502
587
  /** Whether a connect failure is an authentication problem: the SDK's own
503
588
  * UnauthorizedError, a transport error carrying HTTP 401 (which is what a 401
504
589
  * throws when no authProvider was attached, so a first-time login is detected),
@@ -508,6 +593,13 @@ function isUnauthorized(error: unknown): boolean {
508
593
  return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 401
509
594
  }
510
595
 
596
+ // SSEClientTransport is deprecated in favour of Streamable HTTP, but both concrete
597
+ // transports expose finishAuth (the base Transport interface does not), so the union
598
+ // stays as the http-family fallback type through the migration period.
599
+ type HttpFamilyTransport = SSEClientTransport | StreamableHTTPClientTransport // NOSONAR typescript:S1874 - SSE fallback still required by the MCP SDK
600
+
601
+ type MakeTransport = (authProvider?: OAuthClientProvider) => HttpFamilyTransport
602
+
511
603
  /**
512
604
  * Connect an http-family server, running Claude's OAuth login when the server
513
605
  * demands one. Stored tokens ride the first attempt so the SDK refreshes
@@ -516,7 +608,7 @@ function isUnauthorized(error: unknown): boolean {
516
608
  * Bearer-token servers never enter the OAuth path: an explicit token is the
517
609
  * user saying how auth works.
518
610
  */
519
- async function connectHttpFamily(name: string, config: { url: string }, makeTransport: (authProvider?: OAuthClientProvider) => SSEClientTransport | StreamableHTTPClientTransport, label: string, bearerToken: string | undefined, authUi: AuthUi | undefined): Promise<Client> {
611
+ async function connectHttpFamily(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, bearerToken: string | undefined, authUi: AuthUi | undefined): Promise<Client> {
520
612
  const newClient = () => new Client({ name: 'pi-code-mcp', version: '0.1.0' })
521
613
  // Stored tokens ride the first attempt so the SDK refreshes them; with none, no
522
614
  // provider is attached, so a 401 surfaces as a transport error carrying code 401
@@ -530,39 +622,47 @@ async function connectHttpFamily(name: string, config: { url: string }, makeTran
530
622
  } catch (error) {
531
623
  if (bearerToken || !isUnauthorized(error)) throw error
532
624
  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)
625
+ return await runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient)
626
+ }
627
+ }
628
+
629
+ /**
630
+ * The interactive half of the OAuth login, reached only once a silent connect has
631
+ * failed with a 401 and a UI is present: confirm, open the browser, catch the loopback
632
+ * redirect, and exchange the code via the SDK's finishAuth. Past the confirm the server
633
+ * is known to need OAuth, so any failure here (a denied consent page, the 180s wait, a
634
+ * token exchange error) is wrapped as an auth failure, not a transport mismatch: that
635
+ * keeps the typeless-url caller from retrying over SSE and prompting for a second login.
636
+ */
637
+ async function runInteractiveOAuth(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, authUi: AuthUi, newClient: () => Client): Promise<Client> {
638
+ const approved = await authUi.confirm(`MCP server "${name}" requires login`, `Open your browser to authorize ${config.url}?`)
639
+ if (!approved) throw new OAuthRequiredError(`login declined for ${name}`)
640
+ const provider = new FileOAuthProvider(name, (authorizationUrl) => {
641
+ openBrowser(String(authorizationUrl))
642
+ authUi.notify(`Authorize "${name}" in the browser. If it did not open: ${authorizationUrl}`, 'info')
643
+ })
644
+ const { server, port } = await startCallbackServer(provider.savedRedirectPort())
645
+ provider.bindRedirectPort(port)
646
+ try {
647
+ const transport = makeTransport(provider)
648
+ const pendingCode = waitForAuthCode(server, OAUTH_FLOW_TIMEOUT_MS)
649
+ pendingCode.catch(() => {}) // consumed below; an abandoned login must not surface as unhandled
650
+ const client = newClient()
541
651
  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()
652
+ await connectWithTimeout(client, transport, label)
653
+ return client // authorized between attempts; nothing left to exchange
654
+ } catch (retryError) {
655
+ if (!isUnauthorized(retryError)) throw retryError
656
+ const code = await pendingCode
657
+ await transport.finishAuth(code)
658
+ const authed = newClient()
659
+ await connectWithTimeout(authed, makeTransport(provider), label)
660
+ return authed
565
661
  }
662
+ } catch (flowError) {
663
+ throw asOAuthRequiredError(name, flowError)
664
+ } finally {
665
+ server.close()
566
666
  }
567
667
  }
568
668
 
@@ -603,6 +703,73 @@ async function listAllTools(client: Client): Promise<McpToolInfo[]> {
603
703
  return tools
604
704
  }
605
705
 
706
+ async function listAllPrompts(client: Client): Promise<McpPromptInfo[]> {
707
+ const prompts: McpPromptInfo[] = []
708
+ let cursor: string | undefined
709
+ do {
710
+ const page = await client.listPrompts({ cursor })
711
+ prompts.push(...page.prompts)
712
+ cursor = page.nextCursor
713
+ } while (cursor)
714
+ return prompts
715
+ }
716
+
717
+ /** One resource's flat record for the list_mcp_resources output, dropping the optional
718
+ * description/mimeType when the server omits them. */
719
+ function resourceEntry(server: string, resource: { uri: string; name: string; description?: string; mimeType?: string }): Record<string, unknown> {
720
+ return { server, uri: resource.uri, name: resource.name, ...(resource.description ? { description: resource.description } : {}), ...(resource.mimeType ? { mimeType: resource.mimeType } : {}) }
721
+ }
722
+
723
+ /** One resource template's flat record, likewise dropping absent optional fields. */
724
+ function resourceTemplateEntry(server: string, template: { uriTemplate: string; name: string; description?: string; mimeType?: string }): Record<string, unknown> {
725
+ return { server, uriTemplate: template.uriTemplate, name: template.name, ...(template.description ? { description: template.description } : {}), ...(template.mimeType ? { mimeType: template.mimeType } : {}) }
726
+ }
727
+
728
+ /** Page a server's resources to exhaustion under the call budget, appending each as a
729
+ * flat record. Pushed into the caller's array incrementally so a mid-pagination failure
730
+ * still leaves the earlier pages in place. */
731
+ async function collectResources(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
732
+ let cursor: string | undefined
733
+ do {
734
+ const page = await withTimeout(client.listResources({ cursor }, { timeout: budget }), budget, `list resources ${name}`)
735
+ for (const resource of page.resources) entries.push(resourceEntry(name, resource))
736
+ cursor = page.nextCursor
737
+ } while (cursor)
738
+ }
739
+
740
+ /** Page a server's resource templates to exhaustion under the call budget. */
741
+ async function collectResourceTemplates(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
742
+ let cursor: string | undefined
743
+ do {
744
+ const page = await withTimeout(client.listResourceTemplates({ cursor }, { timeout: budget }), budget, `list resource templates ${name}`)
745
+ for (const template of page.resourceTemplates) entries.push(resourceTemplateEntry(name, template))
746
+ cursor = page.nextCursor
747
+ } while (cursor)
748
+ }
749
+
750
+ /** Append every resource and template one server exposes. A resource-listing failure
751
+ * surfaces inline as an error record, so one server cannot empty the whole listing; a
752
+ * template-listing failure is silent, templates being optional (a server with the
753
+ * resources capability but no templates answers method-not-found). */
754
+ async function collectServerResourceEntries(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
755
+ try {
756
+ await collectResources(entries, name, client, budget)
757
+ } catch (error) {
758
+ entries.push({ server: name, error: error instanceof Error ? error.message : String(error) })
759
+ }
760
+ try {
761
+ await collectResourceTemplates(entries, name, client, budget)
762
+ } catch {
763
+ // Templates are optional: a method-not-found here is not worth reporting.
764
+ }
765
+ }
766
+
767
+ /** The optional server-name filter for list_mcp_resources: a non-empty string, else undefined. */
768
+ function resourceServerFilter(params: unknown): string | undefined {
769
+ const server = (params as { server?: unknown }).server
770
+ return typeof server === 'string' && server.length > 0 ? server : undefined
771
+ }
772
+
606
773
  export default async function mcpExtension(pi: ExtensionAPI) {
607
774
  const clients = new Map<string, Client>()
608
775
  const status = new Map<string, { state: string; tools: number }>()
@@ -662,6 +829,147 @@ export default async function mcpExtension(pi: ExtensionAPI) {
662
829
  return count
663
830
  }
664
831
 
832
+ // Prompt command name -> the server and prompt that own it, so a refresh re-listing
833
+ // the same prompt is told apart both from a cross-server collision and from a second
834
+ // prompt on the same server whose name normalizes to the one already taken (e.g.
835
+ // `deploy-prod` and `deploy_prod`), mirroring `registered` for tools.
836
+ const registeredPrompts = new Map<string, { server: string; prompt: string }>()
837
+
838
+ /** Register a slash command for every not-yet-registered prompt of a server. pi has
839
+ * no command unregister, so, like tools, a withdrawn prompt keeps its registration
840
+ * and surfaces the server's own error when invoked; an edit to a prompt's declared
841
+ * arguments only lands on new names, since an existing command keeps its binding. */
842
+ function registerPrompts(name: string, client: Client, prompts: McpPromptInfo[]): void {
843
+ for (const prompt of prompts) {
844
+ const commandName = formatPromptCommandName(name, prompt.name)
845
+ const owner = registeredPrompts.get(commandName)
846
+ if (owner) {
847
+ if (owner.server === name && owner.prompt === prompt.name) continue // a refresh re-listing the same prompt
848
+ console.warn(`pi-code-mcp: skipping colliding prompt command ${commandName}`)
849
+ continue
850
+ }
851
+ registeredPrompts.set(commandName, { server: name, prompt: prompt.name })
852
+ const hint = (prompt.arguments ?? []).map((argument) => (argument.required ? `<${argument.name}>` : `[${argument.name}]`)).join(' ')
853
+ const base = prompt.description ?? `MCP prompt ${prompt.name} from ${name}`
854
+ pi.registerCommand(commandName, {
855
+ description: hint ? `${base} ${hint}` : base,
856
+ handler: async (args, ctx) => {
857
+ try {
858
+ const promptArgs = mapPromptArguments(prompt.arguments, args)
859
+ const params: { name: string; arguments?: Record<string, string> } = { name: prompt.name }
860
+ if (Object.keys(promptArgs).length > 0) params.arguments = promptArgs
861
+ const budget = callTimeoutMs()
862
+ const result = await withTimeout(client.getPrompt(params, { timeout: budget }), budget, commandName)
863
+ // The prompt drives a turn exactly the way a custom slash command does
864
+ // (see commands.ts), carrying its image blocks through. A prompt that
865
+ // yields no content is reported rather than sent as an empty turn.
866
+ const content = promptMessageContent(result.messages)
867
+ if (content.length === 0) {
868
+ ctx.ui.notify(`${commandName}: prompt returned no content`, 'info')
869
+ return
870
+ }
871
+ pi.sendUserMessage(content)
872
+ } catch (error) {
873
+ ctx.ui.notify(`${commandName}: ${error instanceof Error ? error.message : String(error)}`, 'error')
874
+ }
875
+ },
876
+ })
877
+ }
878
+ }
879
+
880
+ /** Claude exposes prompts as slash commands only for servers advertising the
881
+ * prompts capability; a listing failure loses the prompts, not the server. */
882
+ async function connectPrompts(name: string, client: Client): Promise<void> {
883
+ if (!client.getServerCapabilities()?.prompts) return
884
+ try {
885
+ registerPrompts(name, client, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
886
+ } catch (error) {
887
+ console.warn(`pi-code-mcp: prompt listing failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
888
+ }
889
+ }
890
+
891
+ /** Mirror of subscribeToToolChanges for the prompt list: a newly announced prompt
892
+ * registers without a restart, a withdrawn one keeps its registration. */
893
+ function subscribeToPromptChanges(name: string, client: Client): void {
894
+ try {
895
+ client.setNotificationHandler(PromptListChangedNotificationSchema, async () => {
896
+ try {
897
+ registerPrompts(name, client, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
898
+ } catch (error) {
899
+ console.warn(`pi-code-mcp: prompt refresh failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
900
+ }
901
+ })
902
+ } catch {
903
+ // a transport or client without notification support simply never refreshes
904
+ }
905
+ }
906
+
907
+ /** Servers currently connected that advertise the resources capability. */
908
+ const resourceServers = (): Array<[string, Client]> => [...clients.entries()].filter(([, client]) => Boolean(client.getServerCapabilities()?.resources))
909
+
910
+ let resourceToolsRegistered = false
911
+
912
+ /** Claude auto-provides tools to list and read MCP resources when servers support
913
+ * them. Registered once, globally, the first time a connected server advertises the
914
+ * resources capability: the tools span servers, taking the server name as an
915
+ * argument, so per-server registration would only produce duplicates. Listings are
916
+ * fetched live on every call, so a resources list_changed needs no cache
917
+ * invalidation; its handler only re-checks this gate (see subscribeToResourceChanges). */
918
+ function ensureResourceTools(): void {
919
+ if (resourceToolsRegistered || resourceServers().length === 0) return
920
+ resourceToolsRegistered = true
921
+ pi.registerTool({
922
+ name: 'list_mcp_resources',
923
+ label: 'List MCP resources',
924
+ description: 'List available resources and resource templates from connected MCP servers. Optionally filter to a single server by name.',
925
+ parameters: Type.Object({ server: Type.Optional(Type.String({ description: 'Only list resources from this server' })) }),
926
+ async execute(_id, params) {
927
+ const filter = resourceServerFilter(params)
928
+ if (filter && !clients.has(filter)) throw new Error(`MCP server "${filter}" is not connected`)
929
+ const entries: Array<Record<string, unknown>> = []
930
+ for (const [name, client] of resourceServers()) {
931
+ if (filter && name !== filter) continue
932
+ await collectServerResourceEntries(entries, name, client, callTimeoutMs())
933
+ }
934
+ return { content: mapContent([{ type: 'text', text: JSON.stringify(entries, null, 2) }]), details: {} }
935
+ },
936
+ })
937
+ pi.registerTool({
938
+ name: 'read_mcp_resource',
939
+ label: 'Read MCP resource',
940
+ description: 'Read a resource from a connected MCP server by URI.',
941
+ parameters: Type.Object({ server: Type.String({ description: 'The MCP server name' }), uri: Type.String({ description: 'The resource URI to read' }) }),
942
+ async execute(_id, params) {
943
+ const { server, uri } = params as { server: string; uri: string }
944
+ const client = clients.get(server)
945
+ if (!client) throw new Error(`MCP server "${server}" is not connected`)
946
+ const budget = callTimeoutMs()
947
+ const result = await withTimeout(client.readResource({ uri }, { timeout: budget }), budget, `read ${uri}`)
948
+ const blocks = (result.contents as Array<{ uri: string; text?: string; blob?: string; mimeType?: string }>).map((entry): McpContentBlock => {
949
+ if (typeof entry.text === 'string') return { type: 'resource', resource: { uri: entry.uri, text: entry.text } }
950
+ if (entry.blob && entry.mimeType?.startsWith('image/')) return { type: 'image', data: entry.blob, mimeType: entry.mimeType }
951
+ // Non-image binary has no useful text form; a placeholder beats megabytes
952
+ // of base64 reaching the model as JSON.
953
+ return { type: 'text', text: `[Binary resource ${entry.uri} (${entry.mimeType ?? 'unknown type'})]` }
954
+ })
955
+ return { content: mapContent(blocks), details: {} }
956
+ },
957
+ })
958
+ }
959
+
960
+ /** Resource listings are fetched live per call, so the notification has no cache to
961
+ * invalidate; re-checking the registration gate covers a server whose capabilities
962
+ * settled after the connect-time check. */
963
+ function subscribeToResourceChanges(client: Client): void {
964
+ try {
965
+ client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
966
+ ensureResourceTools()
967
+ })
968
+ } catch {
969
+ // a transport or client without notification support simply never refreshes
970
+ }
971
+ }
972
+
665
973
  /** Claude refreshes tools on a server's list_changed notification. pi has no
666
974
  * unregister, so a withdrawn tool keeps its registration and surfaces the server's
667
975
  * own error when called; a newly announced one is registered without a restart. */
@@ -708,6 +1016,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
708
1016
  const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
709
1017
  const count = registerTools(name, config, client, tools)
710
1018
  subscribeToToolChanges(name, config, client)
1019
+ // Prompts and resources are additive surfaces: their failures warn (inside
1020
+ // connectPrompts) rather than flipping a tool-serving server to failed.
1021
+ await connectPrompts(name, client)
1022
+ subscribeToPromptChanges(name, client)
1023
+ ensureResourceTools()
1024
+ subscribeToResourceChanges(client)
711
1025
  status.set(name, { state: 'connected', tools: count })
712
1026
  // A server that dies mid-session would otherwise stay "connected" in /mcp
713
1027
  // while every call fails with the SDK's bare "Not connected"; flip the
@@ -749,15 +1063,6 @@ export default async function mcpExtension(pi: ExtensionAPI) {
749
1063
  return true
750
1064
  }
751
1065
 
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
1066
  let projectConnected = false
762
1067
 
763
1068
  pi.on('session_start', async (_event, ctx) => {
@@ -159,6 +159,33 @@ export function saveMemory(dir: string, indexPath: string, name: string | undefi
159
159
  return { content: [{ type: 'text', text: `Saved memory ${name}.` }], details: {} }
160
160
  }
161
161
 
162
+ /** The read action: a memory's body, capped for context, or a not-found message. */
163
+ function readMemory(dir: string, name: string): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
164
+ try {
165
+ const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
166
+ return { content: [{ type: 'text', text: capForContext(body) }], details: {} }
167
+ } catch {
168
+ return { content: [{ type: 'text', text: `No memory named ${name}.` }], details: {} }
169
+ }
170
+ }
171
+
172
+ /** The delete action: remove a memory file and its index line. The index is read
173
+ * before anything is removed: refusing on a failed read must leave both the memory
174
+ * file and the index as they were. */
175
+ function deleteMemory(dir: string, indexPath: string, name: string): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
176
+ let index: string
177
+ try {
178
+ index = readIndex(dir)
179
+ } catch (error) {
180
+ return { content: [{ type: 'text', text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
181
+ }
182
+ fs.rmSync(path.join(dir, `${name}.md`), { force: true })
183
+ const remaining = removeIndexLine(index, name)
184
+ if (remaining) writeIndex(indexPath, remaining)
185
+ else fs.rmSync(indexPath, { force: true })
186
+ return { content: [{ type: 'text', text: `Deleted memory ${name}.` }], details: {} }
187
+ }
188
+
162
189
  /** The index as injected into the prompt, bounded like Claude's startup load. */
163
190
  export function capIndexForPrompt(index: string): string {
164
191
  const loaded = stripNonLoaded(index)
@@ -327,29 +354,12 @@ export default function memoryExtension(pi: ExtensionAPI) {
327
354
 
328
355
  if (params.action === 'read') {
329
356
  if (!name) return { content: [{ type: 'text' as const, text: 'read requires name.' }], details: {} }
330
- try {
331
- const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
332
- return { content: [{ type: 'text' as const, text: capForContext(body) }], details: {} }
333
- } catch {
334
- return { content: [{ type: 'text' as const, text: `No memory named ${name}.` }], details: {} }
335
- }
357
+ return readMemory(dir, name)
336
358
  }
337
359
 
338
360
  if (params.action === 'delete') {
339
361
  if (!name) return { content: [{ type: 'text' as const, text: 'delete requires name.' }], details: {} }
340
- // The index is read before anything is removed: refusing on a failed read
341
- // must leave both the memory file and the index as they were.
342
- let index: string
343
- try {
344
- index = readIndex(dir)
345
- } catch (error) {
346
- return { content: [{ type: 'text' as const, text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
347
- }
348
- fs.rmSync(path.join(dir, `${name}.md`), { force: true })
349
- const remaining = removeIndexLine(index, name)
350
- if (remaining) writeIndex(indexPath, remaining)
351
- else fs.rmSync(indexPath, { force: true })
352
- return { content: [{ type: 'text' as const, text: `Deleted memory ${name}.` }], details: {} }
362
+ return deleteMemory(dir, indexPath, name)
353
363
  }
354
364
 
355
365
  const index = readIndexQuietly(dir)
@@ -104,10 +104,9 @@ export default function notifyExtension(pi: ExtensionAPI) {
104
104
  // Claude's "appear to be away" check. Undefined until the first prompt this session.
105
105
  let lastInputAt: number | undefined
106
106
 
107
- pi.on('session_start', async (_event, ctx) => {
107
+ pi.on('session_start', async (_event, _ctx) => {
108
108
  channel = resolveNotifChannel(readPreferredNotifChannel(os.homedir()))
109
109
  lastInputAt = undefined
110
- void ctx
111
110
  })
112
111
 
113
112
  pi.on('input', async () => {