@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.
- 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 +6 -5
- 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
package/src/client/connection.ts
DELETED
|
@@ -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
|
-
}
|
package/src/client/resources.ts
DELETED
|
@@ -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
|
-
}
|