@geohar/pi-mcp-combiner 0.14.2 → 0.16.0-dev.1
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 +71 -48
- package/dist/client/config-ladder.d.ts +1 -1
- package/dist/client/config-ladder.d.ts.map +1 -1
- package/dist/client/control.d.ts +1 -1
- package/dist/client/control.d.ts.map +1 -1
- package/dist/client/elicitation.d.ts +4 -2
- package/dist/client/elicitation.d.ts.map +1 -1
- package/dist/client/elicitation.js +8 -2
- package/dist/client/elicitation.js.map +1 -1
- package/dist/client/footer.d.ts +1 -1
- package/dist/client/footer.d.ts.map +1 -1
- package/dist/client/panel.d.ts +1 -1
- package/dist/client/panel.d.ts.map +1 -1
- package/dist/client/panel.js +1 -1
- package/dist/client/panel.js.map +1 -1
- package/dist/client/prompts.d.ts +1 -1
- package/dist/client/prompts.d.ts.map +1 -1
- package/dist/client/proxy-tool.d.ts +1 -1
- package/dist/client/proxy-tool.d.ts.map +1 -1
- package/dist/client/resource-naming.d.ts +8 -0
- package/dist/client/resource-naming.d.ts.map +1 -0
- package/dist/client/resource-naming.js +33 -0
- package/dist/client/resource-naming.js.map +1 -0
- package/dist/client/settings.d.ts +5 -4
- package/dist/client/settings.d.ts.map +1 -1
- package/dist/client/settings.js +3 -3
- package/dist/client/settings.js.map +1 -1
- package/dist/client/tool-matching.d.ts +32 -0
- package/dist/client/tool-matching.d.ts.map +1 -0
- package/dist/client/tool-matching.js +43 -0
- package/dist/client/tool-matching.js.map +1 -0
- package/dist/client/types.d.ts +71 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +16 -0
- package/dist/client/types.js.map +1 -0
- package/dist/client/widget-support.d.ts +17 -0
- package/dist/client/widget-support.d.ts.map +1 -0
- package/dist/client/widget-support.js +89 -0
- package/dist/client/widget-support.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +109 -53
- package/dist/index.js.map +1 -1
- package/dist/{client/connection.d.ts → native/combiner-connection.d.ts} +46 -38
- package/dist/native/combiner-connection.d.ts.map +1 -0
- package/dist/native/combiner-connection.js +412 -0
- package/dist/native/combiner-connection.js.map +1 -0
- package/dist/native/index.d.ts +30 -0
- package/dist/native/index.d.ts.map +1 -0
- package/dist/native/index.js +61 -0
- package/dist/native/index.js.map +1 -0
- package/dist/native/resources.d.ts +18 -0
- package/dist/native/resources.d.ts.map +1 -0
- package/dist/native/resources.js +97 -0
- package/dist/native/resources.js.map +1 -0
- package/dist/native/tool-surface.d.ts +26 -0
- package/dist/native/tool-surface.d.ts.map +1 -0
- package/dist/native/tool-surface.js +113 -0
- package/dist/native/tool-surface.js.map +1 -0
- package/dist/pi.d.ts +19 -0
- package/dist/pi.d.ts.map +1 -1
- package/package.json +4 -6
- package/src/client/config-ladder.ts +1 -1
- package/src/client/control.ts +1 -1
- package/src/client/elicitation.ts +10 -2
- package/src/client/footer.ts +1 -1
- package/src/client/panel.ts +2 -2
- package/src/client/prompts.ts +1 -1
- package/src/client/proxy-tool.ts +1 -1
- package/src/client/resource-naming.ts +36 -0
- package/src/client/settings.ts +7 -6
- package/src/client/tool-matching.ts +69 -0
- package/src/client/types.ts +80 -0
- package/src/client/widget-support.ts +86 -0
- package/src/index.ts +109 -65
- package/src/native/combiner-connection.ts +483 -0
- package/src/native/index.ts +100 -0
- package/src/native/resources.ts +112 -0
- package/src/native/tool-surface.ts +147 -0
- package/src/pi.ts +16 -0
- package/dist/client/connection.d.ts.map +0 -1
- package/dist/client/connection.js +0 -332
- package/dist/client/connection.js.map +0 -1
- package/dist/client/direct-tools.d.ts +0 -41
- package/dist/client/direct-tools.d.ts.map +0 -1
- package/dist/client/direct-tools.js +0 -122
- package/dist/client/direct-tools.js.map +0 -1
- package/dist/client/resources.d.ts +0 -20
- package/dist/client/resources.d.ts.map +0 -1
- package/dist/client/resources.js +0 -119
- package/dist/client/resources.js.map +0 -1
- package/dist/client/script.d.ts +0 -10
- package/dist/client/script.d.ts.map +0 -1
- package/dist/client/script.js +0 -130
- package/dist/client/script.js.map +0 -1
- package/src/client/connection.ts +0 -373
- package/src/client/direct-tools.ts +0 -146
- package/src/client/resources.ts +0 -137
- package/src/client/script.ts +0 -150
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Shared connection contract: the types every surface (router, panel, footer,
|
|
2
|
+
// prompts, resources, the extension wiring) types against. The concrete class
|
|
3
|
+
// lives in native/combiner-connection.ts (the pi-mcp transport); this module
|
|
4
|
+
// owns the INTERFACE so no surface depends on the implementation.
|
|
5
|
+
|
|
6
|
+
import type { ServerFilter } from "./config-ladder.js"
|
|
7
|
+
import type { ElicitUi } from "./elicitation.js"
|
|
8
|
+
|
|
9
|
+
export type LogFn = (level: "info" | "warn" | "error", message: string) => void
|
|
10
|
+
|
|
11
|
+
export type ResolvedConnection = {
|
|
12
|
+
/** Base URL including /mcp, WITHOUT any token path (e.g. http://127.0.0.1:9741/mcp). */
|
|
13
|
+
baseUrl: string
|
|
14
|
+
/** Env var holding the inbound bearer token (read at connect time), if any. */
|
|
15
|
+
bearerTokenEnv?: string
|
|
16
|
+
/** Explicit token already present in the configured URL path (user override). */
|
|
17
|
+
urlToken?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ConnectionState = "disconnected" | "connecting" | "connected" | "failed"
|
|
21
|
+
|
|
22
|
+
export type ToolSummary = { name: string; description?: string; inputSchema?: unknown }
|
|
23
|
+
|
|
24
|
+
export type PromptArgument = { name: string; description?: string; required?: boolean }
|
|
25
|
+
|
|
26
|
+
export type PromptSummary = { name: string; title?: string; description?: string; arguments?: PromptArgument[] }
|
|
27
|
+
|
|
28
|
+
export type ResourceSummary = {
|
|
29
|
+
uri: string
|
|
30
|
+
name?: string
|
|
31
|
+
description?: string
|
|
32
|
+
mimeType?: string
|
|
33
|
+
_meta?: Record<string, unknown>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Optional callbacks fired by the connection (set from index.ts wiring). */
|
|
37
|
+
export type ConnectionHooks = {
|
|
38
|
+
/** tools/list_changed arrived — caches invalidated; refresh derived surfaces. */
|
|
39
|
+
onToolsChanged?: () => void
|
|
40
|
+
/** connection state transitioned — refresh footer/status surfaces. */
|
|
41
|
+
onStateChange?: (state: ConnectionState) => void
|
|
42
|
+
/** Stage 2: the combiner announced a widget UI URL while an agent tool call
|
|
43
|
+
* is held in flight — the client should open it (gated by uiAutoOpen). */
|
|
44
|
+
onWidgetUrl?: (url: string) => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The connection surface every module types against — the duck-parity contract
|
|
48
|
+
* both the retired SDK connection and the native pi-mcp connection implemented. */
|
|
49
|
+
export interface CombinerConnection {
|
|
50
|
+
state: ConnectionState
|
|
51
|
+
lastError: string | undefined
|
|
52
|
+
sessionToken: string | undefined
|
|
53
|
+
setToken(token: string): void
|
|
54
|
+
rebind(conn: ResolvedConnection): boolean
|
|
55
|
+
reset(reason: string): Promise<void>
|
|
56
|
+
ensureConnected(): Promise<unknown>
|
|
57
|
+
listTools(force?: boolean): Promise<ToolSummary[]>
|
|
58
|
+
listPrompts(force?: boolean): Promise<PromptSummary[]>
|
|
59
|
+
getPrompt(name: string, args: Record<string, string>): Promise<unknown>
|
|
60
|
+
listResources(force?: boolean): Promise<ResourceSummary[]>
|
|
61
|
+
readResource(uri: string): Promise<unknown>
|
|
62
|
+
callTool(name: string, args: Record<string, unknown> | undefined): Promise<unknown>
|
|
63
|
+
controlOrigin(): string
|
|
64
|
+
uiUrlFor(resourceUri: string): string
|
|
65
|
+
applyFilter(filter: ServerFilter): Promise<void>
|
|
66
|
+
health(): Promise<unknown>
|
|
67
|
+
setHooks(hooks: ConnectionHooks): void
|
|
68
|
+
bindUi(ui: ElicitUi): void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build the tokened endpoint URL: <origin>/mcp/<token> (combiner's URL form). */
|
|
72
|
+
export function tokenedUrl(baseUrl: string, token: string): string {
|
|
73
|
+
try {
|
|
74
|
+
const u = new URL(baseUrl)
|
|
75
|
+
u.pathname = `${u.pathname.replace(/\/+$/, "")}/${token}`
|
|
76
|
+
return u.toString()
|
|
77
|
+
} catch {
|
|
78
|
+
return `${baseUrl.replace(/\/+$/, "")}/${token}`
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Widget/interactive-resource support — extracted from legacy-client/render.ts,
|
|
2
|
+
// proxy-tool.ts, and resources.ts during the legacy-client factoring.
|
|
3
|
+
//
|
|
4
|
+
// These pieces are NOT proxy-surface specific: they are the combiner UI-host,
|
|
5
|
+
// mcp-app-resource, and browser-open mechanics that SURVIVE into the native mode
|
|
6
|
+
// (widget holds via the connection's onWidgetUrl log-tap; read_* tools for ui://
|
|
7
|
+
// resources, which pi's built-in read_mcp_resource deliberately does NOT cover).
|
|
8
|
+
// When legacy-client/ is deleted, this file keeps them alive; legacy/resources.ts
|
|
9
|
+
// imports them from here in the meantime.
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process"
|
|
12
|
+
import type { ResourceSummary } from "./types.js"
|
|
13
|
+
|
|
14
|
+
export const MAX_RESULT_CHARS = 16 * 1024
|
|
15
|
+
|
|
16
|
+
export const MCP_APP_MIME = "text/html;profile=mcp-app"
|
|
17
|
+
|
|
18
|
+
/** True for interactive ext-apps resources: read_* returns their markup AND
|
|
19
|
+
* surfaces the combiner UI-host URL (which serves the live widget). */
|
|
20
|
+
export function isInteractiveResource(r: ResourceSummary): boolean {
|
|
21
|
+
return r.mimeType === MCP_APP_MIME || r.uri.startsWith("ui://")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Platform browser open — best-effort; failures are fine (the URL is in the
|
|
25
|
+
* result text anyway). Spawned detached so pi退出 doesn't kill the browser. */
|
|
26
|
+
export function openInBrowser(url: string): void {
|
|
27
|
+
const cmd = process.platform === "darwin" ? ["open"] : ["xdg-open"]
|
|
28
|
+
try {
|
|
29
|
+
spawn(cmd[0], [...cmd.slice(1), url], { stdio: "ignore", detached: true }).unref?.()
|
|
30
|
+
} catch {
|
|
31
|
+
// no opener available — the URL is in the result text anyway
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The ui:// resource a tool result carries (port of the adapter's
|
|
36
|
+
* getToolUiResourceUri): `_meta["ui/resourceUri"]` or `_meta.ui.resourceUri`. */
|
|
37
|
+
export function toolUiResourceUri(result: unknown): string | undefined {
|
|
38
|
+
const meta = (result as { _meta?: unknown })?._meta
|
|
39
|
+
if (typeof meta !== "object" || meta === null) return undefined
|
|
40
|
+
const m = meta as Record<string, unknown>
|
|
41
|
+
const nested = (m.ui as Record<string, unknown> | undefined)?.resourceUri
|
|
42
|
+
const uri = typeof nested === "string" ? nested : m["ui/resourceUri"]
|
|
43
|
+
return typeof uri === "string" && uri.startsWith("ui://") ? uri : undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const maxPrettyCharsDefault = 2048
|
|
47
|
+
|
|
48
|
+
function prettifyIfSmallJson(text: string, maxPrettyChars = maxPrettyCharsDefault): string {
|
|
49
|
+
const trimmed = text.trim()
|
|
50
|
+
if (trimmed.length === 0 || trimmed.length > maxPrettyChars) return text
|
|
51
|
+
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return text
|
|
52
|
+
try {
|
|
53
|
+
const parsed: unknown = JSON.parse(trimmed)
|
|
54
|
+
if (typeof parsed !== "object" || parsed === null) return text
|
|
55
|
+
return JSON.stringify(parsed, null, 2)
|
|
56
|
+
} catch {
|
|
57
|
+
return text
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** MCP readResource result → guarded plain text: text contents pass through
|
|
62
|
+
* (pretty-printed when small JSON; with the shared size guard), images become
|
|
63
|
+
* markers, base64 blobs become byte notes. */
|
|
64
|
+
export function renderResourceResult(result: unknown): string {
|
|
65
|
+
const contents = (result as { contents?: unknown[] })?.contents
|
|
66
|
+
if (!Array.isArray(contents) || contents.length === 0) return "(empty resource)"
|
|
67
|
+
const parts: string[] = []
|
|
68
|
+
for (const c of contents) {
|
|
69
|
+
if (typeof c !== "object" || c === null) continue
|
|
70
|
+
const item = c as Record<string, unknown>
|
|
71
|
+
const mime = typeof item.mimeType === "string" ? item.mimeType : "unknown"
|
|
72
|
+
if (typeof item.text === "string") {
|
|
73
|
+
parts.push(prettifyIfSmallJson(item.text))
|
|
74
|
+
} else if (typeof item.blob === "string") {
|
|
75
|
+
const bytes = Math.floor((item.blob.length * 3) / 4) // base64 → approx decoded size
|
|
76
|
+
parts.push(`[binary ${mime}, ~${bytes} bytes — uri ${item.uri ?? "?"}]`)
|
|
77
|
+
} else if (mime.startsWith("image/")) {
|
|
78
|
+
parts.push(`[image: ${mime} — uri ${item.uri ?? "?"}]`)
|
|
79
|
+
} else {
|
|
80
|
+
parts.push(`[unreadable content: ${mime} — uri ${item.uri ?? "?"}]`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const text = parts.join("\n").trim() || "(empty resource)"
|
|
84
|
+
if (text.length <= MAX_RESULT_CHARS) return text
|
|
85
|
+
return `${text.slice(0, MAX_RESULT_CHARS)}\n\n… (resource truncated at ${MAX_RESULT_CHARS} chars)`
|
|
86
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -38,25 +38,23 @@ import type {
|
|
|
38
38
|
ExtensionContext,
|
|
39
39
|
SessionShutdownEvent,
|
|
40
40
|
SessionStartEvent,
|
|
41
|
+
CommandSpec,
|
|
41
42
|
} from "./pi.js"
|
|
42
43
|
import { resolveSharedserver } from "./sharedserver-resolve.js"
|
|
43
|
-
import {
|
|
44
|
-
|
|
44
|
+
import { NativeCombinerConnection, activateNativeMode } from "./native/index.js"
|
|
45
|
+
// ── TOOL SURFACE ───────────────────────────────────────────────────────────────
|
|
46
|
+
// The mcp router (search/describe/call on the owned connection) + the native
|
|
47
|
+
// per-tool registration (pi-1.0 exposure/namespace/annotations) + interactive
|
|
48
|
+
// read_<resource> tools. Shared surface modules live in src/client/; the transport
|
|
49
|
+
// is src/native/combiner-connection.ts (pi-mcp, peer dep).
|
|
50
|
+
import { createMcpTool } from "./client/proxy-tool.js"
|
|
51
|
+
import { openInBrowser } from "./client/widget-support.js"
|
|
45
52
|
import { agentDir, loadSettings, settingsPath } from "./client/settings.js"
|
|
46
53
|
import { resolveSessionConfig, type SessionConfig } from "./client/config-ladder.js"
|
|
47
54
|
import { metaToolCall, statusText as controlStatusText } from "./client/control.js"
|
|
48
55
|
import { openCombinerPanel } from "./client/panel.js"
|
|
49
56
|
import { syncPromptCommands } from "./client/prompts.js"
|
|
50
|
-
import { syncResourceTools } from "./client/resources.js"
|
|
51
|
-
import {
|
|
52
|
-
activateFromSearch,
|
|
53
|
-
applyServerFilter,
|
|
54
|
-
RESERVED_TOOL_NAMES,
|
|
55
|
-
syncAllowlistTools,
|
|
56
|
-
type DirectToolDeps,
|
|
57
|
-
} from "./client/direct-tools.js"
|
|
58
57
|
import { updateFooter } from "./client/footer.js"
|
|
59
|
-
import { createScriptTool } from "./client/script.js"
|
|
60
58
|
|
|
61
59
|
const DEFAULT_PORT = 9741
|
|
62
60
|
const DEFAULT_NAME = "mcp-combiner"
|
|
@@ -340,13 +338,24 @@ export default function mcpCombiner(pi: ExtensionAPI): void {
|
|
|
340
338
|
let uiLevel: "info" | "warn" | "error" = "info"
|
|
341
339
|
if (level === "warn") uiLevel = "warn"
|
|
342
340
|
else if (level === "error") uiLevel = "error"
|
|
343
|
-
|
|
341
|
+
try {
|
|
342
|
+
uiNotify(line, uiLevel)
|
|
343
|
+
} catch {
|
|
344
|
+
// The captured ctx went stale (reload/session replacement kills
|
|
345
|
+
// extension ctxs — pi 1.0 asserts on use). Degrade to stderr and
|
|
346
|
+
// drop the capture so later logs skip the dead path entirely:
|
|
347
|
+
// an async log line (e.g. a superseded connection's reset) must
|
|
348
|
+
// NEVER crash the process.
|
|
349
|
+
uiNotify = undefined
|
|
350
|
+
process.stderr.write(`${line}\n`)
|
|
351
|
+
}
|
|
344
352
|
} else if (level !== "info") {
|
|
345
353
|
process.stderr.write(`${line}\n`)
|
|
346
354
|
}
|
|
347
355
|
}
|
|
348
356
|
|
|
349
|
-
let connection:
|
|
357
|
+
let connection: NativeCombinerConnection | undefined
|
|
358
|
+
let nativeSurface: ReturnType<typeof activateNativeMode> | undefined
|
|
350
359
|
// Session-config HOLDER: everything the ladder shapes, resolved against a SESSION
|
|
351
360
|
// cwd (worktree subagents / project switches get their own project layers).
|
|
352
361
|
// Factory-time resolution with process.cwd() is the pre-session default; every
|
|
@@ -358,17 +367,6 @@ export default function mcpCombiner(pi: ExtensionAPI): void {
|
|
|
358
367
|
bearerTokenEnv: cfg.bearerTokenEnv ?? "MCP_COMBINER_AUTH_TOKEN",
|
|
359
368
|
urlToken: cfg.urlToken,
|
|
360
369
|
})
|
|
361
|
-
const registeredDirect = new Set<string>()
|
|
362
|
-
const directReserved = new Set<string>(RESERVED_TOOL_NAMES)
|
|
363
|
-
directReserved.add(toolName)
|
|
364
|
-
directReserved.add(`${toolName}Script`)
|
|
365
|
-
const directDepsFor = (conn: CombinerConnection): DirectToolDeps => ({
|
|
366
|
-
connection: conn,
|
|
367
|
-
registered: registeredDirect,
|
|
368
|
-
reserved: directReserved,
|
|
369
|
-
register: (tool) => pi.registerTool(tool),
|
|
370
|
-
log: clientLog,
|
|
371
|
-
})
|
|
372
370
|
if (adapterEnabled) {
|
|
373
371
|
sessionCfg = resolveSessionConfig({
|
|
374
372
|
cwd: process.cwd(),
|
|
@@ -376,20 +374,48 @@ export default function mcpCombiner(pi: ExtensionAPI): void {
|
|
|
376
374
|
settingsUrl: settings.url,
|
|
377
375
|
defaultUrl: defaultCombinerUrl(),
|
|
378
376
|
})
|
|
379
|
-
|
|
380
|
-
|
|
377
|
+
// Zombie teardown: pi re-imports the extension module on /reload WITHOUT
|
|
378
|
+
// firing session_shutdown (the session persists, no unload hook exists), so
|
|
379
|
+
// the previous instance's McpClient + SSE stream would live on and compete
|
|
380
|
+
// for the token's elicit routing. A process-global registry survives module
|
|
381
|
+
// re-imports: the fresh factory closes every prior live connection first.
|
|
382
|
+
const g = globalThis as { __piMcpCombinerDispose?: Array<() => void> }
|
|
383
|
+
// The teardown closures below (from prior factory runs, orphaned by pi's
|
|
384
|
+
// reload-without-session_shutdown) know how to fully retire their instance:
|
|
385
|
+
// clear the footer timer, drop the captured UI ctx (stale-ctx reads on it
|
|
386
|
+
// are a hard pi-1.0 assertion), and close the connection.
|
|
387
|
+
for (const dispose of g.__piMcpCombinerDispose ?? []) {
|
|
388
|
+
try {
|
|
389
|
+
dispose()
|
|
390
|
+
} catch {
|
|
391
|
+
// teardown must never break the fresh instance's load
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
g.__piMcpCombinerDispose = []
|
|
395
|
+
connection = new NativeCombinerConnection(connInputsFor(sessionCfg), clientLog)
|
|
396
|
+
// The mcp router registers alongside the per-tool native surface (the same
|
|
397
|
+
// owned connection — not a second proxy): tool_call gates such as
|
|
398
|
+
// pi-permission-system and pi-subagents recognize MCP calls by the tool
|
|
399
|
+
// name "mcp", and the router keeps a discovery/search surface.
|
|
381
400
|
pi.registerTool(
|
|
382
401
|
createMcpTool({
|
|
383
402
|
connection,
|
|
384
403
|
toolName,
|
|
385
404
|
getServerFilter: () => sessionCfg?.serverFilter,
|
|
386
|
-
onSearchMatches: (tools) =>
|
|
387
|
-
sessionCfg?.directSpec === "search" ? activateFromSearch(tools, directDepsFor(activeConn)) : [],
|
|
388
405
|
}),
|
|
389
406
|
)
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
407
|
+
// Per-tool surface + interactive read_*. The FIRST registration runs at
|
|
408
|
+
// session_start (post-setToken — the connection refuses to connect
|
|
409
|
+
// without a grouping token); list_changed re-syncs ride resyncAll below.
|
|
410
|
+
nativeSurface = activateNativeMode(pi, {
|
|
411
|
+
connection,
|
|
412
|
+
directSpec: sessionCfg?.directSpec ?? [],
|
|
413
|
+
serverFilter: sessionCfg?.serverFilter,
|
|
414
|
+
exposeResources: settings.exposeResources !== false,
|
|
415
|
+
uiAutoOpen: settings.uiAutoOpen !== false,
|
|
416
|
+
warnLargeDirectExposure: settings.warnLargeDirectExposure,
|
|
417
|
+
log: clientLog,
|
|
418
|
+
})
|
|
393
419
|
if (sessionCfg.otherServers.length && !adapterPackageInstalled()) {
|
|
394
420
|
clientLog(
|
|
395
421
|
"info",
|
|
@@ -410,10 +436,23 @@ export default function mcpCombiner(pi: ExtensionAPI): void {
|
|
|
410
436
|
if (connection) {
|
|
411
437
|
const conn = connection // captured non-optional for the handlers below
|
|
412
438
|
const registeredPrompts = new Set<string>()
|
|
413
|
-
const registeredResources = new Set<string>()
|
|
414
439
|
let footerTimer: NodeJS.Timeout | undefined
|
|
415
440
|
let uiCtx: ExtensionContext["ui"] | undefined
|
|
416
441
|
|
|
442
|
+
// Full instance teardown, callable from a LATER factory run (pi re-imports
|
|
443
|
+
// the module on /reload without firing session_shutdown): clears this
|
|
444
|
+
// instance's footer timer, drops its captured UI ctx (stale-ctx reads are
|
|
445
|
+
// a hard pi-1.0 assertion), and closes its connection. The registry lives
|
|
446
|
+
// on globalThis — the one thing that survives a module re-import.
|
|
447
|
+
const teardownGlobal = globalThis as { __piMcpCombinerDispose?: Array<() => void> }
|
|
448
|
+
;(teardownGlobal.__piMcpCombinerDispose ??= []).push(() => {
|
|
449
|
+
if (footerTimer) clearInterval(footerTimer)
|
|
450
|
+
footerTimer = undefined
|
|
451
|
+
uiCtx = undefined
|
|
452
|
+
uiNotify = undefined
|
|
453
|
+
void conn.reset("superseded by a newer extension instance (reload)")
|
|
454
|
+
})
|
|
455
|
+
|
|
417
456
|
const refreshFooter = () => {
|
|
418
457
|
if (uiCtx)
|
|
419
458
|
void updateFooter(conn, uiCtx, settings.mcpFooterKey, settings.mcpFooterStatus).catch(() => undefined)
|
|
@@ -424,35 +463,26 @@ export default function mcpCombiner(pi: ExtensionAPI): void {
|
|
|
424
463
|
() => undefined,
|
|
425
464
|
)
|
|
426
465
|
}
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
).catch(() => undefined)
|
|
436
|
-
}
|
|
437
|
-
const syncDirect = () => {
|
|
438
|
-
// Allowlist mode only — "search" registers nothing upfront.
|
|
439
|
-
const spec = sessionCfg?.directSpec
|
|
440
|
-
if (!Array.isArray(spec) || spec.length === 0) return
|
|
441
|
-
void conn
|
|
442
|
-
.listTools()
|
|
443
|
-
.then((tools) => {
|
|
444
|
-
syncAllowlistTools(applyServerFilter(tools, sessionCfg?.serverFilter), spec, directDepsFor(conn))
|
|
445
|
-
})
|
|
446
|
-
.catch(() => undefined)
|
|
466
|
+
const resyncAll = () => {
|
|
467
|
+
// Prompts + footer re-sync on: list_changed notifications, reconnect
|
|
468
|
+
// catch-up (fired by the connection itself), AND session_start — a
|
|
469
|
+
// reload/re-enter with an UNCHANGED token skips any reconnect, so
|
|
470
|
+
// without this the surfaces would only ever update on notifications.
|
|
471
|
+
nativeSurface?.run()
|
|
472
|
+
syncPrompts()
|
|
473
|
+
refreshFooter()
|
|
447
474
|
}
|
|
448
475
|
conn.setHooks({
|
|
449
|
-
onToolsChanged:
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
476
|
+
onToolsChanged: resyncAll,
|
|
477
|
+
onStateChange: (state) => {
|
|
478
|
+
// Re-attach on (re)connect: a combiner bounce kills the instance our
|
|
479
|
+
// sharedserver reference was attached to, and nothing else re-takes
|
|
480
|
+
// ownership until the next session_start — an orphaned combiner then
|
|
481
|
+
// grace-stops 30m later (sharedserver `use` is idempotent per pid, so
|
|
482
|
+
// re-attaching on every connect is safe and cheap).
|
|
483
|
+
if (state === "connected") attachCombiner(clientLog)
|
|
453
484
|
refreshFooter()
|
|
454
485
|
},
|
|
455
|
-
onStateChange: () => refreshFooter(),
|
|
456
486
|
// Stage 2: the widget hold announces its UI URL mid-call — open it.
|
|
457
487
|
onWidgetUrl: (url) => {
|
|
458
488
|
if (settings.uiAutoOpen !== false) openInBrowser(url)
|
|
@@ -474,6 +504,11 @@ export default function mcpCombiner(pi: ExtensionAPI): void {
|
|
|
474
504
|
})
|
|
475
505
|
conn.rebind(connInputsFor(sessionCfg))
|
|
476
506
|
conn.setToken(sessionToken(event, ctx))
|
|
507
|
+
// The native per-tool/resource registration runs HERE — post-setToken,
|
|
508
|
+
// the first moment the grouping token exists (a factory-time run
|
|
509
|
+
// would fail with "no grouping token set"). Subsequent re-syncs ride
|
|
510
|
+
// resyncAll via onToolsChanged.
|
|
511
|
+
nativeSurface?.run()
|
|
477
512
|
// Per-project exposure: pending filters apply server-side before first connect.
|
|
478
513
|
const filter = sessionCfg.serverFilter
|
|
479
514
|
if (filter) {
|
|
@@ -504,8 +539,6 @@ export default function mcpCombiner(pi: ExtensionAPI): void {
|
|
|
504
539
|
.then(() => {
|
|
505
540
|
refreshFooter()
|
|
506
541
|
syncPrompts()
|
|
507
|
-
syncResources()
|
|
508
|
-
syncDirect()
|
|
509
542
|
})
|
|
510
543
|
.catch(() => undefined)
|
|
511
544
|
}
|
|
@@ -549,7 +582,7 @@ export default function mcpCombiner(pi: ExtensionAPI): void {
|
|
|
549
582
|
// restart-server <srv> (combiner meta-tools through our client), system-prompt
|
|
550
583
|
// (show the injected directive), install-config [path] (write the shared mcp.json
|
|
551
584
|
// combiner entry — legacy pairing with pi-mcp-adapter, now also read by our ladder).
|
|
552
|
-
|
|
585
|
+
const combinerCommandSpec: CommandSpec = {
|
|
553
586
|
description:
|
|
554
587
|
"mcp-combiner — verbs: status, enable <srv>, disable <srv>, restart-server <srv>, system-prompt, install-config [path]",
|
|
555
588
|
getArgumentCompletions: (prefix) => completeVerbs(prefix),
|
|
@@ -624,7 +657,16 @@ export default function mcpCombiner(pi: ExtensionAPI): void {
|
|
|
624
657
|
}
|
|
625
658
|
ctx.ui?.notify?.(`mcp-combiner: unknown verb "${v}". Try: ${COMMAND_VERBS.join(", ")}`, "warn")
|
|
626
659
|
},
|
|
627
|
-
}
|
|
660
|
+
}
|
|
661
|
+
pi.registerCommand("mcp-combiner", combinerCommandSpec)
|
|
662
|
+
|
|
663
|
+
// NOTE: we deliberately do NOT register "/mcp" — pi's built-in MCP extension
|
|
664
|
+
// owns that command and stays the native-mode transport; our surfaces ("mcp"
|
|
665
|
+
// router tool, direct <server>_<tool>, read_*) don't collide with the builtin's
|
|
666
|
+
// (codemode / tool-search / read_mcp_resource). Registering "/mcp" would
|
|
667
|
+
// displace the builtin at load (pi's loader drops replaceable built-ins on
|
|
668
|
+
// command-name collision) and with it the native support we're migrating
|
|
669
|
+
// toward.
|
|
628
670
|
|
|
629
671
|
if (hostOwned || !manage) {
|
|
630
672
|
// Registration is static (mcp.json) and the combiner is someone else's to run:
|
|
@@ -636,10 +678,7 @@ export default function mcpCombiner(pi: ExtensionAPI): void {
|
|
|
636
678
|
// Pi forbids starting background resources from the factory, so all of this is
|
|
637
679
|
// deferred to session_start. session_start fires again on reload/new/resume/fork
|
|
638
680
|
// within the same process; the `attachment` guard makes re-entry a no-op attach.
|
|
639
|
-
|
|
640
|
-
if (attachment) return // already attached in this process
|
|
641
|
-
|
|
642
|
-
const log = makeLog(ctx, notify)
|
|
681
|
+
const attachCombiner = (log: LogFn): void => {
|
|
643
682
|
const binary = resolveSharedserver(
|
|
644
683
|
{
|
|
645
684
|
label: "mcp-combiner",
|
|
@@ -728,6 +767,11 @@ export default function mcpCombiner(pi: ExtensionAPI): void {
|
|
|
728
767
|
|
|
729
768
|
attachment = { binary, name }
|
|
730
769
|
log("info", `combiner "${name}" attached on port ${port} (${combiner.cmd} ${wrappedArgs.join(" ")})`)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
pi.on("session_start", (_event, ctx) => {
|
|
773
|
+
if (attachment) return // already attached in this process
|
|
774
|
+
attachCombiner(makeLog(ctx, notify))
|
|
731
775
|
})
|
|
732
776
|
|
|
733
777
|
pi.on("session_shutdown", (event: SessionShutdownEvent) => {
|