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/commands.ts +303 -85
- package/extensions/context-imports.ts +162 -91
- package/extensions/hooks.ts +139 -27
- package/extensions/internal/command-file.ts +91 -48
- package/extensions/internal/html-markdown.ts +11 -1
- package/extensions/internal/instruction-events.ts +2 -2
- package/extensions/internal/managed-settings.ts +1 -1
- package/extensions/internal/mcp-oauth.ts +13 -7
- package/extensions/internal/plugins.ts +29 -16
- package/extensions/internal/strip-comments.ts +56 -33
- package/extensions/mcp.ts +371 -66
- package/extensions/memory.ts +29 -19
- package/extensions/notify.ts +1 -2
- package/extensions/status-line.ts +8 -3
- package/extensions/subagent/index.ts +119 -5
- package/extensions/web.ts +39 -26
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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
|
|
286
|
-
|
|
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:
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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) => {
|
package/extensions/memory.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
package/extensions/notify.ts
CHANGED
|
@@ -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,
|
|
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 () => {
|