@geohar/pi-mcp-combiner 0.14.3 → 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 +3 -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
@@ -1,373 +0,0 @@
1
- // The combiner MCP client: one streamable-HTTP connection, one token, reconnect.
2
- //
3
- // This is the piece that makes the extension the adapter: it owns the Client +
4
- // StreamableHTTPClientTransport pair against the combiner's /mcp endpoint, mints the
5
- // per-Pi-session grouping token into the URL path (URL form beats headers combiner-
6
- // side, so the token rides the path), sends the optional inbound bearer, bridges
7
- // elicitation to Pi's UI, and keeps a tools/list cache fresh via tools/list_changed.
8
- //
9
- // Lifecycle: lazy by default (connect on first mcp() call), `eager` connects at
10
- // session_start. A session reset (new/resume/fork/reload) rebinds the token and
11
- // drops the client; a transport close or stale-session 404 triggers a one-shot
12
- // reconnect-and-retry on the next call. The combiner's handover means the same token
13
- // resumes its parked isolated upstream sessions after a combiner restart.
14
-
15
- import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
16
- import type { ElicitResult } from "@modelcontextprotocol/client"
17
- import { handleElicitation, type ElicitUi } from "./elicitation.js"
18
- import type { ServerFilter } from "./config-ladder.js"
19
-
20
- export type LogFn = (level: "info" | "warn" | "error", message: string) => void
21
-
22
- export type ResolvedConnection = {
23
- /** Base URL including /mcp, WITHOUT any token path (e.g. http://127.0.0.1:9741/mcp). */
24
- baseUrl: string
25
- /** Env var holding the inbound bearer token (read at connect time), if any. */
26
- bearerTokenEnv?: string
27
- /** Explicit token already present in the configured URL path (user override). */
28
- urlToken?: string
29
- }
30
-
31
- export type ConnectionState = "disconnected" | "connecting" | "connected" | "failed"
32
-
33
- export type ToolSummary = { name: string; description?: string; inputSchema?: unknown }
34
-
35
- export type PromptArgument = { name: string; description?: string; required?: boolean }
36
-
37
- export type PromptSummary = { name: string; title?: string; description?: string; arguments?: PromptArgument[] }
38
-
39
- export type ResourceSummary = {
40
- uri: string
41
- name?: string
42
- description?: string
43
- mimeType?: string
44
- _meta?: Record<string, unknown>
45
- }
46
-
47
- /** Optional callbacks fired by the connection (set from index.ts wiring). */
48
- export type ConnectionHooks = {
49
- /** tools/list_changed arrived — caches invalidated; refresh derived surfaces. */
50
- onToolsChanged?: () => void
51
- /** connection state transitioned — refresh footer/status surfaces. */
52
- onStateChange?: (state: ConnectionState) => void
53
- /** Stage 2: the combiner announced a widget UI URL while an agent tool call
54
- * is held in flight — the client should open it (gated by uiAutoOpen). */
55
- onWidgetUrl?: (url: string) => void
56
- }
57
-
58
- const CLIENT_INFO = { name: "pi-mcp-combiner", version: "0.1.0" }
59
- const CONNECT_TIMEOUT_MS = 8_000
60
-
61
- function isRecord(v: unknown): v is Record<string, unknown> {
62
- return typeof v === "object" && v !== null && !Array.isArray(v)
63
- }
64
-
65
- /** Pull {message, requestedSchema} out of an elicitation/create request params,
66
- * tolerating both form-mode and legacy shapes. Returns undefined when unusable. */
67
- function toElicitParams(raw: unknown): { message?: string; requestedSchema?: unknown } | undefined {
68
- const p = isRecord(raw) ? raw : undefined
69
- if (!p) return undefined
70
- const out: { message?: string; requestedSchema?: unknown } = {}
71
- if (typeof p.message === "string") out.message = p.message
72
- if (isRecord(p.requestedSchema)) out.requestedSchema = p.requestedSchema
73
- return out
74
- }
75
-
76
- /** Build the tokened endpoint URL: <origin>/mcp/<token> (combiner's URL form). */
77
- export function tokenedUrl(baseUrl: string, token: string): string {
78
- try {
79
- const u = new URL(baseUrl)
80
- // Normalize to bare /mcp (strip any existing path segments below it), append token.
81
- const path = u.pathname.replace(/\/mcp(?:\/[^/]*)*\/?$/, "/mcp")
82
- u.pathname = `${path.replace(/\/$/, "")}/${encodeURIComponent(token)}`
83
- return u.toString()
84
- } catch {
85
- return baseUrl
86
- }
87
- }
88
-
89
- export class CombinerConnection {
90
- private conn: ResolvedConnection
91
- private readonly log: LogFn
92
- /** Latest dialog-capable UI, refreshed each session_start. */
93
- private elicitUi: ElicitUi = { hasUI: false }
94
- private prompts: PromptSummary[] | undefined
95
- private promptsFetchedAt = 0
96
- private resources: ResourceSummary[] | undefined
97
- private resourcesFetchedAt = 0
98
- private token: string | undefined
99
- private client: Client | undefined
100
- private connecting: Promise<Client> | undefined
101
- private tools: ToolSummary[] | undefined
102
- private toolsFetchedAt = 0
103
- private hooks: ConnectionHooks = {}
104
- state: ConnectionState = "disconnected"
105
- lastError: string | undefined
106
-
107
- constructor(conn: ResolvedConnection, log: LogFn) {
108
- this.conn = conn
109
- this.log = log
110
- }
111
-
112
- /** Bind lifecycle hooks (tools changed / state transitions). */
113
- setHooks(hooks: ConnectionHooks): void {
114
- this.hooks = hooks
115
- }
116
-
117
- /** Effective grouping token for this session (explicit URL token wins). */
118
- get sessionToken(): string | undefined {
119
- return this.effectiveToken()
120
- }
121
-
122
- /** Bind the UI slice used by elicitation (called from session_start). */
123
- bindUi(ui: ElicitUi): void {
124
- this.elicitUi = ui
125
- }
126
-
127
- /** Swap connection inputs (base URL / bearer env) when a session re-resolves a
128
- * different combiner URL (e.g. a worktree branch pinning its own combiner).
129
- * No-op when nothing changed; otherwise drops the client for a fresh connect. */
130
- rebind(conn: ResolvedConnection): boolean {
131
- if (
132
- conn.baseUrl === this.conn.baseUrl &&
133
- conn.bearerTokenEnv === this.conn.bearerTokenEnv &&
134
- conn.urlToken === this.conn.urlToken
135
- ) {
136
- return false
137
- }
138
- this.conn = conn
139
- void this.reset("connection rebound (session config changed)")
140
- return true
141
- }
142
-
143
- /** Set the grouping token for this session; resets any open connection. */
144
- setToken(token: string): void {
145
- if (this.token === token) return
146
- this.token = token
147
- void this.reset("token changed")
148
- }
149
-
150
- /** Drop the connection + caches (session reset / shutdown). Never throws. */
151
- async reset(reason: string): Promise<void> {
152
- const client = this.client
153
- this.client = undefined
154
- this.connecting = undefined
155
- this.tools = undefined
156
- this.state = "disconnected"
157
- try {
158
- await client?.close()
159
- } catch {
160
- // best-effort
161
- }
162
- if (reason) this.log("info", `connection reset (${reason})`)
163
- }
164
-
165
- /** Effective token: explicit URL token wins over the minted session token. */
166
- private effectiveToken(): string | undefined {
167
- return this.conn.urlToken ?? this.token
168
- }
169
-
170
- /** Connect (idempotent, single-flight). Throws with a human message on failure. */
171
- async ensureConnected(): Promise<Client> {
172
- if (this.client) return this.client
173
- if (this.connecting) return this.connecting
174
-
175
- const token = this.effectiveToken()
176
- if (!token) throw new Error("no grouping token set (session not started)")
177
- const url = tokenedUrl(this.conn.baseUrl, token)
178
- let endpoint: URL
179
- try {
180
- endpoint = new URL(url)
181
- } catch {
182
- throw new Error(`combiner URL "${url}" is not parseable; set a valid url (…/mcp) in settings or mcp.json`)
183
- }
184
- const bearer = this.conn.bearerTokenEnv ? process.env[this.conn.bearerTokenEnv] : undefined
185
-
186
- this.state = "connecting"
187
- this.connecting = (async () => {
188
- const client = new Client(CLIENT_INFO, { capabilities: { elicitation: {} } })
189
- const transport = new StreamableHTTPClientTransport(endpoint, {
190
- authProvider: bearer ? { token: async () => bearer } : undefined,
191
- })
192
- // Elicitation bridge — the combiner's permission gate depends on it.
193
- client.setRequestHandler("elicitation/create", (req): ElicitResult | Promise<ElicitResult> => {
194
- const params = toElicitParams(isRecord(req) ? req.params : undefined)
195
- if (!params) return { action: "decline" }
196
- return handleElicitation(params, this.elicitUi)
197
- })
198
- client.setNotificationHandler("notifications/tools/list_changed", () => {
199
- this.tools = undefined // refetch on next list
200
- this.prompts = undefined
201
- this.resources = undefined
202
- try {
203
- this.hooks.onToolsChanged?.()
204
- } catch {
205
- // hook errors must never break the notification path
206
- }
207
- })
208
- client.setNotificationHandler("notifications/resources/list_changed", () => {
209
- this.resources = undefined
210
- try {
211
- this.hooks.onToolsChanged?.()
212
- } catch {
213
- // hook errors must never break the notification path
214
- }
215
- })
216
- // Stage 2: the widget hold announces its UI URL via a log
217
- // notification ("Interactive UI ready: <url>") while the agent's
218
- // call is in flight — open it so the user sees the widget at once.
219
- client.setNotificationHandler("notifications/message", (n) => {
220
- const data = (n.params as { data?: unknown } | undefined)?.data
221
- if (typeof data !== "string") return
222
- const m = data.match(/Interactive UI ready: (\S+)/)
223
- if (m) {
224
- try {
225
- this.hooks.onWidgetUrl?.(m[1])
226
- } catch {
227
- // hook errors must never break the notification path
228
- }
229
- }
230
- })
231
- client.onclose = () => {
232
- if (this.client === client) {
233
- this.client = undefined
234
- this.state = "disconnected"
235
- }
236
- }
237
-
238
- await Promise.race([
239
- client.connect(transport),
240
- new Promise((_, reject) => setTimeout(() => reject(new Error("connect timeout")), CONNECT_TIMEOUT_MS)),
241
- ])
242
-
243
- this.client = client
244
- this.state = "connected"
245
- this.lastError = undefined
246
- this.hooks.onStateChange?.(this.state)
247
- return client
248
- })()
249
-
250
- try {
251
- return await this.connecting
252
- } catch (e) {
253
- this.state = "failed"
254
- this.lastError = e instanceof Error ? e.message : String(e)
255
- this.log("warn", `combiner connect failed (${this.lastError})`)
256
- this.hooks.onStateChange?.(this.state)
257
- throw new Error(`cannot reach the combiner at ${this.conn.baseUrl}: ${this.lastError}`)
258
- } finally {
259
- this.connecting = undefined
260
- }
261
- }
262
-
263
- /** tools/list, cached until invalidated (list_changed / reset / reconnect). */
264
- async listTools(force = false): Promise<ToolSummary[]> {
265
- if (!force && this.tools && Date.now() - this.toolsFetchedAt < 5_000) return this.tools
266
- const client = await this.ensureConnected()
267
- const res = await client.listTools()
268
- this.tools = (res.tools ?? []) as ToolSummary[]
269
- this.toolsFetchedAt = Date.now()
270
- return this.tools
271
- }
272
-
273
- /** prompts/list, cached alongside tools (combiner namespaces `<server>_<name>`). */
274
- async listPrompts(force = false): Promise<PromptSummary[]> {
275
- if (!force && this.prompts && Date.now() - this.promptsFetchedAt < 5_000) return this.prompts
276
- const client = await this.ensureConnected()
277
- const res = await client.listPrompts()
278
- this.prompts = (res.prompts ?? []) as PromptSummary[]
279
- this.promptsFetchedAt = Date.now()
280
- return this.prompts
281
- }
282
-
283
- /** prompts/get through the combiner. */
284
- async getPrompt(name: string, args: Record<string, string>): Promise<unknown> {
285
- const client = await this.ensureConnected()
286
- return client.getPrompt({ name, arguments: args })
287
- }
288
-
289
- /** resources/list, cached alongside tools/prompts. */
290
- async listResources(force = false): Promise<ResourceSummary[]> {
291
- if (!force && this.resources && Date.now() - this.resourcesFetchedAt < 5_000) return this.resources
292
- const client = await this.ensureConnected()
293
- const res = await client.listResources()
294
- this.resources = (res.resources ?? []) as ResourceSummary[]
295
- this.resourcesFetchedAt = Date.now()
296
- return this.resources
297
- }
298
-
299
- /** resources/read through the combiner. */
300
- async readResource(uri: string): Promise<unknown> {
301
- const client = await this.ensureConnected()
302
- return client.readResource({ uri })
303
- }
304
-
305
- /** callTool with a single stale-session retry: on transport/stale errors we reset
306
- * and reconnect once (the combiner may have bounced; handover keeps our token). */
307
- async callTool(name: string, args: Record<string, unknown> | undefined): Promise<unknown> {
308
- const attempt = async (): Promise<unknown> => {
309
- const client = await this.ensureConnected()
310
- return client.callTool({ name, arguments: args ?? {} })
311
- }
312
- try {
313
- return await attempt()
314
- } catch (e) {
315
- const msg = e instanceof Error ? e.message : String(e)
316
- const stale = /404|stale|session|not found|closed|fetch failed/i.test(msg)
317
- if (!stale) throw e
318
- this.log("info", `call "${name}" went stale (${msg}); reconnecting once`)
319
- await this.reset("stale session")
320
- return await attempt()
321
- }
322
- }
323
-
324
- /** Control-plane origin for the combiner's REST routes (/health, /sessions/*). */
325
- controlOrigin(): string {
326
- try {
327
- return new URL(this.conn.baseUrl).origin
328
- } catch {
329
- return this.conn.baseUrl
330
- }
331
- }
332
-
333
- /** The interactive-UI URL for a resource (the combiner's UI host serves the
334
- * widget for THIS session's token), or empty when no token is set. */
335
- uiUrlFor(resourceUri: string): string {
336
- const token = this.effectiveToken()
337
- if (!token || !resourceUri) return ""
338
- return `${this.controlOrigin()}/ui/${encodeURIComponent(token)}/?resource=${encodeURIComponent(resourceUri)}`
339
- }
340
-
341
- /** Headers for control-plane calls: the same inbound bearer as /mcp (the combiner
342
- * gates its mutating routes — /sessions*, /handover* — with it; /health is open
343
- * but presenting anyway is harmless). */
344
- private controlHeaders(): Record<string, string> {
345
- const token = this.conn.bearerTokenEnv ? process.env[this.conn.bearerTokenEnv] : undefined
346
- return token ? { authorization: `Bearer ${token}` } : {}
347
- }
348
-
349
- /** POST the project-derived server filter for our token. The combiner accepts
350
- * pending filters pre-connect, so this is safe before the first connect. */
351
- async applyFilter(filter: ServerFilter): Promise<void> {
352
- const token = this.effectiveToken()
353
- if (!token) throw new Error("no grouping token set (session not started)")
354
- const body: Record<string, unknown> = {}
355
- if (filter.allow?.length) body.allowed_servers = filter.allow
356
- else if (filter.deny?.length) body.disabled_servers = filter.deny
357
- else return
358
- const res = await fetch(`${this.controlOrigin()}/sessions/token/${encodeURIComponent(token)}/filter`, {
359
- method: "POST",
360
- headers: { "content-type": "application/json", ...this.controlHeaders() },
361
- body: JSON.stringify(body),
362
- })
363
- if (!res.ok) throw new Error(`filter apply failed: ${res.status} ${await res.text().catch(() => "")}`)
364
- this.log("info", `session server filter applied (${filter.allow ? "allow" : "deny"}-list)`)
365
- }
366
-
367
- /** GET /health — the control-plane health snapshot. */
368
- async health(): Promise<unknown> {
369
- const res = await fetch(`${this.controlOrigin()}/health`, { headers: this.controlHeaders() })
370
- if (!res.ok) throw new Error(`/health returned ${res.status}`)
371
- return res.json()
372
- }
373
- }
@@ -1,146 +0,0 @@
1
- // Direct tool promotion: allowlist + search-mode, per the agreed design
2
- // (docs/adapter-design.md §Now/Later). Behavior follows pi-mcp-adapter's
3
- // directTools modes (MIT, © 2026 Nico Bailon).
4
- //
5
- // `combiner.directTools` on the shared-file entry is either a glob allowlist over
6
- // combined tool names — registered once at session start, cache-stable — or
7
- // "search": nothing registered upfront; the first mcp({search}) match activates
8
- // those tools as first-class Pi tools for the rest of the session (the adapter's
9
- // directTools:"search" semantics, on our own mid-session registerTool path).
10
- //
11
- // `directTools: true` is deliberately NOT offered: at combiner scale (hundreds of
12
- // tools) it burns the context window the proxy exists to save. Allowlists larger
13
- // than ~50 tools draw a loud warning and still register — an explicit list is the
14
- // user's call.
15
-
16
- import type { ToolDefinition } from "../pi.js"
17
- import type { CombinerConnection, ToolSummary } from "./connection.js"
18
- import { renderToolResult, textResult } from "./render.js"
19
- import { directToolRenderers } from "./renderers.js"
20
-
21
- export type DirectToolsSpec = string[] | "search"
22
-
23
- /** Names that must never be shadowed by a promoted tool (pi builtins). Callers add
24
- * their own tool names on top. */
25
- export const RESERVED_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls", "mcp", "glob"] as const
26
-
27
- /** Apply a server allow/deny filter to a combined tool list. Server names may
28
- * contain underscores (gws_georgeharker), so matching is by PREFIX — a tool
29
- * belongs to entry E iff its name starts `E_` (subsumes first-segment equality). */
30
- export function toolServerMatches(toolName: string, serverEntry: string): boolean {
31
- return toolName.startsWith(`${serverEntry}_`)
32
- }
33
-
34
- /** Apply a server allow/deny filter to a combined tool list (`<server>_` prefix,
35
- * underscore-safe via toolServerMatches). */
36
- export function applyServerFilter(
37
- tools: ToolSummary[],
38
- filter: { allow?: string[]; deny?: string[] } | undefined,
39
- ): ToolSummary[] {
40
- if (!filter) return tools
41
- if (filter.allow?.length) {
42
- return tools.filter((t) => filter.allow!.some((e) => toolServerMatches(t.name, e)))
43
- }
44
- if (filter.deny?.length) {
45
- return tools.filter((t) => !filter.deny!.some((e) => toolServerMatches(t.name, e)))
46
- }
47
- return tools
48
- }
49
-
50
- /** fnmatch-lite: `*` → any run, `?` → one char, everything else literal. */
51
- export function globToRegExp(pattern: string): RegExp {
52
- const escaped = pattern
53
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
54
- .replace(/\*/g, ".*")
55
- .replace(/\?/g, ".")
56
- return new RegExp(`^${escaped}$`, "i")
57
- }
58
-
59
- export function matchesGlob(name: string, pattern: string): boolean {
60
- return globToRegExp(pattern).test(name)
61
- }
62
-
63
- /** Resolve the allowlist spec against the combined tool list (server filter applied
64
- * by the caller). Returns matching tools in list order. */
65
- export function resolveAllowlist(tools: ToolSummary[], patterns: string[]): ToolSummary[] {
66
- const regexes = patterns.map(globToRegExp)
67
- return tools.filter((t) => regexes.some((re) => re.test(t.name)))
68
- }
69
-
70
- /** Ensure a usable parameters object: pass the advertised JSON schema through when it
71
- * is an object; repair anything else to an empty object schema. */
72
- function normalizeInputSchema(schema: unknown): Record<string, unknown> {
73
- if (typeof schema === "object" && schema !== null && !Array.isArray(schema)) {
74
- return schema as Record<string, unknown>
75
- }
76
- return { type: "object", properties: {} }
77
- }
78
-
79
- export type DirectToolDeps = {
80
- connection: CombinerConnection
81
- /** Names already registered (shared across syncs + search activation). */
82
- registered: Set<string>
83
- /** Reserved names to refuse (builtins + this extension's own tool names). */
84
- reserved: Set<string>
85
- register: (tool: ToolDefinition) => void
86
- log: (level: "info" | "warn" | "error", message: string) => void
87
- }
88
-
89
- /** Build the Pi tool definition for one promoted combiner tool. */
90
- export function buildDirectTool(tool: ToolSummary, deps: DirectToolDeps): ToolDefinition {
91
- return {
92
- name: tool.name,
93
- label: `MCP: ${tool.name}`,
94
- description: tool.description ?? `Combiner tool ${tool.name}`,
95
- parameters: normalizeInputSchema(tool.inputSchema),
96
- ...directToolRenderers(tool.name),
97
- execute: async () => {
98
- const result = await deps.connection.callTool(tool.name, undefined)
99
- const text = renderToolResult(result)
100
- if ((result as { isError?: boolean })?.isError) throw new Error(text)
101
- return textResult(text)
102
- },
103
- }
104
- }
105
-
106
- /** Register one tool if new and not reserved. Returns the name, or undefined when skipped. */
107
- export function registerDirectTool(tool: ToolSummary, deps: DirectToolDeps): string | undefined {
108
- if (deps.registered.has(tool.name)) return undefined
109
- if (deps.reserved.has(tool.name)) {
110
- deps.log("warn", `directTools: refusing to register "${tool.name}" — reserved name`)
111
- deps.registered.add(tool.name) // don't re-warn on every sync
112
- return undefined
113
- }
114
- deps.registered.add(tool.name)
115
- deps.register(buildDirectTool(tool, deps))
116
- return tool.name
117
- }
118
-
119
- /** Allowlist sync: register every matching, not-yet-registered tool. */
120
- export function syncAllowlistTools(tools: ToolSummary[], patterns: string[], deps: DirectToolDeps): number {
121
- const matches = resolveAllowlist(tools, patterns)
122
- if (matches.length > 50) {
123
- deps.log(
124
- "warn",
125
- `directTools: allowlist promotes ${matches.length} tools — every schema rides in every ` +
126
- "request. Consider trimming (the mcp() proxy covers the rest).",
127
- )
128
- }
129
- let added = 0
130
- for (const tool of matches) {
131
- if (registerDirectTool(tool, deps)) added++
132
- }
133
- return added
134
- }
135
-
136
- /** Search-mode activation: register matched tools (bounded per activation). Returns
137
- * the newly activated names — empty when everything matched was already live. */
138
- export function activateFromSearch(matches: ToolSummary[], deps: DirectToolDeps, max = 10): string[] {
139
- const activated: string[] = []
140
- for (const tool of matches) {
141
- if (activated.length >= max) break
142
- if (deps.registered.has(tool.name)) continue
143
- if (registerDirectTool(tool, deps)) activated.push(tool.name)
144
- }
145
- return activated
146
- }
@@ -1,137 +0,0 @@
1
- // MCP resources → read_* Pi tools, compatible with pi-mcp-adapter's conventions.
2
- //
3
- // A resource is server-exposed content addressed by URI (the GET to tools' POST);
4
- // each becomes a zero-parameter tool the model can call directly, named
5
- // `read_<sanitized>` (resourceNameToToolName ported verbatim). Reads go through the
6
- // combiner's proxied resources/read; text is guarded, binaries noted by size.
7
- //
8
- // Interactive mcp-app resources (mimeType `text/html;profile=mcp-app`, `ui://` URIs)
9
- // are flagged in the tool description — read_* returns their markup as text; the
10
- // interactive browser experience is the ext-apps host (later, see adapter-design.md).
11
-
12
- import type { ToolDefinition } from "../pi.js"
13
- import type { CombinerConnection, ResourceSummary } from "./connection.js"
14
- import type { ServerFilter } from "./config-ladder.js"
15
- import { renderResourceResult, textResult, toolUiResourceUri } from "./render.js"
16
- import { openInBrowser } from "./proxy-tool.js"
17
- import { callRenderer, resultRenderer } from "./renderers.js"
18
-
19
- /** Ported verbatim from pi-mcp-adapter's resource-tools.ts (MIT, © 2026 Nico Bailon). */
20
- export function resourceNameToToolName(name: string): string {
21
- let result = name
22
- .replace(/[^a-zA-Z0-9]/g, "_")
23
- .replace(/_+/g, "_")
24
- .replace(/^_+/, "") // Remove leading underscores
25
- .replace(/_+$/, "") // Remove trailing underscores
26
- .toLowerCase()
27
-
28
- // Ensure we have a valid name
29
- if (!result || /^\d/.test(result)) {
30
- result = "resource" + (result ? "_" + result : "")
31
- }
32
-
33
- return result
34
- }
35
-
36
- export const MCP_APP_MIME = "text/html;profile=mcp-app"
37
-
38
- /** True for interactive ext-apps resources: read_* returns their markup AND
39
- * surfaces the combiner UI-host URL (which serves the live widget). */
40
- export function isInteractiveResource(r: ResourceSummary): boolean {
41
- return r.mimeType === MCP_APP_MIME || r.uri.startsWith("ui://")
42
- }
43
-
44
- /** Attribute a resource to an upstream server for filtering: ui:// URIs carry the
45
- * server as the host component (ui://todoist/…); otherwise fall back to the name's
46
- * leading word before a dash/underscore. */
47
- export function resourceServer(r: ResourceSummary): string | undefined {
48
- const m = r.uri.match(/^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i)
49
- if (m?.[1]) return m[1].split(".")[0]?.toLowerCase()
50
- const name = r.name ?? r.uri
51
- const head = name.split(/[-_]/, 1)[0]
52
- return head ? head.toLowerCase() : undefined
53
- }
54
-
55
- /** Apply the project server filter to a resource list (mirrors tool filtering;
56
- * attribution by uri host, with `_`/`-` name-prefix fallback for underscore-named
57
- * servers whose resources carry the full prefixed name). */
58
- export function filterResources(resources: ResourceSummary[], filter: ServerFilter | undefined): ResourceSummary[] {
59
- if (!filter) return resources
60
- const matches = (r: ResourceSummary, entry: string): boolean =>
61
- resourceServer(r) === entry || (r.name ?? "").startsWith(`${entry}_`) || (r.name ?? "").startsWith(`${entry}-`)
62
- if (filter.allow?.length) {
63
- return resources.filter((r) => filter.allow!.some((e) => matches(r, e)))
64
- }
65
- if (filter.deny?.length) {
66
- return resources.filter((r) => !filter.deny!.some((e) => matches(r, e)))
67
- }
68
- return resources
69
- }
70
-
71
- /** Discover resources and register one read_* tool each. Idempotent; returns count. */
72
- export async function syncResourceTools(
73
- register: (tool: ToolDefinition) => void,
74
- connection: CombinerConnection,
75
- registered: Set<string>,
76
- filter: ServerFilter | undefined,
77
- notify: (message: string, level: "info" | "warn" | "error") => void,
78
- ): Promise<number> {
79
- let resources: ResourceSummary[] = []
80
- try {
81
- resources = filterResources(await connection.listResources(), filter)
82
- } catch (e) {
83
- notify(`resource discovery failed (${e instanceof Error ? e.message : String(e)})`, "warn")
84
- return 0
85
- }
86
- for (const resource of resources) {
87
- const label = resource.name ?? resource.uri
88
- const toolName = `read_${resourceNameToToolName(label)}`
89
- if (registered.has(toolName)) continue
90
- registered.add(toolName)
91
- const interactive = isInteractiveResource(resource)
92
- const description = [
93
- resource.description ?? `Read MCP resource ${resource.uri}`,
94
- `uri: ${resource.uri}`,
95
- resource.mimeType ? `type: ${resource.mimeType}` : undefined,
96
- interactive
97
- ? "interactive app resource — markup as text; the interactive widget opens from the URL appended to results"
98
- : undefined,
99
- ]
100
- .filter(Boolean)
101
- .join(" | ")
102
- register({
103
- name: toolName,
104
- label: `MCP resource: ${label}`,
105
- description,
106
- promptSnippet: `Read MCP resource ${label}.`,
107
- parameters: { type: "object", properties: {} },
108
- renderCall: callRenderer(toolName, () => resource.uri),
109
- renderResult: resultRenderer(() => toolName, toolName),
110
- execute: async (
111
- _toolCallId: string,
112
- _params: Record<string, unknown>,
113
- _signal: AbortSignal | undefined,
114
- _onUpdate: unknown,
115
- ctx: { hasUI: boolean } | undefined,
116
- ) => {
117
- try {
118
- const result = await connection.readResource(resource.uri)
119
- let text = renderResourceResult(result)
120
- // Interactive resources: the combiner's UI host serves the
121
- // widget for this session's token — surface + auto-open.
122
- if (interactive) {
123
- const url = connection.uiUrlFor(resource.uri)
124
- if (url) {
125
- text = `${text}\n\ninteractive: ${url}`
126
- if (ctx?.hasUI) openInBrowser(url)
127
- }
128
- }
129
- return textResult(text, { mode: "resource", uri: resource.uri })
130
- } catch (e) {
131
- throw new Error(`${toolName}: resource read failed (${e instanceof Error ? e.message : String(e)})`)
132
- }
133
- },
134
- })
135
- }
136
- return resources.length
137
- }