pi-code 1.0.3 → 1.0.5
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 +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +185 -27
- package/extensions/context-imports.ts +358 -41
- package/extensions/git-checkpoint.ts +22 -1
- package/extensions/hooks.ts +397 -79
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +377 -53
- package/extensions/internal/html-markdown.ts +61 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +171 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +125 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +77 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +290 -31
- package/extensions/memory.ts +168 -23
- package/extensions/notify.ts +78 -5
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/index.ts +55 -9
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +97 -4
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +114 -25
- package/extensions/subagent/index.ts +227 -44
- package/extensions/web.ts +87 -16
- package/package.json +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block-level HTML comment stripping for context and rule files.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code strips block-level HTML comments (`<!-- maintainer notes -->`)
|
|
5
|
+
* from CLAUDE.md files before injection, while preserving comments inside code
|
|
6
|
+
* blocks. "Block-level" is read as whole-line-anchored: a comment counts only
|
|
7
|
+
* when it starts a line (after optional indentation) and nothing but comments
|
|
8
|
+
* and whitespace occupy the line(s) it spans; those lines are removed entirely,
|
|
9
|
+
* a multi-line comment with every line it covers. A comment sharing a line with
|
|
10
|
+
* real content is inline prose and stays verbatim, as does anything inside a
|
|
11
|
+
* fenced code block (backtick or tilde).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The fence a line opens or closes, if any; mirrors context-imports. */
|
|
15
|
+
export function fenceMarker(lineStart: string): string | null {
|
|
16
|
+
if (lineStart.startsWith('```')) return '`'
|
|
17
|
+
if (lineStart.startsWith('~~~')) return '~'
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Length of the fence run starting `lineStart`, a run of `marker` characters. */
|
|
22
|
+
function fenceLength(lineStart: string, marker: string): number {
|
|
23
|
+
let length = 0
|
|
24
|
+
while (length < lineStart.length && lineStart[length] === marker) length++
|
|
25
|
+
return length
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Remove whole-line HTML comments, keeping fenced code and inline comments. */
|
|
29
|
+
export function stripBlockComments(text: string): string {
|
|
30
|
+
const out: string[] = []
|
|
31
|
+
// CommonMark closes a fenced block only with a fence of the same character
|
|
32
|
+
// that is at least as long as the opener, so both are tracked: a shorter
|
|
33
|
+
// same-char fence line (the classic 3-backtick block quoted inside a
|
|
34
|
+
// 4-backtick one) is content, not a closer.
|
|
35
|
+
let fence: { marker: string; length: number } | null = null
|
|
36
|
+
let inComment = false
|
|
37
|
+
for (const line of text.split('\n')) {
|
|
38
|
+
if (inComment) {
|
|
39
|
+
const close = line.indexOf('-->')
|
|
40
|
+
if (close === -1) continue // still inside the comment: the line goes with it
|
|
41
|
+
inComment = false
|
|
42
|
+
const rest = line.slice(close + 3)
|
|
43
|
+
// Content trailing the closer keeps its line; a bare closer line is dropped.
|
|
44
|
+
if (rest.trim().length > 0) out.push(rest.trimStart())
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
const trimmed = line.trimStart()
|
|
48
|
+
const marker = fenceMarker(trimmed)
|
|
49
|
+
if (marker !== null && fence === null) {
|
|
50
|
+
fence = { marker, length: fenceLength(trimmed, marker) }
|
|
51
|
+
out.push(line)
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
if (fence !== null) {
|
|
55
|
+
if (marker === fence.marker && fenceLength(trimmed, marker) >= fence.length) fence = null
|
|
56
|
+
out.push(line) // fenced code, closer included: comments are content, not maintainer notes
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
// Consume comments anchored at the line start; several may share one line.
|
|
60
|
+
let rest = trimmed
|
|
61
|
+
let sawComment = false
|
|
62
|
+
while (rest.startsWith('<!--')) {
|
|
63
|
+
sawComment = true
|
|
64
|
+
const close = rest.indexOf('-->')
|
|
65
|
+
if (close === -1) {
|
|
66
|
+
inComment = true // opens a multi-line comment
|
|
67
|
+
rest = ''
|
|
68
|
+
break
|
|
69
|
+
}
|
|
70
|
+
rest = rest.slice(close + 3).trimStart()
|
|
71
|
+
}
|
|
72
|
+
if (sawComment && rest.length === 0) continue // the whole line was comment
|
|
73
|
+
// No comment, or a line-starting comment followed by content: inline, verbatim.
|
|
74
|
+
out.push(line)
|
|
75
|
+
}
|
|
76
|
+
return out.join('\n')
|
|
77
|
+
}
|
|
@@ -32,7 +32,9 @@ export function httpFetch(url: URL, opts: TransportOptions): Promise<Response> {
|
|
|
32
32
|
url,
|
|
33
33
|
{
|
|
34
34
|
method: 'GET',
|
|
35
|
-
|
|
35
|
+
// Prefer markdown, as Claude's WebFetch does, so a content-negotiating
|
|
36
|
+
// server can return markdown directly and skip the lossy HTML conversion.
|
|
37
|
+
headers: { 'User-Agent': opts.userAgent, Accept: 'text/markdown, text/html;q=0.9, */*;q=0.8' },
|
|
36
38
|
signal: opts.signal,
|
|
37
39
|
lookup: opts.lookup,
|
|
38
40
|
// servername is left to default to url.hostname, so SNI and certificate
|
package/extensions/mcp.ts
CHANGED
|
@@ -18,22 +18,29 @@
|
|
|
18
18
|
* environment plus its own `env` block, not the whole process environment.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { execFile } from 'node:child_process'
|
|
21
22
|
import * as fs from 'node:fs'
|
|
22
23
|
import * as os from 'node:os'
|
|
23
24
|
import * as path from 'node:path'
|
|
24
25
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
25
26
|
import { DEFAULT_MAX_BYTES } from '@earendil-works/pi-coding-agent'
|
|
26
|
-
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
27
27
|
// SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
|
|
28
28
|
// the old spec exist, so this stays as a fallback for the migration period.
|
|
29
|
+
import { type OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
|
30
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
29
31
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' // NOSONAR
|
|
30
32
|
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
31
33
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
34
|
+
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'
|
|
32
35
|
import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
33
36
|
import { Type } from 'typebox'
|
|
34
37
|
import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
|
|
38
|
+
import { setMcpToolCaller } from './internal/mcp-call.js'
|
|
39
|
+
import { FileOAuthProvider, openBrowser, startCallbackServer, waitForAuthCode } from './internal/mcp-oauth.js'
|
|
35
40
|
import { capForContext } from './internal/output-guard.js'
|
|
41
|
+
import { type InstalledPlugin, installedPlugins, substitutePluginVars } from './internal/plugins.js'
|
|
36
42
|
import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
43
|
+
import { findNearestFile } from './internal/project-root.js'
|
|
37
44
|
|
|
38
45
|
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
|
39
46
|
const DEFAULT_CALL_TIMEOUT_MS = 120_000
|
|
@@ -64,16 +71,23 @@ export interface StdioServerConfig {
|
|
|
64
71
|
cwd?: string
|
|
65
72
|
/** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
|
|
66
73
|
timeout?: number
|
|
74
|
+
/** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
|
|
75
|
+
aliasPrefix?: string
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
export interface HttpServerConfig {
|
|
70
|
-
type?: 'http' | 'streamable-http' | 'sse'
|
|
79
|
+
type?: 'http' | 'streamable-http' | 'sse' | 'ws' | 'websocket'
|
|
71
80
|
url: string
|
|
72
81
|
headers?: Record<string, string>
|
|
73
82
|
bearerToken?: string
|
|
74
83
|
bearerTokenEnv?: string
|
|
84
|
+
/** A command whose JSON stdout is merged into the connect headers, for auth
|
|
85
|
+
* schemes other than OAuth/static tokens (Claude's headersHelper). */
|
|
86
|
+
headersHelper?: string
|
|
75
87
|
/** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
|
|
76
88
|
timeout?: number
|
|
89
|
+
/** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
|
|
90
|
+
aliasPrefix?: string
|
|
77
91
|
}
|
|
78
92
|
|
|
79
93
|
export type ServerConfig = StdioServerConfig | HttpServerConfig
|
|
@@ -93,9 +107,11 @@ export function userConfigPaths(home: string): string[] {
|
|
|
93
107
|
return [path.join(home, '.claude.json'), path.join(home, '.pi', 'agent', 'mcp.json')]
|
|
94
108
|
}
|
|
95
109
|
|
|
96
|
-
/** Project-scoped MCP config
|
|
110
|
+
/** Project-scoped MCP config, each file the nearest of its name at or above cwd
|
|
111
|
+
* (bounded at the repository root, matching the approval walk). Loaded only for
|
|
112
|
+
* trusted projects: a server's `command` runs on connect. */
|
|
97
113
|
export function projectConfigPaths(cwd: string): string[] {
|
|
98
|
-
return [
|
|
114
|
+
return ['.mcp.json', path.join('.pi', 'mcp.json')].map((rel) => findNearestFile(cwd, rel) ?? path.join(cwd, rel))
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
export interface ProjectServerPolicy {
|
|
@@ -125,8 +141,8 @@ export function projectServerPolicy(cwd: string, home: string, projectApproved:
|
|
|
125
141
|
}
|
|
126
142
|
const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
|
|
127
143
|
const userSettings = read(path.join(home, '.claude', 'settings.json'))
|
|
128
|
-
const projectSettings = read(path.join(cwd, '.claude', 'settings.json'))
|
|
129
|
-
const localSettings = read(path.join(cwd, '.claude', 'settings.local.json'))
|
|
144
|
+
const projectSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json'))
|
|
145
|
+
const localSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json'))
|
|
130
146
|
const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
|
|
131
147
|
const consentSources = projectApproved ? [userSettings, localSettings] : [userSettings]
|
|
132
148
|
const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
|
|
@@ -177,6 +193,40 @@ export function loadUserScope(home: string, cwd: string): Record<string, ServerC
|
|
|
177
193
|
return servers
|
|
178
194
|
}
|
|
179
195
|
|
|
196
|
+
/** Servers shipped by enabled plugins (.mcp.json or the manifest's `mcpServers`,
|
|
197
|
+
* inline or by path), with ${CLAUDE_PLUGIN_*} substituted before parsing. Their
|
|
198
|
+
* tools alias as mcp__plugin_<plugin>_<server>__<tool> for hook matchers, as
|
|
199
|
+
* Claude scopes them. */
|
|
200
|
+
export function loadPluginServers(plugins: InstalledPlugin[]): Record<string, ServerConfig> {
|
|
201
|
+
const fold = (name: string): string => name.replaceAll('-', '_')
|
|
202
|
+
const servers: Record<string, ServerConfig> = {}
|
|
203
|
+
for (const plugin of plugins) {
|
|
204
|
+
const declared = plugin.manifest.mcpServers
|
|
205
|
+
let entries: Record<string, ServerConfig> = {}
|
|
206
|
+
// An inline map of name -> config; an array is not a valid mcpServers map (it
|
|
207
|
+
// would register a server named '0'), so it falls through to the path branch.
|
|
208
|
+
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
209
|
+
try {
|
|
210
|
+
entries = JSON.parse(substitutePluginVars(JSON.stringify(declared), plugin))
|
|
211
|
+
} catch {
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : '.mcp.json')
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin))
|
|
218
|
+
entries = parsed.mcpServers ?? {}
|
|
219
|
+
} catch {
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (const [name, config] of Object.entries(entries)) {
|
|
224
|
+
servers[name] = { ...config, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__` }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return servers
|
|
228
|
+
}
|
|
229
|
+
|
|
180
230
|
/** Claude reports a config entry that has a url but no type as an error; pi-code
|
|
181
231
|
* still connects (streamable HTTP with SSE fallback) but says the entry is wrong. */
|
|
182
232
|
/** An inline bearerToken (interpolated) wins over bearerTokenEnv, which names an
|
|
@@ -187,6 +237,72 @@ export function resolveBearerToken(config: { bearerToken?: string; bearerTokenEn
|
|
|
187
237
|
return undefined
|
|
188
238
|
}
|
|
189
239
|
|
|
240
|
+
/** Claude's `headersHelper` output: a flat JSON object of header name -> string,
|
|
241
|
+
* merged into the connect headers. Non-string values and non-object output are
|
|
242
|
+
* ignored so a broken helper cannot poison the request. */
|
|
243
|
+
export function parseHelperHeaders(stdout: string): Record<string, string> {
|
|
244
|
+
let parsed: unknown
|
|
245
|
+
try {
|
|
246
|
+
parsed = JSON.parse(stdout)
|
|
247
|
+
} catch {
|
|
248
|
+
return {}
|
|
249
|
+
}
|
|
250
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
|
251
|
+
const out: Record<string, string> = {}
|
|
252
|
+
for (const [key, value] of Object.entries(parsed)) if (typeof value === 'string') out[key] = value
|
|
253
|
+
return out
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The OS managed-settings.json path, where Claude sources `allowedMcpServers`/
|
|
257
|
+
* `deniedMcpServers` (an enterprise policy file deployed by IT, not user- or
|
|
258
|
+
* repo-writable in the normal flow). */
|
|
259
|
+
export function managedSettingsPath(platform: NodeJS.Platform = process.platform): string {
|
|
260
|
+
if (platform === 'darwin') return '/Library/Application Support/ClaudeCode/managed-settings.json'
|
|
261
|
+
// The legacy C:\ProgramData\ClaudeCode path was dropped in Claude Code v2.1.75.
|
|
262
|
+
if (platform === 'win32') return 'C:\\Program Files\\ClaudeCode\\managed-settings.json'
|
|
263
|
+
return '/etc/claude-code/managed-settings.json'
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Test seam: override the managed-settings.json path the extension reads. */
|
|
267
|
+
let managedSettingsFileOverride: string | undefined
|
|
268
|
+
export function setManagedSettingsPath(file: string | undefined): void {
|
|
269
|
+
managedSettingsFileOverride = file
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Claude's `allowedMcpServers`/`deniedMcpServers`, read from managed settings only
|
|
273
|
+
* (not user or project settings, so a repo can neither widen nor narrow the policy),
|
|
274
|
+
* applied globally to every server across scopes. Entries are `{ serverName }` objects
|
|
275
|
+
* (bare strings tolerated). `allowed` is null when unset (no restriction); an empty
|
|
276
|
+
* set is an explicit lockdown, as Claude documents (empty allow array = deny all). */
|
|
277
|
+
export function mcpAllowDeny(managedFile: string = managedSettingsFileOverride ?? managedSettingsPath()): { allowed: Set<string> | null; denied: Set<string> } {
|
|
278
|
+
let settings: Record<string, unknown> = {}
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(fs.readFileSync(managedFile, 'utf-8'))
|
|
281
|
+
if (parsed && typeof parsed === 'object') settings = parsed
|
|
282
|
+
} catch {
|
|
283
|
+
// No managed policy on this machine: no restriction.
|
|
284
|
+
}
|
|
285
|
+
const names = (value: unknown): string[] =>
|
|
286
|
+
Array.isArray(value) ? value.map((entry) => (typeof entry === 'string' ? entry : typeof (entry as { serverName?: unknown })?.serverName === 'string' ? (entry as { serverName: string }).serverName : undefined)).filter((name): name is string => typeof name === 'string' && name.length > 0) : []
|
|
287
|
+
return {
|
|
288
|
+
allowed: Array.isArray(settings.allowedMcpServers) ? new Set(names(settings.allowedMcpServers)) : null,
|
|
289
|
+
denied: new Set(names(settings.deniedMcpServers)),
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Claude's managed allow/deny lists: `allowed` null means no allow list (keep all);
|
|
294
|
+
* a set (even empty) is exclusive, so only its members survive; a deny list removes
|
|
295
|
+
* servers on top, deny winning over allow. */
|
|
296
|
+
export function applyServerPolicy(servers: Record<string, ServerConfig>, allowed: ReadonlySet<string> | null, denied: ReadonlySet<string>): Record<string, ServerConfig> {
|
|
297
|
+
const out: Record<string, ServerConfig> = {}
|
|
298
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
299
|
+
if (denied.has(name)) continue
|
|
300
|
+
if (allowed !== null && !allowed.has(name)) continue
|
|
301
|
+
out[name] = config
|
|
302
|
+
}
|
|
303
|
+
return out
|
|
304
|
+
}
|
|
305
|
+
|
|
190
306
|
/** A server cwd expands ${VAR} then a leading ~, or stays unset. */
|
|
191
307
|
export function expandCwd(cwd: string | undefined): string | undefined {
|
|
192
308
|
if (!cwd) return undefined
|
|
@@ -308,7 +424,7 @@ async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): P
|
|
|
308
424
|
}
|
|
309
425
|
}
|
|
310
426
|
|
|
311
|
-
async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
427
|
+
async function connect(name: string, config: ServerConfig, authUi?: AuthUi): Promise<Client> {
|
|
312
428
|
const client = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
313
429
|
if (isStdio(config)) {
|
|
314
430
|
// Start from the SDK's allowlist (PATH, HOME, SHELL, ...) rather than the whole
|
|
@@ -326,27 +442,127 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
|
326
442
|
await connectWithTimeout(client, transport, `connect ${name}`)
|
|
327
443
|
return client
|
|
328
444
|
}
|
|
445
|
+
const url = new URL(interpolateEnv(config.url))
|
|
446
|
+
if (config.type === 'ws' || config.type === 'websocket') {
|
|
447
|
+
// The SDK's WebSocket transport takes only a url: it carries no headers, bearer
|
|
448
|
+
// token, or headersHelper output. Warn rather than silently dropping configured
|
|
449
|
+
// auth, and skip the helper entirely (running it would block the connect for up to
|
|
450
|
+
// 10s while contributing nothing). A ws server must be reachable without auth.
|
|
451
|
+
if (config.headers || config.bearerToken || config.bearerTokenEnv || config.headersHelper) {
|
|
452
|
+
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`)
|
|
453
|
+
}
|
|
454
|
+
const transport = new WebSocketClientTransport(url)
|
|
455
|
+
await connectWithTimeout(client, transport, `connect ${name} (ws)`)
|
|
456
|
+
return client
|
|
457
|
+
}
|
|
329
458
|
const headers: Record<string, string> = {}
|
|
330
459
|
for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = interpolateEnv(value)
|
|
331
460
|
const token = resolveBearerToken(config)
|
|
332
461
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
333
|
-
|
|
462
|
+
// A headersHelper generates connect-time headers for non-OAuth auth schemes; its
|
|
463
|
+
// JSON stdout merges over the static headers.
|
|
464
|
+
if (config.headersHelper) Object.assign(headers, await runHeadersHelper(interpolateEnv(config.headersHelper)))
|
|
465
|
+
const sseTransport = (authProvider?: OAuthClientProvider) => new SSEClientTransport(url, { requestInit: { headers }, authProvider }) // NOSONAR: explicitly declared or deliberate legacy transport
|
|
334
466
|
if (config.type === 'sse') {
|
|
335
|
-
|
|
336
|
-
await connectWithTimeout(client, transport, `connect ${name} (sse)`)
|
|
337
|
-
return client
|
|
467
|
+
return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
|
|
338
468
|
}
|
|
339
469
|
try {
|
|
340
|
-
|
|
341
|
-
await connectWithTimeout(client, transport, `connect ${name}`)
|
|
342
|
-
return client
|
|
470
|
+
return await connectHttpFamily(name, config, (authProvider) => new StreamableHTTPClientTransport(url, { requestInit: { headers }, authProvider }), `connect ${name}`, token, authUi)
|
|
343
471
|
} catch (error) {
|
|
344
472
|
// An explicitly declared streamable transport must not silently degrade to SSE.
|
|
345
|
-
if (config.type !== undefined ||
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
473
|
+
if (config.type !== undefined || isUnauthorized(error)) throw error
|
|
474
|
+
return await connectHttpFamily(name, config, sseTransport, `connect ${name} (sse)`, token, authUi)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Run a headersHelper command and parse its JSON stdout into headers. A failure or
|
|
479
|
+
* a 10s timeout yields no extra headers rather than blocking the connection. */
|
|
480
|
+
function runHeadersHelper(command: string): Promise<Record<string, string>> {
|
|
481
|
+
return new Promise((resolve) => {
|
|
482
|
+
execFile('/bin/sh', ['-c', command], { timeout: 10_000 }, (error, stdout) => {
|
|
483
|
+
resolve(error ? {} : parseHelperHeaders(stdout))
|
|
484
|
+
})
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** UI seams the OAuth flow needs; absent in headless runs, which fail with advice. */
|
|
489
|
+
export interface AuthUi {
|
|
490
|
+
confirm: (title: string, body: string) => Promise<boolean>
|
|
491
|
+
notify: (message: string, level: 'info' | 'warning' | 'error') => void
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Browser logins are human-paced; a connect-sized timeout would cut them off. */
|
|
495
|
+
const OAUTH_FLOW_TIMEOUT_MS = 180_000
|
|
496
|
+
|
|
497
|
+
/** A server needs OAuth pi could not complete (headless, declined, or the flow
|
|
498
|
+
* failed). A typed marker so the SSE-fallback caller can tell an auth failure
|
|
499
|
+
* from a transport mismatch without matching on message text. */
|
|
500
|
+
class OAuthRequiredError extends Error {}
|
|
501
|
+
|
|
502
|
+
/** Whether a connect failure is an authentication problem: the SDK's own
|
|
503
|
+
* UnauthorizedError, a transport error carrying HTTP 401 (which is what a 401
|
|
504
|
+
* throws when no authProvider was attached, so a first-time login is detected),
|
|
505
|
+
* or our own marker. */
|
|
506
|
+
function isUnauthorized(error: unknown): boolean {
|
|
507
|
+
if (error instanceof UnauthorizedError || error instanceof OAuthRequiredError) return true
|
|
508
|
+
return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 401
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Connect an http-family server, running Claude's OAuth login when the server
|
|
513
|
+
* demands one. Stored tokens ride the first attempt so the SDK refreshes
|
|
514
|
+
* silently; a 401 without tokens asks the user, opens the browser, catches the
|
|
515
|
+
* loopback redirect, and exchanges the code via the SDK's finishAuth.
|
|
516
|
+
* Bearer-token servers never enter the OAuth path: an explicit token is the
|
|
517
|
+
* user saying how auth works.
|
|
518
|
+
*/
|
|
519
|
+
async function connectHttpFamily(name: string, config: { url: string }, makeTransport: (authProvider?: OAuthClientProvider) => SSEClientTransport | StreamableHTTPClientTransport, label: string, bearerToken: string | undefined, authUi: AuthUi | undefined): Promise<Client> {
|
|
520
|
+
const newClient = () => new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
521
|
+
// Stored tokens ride the first attempt so the SDK refreshes them; with none, no
|
|
522
|
+
// provider is attached, so a 401 surfaces as a transport error carrying code 401
|
|
523
|
+
// (isUnauthorized detects it) and only the interactive provider below ever runs
|
|
524
|
+
// dynamic registration, keeping it bound to the real callback port.
|
|
525
|
+
const silent = bearerToken ? undefined : new FileOAuthProvider(name, () => {})
|
|
526
|
+
try {
|
|
527
|
+
const client = newClient()
|
|
528
|
+
await connectWithTimeout(client, makeTransport(silent?.hasTokens() ? silent : undefined), label)
|
|
529
|
+
return client
|
|
530
|
+
} catch (error) {
|
|
531
|
+
if (bearerToken || !isUnauthorized(error)) throw error
|
|
532
|
+
if (!authUi) throw new OAuthRequiredError(`${name} requires a login; run pi interactively to authenticate`)
|
|
533
|
+
const approved = await authUi.confirm(`MCP server "${name}" requires login`, `Open your browser to authorize ${config.url}?`)
|
|
534
|
+
if (!approved) throw new OAuthRequiredError(`login declined for ${name}`)
|
|
535
|
+
const provider = new FileOAuthProvider(name, (authorizationUrl) => {
|
|
536
|
+
openBrowser(String(authorizationUrl))
|
|
537
|
+
authUi.notify(`Authorize "${name}" in the browser. If it did not open: ${authorizationUrl}`, 'info')
|
|
538
|
+
})
|
|
539
|
+
const { server, port } = await startCallbackServer(provider.savedRedirectPort())
|
|
540
|
+
provider.bindRedirectPort(port)
|
|
541
|
+
try {
|
|
542
|
+
const transport = makeTransport(provider)
|
|
543
|
+
const pendingCode = waitForAuthCode(server, OAUTH_FLOW_TIMEOUT_MS)
|
|
544
|
+
pendingCode.catch(() => {}) // consumed below; an abandoned login must not surface as unhandled
|
|
545
|
+
const client = newClient()
|
|
546
|
+
try {
|
|
547
|
+
await connectWithTimeout(client, transport, label)
|
|
548
|
+
return client // authorized between attempts; nothing left to exchange
|
|
549
|
+
} catch (retryError) {
|
|
550
|
+
if (!isUnauthorized(retryError)) throw retryError
|
|
551
|
+
const code = await pendingCode
|
|
552
|
+
await transport.finishAuth(code)
|
|
553
|
+
const authed = newClient()
|
|
554
|
+
await connectWithTimeout(authed, makeTransport(provider), label)
|
|
555
|
+
return authed
|
|
556
|
+
}
|
|
557
|
+
} catch (flowError) {
|
|
558
|
+
// Past the confirm the server is known to need OAuth, so any failure here (a
|
|
559
|
+
// denied consent page, the 180s wait, a token exchange error) is an auth
|
|
560
|
+
// failure, not a transport mismatch. Marking it keeps the typeless-url caller
|
|
561
|
+
// from retrying over SSE and prompting the user to log in a second time.
|
|
562
|
+
throw flowError instanceof OAuthRequiredError ? flowError : new OAuthRequiredError(`login for ${name} failed: ${flowError instanceof Error ? flowError.message : String(flowError)}`)
|
|
563
|
+
} finally {
|
|
564
|
+
server.close()
|
|
565
|
+
}
|
|
350
566
|
}
|
|
351
567
|
}
|
|
352
568
|
|
|
@@ -390,6 +606,17 @@ async function listAllTools(client: Client): Promise<McpToolInfo[]> {
|
|
|
390
606
|
export default async function mcpExtension(pi: ExtensionAPI) {
|
|
391
607
|
const clients = new Map<string, Client>()
|
|
392
608
|
const status = new Map<string, { state: string; tools: number }>()
|
|
609
|
+
// Let other extensions (hooks' mcp_tool type) call a connected server's tool.
|
|
610
|
+
setMcpToolCaller(async (server, tool, input) => {
|
|
611
|
+
const client = clients.get(server)
|
|
612
|
+
if (!client) throw new Error(`MCP server "${server}" is not connected`)
|
|
613
|
+
const result = await client.callTool({ name: tool, arguments: input }, undefined, { timeout: callTimeoutMs() })
|
|
614
|
+
const text = mapContent(result.content as McpContentBlock[], result.structuredContent)
|
|
615
|
+
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
616
|
+
.map((part) => part.text)
|
|
617
|
+
.join('\n')
|
|
618
|
+
return { text, isError: result.isError === true }
|
|
619
|
+
})
|
|
393
620
|
// pi tool name -> owning server, so a refresh can tell its own tools from a conflict.
|
|
394
621
|
const registered = new Map<string, string>()
|
|
395
622
|
// Original server/tool names per registered pi name, for Claude-style hook matchers.
|
|
@@ -407,7 +634,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
407
634
|
continue
|
|
408
635
|
}
|
|
409
636
|
registered.set(toolName, name)
|
|
410
|
-
aliases.push({ pi: toolName, claude: `mcp__${name}__${tool.name}` })
|
|
637
|
+
aliases.push({ pi: toolName, claude: config.aliasPrefix ? `${config.aliasPrefix}${tool.name}` : `mcp__${name}__${tool.name}` })
|
|
411
638
|
count++
|
|
412
639
|
pi.registerTool({
|
|
413
640
|
name: toolName,
|
|
@@ -457,7 +684,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
457
684
|
}
|
|
458
685
|
}
|
|
459
686
|
|
|
460
|
-
async function connectServers(servers: Record<string, ServerConfig
|
|
687
|
+
async function connectServers(servers: Record<string, ServerConfig>, authUi?: AuthUi): Promise<void> {
|
|
461
688
|
const pending: [string, ServerConfig][] = []
|
|
462
689
|
for (const [name, config] of Object.entries(servers)) {
|
|
463
690
|
// A later scope must not take the name of a server that already connected: it
|
|
@@ -476,14 +703,30 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
476
703
|
pending.map(async ([name, config]) => {
|
|
477
704
|
warnOnTypelessUrl(name, config)
|
|
478
705
|
try {
|
|
479
|
-
const client = await connect(name, config)
|
|
706
|
+
const client = await connect(name, config, authUi)
|
|
480
707
|
clients.set(name, client)
|
|
481
708
|
const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
|
|
482
709
|
const count = registerTools(name, config, client, tools)
|
|
483
710
|
subscribeToToolChanges(name, config, client)
|
|
484
711
|
status.set(name, { state: 'connected', tools: count })
|
|
712
|
+
// A server that dies mid-session would otherwise stay "connected" in /mcp
|
|
713
|
+
// while every call fails with the SDK's bare "Not connected"; flip the
|
|
714
|
+
// status and free the name so a later session start can reconnect it.
|
|
715
|
+
client.onclose = () => {
|
|
716
|
+
if (clients.get(name) !== client) return
|
|
717
|
+
clients.delete(name)
|
|
718
|
+
status.set(name, { state: 'disconnected', tools: 0 })
|
|
719
|
+
}
|
|
485
720
|
} catch (error) {
|
|
486
721
|
status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
|
|
722
|
+
// Connected but failed after (tool listing hung or errored): left in the
|
|
723
|
+
// map, the client idles its process for the whole session and the
|
|
724
|
+
// duplicate-name guard blocks the name for every later attempt.
|
|
725
|
+
const leaked = clients.get(name)
|
|
726
|
+
if (leaked) {
|
|
727
|
+
clients.delete(name)
|
|
728
|
+
void leaked.close().catch(() => {})
|
|
729
|
+
}
|
|
487
730
|
}
|
|
488
731
|
}),
|
|
489
732
|
)
|
|
@@ -494,25 +737,41 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
494
737
|
async function connectProjectScope(ctx: ExtensionContext): Promise<boolean> {
|
|
495
738
|
// The stored decision, read without prompting: consent recorded inside the
|
|
496
739
|
// project only counts once the project itself has been approved.
|
|
497
|
-
const
|
|
498
|
-
const
|
|
499
|
-
|
|
740
|
+
const approved = isProjectApprovedSilently(ctx)
|
|
741
|
+
const policy = projectServerPolicy(ctx.cwd, os.homedir(), approved)
|
|
742
|
+
const { allowed, denied } = mcpAllowDeny()
|
|
743
|
+
const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), policy)
|
|
744
|
+
const authUi = authUiFor(ctx)
|
|
745
|
+
if (Object.keys(consented).length > 0) await connectServers(consented, authUi)
|
|
500
746
|
if (Object.keys(gated).length === 0) return true
|
|
501
747
|
if (!(await isProjectApproved(ctx))) return false
|
|
502
|
-
await connectServers(gated)
|
|
748
|
+
await connectServers(gated, authUi)
|
|
503
749
|
return true
|
|
504
750
|
}
|
|
505
751
|
|
|
506
|
-
|
|
752
|
+
/** The OAuth flow's UI seams, absent in headless runs. */
|
|
753
|
+
function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
|
|
754
|
+
if (!ctx.hasUI) return undefined
|
|
755
|
+
return {
|
|
756
|
+
confirm: (title, body) => ctx.ui.confirm(title, body),
|
|
757
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
507
761
|
let projectConnected = false
|
|
508
762
|
|
|
509
763
|
pi.on('session_start', async (_event, ctx) => {
|
|
510
764
|
// Connecting spawns processes and opens sockets, so it belongs here rather than in
|
|
511
765
|
// the factory: pi runs the factory for invocations that never start a session.
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
766
|
+
// Names still connected are filtered out, so a later session start only retries
|
|
767
|
+
// servers that failed or whose transport dropped, without duplicate-name warnings.
|
|
768
|
+
// Plugin servers merge under the user scope (plugins are user-installed);
|
|
769
|
+
// the user's own entry wins a name clash with a plugin's.
|
|
770
|
+
const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
|
|
771
|
+
const { allowed, denied } = mcpAllowDeny()
|
|
772
|
+
const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, allowed, denied)
|
|
773
|
+
const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name)))
|
|
774
|
+
if (Object.keys(userServers).length > 0) await connectServers(userServers, authUiFor(ctx))
|
|
516
775
|
// A project .mcp.json can run arbitrary commands on connect, so only honor it once
|
|
517
776
|
// the project is trusted. Per-server settings refine that: disabled servers never
|
|
518
777
|
// connect, servers the user consented to individually connect without the
|