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.
- package/README.md +1 -1
- package/extensions/commands.ts +27 -30
- package/extensions/context-imports.ts +6 -14
- package/extensions/hooks/config.ts +234 -0
- package/extensions/hooks/decisions.ts +169 -0
- package/extensions/hooks/index.ts +497 -0
- package/extensions/hooks/matcher.ts +126 -0
- package/extensions/hooks/runners.ts +269 -0
- package/extensions/internal/command-file.ts +1 -1
- package/extensions/internal/managed-settings.ts +5 -0
- package/extensions/internal/output-guard.ts +1 -1
- package/extensions/internal/settings-chain.ts +24 -0
- package/extensions/internal/stat-token.ts +14 -0
- package/extensions/internal/turn-override.ts +73 -0
- package/extensions/mcp/config.ts +159 -0
- package/extensions/mcp/index.ts +513 -0
- package/extensions/mcp/listing.ts +92 -0
- package/extensions/mcp/mapping.ts +173 -0
- package/extensions/mcp/oauth-flow.ts +80 -0
- package/extensions/mcp/policy.ts +140 -0
- package/extensions/mcp/transport.ts +258 -0
- package/extensions/memory.ts +6 -10
- package/extensions/output-styles.ts +2 -6
- package/extensions/plan-mode/utils.ts +1 -1
- package/extensions/question.ts +2 -2
- package/extensions/status-line.ts +1 -1
- package/extensions/subagent/agents.ts +1 -1
- package/extensions/subagent/index.ts +6 -123
- package/extensions/subagent/render.ts +131 -0
- package/extensions/thinking.ts +25 -31
- package/package.json +1 -1
- package/extensions/hooks.ts +0 -1179
- package/extensions/mcp.ts +0 -1358
|
@@ -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
|
+
}
|