pi-code 1.0.26 → 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.
@@ -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
@@ -200,7 +208,7 @@ export function loadPluginServers(plugins: InstalledPlugin[], projectDir?: strin
200
208
  for (const plugin of plugins) {
201
209
  for (const [name, config] of Object.entries(rawPluginServerEntries(plugin))) {
202
210
  const substituted = substitutedPluginServer(plugin, name, config, projectDir)
203
- 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 }
204
212
  }
205
213
  }
206
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
@@ -47,7 +48,7 @@ import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope,
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, type ServerCallTuning, serverCallTuning, 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
@@ -87,6 +88,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
87
88
  // Config per server name, kept for call-time timeout tuning: the idle tier follows
88
89
  // the transport kind, and a declared per-server timeout governs the wall budget.
89
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
90
94
  const callTuning = (name: string): ServerCallTuning => {
91
95
  const config = serverConfigs.get(name)
92
96
  return config ? serverCallTuning(config) : {}
@@ -356,7 +360,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
356
360
  pending.map(async ([name, config]) => {
357
361
  warnOnTypelessUrl(name, config)
358
362
  try {
359
- const client = await connect(name, config, authUi)
363
+ const client = await connect(name, config, authUi, sessionDirs)
360
364
  clients.set(name, client)
361
365
  const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
362
366
  registerTools(name, config, tools)
@@ -448,7 +452,10 @@ export default async function mcpExtension(pi: ExtensionAPI) {
448
452
  // The stored project decision, read without prompting: consent recorded inside
449
453
  // the project only counts once the project itself has been approved.
450
454
  const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
451
- const { consented: consentedRaw, 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)
452
459
  // Claude's scope precedence is local over project: a name the local scope defines
453
460
  // stays with the local (user-side) definition, so the project's entry is dropped
454
461
  // here rather than allowed to shadow it.
@@ -480,6 +487,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
480
487
  // withdrawn tool keeps its registration and surfaces the server's own error), which
481
488
  // is why serverToolCount reads from `registered` to recover the true count here.
482
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 }
483
493
  const authUi = authUiFor(ctx)
484
494
  // The allow/deny lists filter every scope, including a managed-mcp.json set. They
485
495
  // merge from managed settings plus the trust-gated settings chain, as Claude
@@ -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,8 +14,9 @@ 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.
@@ -128,6 +130,67 @@ function isStdio(config: ServerConfig): config is StdioServerConfig {
128
130
  return 'command' in config && (config.type === undefined || config.type === 'stdio')
129
131
  }
130
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
+
131
194
  export async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
132
195
  let timer: ReturnType<typeof setTimeout> | undefined
133
196
  const timeout = new Promise<never>((_, reject) => {
@@ -143,8 +206,8 @@ export async function withTimeout<T>(promise: Promise<T>, ms: number, label: str
143
206
  }
144
207
  }
145
208
 
146
- export async function connect(name: string, config: ServerConfig, authUi?: AuthUi): Promise<Client> {
147
- 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)
148
211
  // Names referenced by ${VAR} with no value and no default, gathered across this
149
212
  // server's interpolated fields so the connect can warn once rather than fail with a
150
213
  // mystery 401 or a command that lost an argument.
@@ -157,12 +220,10 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
157
220
  // Start from the SDK's allowlist (PATH, HOME, SHELL, ...) rather than the whole
158
221
  // process env: a server should not receive ANTHROPIC_API_KEY or GITHUB_TOKEN just
159
222
  // for being launched. A server that needs a variable names it in its own env block.
160
- const env: Record<string, string> = { ...getDefaultEnvironment() }
161
- for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
162
223
  const transport = new StdioClientTransport({
163
224
  command: fill(config.command),
164
225
  args: (config.args ?? []).map((arg) => fill(arg)),
165
- env,
226
+ env: stdioEnv(config, fill, session),
166
227
  cwd: expandCwd(config.cwd),
167
228
  stderr: 'ignore',
168
229
  })
@@ -192,26 +253,30 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
192
253
  if (token) headers.Authorization = `Bearer ${token}`
193
254
  // A headersHelper generates connect-time headers for non-OAuth auth schemes; its
194
255
  // JSON stdout merges over the static headers.
195
- 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)))
196
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')
197
261
  const sseTransport = (authProvider?: OAuthClientProvider) => new SSEClientTransport(url, { requestInit: { headers }, authProvider }) // NOSONAR: explicitly declared or deliberate legacy transport
198
262
  if (config.type === 'sse') {
199
- return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
263
+ return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, configuredAuth, authUi, session)
200
264
  }
201
265
  try {
202
- 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)
203
267
  } catch (error) {
204
268
  // An explicitly declared streamable transport must not silently degrade to SSE.
205
269
  if (config.type !== undefined || isUnauthorized(error)) throw error
206
- return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
270
+ return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, configuredAuth, authUi, session)
207
271
  }
208
272
  }
209
273
 
210
- /** Run a headersHelper command and parse its JSON stdout into headers. A failure or
211
- * a 10s timeout yields no extra headers rather than blocking the connection. */
212
- 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>> {
213
278
  return new Promise((resolve) => {
214
- execFile('/bin/sh', ['-c', command], { timeout: 10_000 }, (error, stdout) => {
279
+ execFile('/bin/sh', ['-c', command], { timeout: 10_000, env }, (error, stdout) => {
215
280
  resolve(error ? {} : parseHelperHeaders(stdout))
216
281
  })
217
282
  })
@@ -229,12 +294,12 @@ export interface AuthUi {
229
294
  export class OAuthRequiredError extends Error {}
230
295
 
231
296
  /** Whether a connect failure is an authentication problem: the SDK's own
232
- * UnauthorizedError, a transport error carrying HTTP 401 (which is what a 401
233
- * throws when no authProvider was attached, so a first-time login is detected),
234
- * 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. */
235
299
  export function isUnauthorized(error: unknown): boolean {
236
300
  if (error instanceof UnauthorizedError || error instanceof OAuthRequiredError) return true
237
- 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
238
303
  }
239
304
 
240
305
  // SSEClientTransport is deprecated in favour of Streamable HTTP, but both concrete
@@ -249,22 +314,23 @@ export type MakeTransport = (authProvider?: OAuthClientProvider) => HttpFamilyTr
249
314
  * demands one. Stored tokens ride the first attempt so the SDK refreshes
250
315
  * silently; a 401 without tokens asks the user, opens the browser, catches the
251
316
  * loopback redirect, and exchanges the code via the SDK's finishAuth.
252
- * Bearer-token servers never enter the OAuth path: an explicit token is the
253
- * 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.
254
320
  */
255
- async function connectHttpFamily(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, bearerToken: string | undefined, authUi: AuthUi | undefined): Promise<Client> {
256
- 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)
257
323
  // Stored tokens ride the first attempt so the SDK refreshes them; with none, no
258
324
  // provider is attached, so a 401 surfaces as a transport error carrying code 401
259
325
  // (isUnauthorized detects it) and only the interactive provider below ever runs
260
326
  // dynamic registration, keeping it bound to the real callback port.
261
- const silent = bearerToken ? undefined : new FileOAuthProvider(name, () => {})
327
+ const silent = hasConfiguredAuth ? undefined : new FileOAuthProvider(name, () => {})
262
328
  try {
263
329
  const client = newClient()
264
330
  await connectWithTimeout(client, makeTransport(silent?.hasTokens() ? silent : undefined), label)
265
331
  return client
266
332
  } catch (error) {
267
- if (bearerToken || !isUnauthorized(error)) throw error
333
+ if (hasConfiguredAuth || !isUnauthorized(error)) throw error
268
334
  if (!authUi) throw new OAuthRequiredError(`${name} requires a login; run pi interactively to authenticate`)
269
335
  return await serializeInteractiveOAuth(() => runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient))
270
336
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.26",
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",