pi-code 1.0.4 → 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/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +417 -41
- package/extensions/context-imports.ts +446 -61
- package/extensions/hooks.ts +473 -73
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +423 -66
- package/extensions/internal/html-markdown.ts +71 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +177 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +138 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +100 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +579 -30
- package/extensions/memory.ts +158 -35
- package/extensions/notify.ts +76 -4
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +100 -5
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +25 -6
- package/extensions/subagent/index.ts +310 -31
- package/extensions/web.ts +93 -15
- package/package.json +1 -1
package/extensions/mcp.ts
CHANGED
|
@@ -16,24 +16,43 @@
|
|
|
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
|
|
|
32
|
+
import { execFile } from 'node:child_process'
|
|
21
33
|
import * as fs from 'node:fs'
|
|
22
34
|
import * as os from 'node:os'
|
|
23
35
|
import * as path from 'node:path'
|
|
24
36
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
25
37
|
import { DEFAULT_MAX_BYTES } from '@earendil-works/pi-coding-agent'
|
|
26
|
-
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
27
38
|
// SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
|
|
28
39
|
// the old spec exist, so this stays as a fallback for the migration period.
|
|
40
|
+
import { type OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
|
41
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
29
42
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' // NOSONAR
|
|
30
43
|
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
31
44
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
32
|
-
import {
|
|
45
|
+
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'
|
|
46
|
+
import { PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
33
47
|
import { Type } from 'typebox'
|
|
48
|
+
import { splitArgs } from './internal/command-file.js'
|
|
34
49
|
import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
|
|
50
|
+
import { setMcpToolCaller } from './internal/mcp-call.js'
|
|
51
|
+
import { FileOAuthProvider, openBrowser, startCallbackServer, waitForAuthCode } from './internal/mcp-oauth.js'
|
|
35
52
|
import { capForContext } from './internal/output-guard.js'
|
|
53
|
+
import { type InstalledPlugin, installedPlugins, substitutePluginVars } from './internal/plugins.js'
|
|
36
54
|
import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
55
|
+
import { findNearestFile } from './internal/project-root.js'
|
|
37
56
|
|
|
38
57
|
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
|
39
58
|
const DEFAULT_CALL_TIMEOUT_MS = 120_000
|
|
@@ -54,7 +73,9 @@ const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_
|
|
|
54
73
|
// pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
|
|
55
74
|
// These are pi-code's own tools, and mcp.ts registers before the extensions owning
|
|
56
75
|
// them, so without this guard a server named `web` would replace the SSRF-checked fetch.
|
|
57
|
-
|
|
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'])
|
|
58
79
|
|
|
59
80
|
export interface StdioServerConfig {
|
|
60
81
|
type?: 'stdio'
|
|
@@ -64,16 +85,23 @@ export interface StdioServerConfig {
|
|
|
64
85
|
cwd?: string
|
|
65
86
|
/** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
|
|
66
87
|
timeout?: number
|
|
88
|
+
/** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
|
|
89
|
+
aliasPrefix?: string
|
|
67
90
|
}
|
|
68
91
|
|
|
69
92
|
export interface HttpServerConfig {
|
|
70
|
-
type?: 'http' | 'streamable-http' | 'sse'
|
|
93
|
+
type?: 'http' | 'streamable-http' | 'sse' | 'ws' | 'websocket'
|
|
71
94
|
url: string
|
|
72
95
|
headers?: Record<string, string>
|
|
73
96
|
bearerToken?: string
|
|
74
97
|
bearerTokenEnv?: string
|
|
98
|
+
/** A command whose JSON stdout is merged into the connect headers, for auth
|
|
99
|
+
* schemes other than OAuth/static tokens (Claude's headersHelper). */
|
|
100
|
+
headersHelper?: string
|
|
75
101
|
/** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
|
|
76
102
|
timeout?: number
|
|
103
|
+
/** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
|
|
104
|
+
aliasPrefix?: string
|
|
77
105
|
}
|
|
78
106
|
|
|
79
107
|
export type ServerConfig = StdioServerConfig | HttpServerConfig
|
|
@@ -93,9 +121,11 @@ export function userConfigPaths(home: string): string[] {
|
|
|
93
121
|
return [path.join(home, '.claude.json'), path.join(home, '.pi', 'agent', 'mcp.json')]
|
|
94
122
|
}
|
|
95
123
|
|
|
96
|
-
/** Project-scoped MCP config
|
|
124
|
+
/** Project-scoped MCP config, each file the nearest of its name at or above cwd
|
|
125
|
+
* (bounded at the repository root, matching the approval walk). Loaded only for
|
|
126
|
+
* trusted projects: a server's `command` runs on connect. */
|
|
97
127
|
export function projectConfigPaths(cwd: string): string[] {
|
|
98
|
-
return [
|
|
128
|
+
return ['.mcp.json', path.join('.pi', 'mcp.json')].map((rel) => findNearestFile(cwd, rel) ?? path.join(cwd, rel))
|
|
99
129
|
}
|
|
100
130
|
|
|
101
131
|
export interface ProjectServerPolicy {
|
|
@@ -125,8 +155,8 @@ export function projectServerPolicy(cwd: string, home: string, projectApproved:
|
|
|
125
155
|
}
|
|
126
156
|
const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
|
|
127
157
|
const userSettings = read(path.join(home, '.claude', 'settings.json'))
|
|
128
|
-
const projectSettings = read(path.join(cwd, '.claude', 'settings.json'))
|
|
129
|
-
const localSettings = read(path.join(cwd, '.claude', 'settings.local.json'))
|
|
158
|
+
const projectSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json'))
|
|
159
|
+
const localSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json'))
|
|
130
160
|
const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
|
|
131
161
|
const consentSources = projectApproved ? [userSettings, localSettings] : [userSettings]
|
|
132
162
|
const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
|
|
@@ -177,6 +207,44 @@ export function loadUserScope(home: string, cwd: string): Record<string, ServerC
|
|
|
177
207
|
return servers
|
|
178
208
|
}
|
|
179
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
|
+
|
|
233
|
+
/** Servers shipped by enabled plugins (.mcp.json or the manifest's `mcpServers`,
|
|
234
|
+
* inline or by path), with ${CLAUDE_PLUGIN_*} substituted before parsing. Their
|
|
235
|
+
* tools alias as mcp__plugin_<plugin>_<server>__<tool> for hook matchers, as
|
|
236
|
+
* Claude scopes them. */
|
|
237
|
+
export function loadPluginServers(plugins: InstalledPlugin[]): Record<string, ServerConfig> {
|
|
238
|
+
const fold = (name: string): string => name.replaceAll('-', '_')
|
|
239
|
+
const servers: Record<string, ServerConfig> = {}
|
|
240
|
+
for (const plugin of plugins) {
|
|
241
|
+
for (const [name, config] of Object.entries(pluginServerEntries(plugin))) {
|
|
242
|
+
servers[name] = { ...config, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__` }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return servers
|
|
246
|
+
}
|
|
247
|
+
|
|
180
248
|
/** Claude reports a config entry that has a url but no type as an error; pi-code
|
|
181
249
|
* still connects (streamable HTTP with SSE fallback) but says the entry is wrong. */
|
|
182
250
|
/** An inline bearerToken (interpolated) wins over bearerTokenEnv, which names an
|
|
@@ -187,6 +255,76 @@ export function resolveBearerToken(config: { bearerToken?: string; bearerTokenEn
|
|
|
187
255
|
return undefined
|
|
188
256
|
}
|
|
189
257
|
|
|
258
|
+
/** Claude's `headersHelper` output: a flat JSON object of header name -> string,
|
|
259
|
+
* merged into the connect headers. Non-string values and non-object output are
|
|
260
|
+
* ignored so a broken helper cannot poison the request. */
|
|
261
|
+
export function parseHelperHeaders(stdout: string): Record<string, string> {
|
|
262
|
+
let parsed: unknown
|
|
263
|
+
try {
|
|
264
|
+
parsed = JSON.parse(stdout)
|
|
265
|
+
} catch {
|
|
266
|
+
return {}
|
|
267
|
+
}
|
|
268
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
|
269
|
+
const out: Record<string, string> = {}
|
|
270
|
+
for (const [key, value] of Object.entries(parsed)) if (typeof value === 'string') out[key] = value
|
|
271
|
+
return out
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The OS managed-settings.json path, where Claude sources `allowedMcpServers`/
|
|
275
|
+
* `deniedMcpServers` (an enterprise policy file deployed by IT, not user- or
|
|
276
|
+
* repo-writable in the normal flow). */
|
|
277
|
+
export function managedSettingsPath(platform: NodeJS.Platform = process.platform): string {
|
|
278
|
+
if (platform === 'darwin') return '/Library/Application Support/ClaudeCode/managed-settings.json'
|
|
279
|
+
// The legacy C:\ProgramData\ClaudeCode path was dropped in Claude Code v2.1.75.
|
|
280
|
+
if (platform === 'win32') return String.raw`C:\Program Files\ClaudeCode\managed-settings.json`
|
|
281
|
+
return '/etc/claude-code/managed-settings.json'
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Test seam: override the managed-settings.json path the extension reads. */
|
|
285
|
+
let managedSettingsFileOverride: string | undefined
|
|
286
|
+
export function setManagedSettingsPath(file: string | undefined): void {
|
|
287
|
+
managedSettingsFileOverride = file
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Claude's `allowedMcpServers`/`deniedMcpServers`, read from managed settings only
|
|
291
|
+
* (not user or project settings, so a repo can neither widen nor narrow the policy),
|
|
292
|
+
* applied globally to every server across scopes. Entries are `{ serverName }` objects
|
|
293
|
+
* (bare strings tolerated). `allowed` is null when unset (no restriction); an empty
|
|
294
|
+
* set is an explicit lockdown, as Claude documents (empty allow array = deny all). */
|
|
295
|
+
export function mcpAllowDeny(managedFile: string = managedSettingsFileOverride ?? managedSettingsPath()): { allowed: Set<string> | null; denied: Set<string> } {
|
|
296
|
+
let settings: Record<string, unknown> = {}
|
|
297
|
+
try {
|
|
298
|
+
const parsed = JSON.parse(fs.readFileSync(managedFile, 'utf-8'))
|
|
299
|
+
if (parsed && typeof parsed === 'object') settings = parsed
|
|
300
|
+
} catch {
|
|
301
|
+
// No managed policy on this machine: no restriction.
|
|
302
|
+
}
|
|
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) : [])
|
|
309
|
+
return {
|
|
310
|
+
allowed: Array.isArray(settings.allowedMcpServers) ? new Set(names(settings.allowedMcpServers)) : null,
|
|
311
|
+
denied: new Set(names(settings.deniedMcpServers)),
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Claude's managed allow/deny lists: `allowed` null means no allow list (keep all);
|
|
316
|
+
* a set (even empty) is exclusive, so only its members survive; a deny list removes
|
|
317
|
+
* servers on top, deny winning over allow. */
|
|
318
|
+
export function applyServerPolicy(servers: Record<string, ServerConfig>, allowed: ReadonlySet<string> | null, denied: ReadonlySet<string>): Record<string, ServerConfig> {
|
|
319
|
+
const out: Record<string, ServerConfig> = {}
|
|
320
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
321
|
+
if (denied.has(name)) continue
|
|
322
|
+
if (allowed !== null && !allowed.has(name)) continue
|
|
323
|
+
out[name] = config
|
|
324
|
+
}
|
|
325
|
+
return out
|
|
326
|
+
}
|
|
327
|
+
|
|
190
328
|
/** A server cwd expands ${VAR} then a leading ~, or stays unset. */
|
|
191
329
|
export function expandCwd(cwd: string | undefined): string | undefined {
|
|
192
330
|
if (!cwd) return undefined
|
|
@@ -203,6 +341,52 @@ export function formatToolName(server: string, tool: string): string {
|
|
|
203
341
|
return `${server}_${tool}`.replaceAll('-', '_')
|
|
204
342
|
}
|
|
205
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
|
+
|
|
206
390
|
export function normalizeSchema(schema: unknown): object {
|
|
207
391
|
const base = (schema as Record<string, unknown>) ?? {}
|
|
208
392
|
const { $schema: _dropSchema, additionalProperties: _dropAdditional, ...rest } = base
|
|
@@ -308,7 +492,7 @@ async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): P
|
|
|
308
492
|
}
|
|
309
493
|
}
|
|
310
494
|
|
|
311
|
-
async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
495
|
+
async function connect(name: string, config: ServerConfig, authUi?: AuthUi): Promise<Client> {
|
|
312
496
|
const client = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
313
497
|
if (isStdio(config)) {
|
|
314
498
|
// Start from the SDK's allowlist (PATH, HOME, SHELL, ...) rather than the whole
|
|
@@ -326,27 +510,159 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
|
326
510
|
await connectWithTimeout(client, transport, `connect ${name}`)
|
|
327
511
|
return client
|
|
328
512
|
}
|
|
513
|
+
const url = new URL(interpolateEnv(config.url))
|
|
514
|
+
if (config.type === 'ws' || config.type === 'websocket') {
|
|
515
|
+
// The SDK's WebSocket transport takes only a url: it carries no headers, bearer
|
|
516
|
+
// token, or headersHelper output. Warn rather than silently dropping configured
|
|
517
|
+
// auth, and skip the helper entirely (running it would block the connect for up to
|
|
518
|
+
// 10s while contributing nothing). A ws server must be reachable without auth.
|
|
519
|
+
if (config.headers || config.bearerToken || config.bearerTokenEnv || config.headersHelper) {
|
|
520
|
+
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`)
|
|
521
|
+
}
|
|
522
|
+
const transport = new WebSocketClientTransport(url)
|
|
523
|
+
await connectWithTimeout(client, transport, `connect ${name} (ws)`)
|
|
524
|
+
return client
|
|
525
|
+
}
|
|
329
526
|
const headers: Record<string, string> = {}
|
|
330
527
|
for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = interpolateEnv(value)
|
|
331
528
|
const token = resolveBearerToken(config)
|
|
332
529
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
333
|
-
|
|
530
|
+
// A headersHelper generates connect-time headers for non-OAuth auth schemes; its
|
|
531
|
+
// JSON stdout merges over the static headers.
|
|
532
|
+
if (config.headersHelper) Object.assign(headers, await runHeadersHelper(interpolateEnv(config.headersHelper)))
|
|
533
|
+
const sseTransport = (authProvider?: OAuthClientProvider) => new SSEClientTransport(url, { requestInit: { headers }, authProvider }) // NOSONAR: explicitly declared or deliberate legacy transport
|
|
334
534
|
if (config.type === 'sse') {
|
|
335
|
-
|
|
336
|
-
await connectWithTimeout(client, transport, `connect ${name} (sse)`)
|
|
337
|
-
return client
|
|
535
|
+
return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
|
|
338
536
|
}
|
|
339
537
|
try {
|
|
340
|
-
|
|
341
|
-
await connectWithTimeout(client, transport, `connect ${name}`)
|
|
342
|
-
return client
|
|
538
|
+
return await connectHttpFamily(name, config, (authProvider) => new StreamableHTTPClientTransport(url, { requestInit: { headers }, authProvider }), `connect ${name}`, token, authUi)
|
|
343
539
|
} catch (error) {
|
|
344
540
|
// An explicitly declared streamable transport must not silently degrade to SSE.
|
|
345
|
-
if (config.type !== undefined ||
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
541
|
+
if (config.type !== undefined || isUnauthorized(error)) throw error
|
|
542
|
+
return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Run a headersHelper command and parse its JSON stdout into headers. A failure or
|
|
547
|
+
* a 10s timeout yields no extra headers rather than blocking the connection. */
|
|
548
|
+
function runHeadersHelper(command: string): Promise<Record<string, string>> {
|
|
549
|
+
return new Promise((resolve) => {
|
|
550
|
+
execFile('/bin/sh', ['-c', command], { timeout: 10_000 }, (error, stdout) => {
|
|
551
|
+
resolve(error ? {} : parseHelperHeaders(stdout))
|
|
552
|
+
})
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** UI seams the OAuth flow needs; absent in headless runs, which fail with advice. */
|
|
557
|
+
export interface AuthUi {
|
|
558
|
+
confirm: (title: string, body: string) => Promise<boolean>
|
|
559
|
+
notify: (message: string, level: 'info' | 'warning' | 'error') => void
|
|
560
|
+
}
|
|
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
|
+
|
|
571
|
+
/** Browser logins are human-paced; a connect-sized timeout would cut them off. */
|
|
572
|
+
const OAUTH_FLOW_TIMEOUT_MS = 180_000
|
|
573
|
+
|
|
574
|
+
/** A server needs OAuth pi could not complete (headless, declined, or the flow
|
|
575
|
+
* failed). A typed marker so the SSE-fallback caller can tell an auth failure
|
|
576
|
+
* from a transport mismatch without matching on message text. */
|
|
577
|
+
class OAuthRequiredError extends Error {}
|
|
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
|
+
|
|
587
|
+
/** Whether a connect failure is an authentication problem: the SDK's own
|
|
588
|
+
* UnauthorizedError, a transport error carrying HTTP 401 (which is what a 401
|
|
589
|
+
* throws when no authProvider was attached, so a first-time login is detected),
|
|
590
|
+
* or our own marker. */
|
|
591
|
+
function isUnauthorized(error: unknown): boolean {
|
|
592
|
+
if (error instanceof UnauthorizedError || error instanceof OAuthRequiredError) return true
|
|
593
|
+
return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 401
|
|
594
|
+
}
|
|
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
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Connect an http-family server, running Claude's OAuth login when the server
|
|
605
|
+
* demands one. Stored tokens ride the first attempt so the SDK refreshes
|
|
606
|
+
* silently; a 401 without tokens asks the user, opens the browser, catches the
|
|
607
|
+
* loopback redirect, and exchanges the code via the SDK's finishAuth.
|
|
608
|
+
* Bearer-token servers never enter the OAuth path: an explicit token is the
|
|
609
|
+
* user saying how auth works.
|
|
610
|
+
*/
|
|
611
|
+
async function connectHttpFamily(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, bearerToken: string | undefined, authUi: AuthUi | undefined): Promise<Client> {
|
|
612
|
+
const newClient = () => new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
613
|
+
// Stored tokens ride the first attempt so the SDK refreshes them; with none, no
|
|
614
|
+
// provider is attached, so a 401 surfaces as a transport error carrying code 401
|
|
615
|
+
// (isUnauthorized detects it) and only the interactive provider below ever runs
|
|
616
|
+
// dynamic registration, keeping it bound to the real callback port.
|
|
617
|
+
const silent = bearerToken ? undefined : new FileOAuthProvider(name, () => {})
|
|
618
|
+
try {
|
|
619
|
+
const client = newClient()
|
|
620
|
+
await connectWithTimeout(client, makeTransport(silent?.hasTokens() ? silent : undefined), label)
|
|
621
|
+
return client
|
|
622
|
+
} catch (error) {
|
|
623
|
+
if (bearerToken || !isUnauthorized(error)) throw error
|
|
624
|
+
if (!authUi) throw new OAuthRequiredError(`${name} requires a login; run pi interactively to authenticate`)
|
|
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()
|
|
651
|
+
try {
|
|
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
|
|
661
|
+
}
|
|
662
|
+
} catch (flowError) {
|
|
663
|
+
throw asOAuthRequiredError(name, flowError)
|
|
664
|
+
} finally {
|
|
665
|
+
server.close()
|
|
350
666
|
}
|
|
351
667
|
}
|
|
352
668
|
|
|
@@ -387,9 +703,87 @@ async function listAllTools(client: Client): Promise<McpToolInfo[]> {
|
|
|
387
703
|
return tools
|
|
388
704
|
}
|
|
389
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
|
+
|
|
390
773
|
export default async function mcpExtension(pi: ExtensionAPI) {
|
|
391
774
|
const clients = new Map<string, Client>()
|
|
392
775
|
const status = new Map<string, { state: string; tools: number }>()
|
|
776
|
+
// Let other extensions (hooks' mcp_tool type) call a connected server's tool.
|
|
777
|
+
setMcpToolCaller(async (server, tool, input) => {
|
|
778
|
+
const client = clients.get(server)
|
|
779
|
+
if (!client) throw new Error(`MCP server "${server}" is not connected`)
|
|
780
|
+
const result = await client.callTool({ name: tool, arguments: input }, undefined, { timeout: callTimeoutMs() })
|
|
781
|
+
const text = mapContent(result.content as McpContentBlock[], result.structuredContent)
|
|
782
|
+
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
783
|
+
.map((part) => part.text)
|
|
784
|
+
.join('\n')
|
|
785
|
+
return { text, isError: result.isError === true }
|
|
786
|
+
})
|
|
393
787
|
// pi tool name -> owning server, so a refresh can tell its own tools from a conflict.
|
|
394
788
|
const registered = new Map<string, string>()
|
|
395
789
|
// Original server/tool names per registered pi name, for Claude-style hook matchers.
|
|
@@ -407,7 +801,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
407
801
|
continue
|
|
408
802
|
}
|
|
409
803
|
registered.set(toolName, name)
|
|
410
|
-
aliases.push({ pi: toolName, claude: `mcp__${name}__${tool.name}` })
|
|
804
|
+
aliases.push({ pi: toolName, claude: config.aliasPrefix ? `${config.aliasPrefix}${tool.name}` : `mcp__${name}__${tool.name}` })
|
|
411
805
|
count++
|
|
412
806
|
pi.registerTool({
|
|
413
807
|
name: toolName,
|
|
@@ -435,6 +829,147 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
435
829
|
return count
|
|
436
830
|
}
|
|
437
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
|
+
|
|
438
973
|
/** Claude refreshes tools on a server's list_changed notification. pi has no
|
|
439
974
|
* unregister, so a withdrawn tool keeps its registration and surfaces the server's
|
|
440
975
|
* own error when called; a newly announced one is registered without a restart. */
|
|
@@ -457,7 +992,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
457
992
|
}
|
|
458
993
|
}
|
|
459
994
|
|
|
460
|
-
async function connectServers(servers: Record<string, ServerConfig
|
|
995
|
+
async function connectServers(servers: Record<string, ServerConfig>, authUi?: AuthUi): Promise<void> {
|
|
461
996
|
const pending: [string, ServerConfig][] = []
|
|
462
997
|
for (const [name, config] of Object.entries(servers)) {
|
|
463
998
|
// A later scope must not take the name of a server that already connected: it
|
|
@@ -476,11 +1011,17 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
476
1011
|
pending.map(async ([name, config]) => {
|
|
477
1012
|
warnOnTypelessUrl(name, config)
|
|
478
1013
|
try {
|
|
479
|
-
const client = await connect(name, config)
|
|
1014
|
+
const client = await connect(name, config, authUi)
|
|
480
1015
|
clients.set(name, client)
|
|
481
1016
|
const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
|
|
482
1017
|
const count = registerTools(name, config, client, tools)
|
|
483
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)
|
|
484
1025
|
status.set(name, { state: 'connected', tools: count })
|
|
485
1026
|
// A server that dies mid-session would otherwise stay "connected" in /mcp
|
|
486
1027
|
// while every call fails with the SDK's bare "Not connected"; flip the
|
|
@@ -510,12 +1051,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
510
1051
|
async function connectProjectScope(ctx: ExtensionContext): Promise<boolean> {
|
|
511
1052
|
// The stored decision, read without prompting: consent recorded inside the
|
|
512
1053
|
// project only counts once the project itself has been approved.
|
|
513
|
-
const
|
|
514
|
-
const
|
|
515
|
-
|
|
1054
|
+
const approved = isProjectApprovedSilently(ctx)
|
|
1055
|
+
const policy = projectServerPolicy(ctx.cwd, os.homedir(), approved)
|
|
1056
|
+
const { allowed, denied } = mcpAllowDeny()
|
|
1057
|
+
const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), policy)
|
|
1058
|
+
const authUi = authUiFor(ctx)
|
|
1059
|
+
if (Object.keys(consented).length > 0) await connectServers(consented, authUi)
|
|
516
1060
|
if (Object.keys(gated).length === 0) return true
|
|
517
1061
|
if (!(await isProjectApproved(ctx))) return false
|
|
518
|
-
await connectServers(gated)
|
|
1062
|
+
await connectServers(gated, authUi)
|
|
519
1063
|
return true
|
|
520
1064
|
}
|
|
521
1065
|
|
|
@@ -526,8 +1070,13 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
526
1070
|
// the factory: pi runs the factory for invocations that never start a session.
|
|
527
1071
|
// Names still connected are filtered out, so a later session start only retries
|
|
528
1072
|
// servers that failed or whose transport dropped, without duplicate-name warnings.
|
|
529
|
-
|
|
530
|
-
|
|
1073
|
+
// Plugin servers merge under the user scope (plugins are user-installed);
|
|
1074
|
+
// the user's own entry wins a name clash with a plugin's.
|
|
1075
|
+
const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
|
|
1076
|
+
const { allowed, denied } = mcpAllowDeny()
|
|
1077
|
+
const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, allowed, denied)
|
|
1078
|
+
const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name)))
|
|
1079
|
+
if (Object.keys(userServers).length > 0) await connectServers(userServers, authUiFor(ctx))
|
|
531
1080
|
// A project .mcp.json can run arbitrary commands on connect, so only honor it once
|
|
532
1081
|
// the project is trusted. Per-server settings refine that: disabled servers never
|
|
533
1082
|
// connect, servers the user consented to individually connect without the
|