pi-code 1.0.25 → 1.0.27
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/internal/plugins.ts +3 -1
- package/extensions/mcp/config.ts +47 -13
- package/extensions/mcp/index.ts +41 -14
- package/extensions/mcp/mapping.ts +12 -13
- package/extensions/mcp/transport.ts +136 -39
- package/package.json +1 -1
|
@@ -180,7 +180,9 @@ function resolvePlugin(home: string, cacheDir: string, marketplace: string, plug
|
|
|
180
180
|
const root = path.join(cacheDir, marketplace, pluginDir, version)
|
|
181
181
|
const manifest = readJson(path.join(root, '.claude-plugin', 'plugin.json'))
|
|
182
182
|
const name = typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : pluginDir
|
|
183
|
-
|
|
183
|
+
// Claude: "{id} is the plugin identifier with characters outside a-z, A-Z, 0-9,
|
|
184
|
+
// _, and - replaced by -", one dash per character, underscores kept.
|
|
185
|
+
const id = qualified.replace(/[^A-Za-z0-9_-]/g, '-')
|
|
184
186
|
const userConfig = configs[qualified] ?? configs[pluginDir] ?? configs[name]
|
|
185
187
|
return { name, root, dataDir: path.join(claudeConfigDir(home), 'plugins', 'data', id), manifest, ...(userConfig ? { userConfig } : {}) }
|
|
186
188
|
}
|
package/extensions/mcp/config.ts
CHANGED
|
@@ -20,6 +20,10 @@ export interface StdioServerConfig {
|
|
|
20
20
|
timeout?: number
|
|
21
21
|
/** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
|
|
22
22
|
aliasPrefix?: string
|
|
23
|
+
/** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
|
|
24
|
+
pluginRoot?: string
|
|
25
|
+
/** Loaded from the project scope, whose helpers run credential-stripped. */
|
|
26
|
+
projectScope?: boolean
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export interface HttpServerConfig {
|
|
@@ -35,6 +39,10 @@ export interface HttpServerConfig {
|
|
|
35
39
|
timeout?: number
|
|
36
40
|
/** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
|
|
37
41
|
aliasPrefix?: string
|
|
42
|
+
/** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
|
|
43
|
+
pluginRoot?: string
|
|
44
|
+
/** Loaded from the project scope, whose helpers run credential-stripped. */
|
|
45
|
+
projectScope?: boolean
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
export type ServerConfig = StdioServerConfig | HttpServerConfig
|
|
@@ -99,13 +107,34 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
|
99
107
|
*/
|
|
100
108
|
export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
|
|
101
109
|
const servers = loadConfigFrom(userConfigPaths(home))
|
|
110
|
+
Object.assign(servers, projectRecord(home, cwd).mcpServers ?? {})
|
|
111
|
+
return servers
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The per-project record for `cwd` in ~/.claude.json, or an empty one when the file
|
|
115
|
+
* is missing, invalid, or has no entry for this project. */
|
|
116
|
+
function projectRecord(home: string, cwd: string): { mcpServers?: Record<string, ServerConfig>; disabledMcpServers?: unknown } {
|
|
102
117
|
try {
|
|
103
118
|
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath(home), 'utf-8'))
|
|
104
|
-
|
|
119
|
+
return claudeJson.projects?.[cwd] ?? {}
|
|
105
120
|
} catch {
|
|
106
|
-
|
|
121
|
+
return {}
|
|
107
122
|
}
|
|
108
|
-
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Names the local scope defines for this project. Claude's precedence is local over
|
|
126
|
+
* project over user, so a local name must also outrank a project .mcp.json entry. */
|
|
127
|
+
export function localScopeServerNames(home: string, cwd: string): Set<string> {
|
|
128
|
+
return new Set(Object.keys(projectRecord(home, cwd).mcpServers ?? {}))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The per-project `disabledMcpServers` toggle list from ~/.claude.json: Claude's /mcp
|
|
132
|
+
* panel records a server toggled off here (an opt-out list for user-configured and
|
|
133
|
+
* plugin servers) and does not connect to it. The `enabledMcpServers` opt-in list
|
|
134
|
+
* covers only default-off built-in servers, which pi-code has none of. */
|
|
135
|
+
export function disabledServerNames(home: string, cwd: string): Set<string> {
|
|
136
|
+
const listed = projectRecord(home, cwd).disabledMcpServers
|
|
137
|
+
return new Set(Array.isArray(listed) ? listed.filter((entry): entry is string => typeof entry === 'string') : [])
|
|
109
138
|
}
|
|
110
139
|
|
|
111
140
|
/** The mcpServers one plugin declares, parsed WITHOUT substitution: an inline map on
|
|
@@ -115,18 +144,22 @@ export function loadUserScope(home: string, cwd: string): Record<string, ServerC
|
|
|
115
144
|
* the parse and headersHelper can be shielded. */
|
|
116
145
|
function rawPluginServerEntries(plugin: InstalledPlugin): Record<string, unknown> {
|
|
117
146
|
const declared = plugin.manifest.mcpServers
|
|
118
|
-
// An inline map of name -> config
|
|
119
|
-
//
|
|
147
|
+
// An inline map of name -> config. The manifest field is string|array|object per
|
|
148
|
+
// Claude's plugin reference; an array lists config file paths, merged in order.
|
|
120
149
|
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
121
150
|
return { ...(declared as Record<string, unknown>) }
|
|
122
151
|
}
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
152
|
+
const paths = Array.isArray(declared) ? declared.filter((entry): entry is string => typeof entry === 'string') : [typeof declared === 'string' ? declared : '.mcp.json']
|
|
153
|
+
const servers: Record<string, unknown> = {}
|
|
154
|
+
for (const entry of paths) {
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(fs.readFileSync(path.resolve(plugin.root, entry), 'utf-8'))
|
|
157
|
+
Object.assign(servers, parsed.mcpServers ?? {})
|
|
158
|
+
} catch {
|
|
159
|
+
// Malformed or missing JSON contributes no entries.
|
|
160
|
+
}
|
|
129
161
|
}
|
|
162
|
+
return servers
|
|
130
163
|
}
|
|
131
164
|
|
|
132
165
|
/** Every string in the value mapped through `substitute`, arrays and objects walked. */
|
|
@@ -169,12 +202,13 @@ function substitutePathPluginVars(text: string, plugin: InstalledPlugin): string
|
|
|
169
202
|
* tools alias as mcp__plugin_<plugin>_<server>__<tool> for hook matchers, as
|
|
170
203
|
* Claude scopes them. */
|
|
171
204
|
export function loadPluginServers(plugins: InstalledPlugin[], projectDir?: string): Record<string, ServerConfig> {
|
|
172
|
-
|
|
205
|
+
// Claude keeps hyphens in the alias; only characters outside A-Za-z0-9_- fold to _.
|
|
206
|
+
const fold = (name: string): string => name.replace(/[^A-Za-z0-9_-]/g, '_')
|
|
173
207
|
const servers: Record<string, ServerConfig> = {}
|
|
174
208
|
for (const plugin of plugins) {
|
|
175
209
|
for (const [name, config] of Object.entries(rawPluginServerEntries(plugin))) {
|
|
176
210
|
const substituted = substitutedPluginServer(plugin, name, config, projectDir)
|
|
177
|
-
if (substituted) servers[name] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__
|
|
211
|
+
if (substituted) servers[name] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__`, pluginRoot: plugin.root }
|
|
178
212
|
}
|
|
179
213
|
}
|
|
180
214
|
return servers
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
* environment plus its own `env` block, not the whole process environment.
|
|
21
21
|
*
|
|
22
22
|
* Servers advertising the `prompts` capability get their prompts registered as Claude's
|
|
23
|
-
* /mcp__<server>__<prompt> slash commands (
|
|
24
|
-
*
|
|
23
|
+
* /mcp__<server>__<prompt> slash commands (server-name characters outside A-Za-z0-9_-
|
|
24
|
+
* fold to underscores, args split on whitespace and mapped positionally, one token
|
|
25
|
+
* per declared argument); the prompt result drives a turn via
|
|
25
26
|
* sendUserMessage, exactly how custom slash commands do. Servers advertising `resources`
|
|
26
27
|
* make the global list_mcp_resources / read_mcp_resource tools available, mirroring
|
|
27
28
|
* Claude's automatic resource tools. Resource and prompt output rides the same
|
|
@@ -43,11 +44,11 @@ import { installedPlugins } from '../internal/plugins.js'
|
|
|
43
44
|
import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
|
|
44
45
|
import { repoRoot } from '../internal/project-root.js'
|
|
45
46
|
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
46
|
-
import { loadConfigFrom, loadPluginServers, loadUserScope, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
|
|
47
|
+
import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope, localScopeServerNames, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
|
|
47
48
|
import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
|
|
48
49
|
import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
|
|
49
50
|
import { applyServerPolicy, loadManagedMcpServers, type McpPolicy, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
|
|
50
|
-
import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, withTimeout } from './transport.js'
|
|
51
|
+
import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, type ServerCallTuning, type SessionDirs, serverCallTuning, withTimeout } from './transport.js'
|
|
51
52
|
|
|
52
53
|
export { managedSettingsPath, setManagedSettingsPath } from '../internal/managed-settings.js'
|
|
53
54
|
// Re-exports for consumers: the module split keeps the extension's public surface
|
|
@@ -84,11 +85,22 @@ function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
|
|
|
84
85
|
export default async function mcpExtension(pi: ExtensionAPI) {
|
|
85
86
|
const clients = new Map<string, Client>()
|
|
86
87
|
const status = new Map<string, { state: string; tools: number }>()
|
|
88
|
+
// Config per server name, kept for call-time timeout tuning: the idle tier follows
|
|
89
|
+
// the transport kind, and a declared per-server timeout governs the wall budget.
|
|
90
|
+
const serverConfigs = new Map<string, ServerConfig>()
|
|
91
|
+
// The session's directories, set at session_start before any connect: the launch
|
|
92
|
+
// directory answers roots/list, the project root feeds CLAUDE_PROJECT_DIR.
|
|
93
|
+
let sessionDirs: SessionDirs | undefined
|
|
94
|
+
const callTuning = (name: string): ServerCallTuning => {
|
|
95
|
+
const config = serverConfigs.get(name)
|
|
96
|
+
return config ? serverCallTuning(config) : {}
|
|
97
|
+
}
|
|
87
98
|
// Let other extensions (hooks' mcp_tool type) call a connected server's tool.
|
|
88
99
|
setMcpToolCaller(async (server, tool, input) => {
|
|
89
100
|
const client = clients.get(server)
|
|
90
101
|
if (!client) throw new Error(`MCP server "${server}" is not connected`)
|
|
91
|
-
const
|
|
102
|
+
const tuning = callTuning(server)
|
|
103
|
+
const result = await client.callTool({ name: tool, arguments: input }, undefined, callRequestOptions(tuning.serverTimeoutMs ?? callTimeoutMs(), tuning))
|
|
92
104
|
const text = mapContent(result.content as McpContentBlock[], result.structuredContent)
|
|
93
105
|
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
94
106
|
.map((part) => part.text)
|
|
@@ -138,9 +150,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
138
150
|
// own default request timeout is 60s and would otherwise reject first. The outer
|
|
139
151
|
// race uses the wall budget, never the idle window, so a progressing call is not
|
|
140
152
|
// cut off at the idle timeout.
|
|
141
|
-
const
|
|
142
|
-
const wall =
|
|
143
|
-
const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, callRequestOptions(wall)), wall, toolName)
|
|
153
|
+
const tuning = serverCallTuning(config)
|
|
154
|
+
const wall = tuning.serverTimeoutMs ?? callTimeoutMs()
|
|
155
|
+
const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, callRequestOptions(wall, tuning)), wall, toolName)
|
|
144
156
|
const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
|
|
145
157
|
const details: { error?: string } = {}
|
|
146
158
|
if (result.isError) {
|
|
@@ -193,7 +205,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
193
205
|
const params: { name: string; arguments?: Record<string, string> } = { name: prompt.name }
|
|
194
206
|
if (Object.keys(promptArgs).length > 0) params.arguments = promptArgs
|
|
195
207
|
const wall = callTimeoutMs()
|
|
196
|
-
const result = await withTimeout(current.getPrompt(params, callRequestOptions(wall)), wall, commandName)
|
|
208
|
+
const result = await withTimeout(current.getPrompt(params, callRequestOptions(wall, callTuning(name))), wall, commandName)
|
|
197
209
|
// The prompt drives a turn exactly the way a custom slash command does
|
|
198
210
|
// (see commands.ts), carrying its image blocks through. A prompt that
|
|
199
211
|
// yields no content is reported rather than sent as an empty turn.
|
|
@@ -280,7 +292,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
280
292
|
const client = clients.get(server)
|
|
281
293
|
if (!client) throw new Error(`MCP server "${server}" is not connected`)
|
|
282
294
|
const wall = callTimeoutMs()
|
|
283
|
-
const result = await withTimeout(client.readResource({ uri }, callRequestOptions(wall)), wall, `read ${uri}`)
|
|
295
|
+
const result = await withTimeout(client.readResource({ uri }, callRequestOptions(wall, callTuning(server))), wall, `read ${uri}`)
|
|
284
296
|
const blocks = (result.contents as Array<{ uri: string; text?: string; blob?: string; mimeType?: string }>).map((entry): McpContentBlock => {
|
|
285
297
|
if (typeof entry.text === 'string') return { type: 'resource', resource: { uri: entry.uri, text: entry.text } }
|
|
286
298
|
if (entry.blob && entry.mimeType?.startsWith('image/')) return { type: 'image', data: entry.blob, mimeType: entry.mimeType }
|
|
@@ -341,13 +353,14 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
341
353
|
// Seed in config order before connecting: parallel connects settle in completion
|
|
342
354
|
// order, and /mcp plus the session summary iterate the map's insertion order.
|
|
343
355
|
status.set(name, { state: 'connecting', tools: 0 })
|
|
356
|
+
serverConfigs.set(name, config)
|
|
344
357
|
pending.push([name, config])
|
|
345
358
|
}
|
|
346
359
|
await Promise.all(
|
|
347
360
|
pending.map(async ([name, config]) => {
|
|
348
361
|
warnOnTypelessUrl(name, config)
|
|
349
362
|
try {
|
|
350
|
-
const client = await connect(name, config, authUi)
|
|
363
|
+
const client = await connect(name, config, authUi, sessionDirs)
|
|
351
364
|
clients.set(name, client)
|
|
352
365
|
const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
|
|
353
366
|
registerTools(name, config, tools)
|
|
@@ -424,9 +437,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
424
437
|
* or whose transport dropped, without duplicate-name warnings. */
|
|
425
438
|
async function connectNormalScopes(ctx: ExtensionContext, policy: McpPolicy, authUi?: AuthUi): Promise<void> {
|
|
426
439
|
// Plugin servers merge under the user scope (plugins are user-installed);
|
|
427
|
-
// the user's own entry wins a name clash with a plugin's.
|
|
440
|
+
// the user's own entry wins a name clash with a plugin's. A server toggled off
|
|
441
|
+
// in ~/.claude.json's per-project disabledMcpServers list never connects.
|
|
428
442
|
const pluginServers = loadPluginServers(installedPlugins(os.homedir()), repoRoot(ctx.cwd) ?? ctx.cwd)
|
|
429
|
-
const
|
|
443
|
+
const disabled = disabledServerNames(os.homedir(), ctx.cwd)
|
|
444
|
+
const merged = Object.fromEntries(Object.entries({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }).filter(([name]) => !disabled.has(name)))
|
|
445
|
+
const scoped = applyServerPolicy(merged, policy)
|
|
430
446
|
// Claude's precedence is project over user for a duplicate name. A project .mcp.json
|
|
431
447
|
// server only outranks the user's own when it will actually connect (the user already
|
|
432
448
|
// consented to it, or an approved project's), so a merely-present untrusted project
|
|
@@ -436,7 +452,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
436
452
|
// The stored project decision, read without prompting: consent recorded inside
|
|
437
453
|
// the project only counts once the project itself has been approved.
|
|
438
454
|
const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
|
|
439
|
-
|
|
455
|
+
// Tag the scope on each project server: a repository-supplied headersHelper runs
|
|
456
|
+
// with credential variables stripped, unlike a user-scope one.
|
|
457
|
+
const projectServers = Object.fromEntries(Object.entries(loadConfigFrom(projectConfigPaths(ctx.cwd))).map(([name, config]) => [name, { ...config, projectScope: true }]))
|
|
458
|
+
const { consented: consentedRaw, gated } = splitByPolicy(applyServerPolicy(projectServers, policy), projectPolicy)
|
|
459
|
+
// Claude's scope precedence is local over project: a name the local scope defines
|
|
460
|
+
// stays with the local (user-side) definition, so the project's entry is dropped
|
|
461
|
+
// here rather than allowed to shadow it.
|
|
462
|
+
const localNames = localScopeServerNames(os.homedir(), ctx.cwd)
|
|
463
|
+
const consented = Object.fromEntries(Object.entries(consentedRaw).filter(([name]) => !localNames.has(name)))
|
|
440
464
|
const projectWinners = new Set(Object.keys(consented))
|
|
441
465
|
const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
|
|
442
466
|
// The consented project servers carry no ordering dependency on the user scope:
|
|
@@ -463,6 +487,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
463
487
|
// withdrawn tool keeps its registration and surfaces the server's own error), which
|
|
464
488
|
// is why serverToolCount reads from `registered` to recover the true count here.
|
|
465
489
|
status.clear()
|
|
490
|
+
// Claude answers roots/list with the session's launch directory and exports the
|
|
491
|
+
// project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
|
|
492
|
+
sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd }
|
|
466
493
|
const authUi = authUiFor(ctx)
|
|
467
494
|
// The allow/deny lists filter every scope, including a managed-mcp.json set. They
|
|
468
495
|
// merge from managed settings plus the trust-gated settings chain, as Claude
|
|
@@ -5,19 +5,19 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { DEFAULT_MAX_BYTES } from '@earendil-works/pi-coding-agent'
|
|
8
|
-
import { splitArgs } from '../internal/command-file.js'
|
|
9
8
|
import { capForContext } from '../internal/output-guard.js'
|
|
10
9
|
|
|
11
10
|
export function formatToolName(server: string, tool: string): string {
|
|
12
11
|
return `${server}_${tool}`.replaceAll('-', '_')
|
|
13
12
|
}
|
|
14
13
|
|
|
15
|
-
/** Claude exposes server prompts as /mcp__<server>__<prompt> slash commands
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
/** Claude exposes server prompts as /mcp__<server>__<prompt> slash commands: any
|
|
15
|
+
* character in the server name outside A-Za-z0-9_- becomes an underscore (hyphens
|
|
16
|
+
* stay), and the prompt name is used as the server declares it. Divergence: pi
|
|
17
|
+
* dispatches commands on the first whitespace-delimited token, so whitespace in the
|
|
18
|
+
* prompt name also folds to an underscore or the command would be unreachable. */
|
|
18
19
|
export function formatPromptCommandName(server: string, prompt: string): string {
|
|
19
|
-
|
|
20
|
-
return `mcp__${normalize(server)}__${normalize(prompt)}`
|
|
20
|
+
return `mcp__${server.replace(/[^A-Za-z0-9_-]/g, '_')}__${prompt.replace(/\s/g, '_')}`
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export interface McpPromptArgumentInfo {
|
|
@@ -32,17 +32,16 @@ export interface McpPromptInfo {
|
|
|
32
32
|
arguments?: McpPromptArgumentInfo[]
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/** Claude passes prompt arguments space-separated after the command
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* no token are omitted, and the server enforces its own `required`. */
|
|
35
|
+
/** Claude passes prompt arguments space-separated after the command and "splits the
|
|
36
|
+
* arguments on whitespace, so each argument is a single token": no quote handling,
|
|
37
|
+
* one token per declared argument, extra trailing tokens dropped. Declared arguments
|
|
38
|
+
* with no token are omitted, and the server enforces its own `required`. */
|
|
40
39
|
export function mapPromptArguments(declared: ReadonlyArray<{ name: string }> | undefined, args: string): Record<string, string> {
|
|
41
|
-
const tokens =
|
|
40
|
+
const tokens = args.split(/\s+/).filter((token) => token !== '')
|
|
42
41
|
const names = (declared ?? []).map((argument) => argument.name)
|
|
43
42
|
const mapped: Record<string, string> = {}
|
|
44
43
|
for (let index = 0; index < names.length && index < tokens.length; index++) {
|
|
45
|
-
mapped[names[index]] =
|
|
44
|
+
mapped[names[index]] = tokens[index]
|
|
46
45
|
}
|
|
47
46
|
return mapped
|
|
48
47
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { execFile } from 'node:child_process'
|
|
8
|
+
import { pathToFileURL } from 'node:url'
|
|
8
9
|
// SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
|
|
9
10
|
// the old spec exist, so this stays as a fallback for the migration period.
|
|
10
11
|
import { type OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
|
@@ -13,42 +14,66 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' //
|
|
|
13
14
|
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
14
15
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
15
16
|
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'
|
|
17
|
+
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
16
18
|
import { FileOAuthProvider } from '../internal/mcp-oauth.js'
|
|
17
|
-
import { expandCwd, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
|
|
19
|
+
import { expandCwd, type HttpServerConfig, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
|
|
18
20
|
import { runInteractiveOAuth, serializeInteractiveOAuth } from './oauth-flow.js'
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
// Claude's MCP_TIMEOUT default: 30 seconds per connect attempt.
|
|
23
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 30_000
|
|
21
24
|
// Claude's MCP_TOOL_TIMEOUT default is effectively hours: the per-call wall-clock budget
|
|
22
25
|
// is only a ceiling, and the idle timeout below is the real guard. 4h matches that model,
|
|
23
26
|
// so a legitimately slow-but-progressing tool is not killed at the old 2 minutes.
|
|
24
27
|
const DEFAULT_CALL_TIMEOUT_MS = 14_400_000
|
|
25
28
|
// The idle timeout: the longest a call may go with no response or progress before it is
|
|
26
|
-
// abandoned. Claude uses a separate idle guard
|
|
27
|
-
//
|
|
29
|
+
// abandoned. Claude uses a separate idle guard rather than the hours-long wall-clock
|
|
30
|
+
// budget, defaulting to five minutes for remote transports and 30 minutes for stdio
|
|
31
|
+
// servers; the SDK resets this window on every progress notification.
|
|
28
32
|
const DEFAULT_CALL_IDLE_TIMEOUT_MS = 300_000
|
|
33
|
+
const DEFAULT_STDIO_CALL_IDLE_TIMEOUT_MS = 1_800_000
|
|
34
|
+
|
|
35
|
+
/** Claude's numeric env vars accept scientific notation and digit-separator spellings
|
|
36
|
+
* (2e3 as 2000, 64_000 as 64000). A non-numeric value is undefined, not zero. */
|
|
37
|
+
function parseNumericEnv(raw: string): number | undefined {
|
|
38
|
+
const cleaned = raw.replaceAll('_', '')
|
|
39
|
+
if (cleaned.trim() === '') return undefined
|
|
40
|
+
const value = Number(cleaned)
|
|
41
|
+
return Number.isFinite(value) ? Math.floor(value) : undefined
|
|
42
|
+
}
|
|
29
43
|
|
|
30
44
|
/** A positive-integer env override, or the default when unset or unparseable. */
|
|
31
45
|
function envTimeout(name: string, fallback: number): number {
|
|
32
46
|
const raw = process.env[name]
|
|
33
47
|
if (raw === undefined) return fallback
|
|
34
|
-
const value =
|
|
35
|
-
return
|
|
48
|
+
const value = parseNumericEnv(raw)
|
|
49
|
+
return value !== undefined && value > 0 ? value : fallback
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
// Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
|
|
39
53
|
export const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
|
|
40
54
|
export const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
|
|
41
55
|
|
|
56
|
+
/** Per-server inputs to the idle-window choice: the transport kind picks the default
|
|
57
|
+
* tier, and a per-server `timeout` of at least 1000 also floors the idle window. */
|
|
58
|
+
export interface ServerCallTuning {
|
|
59
|
+
stdio?: boolean
|
|
60
|
+
serverTimeoutMs?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
/** The idle timeout in ms: the longest a call may go with no response or progress before
|
|
43
|
-
* it is abandoned
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
64
|
+
* it is abandoned. Defaults to Claude's tiers (five minutes remote, 30 minutes stdio),
|
|
65
|
+
* overridable by CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT, with 0 disabling it (leaving only
|
|
66
|
+
* the wall-clock budget). Unlike envTimeout, an explicit 0 is honored as "disabled"
|
|
67
|
+
* rather than falling back to the default. A per-server timeout of at least 1000 floors
|
|
68
|
+
* the enabled window, so a server granted a long wall budget is not idled out earlier. */
|
|
69
|
+
function idleTimeoutMs(tuning: ServerCallTuning): number {
|
|
47
70
|
const raw = process.env.CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
71
|
+
const override = raw === undefined ? undefined : parseNumericEnv(raw)
|
|
72
|
+
if (override === 0) return 0
|
|
73
|
+
const tierDefault = tuning.stdio ? DEFAULT_STDIO_CALL_IDLE_TIMEOUT_MS : DEFAULT_CALL_IDLE_TIMEOUT_MS
|
|
74
|
+
const base = override !== undefined && override > 0 ? override : tierDefault
|
|
75
|
+
const floor = tuning.serverTimeoutMs !== undefined && tuning.serverTimeoutMs >= 1000 ? tuning.serverTimeoutMs : 0
|
|
76
|
+
return Math.max(base, floor)
|
|
52
77
|
}
|
|
53
78
|
|
|
54
79
|
/** The SDK RequestOptions for a call under pi's two-tier timeout: a wall-clock ceiling and,
|
|
@@ -60,12 +85,20 @@ function idleTimeoutMs(): number {
|
|
|
60
85
|
* wall budget, only the wall budget applies. The outer withTimeout race is a wall-clock
|
|
61
86
|
* backstop and must be raced against `wall`, never the idle window, so a legitimately
|
|
62
87
|
* progressing call is not cut off. */
|
|
63
|
-
export function callRequestOptions(wall: number): { timeout: number; resetTimeoutOnProgress?: boolean; maxTotalTimeout?: number; onprogress?: () => void } {
|
|
64
|
-
const idle = idleTimeoutMs()
|
|
88
|
+
export function callRequestOptions(wall: number, tuning: ServerCallTuning = {}): { timeout: number; resetTimeoutOnProgress?: boolean; maxTotalTimeout?: number; onprogress?: () => void } {
|
|
89
|
+
const idle = idleTimeoutMs(tuning)
|
|
65
90
|
if (idle === 0 || idle >= wall) return { timeout: wall }
|
|
66
91
|
return { timeout: idle, resetTimeoutOnProgress: true, maxTotalTimeout: wall, onprogress: () => {} }
|
|
67
92
|
}
|
|
68
93
|
|
|
94
|
+
/** The tuning one server's config yields: its transport kind, and its declared
|
|
95
|
+
* per-server timeout. Per Claude, timeout values below 1000 are ignored and fall
|
|
96
|
+
* through to MCP_TOOL_TIMEOUT. */
|
|
97
|
+
export function serverCallTuning(config: ServerConfig): ServerCallTuning {
|
|
98
|
+
const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
|
|
99
|
+
return { stdio: isStdio(config), ...(declared !== undefined ? { serverTimeoutMs: declared } : {}) }
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
/** Claude reports a config entry that has a url but no type as an error; pi-code
|
|
70
103
|
* still connects (streamable HTTP with SSE fallback) but says the entry is wrong. */
|
|
71
104
|
/** An inline bearerToken (interpolated) wins over bearerTokenEnv, which names an
|
|
@@ -97,6 +130,67 @@ function isStdio(config: ServerConfig): config is StdioServerConfig {
|
|
|
97
130
|
return 'command' in config && (config.type === undefined || config.type === 'stdio')
|
|
98
131
|
}
|
|
99
132
|
|
|
133
|
+
/** The session's directories: the launch directory answers roots/list, and the
|
|
134
|
+
* project root becomes CLAUDE_PROJECT_DIR in a stdio server's environment. */
|
|
135
|
+
export interface SessionDirs {
|
|
136
|
+
projectDir: string
|
|
137
|
+
launchDir: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A client that, like Claude, declares the roots capability and answers roots/list
|
|
141
|
+
* with the session's launch directory. pi's directory set is static, so no
|
|
142
|
+
* roots/list_changed notification is ever sent. */
|
|
143
|
+
function makeClient(session?: SessionDirs): Client {
|
|
144
|
+
if (!session) return new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
145
|
+
const client = new Client({ name: 'pi-code-mcp', version: '0.1.0' }, { capabilities: { roots: {} } })
|
|
146
|
+
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: [{ uri: pathToFileURL(session.launchDir).href }] }))
|
|
147
|
+
return client
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Claude's credential heuristic for helper environments: any name with TOKEN,
|
|
151
|
+
* SECRET, PASSWORD, KEY, or AUTH in it in either case (Git's GIT_CONFIG_KEY_<n>
|
|
152
|
+
* excepted), plus a fixed list of credential names outside the pattern. */
|
|
153
|
+
const CREDENTIAL_NAME_EXTRAS = new Set(['ANTHROPIC_CUSTOM_HEADERS'])
|
|
154
|
+
function isCredentialEnvName(name: string): boolean {
|
|
155
|
+
if (/^GIT_CONFIG_KEY_\d+$/.test(name)) return false
|
|
156
|
+
return /TOKEN|SECRET|PASSWORD|KEY|AUTH/i.test(name) || CREDENTIAL_NAME_EXTRAS.has(name)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** process.env with every credential-named value replaced by REDACTED, used to
|
|
160
|
+
* expand the url a helper is shown without handing it the credential. */
|
|
161
|
+
function redactedEnv(): NodeJS.ProcessEnv {
|
|
162
|
+
return Object.fromEntries(Object.entries(process.env).map(([key, value]) => [key, isCredentialEnvName(key) ? 'REDACTED' : value]))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The environment a headersHelper runs with: Claude's CLAUDE_CODE_MCP_SERVER_NAME
|
|
166
|
+
* and CLAUDE_CODE_MCP_SERVER_URL (credential-expanded url parts REDACTED), plus
|
|
167
|
+
* CLAUDE_PLUGIN_ROOT for a plugin's server. A helper a repository or plugin
|
|
168
|
+
* supplies is a command the user did not write, so it runs without the
|
|
169
|
+
* credential-named variables; a user-scope helper keeps them. */
|
|
170
|
+
function helperEnv(name: string, config: HttpServerConfig): NodeJS.ProcessEnv {
|
|
171
|
+
const stripped = config.projectScope === true || config.pluginRoot !== undefined
|
|
172
|
+
const env: NodeJS.ProcessEnv = {}
|
|
173
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
174
|
+
if (stripped && isCredentialEnvName(key)) continue
|
|
175
|
+
env[key] = value
|
|
176
|
+
}
|
|
177
|
+
env.CLAUDE_CODE_MCP_SERVER_NAME = name
|
|
178
|
+
env.CLAUDE_CODE_MCP_SERVER_URL = interpolateEnv(config.url, redactedEnv())
|
|
179
|
+
if (config.pluginRoot !== undefined) env.CLAUDE_PLUGIN_ROOT = config.pluginRoot
|
|
180
|
+
return env
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The env a stdio server process starts with: the SDK allowlist, the config's own
|
|
184
|
+
* env block, and Claude's path variables (CLAUDE_PROJECT_DIR, and CLAUDE_PLUGIN_ROOT
|
|
185
|
+
* for a plugin's server). */
|
|
186
|
+
function stdioEnv(config: StdioServerConfig, fill: (value: string) => string, session?: SessionDirs): Record<string, string> {
|
|
187
|
+
const env: Record<string, string> = { ...getDefaultEnvironment() }
|
|
188
|
+
for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
|
|
189
|
+
if (session) env.CLAUDE_PROJECT_DIR = session.projectDir
|
|
190
|
+
if (config.pluginRoot !== undefined) env.CLAUDE_PLUGIN_ROOT = config.pluginRoot
|
|
191
|
+
return env
|
|
192
|
+
}
|
|
193
|
+
|
|
100
194
|
export async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
101
195
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
102
196
|
const timeout = new Promise<never>((_, reject) => {
|
|
@@ -112,8 +206,8 @@ export async function withTimeout<T>(promise: Promise<T>, ms: number, label: str
|
|
|
112
206
|
}
|
|
113
207
|
}
|
|
114
208
|
|
|
115
|
-
export async function connect(name: string, config: ServerConfig, authUi?: AuthUi): Promise<Client> {
|
|
116
|
-
const client =
|
|
209
|
+
export async function connect(name: string, config: ServerConfig, authUi?: AuthUi, session?: SessionDirs): Promise<Client> {
|
|
210
|
+
const client = makeClient(session)
|
|
117
211
|
// Names referenced by ${VAR} with no value and no default, gathered across this
|
|
118
212
|
// server's interpolated fields so the connect can warn once rather than fail with a
|
|
119
213
|
// mystery 401 or a command that lost an argument.
|
|
@@ -126,12 +220,10 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
|
|
|
126
220
|
// Start from the SDK's allowlist (PATH, HOME, SHELL, ...) rather than the whole
|
|
127
221
|
// process env: a server should not receive ANTHROPIC_API_KEY or GITHUB_TOKEN just
|
|
128
222
|
// for being launched. A server that needs a variable names it in its own env block.
|
|
129
|
-
const env: Record<string, string> = { ...getDefaultEnvironment() }
|
|
130
|
-
for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
|
|
131
223
|
const transport = new StdioClientTransport({
|
|
132
224
|
command: fill(config.command),
|
|
133
225
|
args: (config.args ?? []).map((arg) => fill(arg)),
|
|
134
|
-
env,
|
|
226
|
+
env: stdioEnv(config, fill, session),
|
|
135
227
|
cwd: expandCwd(config.cwd),
|
|
136
228
|
stderr: 'ignore',
|
|
137
229
|
})
|
|
@@ -161,26 +253,30 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
|
|
|
161
253
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
162
254
|
// A headersHelper generates connect-time headers for non-OAuth auth schemes; its
|
|
163
255
|
// JSON stdout merges over the static headers.
|
|
164
|
-
if (config.headersHelper) Object.assign(headers, await runHeadersHelper(fill(config.headersHelper)))
|
|
256
|
+
if (config.headersHelper) Object.assign(headers, await runHeadersHelper(fill(config.headersHelper), helperEnv(name, config)))
|
|
165
257
|
warnMissing()
|
|
258
|
+
// Claude: a configured Authorization header, whether static, a bearer token, or
|
|
259
|
+
// helper output, is the server's authentication; there is no OAuth fallback for it.
|
|
260
|
+
const configuredAuth = Boolean(token) || Object.keys(headers).some((header) => header.toLowerCase() === 'authorization')
|
|
166
261
|
const sseTransport = (authProvider?: OAuthClientProvider) => new SSEClientTransport(url, { requestInit: { headers }, authProvider }) // NOSONAR: explicitly declared or deliberate legacy transport
|
|
167
262
|
if (config.type === 'sse') {
|
|
168
|
-
return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`,
|
|
263
|
+
return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, configuredAuth, authUi, session)
|
|
169
264
|
}
|
|
170
265
|
try {
|
|
171
|
-
return await connectHttpFamily(name, config, (authProvider) => new StreamableHTTPClientTransport(url, { requestInit: { headers }, authProvider }), `connect ${name}`,
|
|
266
|
+
return await connectHttpFamily(name, config, (authProvider) => new StreamableHTTPClientTransport(url, { requestInit: { headers }, authProvider }), `connect ${name}`, configuredAuth, authUi, session)
|
|
172
267
|
} catch (error) {
|
|
173
268
|
// An explicitly declared streamable transport must not silently degrade to SSE.
|
|
174
269
|
if (config.type !== undefined || isUnauthorized(error)) throw error
|
|
175
|
-
return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`,
|
|
270
|
+
return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, configuredAuth, authUi, session)
|
|
176
271
|
}
|
|
177
272
|
}
|
|
178
273
|
|
|
179
|
-
/** Run a headersHelper command and parse its JSON stdout into headers
|
|
180
|
-
* a 10s timeout yields no extra headers
|
|
181
|
-
|
|
274
|
+
/** Run a headersHelper command and parse its JSON stdout into headers, under the
|
|
275
|
+
* environment helperEnv built. A failure or a 10s timeout yields no extra headers
|
|
276
|
+
* rather than blocking the connection. */
|
|
277
|
+
function runHeadersHelper(command: string, env: NodeJS.ProcessEnv): Promise<Record<string, string>> {
|
|
182
278
|
return new Promise((resolve) => {
|
|
183
|
-
execFile('/bin/sh', ['-c', command], { timeout: 10_000 }, (error, stdout) => {
|
|
279
|
+
execFile('/bin/sh', ['-c', command], { timeout: 10_000, env }, (error, stdout) => {
|
|
184
280
|
resolve(error ? {} : parseHelperHeaders(stdout))
|
|
185
281
|
})
|
|
186
282
|
})
|
|
@@ -198,12 +294,12 @@ export interface AuthUi {
|
|
|
198
294
|
export class OAuthRequiredError extends Error {}
|
|
199
295
|
|
|
200
296
|
/** Whether a connect failure is an authentication problem: the SDK's own
|
|
201
|
-
* UnauthorizedError, a transport error carrying HTTP 401
|
|
202
|
-
*
|
|
203
|
-
* or our own marker. */
|
|
297
|
+
* UnauthorizedError, a transport error carrying HTTP 401 or 403 (Claude: "either
|
|
298
|
+
* status code flags it" for OAuth), or our own marker. */
|
|
204
299
|
export function isUnauthorized(error: unknown): boolean {
|
|
205
300
|
if (error instanceof UnauthorizedError || error instanceof OAuthRequiredError) return true
|
|
206
|
-
|
|
301
|
+
const code = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined
|
|
302
|
+
return code === 401 || code === 403
|
|
207
303
|
}
|
|
208
304
|
|
|
209
305
|
// SSEClientTransport is deprecated in favour of Streamable HTTP, but both concrete
|
|
@@ -218,22 +314,23 @@ export type MakeTransport = (authProvider?: OAuthClientProvider) => HttpFamilyTr
|
|
|
218
314
|
* demands one. Stored tokens ride the first attempt so the SDK refreshes
|
|
219
315
|
* silently; a 401 without tokens asks the user, opens the browser, catches the
|
|
220
316
|
* loopback redirect, and exchanges the code via the SDK's finishAuth.
|
|
221
|
-
*
|
|
222
|
-
*
|
|
317
|
+
* A server with configured authentication (a bearer token, a static Authorization
|
|
318
|
+
* header, or one from a headersHelper) never enters the OAuth path: the credential
|
|
319
|
+
* to fix is the configured one, so an auth failure reports as a failed connection.
|
|
223
320
|
*/
|
|
224
|
-
async function connectHttpFamily(name: string, config: { url: string }, makeTransport: MakeTransport, label: string,
|
|
225
|
-
const newClient = () =>
|
|
321
|
+
async function connectHttpFamily(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, hasConfiguredAuth: boolean, authUi: AuthUi | undefined, session?: SessionDirs): Promise<Client> {
|
|
322
|
+
const newClient = () => makeClient(session)
|
|
226
323
|
// Stored tokens ride the first attempt so the SDK refreshes them; with none, no
|
|
227
324
|
// provider is attached, so a 401 surfaces as a transport error carrying code 401
|
|
228
325
|
// (isUnauthorized detects it) and only the interactive provider below ever runs
|
|
229
326
|
// dynamic registration, keeping it bound to the real callback port.
|
|
230
|
-
const silent =
|
|
327
|
+
const silent = hasConfiguredAuth ? undefined : new FileOAuthProvider(name, () => {})
|
|
231
328
|
try {
|
|
232
329
|
const client = newClient()
|
|
233
330
|
await connectWithTimeout(client, makeTransport(silent?.hasTokens() ? silent : undefined), label)
|
|
234
331
|
return client
|
|
235
332
|
} catch (error) {
|
|
236
|
-
if (
|
|
333
|
+
if (hasConfiguredAuth || !isUnauthorized(error)) throw error
|
|
237
334
|
if (!authUi) throw new OAuthRequiredError(`${name} requires a login; run pi interactively to authenticate`)
|
|
238
335
|
return await serializeInteractiveOAuth(() => runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient))
|
|
239
336
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.27",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|