@geohar/pi-mcp-combiner 0.14.3 → 0.16.0-dev.2

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 +6 -5
  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,483 @@
1
+ // NATIVE CONNECTION : the combiner MCP client rebuilt
2
+ // on @earendil-works/pi-mcp (peer dependency on pi's own published MCP package) so the
3
+ // connection is OURS and therefore the elicitation capability is OURS to declare:
4
+ //
5
+ // capabilities: { elicitation: {} } // advertised, because we CAN answer
6
+ // client.setRequestHandler("elicitation/create", …) // elicitation.ts bridge
7
+ //
8
+ // Everything else falls through to pi-mcp: transports, reconnection of dropped SSE
9
+ // streams, protocol negotiation, tool/resource listing with pagination, roots. We add
10
+ // only what the combiner needs on top: the per-Pi-session grouping token in the URL
11
+ // path, the optional inbound bearer, tools/prompts/resources caches, one stale-session
12
+ // retry, and the widget log-notification tap.
13
+ //
14
+ // CONTRACT: this class matches client/connection.ts's public surface structurally, so
15
+ // existing consumers (prompts.ts, resources.ts, footer.ts, panel.ts, control.ts) work
16
+ // unchanged and index.ts's mode gate can pick either implementation. The model-facing
17
+ // difference from the SDK client half is registration: see native/tool-surface.ts —
18
+ // tools are declared to pi NATIVELY (pi.registerTool with exposure/namespace), there is
19
+ // no mcp() proxy and no script tool.
20
+ //
21
+ // Why instantiate instead of wrapping: pi's built-in MCP extension owns its client
22
+ // instances privately; no hook reaches them (verified 0.99→1.0). Owning the connection
23
+ // is the only sanctioned way to advertise elicitation today. The two-switch upstream
24
+ // design (per-server config + handler-availability negotiation) remains the ecosystem
25
+ // endgame; this class is what the extension ships until and after that lands.
26
+
27
+ import { appendFileSync } from "node:fs"
28
+ import { McpClient, McpSessionExpiredError, StreamableHttpTransport } from "@earendil-works/pi-mcp"
29
+ import type { CallToolResult } from "@earendil-works/pi-mcp"
30
+ import { handleElicitation, type ElicitResponse, type ElicitUi } from "../client/elicitation.js"
31
+ import type { CombinerConnection } from "../client/types.js"
32
+ import type { ServerFilter } from "../client/config-ladder.js"
33
+
34
+ // ── shared contracts (kept aligned with client/connection.ts by the callers) ────────
35
+
36
+ export type LogFn = (level: "info" | "warn" | "error", message: string) => void
37
+
38
+ export type ResolvedConnection = {
39
+ /** Base URL including /mcp, WITHOUT any token path (e.g. http://127.0.0.1:9741/mcp). */
40
+ baseUrl: string
41
+ /** Env var holding the inbound bearer token (read at connect time), if any. */
42
+ bearerTokenEnv?: string
43
+ /** Explicit token already present in the configured URL path (user override). */
44
+ urlToken?: string
45
+ }
46
+
47
+ export type ConnectionState = "disconnected" | "connecting" | "connected" | "failed"
48
+
49
+ export type ToolSummary = {
50
+ name: string
51
+ description?: string
52
+ inputSchema?: unknown
53
+ /** MCP tool annotations, carried straight through to pi.registerTool so pi's
54
+ * permission extensions can classify calls. */
55
+ annotations?: ToolAnnotationsSummary
56
+ }
57
+ export type ToolAnnotationsSummary = {
58
+ readOnlyHint?: boolean
59
+ destructiveHint?: boolean
60
+ idempotentHint?: boolean
61
+ openWorldHint?: boolean
62
+ }
63
+
64
+ export type PromptArgument = { name: string; description?: string; required?: boolean }
65
+ export type PromptSummary = { name: string; title?: string; description?: string; arguments?: PromptArgument[] }
66
+ export type ResourceSummary = {
67
+ uri: string
68
+ name?: string
69
+ description?: string
70
+ mimeType?: string
71
+ _meta?: Record<string, unknown>
72
+ }
73
+
74
+ export type ConnectionHooks = {
75
+ onToolsChanged?: () => void
76
+ onStateChange?: (state: ConnectionState) => void
77
+ onWidgetUrl?: (url: string) => void
78
+ }
79
+
80
+ // pi-mcp doesn't (yet) export an ElicitResult type — shapes are protocol-stable:
81
+ // accept/decline/cancel with an optional content record. Tracked as a tiny upstream
82
+ // export PR candidate.
83
+ export type ElicitResult = ElicitResponse
84
+
85
+
86
+ function ncDbg(s: string): void {
87
+ try {
88
+ appendFileSync("/tmp/pi-combiner-dbg.log", `${new Date().toISOString()} ${s}\n`)
89
+ } catch {
90
+ // debug only
91
+ }
92
+ }
93
+
94
+ const CLIENT_INFO = { name: "pi-mcp-combiner", version: "0.14.3" }
95
+ const CONNECT_TIMEOUT_MS = 8_000
96
+ const LIST_CACHE_MS = 5_000
97
+
98
+ function isRecord(v: unknown): v is Record<string, unknown> {
99
+ return typeof v === "object" && v !== null && !Array.isArray(v)
100
+ }
101
+
102
+ /** Build the tokened endpoint URL: <origin>/mcp/<token> (identical to client/connection.ts). */
103
+ export function tokenedUrl(baseUrl: string, token: string): string {
104
+ try {
105
+ const u = new URL(baseUrl)
106
+ const path = u.pathname.replace(/\/mcp(?:\/[^/]*)*\/?$/, "/mcp")
107
+ u.pathname = `${path.replace(/\/$/, "")}/${encodeURIComponent(token)}`
108
+ return u.toString()
109
+ } catch {
110
+ return baseUrl
111
+ }
112
+ }
113
+
114
+ export class NativeCombinerConnection implements CombinerConnection {
115
+ private conn: ResolvedConnection
116
+ private readonly log: LogFn
117
+ private elicitUi: ElicitUi = { hasUI: false }
118
+ private token: string | undefined
119
+ private client: McpClient | undefined
120
+ private connecting: Promise<McpClient> | undefined
121
+ private tools: ToolSummary[] | undefined
122
+ private toolsFetchedAt = 0
123
+ private prompts: PromptSummary[] | undefined
124
+ private promptsFetchedAt = 0
125
+ private resources: ResourceSummary[] | undefined
126
+ private resourcesFetchedAt = 0
127
+ private hooks: ConnectionHooks = {}
128
+ state: ConnectionState = "disconnected"
129
+ lastError: string | undefined
130
+
131
+ constructor(conn: ResolvedConnection, log: LogFn) {
132
+ this.conn = conn
133
+ this.log = log
134
+ }
135
+
136
+ setHooks(hooks: ConnectionHooks): void {
137
+ this.hooks = hooks
138
+ }
139
+
140
+ /** Effective grouping token for this session (explicit URL token wins). */
141
+ get sessionToken(): string | undefined {
142
+ return this.effectiveToken()
143
+ }
144
+
145
+ /** Bind the UI slice used by elicitation (called from session_start). */
146
+ bindUi(ui: ElicitUi): void {
147
+ this.elicitUi = ui
148
+ }
149
+
150
+ /** Swap connection inputs (base URL / bearer env); drops the client when changed. */
151
+ rebind(conn: ResolvedConnection): boolean {
152
+ if (
153
+ conn.baseUrl === this.conn.baseUrl &&
154
+ conn.bearerTokenEnv === this.conn.bearerTokenEnv &&
155
+ conn.urlToken === this.conn.urlToken
156
+ ) {
157
+ return false
158
+ }
159
+ this.conn = conn
160
+ void this.reset("connection rebound (session config changed)")
161
+ return true
162
+ }
163
+
164
+ /** Set the grouping token for this session; resets any open connection. */
165
+ setToken(token: string): void {
166
+ if (this.token === token) return
167
+ this.token = token
168
+ void this.reset("token changed")
169
+ }
170
+
171
+ /** Drop the connection + caches (session reset / shutdown). Never throws. */
172
+ async reset(reason: string): Promise<void> {
173
+ const client = this.client
174
+ this.client = undefined
175
+ this.connecting = undefined
176
+ this.tools = undefined
177
+ this.prompts = undefined
178
+ this.resources = undefined
179
+ this.state = "disconnected"
180
+ try {
181
+ await client?.close()
182
+ } catch {
183
+ // best-effort
184
+ }
185
+ if (reason) this.log("info", `connection reset (${reason})`)
186
+ }
187
+
188
+ private effectiveToken(): string | undefined {
189
+ return this.conn.urlToken ?? this.token
190
+ }
191
+
192
+ /** Connect (idempotent, single-flight). THE elicitation-relevant site: the
193
+ * capability + request handler live HERE, on a client we own. */
194
+ async ensureConnected(): Promise<McpClient> {
195
+ if (this.client) return this.client
196
+ if (this.connecting) return this.connecting
197
+
198
+ const token = this.effectiveToken()
199
+ if (!token) throw new Error("no grouping token set (session not started)")
200
+ const endpoint = tokenedUrl(this.conn.baseUrl, token)
201
+ let url: URL
202
+ try {
203
+ url = new URL(endpoint)
204
+ } catch {
205
+ throw new Error(`combiner URL "${endpoint}" is not parseable; set a valid url (…/mcp) in settings or mcp.json`)
206
+ }
207
+ const bearer = this.conn.bearerTokenEnv ? process.env[this.conn.bearerTokenEnv] : undefined
208
+
209
+ this.state = "connecting"
210
+ this.connecting = (async () => {
211
+ const client = new McpClient({
212
+ name: CLIENT_INFO.name,
213
+ version: CLIENT_INFO.version,
214
+ // Advertised elicitation — the combiner's permission gate depends on it,
215
+ // and our handler below CAN answer (decline when no dialog UI exists).
216
+ capabilities: { elicitation: {} },
217
+ // Widget holds keep a call in flight up to MCP_COMBINER_UI_HOLD_TIMEOUT
218
+ // (default 50s combiner-side); 90s headroom so the native call outlives it.
219
+ requestTimeoutMs: 90_000,
220
+ })
221
+
222
+ client.setRequestHandler(
223
+ "elicitation/create",
224
+ async (raw: unknown, { signal }: { signal: AbortSignal }): Promise<ElicitResult> => {
225
+ const params = isRecord(raw) ? raw : undefined
226
+ const message = typeof params?.message === "string" ? params.message : undefined
227
+ const requestedSchema = isRecord(params?.requestedSchema) ? params?.requestedSchema : undefined
228
+ if (!message && !requestedSchema) return { action: "decline" }
229
+ const result = await handleElicitation({ message, requestedSchema }, this.elicitUi, signal)
230
+ return result as ElicitResult
231
+ },
232
+ )
233
+
234
+ // pi-mcp's onNotification listeners receive the notification PARAMS directly
235
+ // (not the raw envelope) — params shape for notifications/message is
236
+ // { level, logger, data }.
237
+ client.onNotification("notifications/tools/list_changed", () => {
238
+ this.tools = undefined
239
+ this.prompts = undefined
240
+ this.resources = undefined
241
+ try {
242
+ this.hooks.onToolsChanged?.()
243
+ } catch {
244
+ // hook errors must never break the notification path
245
+ }
246
+ })
247
+ client.onNotification("notifications/resources/list_changed", () => {
248
+ this.resources = undefined
249
+ try {
250
+ this.hooks.onToolsChanged?.()
251
+ } catch {
252
+ // hook errors must never break the notification path
253
+ }
254
+ })
255
+ // Widget hold announces its UI URL via a log notification mid-flight.
256
+ client.onNotification("notifications/message", (params: unknown) => {
257
+ const data = (isRecord(params) ? params.data : undefined)
258
+ if (typeof data !== "string") return
259
+ const m = data.match(/Interactive UI ready: (\S+)/)
260
+ if (m) {
261
+ try {
262
+ this.hooks.onWidgetUrl?.(m[1])
263
+ } catch {
264
+ // hook errors must never break the notification path
265
+ }
266
+ }
267
+ })
268
+ client.onClose(() => {
269
+ if (this.client === client) {
270
+ this.client = undefined
271
+ this.state = "disconnected"
272
+ this.hooks.onStateChange?.(this.state)
273
+ }
274
+ })
275
+
276
+ const transport = new StreamableHttpTransport({
277
+ url,
278
+ authProvider: bearer ? { token: async () => bearer } : undefined,
279
+ })
280
+ await Promise.race([
281
+ client.connect(transport),
282
+ new Promise((_, reject) => setTimeout(() => reject(new Error("connect timeout")), CONNECT_TIMEOUT_MS)),
283
+ ])
284
+
285
+ this.client = client
286
+ ncDbg(`ensureConnected: CONNECTED session=${(client as unknown as { sessionId?: string }).sessionId ?? "?"} state=${(client as unknown as { transport?: { sessionId?: string } }).transport?.sessionId ?? "?"}`)
287
+ this.state = "connected"
288
+ this.lastError = undefined
289
+ // Catch-up after (re)connect — same rationale as client/connection.ts:
290
+ // MCP notifications are lossy for disconnected clients, so re-sync every
291
+ // surface the moment the stream is live again. Idempotent upstream.
292
+ try {
293
+ this.hooks.onToolsChanged?.()
294
+ } catch {
295
+ // hook errors must never break the connect path
296
+ }
297
+ this.hooks.onStateChange?.(this.state)
298
+ return client
299
+ })()
300
+
301
+ try {
302
+ return await this.connecting
303
+ } catch (e) {
304
+ this.state = "failed"
305
+ this.lastError = e instanceof Error ? e.message : String(e)
306
+ this.log("warn", `combiner connect failed (${this.lastError})`)
307
+ this.hooks.onStateChange?.(this.state)
308
+ throw new Error(`cannot reach the combiner at ${this.conn.baseUrl}: ${this.lastError} — the combiner may have restarted; this call did not run and is safe to retry (carried state: grants, filters, parked sessions survives restarts)`)
309
+ } finally {
310
+ this.connecting = undefined
311
+ }
312
+ }
313
+
314
+ /** One-shot stale-session retry shared by EVERY request method — a combiner
315
+ * bounce invalidates the transport session, and the first operation to notice
316
+ * can be any of them (the router's listTools lookup runs BEFORE callTool;
317
+ * without this it surfaced raw McpSessionExpiredError and never reset the
318
+ * client, wedging the connection until reload). Same classification as
319
+ * callTool: the typed McpSessionExpiredError first, the message regex as belt. */
320
+ private async withStaleRetry<T>(label: string, op: (client: McpClient) => Promise<T>): Promise<T> {
321
+ const client = await this.ensureConnected()
322
+ try {
323
+ return await op(client)
324
+ } catch (e) {
325
+ const typedStale = e instanceof McpSessionExpiredError
326
+ const msg = e instanceof Error ? e.message : String(e)
327
+ ncDbg(`${label} THROW typed=${typedStale} ${msg.slice(0, 140)}`)
328
+ const stale =
329
+ typedStale || /404|stale|session|not found|closed|fetch failed|illegal/i.test(msg)
330
+ if (!stale) throw e
331
+ ncDbg(`${label} stale-retry FIRED`)
332
+ this.log("info", `${label} went stale (${msg}); reconnecting once`)
333
+ await this.reset("stale session")
334
+ return await op(await this.ensureConnected())
335
+ }
336
+ }
337
+
338
+ /** tools/list, cached until invalidated (list_changed / reset / reconnect).
339
+ * pi-mcp returns the tools array directly (pagination already resolved). */
340
+ async listTools(force = false): Promise<ToolSummary[]> {
341
+ if (!force && this.tools && Date.now() - this.toolsFetchedAt < LIST_CACHE_MS) return this.tools
342
+ const client = await this.ensureConnected()
343
+ const tools: ToolSummary[] = (await this.withStaleRetry("listTools", (c) => c.listTools())).map((t) => ({
344
+ name: t.name,
345
+ description: t.description,
346
+ inputSchema: t.inputSchema,
347
+ annotations: t.annotations as ToolAnnotationsSummary | undefined,
348
+ }))
349
+ this.tools = tools
350
+ this.toolsFetchedAt = Date.now()
351
+ return tools
352
+ }
353
+
354
+ /** prompts/list, cached alongside tools. pi-mcp has no prompts convenience (yet);
355
+ * the generic request() is the sanctioned path for server-initiated methods the
356
+ * client doesn't model. */
357
+ async listPrompts(force = false): Promise<PromptSummary[]> {
358
+ if (!force && this.prompts && Date.now() - this.promptsFetchedAt < LIST_CACHE_MS) return this.prompts
359
+ const res = await this.withStaleRetry("listPrompts", (c) =>
360
+ c.request<{ prompts: PromptSummary[] }>("prompts/list"),
361
+ )
362
+ this.prompts = res.prompts ?? []
363
+ this.promptsFetchedAt = Date.now()
364
+ return this.prompts
365
+ }
366
+
367
+ /** prompts/get through the combiner. */
368
+ async getPrompt(name: string, args: Record<string, string>): Promise<unknown> {
369
+ return this.withStaleRetry(`getPrompt ${name}`, (c) =>
370
+ c.request("prompts/get", { name, arguments: args }),
371
+ )
372
+ }
373
+
374
+ /** resources/list, cached alongside tools/prompts.
375
+ * SAFETY: pi-mcp's Resource adds typed `_meta` variance the combiner surfaces as
376
+ * Record<string, unknown> — the shapes agree on the fields we consume
377
+ * (uri/name/description/mimeType), so the cast narrows only metadata typing. */
378
+ async listResources(force = false): Promise<ResourceSummary[]> {
379
+ if (!force && this.resources && Date.now() - this.resourcesFetchedAt < LIST_CACHE_MS) return this.resources
380
+ const resources: ResourceSummary[] = ((await this.withStaleRetry("listResources", (c) =>
381
+ c.listResources(),
382
+ )) as unknown) as ResourceSummary[]
383
+ this.resources = resources
384
+ this.resourcesFetchedAt = Date.now()
385
+ return resources
386
+ }
387
+
388
+ /** resources/read through the combiner. */
389
+ async readResource(uri: string): Promise<unknown> {
390
+ return this.withStaleRetry(`readResource ${uri}`, (c) => c.readResource(uri))
391
+ }
392
+
393
+ /** callTool with one stale-session retry (combiner bounce; handover keeps token).
394
+ * A server-marked error (CallToolResult.isError) THROWS so pi records the call
395
+ * as a tool error — same convention the rest of the extension uses. */
396
+ async callTool(name: string, args: Record<string, unknown> | undefined): Promise<CallToolResult> {
397
+ const attempt = async (): Promise<CallToolResult> => {
398
+ const client = await this.ensureConnected()
399
+ const result = await client.callTool(name, args)
400
+ if (result.isError) {
401
+ const text = (result.content ?? [])
402
+ .map((b) => (isRecord(b) && b.type === "text" && typeof b.text === "string" ? b.text : JSON.stringify(b)))
403
+ .filter(Boolean)
404
+ .join("\n")
405
+ throw new Error(`combiner tool error (${name}): ${text}`)
406
+ }
407
+ return result
408
+ }
409
+ try {
410
+ return await attempt()
411
+ } catch (e) {
412
+ // pi-mcp raises a TYPED McpSessionExpiredError on 404-with-session-id (the
413
+ // combiner's stale-session 404) — catch it first-class, exactly like pi's
414
+ // own builtin does ("retry once on a new session; the old client is
415
+ // detached, not closed, so other in-flight calls get their own 404 and
416
+ // retry the same way"). The message regex stays as a belt for anything
417
+ // the transport doesn't classify.
418
+ const typedStale = e instanceof McpSessionExpiredError
419
+ const msg = e instanceof Error ? e.message : String(e)
420
+ ncDbg(`callTool ${name} THROW typed=${typedStale} status=${(e as { status?: number } | null)?.status} ${msg.slice(0, 140)}`)
421
+ const stale = typedStale || /404|stale|session|not found|closed|fetch failed|illegal/i.test(msg)
422
+ if (!stale) throw e
423
+ ncDbg(`callTool ${name} stale-retry FIRED`)
424
+ this.log(
425
+ "info",
426
+ `call "${name}" went stale (${typedStale ? "session expired (typed)" : msg}); reconnecting once`,
427
+ )
428
+ await this.reset("stale session")
429
+ ncDbg(`callTool ${name} reset done; retrying`)
430
+ try {
431
+ const result = await attempt()
432
+ ncDbg(`callTool ${name} RETRY SUCCEEDED`)
433
+ return result
434
+ } catch (e2) {
435
+ ncDbg(`callTool ${name} RETRY FAILED: ${e2 instanceof Error ? e2.message : String(e2)}`)
436
+ throw e2
437
+ }
438
+ }
439
+ }
440
+
441
+ // ── control plane (unchanged from client/connection.ts) ─────────────────────────
442
+
443
+ controlOrigin(): string {
444
+ try {
445
+ return new URL(this.conn.baseUrl).origin
446
+ } catch {
447
+ return this.conn.baseUrl
448
+ }
449
+ }
450
+
451
+ uiUrlFor(resourceUri: string): string {
452
+ const token = this.effectiveToken()
453
+ if (!token || !resourceUri) return ""
454
+ return `${this.controlOrigin()}/ui/${encodeURIComponent(token)}/?resource=${encodeURIComponent(resourceUri)}`
455
+ }
456
+
457
+ private controlHeaders(): Record<string, string> {
458
+ const token = this.conn.bearerTokenEnv ? process.env[this.conn.bearerTokenEnv] : undefined
459
+ return token ? { authorization: `Bearer ${token}` } : {}
460
+ }
461
+
462
+ async applyFilter(filter: ServerFilter): Promise<void> {
463
+ const token = this.effectiveToken()
464
+ if (!token) throw new Error("no grouping token set (session not started)")
465
+ const body: Record<string, unknown> = {}
466
+ if (filter.allow?.length) body.allowed_servers = filter.allow
467
+ else if (filter.deny?.length) body.disabled_servers = filter.deny
468
+ else return
469
+ const res = await fetch(`${this.controlOrigin()}/sessions/token/${encodeURIComponent(token)}/filter`, {
470
+ method: "POST",
471
+ headers: { "content-type": "application/json", ...this.controlHeaders() },
472
+ body: JSON.stringify(body),
473
+ })
474
+ if (!res.ok) throw new Error(`filter apply failed: ${res.status} ${await res.text().catch(() => "")}`)
475
+ this.log("info", `session server filter applied (${filter.allow ? "allow" : "deny"}-list)`)
476
+ }
477
+
478
+ async health(): Promise<unknown> {
479
+ const res = await fetch(`${this.controlOrigin()}/health`, { headers: this.controlHeaders() })
480
+ if (!res.ok) throw new Error(`/health returned ${res.status}`)
481
+ return res.json()
482
+ }
483
+ }
@@ -0,0 +1,100 @@
1
+ // NATIVE MODE : activation entry for the
2
+ // pi-mcp-based combiner connection + native tool surface. This file exists so the
3
+ // wire-in stays a few lines in src/index.ts's mode gate while the shape is reviewed:
4
+ //
5
+ // const native = settings.integration === "nativeTools" // new tri-state member
6
+ // const conn = native ? new NativeCombinerConnection(…, clientLog) // same contract
7
+ // : new CombinerConnection(…, clientLog)
8
+ // if (native) void activateNativeTools(pi, { connection: conn, ... }) as needed
9
+ //
10
+ // Mode matrix (the native-migration plan item):: extension-owned connection (peer dep on @earendil-works/pi-mcp) + native
11
+ // tool declaration via pi.registerTool(exposure/namespace/annotations) = native
12
+ // exposure with elicitation STILL ANSWERED (capability advertised on the owned
13
+ // connection) — no mcp() proxy, no script tool, no upstream PR required for v1.
14
+
15
+ import type { ExtensionAPI, ExtensionContext } from "../pi.js"
16
+ import type { ServerFilter } from "../client/config-ladder.js"
17
+ import { NativeCombinerConnection } from "./combiner-connection.js"
18
+ import { activateNativeTools } from "./tool-surface.js"
19
+ import { activateNativeResources } from "./resources.js"
20
+
21
+ export { NativeCombinerConnection } from "./combiner-connection.js"
22
+ export { activateNativeTools, type NativeToolSurfaceOptions } from "./tool-surface.js"
23
+ export { tokenedUrl } from "./combiner-connection.js"
24
+ export { activateNativeResources, filterInteractiveResources, type NativeResourceOptions } from "./resources.js"
25
+
26
+ export type NativeActivationOptions = {
27
+ connection: NativeCombinerConnection
28
+ directSpec?: string[] | "search"
29
+ serverFilter?: ServerFilter
30
+ /** Register the interactive read_<resource> tools (index.ts's exposeResources
31
+ * setting); default true. */
32
+ exposeResources?: boolean
33
+ /** Auto-open widget URLs (index.ts's uiAutoOpen setting); default true. */
34
+ uiAutoOpen?: boolean
35
+ /** Warn when the direct-exposure set grows large; settings kill-switch
36
+ * warnLargeDirectExposure disables it (default true, i.e. warn). */
37
+ warnLargeDirectExposure?: boolean
38
+ log: (level: "info" | "warn" | "error", message: string) => void
39
+ }
40
+
41
+ /** Register the declared tool surface + interactive-resource read_* tools + the
42
+ * list_changed re-registration handler. Called once at session_start; the
43
+ * list_changed hook re-syncs both surfaces on the same names. */
44
+ export function activateNativeMode(
45
+ pi: ExtensionAPI,
46
+ opts: NativeActivationOptions,
47
+ ): { run(): void; dispose(): void } {
48
+ const resourceRegistered = new Set<string>()
49
+ let active = true
50
+ const resourceOpts = {
51
+ connection: opts.connection,
52
+ serverFilter: opts.serverFilter,
53
+ uiAutoOpen: opts.uiAutoOpen,
54
+ log: opts.log,
55
+ }
56
+ const run = () => {
57
+ if (!active) return
58
+ void activateNativeTools(pi, {
59
+ connection: opts.connection,
60
+ serverFilter: opts.serverFilter,
61
+ directSpec: opts.directSpec,
62
+ warnLargeDirectExposure: opts.warnLargeDirectExposure,
63
+ log: opts.log,
64
+ }).catch((e) =>
65
+ opts.log("warn", `native tool surface failed: ${e instanceof Error ? e.message : String(e)}`),
66
+ )
67
+ if (opts.exposeResources !== false) {
68
+ void activateNativeResources(pi, resourceOpts, resourceRegistered).catch((e) =>
69
+ opts.log("warn", `native resource surface failed: ${e instanceof Error ? e.message : String(e)}`),
70
+ )
71
+ }
72
+ }
73
+ // The FIRST run is the caller's to schedule: the grouping token only exists
74
+ // after session_start (the connection refuses to connect without it), so a
75
+ // factory-time run() would fail with "no grouping token set" — the startup
76
+ // warnings this signature used to emit. index.ts calls run() at
77
+ // session_start (post-setToken) and from its own onToolsChanged hook; we
78
+ // deliberately do NOT setHooks here — a second setHooks call from the
79
+ // wiring would replace ours wholesale (last-wins), so the re-sync lives in
80
+ // ONE hook set, index.ts's.
81
+ return {
82
+ run,
83
+ dispose() {
84
+ active = false
85
+ },
86
+ }
87
+ }
88
+
89
+ // NOTE on codemode activation (verified live, pi 1.0.x): pi does NOT
90
+ // auto-activate the codemode tool for extension-registered codeme tools (only
91
+ // for connected MCP servers) — enable it with settings.json →
92
+ // "defaultTools": ["+codemode"]
93
+ // (defaultTools applies at a FRESH session's creation; resumed sessions keep
94
+ // their recorded tool set). With codemode on, our registered tools are
95
+ // first-class script members under their bare names (`tools.mock_echo(...)`),
96
+ // searchTools() discovers them (embedding their TS declarations), and
97
+ // pi's codemode globals are text()/image()/console.log/return (no emit).
98
+ // Also verified: registerTool accepts exposure/namespace/annotations, and
99
+ // the peer-dep import resolves from the host's installed package tree.
100
+ export type { ExtensionContext }