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.
@@ -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
- const id = qualified.replace(/[^A-Za-z0-9]+/g, '-')
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
  }
@@ -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
- Object.assign(servers, claudeJson.projects?.[cwd]?.mcpServers ?? {})
119
+ return claudeJson.projects?.[cwd] ?? {}
105
120
  } catch {
106
- // missing or invalid ~/.claude.json: the top-level user servers already loaded
121
+ return {}
107
122
  }
108
- return servers
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; an array is not a valid mcpServers map (it
119
- // would register a server named '0'), so it falls through to the path branch.
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 file = path.resolve(plugin.root, typeof declared === 'string' ? declared : '.mcp.json')
124
- try {
125
- const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
126
- return parsed.mcpServers ?? {}
127
- } catch {
128
- return {}
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
- const fold = (name: string): string => name.replaceAll('-', '_')
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
@@ -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 (names normalized dashes/spaces to underscores,
24
- * args space-separated and mapped positionally); the prompt result drives a turn via
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 result = await client.callTool({ name: tool, arguments: input }, undefined, callRequestOptions(callTimeoutMs()))
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 declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
142
- const wall = declared ?? callTimeoutMs()
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 scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, policy)
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
- const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), policy), projectPolicy)
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. Both
16
- * names normalize like formatToolName, extended to spaces: dashes and spaces each
17
- * become an underscore. */
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
- const normalize = (name: string): string => name.replace(/[\s-]/g, '_')
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. Tokens map
36
- * positionally onto the declared arguments, split the way slash-command args are
37
- * (quoted runs stay together); the last declared argument absorbs any trailing
38
- * tokens so free text at the end is not silently dropped. Declared arguments with
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 = splitArgs(args)
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]] = index === names.length - 1 ? tokens.slice(index).join(' ') : tokens[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
- const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
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 (minutes) rather than the hours-long
27
- // wall-clock budget; the SDK resets this window on every progress notification.
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 = Number.parseInt(raw, 10)
35
- return Number.isInteger(value) && value > 0 ? value : fallback
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, overridable by CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT, with 0 disabling it
44
- * (leaving only the wall-clock budget). Unlike envTimeout, an explicit 0 is honored as
45
- * "disabled" rather than falling back to the default. */
46
- function idleTimeoutMs(): number {
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
- if (raw === undefined) return DEFAULT_CALL_IDLE_TIMEOUT_MS
49
- const value = Number.parseInt(raw, 10)
50
- if (value === 0) return 0
51
- return Number.isInteger(value) && value > 0 ? value : DEFAULT_CALL_IDLE_TIMEOUT_MS
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 = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
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)`, token, authUi)
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}`, token, authUi)
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)`, token, authUi)
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. A failure or
180
- * a 10s timeout yields no extra headers rather than blocking the connection. */
181
- function runHeadersHelper(command: string): Promise<Record<string, string>> {
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 (which is what a 401
202
- * throws when no authProvider was attached, so a first-time login is detected),
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
- return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 401
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
- * Bearer-token servers never enter the OAuth path: an explicit token is the
222
- * user saying how auth works.
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, bearerToken: string | undefined, authUi: AuthUi | undefined): Promise<Client> {
225
- const newClient = () => new Client({ name: 'pi-code-mcp', version: '0.1.0' })
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 = bearerToken ? undefined : new FileOAuthProvider(name, () => {})
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 (bearerToken || !isUnauthorized(error)) throw error
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.25",
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",