@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.
Files changed (98) hide show
  1. package/README.md +71 -48
  2. package/dist/client/config-ladder.d.ts +1 -1
  3. package/dist/client/config-ladder.d.ts.map +1 -1
  4. package/dist/client/control.d.ts +1 -1
  5. package/dist/client/control.d.ts.map +1 -1
  6. package/dist/client/elicitation.d.ts +4 -2
  7. package/dist/client/elicitation.d.ts.map +1 -1
  8. package/dist/client/elicitation.js +8 -2
  9. package/dist/client/elicitation.js.map +1 -1
  10. package/dist/client/footer.d.ts +1 -1
  11. package/dist/client/footer.d.ts.map +1 -1
  12. package/dist/client/panel.d.ts +1 -1
  13. package/dist/client/panel.d.ts.map +1 -1
  14. package/dist/client/panel.js +1 -1
  15. package/dist/client/panel.js.map +1 -1
  16. package/dist/client/prompts.d.ts +1 -1
  17. package/dist/client/prompts.d.ts.map +1 -1
  18. package/dist/client/proxy-tool.d.ts +1 -1
  19. package/dist/client/proxy-tool.d.ts.map +1 -1
  20. package/dist/client/resource-naming.d.ts +8 -0
  21. package/dist/client/resource-naming.d.ts.map +1 -0
  22. package/dist/client/resource-naming.js +33 -0
  23. package/dist/client/resource-naming.js.map +1 -0
  24. package/dist/client/settings.d.ts +5 -4
  25. package/dist/client/settings.d.ts.map +1 -1
  26. package/dist/client/settings.js +3 -3
  27. package/dist/client/settings.js.map +1 -1
  28. package/dist/client/tool-matching.d.ts +32 -0
  29. package/dist/client/tool-matching.d.ts.map +1 -0
  30. package/dist/client/tool-matching.js +43 -0
  31. package/dist/client/tool-matching.js.map +1 -0
  32. package/dist/client/types.d.ts +71 -0
  33. package/dist/client/types.d.ts.map +1 -0
  34. package/dist/client/types.js +16 -0
  35. package/dist/client/types.js.map +1 -0
  36. package/dist/client/widget-support.d.ts +17 -0
  37. package/dist/client/widget-support.d.ts.map +1 -0
  38. package/dist/client/widget-support.js +89 -0
  39. package/dist/client/widget-support.js.map +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +109 -53
  42. package/dist/index.js.map +1 -1
  43. package/dist/{client/connection.d.ts → native/combiner-connection.d.ts} +46 -38
  44. package/dist/native/combiner-connection.d.ts.map +1 -0
  45. package/dist/native/combiner-connection.js +412 -0
  46. package/dist/native/combiner-connection.js.map +1 -0
  47. package/dist/native/index.d.ts +30 -0
  48. package/dist/native/index.d.ts.map +1 -0
  49. package/dist/native/index.js +61 -0
  50. package/dist/native/index.js.map +1 -0
  51. package/dist/native/resources.d.ts +18 -0
  52. package/dist/native/resources.d.ts.map +1 -0
  53. package/dist/native/resources.js +97 -0
  54. package/dist/native/resources.js.map +1 -0
  55. package/dist/native/tool-surface.d.ts +26 -0
  56. package/dist/native/tool-surface.d.ts.map +1 -0
  57. package/dist/native/tool-surface.js +113 -0
  58. package/dist/native/tool-surface.js.map +1 -0
  59. package/dist/pi.d.ts +19 -0
  60. package/dist/pi.d.ts.map +1 -1
  61. package/package.json +4 -6
  62. package/src/client/config-ladder.ts +1 -1
  63. package/src/client/control.ts +1 -1
  64. package/src/client/elicitation.ts +10 -2
  65. package/src/client/footer.ts +1 -1
  66. package/src/client/panel.ts +2 -2
  67. package/src/client/prompts.ts +1 -1
  68. package/src/client/proxy-tool.ts +1 -1
  69. package/src/client/resource-naming.ts +36 -0
  70. package/src/client/settings.ts +7 -6
  71. package/src/client/tool-matching.ts +69 -0
  72. package/src/client/types.ts +80 -0
  73. package/src/client/widget-support.ts +86 -0
  74. package/src/index.ts +109 -65
  75. package/src/native/combiner-connection.ts +483 -0
  76. package/src/native/index.ts +100 -0
  77. package/src/native/resources.ts +112 -0
  78. package/src/native/tool-surface.ts +147 -0
  79. package/src/pi.ts +16 -0
  80. package/dist/client/connection.d.ts.map +0 -1
  81. package/dist/client/connection.js +0 -332
  82. package/dist/client/connection.js.map +0 -1
  83. package/dist/client/direct-tools.d.ts +0 -41
  84. package/dist/client/direct-tools.d.ts.map +0 -1
  85. package/dist/client/direct-tools.js +0 -122
  86. package/dist/client/direct-tools.js.map +0 -1
  87. package/dist/client/resources.d.ts +0 -20
  88. package/dist/client/resources.d.ts.map +0 -1
  89. package/dist/client/resources.js +0 -119
  90. package/dist/client/resources.js.map +0 -1
  91. package/dist/client/script.d.ts +0 -10
  92. package/dist/client/script.d.ts.map +0 -1
  93. package/dist/client/script.js +0 -130
  94. package/dist/client/script.js.map +0 -1
  95. package/src/client/connection.ts +0 -373
  96. package/src/client/direct-tools.ts +0 -146
  97. package/src/client/resources.ts +0 -137
  98. 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 { CombinerConnection } from "./client/connection.js"
44
- import { createMcpTool, openInBrowser } from "./client/proxy-tool.js"
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
- uiNotify(line, uiLevel)
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: CombinerConnection | undefined
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
- connection = new CombinerConnection(connInputsFor(sessionCfg), clientLog)
380
- const activeConn = connection // narrowed for the activation closure below
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
- if (settings.scriptMode) {
391
- pi.registerTool(createScriptTool({ connection, name: `${toolName}Script` }))
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 syncResources = () => {
428
- if (!settings.exposeResources) return
429
- void syncResourceTools(
430
- (tool) => pi.registerTool(tool),
431
- conn,
432
- registeredResources,
433
- sessionCfg?.serverFilter,
434
- (m, l) => clientLog(l, m),
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
- syncPrompts()
451
- syncResources()
452
- syncDirect()
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
- pi.registerCommand("mcp-combiner", {
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
- pi.on("session_start", (_event, ctx) => {
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) => {