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