pi-code 1.0.13 → 1.0.15

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.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * MCP name and content mapping: the pi tool/prompt-command name formatting, prompt
3
+ * argument mapping, input-schema normalization, and the tool/prompt/resource content
4
+ * blocks mapped into pi's output budget.
5
+ */
6
+
7
+ import { DEFAULT_MAX_BYTES } from '@earendil-works/pi-coding-agent'
8
+ import { splitArgs } from '../internal/command-file.js'
9
+ import { capForContext } from '../internal/output-guard.js'
10
+
11
+ export function formatToolName(server: string, tool: string): string {
12
+ return `${server}_${tool}`.replaceAll('-', '_')
13
+ }
14
+
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. */
18
+ 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)}`
21
+ }
22
+
23
+ export interface McpPromptArgumentInfo {
24
+ name: string
25
+ description?: string
26
+ required?: boolean
27
+ }
28
+
29
+ export interface McpPromptInfo {
30
+ name: string
31
+ description?: string
32
+ arguments?: McpPromptArgumentInfo[]
33
+ }
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`. */
40
+ export function mapPromptArguments(declared: ReadonlyArray<{ name: string }> | undefined, args: string): Record<string, string> {
41
+ const tokens = splitArgs(args)
42
+ const names = (declared ?? []).map((argument) => argument.name)
43
+ const mapped: Record<string, string> = {}
44
+ 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]
46
+ }
47
+ return mapped
48
+ }
49
+
50
+ /** The content blocks a getPrompt result injects. Each message carries one content
51
+ * block; the blocks ride the same mapContent budget as tool output, and image blocks
52
+ * are carried through rather than dropped, since sendUserMessage accepts them and a
53
+ * vision prompt is worthless flattened to text. An empty message list yields no
54
+ * blocks, and messages that carry only empty text yield none either, so the caller
55
+ * can skip the turn rather than drive it on an empty or sentinel message. */
56
+ export function promptMessageContent(messages: ReadonlyArray<{ content: unknown }>): ToolContent[] {
57
+ if (messages.length === 0) return []
58
+ return mapContent(messages.map((message) => message.content as McpContentBlock)).filter((block) => block.type !== 'text' || block.text.trim() !== '')
59
+ }
60
+
61
+ /** Merge the `properties` (and, for allOf, the `required`) of a root-level combinator's
62
+ * branches into one flat object schema. Without this a tool whose input schema is a bare
63
+ * anyOf/oneOf/allOf (no top-level `type`) would present no properties at all, so the model
64
+ * would be forced to call it with no arguments. */
65
+ function mergeCombinatorBranches(branches: unknown[]): { properties: Record<string, unknown>; required: string[] } {
66
+ const properties: Record<string, unknown> = {}
67
+ const required = new Set<string>()
68
+ for (const branch of branches) {
69
+ if (!branch || typeof branch !== 'object') continue
70
+ const b = branch as Record<string, unknown>
71
+ if (b.properties && typeof b.properties === 'object') Object.assign(properties, b.properties as Record<string, unknown>)
72
+ if (Array.isArray(b.required)) for (const name of b.required) if (typeof name === 'string') required.add(name)
73
+ }
74
+ return { properties, required: [...required] }
75
+ }
76
+
77
+ export function normalizeSchema(schema: unknown): object {
78
+ const base = (schema as Record<string, unknown>) ?? {}
79
+ const { $schema: _dropSchema, additionalProperties: _dropAdditional, ...rest } = base
80
+ if (rest.type) return rest
81
+ // A root-level combinator carries the real parameters in its branches; flatten them
82
+ // into one object schema rather than emptying it. allOf means every branch applies, so
83
+ // its required union is kept; anyOf/oneOf branches are alternatives, so required is left
84
+ // open (the server still enforces its own).
85
+ const allOf = Array.isArray(rest.allOf) ? rest.allOf : undefined
86
+ let branches = allOf
87
+ if (!branches && Array.isArray(rest.anyOf)) branches = rest.anyOf
88
+ if (!branches && Array.isArray(rest.oneOf)) branches = rest.oneOf
89
+ if (!branches) return { type: 'object', properties: {} }
90
+ const { properties, required } = mergeCombinatorBranches(branches)
91
+ const merged: Record<string, unknown> = { type: 'object', properties }
92
+ if (typeof rest.description === 'string') merged.description = rest.description
93
+ if (allOf && required.length > 0) merged.required = required
94
+ return merged
95
+ }
96
+
97
+ export interface McpContentBlock {
98
+ type: string
99
+ text?: string
100
+ data?: string
101
+ mimeType?: string
102
+ resource?: { uri?: string; text?: string }
103
+ }
104
+
105
+ export type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
106
+
107
+ export function mapContent(content: McpContentBlock[] | undefined, structured?: unknown): ToolContent[] {
108
+ // capForContext every text output, whatever its source: a server can blow the tool-output
109
+ // budget through a resource block, a JSON-stringified block, or the structured fallback,
110
+ // not only a text block. The per-block cap alone is not a budget, though: a server
111
+ // answering with one block per file multiplies it by the block count, so the blocks
112
+ // are capped again as a whole below.
113
+ const text = (value: string): ToolContent => ({ type: 'text', text: capForContext(value) })
114
+ if (!content || content.length === 0) {
115
+ return [text(structured !== undefined ? JSON.stringify(structured, null, 2) : '(empty result)')]
116
+ }
117
+ const mapped: ToolContent[] = content.map((block): ToolContent => {
118
+ if (block.type === 'text') {
119
+ return text(block.text ?? '')
120
+ }
121
+ if (block.type === 'image' && block.data) {
122
+ return { type: 'image', data: block.data, mimeType: block.mimeType ?? 'image/png' }
123
+ }
124
+ if (block.type === 'resource' && block.resource) {
125
+ return text(`[Resource: ${block.resource.uri ?? 'unknown'}]\n${block.resource.text ?? ''}`)
126
+ }
127
+ return text(JSON.stringify(block))
128
+ })
129
+ return capTotal(mapped)
130
+ }
131
+
132
+ /**
133
+ * Bound a result's text as a whole, not each block. The per-block cap multiplies by
134
+ * the block count, so a server answering with one block per file still injects
135
+ * megabytes.
136
+ *
137
+ * Blocks are kept whole. Each has already been capped on its own, so keeping the one
138
+ * that crosses the budget bounds the text at roughly a single cap rather than at the
139
+ * block count times it, and it preserves that block's own truncation notice, which
140
+ * states how much of it was dropped. Blocks after it are omitted rather than skipped
141
+ * over, so what reaches the model is a prefix of what the server sent, and the number
142
+ * omitted is stated so a truncated set is distinguishable from a complete one.
143
+ *
144
+ * Images pass through uncut and do not spend the budget: base64 cut short is a broken
145
+ * image rather than a smaller one, so nothing here can bound them, and charging the
146
+ * budget for one would only delete the caption that accompanies a screenshot.
147
+ */
148
+ export function capTotal(blocks: ToolContent[]): ToolContent[] {
149
+ const kept: ToolContent[] = []
150
+ let spent = 0
151
+ let full = false
152
+ let dropped = 0
153
+ for (const block of blocks) {
154
+ if (block.type !== 'text') {
155
+ kept.push(block)
156
+ continue
157
+ }
158
+ const size = Buffer.byteLength(block.text, 'utf-8')
159
+ // The first text block always goes through: a lone oversized one is better read
160
+ // truncated, with its own notice, than replaced by a marker saying it existed.
161
+ if (full || (spent > 0 && spent + size > DEFAULT_MAX_BYTES)) {
162
+ full = true
163
+ dropped++
164
+ continue
165
+ }
166
+ kept.push(block)
167
+ spent += size
168
+ }
169
+ if (dropped > 0) {
170
+ kept.push({ type: 'text', text: `[${dropped} further content block${dropped === 1 ? '' : 's'} omitted: tool output budget spent]` })
171
+ }
172
+ return kept
173
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * MCP interactive OAuth: the serialization queue that stops two browser logins from
3
+ * stacking dialogs and tabs, and the interactive login itself (confirm, open the
4
+ * browser, catch the loopback redirect, exchange the code via the SDK's finishAuth).
5
+ * The FileOAuthProvider and callback server live in internal/mcp-oauth.
6
+ */
7
+
8
+ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
9
+ import { FileOAuthProvider, openBrowser, startCallbackServer, waitForAuthCode } from '../internal/mcp-oauth.js'
10
+ import { type AuthUi, connectWithTimeout, isUnauthorized, type MakeTransport, OAuthRequiredError } from './transport.js'
11
+
12
+ /** Browser logins are human-paced; a connect-sized timeout would cut them off. */
13
+ const OAUTH_FLOW_TIMEOUT_MS = 180_000
14
+
15
+ /** Wrap a login-flow failure as OAuthRequiredError, passing an existing one through
16
+ * unchanged so its message is not doubled. */
17
+ function asOAuthRequiredError(name: string, error: unknown): OAuthRequiredError {
18
+ if (error instanceof OAuthRequiredError) return error
19
+ const detail = error instanceof Error ? error.message : String(error)
20
+ return new OAuthRequiredError(`login for ${name} failed: ${detail}`)
21
+ }
22
+
23
+ /** Interactive OAuth logins block on a confirm dialog and open a browser tab, so two
24
+ * at once (a user-scope and a consented project-scope server both 401ing, connecting in
25
+ * parallel) would stack dialogs and browser tabs. This chains them so a second
26
+ * interactive login waits for the first to settle; the tail is reset to a resolved
27
+ * promise regardless of outcome, so a failed login never poisons the queue. Silent
28
+ * (stored-token) connects do not pass through here and stay fully parallel. */
29
+ let oauthQueue: Promise<unknown> = Promise.resolve()
30
+
31
+ export function serializeInteractiveOAuth<T>(run: () => Promise<T>): Promise<T> {
32
+ const result = oauthQueue.then(run, run)
33
+ oauthQueue = result.then(
34
+ () => {},
35
+ () => {},
36
+ )
37
+ return result
38
+ }
39
+
40
+ /**
41
+ * The interactive half of the OAuth login, reached only once a silent connect has
42
+ * failed with a 401 and a UI is present: confirm, open the browser, catch the loopback
43
+ * redirect, and exchange the code via the SDK's finishAuth. Past the confirm the server
44
+ * is known to need OAuth, so any failure here (a denied consent page, the 180s wait, a
45
+ * token exchange error) is wrapped as an auth failure, not a transport mismatch: that
46
+ * keeps the typeless-url caller from retrying over SSE and prompting for a second login.
47
+ */
48
+ export async function runInteractiveOAuth(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, authUi: AuthUi, newClient: () => Client): Promise<Client> {
49
+ const approved = await authUi.confirm(`MCP server "${name}" requires login`, `Open your browser to authorize ${config.url}?`)
50
+ if (!approved) throw new OAuthRequiredError(`login declined for ${name}`)
51
+ const provider = new FileOAuthProvider(name, (authorizationUrl) => {
52
+ openBrowser(String(authorizationUrl))
53
+ authUi.notify(`Authorize "${name}" in the browser. If it did not open: ${authorizationUrl}`, 'info')
54
+ })
55
+ const { server, port } = await startCallbackServer(provider.savedRedirectPort())
56
+ provider.bindRedirectPort(port)
57
+ try {
58
+ const transport = makeTransport(provider)
59
+ // Verify the redirect echoes this login's state, so a stray or forged callback to the
60
+ // loopback port cannot inject a code or abort the login (see waitForAuthCode).
61
+ const pendingCode = waitForAuthCode(server, OAUTH_FLOW_TIMEOUT_MS, provider.state())
62
+ pendingCode.catch(() => {}) // consumed below; an abandoned login must not surface as unhandled
63
+ const client = newClient()
64
+ try {
65
+ await connectWithTimeout(client, transport, label)
66
+ return client // authorized between attempts; nothing left to exchange
67
+ } catch (retryError) {
68
+ if (!isUnauthorized(retryError)) throw retryError
69
+ const code = await pendingCode
70
+ await transport.finishAuth(code)
71
+ const authed = newClient()
72
+ await connectWithTimeout(authed, makeTransport(provider), label)
73
+ return authed
74
+ }
75
+ } catch (flowError) {
76
+ throw asOAuthRequiredError(name, flowError)
77
+ } finally {
78
+ server.close()
79
+ }
80
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * MCP server policy: the per-project .mcp.json approvals, the managed allow/deny
3
+ * lists, and the managed-mcp.json exclusive-control loader. The managed settings
4
+ * path (and its test-seam override) is owned by internal/managed-settings.
5
+ */
6
+
7
+ import * as fs from 'node:fs'
8
+ import * as path from 'node:path'
9
+ import { claudeConfigDir } from '../internal/config-dir.js'
10
+ import { managedSettingsFile } from '../internal/managed-settings.js'
11
+ import { findNearestFile } from '../internal/project-root.js'
12
+ import type { ServerConfig } from './config.js'
13
+
14
+ export interface ProjectServerPolicy {
15
+ disabled: Set<string>
16
+ consented: Set<string>
17
+ consentAll: boolean
18
+ }
19
+
20
+ /** Claude's per-server approvals for project .mcp.json servers.
21
+ *
22
+ * Consent-granting keys (enabledMcpjsonServers, enableAllProjectMcpServers) count
23
+ * from the user's own settings always, and from the project's settings.local.json
24
+ * only once the project itself is approved. That file is gitignored by convention,
25
+ * not by enforcement: a repository can commit one, and honoring it unconditionally
26
+ * let a hostile repo self-approve a server whose `command` runs on connect, even
27
+ * after the user declined the trust prompt.
28
+ *
29
+ * disabledMcpjsonServers counts from every file, including the repo's own, and wins
30
+ * over consent: a repo may always restrict itself further, never less. */
31
+ export function projectServerPolicy(cwd: string, home: string, projectApproved: boolean): ProjectServerPolicy {
32
+ const read = (file: string): Record<string, unknown> => {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(file, 'utf-8'))
35
+ } catch {
36
+ return {}
37
+ }
38
+ }
39
+ const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
40
+ const userSettings = read(path.join(claudeConfigDir(home), 'settings.json'))
41
+ const projectSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json'))
42
+ const localSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json'))
43
+ const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
44
+ const consentSources = projectApproved ? [userSettings, localSettings] : [userSettings]
45
+ const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
46
+ const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
47
+ return { disabled, consented, consentAll }
48
+ }
49
+
50
+ /** Split project servers by the per-server policy: never-connect, connect without the
51
+ * whole-project confirm, and still gated behind it. */
52
+ export function splitByPolicy(candidates: Record<string, ServerConfig>, policy: ProjectServerPolicy): { consented: Record<string, ServerConfig>; gated: Record<string, ServerConfig> } {
53
+ const consented: Record<string, ServerConfig> = {}
54
+ const gated: Record<string, ServerConfig> = {}
55
+ for (const [name, config] of Object.entries(candidates)) {
56
+ if (policy.disabled.has(name)) continue
57
+ if (policy.consentAll || policy.consented.has(name)) consented[name] = config
58
+ else gated[name] = config
59
+ }
60
+ return { consented, gated }
61
+ }
62
+
63
+ /** Claude's `allowedMcpServers`/`deniedMcpServers`, read from managed settings only
64
+ * (not user or project settings, so a repo can neither widen nor narrow the policy),
65
+ * applied globally to every server across scopes. Entries are `{ serverName }` objects
66
+ * (bare strings tolerated). `allowed` is null when unset (no restriction); an empty
67
+ * set is an explicit lockdown, as Claude documents (empty allow array = deny all). */
68
+ export function mcpAllowDeny(managedFile: string = managedSettingsFile()): { allowed: Set<string> | null; denied: Set<string> } {
69
+ let settings: Record<string, unknown> = {}
70
+ try {
71
+ const parsed = JSON.parse(fs.readFileSync(managedFile, 'utf-8'))
72
+ if (parsed && typeof parsed === 'object') settings = parsed
73
+ } catch {
74
+ // No managed policy on this machine: no restriction.
75
+ }
76
+ const entryName = (entry: unknown): string | undefined => {
77
+ if (typeof entry === 'string') return entry
78
+ const serverName = (entry as { serverName?: unknown })?.serverName
79
+ return typeof serverName === 'string' ? serverName : undefined
80
+ }
81
+ const names = (value: unknown): string[] => (Array.isArray(value) ? value.map(entryName).filter((name): name is string => typeof name === 'string' && name.length > 0) : [])
82
+ return {
83
+ allowed: Array.isArray(settings.allowedMcpServers) ? new Set(names(settings.allowedMcpServers)) : null,
84
+ denied: new Set(names(settings.deniedMcpServers)),
85
+ }
86
+ }
87
+
88
+ /** The managed-mcp.json path: a sibling of managed-settings.json (same directory). Derived
89
+ * through the same test seam so a test can write both into one temp dir. */
90
+ export function managedMcpPath(managedFile: string = managedSettingsFile()): string {
91
+ return path.join(path.dirname(managedFile), 'managed-mcp.json')
92
+ }
93
+
94
+ /** Claude's managed-mcp.json: when it exists beside managed-settings.json it takes
95
+ * exclusive control of MCP. Only its `mcpServers` load; user, project, and plugin servers
96
+ * are all suppressed (and the project-approval flow with them), and an empty map disables
97
+ * MCP entirely. Returns the managed server map (possibly empty) when the file exists and
98
+ * parses, or null only when the file is absent, in which case MCP loads from the usual
99
+ * scopes exactly as before. A file that parses but carries no `mcpServers` object is an
100
+ * empty managed set, so a deployed-but-bodyless policy locks down rather than silently
101
+ * reopening the other scopes. A file that is PRESENT but not valid JSON fails closed to
102
+ * the same empty set (deny-all) rather than reopening those scopes: the lockdown intent
103
+ * means a corrupt or truncated policy file must not become an allow-all. The allow/deny
104
+ * lists still filter the returned set. */
105
+ export function loadManagedMcpServers(managedFile: string = managedSettingsFile()): Record<string, ServerConfig> | null {
106
+ const file = managedMcpPath(managedFile)
107
+ let raw: string
108
+ try {
109
+ raw = fs.readFileSync(file, 'utf-8')
110
+ } catch {
111
+ // Absent (or unreadable) managed-mcp.json: no managed MCP control, load normally.
112
+ return null
113
+ }
114
+ let parsed: unknown
115
+ try {
116
+ parsed = JSON.parse(raw)
117
+ } catch (error) {
118
+ // Present but corrupt: fail closed to an empty managed set, exactly like an empty map,
119
+ // rather than reopening the user/project/plugin scopes.
120
+ console.warn(`pi-code-mcp: managed-mcp.json is present but not valid JSON (${file}); failing closed to no MCP servers: ${error instanceof Error ? error.message : String(error)}`)
121
+ return {}
122
+ }
123
+ if (parsed === null || typeof parsed !== 'object') return {}
124
+ const servers = (parsed as { mcpServers?: unknown }).mcpServers
125
+ if (servers === null || typeof servers !== 'object' || Array.isArray(servers)) return {}
126
+ return servers as Record<string, ServerConfig>
127
+ }
128
+
129
+ /** Claude's managed allow/deny lists: `allowed` null means no allow list (keep all);
130
+ * a set (even empty) is exclusive, so only its members survive; a deny list removes
131
+ * servers on top, deny winning over allow. */
132
+ export function applyServerPolicy(servers: Record<string, ServerConfig>, allowed: ReadonlySet<string> | null, denied: ReadonlySet<string>): Record<string, ServerConfig> {
133
+ const out: Record<string, ServerConfig> = {}
134
+ for (const [name, config] of Object.entries(servers)) {
135
+ if (denied.has(name)) continue
136
+ if (allowed !== null && !allowed.has(name)) continue
137
+ out[name] = config
138
+ }
139
+ return out
140
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * MCP transport: the connect/call timeouts, the SDK request options for pi's two-tier
3
+ * timeout, the stdio and HTTP-family (streamable with SSE fallback) connect paths, the
4
+ * headersHelper, and the auth primitives shared with the interactive OAuth flow.
5
+ */
6
+
7
+ import { execFile } from 'node:child_process'
8
+ // SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
9
+ // the old spec exist, so this stays as a fallback for the migration period.
10
+ import { type OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
11
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
12
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' // NOSONAR
13
+ import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
14
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
15
+ import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'
16
+ import { FileOAuthProvider } from '../internal/mcp-oauth.js'
17
+ import { expandCwd, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
18
+ import { runInteractiveOAuth, serializeInteractiveOAuth } from './oauth-flow.js'
19
+
20
+ const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
21
+ // Claude's MCP_TOOL_TIMEOUT default is effectively hours: the per-call wall-clock budget
22
+ // is only a ceiling, and the idle timeout below is the real guard. 4h matches that model,
23
+ // so a legitimately slow-but-progressing tool is not killed at the old 2 minutes.
24
+ const DEFAULT_CALL_TIMEOUT_MS = 14_400_000
25
+ // 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.
28
+ const DEFAULT_CALL_IDLE_TIMEOUT_MS = 300_000
29
+
30
+ /** A positive-integer env override, or the default when unset or unparseable. */
31
+ function envTimeout(name: string, fallback: number): number {
32
+ const raw = process.env[name]
33
+ if (raw === undefined) return fallback
34
+ const value = Number.parseInt(raw, 10)
35
+ return Number.isInteger(value) && value > 0 ? value : fallback
36
+ }
37
+
38
+ // Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
39
+ export const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
40
+ export const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
41
+
42
+ /** 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 {
47
+ 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
52
+ }
53
+
54
+ /** The SDK RequestOptions for a call under pi's two-tier timeout: a wall-clock ceiling and,
55
+ * under it, an idle timeout the SDK resets on every progress notification. When the idle
56
+ * window is enabled and tighter than the wall budget, `timeout` is that per-quiet-period
57
+ * deadline (resetTimeoutOnProgress), maxTotalTimeout caps the wall clock, and an onprogress
58
+ * handler is required: it makes the server address progress to this request and lets the
59
+ * SDK reset the timer on it. When the idle timeout is disabled, or already looser than the
60
+ * wall budget, only the wall budget applies. The outer withTimeout race is a wall-clock
61
+ * backstop and must be raced against `wall`, never the idle window, so a legitimately
62
+ * progressing call is not cut off. */
63
+ export function callRequestOptions(wall: number): { timeout: number; resetTimeoutOnProgress?: boolean; maxTotalTimeout?: number; onprogress?: () => void } {
64
+ const idle = idleTimeoutMs()
65
+ if (idle === 0 || idle >= wall) return { timeout: wall }
66
+ return { timeout: idle, resetTimeoutOnProgress: true, maxTotalTimeout: wall, onprogress: () => {} }
67
+ }
68
+
69
+ /** Claude reports a config entry that has a url but no type as an error; pi-code
70
+ * still connects (streamable HTTP with SSE fallback) but says the entry is wrong. */
71
+ /** An inline bearerToken (interpolated) wins over bearerTokenEnv, which names an
72
+ * environment variable read as-is. */
73
+ export function resolveBearerToken(config: { bearerToken?: string; bearerTokenEnv?: string }): string | undefined {
74
+ if (config.bearerToken) return interpolateEnv(config.bearerToken)
75
+ if (config.bearerTokenEnv) return process.env[config.bearerTokenEnv]
76
+ return undefined
77
+ }
78
+
79
+ /** Claude's `headersHelper` output: a flat JSON object of header name -> string,
80
+ * merged into the connect headers. Non-string values and non-object output are
81
+ * ignored so a broken helper cannot poison the request. */
82
+ export function parseHelperHeaders(stdout: string): Record<string, string> {
83
+ let parsed: unknown
84
+ try {
85
+ parsed = JSON.parse(stdout)
86
+ } catch {
87
+ return {}
88
+ }
89
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
90
+ const out: Record<string, string> = {}
91
+ for (const [key, value] of Object.entries(parsed)) if (typeof value === 'string') out[key] = value
92
+ return out
93
+ }
94
+
95
+ function isStdio(config: ServerConfig): config is StdioServerConfig {
96
+ // An explicit type wins; without one, a command field means stdio.
97
+ return 'command' in config && (config.type === undefined || config.type === 'stdio')
98
+ }
99
+
100
+ export async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
101
+ let timer: ReturnType<typeof setTimeout> | undefined
102
+ const timeout = new Promise<never>((_, reject) => {
103
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
104
+ })
105
+ // If the timeout wins, `promise` stays pending; swallow any late rejection so it can never
106
+ // surface as an unhandled rejection that crashes the host.
107
+ promise.catch(() => {})
108
+ try {
109
+ return await Promise.race([promise, timeout])
110
+ } finally {
111
+ clearTimeout(timer)
112
+ }
113
+ }
114
+
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' })
117
+ // Names referenced by ${VAR} with no value and no default, gathered across this
118
+ // server's interpolated fields so the connect can warn once rather than fail with a
119
+ // mystery 401 or a command that lost an argument.
120
+ const missing = new Set<string>()
121
+ const fill = (value: string): string => interpolateEnv(value, process.env, (varName) => missing.add(varName))
122
+ const warnMissing = (): void => {
123
+ if (missing.size > 0) console.warn(`pi-code-mcp: server ${name} references undefined variable(s) ${[...missing].join(', ')}; leaving them unexpanded`)
124
+ }
125
+ if (isStdio(config)) {
126
+ // Start from the SDK's allowlist (PATH, HOME, SHELL, ...) rather than the whole
127
+ // process env: a server should not receive ANTHROPIC_API_KEY or GITHUB_TOKEN just
128
+ // 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
+ const transport = new StdioClientTransport({
132
+ command: fill(config.command),
133
+ args: (config.args ?? []).map((arg) => fill(arg)),
134
+ env,
135
+ cwd: expandCwd(config.cwd),
136
+ stderr: 'ignore',
137
+ })
138
+ warnMissing()
139
+ await connectWithTimeout(client, transport, `connect ${name}`)
140
+ return client
141
+ }
142
+ const url = new URL(fill(config.url))
143
+ if (config.type === 'ws' || config.type === 'websocket') {
144
+ // The SDK's WebSocket transport takes only a url: it carries no headers, bearer
145
+ // token, or headersHelper output. Warn rather than silently dropping configured
146
+ // auth, and skip the helper entirely (running it would block the connect for up to
147
+ // 10s while contributing nothing). A ws server must be reachable without auth.
148
+ if (config.headers || config.bearerToken || config.bearerTokenEnv || config.headersHelper) {
149
+ console.warn(`pi-code-mcp: server ${name} is a WebSocket server; the SDK ws transport is url-only, so its headers/bearerToken/headersHelper are ignored`)
150
+ }
151
+ const transport = new WebSocketClientTransport(url)
152
+ warnMissing()
153
+ await connectWithTimeout(client, transport, `connect ${name} (ws)`)
154
+ return client
155
+ }
156
+ const headers: Record<string, string> = {}
157
+ for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = fill(value)
158
+ const token = resolveBearerToken(config)
159
+ if (token) headers.Authorization = `Bearer ${token}`
160
+ // A headersHelper generates connect-time headers for non-OAuth auth schemes; its
161
+ // JSON stdout merges over the static headers.
162
+ if (config.headersHelper) Object.assign(headers, await runHeadersHelper(fill(config.headersHelper)))
163
+ warnMissing()
164
+ const sseTransport = (authProvider?: OAuthClientProvider) => new SSEClientTransport(url, { requestInit: { headers }, authProvider }) // NOSONAR: explicitly declared or deliberate legacy transport
165
+ if (config.type === 'sse') {
166
+ return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
167
+ }
168
+ try {
169
+ return await connectHttpFamily(name, config, (authProvider) => new StreamableHTTPClientTransport(url, { requestInit: { headers }, authProvider }), `connect ${name}`, token, authUi)
170
+ } catch (error) {
171
+ // An explicitly declared streamable transport must not silently degrade to SSE.
172
+ if (config.type !== undefined || isUnauthorized(error)) throw error
173
+ return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
174
+ }
175
+ }
176
+
177
+ /** Run a headersHelper command and parse its JSON stdout into headers. A failure or
178
+ * a 10s timeout yields no extra headers rather than blocking the connection. */
179
+ function runHeadersHelper(command: string): Promise<Record<string, string>> {
180
+ return new Promise((resolve) => {
181
+ execFile('/bin/sh', ['-c', command], { timeout: 10_000 }, (error, stdout) => {
182
+ resolve(error ? {} : parseHelperHeaders(stdout))
183
+ })
184
+ })
185
+ }
186
+
187
+ /** UI seams the OAuth flow needs; absent in headless runs, which fail with advice. */
188
+ export interface AuthUi {
189
+ confirm: (title: string, body: string) => Promise<boolean>
190
+ notify: (message: string, level: 'info' | 'warning' | 'error') => void
191
+ }
192
+
193
+ /** A server needs OAuth pi could not complete (headless, declined, or the flow
194
+ * failed). A typed marker so the SSE-fallback caller can tell an auth failure
195
+ * from a transport mismatch without matching on message text. */
196
+ export class OAuthRequiredError extends Error {}
197
+
198
+ /** Whether a connect failure is an authentication problem: the SDK's own
199
+ * UnauthorizedError, a transport error carrying HTTP 401 (which is what a 401
200
+ * throws when no authProvider was attached, so a first-time login is detected),
201
+ * or our own marker. */
202
+ export function isUnauthorized(error: unknown): boolean {
203
+ if (error instanceof UnauthorizedError || error instanceof OAuthRequiredError) return true
204
+ return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 401
205
+ }
206
+
207
+ // SSEClientTransport is deprecated in favour of Streamable HTTP, but both concrete
208
+ // transports expose finishAuth (the base Transport interface does not), so the union
209
+ // stays as the http-family fallback type through the migration period.
210
+ type HttpFamilyTransport = SSEClientTransport | StreamableHTTPClientTransport // NOSONAR typescript:S1874 - SSE fallback still required by the MCP SDK
211
+
212
+ export type MakeTransport = (authProvider?: OAuthClientProvider) => HttpFamilyTransport
213
+
214
+ /**
215
+ * Connect an http-family server, running Claude's OAuth login when the server
216
+ * demands one. Stored tokens ride the first attempt so the SDK refreshes
217
+ * silently; a 401 without tokens asks the user, opens the browser, catches the
218
+ * loopback redirect, and exchanges the code via the SDK's finishAuth.
219
+ * Bearer-token servers never enter the OAuth path: an explicit token is the
220
+ * user saying how auth works.
221
+ */
222
+ async function connectHttpFamily(name: string, config: { url: string }, makeTransport: MakeTransport, label: string, bearerToken: string | undefined, authUi: AuthUi | undefined): Promise<Client> {
223
+ const newClient = () => new Client({ name: 'pi-code-mcp', version: '0.1.0' })
224
+ // Stored tokens ride the first attempt so the SDK refreshes them; with none, no
225
+ // provider is attached, so a 401 surfaces as a transport error carrying code 401
226
+ // (isUnauthorized detects it) and only the interactive provider below ever runs
227
+ // dynamic registration, keeping it bound to the real callback port.
228
+ const silent = bearerToken ? undefined : new FileOAuthProvider(name, () => {})
229
+ try {
230
+ const client = newClient()
231
+ await connectWithTimeout(client, makeTransport(silent?.hasTokens() ? silent : undefined), label)
232
+ return client
233
+ } catch (error) {
234
+ if (bearerToken || !isUnauthorized(error)) throw error
235
+ if (!authUi) throw new OAuthRequiredError(`${name} requires a login; run pi interactively to authenticate`)
236
+ return await serializeInteractiveOAuth(() => runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient))
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Connect with a deadline, closing the client if the deadline (not a connect error) wins.
242
+ * Without this, a slow-but-successful server finishes connecting after the race is lost and
243
+ * lingers unreferenced: process/socket alive, never in `clients`, invisible to shutdown.
244
+ */
245
+ export async function connectWithTimeout(client: Client, transport: Parameters<Client['connect']>[0], label: string): Promise<void> {
246
+ const connecting = client.connect(transport)
247
+ try {
248
+ await withTimeout(connecting, connectTimeoutMs(), label)
249
+ } catch (error) {
250
+ // Only a timeout can orphan a still-opening transport; a connect rejection means the
251
+ // SDK already tore it down, so closing again would be redundant.
252
+ if (String(error).includes('timed out after')) {
253
+ connecting.catch(() => {}) // a late rejection must not surface as unhandled
254
+ void client.close().catch(() => {})
255
+ }
256
+ throw error
257
+ }
258
+ }