@agimon-ai/po-mcp 0.0.0-alpha.0

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/dist/apiContracts.cjs +269 -0
  4. package/dist/apiContracts.cjs.map +1 -0
  5. package/dist/apiContracts.d.mts +137 -0
  6. package/dist/apiContracts.d.mts.map +1 -0
  7. package/dist/apiContracts.mjs +258 -0
  8. package/dist/apiContracts.mjs.map +1 -0
  9. package/dist/extensions/mcp-ui.mjs +31 -0
  10. package/dist/extensions/mcp-ui.mjs.map +1 -0
  11. package/dist/extensions/mcp.cjs +104 -0
  12. package/dist/extensions/mcp.cjs.map +1 -0
  13. package/dist/extensions/mcp.d.mts +5 -0
  14. package/dist/extensions/mcp.d.mts.map +1 -0
  15. package/dist/extensions/mcp.mjs +99 -0
  16. package/dist/extensions/mcp.mjs.map +1 -0
  17. package/dist/extensions/server.cjs +2852 -0
  18. package/dist/extensions/server.cjs.map +1 -0
  19. package/dist/extensions/server.d.mts +5 -0
  20. package/dist/extensions/server.d.mts.map +1 -0
  21. package/dist/extensions/server.mjs +2839 -0
  22. package/dist/extensions/server.mjs.map +1 -0
  23. package/dist/extensions/web.mjs +2200 -0
  24. package/dist/extensions/web.mjs.map +1 -0
  25. package/dist/index.cjs +18 -0
  26. package/dist/index.cjs.map +1 -0
  27. package/dist/index.d.mts +89 -0
  28. package/dist/index.d.mts.map +1 -0
  29. package/dist/index.mjs +9 -0
  30. package/dist/index.mjs.map +1 -0
  31. package/dist/mcpCatalog-6Tpt6Gqn.cjs +900 -0
  32. package/dist/mcpCatalog-6Tpt6Gqn.cjs.map +1 -0
  33. package/dist/mcpCatalog-SlqWS-sb.mjs +806 -0
  34. package/dist/mcpCatalog-SlqWS-sb.mjs.map +1 -0
  35. package/dist/mcpConfig-BYESy-zP.d.mts +57 -0
  36. package/dist/mcpConfig-BYESy-zP.d.mts.map +1 -0
  37. package/dist/projection-gyAA2jRB.mjs +43 -0
  38. package/dist/projection-gyAA2jRB.mjs.map +1 -0
  39. package/dist/projection-w3XxXmVi.cjs +51 -0
  40. package/dist/projection-w3XxXmVi.cjs.map +1 -0
  41. package/dist/projection.cjs +17 -0
  42. package/dist/projection.d.mts +12 -0
  43. package/dist/projection.d.mts.map +1 -0
  44. package/dist/projection.mjs +4 -0
  45. package/dist/rolldown-runtime-C0BPl7ul.cjs +43 -0
  46. package/llms.txt +9 -0
  47. package/package.json +157 -0
  48. package/src/prompts/po-use-mcp/SKILL.md +79 -0
@@ -0,0 +1,2839 @@
1
+ import { _ as buildMcpConfigGroups, a as normalizeMcpAppMetadata, d as McpRuntimeOwner, h as readCachedCatalog, i as mcpToolExecutionMetadata, l as readSessionConfig, n as isAppVisibleMcpTool, o as toPiToolName, p as hubCallbackSink, r as isModelVisibleMcpTool, s as DIRECT_TOOLS_ENV, t as McpCatalog, v as __exportAll } from "../mcpCatalog-SlqWS-sb.mjs";
2
+ import { t as mcpSessionConfigFromProjection } from "../projection-gyAA2jRB.mjs";
3
+ import { randomUUID } from "node:crypto";
4
+ import fs, { constants } from "node:fs";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import { DOOM_MCP_PROJECTION_RESOLVER_SERVICE, isDoomMcpProjection } from "@agimon-ai/po-core/mcpProjection";
8
+ import { composeServerProviderOverrides, defineServerMethod, defineServerPlugin, readPackageResource, writeSessionAttachment } from "@agimon-ai/po-core/serverFacet";
9
+ import { composeRootHooks, defineResource, defineRoot, defineRoute, defineRoutedContribution, defineServerCommand } from "@agimon-ai/po-core/extensionFile";
10
+ import { DOOM_CHILD_SESSION_MCP_TOOL_SERVICE } from "@agimon-ai/po-core/childSession";
11
+ import { DOOM_HEADLESS_HOST_SERVICE } from "@agimon-ai/po-core/headless";
12
+ import { DOOM_MCP_STATUS_SERVICE } from "@agimon-ai/po-core/mcpStatus";
13
+ import { DOOM_MCP_TOOL_RESOLVER_SERVICE } from "@agimon-ai/po-core/mcpToolResolver";
14
+ import { Value } from "typebox/value";
15
+ import { Type } from "typebox";
16
+ import dns from "node:dns/promises";
17
+ import https from "node:https";
18
+ import net from "node:net";
19
+ import fs$1 from "node:fs/promises";
20
+ import { defineDoomPluginMethod } from "@agimon-ai/po-core/pluginProtocol";
21
+ import { loadHarnessState } from "@agimon-ai/po-config/harnessStore";
22
+ import { CONTEXT_TOOL_WARNINGS_STATUS_KEY } from "@agimon-ai/po-core/contextApi";
23
+ import { FileTokenStore } from "@agimon-ai/mcp-proxy";
24
+ import { apiResponse, defineApiRoutes } from "@agimon-ai/po-core/web";
25
+ import { DOOM_HELP_WHEN } from "@agimon-ai/po-core/help";
26
+ //#region src/schemas/fileTransfer.ts
27
+ const ShareFileParameters = Type.Object({ path: Type.String({ minLength: 1 }) }, { additionalProperties: false });
28
+ const SaveFileParameters = Type.Object({
29
+ file: Type.Union([Type.Object({
30
+ download_url: Type.String(),
31
+ file_name: Type.String(),
32
+ file_id: Type.Optional(Type.String()),
33
+ mime_type: Type.Optional(Type.String())
34
+ }, { additionalProperties: false }), Type.Object({
35
+ name: Type.String(),
36
+ data_base64: Type.String()
37
+ }, { additionalProperties: false })]),
38
+ path: Type.Optional(Type.String())
39
+ }, { additionalProperties: false });
40
+ //#endregion
41
+ //#region src/services/fileTransfer/download.ts
42
+ const MAX_BYTES = 26214400;
43
+ /** Conservative public-address allowlist. IPv6 is limited to global unicast, excluding special ranges. */
44
+ function isPublicAddress(address) {
45
+ if (net.isIP(address) === 4) {
46
+ const [a, b] = address.split(".").map(Number);
47
+ return !(a === 0 || a === 10 || a === 127 || a >= 224 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && (b === 168 || b === 0 || b === 2) || a === 198 && (b === 18 || b === 19 || b === 51) || a === 203 && b === 0);
48
+ }
49
+ return net.isIP(address) === 6 && /^[23]/u.test(address) && !/^(2001:|2002:|3fff:)/iu.test(address);
50
+ }
51
+ async function downloadPublicFile(input, cancellation) {
52
+ const signal = AbortSignal.any([AbortSignal.timeout(3e4), ...cancellation ? [cancellation] : []]);
53
+ let url = new URL(input);
54
+ for (let redirects = 0;; redirects += 1) {
55
+ signal.throwIfAborted();
56
+ if (url.protocol !== "https:" || url.username || url.password) throw new Error("Only public HTTPS URLs without credentials are accepted.");
57
+ const hostname = url.hostname.replace(/^\[|\]$/gu, "");
58
+ const addresses = net.isIP(hostname) ? [{
59
+ address: hostname,
60
+ family: net.isIP(hostname)
61
+ }] : await new Promise((resolve, reject) => {
62
+ const abort = () => reject(signal.reason);
63
+ signal.addEventListener("abort", abort, { once: true });
64
+ dns.lookup(hostname, { all: true }).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
65
+ if (signal.aborted) abort();
66
+ });
67
+ signal.throwIfAborted();
68
+ if (!addresses.length || addresses.some(({ address }) => !isPublicAddress(address))) throw new Error("URL must resolve only to public addresses.");
69
+ const pinned = addresses[0];
70
+ const result = await new Promise((resolve, reject) => {
71
+ https.get(url, {
72
+ signal,
73
+ agent: false,
74
+ lookup: (_host, options, callback) => {
75
+ if (typeof options === "object" && options.all) callback(null, [pinned]);
76
+ else callback(null, pinned.address, pinned.family);
77
+ }
78
+ }, (response) => {
79
+ const status = response.statusCode ?? 0;
80
+ if ([
81
+ 301,
82
+ 302,
83
+ 303,
84
+ 307,
85
+ 308
86
+ ].includes(status)) {
87
+ response.destroy();
88
+ if (redirects >= 3 || !response.headers.location) reject(/* @__PURE__ */ new Error("Too many redirects or missing redirect location."));
89
+ else try {
90
+ resolve(new URL(response.headers.location, url));
91
+ } catch (error) {
92
+ reject(error);
93
+ }
94
+ return;
95
+ }
96
+ if (status !== 200 || Number(response.headers["content-length"]) > MAX_BYTES) {
97
+ response.destroy();
98
+ reject(/* @__PURE__ */ new Error("Download refused: status or size limit."));
99
+ return;
100
+ }
101
+ const chunks = [];
102
+ let size = 0;
103
+ response.on("data", (chunk) => {
104
+ size += chunk.length;
105
+ if (size > MAX_BYTES) response.destroy(/* @__PURE__ */ new Error("Download exceeds 25 MiB."));
106
+ else chunks.push(chunk);
107
+ });
108
+ response.on("error", reject);
109
+ response.on("aborted", () => reject(/* @__PURE__ */ new Error("Download response was interrupted.")));
110
+ response.on("close", () => {
111
+ if (!response.complete) reject(/* @__PURE__ */ new Error("Download response was interrupted."));
112
+ });
113
+ response.on("end", () => resolve(Buffer.concat(chunks, size)));
114
+ }).on("error", reject);
115
+ });
116
+ if (Buffer.isBuffer(result)) return result;
117
+ url = result;
118
+ }
119
+ }
120
+ function decodeFileBase64(data) {
121
+ if (data.length > 4 * Math.ceil(5242880 / 3) || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(data)) throw new Error("Invalid or oversized base64 file.");
122
+ const size = data.length / 4 * 3 - (data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0);
123
+ if (size < 0 || data.includes("=") && data.length < 4) throw new Error("Invalid base64 padding.");
124
+ if (size > 5242880) throw new Error("Base64 file exceeds 5 MiB.");
125
+ return Buffer.from(data, "base64");
126
+ }
127
+ //#endregion
128
+ //#region src/services/fileTransfer/storage.ts
129
+ /** Existing parents only, pinned by identity before and after exclusive creation. */
130
+ async function writeCwdFile(cwd, relative, bytes) {
131
+ if (!relative || path.isAbsolute(relative) || relative.includes("\\") || relative.includes("\0") || relative.split("/").some((part) => !part || part === "." || part === "..")) throw new Error("Path must be relative without traversal.");
132
+ const root = await fs$1.realpath(cwd);
133
+ const target = path.join(root, relative);
134
+ const parents = [root];
135
+ for (const segment of relative.split("/").slice(0, -1)) parents.push(path.join(parents.at(-1), segment));
136
+ const identities = await Promise.all(parents.map(async (parent) => {
137
+ const stat = await fs$1.lstat(parent);
138
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await fs$1.realpath(parent) !== parent) throw new Error("Unsafe parent directory.");
139
+ return stat;
140
+ }));
141
+ const check = async () => {
142
+ for (let i = 0; i < parents.length; i += 1) {
143
+ const stat = await fs$1.lstat(parents[i]);
144
+ if (stat.dev !== identities[i].dev || stat.ino !== identities[i].ino || await fs$1.realpath(parents[i]) !== parents[i]) throw new Error("Parent directory changed.");
145
+ }
146
+ };
147
+ await check();
148
+ const handle = await fs$1.open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
149
+ try {
150
+ const identity = await handle.stat();
151
+ const checkFile = async () => {
152
+ const named = await fs$1.lstat(target);
153
+ if (named.dev !== identity.dev || named.ino !== identity.ino || await fs$1.realpath(target) !== target) throw new Error("File identity changed.");
154
+ };
155
+ await check();
156
+ await checkFile();
157
+ await handle.writeFile(bytes);
158
+ await check();
159
+ await checkFile();
160
+ return target;
161
+ } catch (error) {
162
+ await handle.truncate(0);
163
+ throw error;
164
+ } finally {
165
+ await handle.close();
166
+ }
167
+ }
168
+ //#endregion
169
+ //#region src/services/fileTransfer/index.ts
170
+ function createFileTransferTools(owner) {
171
+ return [{
172
+ name: "share_file",
173
+ description: "Publish a session file as a short-lived public download link. No consent prompt.",
174
+ parameters: ShareFileParameters,
175
+ _meta: { "doompi/fileTransfer": true },
176
+ async execute(_id, input, signal) {
177
+ const executionSignal = AbortSignal.any([...signal ? [signal] : [], ...owner?.signal ? [owner.signal] : []]);
178
+ executionSignal.throwIfAborted();
179
+ if (!Value.Check(ShareFileParameters, input)) throw new Error("Invalid share_file parameters.");
180
+ const host = owner?.host.context;
181
+ if (!host?.shareFile) throw new Error("File sharing is unavailable.");
182
+ const shared = await host.shareFile(input.path, "share_file");
183
+ if (executionSignal.aborted) {
184
+ shared.revoke();
185
+ executionSignal.throwIfAborted();
186
+ }
187
+ const result = {
188
+ url: shared.url,
189
+ fileName: shared.fileName,
190
+ mimeType: shared.mimeType,
191
+ size: shared.size,
192
+ expiresAt: shared.expiresAt,
193
+ maxDownloads: shared.maxDownloads
194
+ };
195
+ return {
196
+ content: [{
197
+ type: "text",
198
+ text: JSON.stringify(result)
199
+ }],
200
+ structuredContent: { ...result }
201
+ };
202
+ }
203
+ }, {
204
+ name: "save_file",
205
+ description: "Save a public HTTPS file (25 MiB maximum) or base64 file (5 MiB maximum) to session attachments or a new relative cwd path.",
206
+ parameters: SaveFileParameters,
207
+ _meta: {
208
+ "doompi/fileTransfer": true,
209
+ "openai/fileParams": ["file"]
210
+ },
211
+ async execute(_id, input, cancellation) {
212
+ if (!Value.Check(SaveFileParameters, input)) throw new Error("Provide exactly one file form.");
213
+ const context = owner?.agent?.context;
214
+ if (!context) throw new Error("Session file storage is unavailable.");
215
+ const signal = AbortSignal.any([...cancellation ? [cancellation] : [], ...owner?.signal ? [owner.signal] : []]);
216
+ signal.throwIfAborted();
217
+ const file = input.file;
218
+ const bytes = "download_url" in file ? await downloadPublicFile(file.download_url, signal) : decodeFileBase64(file.data_base64);
219
+ signal.throwIfAborted();
220
+ const name = "download_url" in file ? file.file_name : file.name;
221
+ const result = input.path === void 0 ? await writeSessionAttachment(context.sessionId, name, bytes, context.environment) : {
222
+ path: await writeCwdFile(context.cwd, input.path, bytes),
223
+ name: path.basename(input.path),
224
+ size: bytes.length,
225
+ mimeType: "application/octet-stream"
226
+ };
227
+ return {
228
+ content: [{
229
+ type: "text",
230
+ text: JSON.stringify(result)
231
+ }],
232
+ structuredContent: { ...result }
233
+ };
234
+ }
235
+ }];
236
+ }
237
+ //#endregion
238
+ //#region src/schemas/mcpApps.ts
239
+ const Lease = Type.String({
240
+ minLength: 1,
241
+ maxLength: 128
242
+ });
243
+ const JsonObject = Type.Record(Type.String(), Type.Unknown());
244
+ const Empty = Type.Object({}, { additionalProperties: false });
245
+ const Result = Type.Object({
246
+ content: Type.Array(Type.Unknown()),
247
+ structuredContent: Type.Optional(Type.Unknown()),
248
+ _meta: Type.Optional(JsonObject),
249
+ isError: Type.Optional(Type.Boolean())
250
+ });
251
+ const Address = {
252
+ service: "mcp.apps",
253
+ scope: "session",
254
+ direction: "client-to-server"
255
+ };
256
+ const mcpAppOpenMethod = defineDoomPluginMethod({
257
+ ...Address,
258
+ method: "open",
259
+ input: Type.Object({ toolCallId: Type.String({
260
+ minLength: 1,
261
+ maxLength: 512
262
+ }) }, { additionalProperties: false }),
263
+ output: Type.Object({
264
+ leaseId: Lease,
265
+ html: Type.String(),
266
+ resourceUri: Type.String(),
267
+ protocol: Type.Union([Type.Literal("mcp"), Type.Literal("openai")]),
268
+ resourceMeta: JsonObject,
269
+ tool: Type.Object({
270
+ name: Type.String(),
271
+ description: Type.String(),
272
+ inputSchema: Type.Object({ type: Type.Literal("object") }, { additionalProperties: true }),
273
+ _meta: JsonObject
274
+ }),
275
+ args: JsonObject,
276
+ result: Result,
277
+ state: Type.Unknown(),
278
+ readOnly: Type.Boolean()
279
+ })
280
+ });
281
+ const mcpAppActivateMethod = defineDoomPluginMethod({
282
+ ...Address,
283
+ method: "activate",
284
+ input: Type.Object({ leaseId: Lease }, { additionalProperties: false }),
285
+ output: Type.Object({ readOnly: Type.Literal(false) })
286
+ });
287
+ const mcpAppCallToolMethod = defineDoomPluginMethod({
288
+ ...Address,
289
+ method: "callTool",
290
+ input: Type.Object({
291
+ leaseId: Lease,
292
+ name: Type.String({
293
+ minLength: 1,
294
+ maxLength: 256
295
+ }),
296
+ arguments: JsonObject
297
+ }, { additionalProperties: false }),
298
+ output: Result
299
+ });
300
+ const mcpAppSetStateMethod = defineDoomPluginMethod({
301
+ ...Address,
302
+ method: "setState",
303
+ input: Type.Object({
304
+ leaseId: Lease,
305
+ state: Type.Unknown()
306
+ }, { additionalProperties: false }),
307
+ output: Empty
308
+ });
309
+ const mcpAppFollowUpMethod = defineDoomPluginMethod({
310
+ ...Address,
311
+ method: "followUp",
312
+ input: Type.Object({
313
+ leaseId: Lease,
314
+ prompt: Type.String({
315
+ minLength: 1,
316
+ maxLength: 16e3
317
+ })
318
+ }, { additionalProperties: false }),
319
+ output: Empty
320
+ });
321
+ const mcpAppCloseMethod = defineDoomPluginMethod({
322
+ ...Address,
323
+ method: "close",
324
+ input: Type.Object({ leaseId: Lease }, { additionalProperties: false }),
325
+ output: Empty
326
+ });
327
+ //#endregion
328
+ //#region src/services/mcpFileParams/index.ts
329
+ /** ChatGPT Apps metadata naming the top-level arguments that carry a file. */
330
+ const FILE_PARAMS_META_KEY = "openai/fileParams";
331
+ const MAX_FILE_PARAMS = 16;
332
+ const FILE_ID_PREFIX = "doomfile_";
333
+ const FILE_SHARE_ENTRY_TYPE = "mcp.file.share";
334
+ const CONSENT_TIMEOUT_MS = 12e4;
335
+ const NO_PUBLISHER = "This MCP tool takes a file, which needs the Po web host with remote access on.";
336
+ function record$1(value) {
337
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
338
+ }
339
+ function filePath(value) {
340
+ const path = record$1(value)?.path;
341
+ return typeof path === "string" ? path : void 0;
342
+ }
343
+ /** Declared file parameters that the input schema really has at its top level. */
344
+ function fileParamNames(tool) {
345
+ const declared = tool._meta?.[FILE_PARAMS_META_KEY];
346
+ const properties = record$1(tool.inputSchema.properties);
347
+ if (!Array.isArray(declared) || properties === void 0) return [];
348
+ const names = declared.filter((name) => typeof name === "string" && Object.hasOwn(properties, name));
349
+ return [...new Set(names)].slice(0, MAX_FILE_PARAMS);
350
+ }
351
+ /** What the model is asked for: a `{path}` object in place of each server file shape. */
352
+ function modelInputSchema(tool) {
353
+ if (Object.keys(tool.inputSchema).length === 0) return {
354
+ type: "object",
355
+ properties: {}
356
+ };
357
+ const names = fileParamNames(tool);
358
+ if (names.length === 0) return tool.inputSchema;
359
+ const properties = { ...record$1(tool.inputSchema.properties) };
360
+ for (const name of names) {
361
+ const description = record$1(properties[name])?.description;
362
+ properties[name] = {
363
+ type: "object",
364
+ description: `${typeof description === "string" ? description : "A file."} Pass {"path": "<file in the working directory, or the path from an Attached file note>"}.`,
365
+ properties: { path: { type: "string" } },
366
+ required: ["path"],
367
+ additionalProperties: false
368
+ };
369
+ }
370
+ return {
371
+ ...tool.inputSchema,
372
+ properties
373
+ };
374
+ }
375
+ /**
376
+ * Swaps each `{path}` file argument for a published link, on a copy.
377
+ *
378
+ * Runs before the server is dialled, so a refused share never reaches it. Values that
379
+ * are not `{path}` objects pass through, including a file a widget already shaped.
380
+ */
381
+ async function publishFileParams(tool, params, publish) {
382
+ const names = fileParamNames(tool).filter((name) => filePath(params[name]) !== void 0);
383
+ if (names.length === 0) return {
384
+ outbound: params,
385
+ release: () => void 0
386
+ };
387
+ if (publish === void 0) throw new Error(NO_PUBLISHER);
388
+ const outbound = { ...params };
389
+ const shared = [];
390
+ const release = () => {
391
+ for (const file of shared.splice(0)) file.revoke();
392
+ };
393
+ try {
394
+ for (const name of names) {
395
+ const file = await publish(filePath(params[name]));
396
+ shared.push(file);
397
+ outbound[name] = {
398
+ file_id: `${FILE_ID_PREFIX}${crypto.randomUUID()}`,
399
+ download_url: file.url,
400
+ file_name: file.fileName,
401
+ mime_type: file.mimeType
402
+ };
403
+ }
404
+ } catch (error) {
405
+ release();
406
+ throw error;
407
+ }
408
+ return {
409
+ outbound,
410
+ release
411
+ };
412
+ }
413
+ /**
414
+ * Publishes a file only after the user approves it for this server and tool.
415
+ *
416
+ * The host mints first so the dialog can name the file and its size; the link leaves
417
+ * this function only on approval. No client, no answer within two minutes, a
418
+ * cancelled call, or a failed audit all revoke it.
419
+ */
420
+ function createConsentedFilePublisher(options) {
421
+ return async (path, tool, signal) => {
422
+ const label = `${tool.serverName} / ${tool.toolName}`;
423
+ const shared = await options.shareFile(path, label);
424
+ try {
425
+ const host = options.host();
426
+ if (host === void 0) throw new Error(`There is no Po client to approve sharing "${shared.fileName}".`);
427
+ const deadline = new AbortController();
428
+ const timer = setTimeout(() => deadline.abort(/* @__PURE__ */ new Error("No answer within two minutes.")), CONSENT_TIMEOUT_MS);
429
+ const abort = AbortSignal.any([
430
+ deadline.signal,
431
+ signal,
432
+ options.signal
433
+ ].filter((candidate) => candidate !== void 0));
434
+ const abandoned = new Promise((_resolve, reject) => {
435
+ if (abort.aborted) reject(abort.reason);
436
+ abort.addEventListener("abort", () => reject(abort.reason), { once: true });
437
+ });
438
+ const answer = await Promise.race([host.context.client.request({
439
+ kind: "confirm",
440
+ title: "Share a file with an MCP server?",
441
+ message: `Share "${shared.fileName}" (${shared.size} bytes) with ${label}? Po publishes it at a link on your remote-access tunnel until this call finishes (10 minutes at most). Cloudflare and ${tool.serverName} can read the file.`
442
+ }, abort), abandoned]).then((accepted) => accepted === true ? "shared" : "declined", () => "unanswered").finally(() => clearTimeout(timer));
443
+ await host.context.session.appendCustomEntry(FILE_SHARE_ENTRY_TYPE, {
444
+ file: shared.fileName,
445
+ server: tool.serverName,
446
+ tool: tool.toolName,
447
+ outcome: answer
448
+ });
449
+ if (answer !== "shared") throw new Error(`The user declined to share "${shared.fileName}".`);
450
+ return shared;
451
+ } catch (error) {
452
+ shared.revoke();
453
+ throw error;
454
+ }
455
+ };
456
+ }
457
+ //#endregion
458
+ //#region src/services/mcpTools/index.ts
459
+ const MAX_APP_RESULT_BYTES = 1048576;
460
+ const appSnapshots = /* @__PURE__ */ new WeakMap();
461
+ /** Clone before output guards can replace structured data or mutate the source. */
462
+ function captureMcpAppResult(tool, result) {
463
+ const app = normalizeMcpAppMetadata(tool._meta);
464
+ if (!app.appVisible && !app.resourceUri) return void 0;
465
+ try {
466
+ const json = JSON.stringify(result);
467
+ if (Buffer.byteLength(json, "utf8") > MAX_APP_RESULT_BYTES) return void 0;
468
+ return {
469
+ version: 1,
470
+ ...app.resourceUri === void 0 ? {} : {
471
+ resourceUri: app.resourceUri,
472
+ protocol: app.protocol
473
+ },
474
+ result: JSON.parse(json)
475
+ };
476
+ } catch {
477
+ return;
478
+ }
479
+ }
480
+ /** Historical details are untrusted JSON; reject malformed or unbounded snapshots. */
481
+ function parseMcpAppSnapshot(value) {
482
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
483
+ const record = value;
484
+ if (record.version !== 1 || typeof record.result !== "object" || record.result === null || Array.isArray(record.result)) return void 0;
485
+ if (record.resourceUri !== void 0 && (typeof record.resourceUri !== "string" || !record.resourceUri.startsWith("ui://") || record.resourceUri.length > 8192)) return void 0;
486
+ if (record.protocol !== void 0 && record.protocol !== "mcp" && record.protocol !== "openai") return void 0;
487
+ if (record.resourceUri === void 0 !== (record.protocol === void 0)) return void 0;
488
+ const result = record.result;
489
+ if (!Array.isArray(result.content) || result.isError !== void 0 && typeof result.isError !== "boolean") return void 0;
490
+ if (result.content.some((block) => typeof block !== "object" || block === null || !("type" in block) || typeof block.type !== "string")) return void 0;
491
+ try {
492
+ const json = JSON.stringify(value);
493
+ if (Buffer.byteLength(json, "utf8") > MAX_APP_RESULT_BYTES) return void 0;
494
+ return JSON.parse(json);
495
+ } catch {
496
+ return;
497
+ }
498
+ }
499
+ /** Associate the original private payload with the guarded result without widening MCP wire output. */
500
+ function retainMcpAppResult(result, snapshot) {
501
+ if (snapshot) appSnapshots.set(result, snapshot);
502
+ }
503
+ /** The text of a downstream result: its text blocks joined, which is what the model reads. */
504
+ function resultText(result) {
505
+ return (result.content ?? []).flatMap((block) => block.type === "text" ? [block.text] : []).join("\n").trim();
506
+ }
507
+ /** The image blocks, re-emitted as Pi's own image content so the model and the cockpit both see them. */
508
+ function resultImages(result) {
509
+ return (result.content ?? []).flatMap((block) => block.type === "image" ? [{
510
+ type: "image",
511
+ data: block.data,
512
+ mimeType: block.mimeType
513
+ }] : []);
514
+ }
515
+ /**
516
+ * Everything else a downstream result carried: audio, resource links, embedded
517
+ * resources, and the structured content. Pi's content cannot hold them, so
518
+ * they ride the result's details for the cockpit's MCP message to render.
519
+ */
520
+ function resultBlocks(result) {
521
+ const blocks = [];
522
+ for (const block of result.content ?? []) if (block.type === "audio") blocks.push({
523
+ type: "audio",
524
+ data: block.data,
525
+ mimeType: block.mimeType
526
+ });
527
+ else if (block.type === "resource_link") blocks.push({
528
+ type: "resource_link",
529
+ uri: block.uri,
530
+ name: block.name,
531
+ ...block.title === void 0 ? {} : { title: block.title },
532
+ ...block.description === void 0 ? {} : { description: block.description },
533
+ ...block.mimeType === void 0 ? {} : { mimeType: block.mimeType }
534
+ });
535
+ else if (block.type === "resource") {
536
+ const resource = block.resource;
537
+ blocks.push({
538
+ type: "resource",
539
+ uri: resource.uri,
540
+ ...resource.mimeType === void 0 ? {} : { mimeType: resource.mimeType },
541
+ ..."text" in resource && typeof resource.text === "string" ? { text: resource.text } : {},
542
+ ..."blob" in resource && typeof resource.blob === "string" ? { blob: resource.blob } : {}
543
+ });
544
+ }
545
+ const structured = structuredRecord(result);
546
+ if (structured !== void 0) blocks.push({
547
+ type: "structured",
548
+ value: structured
549
+ });
550
+ return blocks;
551
+ }
552
+ /**
553
+ * MCP v2 allows any JSON value as structured content; Po's contract carries an object, which
554
+ * is all v1 permitted. Any other value still reaches the model through the text fallback.
555
+ */
556
+ function structuredRecord(result) {
557
+ const value = result.structuredContent;
558
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
559
+ }
560
+ /** Remote MCP preserves error flags and public structured results instead of Pi's thrown-error convention. */
561
+ function toHeadlessToolResult(tool, result) {
562
+ const text = resultText(result) || (result.structuredContent === void 0 ? "No output." : JSON.stringify(result.structuredContent));
563
+ const blocks = resultBlocks(result);
564
+ const structured = structuredRecord(result);
565
+ const app = appSnapshots.get(result) ?? (normalizeMcpAppMetadata(tool._meta).resourceUri === void 0 ? void 0 : captureMcpAppResult(tool, result));
566
+ return {
567
+ content: [{
568
+ type: "text",
569
+ text
570
+ }, ...resultImages(result)],
571
+ ...structured === void 0 ? {} : { structuredContent: structured },
572
+ ...result.isError === void 0 ? {} : { isError: result.isError },
573
+ ...result._meta === void 0 ? {} : { _meta: result._meta },
574
+ details: {
575
+ server: tool.serverName,
576
+ tool: tool.toolName,
577
+ ...blocks.length > 0 ? { blocks } : {},
578
+ ...app === void 0 ? {} : { app }
579
+ }
580
+ };
581
+ }
582
+ //#endregion
583
+ //#region src/services/mcpApps/index.ts
584
+ const STATE_ENTRY = "mcp.app.state";
585
+ const MAX_HTML_BYTES = 2097152;
586
+ const MAX_RESULT_BYTES = 1048576;
587
+ const MAX_STATE_BYTES = 65536;
588
+ const MAX_LEASES = 64;
589
+ function record(value) {
590
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
591
+ }
592
+ function boundedJson(value, limit, label) {
593
+ const encoded = JSON.stringify(value);
594
+ if (encoded === void 0 || Buffer.byteLength(encoded) > limit) throw new Error(`${label} exceeds its size limit.`);
595
+ return JSON.parse(encoded);
596
+ }
597
+ /** Browser inputs identify a persisted invocation, never an upstream server or resource. */
598
+ function createMcpAppsService(tools, getHost, lifetime) {
599
+ const leases = /* @__PURE__ */ new Map();
600
+ let disposed = false;
601
+ let opening = 0;
602
+ const revoke = (lease) => {
603
+ leases.delete(lease.id);
604
+ lease.controller.abort();
605
+ };
606
+ const check = (lease) => {
607
+ lease.signal.throwIfAborted();
608
+ if (disposed || getHost() !== lease.host) throw new Error("App session is no longer available.");
609
+ lease.host.assertActive();
610
+ const surface = lease.host.toolSurface;
611
+ const current = surface?.readSurface();
612
+ if (!surface || current?.revision !== lease.revision || tools.runtimeRevision() !== lease.runtimeRevision) throw new Error("App permissions changed. Reopen the App.");
613
+ const source = tools.snapshot().find((tool) => tool.serverName === lease.server && tool.toolName === lease.tool);
614
+ if (!source || normalizeMcpAppMetadata(source._meta).resourceUri !== lease.resourceUri) throw new Error("App resource is no longer available.");
615
+ if (!current.tools.some((tool) => tool.name === source.piName || tool.name === "mcp")) throw new Error("The original App tool is not admitted in this session.");
616
+ return surface;
617
+ };
618
+ const owned = (id, caller) => {
619
+ caller.signal?.throwIfAborted();
620
+ const lease = leases.get(id);
621
+ if (!lease || !caller.connectionId || lease.connectionId !== caller.connectionId) throw new Error("App lease is not owned by this connection.");
622
+ try {
623
+ check(lease);
624
+ } catch (error) {
625
+ revoke(lease);
626
+ throw error;
627
+ }
628
+ return lease;
629
+ };
630
+ const operation = async (id, caller, write, run) => {
631
+ const lease = owned(id, caller);
632
+ if (write && lease.readOnly) throw new Error("Enable App interactions before performing this action.");
633
+ if (lease.busy) throw new Error("An App action is already in progress.");
634
+ if (++lease.actions > 256) throw new Error("App action limit reached. Reopen the App.");
635
+ lease.busy = true;
636
+ try {
637
+ return await run(lease);
638
+ } finally {
639
+ lease.busy = false;
640
+ }
641
+ };
642
+ const confirm = async (lease, title, message) => {
643
+ check(lease);
644
+ const accepted = await lease.host.context.client.request({
645
+ kind: "confirm",
646
+ title,
647
+ message
648
+ }, AbortSignal.any([lease.signal, AbortSignal.timeout(12e4)]));
649
+ check(lease);
650
+ if (accepted !== true) throw new Error("App action was not approved.");
651
+ };
652
+ const invocation = async (host, toolCallId) => {
653
+ const entries = await host.context.session.entries({ type: "message" });
654
+ let call;
655
+ let result;
656
+ for (const entry of entries) {
657
+ const message = record(entry.message);
658
+ if (message?.role === "assistant" && Array.isArray(message.content)) for (const block of message.content) {
659
+ const candidate = record(block);
660
+ if (candidate?.type === "toolCall" && candidate.id === toolCallId) call = candidate;
661
+ }
662
+ if (message?.role === "toolResult" && message.toolCallId === toolCallId) result = message;
663
+ }
664
+ const details = record(result?.details);
665
+ const app = parseMcpAppSnapshot(details?.app);
666
+ if (!call || !app?.resourceUri || !app.protocol || typeof details?.server !== "string" || typeof details.tool !== "string") throw new Error("This invocation has no retained App result.");
667
+ return {
668
+ call,
669
+ details,
670
+ app
671
+ };
672
+ };
673
+ const stopTools = tools.onChange(() => {
674
+ for (const lease of leases.values()) try {
675
+ check(lease);
676
+ } catch {
677
+ revoke(lease);
678
+ }
679
+ });
680
+ const dispose = () => {
681
+ disposed = true;
682
+ stopTools();
683
+ for (const lease of leases.values()) revoke(lease);
684
+ };
685
+ lifetime?.addEventListener("abort", dispose, { once: true });
686
+ return {
687
+ dispose,
688
+ methods: [
689
+ defineServerMethod(mcpAppOpenMethod, async ({ toolCallId }, caller) => {
690
+ if (!caller.connectionId || !caller.signal) throw new Error("An authenticated live connection is required.");
691
+ caller.signal.throwIfAborted();
692
+ if (disposed || lifetime?.aborted) throw new Error("App session is no longer available.");
693
+ if (leases.size + opening >= MAX_LEASES) throw new Error("Too many open Apps. Close an App before opening another.");
694
+ opening++;
695
+ try {
696
+ const host = getHost();
697
+ if (!host?.toolSurface) throw new Error("App execution is unavailable in this session.");
698
+ host.assertActive();
699
+ const revision = host.toolSurface.readSurface().revision;
700
+ const runtimeRevision = tools.runtimeRevision();
701
+ const { call, details, app } = await invocation(host, toolCallId);
702
+ const source = tools.snapshot().find((tool) => tool.serverName === details.server && tool.toolName === details.tool);
703
+ const declared = normalizeMcpAppMetadata(source?._meta);
704
+ if (!source || declared.resourceUri !== app.resourceUri || declared.protocol !== app.protocol) throw new Error("The original App resource is no longer declared.");
705
+ const args = record(call.arguments) ?? {};
706
+ const generic = call.name === "mcp" && args.server === source.serverName && args.tool === source.toolName;
707
+ if (call.name !== source.piName && !generic) throw new Error("App invocation identity does not match its tool.");
708
+ const controller = new AbortController();
709
+ const lease = {
710
+ id: randomUUID(),
711
+ connectionId: caller.connectionId,
712
+ host,
713
+ toolCallId,
714
+ server: source.serverName,
715
+ tool: source.toolName,
716
+ resourceUri: app.resourceUri,
717
+ revision,
718
+ runtimeRevision,
719
+ controller,
720
+ signal: AbortSignal.any([
721
+ controller.signal,
722
+ caller.signal,
723
+ ...lifetime ? [lifetime] : []
724
+ ]),
725
+ readOnly: true,
726
+ busy: false,
727
+ actions: 0
728
+ };
729
+ check(lease);
730
+ const resource = await tools.readAppResource(lease.server, lease.tool, lease.resourceUri, lease.signal);
731
+ check(lease);
732
+ const content = resource.contents.find((item) => item.uri === lease.resourceUri && "text" in item);
733
+ if (!content || !("text" in content) || typeof content.text !== "string") throw new Error("App resource must contain HTML text.");
734
+ if (content.mimeType !== "text/html;profile=mcp-app" && content.mimeType !== "text/html+skybridge") throw new Error("Unsupported App resource MIME type.");
735
+ if (Buffer.byteLength(content.text) > MAX_HTML_BYTES) throw new Error("App HTML exceeds its size limit.");
736
+ const states = await host.context.session.entries({
737
+ type: "custom",
738
+ customType: STATE_ENTRY
739
+ });
740
+ let state = null;
741
+ for (const entry of states) {
742
+ const data = record(entry.data);
743
+ if (data?.version === 1 && data.toolCallId === toolCallId && data.server === lease.server && data.tool === lease.tool && data.resourceUri === lease.resourceUri) state = data.state ?? null;
744
+ }
745
+ check(lease);
746
+ if (leases.size >= MAX_LEASES) throw new Error("Too many open Apps.");
747
+ const opened = {
748
+ leaseId: lease.id,
749
+ html: content.text,
750
+ resourceUri: lease.resourceUri,
751
+ protocol: app.protocol,
752
+ resourceMeta: boundedJson(record(content._meta) ?? record(resource._meta) ?? {}, MAX_STATE_BYTES, "Resource metadata"),
753
+ tool: {
754
+ name: source.toolName,
755
+ description: source.description ?? "",
756
+ inputSchema: boundedJson({
757
+ ...source.inputSchema,
758
+ type: "object"
759
+ }, MAX_STATE_BYTES, "Tool schema"),
760
+ _meta: boundedJson(source._meta ?? {}, MAX_STATE_BYTES, "Tool metadata")
761
+ },
762
+ args: boundedJson(generic ? record(args.arguments) ?? {} : args, MAX_STATE_BYTES, "Tool input"),
763
+ result: app.result,
764
+ state: boundedJson(state, MAX_STATE_BYTES, "App state"),
765
+ readOnly: true
766
+ };
767
+ leases.set(lease.id, lease);
768
+ lease.signal.addEventListener("abort", () => leases.delete(lease.id), { once: true });
769
+ return opened;
770
+ } finally {
771
+ opening--;
772
+ }
773
+ }),
774
+ defineServerMethod(mcpAppActivateMethod, ({ leaseId }, caller) => operation(leaseId, caller, false, async (lease) => {
775
+ await confirm(lease, `Enable ${lease.server} App interactions?`, "This App may request tool calls and follow-up messages. Each action still requires approval.");
776
+ lease.readOnly = false;
777
+ return { readOnly: false };
778
+ })),
779
+ defineServerMethod(mcpAppCallToolMethod, ({ leaseId, name, arguments: args }, caller) => operation(leaseId, caller, true, async (lease) => {
780
+ boundedJson(args, MAX_STATE_BYTES, "Tool arguments");
781
+ const target = tools.snapshot().find((tool) => tool.serverName === lease.server && tool.toolName === name && isAppVisibleMcpTool(tool));
782
+ if (!target) throw new Error("This tool is not available to this App.");
783
+ const surface = check(lease);
784
+ const exposed = surface.readSurface().tools;
785
+ const direct = exposed.some((tool) => tool.name === target.piName);
786
+ if (!direct && !exposed.some((tool) => tool.name === "mcp")) throw new Error("This tool is not admitted in this session.");
787
+ await confirm(lease, `Allow ${lease.server} App to call ${name}?`, JSON.stringify(args, null, 2));
788
+ const result = await surface.invokeTool({
789
+ revision: lease.revision,
790
+ name: direct ? target.piName : "mcp",
791
+ arguments: direct ? args : {
792
+ server: lease.server,
793
+ tool: name,
794
+ arguments: args
795
+ },
796
+ signal: AbortSignal.any([lease.signal, AbortSignal.timeout(12e4)]),
797
+ authorize: () => {
798
+ check(lease);
799
+ }
800
+ });
801
+ check(lease);
802
+ return parseMcpAppSnapshot(record(result.details)?.app)?.result ?? boundedJson({
803
+ content: result.content,
804
+ ...result.structuredContent === void 0 ? {} : { structuredContent: result.structuredContent },
805
+ ...result._meta === void 0 ? {} : { _meta: result._meta },
806
+ ...result.isError === void 0 ? {} : { isError: result.isError }
807
+ }, MAX_RESULT_BYTES, "Tool result");
808
+ })),
809
+ defineServerMethod(mcpAppSetStateMethod, ({ leaseId, state }, caller) => operation(leaseId, caller, false, async (lease) => {
810
+ const saved = boundedJson(state, MAX_STATE_BYTES, "App state");
811
+ await lease.host.context.session.appendCustomEntry(STATE_ENTRY, {
812
+ version: 1,
813
+ toolCallId: lease.toolCallId,
814
+ server: lease.server,
815
+ tool: lease.tool,
816
+ resourceUri: lease.resourceUri,
817
+ state: saved
818
+ });
819
+ check(lease);
820
+ return {};
821
+ })),
822
+ defineServerMethod(mcpAppFollowUpMethod, ({ leaseId, prompt }, caller) => operation(leaseId, caller, true, async (lease) => {
823
+ const session = lease.host.context.session;
824
+ if (!session.admitPrompt) throw new Error("App follow-up messages are unavailable in this session.");
825
+ await confirm(lease, `Send a follow-up from ${lease.server}?`, prompt);
826
+ await session.admitPrompt(`[MCP App ${lease.server}/${lease.tool}, invocation ${lease.toolCallId}]\n${prompt}`, "followUp");
827
+ check(lease);
828
+ return {};
829
+ })),
830
+ defineServerMethod(mcpAppCloseMethod, ({ leaseId }, caller) => {
831
+ const lease = leases.get(leaseId);
832
+ if (lease && lease.connectionId !== caller.connectionId) throw new Error("App lease is not owned by this connection.");
833
+ if (lease) revoke(lease);
834
+ return {};
835
+ })
836
+ ]
837
+ };
838
+ }
839
+ //#endregion
840
+ //#region src/services/mcpHeadlessTools/index.ts
841
+ const MCP_HEADLESS_TOOLS_SERVICE = "doom/mcp-headless-tools";
842
+ function mountMcpHeadlessTools(tools) {
843
+ return (context) => {
844
+ context.plugin((providerContext) => {
845
+ providerContext.provide(MCP_HEADLESS_TOOLS_SERVICE, { get: (name) => tools.find((tool) => tool.name === name) });
846
+ });
847
+ };
848
+ }
849
+ //#endregion
850
+ //#region src/schemas/mcpHeadlessTool.ts
851
+ const McpHeadlessToolParameters = Type.Object({
852
+ server: Type.String({ minLength: 1 }),
853
+ tool: Type.String({ minLength: 1 }),
854
+ arguments: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
855
+ });
856
+ //#endregion
857
+ //#region src/services/mcpSessionTools/index.ts
858
+ const MCP_SESSION_TOOLS_SERVICE = "doom/mcp-session-tools";
859
+ function createMcpSessionToolsService(session, generation, assertAvailable = () => void 0) {
860
+ let disposed = false;
861
+ const listeners = /* @__PURE__ */ new Set();
862
+ const retained = /* @__PURE__ */ new Map();
863
+ const snapshot = () => {
864
+ try {
865
+ if (disposed) return [];
866
+ assertAvailable();
867
+ return session.activeToolDefinitions();
868
+ } catch {
869
+ return [];
870
+ }
871
+ };
872
+ const project = () => {
873
+ const definitions = snapshot();
874
+ const names = new Set(definitions.map((tool) => tool.piName));
875
+ for (const [name, entry] of retained) {
876
+ if (names.has(name)) continue;
877
+ entry.retire();
878
+ retained.delete(name);
879
+ }
880
+ return definitions.map((definition) => {
881
+ const fingerprint = JSON.stringify([session.toolGeneration, definition]);
882
+ const previous = retained.get(definition.piName);
883
+ if (previous?.fingerprint === fingerprint) return previous.tool;
884
+ previous?.retire();
885
+ let retired = false;
886
+ const invoke = session.bindToolInvocation(definition);
887
+ const tool = {
888
+ name: definition.piName,
889
+ label: `${definition.serverName}: ${definition.toolName}`,
890
+ description: definition.description ?? `${definition.toolName} on the ${definition.serverName} MCP server.`,
891
+ ...definition.annotations === void 0 ? {} : { annotations: definition.annotations },
892
+ ...definition._meta === void 0 ? {} : { _meta: mcpToolExecutionMetadata(definition) },
893
+ ...definition.outputSchema === void 0 ? {} : { outputSchema: definition.outputSchema },
894
+ parameters: modelInputSchema(definition),
895
+ async execute(_toolCallId, parameters, signal) {
896
+ assertAvailable();
897
+ if (retired) throw new Error(`MCP tool ${definition.piName} is no longer available.`);
898
+ signal?.throwIfAborted();
899
+ const arguments_ = parameters === void 0 ? {} : parameters;
900
+ if (!Value.Check(tool.parameters, arguments_)) throw new Error(`Invalid arguments for MCP tool ${definition.piName}.`);
901
+ const result = await invoke(arguments_, signal);
902
+ signal?.throwIfAborted();
903
+ return result;
904
+ }
905
+ };
906
+ retained.set(definition.piName, {
907
+ fingerprint,
908
+ tool,
909
+ retire: () => {
910
+ retired = true;
911
+ }
912
+ });
913
+ return tool;
914
+ });
915
+ };
916
+ const refresh = () => {
917
+ project();
918
+ for (const listener of listeners) listener();
919
+ };
920
+ const stopSession = session.onChange(refresh);
921
+ return Object.freeze({
922
+ generation,
923
+ runtimeRevision: () => session.toolGeneration,
924
+ readAppResource: async (server, tool, resourceUri, signal) => {
925
+ assertAvailable();
926
+ if (disposed) throw new Error("The MCP session is no longer available.");
927
+ const result = await session.readAppResource(server, tool, resourceUri, signal);
928
+ assertAvailable();
929
+ if (disposed) throw new Error("The MCP session is no longer available.");
930
+ return result;
931
+ },
932
+ snapshot,
933
+ project,
934
+ refresh,
935
+ dispose: () => {
936
+ disposed = true;
937
+ stopSession();
938
+ refresh();
939
+ listeners.clear();
940
+ },
941
+ resolveSelectors: (selectors) => {
942
+ const active = new Set(snapshot().map((tool) => tool.piName));
943
+ return session.resolveToolSelectors(selectors).map((tool) => tool.name).filter((name) => active.has(name));
944
+ },
945
+ onChange: (listener) => {
946
+ listeners.add(listener);
947
+ return () => {
948
+ listeners.delete(listener);
949
+ };
950
+ },
951
+ invoke: async (name, parameters, signal) => {
952
+ assertAvailable();
953
+ const tool = project().find((candidate) => candidate.name === name);
954
+ if (!tool) throw new Error(`MCP tool ${name} is not available in this session.`);
955
+ return tool.execute("mcp", parameters, signal);
956
+ }
957
+ });
958
+ }
959
+ /** Borrow an existing MCP runtime; a child never starts or disposes its own upstream connections. */
960
+ function createMcpChildTool(invoke, catalog) {
961
+ return {
962
+ name: "mcp",
963
+ ...catalog === void 0 ? {} : { catalog },
964
+ description: "Call a tool exposed by a connected MCP server.",
965
+ parameters: McpHeadlessToolParameters,
966
+ async execute(_toolCallId, parameters, signal) {
967
+ signal?.throwIfAborted();
968
+ if (!Value.Check(McpHeadlessToolParameters, parameters)) throw new Error("Invalid MCP tool arguments.");
969
+ const result = await invoke(parameters, signal);
970
+ signal?.throwIfAborted();
971
+ return result;
972
+ }
973
+ };
974
+ }
975
+ //#endregion
976
+ //#region src/constants/mcp.ts
977
+ const SERVER_COMMAND_DESCRIPTION = "Show MCP configuration or begin an authorization request.";
978
+ //#endregion
979
+ //#region src/constants/piMcp.ts
980
+ const PACKAGE_SOURCE = "@agimon-ai/po-mcp";
981
+ /**
982
+ * Footer status carrying the session's MCP server names, comma separated.
983
+ *
984
+ * MCP tools are registered under `<server>_<tool>`, a name only known at
985
+ * runtime, so the cockpit's web plugin reads this status to recognise which
986
+ * tool calls are MCP calls. The web half duplicates the literal (it may only
987
+ * import src/types and src/constants), and a test keeps the two in step.
988
+ */
989
+ const MCP_STATUS_KEY = "doom-mcp";
990
+ //#endregion
991
+ //#region src/types/webMcp.ts
992
+ const MCP_SESSION_AUTH_STATUS_KEY = "doom-mcp-session-auth";
993
+ new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "gu");
994
+ const MAX_AUTHORIZATION_URL_LENGTH = 8192;
995
+ const MAX_SERVER_TOOLS = 512;
996
+ const MAX_TOOL_NAME_LENGTH = 256;
997
+ const SESSION_AUTH_STATES = [
998
+ "not-connected",
999
+ "connecting",
1000
+ "connected",
1001
+ "degraded",
1002
+ "needs-auth",
1003
+ "failed",
1004
+ "closed",
1005
+ "disabled"
1006
+ ];
1007
+ function isSessionAuthState(value) {
1008
+ return SESSION_AUTH_STATES.some((state) => state === value);
1009
+ }
1010
+ /** Session route for one reachable tool's description and schema. */
1011
+ const MCP_SESSION_TOOL_API_PATH = "/tool";
1012
+ const MCP_SESSION_TOOL_SERVER_QUERY = "server";
1013
+ const MCP_SESSION_TOOL_NAME_QUERY = "tool";
1014
+ /** Keeps servers visible before discovery and after failures, without diagnostics or credentials. */
1015
+ function formatMcpSessionAuthStatus(servers) {
1016
+ const items = servers.flatMap((server) => {
1017
+ if (!isSessionAuthState(server.state)) return [];
1018
+ const tools = (server.tools ?? []).filter((tool) => tool.active && isToolName(tool.toolName)).map((tool) => ({
1019
+ name: tool.toolName,
1020
+ ...isToolName(tool.piName) ? { piName: tool.piName } : {},
1021
+ ...isTokenCount(tool.tokens) ? { tokens: tool.tokens } : {}
1022
+ })).slice(0, MAX_SERVER_TOOLS);
1023
+ return [{
1024
+ name: server.name,
1025
+ state: server.state,
1026
+ ...isAuthorizationUrl(server.authorizationUrl) ? { authorizationUrl: server.authorizationUrl } : {},
1027
+ ...tools.length === 0 ? {} : { tools }
1028
+ }];
1029
+ });
1030
+ return items.length === 0 ? void 0 : JSON.stringify(items);
1031
+ }
1032
+ function hasControlCharacter(value) {
1033
+ for (const character of value) {
1034
+ const codePoint = character.codePointAt(0);
1035
+ if (codePoint !== void 0 && (codePoint < 32 || codePoint >= 127 && codePoint <= 159)) return true;
1036
+ }
1037
+ return false;
1038
+ }
1039
+ function isAuthorizationUrl(value) {
1040
+ if (typeof value !== "string" || value.length > MAX_AUTHORIZATION_URL_LENGTH || hasControlCharacter(value)) return false;
1041
+ try {
1042
+ const url = new URL(value);
1043
+ return (url.protocol === "http:" || url.protocol === "https:") && url.username === "" && url.password === "";
1044
+ } catch {
1045
+ return false;
1046
+ }
1047
+ }
1048
+ function isToolName(value) {
1049
+ return typeof value === "string" && value.trim() !== "" && value.length <= MAX_TOOL_NAME_LENGTH && !hasControlCharacter(value);
1050
+ }
1051
+ function isTokenCount(value) {
1052
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
1053
+ }
1054
+ /** Hub API paths for repository-scoped MCP management. */
1055
+ const MCP_REPOSITORY_API_PATH = "/repository";
1056
+ const MCP_DISCOVERY_API_PATH = "/repository/discover";
1057
+ const MCP_AUTHORIZATION_API_PATH = "/repository/authorize";
1058
+ /** The repository every route is asked about, and the flow segment the two flow routes carry. */
1059
+ const MCP_REPOSITORY_ID_QUERY = "repositoryId";
1060
+ const MCP_FLOW_ID_PARAM = "flowId";
1061
+ //#endregion
1062
+ //#region src/services/mcpCommand/index.ts
1063
+ /** One line per server: its state, and how many tools it currently offers. */
1064
+ function formatStatus(session) {
1065
+ const { servers } = session.getSnapshot();
1066
+ if (servers.length === 0) return "No MCP servers are configured for this session.";
1067
+ return [...servers.map((server) => {
1068
+ const detail = server.error ? ` (${server.error})` : "";
1069
+ return `${server.name}: ${server.state} · ${server.tools.length} tools${detail}`;
1070
+ }), ...session.getDiagnostics()].join("\n");
1071
+ }
1072
+ //#endregion
1073
+ //#region src/services/directTools/index.ts
1074
+ const SELECTOR_SEPARATOR = "/";
1075
+ /**
1076
+ * Which downstream tools a child may see.
1077
+ *
1078
+ * A selector is either a whole server (`pencil`) or one tool of it
1079
+ * (`pencil/get_screenshot`).
1080
+ */
1081
+ var DirectToolFilter = class {
1082
+ servers = /* @__PURE__ */ new Set();
1083
+ tools = /* @__PURE__ */ new Map();
1084
+ constructor(selectors) {
1085
+ for (const raw of selectors) {
1086
+ const selector = raw.trim().replace(/\/+$/, "");
1087
+ if (!selector) continue;
1088
+ const separator = selector.indexOf(SELECTOR_SEPARATOR);
1089
+ if (separator === -1) {
1090
+ this.servers.add(selector);
1091
+ continue;
1092
+ }
1093
+ const server = selector.slice(0, separator);
1094
+ const tool = selector.slice(separator + 1);
1095
+ if (!server) continue;
1096
+ if (!tool) {
1097
+ this.servers.add(server);
1098
+ continue;
1099
+ }
1100
+ const selected = this.tools.get(server) ?? /* @__PURE__ */ new Set();
1101
+ selected.add(tool);
1102
+ this.tools.set(server, selected);
1103
+ }
1104
+ }
1105
+ allows(serverName, toolName) {
1106
+ if (this.servers.has(serverName)) return true;
1107
+ return this.tools.get(serverName)?.has(toolName) ?? false;
1108
+ }
1109
+ };
1110
+ //#endregion
1111
+ //#region src/services/directToolsEnvironment/index.ts
1112
+ /** Reads a child agent's MCP tool selection from the process environment. */
1113
+ function readDirectToolFilter(environment = process.env) {
1114
+ const raw = environment[DIRECT_TOOLS_ENV];
1115
+ if (raw === void 0 || raw === "") return void 0;
1116
+ if (raw === "__none__") return new DirectToolFilter([]);
1117
+ return new DirectToolFilter(raw.split(","));
1118
+ }
1119
+ //#endregion
1120
+ //#region src/services/toolVisibility/index.ts
1121
+ /**
1122
+ * Hides every wrapper this extension owns that the catalog does not currently offer.
1123
+ *
1124
+ * The surface recomputes from the whole registered inventory, so this only ever
1125
+ * removes: a server that reconnected is already in `incoming` and nothing has to
1126
+ * put it back. Historical ownership matters after reconfiguration because Pi
1127
+ * cannot unregister a wrapper the new domain no longer selects.
1128
+ *
1129
+ * The visible set is read once, here, so the returned function stays pure and a
1130
+ * later re-apply by another owner cannot see half a catalog change.
1131
+ */
1132
+ function mcpToolRestriction(catalog, historicallyOwnedNames = new Set(catalog.allTools().map((tool) => tool.piName)), incompatibleNames = /* @__PURE__ */ new Set()) {
1133
+ const visible = new Set(catalog.activeToolNames().filter((name) => !incompatibleNames.has(name)));
1134
+ return (incoming) => incoming.filter((name) => !historicallyOwnedNames.has(name) || visible.has(name));
1135
+ }
1136
+ //#endregion
1137
+ //#region src/services/mcpSession/index.ts
1138
+ /** Raised by anything needing a live container before one has been built. */
1139
+ const RUNTIME_NOT_STARTED = "The MCP runtime has not started yet.";
1140
+ const AUTHORIZATION_URL_NOT_READY = "No authorization page is waiting for this MCP server.";
1141
+ function canonicalJson(value) {
1142
+ const normalize = (candidate) => {
1143
+ if (Array.isArray(candidate)) return candidate.map(normalize);
1144
+ if (typeof candidate !== "object" || candidate === null) return candidate;
1145
+ return Object.fromEntries(Object.entries(candidate).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, normalize(child)]));
1146
+ };
1147
+ return JSON.stringify(normalize(value));
1148
+ }
1149
+ function mcpConfigurationFingerprint(configuration) {
1150
+ return canonicalJson({
1151
+ enabled: configuration.enabled ?? true,
1152
+ repoRoot: configuration.repoRoot,
1153
+ stagingDirectory: configuration.stagingDirectory,
1154
+ generatedConfigPath: configuration.generatedConfigPath,
1155
+ pluginConfigPaths: configuration.pluginConfigPaths ?? [],
1156
+ sources: configuration.sources ?? [],
1157
+ allowlist: {
1158
+ servers: [...configuration.allowlist?.servers ?? []].sort((left, right) => left.localeCompare(right)),
1159
+ proxy: [...configuration.allowlist?.proxy ?? []].sort((left, right) => left.localeCompare(right))
1160
+ }
1161
+ });
1162
+ }
1163
+ function toolRegistrationFingerprint(tool) {
1164
+ return canonicalJson({
1165
+ serverName: tool.serverName,
1166
+ toolName: tool.toolName,
1167
+ inputSchema: tool.inputSchema,
1168
+ _meta: tool._meta
1169
+ });
1170
+ }
1171
+ /**
1172
+ * The MCP picture for one Pi session.
1173
+ *
1174
+ * Install is synchronous and cheap: the previous run's catalog is read off disk and
1175
+ * its tools registered, so Pi's first turn already has them. Connecting happens on
1176
+ * a detached promise afterwards, and each server folds into the active tool set as
1177
+ * it lands. Nothing on this path waits for a socket.
1178
+ */
1179
+ var McpSession = class {
1180
+ /** Created after session_start, never while Pi is importing extensions. */
1181
+ runtime;
1182
+ catalog;
1183
+ groups = {
1184
+ shared: {
1185
+ configSources: [],
1186
+ configPaths: [],
1187
+ serverNames: []
1188
+ },
1189
+ sessionLocal: {
1190
+ configSources: [],
1191
+ configPaths: [],
1192
+ serverNames: []
1193
+ },
1194
+ droppedServers: [],
1195
+ diagnostics: []
1196
+ };
1197
+ options;
1198
+ /** Credential persistence used by the active runtime and explicit reauthorization. */
1199
+ tokenStore;
1200
+ /** Resources per server, listed on demand. Errors are never cached, so a retry re-dials. */
1201
+ resourceCache = /* @__PURE__ */ new Map();
1202
+ changeListeners = /* @__PURE__ */ new Set();
1203
+ /** Authorization URLs waiting to be opened, by server. */
1204
+ authorizationUrls = /* @__PURE__ */ new Map();
1205
+ /** Servers whose explicit auth action should launch the browser when its URL arrives. */
1206
+ browserAuthorizationRequests = /* @__PURE__ */ new Set();
1207
+ /** Explicit session disconnects fence late discovery and stale tool invocations. */
1208
+ disconnectedServers = /* @__PURE__ */ new Set();
1209
+ /** Bounded, session-local diagnostics. Never tool-result values or cached credentials. */
1210
+ outputSchemaWarnings = [];
1211
+ /** Invalidates deferred startup work when a session reloads or shuts down. */
1212
+ lifecycleGeneration = 0;
1213
+ configurationFingerprint;
1214
+ workspaceRoot;
1215
+ executionCwd;
1216
+ /** Pi wrappers survive configuration changes because the host cannot unregister them. */
1217
+ retainedTools = /* @__PURE__ */ new Map();
1218
+ registeredToolFingerprints = /* @__PURE__ */ new Map();
1219
+ historicallyOwnedNames = /* @__PURE__ */ new Set();
1220
+ incompatibleNames = /* @__PURE__ */ new Set();
1221
+ /** The session's slot in the tool surface, held only while a session fiber is up. */
1222
+ toolRestriction;
1223
+ /** Resolved per tool call, so a registration outlives the container it was made under. */
1224
+ getClientManager = () => this.runtime?.getServices()?.clientManager;
1225
+ constructor(options) {
1226
+ this.options = options;
1227
+ this.catalog = this.createCatalog();
1228
+ }
1229
+ /**
1230
+ * Puts this session's tool visibility under the Doom tool surface.
1231
+ *
1232
+ * Registering applies the current restriction immediately, which is the first
1233
+ * point the session can express that nothing has connected yet: the surface
1234
+ * only exists once a session fiber is up, while the cached tools were
1235
+ * registered back at install. Dropping the handle restores every wrapper, so
1236
+ * nothing here keeps a snapshot of the tool list.
1237
+ */
1238
+ bindToolSurface(surface) {
1239
+ const handle = surface.register({
1240
+ source: PACKAGE_SOURCE,
1241
+ restrict: this.currentRestriction()
1242
+ });
1243
+ this.toolRestriction = handle;
1244
+ return () => {
1245
+ if (this.toolRestriction !== handle) return;
1246
+ this.toolRestriction = void 0;
1247
+ handle.dispose();
1248
+ };
1249
+ }
1250
+ createCatalog() {
1251
+ return new McpCatalog(readDirectToolFilter(this.options.environment));
1252
+ }
1253
+ /**
1254
+ * Registers the cached catalog with Pi. Runs at extension install.
1255
+ *
1256
+ * Returns the servers it seeded, so a caller can report what the session started
1257
+ * with before anything connected.
1258
+ */
1259
+ install(configuration = readSessionConfig(this.options.environment)) {
1260
+ this.executionCwd = configuration.repoRoot;
1261
+ this.bindConfiguration(configuration);
1262
+ this.emitChange();
1263
+ return this.catalog.toSnapshot();
1264
+ }
1265
+ /** Replaces the complete MCP projection and retires the previous live owner. */
1266
+ async reconfigure(configuration, executionCwd = configuration.repoRoot, workspaceRoot = configuration.repoRoot) {
1267
+ if (mcpConfigurationFingerprint(configuration) === this.configurationFingerprint && executionCwd === this.executionCwd && workspaceRoot === this.workspaceRoot) {
1268
+ if (!this.runtime?.getServices()) this.startDetached();
1269
+ return;
1270
+ }
1271
+ const generation = ++this.lifecycleGeneration;
1272
+ const retiredRuntime = this.runtime;
1273
+ this.runtime = void 0;
1274
+ await retiredRuntime?.dispose();
1275
+ if (generation !== this.lifecycleGeneration) return;
1276
+ this.resourceCache.clear();
1277
+ this.authorizationUrls.clear();
1278
+ this.browserAuthorizationRequests.clear();
1279
+ this.executionCwd = executionCwd;
1280
+ this.bindConfiguration(configuration);
1281
+ this.workspaceRoot = workspaceRoot;
1282
+ this.emitChange();
1283
+ this.updateToolVisibility();
1284
+ this.startDetached();
1285
+ }
1286
+ bindConfiguration(configuration) {
1287
+ this.workspaceRoot = configuration.repoRoot;
1288
+ this.configurationFingerprint = mcpConfigurationFingerprint(configuration);
1289
+ this.groups = buildMcpConfigGroups(configuration);
1290
+ this.disconnectedServers.clear();
1291
+ this.catalog = this.createCatalog();
1292
+ const configuredServers = [.../* @__PURE__ */ new Set([...this.groups.sessionLocal.serverNames, ...this.groups.shared.serverNames])];
1293
+ this.catalog.seed(readCachedCatalog(this.configSources()), configuredServers);
1294
+ for (const diagnostic of this.groups.diagnostics) this.catalog.addDiagnostic(diagnostic);
1295
+ this.trackTools(this.catalog.allTools());
1296
+ }
1297
+ trackTools(tools) {
1298
+ for (const tool of tools) {
1299
+ const fingerprint = toolRegistrationFingerprint(tool);
1300
+ const registered = this.registeredToolFingerprints.get(tool.piName);
1301
+ this.historicallyOwnedNames.add(tool.piName);
1302
+ if (registered === void 0) {
1303
+ this.registeredToolFingerprints.set(tool.piName, fingerprint);
1304
+ this.incompatibleNames.delete(tool.piName);
1305
+ this.retainedTools.set(tool.piName, tool);
1306
+ continue;
1307
+ }
1308
+ if (registered === fingerprint) {
1309
+ this.incompatibleNames.delete(tool.piName);
1310
+ continue;
1311
+ }
1312
+ this.incompatibleNames.add(tool.piName);
1313
+ this.catalog.addDiagnostic(`MCP tool "${tool.piName}" changed identity, App metadata, or input schema and is hidden until Pi is relaunched.`);
1314
+ }
1315
+ }
1316
+ /** Tools the session currently permits on both local and remote surfaces. */
1317
+ activeToolDefinitions() {
1318
+ const active = new Set(this.catalog.activeToolNames());
1319
+ return this.toolDefinitions().flatMap((tool) => {
1320
+ const current = this.catalog.findTool(tool.piName);
1321
+ return active.has(tool.piName) && current !== void 0 ? [current] : [];
1322
+ });
1323
+ }
1324
+ toolDefinitions() {
1325
+ return [...this.retainedTools.values()].filter((tool) => this.catalog.findTool(tool.piName) !== void 0 && !this.incompatibleNames.has(tool.piName) && !this.disconnectedServers.has(tool.serverName));
1326
+ }
1327
+ isToolAvailable(registeredTool) {
1328
+ if (!this.catalog.activeToolNames().includes(registeredTool.piName)) return false;
1329
+ const current = this.catalog.findTool(registeredTool.piName);
1330
+ return current !== void 0 && toolRegistrationFingerprint(current) === toolRegistrationFingerprint(registeredTool);
1331
+ }
1332
+ get toolGeneration() {
1333
+ return this.lifecycleGeneration;
1334
+ }
1335
+ /** Capture a declaration's runtime, so retired wrappers cannot reach a same-name replacement. */
1336
+ bindToolInvocation(tool) {
1337
+ const generation = this.lifecycleGeneration;
1338
+ return async (parameters, signal) => {
1339
+ if (generation !== this.lifecycleGeneration || !this.isToolAvailable(tool)) throw new Error(`MCP tool ${tool.piName} is no longer available in the current session configuration.`);
1340
+ return toHeadlessToolResult(tool, await this.callTool(tool, parameters, signal));
1341
+ };
1342
+ }
1343
+ /** Executes through this session's existing manager, never a remote replacement runtime. */
1344
+ async invokeTool(name, parameters, signal) {
1345
+ const tool = this.activeToolDefinitions().find((candidate) => candidate.piName === name);
1346
+ if (!tool || !this.isToolAvailable(tool)) throw new Error(`MCP tool ${name} is not available in the current session configuration.`);
1347
+ return this.bindToolInvocation(tool)(parameters, signal);
1348
+ }
1349
+ /** Shared raw execution for Pi and headless, before their distinct result conventions. */
1350
+ async callTool(tool, parameters, signal) {
1351
+ const name = tool.piName;
1352
+ if (!this.isToolAvailable(tool)) throw new Error(`MCP tool ${name} is not available in the current session configuration.`);
1353
+ const runtime = this.runtime;
1354
+ const services = runtime?.getServices();
1355
+ if (!runtime || !services) throw new Error(RUNTIME_NOT_STARTED);
1356
+ signal?.throwIfAborted();
1357
+ const { publishFile } = this.options;
1358
+ const files = await publishFileParams(tool, parameters, publishFile && ((path) => publishFile(path, tool, signal)));
1359
+ try {
1360
+ signal?.throwIfAborted();
1361
+ const connection = await services.clientManager.ensureConnected(tool.serverName);
1362
+ signal?.throwIfAborted();
1363
+ if (!runtime.isCurrent(services) || !this.isToolAvailable(tool)) throw new Error(`MCP tool ${name} is no longer available in the current session configuration.`);
1364
+ const timeout = services.clientManager.getServerRequestTimeout(tool.serverName);
1365
+ this.clearToolWarnings(tool.serverName, "tools/call", tool.toolName);
1366
+ let snapshot;
1367
+ const result = await connection.callTool(tool.toolName, files.outbound, {
1368
+ ...timeout === void 0 ? {} : { timeout },
1369
+ ...signal === void 0 ? {} : { signal },
1370
+ onResult: (original) => {
1371
+ snapshot = captureMcpAppResult(tool, original);
1372
+ }
1373
+ });
1374
+ signal?.throwIfAborted();
1375
+ if (!runtime.isCurrent(services) || !this.isToolAvailable(tool)) throw new Error(`MCP tool ${name} is no longer available in the current session configuration.`);
1376
+ retainMcpAppResult(result, snapshot);
1377
+ return result;
1378
+ } finally {
1379
+ files.release();
1380
+ }
1381
+ }
1382
+ /**
1383
+ * What this extension hides right now.
1384
+ *
1385
+ * Rebuilt from scratch every time, so a server that failed drops out and one
1386
+ * that reconnected comes back.
1387
+ */
1388
+ currentRestriction() {
1389
+ const unavailable = new Set(this.incompatibleNames);
1390
+ for (const tool of this.catalog.allTools()) if (this.disconnectedServers.has(tool.serverName)) unavailable.add(tool.piName);
1391
+ return mcpToolRestriction(this.catalog, this.historicallyOwnedNames, unavailable);
1392
+ }
1393
+ updateToolVisibility() {
1394
+ this.toolRestriction?.update(this.currentRestriction());
1395
+ }
1396
+ startDetached() {
1397
+ this.start().catch((error) => {
1398
+ this.catalog.addDiagnostic(`Could not start the MCP runtime: ${error instanceof Error ? error.message : String(error)}`);
1399
+ this.emitChange();
1400
+ });
1401
+ }
1402
+ /**
1403
+ * Starts connecting. Never awaited on Pi's critical path.
1404
+ *
1405
+ * Resolves once the container exists, not once servers are reachable. A start
1406
+ * that a newer generation superseded resolves to nothing and registers nothing.
1407
+ */
1408
+ async start() {
1409
+ const generation = ++this.lifecycleGeneration;
1410
+ this.disconnectedServers.clear();
1411
+ this.outputSchemaWarnings = [];
1412
+ this.emitChange();
1413
+ if (this.configuredServerNames().length === 0) return;
1414
+ const [{ createTokenStore }, { McpRuntimeOwner }] = await Promise.all([Promise.resolve().then(() => keyringTokenStore_exports), import("../mcpCatalog-SlqWS-sb.mjs").then((n) => n.m)]);
1415
+ if (generation !== this.lifecycleGeneration) return;
1416
+ this.runtime ??= new McpRuntimeOwner();
1417
+ const runtime = this.runtime;
1418
+ this.resourceCache.clear();
1419
+ const configSources = this.configSources();
1420
+ const tokenStore = this.options.tokenStore ?? await createTokenStore();
1421
+ this.tokenStore = tokenStore;
1422
+ await runtime.start({
1423
+ configSources,
1424
+ workspaceRoot: this.workspaceRoot,
1425
+ executionCwd: this.executionCwd,
1426
+ environment: this.options.environment,
1427
+ tokenStore,
1428
+ onAuthorizationUrl: (url, serverName) => this.onAuthorizationUrl(url, serverName, generation),
1429
+ onServerStateChange: (change) => this.onServerStateChange(change, generation),
1430
+ onOutputSchemaWarning: (warning) => {
1431
+ if (generation !== this.lifecycleGeneration || this.disconnectedServers.has(warning.serverName) || !this.configuredServerNames().includes(warning.serverName)) return;
1432
+ if (this.outputSchemaWarnings.length >= 64) return;
1433
+ if (this.outputSchemaWarnings.some((current) => canonicalJson(current) === canonicalJson(warning))) return;
1434
+ this.outputSchemaWarnings.push(warning);
1435
+ this.emitChange();
1436
+ }
1437
+ });
1438
+ if (generation !== this.lifecycleGeneration) await runtime.dispose();
1439
+ }
1440
+ /**
1441
+ * Records the URL a flow is waiting on, then passes it to the host.
1442
+ *
1443
+ * Kept here because the status panel a user starts the flow from is where the
1444
+ * link is shown; hosts do not repeat it in the transcript. Host work is
1445
+ * detached so a failed desktop browser launch never cancels a still-usable OAuth
1446
+ * callback; the overlay retains a clickable fallback and a retry action.
1447
+ */
1448
+ onAuthorizationUrl(url, serverName, generation) {
1449
+ if (generation !== this.lifecycleGeneration || this.disconnectedServers.has(serverName)) return;
1450
+ this.authorizationUrls.set(serverName, url.toString());
1451
+ this.emitChange();
1452
+ const openBrowser = this.browserAuthorizationRequests.has(serverName);
1453
+ Promise.resolve().then(() => {
1454
+ if (generation !== this.lifecycleGeneration || this.disconnectedServers.has(serverName)) return;
1455
+ return this.options.onAuthorizationUrl?.(url, serverName, { openBrowser });
1456
+ }).catch((error) => {
1457
+ if (generation !== this.lifecycleGeneration) return;
1458
+ const detail = error instanceof Error ? error.message : String(error);
1459
+ this.catalog.addDiagnostic(`Could not open authorization page for "${serverName}": ${detail}`);
1460
+ this.emitChange();
1461
+ });
1462
+ }
1463
+ /** Ordered layers for the one in-process client container. */
1464
+ configSources() {
1465
+ return [...this.groups.sessionLocal.configSources, ...this.groups.shared.configSources];
1466
+ }
1467
+ configuredServerNames() {
1468
+ return [.../* @__PURE__ */ new Set([...this.groups.sessionLocal.serverNames, ...this.groups.shared.serverNames])];
1469
+ }
1470
+ /** Closes only this session's connection. Saved OAuth credentials are untouched. */
1471
+ async disconnect(serverName) {
1472
+ if (!this.configuredServerNames().includes(serverName)) throw new Error(`Unknown MCP server: ${serverName}`);
1473
+ const clientManager = this.getClientManager();
1474
+ if (!clientManager) throw new Error(RUNTIME_NOT_STARTED);
1475
+ const generation = this.lifecycleGeneration;
1476
+ this.disconnectedServers.add(serverName);
1477
+ try {
1478
+ await clientManager.disconnectServer(serverName);
1479
+ } catch (error) {
1480
+ if (generation === this.lifecycleGeneration) this.disconnectedServers.delete(serverName);
1481
+ throw error;
1482
+ }
1483
+ if (generation !== this.lifecycleGeneration) return;
1484
+ this.clearToolWarnings(serverName);
1485
+ this.resourceCache.delete(serverName);
1486
+ this.authorizationUrls.delete(serverName);
1487
+ this.browserAuthorizationRequests.delete(serverName);
1488
+ this.catalog.applyStateChange({
1489
+ serverName,
1490
+ state: "closed"
1491
+ }, []);
1492
+ this.emitChange();
1493
+ this.updateToolVisibility();
1494
+ }
1495
+ /**
1496
+ * Reconnects one server with a fresh OAuth registration.
1497
+ *
1498
+ * The connection is dropped before credentials are cleared so a failed disconnect
1499
+ * does not silently sign the user out. Clearing both tokens and dynamic client
1500
+ * information prevents a server-side registration reset from replaying a stale
1501
+ * `client_id` forever.
1502
+ */
1503
+ async reauthorize(serverName) {
1504
+ const clientManager = this.getClientManager();
1505
+ if (!clientManager) throw new Error(RUNTIME_NOT_STARTED);
1506
+ const generation = this.lifecycleGeneration;
1507
+ const tokenStore = this.tokenStore;
1508
+ this.disconnectedServers.delete(serverName);
1509
+ this.resourceCache.delete(serverName);
1510
+ if (this.authorizationUrls.delete(serverName)) this.emitChange();
1511
+ this.browserAuthorizationRequests.add(serverName);
1512
+ try {
1513
+ await clientManager.disconnectServer(serverName);
1514
+ if (generation !== this.lifecycleGeneration) return;
1515
+ await tokenStore?.clear(serverName);
1516
+ if (generation !== this.lifecycleGeneration) return;
1517
+ await clientManager.ensureConnected(serverName);
1518
+ } finally {
1519
+ if (generation === this.lifecycleGeneration) this.browserAuthorizationRequests.delete(serverName);
1520
+ }
1521
+ }
1522
+ /** Reopens the URL already reserved for a flow without restarting that flow. */
1523
+ async openAuthorizationPage(serverName) {
1524
+ const authorizationUrl = this.authorizationUrls.get(serverName);
1525
+ if (!authorizationUrl) throw new Error(AUTHORIZATION_URL_NOT_READY);
1526
+ const onAuthorizationUrl = this.options.onAuthorizationUrl;
1527
+ if (!onAuthorizationUrl) throw new Error("This host cannot open authorization pages.");
1528
+ await onAuthorizationUrl(new URL(authorizationUrl), serverName, { openBrowser: true });
1529
+ }
1530
+ getSnapshot() {
1531
+ return this.catalog.toSnapshot();
1532
+ }
1533
+ resolveToolSelectors(selectors) {
1534
+ return this.catalog.resolveToolSelectors(selectors);
1535
+ }
1536
+ /** The fuller picture the overlay renders, with tool names and descriptions. */
1537
+ getServers() {
1538
+ return this.catalog.toView().map((server) => {
1539
+ const authorizationUrl = this.authorizationUrls.get(server.name);
1540
+ return authorizationUrl ? {
1541
+ ...server,
1542
+ authorizationUrl
1543
+ } : server;
1544
+ });
1545
+ }
1546
+ /** Both the proxy tool and direct tools point to the same live warning origins. */
1547
+ getToolWarnings() {
1548
+ const entries = this.activeToolDefinitions().filter(isModelVisibleMcpTool).flatMap((tool) => {
1549
+ const warnings = this.outputSchemaWarnings.filter((warning) => warning.serverName === tool.serverName && warning.toolName === tool.toolName).map((warning) => ({
1550
+ source: `${warning.serverName}/${warning.toolName} (${warning.method})`,
1551
+ path: warning.path,
1552
+ message: warning.message
1553
+ }));
1554
+ return warnings.length === 0 ? [] : [[tool.piName, warnings]];
1555
+ });
1556
+ if (entries.length === 0) return {};
1557
+ return Object.fromEntries([...entries, ["mcp_use", entries.flatMap(([, warnings]) => warnings)]]);
1558
+ }
1559
+ clearToolWarnings(serverName, method, toolName) {
1560
+ const remaining = this.outputSchemaWarnings.filter((warning) => warning.serverName !== serverName || method !== void 0 && warning.method !== method || toolName !== void 0 && warning.toolName !== toolName);
1561
+ if (remaining.length === this.outputSchemaWarnings.length) return;
1562
+ this.outputSchemaWarnings = remaining;
1563
+ this.emitChange();
1564
+ }
1565
+ /**
1566
+ * Hides or restores a server's tools for the rest of this session.
1567
+ *
1568
+ * Deliberately does not disconnect: this is a session visibility control, so a
1569
+ * later enable is instant and does not discard an authenticated live connection.
1570
+ * Because each catalog row is now an actual downstream server, the control acts
1571
+ * on only that server's tools rather than on an aggregate proxy group.
1572
+ *
1573
+ * Nothing is re-registered on the way back: Pi 0.84 cannot unregister a tool, so
1574
+ * every tool stays registered and only the restriction moves.
1575
+ */
1576
+ setEnabled(serverName, enabled) {
1577
+ this.catalog.setDisabled(serverName, !enabled);
1578
+ this.emitChange();
1579
+ this.updateToolVisibility();
1580
+ }
1581
+ /**
1582
+ * Lists one server's resources, from cache unless asked for a fresh call.
1583
+ *
1584
+ * The cache lives here rather than in the overlay so closing and reopening the
1585
+ * panel does not re-dial. A rejection is never cached, so a server that has no
1586
+ * `resources/list` capability, or one that was briefly unreachable, is retried by
1587
+ * the next request rather than remembered as empty.
1588
+ */
1589
+ async listResources(serverName, options = {}) {
1590
+ if (this.disconnectedServers.has(serverName)) throw new Error(`MCP server is disconnected: ${serverName}`);
1591
+ if (!options.refresh) {
1592
+ const cached = this.resourceCache.get(serverName);
1593
+ if (cached) return cached;
1594
+ }
1595
+ const runtime = this.runtime;
1596
+ const services = runtime?.getServices();
1597
+ if (!runtime || !services) throw new Error(RUNTIME_NOT_STARTED);
1598
+ const resources = await (await services.clientManager.ensureConnected(serverName)).listResources();
1599
+ if (!runtime.isCurrent(services)) return resources;
1600
+ this.resourceCache.set(serverName, resources);
1601
+ this.catalog.setResourceCount(serverName, resources.length);
1602
+ this.emitChange();
1603
+ return resources;
1604
+ }
1605
+ /** Reads only the resource advertised by a currently admitted tool, on its existing server. */
1606
+ async readAppResource(server, tool, resourceUri, signal) {
1607
+ const declaration = this.activeToolDefinitions().find((candidate) => candidate.serverName === server && candidate.toolName === tool);
1608
+ if (!declaration || normalizeMcpAppMetadata(declaration._meta).resourceUri !== resourceUri) throw new Error("This App resource is not available in the current session.");
1609
+ const runtime = this.runtime;
1610
+ const services = runtime?.getServices();
1611
+ const generation = this.lifecycleGeneration;
1612
+ if (!runtime || !services) throw new Error(RUNTIME_NOT_STARTED);
1613
+ signal?.throwIfAborted();
1614
+ const connection = await services.clientManager.ensureConnected(server);
1615
+ const check = () => {
1616
+ signal?.throwIfAborted();
1617
+ if (generation !== this.lifecycleGeneration || !runtime.isCurrent(services) || !this.isToolAvailable(declaration)) throw new Error("This App resource belongs to a retired MCP runtime.");
1618
+ };
1619
+ check();
1620
+ const options = {
1621
+ timeout: services.clientManager.getServerRequestTimeout(server),
1622
+ signal
1623
+ };
1624
+ const result = await connection.readResource(resourceUri, options);
1625
+ check();
1626
+ return result;
1627
+ }
1628
+ /** Fires whenever the server picture changes. Returns its own disposer. */
1629
+ onChange(listener) {
1630
+ this.changeListeners.add(listener);
1631
+ return () => {
1632
+ this.changeListeners.delete(listener);
1633
+ };
1634
+ }
1635
+ /**
1636
+ * Announces a change to whoever is watching.
1637
+ *
1638
+ * Listener failures are contained rather than propagated: this runs inside the
1639
+ * continuation that folds a server into Pi's tool set, and a throw escaping here
1640
+ * would abandon that fold, leaving the session silently short of tools because
1641
+ * something failed to repaint.
1642
+ */
1643
+ emitChange() {
1644
+ for (const listener of this.changeListeners) try {
1645
+ listener();
1646
+ } catch (error) {
1647
+ this.catalog.addDiagnostic(`An MCP change listener failed: ${error instanceof Error ? error.message : String(error)}`);
1648
+ }
1649
+ }
1650
+ getDiagnostics() {
1651
+ return this.catalog.getDiagnostics();
1652
+ }
1653
+ async dispose() {
1654
+ ++this.lifecycleGeneration;
1655
+ this.browserAuthorizationRequests.clear();
1656
+ this.authorizationUrls.clear();
1657
+ this.resourceCache.clear();
1658
+ this.outputSchemaWarnings = [];
1659
+ const retiredRuntime = this.runtime;
1660
+ this.runtime = void 0;
1661
+ await retiredRuntime?.dispose();
1662
+ }
1663
+ /**
1664
+ * Folds one server transition into Pi's tool set.
1665
+ *
1666
+ * The tool list is read from the live connection rather than the state event,
1667
+ * which carries no tools; a server that is not readable contributes none and its
1668
+ * cached tools simply stay inactive.
1669
+ *
1670
+ * `ensureConnected` rather than `getClient`: the manager announces `connected`
1671
+ * before it registers the client, so a listener that looks the client up
1672
+ * synchronously finds nothing and the server lands with an empty tool list.
1673
+ * This handler runs detached from the connect, so awaiting it cannot deadlock.
1674
+ */
1675
+ onServerStateChange(change, generation) {
1676
+ if (generation !== this.lifecycleGeneration || this.disconnectedServers.has(change.serverName)) return;
1677
+ if (change.state === "connecting") this.clearToolWarnings(change.serverName);
1678
+ if (change.state !== "connecting" && change.state !== "needs-auth") this.authorizationUrls.delete(change.serverName);
1679
+ const runtime = this.runtime;
1680
+ const services = runtime?.getServices();
1681
+ (change.state === "connected" && services ? services.clientManager.ensureConnected(change.serverName).then((connection) => connection.listTools()) : Promise.resolve([])).then((tools) => ({
1682
+ tools,
1683
+ error: void 0
1684
+ }), () => ({
1685
+ tools: [],
1686
+ error: `Could not discover tools for MCP server "${change.serverName}".`
1687
+ })).then(({ tools, error }) => {
1688
+ if (generation !== this.lifecycleGeneration || !runtime?.isCurrent(services) || this.disconnectedServers.has(change.serverName)) return;
1689
+ if (error) this.catalog.addDiagnostic(error);
1690
+ this.catalog.applyStateChange(error ? {
1691
+ ...change,
1692
+ error
1693
+ } : change, tools);
1694
+ this.trackTools(this.catalog.allTools());
1695
+ this.emitChange();
1696
+ this.updateToolVisibility();
1697
+ });
1698
+ }
1699
+ };
1700
+ //#endregion
1701
+ //#region src/services/mcpToolCost/index.ts
1702
+ /**
1703
+ * What one downstream tool would cost if it were added to the model's tool list directly: its
1704
+ * name, description and input schema, priced the way the context panel prices every other tool.
1705
+ */
1706
+ function mcpToolTokens(tool, countTokens) {
1707
+ return countTokens(JSON.stringify({
1708
+ name: tool.piName,
1709
+ ...tool.description === void 0 ? {} : { description: tool.description },
1710
+ parameters: tool.inputSchema
1711
+ }));
1712
+ }
1713
+ //#endregion
1714
+ //#region src/services/mcpSessionApi/index.ts
1715
+ const JSON_HEADERS$1 = { "content-type": "application/json" };
1716
+ function json$1(body, status = 200) {
1717
+ return new Response(JSON.stringify(body), {
1718
+ status,
1719
+ headers: JSON_HEADERS$1
1720
+ });
1721
+ }
1722
+ /**
1723
+ * This package's session mount, `mcp-session`: one reachable tool's description and schema.
1724
+ *
1725
+ * Read-only and bounded to available tools, so a page can inspect what the agent may
1726
+ * call without the status line carrying every schema.
1727
+ */
1728
+ function createMcpSessionApi(source, countTokens) {
1729
+ return {
1730
+ basePath: "mcp-session",
1731
+ start() {
1732
+ return {
1733
+ async fetch(request) {
1734
+ const url = new URL(request.url);
1735
+ if (request.method !== "GET" || url.pathname !== "/tool") return json$1({ error: "Not found." }, 404);
1736
+ const server = url.searchParams.get("server") ?? "";
1737
+ const name = url.searchParams.get("tool") ?? "";
1738
+ if (!server || !name) return json$1({ error: "A tool is named by its server and tool name." }, 400);
1739
+ const tool = source.activeToolDefinitions().find((candidate) => candidate.serverName === server && candidate.toolName === name);
1740
+ if (!tool) return json$1({ error: `MCP tool ${server}/${name} is not available in this session.` }, 404);
1741
+ const warnings = source.getToolWarnings?.()[tool.piName];
1742
+ return json$1({
1743
+ server,
1744
+ tool: name,
1745
+ piName: tool.piName,
1746
+ ...warnings?.length ? { warnings } : {},
1747
+ ...tool.description === void 0 ? {} : { description: tool.description },
1748
+ inputSchema: tool.inputSchema,
1749
+ ...tool._meta === void 0 ? {} : { _meta: tool._meta },
1750
+ tokens: mcpToolTokens(tool, await countTokens())
1751
+ });
1752
+ },
1753
+ close: () => void 0
1754
+ };
1755
+ }
1756
+ };
1757
+ }
1758
+ //#endregion
1759
+ //#region src/services/serverRuntime/index.ts
1760
+ function sessionConfiguration(execution, workspaceRoot) {
1761
+ if (workspaceRoot !== void 0) {
1762
+ const loaded = loadHarnessState({ ...execution.environment });
1763
+ const projection = loaded.state.mcpProjection;
1764
+ if (loaded.filePath && loaded.state.root === workspaceRoot && execution.repoRoot === workspaceRoot && loaded.state.mcp && loaded.state.majorMode === execution.selection.majorMode && JSON.stringify(loaded.state.domains) === JSON.stringify(execution.selection.domains) && JSON.stringify(loaded.state.layers) === JSON.stringify(execution.selection.activeLayers) && isDoomMcpProjection(projection) && projection.repoRoot === workspaceRoot) return mcpSessionConfigFromProjection(projection, execution.cwd);
1765
+ return {
1766
+ enabled: false,
1767
+ repoRoot: execution.cwd,
1768
+ stagingDirectory: path.join(execution.cwd, ".po", "mcp-disabled"),
1769
+ sources: []
1770
+ };
1771
+ }
1772
+ return readSessionConfig(execution.environment, execution.cwd);
1773
+ }
1774
+ function createMcpServerRuntime(environment = {}, workspaceRoot, getResolver, publishFile) {
1775
+ let active;
1776
+ let currentSelection;
1777
+ let staleSelection = false;
1778
+ let cleanup;
1779
+ let transition = Promise.resolve();
1780
+ const authorizing = /* @__PURE__ */ new Map();
1781
+ const session = new McpSession({
1782
+ environment: { ...environment },
1783
+ ...publishFile === void 0 ? {} : { publishFile }
1784
+ });
1785
+ const assertAvailable = () => {
1786
+ if (!active) throw new Error("The MCP runtime has not started yet.");
1787
+ if (staleSelection) throw new Error("The MCP projection is stale for the current selection.");
1788
+ };
1789
+ const sessionTools = createMcpSessionToolsService(session, crypto.randomUUID(), assertAvailable);
1790
+ const refresh = (execution, selection) => {
1791
+ staleSelection = true;
1792
+ sessionTools.refresh();
1793
+ authorizing.clear();
1794
+ transition = transition.catch(() => {}).then(async () => {
1795
+ if (active !== execution) return;
1796
+ const previousCleanup = cleanup;
1797
+ cleanup = void 0;
1798
+ try {
1799
+ await session.dispose();
1800
+ } finally {
1801
+ session.install({
1802
+ enabled: false,
1803
+ repoRoot: execution.cwd,
1804
+ stagingDirectory: execution.cwd
1805
+ });
1806
+ await previousCleanup?.();
1807
+ }
1808
+ if (active !== execution) return;
1809
+ const resolver = getResolver?.();
1810
+ const loaded = workspaceRoot ? loadHarnessState({ ...execution.environment }) : void 0;
1811
+ const authorized = workspaceRoot && loaded?.filePath && loaded.state.root === workspaceRoot && execution.repoRoot === workspaceRoot && loaded.state.mcp && isDoomMcpProjection(loaded.state.mcpProjection) && loaded.state.mcpProjection.repoRoot === workspaceRoot && loaded.state.mcpProjection.enabled;
1812
+ if (!resolver || !authorized) {
1813
+ if (active === execution && selection === currentSelection) {
1814
+ if (workspaceRoot === void 0 || JSON.stringify(execution.selection.domains) === JSON.stringify(selection.domains)) await session.reconfigure(sessionConfiguration(execution, workspaceRoot), execution.cwd, execution.repoRoot);
1815
+ staleSelection = workspaceRoot !== void 0 && JSON.stringify(execution.selection.domains) !== JSON.stringify(selection.domains);
1816
+ }
1817
+ return;
1818
+ }
1819
+ const candidate = await resolver.resolve(selection.domains);
1820
+ if (active !== execution || selection !== currentSelection) {
1821
+ await candidate.cleanup();
1822
+ return;
1823
+ }
1824
+ try {
1825
+ if (!isDoomMcpProjection(candidate.projection) || candidate.projection.repoRoot !== workspaceRoot || !candidate.projection.enabled) throw new Error("The selected domains returned an invalid MCP projection.");
1826
+ await session.reconfigure(mcpSessionConfigFromProjection(candidate.projection, execution.cwd), execution.cwd, execution.repoRoot);
1827
+ cleanup = () => candidate.cleanup();
1828
+ if (active === execution && selection === currentSelection) staleSelection = false;
1829
+ } catch (error) {
1830
+ await candidate.cleanup();
1831
+ throw error;
1832
+ }
1833
+ }).then(() => sessionTools.refresh()).catch(async (error) => {
1834
+ if (active !== execution || selection !== currentSelection) return;
1835
+ await execution.client.notify({
1836
+ title: "Po MCP unavailable",
1837
+ body: error instanceof Error ? error.message : String(error),
1838
+ level: "warning"
1839
+ });
1840
+ });
1841
+ return transition;
1842
+ };
1843
+ let countTokens;
1844
+ let tokenizer;
1845
+ const loadTokenizer = () => {
1846
+ tokenizer ??= import("gpt-tokenizer").then((module) => {
1847
+ countTokens = module.countTokens;
1848
+ return module.countTokens;
1849
+ });
1850
+ return tokenizer;
1851
+ };
1852
+ const toolTokens = /* @__PURE__ */ new Map();
1853
+ /** Each reachable tool's schema cost, keyed by its registered name, once the tokenizer is loaded. */
1854
+ const reachableTokens = () => {
1855
+ if (countTokens === void 0) return toolTokens;
1856
+ for (const tool of session.activeToolDefinitions()) if (!toolTokens.has(tool.piName)) toolTokens.set(tool.piName, mcpToolTokens(tool, countTokens));
1857
+ return toolTokens;
1858
+ };
1859
+ const api = createMcpSessionApi(session, loadTokenizer);
1860
+ const activity = {
1861
+ name: "po-mcp-runtime",
1862
+ async start(execution) {
1863
+ active = execution;
1864
+ currentSelection = execution.selection;
1865
+ staleSelection = false;
1866
+ const reported = /* @__PURE__ */ new Set();
1867
+ const publish = () => {
1868
+ execution.client.setStatus(MCP_STATUS_KEY, session.getServers().map((server) => server.name).join(","));
1869
+ const tokens = reachableTokens();
1870
+ execution.client.setStatus(MCP_SESSION_AUTH_STATUS_KEY, formatMcpSessionAuthStatus(session.getServers().map((server) => ({
1871
+ ...server,
1872
+ tools: server.tools.map((tool) => {
1873
+ const cost = tokens.get(tool.piName);
1874
+ return cost === void 0 ? tool : {
1875
+ ...tool,
1876
+ tokens: cost
1877
+ };
1878
+ })
1879
+ }))));
1880
+ const warnings = session.getToolWarnings();
1881
+ execution.client.setStatus(CONTEXT_TOOL_WARNINGS_STATUS_KEY, Object.keys(warnings).length === 0 ? void 0 : JSON.stringify(warnings));
1882
+ for (const diagnostic of session.getDiagnostics()) {
1883
+ if (reported.has(diagnostic)) continue;
1884
+ reported.add(diagnostic);
1885
+ execution.client.notify({
1886
+ title: "Po MCP",
1887
+ body: diagnostic,
1888
+ level: "warning"
1889
+ });
1890
+ }
1891
+ };
1892
+ const stopPublishing = session.onChange(() => {
1893
+ toolTokens.clear();
1894
+ publish();
1895
+ });
1896
+ loadTokenizer().then(() => {
1897
+ if (active === execution) publish();
1898
+ });
1899
+ try {
1900
+ await refresh(execution, execution.selection);
1901
+ publish();
1902
+ } catch (error) {
1903
+ await execution.client.notify({
1904
+ title: "Po MCP unavailable",
1905
+ body: error instanceof Error ? error.message : String(error),
1906
+ level: "warning"
1907
+ });
1908
+ }
1909
+ return async () => {
1910
+ stopPublishing();
1911
+ if (active !== execution) return;
1912
+ active = void 0;
1913
+ currentSelection = void 0;
1914
+ staleSelection = true;
1915
+ sessionTools.refresh();
1916
+ authorizing.clear();
1917
+ await transition;
1918
+ const retiredCleanup = cleanup;
1919
+ cleanup = void 0;
1920
+ try {
1921
+ await session.dispose();
1922
+ } finally {
1923
+ session.install({
1924
+ enabled: false,
1925
+ repoRoot: execution.cwd,
1926
+ stagingDirectory: execution.cwd
1927
+ });
1928
+ await retiredCleanup?.();
1929
+ execution.client.setStatus(MCP_STATUS_KEY, void 0);
1930
+ execution.client.setStatus(MCP_SESSION_AUTH_STATUS_KEY, void 0);
1931
+ execution.client.setStatus(CONTEXT_TOOL_WARNINGS_STATUS_KEY, void 0);
1932
+ }
1933
+ };
1934
+ }
1935
+ };
1936
+ const invoke = async (parameters, signal) => {
1937
+ assertAvailable();
1938
+ const selected = sessionTools.snapshot().filter(isModelVisibleMcpTool).find((candidate) => candidate.serverName === parameters.server && candidate.toolName === parameters.tool);
1939
+ if (!selected) throw new Error(`MCP tool ${parameters.server}/${parameters.tool} is not available in this session.`);
1940
+ return sessionTools.invoke(selected.piName, parameters.arguments ?? {}, signal);
1941
+ };
1942
+ const childTool = createMcpChildTool(invoke, {
1943
+ snapshot: () => sessionTools.project().filter(isModelVisibleMcpTool),
1944
+ resolveSelectors: (selectors) => sessionTools.resolveSelectors(selectors),
1945
+ subscribe: (listener) => sessionTools.onChange(listener)
1946
+ });
1947
+ return {
1948
+ session,
1949
+ sessionTools,
1950
+ api: [api],
1951
+ activities: [activity],
1952
+ commands: [{
1953
+ name: "mcp",
1954
+ description: SERVER_COMMAND_DESCRIPTION,
1955
+ async execute(args, execution) {
1956
+ const [subcommand = "status", ...rest] = args.trim().split(/\s+/u).filter(Boolean);
1957
+ const serverName = rest.join(" ");
1958
+ const notify = (body, level = "info") => execution.client.notify({
1959
+ title: "Po MCP",
1960
+ body,
1961
+ level
1962
+ });
1963
+ try {
1964
+ if (subcommand === "status") {
1965
+ await notify(formatStatus(session));
1966
+ return;
1967
+ }
1968
+ if (!active) throw new Error("The MCP runtime has not started yet.");
1969
+ if (staleSelection) throw new Error("The MCP projection is stale for the current selection.");
1970
+ if (subcommand === "auth" || subcommand === "disconnect") {
1971
+ if (!serverName) throw new Error(`Name the server, for example /mcp ${subcommand} <server>.`);
1972
+ const server = session.getServers().find((candidate) => candidate.name === serverName);
1973
+ if (!server) throw new Error(`Unknown MCP server: ${serverName}`);
1974
+ if (subcommand === "disconnect") {
1975
+ await session.disconnect(serverName);
1976
+ authorizing.delete(serverName);
1977
+ await notify(`${serverName} is disconnected from this session. Saved credentials were kept.`);
1978
+ return;
1979
+ }
1980
+ if (server.authorizationUrl) {
1981
+ await notify(`${serverName} is waiting for sign-in. Open it from the MCP servers panel.`);
1982
+ return;
1983
+ }
1984
+ if (authorizing.has(serverName)) return;
1985
+ const request = Symbol(serverName);
1986
+ authorizing.set(serverName, request);
1987
+ const owner = active;
1988
+ session.reauthorize(serverName).then(async () => {
1989
+ if (active !== owner || authorizing.get(serverName) !== request) return;
1990
+ authorizing.delete(serverName);
1991
+ await notify(`${serverName} is authorized.`);
1992
+ }, async (error) => {
1993
+ if (active !== owner || authorizing.get(serverName) !== request) return;
1994
+ authorizing.delete(serverName);
1995
+ await notify(`Could not authorize ${serverName}: ${error instanceof Error ? error.message : String(error)}`, "warning");
1996
+ });
1997
+ return;
1998
+ }
1999
+ if (subcommand === "reload") {
2000
+ if (!currentSelection) throw new Error("The MCP runtime has not started yet.");
2001
+ await refresh(active, currentSelection);
2002
+ if (!staleSelection) await notify("Reconnecting MCP servers.");
2003
+ return;
2004
+ }
2005
+ throw new Error(`Unknown /mcp subcommand "${subcommand}". Use status, auth, disconnect, reload.`);
2006
+ } catch (error) {
2007
+ await notify(error instanceof Error ? error.message : String(error), "warning");
2008
+ }
2009
+ }
2010
+ }],
2011
+ tools: [{
2012
+ name: "mcp_use",
2013
+ label: "MCP use",
2014
+ description: "Call a tool exposed by a connected MCP server.",
2015
+ parameters: McpHeadlessToolParameters,
2016
+ executionMode: "serial",
2017
+ async execute(_toolCallId, parameters, signal) {
2018
+ try {
2019
+ return await invoke(parameters, signal);
2020
+ } catch (error) {
2021
+ return {
2022
+ content: [{
2023
+ type: "text",
2024
+ text: error instanceof Error ? error.message : String(error)
2025
+ }],
2026
+ isError: true
2027
+ };
2028
+ }
2029
+ }
2030
+ }],
2031
+ childTool,
2032
+ async onSelectionChange(selection) {
2033
+ if (!active || workspaceRoot === void 0) return;
2034
+ if (JSON.stringify(currentSelection) === JSON.stringify(selection)) return;
2035
+ currentSelection = selection;
2036
+ await refresh(active, selection);
2037
+ }
2038
+ };
2039
+ }
2040
+ //#endregion
2041
+ //#region src/extensions/workspaces/sessions/(backend)/_lib/serverRoot.ts
2042
+ const createMcpServerRoot = (context) => {
2043
+ let resolver;
2044
+ let appHost = context?.agent;
2045
+ const hostContext = context?.host.context;
2046
+ const publishFile = hostContext?.shareFile === void 0 ? void 0 : createConsentedFilePublisher({
2047
+ shareFile: (path, label) => hostContext.shareFile(path, label),
2048
+ host: () => appHost,
2049
+ signal: context?.signal
2050
+ });
2051
+ const runtime = createMcpServerRuntime(context?.agent?.context?.environment, context?.host.context.workspaceRoot, () => resolver, publishFile);
2052
+ const generation = `${context?.agent?.context?.sessionId ?? crypto.randomUUID()}:mcp-server`;
2053
+ const { session } = runtime;
2054
+ const tools = [...runtime.tools, ...createFileTransferTools(context)];
2055
+ const apps = createMcpAppsService(runtime.sessionTools, () => appHost, context?.signal);
2056
+ const mountSession = (cordis) => {
2057
+ cordis.inject([DOOM_MCP_PROJECTION_RESOLVER_SERVICE], (resolverContext) => {
2058
+ resolver = resolverContext.get(DOOM_MCP_PROJECTION_RESOLVER_SERVICE);
2059
+ return () => {
2060
+ resolver = void 0;
2061
+ };
2062
+ });
2063
+ cordis.inject([DOOM_HEADLESS_HOST_SERVICE], (hostContext) => {
2064
+ const host = hostContext.get(DOOM_HEADLESS_HOST_SERVICE);
2065
+ appHost = host;
2066
+ const registrations = /* @__PURE__ */ new Map();
2067
+ const reconcile = () => {
2068
+ const tools = runtime.sessionTools.project();
2069
+ const names = new Set(tools.map((tool) => tool.name));
2070
+ for (const [name, entry] of registrations) {
2071
+ if (names.has(name)) continue;
2072
+ entry.registration.dispose();
2073
+ registrations.delete(name);
2074
+ }
2075
+ for (const tool of tools) {
2076
+ const previous = registrations.get(tool.name);
2077
+ if (previous?.tool === tool) continue;
2078
+ previous?.registration.dispose();
2079
+ registrations.set(tool.name, {
2080
+ tool,
2081
+ registration: host.registerTool(tool)
2082
+ });
2083
+ }
2084
+ };
2085
+ const stopTools = runtime.sessionTools.onChange(reconcile);
2086
+ const stopSelection = host.subscribeSelection((selection) => runtime.onSelectionChange(selection));
2087
+ reconcile();
2088
+ return () => {
2089
+ if (appHost === host) appHost = void 0;
2090
+ stopTools();
2091
+ stopSelection();
2092
+ for (const entry of registrations.values()) entry.registration.dispose();
2093
+ registrations.clear();
2094
+ };
2095
+ });
2096
+ cordis.plugin((provider) => {
2097
+ provider.provide(DOOM_MCP_STATUS_SERVICE, {
2098
+ generation,
2099
+ getSnapshot: () => session.getSnapshot(),
2100
+ onChange: (listener) => session.onChange(listener)
2101
+ });
2102
+ provider.provide(DOOM_MCP_TOOL_RESOLVER_SERVICE, {
2103
+ generation,
2104
+ resolve: (selectors) => session.resolveToolSelectors(selectors)
2105
+ });
2106
+ provider.provide(MCP_SESSION_TOOLS_SERVICE, runtime.sessionTools);
2107
+ provider.provide(DOOM_CHILD_SESSION_MCP_TOOL_SERVICE, runtime.childTool);
2108
+ return () => runtime.sessionTools.dispose();
2109
+ });
2110
+ };
2111
+ return {
2112
+ value: {
2113
+ ...runtime,
2114
+ appMethods: apps.methods
2115
+ },
2116
+ activities: runtime.activities,
2117
+ services: [mountMcpHeadlessTools(tools), mountSession],
2118
+ onDispose: apps.dispose
2119
+ };
2120
+ };
2121
+ //#endregion
2122
+ //#region src/extensions/workspaces/sessions/(backend)/root.server.ts
2123
+ var root_server_default = defineRoot(createMcpServerRoot);
2124
+ //#endregion
2125
+ //#region src/services/keyringTokenStore/index.ts
2126
+ var keyringTokenStore_exports = /* @__PURE__ */ __exportAll({
2127
+ KEYRING_SERVICE: () => KEYRING_SERVICE,
2128
+ KeyringTokenStore: () => KeyringTokenStore,
2129
+ createTokenStore: () => createTokenStore
2130
+ });
2131
+ /** Keyring service name. One credential per downstream MCP server. */
2132
+ const KEYRING_SERVICE = "ai.agimon.doom-mcp.oauth";
2133
+ /**
2134
+ * OAuth credentials in the OS keyring, with an owner-only file store behind it.
2135
+ *
2136
+ * The keyring is a native module and the platform service it talks to may be absent
2137
+ * (a headless Linux box with no secret service, an unsupported architecture). A
2138
+ * missing credential is reported as `undefined` rather than raised, so a rejection
2139
+ * means the keyring itself is unusable and the store degrades to the file fallback
2140
+ * for the rest of the session instead of failing the run.
2141
+ */
2142
+ var KeyringTokenStore = class {
2143
+ createEntry;
2144
+ fallback;
2145
+ degraded = false;
2146
+ constructor(createEntry, fallback = new FileTokenStore()) {
2147
+ this.createEntry = createEntry;
2148
+ this.fallback = fallback;
2149
+ }
2150
+ /** True once the keyring has failed and the file store has taken over. */
2151
+ get isDegraded() {
2152
+ return this.degraded;
2153
+ }
2154
+ async read(serverName) {
2155
+ if (this.degraded) return this.fallback.read(serverName);
2156
+ let raw;
2157
+ try {
2158
+ raw = await this.createEntry(KEYRING_SERVICE, serverName).getPassword();
2159
+ } catch {
2160
+ this.degraded = true;
2161
+ return this.fallback.read(serverName);
2162
+ }
2163
+ if (!raw) return void 0;
2164
+ try {
2165
+ return JSON.parse(raw);
2166
+ } catch {
2167
+ return;
2168
+ }
2169
+ }
2170
+ async write(serverName, entry) {
2171
+ if (!this.degraded) try {
2172
+ await this.createEntry(KEYRING_SERVICE, serverName).setPassword(JSON.stringify(entry));
2173
+ return;
2174
+ } catch {
2175
+ this.degraded = true;
2176
+ }
2177
+ await this.fallback.write(serverName, entry);
2178
+ }
2179
+ /**
2180
+ * Removes the credential from both stores.
2181
+ *
2182
+ * A session that degraded part way through can have left a copy in either place,
2183
+ * and "signed out" has to mean no credential survives anywhere. A keyring that
2184
+ * refuses the delete is reported rather than absorbed: the caller was told the
2185
+ * credential is gone, and it is not. The fallback is still cleared first so the
2186
+ * failure leaves one surviving copy instead of two.
2187
+ */
2188
+ async clear(serverName) {
2189
+ let keyringFailure;
2190
+ if (!this.degraded) try {
2191
+ await this.createEntry(KEYRING_SERVICE, serverName).deletePassword();
2192
+ } catch (error) {
2193
+ keyringFailure = error;
2194
+ }
2195
+ await this.fallback.clear(serverName);
2196
+ if (keyringFailure !== void 0) {
2197
+ const detail = keyringFailure instanceof Error ? keyringFailure.message : JSON.stringify(keyringFailure);
2198
+ throw new Error(`Keyring credential for "${serverName}" could not be removed: ${detail}`, { cause: keyringFailure });
2199
+ }
2200
+ }
2201
+ };
2202
+ /**
2203
+ * Builds the token store for this host, falling back when the native module is absent.
2204
+ *
2205
+ * doom-mcp loads inside Pi, so a platform without a prebuilt keyring binary must
2206
+ * still get a working session rather than a failed extension install.
2207
+ */
2208
+ async function createTokenStore(fallback = new FileTokenStore()) {
2209
+ try {
2210
+ const { AsyncEntry } = await import("@napi-rs/keyring");
2211
+ return new KeyringTokenStore((service, account) => new AsyncEntry(service, account), fallback);
2212
+ } catch {
2213
+ return fallback;
2214
+ }
2215
+ }
2216
+ //#endregion
2217
+ //#region src/services/mcpSettingsManager/index.ts
2218
+ const DISCOVERY_TIMEOUT_MS = 3e4;
2219
+ const AUTHORIZATION_TIMEOUT_MS = 3e5;
2220
+ const TERMINAL_FLOW_RETENTION_MS = 3e5;
2221
+ const EMPTY_SYNC = {
2222
+ fresh: false,
2223
+ reasons: ["never-synced"]
2224
+ };
2225
+ function configuredServerNames(groups) {
2226
+ return [.../* @__PURE__ */ new Set([...groups.sessionLocal.serverNames, ...groups.shared.serverNames])].sort((left, right) => left.localeCompare(right));
2227
+ }
2228
+ function configSources(groups) {
2229
+ return [...groups.sessionLocal.configSources, ...groups.shared.configSources];
2230
+ }
2231
+ function toolView(serverName, tool) {
2232
+ return {
2233
+ name: tool.name,
2234
+ piName: toPiToolName(serverName, tool.name),
2235
+ ...tool.description ? { description: tool.description } : {}
2236
+ };
2237
+ }
2238
+ function sanitizeDiagnostic(message, repositoryRoot, stagingDirectory) {
2239
+ let safe = message;
2240
+ for (const value of [
2241
+ stagingDirectory,
2242
+ repositoryRoot,
2243
+ os.homedir()
2244
+ ]) if (value) safe = safe.split(value).join(value === os.homedir() ? "~" : "<repository>");
2245
+ return safe.replace(/[A-Za-z]:\\[^\s,;)'"`]+/gu, "<path>").replace(/(^|\s)\/(?!\/)[^\s,;)'"`]+/gu, "$1<path>");
2246
+ }
2247
+ function publicFlow(flow) {
2248
+ return {
2249
+ id: flow.id,
2250
+ repositoryId: flow.repositoryId,
2251
+ serverName: flow.serverName,
2252
+ status: flow.status,
2253
+ ...flow.authorizationUrl ? { authorizationUrl: flow.authorizationUrl } : {},
2254
+ ...flow.error ? { error: flow.error } : {},
2255
+ expiresAt: flow.expiresAt
2256
+ };
2257
+ }
2258
+ function isTerminal(status) {
2259
+ return status === "completed" || status === "failed" || status === "cancelled" || status === "expired";
2260
+ }
2261
+ function safeAuthorizationUrl(url) {
2262
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.toString().length > 4096) return void 0;
2263
+ return url.toString();
2264
+ }
2265
+ var McpSettingsManager = class {
2266
+ discoveryTimeoutMs;
2267
+ authorizationTimeoutMs;
2268
+ injectedTokenStore;
2269
+ busyRepositories = /* @__PURE__ */ new Set();
2270
+ flows = /* @__PURE__ */ new Map();
2271
+ constructor(options = {}) {
2272
+ this.discoveryTimeoutMs = options.discoveryTimeoutMs ?? DISCOVERY_TIMEOUT_MS;
2273
+ this.authorizationTimeoutMs = options.authorizationTimeoutMs ?? AUTHORIZATION_TIMEOUT_MS;
2274
+ this.injectedTokenStore = options.tokenStore;
2275
+ }
2276
+ async tokenStore() {
2277
+ return this.injectedTokenStore ?? await createTokenStore();
2278
+ }
2279
+ async credentialPresence(serverNames) {
2280
+ const presence = /* @__PURE__ */ new Map();
2281
+ try {
2282
+ const tokenStore = await this.tokenStore();
2283
+ await Promise.all(serverNames.map(async (serverName) => {
2284
+ try {
2285
+ presence.set(serverName, await tokenStore.read(serverName) !== void 0);
2286
+ } catch {
2287
+ presence.set(serverName, false);
2288
+ }
2289
+ }));
2290
+ } catch {
2291
+ for (const serverName of serverNames) presence.set(serverName, false);
2292
+ }
2293
+ return presence;
2294
+ }
2295
+ prepare(repositoryId, repositoryRoot, sync, onlyServer) {
2296
+ if (!sync?.mcpProjection) {
2297
+ const readiness = sync ?? EMPTY_SYNC;
2298
+ return {
2299
+ catalog: {
2300
+ repositoryId,
2301
+ sync: {
2302
+ fresh: readiness.fresh,
2303
+ reasons: [...readiness.reasons]
2304
+ },
2305
+ servers: [],
2306
+ droppedServers: [],
2307
+ diagnostics: []
2308
+ },
2309
+ configSources: [],
2310
+ groups: {
2311
+ shared: {
2312
+ configSources: [],
2313
+ configPaths: [],
2314
+ serverNames: []
2315
+ },
2316
+ sessionLocal: {
2317
+ configSources: [],
2318
+ configPaths: [],
2319
+ serverNames: []
2320
+ },
2321
+ droppedServers: [],
2322
+ diagnostics: []
2323
+ }
2324
+ };
2325
+ }
2326
+ const stagingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "po-mcp-settings-"));
2327
+ fs.chmodSync(stagingDirectory, 448);
2328
+ try {
2329
+ const configuration = {
2330
+ ...mcpSessionConfigFromProjection(sync.mcpProjection),
2331
+ repoRoot: repositoryRoot,
2332
+ stagingDirectory,
2333
+ ...onlyServer ? { allowlist: {
2334
+ servers: [onlyServer],
2335
+ proxy: []
2336
+ } } : {}
2337
+ };
2338
+ const groups = buildMcpConfigGroups(configuration);
2339
+ const sources = configSources(groups);
2340
+ const names = configuredServerNames(groups);
2341
+ const cachedByServer = new Map(readCachedCatalog(sources).servers.map((server) => [server.name, server.tools]));
2342
+ const servers = names.map((name) => {
2343
+ const tools = cachedByServer.get(name) ?? [];
2344
+ return {
2345
+ name,
2346
+ state: "not-connected",
2347
+ source: tools.length === 0 ? "configured" : "cached",
2348
+ credentialPresent: false,
2349
+ tools: tools.map((tool) => toolView(name, tool))
2350
+ };
2351
+ });
2352
+ return {
2353
+ catalog: {
2354
+ repositoryId,
2355
+ sync: {
2356
+ fresh: sync.fresh,
2357
+ reasons: [...sync.reasons]
2358
+ },
2359
+ servers,
2360
+ droppedServers: [...groups.droppedServers],
2361
+ diagnostics: groups.diagnostics.map((diagnostic) => sanitizeDiagnostic(diagnostic, repositoryRoot, stagingDirectory))
2362
+ },
2363
+ configSources: sources,
2364
+ groups,
2365
+ stagingDirectory
2366
+ };
2367
+ } catch (error) {
2368
+ fs.rmSync(stagingDirectory, {
2369
+ recursive: true,
2370
+ force: true
2371
+ });
2372
+ throw error;
2373
+ }
2374
+ }
2375
+ cleanupPrepared(prepared) {
2376
+ if (prepared.stagingDirectory) fs.rmSync(prepared.stagingDirectory, {
2377
+ recursive: true,
2378
+ force: true
2379
+ });
2380
+ }
2381
+ async readCatalog(repositoryId, repositoryRoot, sync) {
2382
+ const prepared = this.prepare(repositoryId, repositoryRoot, sync);
2383
+ try {
2384
+ const presence = await this.credentialPresence(prepared.catalog.servers.map((server) => server.name));
2385
+ return {
2386
+ ...prepared.catalog,
2387
+ servers: prepared.catalog.servers.map((server) => ({
2388
+ ...server,
2389
+ credentialPresent: presence.get(server.name) ?? false
2390
+ }))
2391
+ };
2392
+ } finally {
2393
+ this.cleanupPrepared(prepared);
2394
+ }
2395
+ }
2396
+ async discover(repositoryId, repositoryRoot, sync) {
2397
+ if (this.busyRepositories.has(repositoryId)) throw new Error("MCP management is already active for this repository.");
2398
+ const prepared = this.prepare(repositoryId, repositoryRoot, sync);
2399
+ if (!prepared.catalog.sync.fresh) {
2400
+ this.cleanupPrepared(prepared);
2401
+ throw new Error("Sync this repository before discovering MCP capabilities.");
2402
+ }
2403
+ if (prepared.configSources.length === 0) {
2404
+ this.cleanupPrepared(prepared);
2405
+ return prepared.catalog;
2406
+ }
2407
+ this.busyRepositories.add(repositoryId);
2408
+ const owner = new McpRuntimeOwner();
2409
+ const liveStates = /* @__PURE__ */ new Map();
2410
+ try {
2411
+ const handle = await owner.start({
2412
+ configSources: prepared.configSources,
2413
+ workspaceRoot: repositoryRoot,
2414
+ executionCwd: repositoryRoot,
2415
+ tokenStore: await this.tokenStore(),
2416
+ onAuthorizationUrl: (_url, serverName) => {
2417
+ liveStates.set(serverName, {
2418
+ serverName,
2419
+ state: "needs-auth"
2420
+ });
2421
+ },
2422
+ onServerStateChange: (change) => liveStates.set(change.serverName, change)
2423
+ });
2424
+ if (handle) await Promise.race([handle.connectionsSettled, new Promise((resolve) => setTimeout(resolve, this.discoveryTimeoutMs))]);
2425
+ const manager = handle?.services.clientManager;
2426
+ const cachedByServer = new Map(readCachedCatalog(prepared.configSources).servers.map((server) => [server.name, server.tools]));
2427
+ const presence = await this.credentialPresence(prepared.catalog.servers.map((server) => server.name));
2428
+ const servers = await Promise.all(prepared.catalog.servers.map(async (server) => {
2429
+ const change = liveStates.get(server.name);
2430
+ const state = change?.state ?? manager?.getServerState(server.name) ?? server.state;
2431
+ let tools = cachedByServer.get(server.name) ?? [];
2432
+ let source = tools.length === 0 ? "configured" : "cached";
2433
+ if (state === "connected" && manager) try {
2434
+ tools = await manager.ensureConnected(server.name).then(async (connection) => await connection.listTools());
2435
+ source = "live";
2436
+ } catch {}
2437
+ return {
2438
+ name: server.name,
2439
+ state,
2440
+ source,
2441
+ credentialPresent: presence.get(server.name) ?? false,
2442
+ tools: tools.map((tool) => toolView(server.name, tool)),
2443
+ ...change?.error ? { error: "The server reported a connection error." } : {}
2444
+ };
2445
+ }));
2446
+ return {
2447
+ ...prepared.catalog,
2448
+ servers
2449
+ };
2450
+ } finally {
2451
+ await owner.dispose();
2452
+ this.cleanupPrepared(prepared);
2453
+ this.busyRepositories.delete(repositoryId);
2454
+ }
2455
+ }
2456
+ async authorize(repositoryId, repositoryRoot, sync, serverName, redirect) {
2457
+ this.sweepFlows();
2458
+ if (this.busyRepositories.has(repositoryId)) throw new Error("MCP management is already active for this repository.");
2459
+ const full = this.prepare(repositoryId, repositoryRoot, sync);
2460
+ const configured = full.catalog.servers.some((server) => server.name === serverName);
2461
+ const fresh = full.catalog.sync.fresh;
2462
+ this.cleanupPrepared(full);
2463
+ if (!fresh) throw new Error("Sync this repository before authorizing an MCP server.");
2464
+ if (!configured) throw new Error("That MCP server is not enabled for this repository.");
2465
+ const prepared = this.prepare(repositoryId, repositoryRoot, sync, serverName);
2466
+ const flow = {
2467
+ id: randomUUID(),
2468
+ repositoryId,
2469
+ serverName,
2470
+ status: "starting",
2471
+ expiresAt: Date.now() + this.authorizationTimeoutMs,
2472
+ stagingDirectory: prepared.stagingDirectory
2473
+ };
2474
+ this.flows.set(flow.id, flow);
2475
+ this.busyRepositories.add(repositoryId);
2476
+ this.runAuthorization(flow, prepared.configSources, repositoryRoot, redirect);
2477
+ return publicFlow(flow);
2478
+ }
2479
+ getAuthorization(flowId, repositoryId) {
2480
+ this.sweepFlows();
2481
+ const flow = this.flows.get(flowId);
2482
+ return flow?.repositoryId === repositoryId ? publicFlow(flow) : void 0;
2483
+ }
2484
+ async cancelAuthorization(flowId, repositoryId) {
2485
+ const flow = this.flows.get(flowId);
2486
+ if (!flow || flow.repositoryId !== repositoryId) return void 0;
2487
+ if (!isTerminal(flow.status)) {
2488
+ flow.status = "cancelled";
2489
+ await this.finishFlow(flow);
2490
+ }
2491
+ return publicFlow(flow);
2492
+ }
2493
+ async runAuthorization(flow, sources, repositoryRoot, redirect) {
2494
+ const owner = new McpRuntimeOwner();
2495
+ flow.owner = owner;
2496
+ flow.timeout = setTimeout(() => {
2497
+ if (isTerminal(flow.status)) return;
2498
+ flow.status = "expired";
2499
+ flow.error = "Authorization timed out.";
2500
+ this.finishFlow(flow);
2501
+ }, this.authorizationTimeoutMs);
2502
+ try {
2503
+ const handle = await owner.start({
2504
+ configSources: sources,
2505
+ workspaceRoot: repositoryRoot,
2506
+ executionCwd: repositoryRoot,
2507
+ tokenStore: await this.tokenStore(),
2508
+ ...redirect ? { callbackServer: hubCallbackSink(redirect) } : {},
2509
+ onAuthorizationUrl: (url, serverName) => {
2510
+ if (serverName !== flow.serverName || isTerminal(flow.status)) return;
2511
+ const safeUrl = safeAuthorizationUrl(url);
2512
+ if (!safeUrl) {
2513
+ flow.status = "failed";
2514
+ flow.error = "The server returned an invalid authorization URL.";
2515
+ this.finishFlow(flow);
2516
+ return;
2517
+ }
2518
+ flow.authorizationUrl = safeUrl;
2519
+ flow.status = "waiting";
2520
+ },
2521
+ onServerStateChange: (change) => this.applyAuthorizationState(flow, change)
2522
+ });
2523
+ if (!handle || isTerminal(flow.status)) return;
2524
+ const manager = handle.services.clientManager;
2525
+ await manager.disconnectServer(flow.serverName);
2526
+ await manager.ensureConnected(flow.serverName);
2527
+ if (!isTerminal(flow.status)) flow.status = "completed";
2528
+ } catch {
2529
+ if (!isTerminal(flow.status)) {
2530
+ flow.status = "failed";
2531
+ flow.error = "The MCP server could not complete authorization.";
2532
+ }
2533
+ } finally {
2534
+ if (!isTerminal(flow.status)) flow.status = "completed";
2535
+ await this.finishFlow(flow);
2536
+ }
2537
+ }
2538
+ applyAuthorizationState(flow, change) {
2539
+ if (change.serverName !== flow.serverName || isTerminal(flow.status)) return;
2540
+ if (change.state === "connected") flow.status = "completed";
2541
+ else if (change.state === "needs-auth" && flow.status === "starting") flow.status = "waiting";
2542
+ else if (change.state === "failed") {
2543
+ flow.status = "failed";
2544
+ flow.error = "The MCP server rejected the authorization attempt.";
2545
+ }
2546
+ }
2547
+ async finishFlow(flow) {
2548
+ if (flow.timeout) clearTimeout(flow.timeout);
2549
+ flow.timeout = void 0;
2550
+ const owner = flow.owner;
2551
+ flow.owner = void 0;
2552
+ await owner?.dispose();
2553
+ if (flow.stagingDirectory) fs.rmSync(flow.stagingDirectory, {
2554
+ recursive: true,
2555
+ force: true
2556
+ });
2557
+ flow.stagingDirectory = void 0;
2558
+ this.busyRepositories.delete(flow.repositoryId);
2559
+ flow.retainedUntil = Date.now() + TERMINAL_FLOW_RETENTION_MS;
2560
+ }
2561
+ sweepFlows() {
2562
+ const now = Date.now();
2563
+ for (const [id, flow] of this.flows) if (flow.retainedUntil !== void 0 && flow.retainedUntil <= now) this.flows.delete(id);
2564
+ }
2565
+ async dispose() {
2566
+ await Promise.all([...this.flows.values()].map(async (flow) => {
2567
+ if (!isTerminal(flow.status)) flow.status = "cancelled";
2568
+ await this.finishFlow(flow);
2569
+ }));
2570
+ this.flows.clear();
2571
+ this.busyRepositories.clear();
2572
+ }
2573
+ };
2574
+ //#endregion
2575
+ //#region src/types/apiRoutes.ts
2576
+ /** One authorization flow, addressed by id below the flow collection. */
2577
+ const AUTHORIZATION_FLOW_PATH = `${MCP_AUTHORIZATION_API_PATH}/:${MCP_FLOW_ID_PARAM}`;
2578
+ /**
2579
+ * This package's routes, as data.
2580
+ *
2581
+ * No scope and no base path. The build reads both off
2582
+ * `src/extensions/(backend)/api/mcp/`, so the mount is stated once, by the
2583
+ * folder that creates it. The sub-paths come from `webMcp`, which already owned
2584
+ * them, so this table names where a route sits and nothing else.
2585
+ *
2586
+ * This is the one file the hub handler and the settings panel both read, so a
2587
+ * path cannot move on one side alone. The handler matches on these strings
2588
+ * rather than parsing its own copies of them.
2589
+ *
2590
+ * `:flowId` is the client's parameter syntax; the handler reads the segment off
2591
+ * the same path, so the two cannot disagree about where the id sits.
2592
+ */
2593
+ var apiRoutes_default = defineApiRoutes({
2594
+ catalog: {
2595
+ method: "GET",
2596
+ path: MCP_REPOSITORY_API_PATH,
2597
+ query: [MCP_REPOSITORY_ID_QUERY],
2598
+ response: apiResponse()
2599
+ },
2600
+ discover: {
2601
+ method: "POST",
2602
+ path: MCP_DISCOVERY_API_PATH,
2603
+ response: apiResponse()
2604
+ },
2605
+ authorize: {
2606
+ method: "POST",
2607
+ path: MCP_AUTHORIZATION_API_PATH,
2608
+ response: apiResponse()
2609
+ },
2610
+ readAuthorization: {
2611
+ method: "GET",
2612
+ path: AUTHORIZATION_FLOW_PATH,
2613
+ query: [MCP_REPOSITORY_ID_QUERY],
2614
+ response: apiResponse()
2615
+ },
2616
+ /** The same path as `readAuthorization`; the method is the whole of the difference. */
2617
+ cancelAuthorization: {
2618
+ method: "DELETE",
2619
+ path: AUTHORIZATION_FLOW_PATH,
2620
+ query: [MCP_REPOSITORY_ID_QUERY],
2621
+ response: apiResponse()
2622
+ },
2623
+ /** Session scope: one available tool's registered name, description and schema. */
2624
+ sessionTool: {
2625
+ method: "GET",
2626
+ path: MCP_SESSION_TOOL_API_PATH,
2627
+ query: [MCP_SESSION_TOOL_SERVER_QUERY, MCP_SESSION_TOOL_NAME_QUERY],
2628
+ response: apiResponse()
2629
+ }
2630
+ });
2631
+ //#endregion
2632
+ //#region src/services/mcpHubApi/index.ts
2633
+ /**
2634
+ * What sits above a flow id, read off the flow route itself.
2635
+ *
2636
+ * This handler matches paths by hand rather than through a router, so the one
2637
+ * route carrying a parameter is split here: everything before `:flowId` is the
2638
+ * prefix a request must start with, and what follows it is the id.
2639
+ */
2640
+ const AUTHORIZATION_FLOW_PREFIX = apiRoutes_default.readAuthorization.path.replace(`:${MCP_FLOW_ID_PARAM}`, "");
2641
+ const JSON_HEADERS = { "content-type": "application/json" };
2642
+ const REPOSITORY_ID = /^(?:[a-f0-9]{32}|repo-[A-Za-z0-9_-]{24}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/u;
2643
+ const FLOW_ID = /^[a-f0-9-]{36}$/u;
2644
+ const MAX_BODY_BYTES = 8192;
2645
+ const MAX_SERVER_NAME = 160;
2646
+ const manager = new McpSettingsManager();
2647
+ function json(body, status = 200) {
2648
+ return new Response(JSON.stringify(body), {
2649
+ status,
2650
+ headers: JSON_HEADERS
2651
+ });
2652
+ }
2653
+ function isRecord(value) {
2654
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2655
+ }
2656
+ async function bodyOf(request) {
2657
+ const text = await request.text();
2658
+ if (text.length > MAX_BODY_BYTES) return void 0;
2659
+ try {
2660
+ const parsed = JSON.parse(text);
2661
+ return isRecord(parsed) ? parsed : void 0;
2662
+ } catch {
2663
+ return;
2664
+ }
2665
+ }
2666
+ function repositoryIdOf(value) {
2667
+ return typeof value === "string" && REPOSITORY_ID.test(value) ? value : void 0;
2668
+ }
2669
+ function serverNameOf(value) {
2670
+ return typeof value === "string" && value.length > 0 && value.length <= MAX_SERVER_NAME && !Array.from(value).some((character) => character.charCodeAt(0) <= 31) ? value : void 0;
2671
+ }
2672
+ function resolveRepository(context, repositoryId) {
2673
+ return context.resolveRepository?.(repositoryId);
2674
+ }
2675
+ function errorStatus(error) {
2676
+ const message = error instanceof Error ? error.message : "";
2677
+ if (message.includes("already active")) return {
2678
+ status: 409,
2679
+ message
2680
+ };
2681
+ if (message.startsWith("Sync this repository")) return {
2682
+ status: 409,
2683
+ message
2684
+ };
2685
+ if (message.includes("not enabled")) return {
2686
+ status: 404,
2687
+ message
2688
+ };
2689
+ return {
2690
+ status: 500,
2691
+ message: "MCP management could not complete that request."
2692
+ };
2693
+ }
2694
+ async function handleCatalog(request, context) {
2695
+ const repositoryId = repositoryIdOf(new URL(request.url).searchParams.get(MCP_REPOSITORY_ID_QUERY));
2696
+ if (!repositoryId) return json({ error: "A valid repositoryId is required." }, 400);
2697
+ const repositoryRoot = resolveRepository(context, repositoryId);
2698
+ if (!repositoryRoot) return json({ error: "That repository is not available to this hub." }, 404);
2699
+ try {
2700
+ return json(await manager.readCatalog(repositoryId, repositoryRoot, await context.readRepositorySync?.(repositoryId)));
2701
+ } catch {
2702
+ return json({ error: "The synced MCP catalog could not be read." }, 500);
2703
+ }
2704
+ }
2705
+ async function handleDiscovery(request, context) {
2706
+ const repositoryId = repositoryIdOf((await bodyOf(request))?.repositoryId);
2707
+ if (!repositoryId) return json({ error: "A valid repositoryId is required." }, 400);
2708
+ const repositoryRoot = resolveRepository(context, repositoryId);
2709
+ if (!repositoryRoot) return json({ error: "That repository is not available to this hub." }, 404);
2710
+ try {
2711
+ return json(await manager.discover(repositoryId, repositoryRoot, await context.readRepositorySync?.(repositoryId)));
2712
+ } catch (error) {
2713
+ const failure = errorStatus(error);
2714
+ return json({ error: failure.message }, failure.status);
2715
+ }
2716
+ }
2717
+ async function handleAuthorize(request, context) {
2718
+ const body = await bodyOf(request);
2719
+ const repositoryId = repositoryIdOf(body?.repositoryId);
2720
+ const serverName = serverNameOf(body?.serverName);
2721
+ if (!repositoryId || !serverName) return json({ error: "A valid repositoryId and serverName are required." }, 400);
2722
+ const repositoryRoot = resolveRepository(context, repositoryId);
2723
+ if (!repositoryRoot) return json({ error: "That repository is not available to this hub." }, 404);
2724
+ try {
2725
+ return json(await manager.authorize(repositoryId, repositoryRoot, await context.readRepositorySync?.(repositoryId), serverName, context.oauthRedirect?.()), 202);
2726
+ } catch (error) {
2727
+ const failure = errorStatus(error);
2728
+ return json({ error: failure.message }, failure.status);
2729
+ }
2730
+ }
2731
+ async function handleAuthorizationFlow(request, context, path) {
2732
+ const flowId = path.slice(AUTHORIZATION_FLOW_PREFIX.length);
2733
+ const repositoryId = repositoryIdOf(new URL(request.url).searchParams.get(MCP_REPOSITORY_ID_QUERY));
2734
+ if (!repositoryId || !FLOW_ID.test(flowId)) return json({ error: "A valid repositoryId and flow id are required." }, 400);
2735
+ if (!resolveRepository(context, repositoryId)) return json({ error: "That repository is not available to this hub." }, 404);
2736
+ if (request.method === apiRoutes_default.cancelAuthorization.method) {
2737
+ const flow = await manager.cancelAuthorization(flowId, repositoryId);
2738
+ return flow ? json(flow) : json({ error: "That authorization flow was not found." }, 404);
2739
+ }
2740
+ const flow = manager.getAuthorization(flowId, repositoryId);
2741
+ return flow ? json(flow) : json({ error: "That authorization flow was not found." }, 404);
2742
+ }
2743
+ //#endregion
2744
+ //#region src/extensions/(backend)/api/mcp/route.server.ts
2745
+ /**
2746
+ * Mounts this package's HTTP API at `mcp`, which the folder names.
2747
+ *
2748
+ * The folder is the declaration: the build reads the base path off it and hands
2749
+ * the browser a client bound to the same segment, so the settings panel can no
2750
+ * longer spell a workspace mount by hand.
2751
+ */
2752
+ var route_server_default$1 = defineRoute({
2753
+ basePath: "mcp",
2754
+ start(context) {
2755
+ return {
2756
+ async fetch(request) {
2757
+ const path = new URL(request.url).pathname;
2758
+ if (request.method === apiRoutes_default.catalog.method && path === apiRoutes_default.catalog.path) return await handleCatalog(request, context);
2759
+ if (request.method === apiRoutes_default.discover.method && path === apiRoutes_default.discover.path) return await handleDiscovery(request, context);
2760
+ if (request.method === apiRoutes_default.authorize.method && path === apiRoutes_default.authorize.path) return await handleAuthorize(request, context);
2761
+ if ((request.method === apiRoutes_default.readAuthorization.method || request.method === apiRoutes_default.cancelAuthorization.method) && path.startsWith(AUTHORIZATION_FLOW_PREFIX)) return await handleAuthorizationFlow(request, context, path);
2762
+ return json({ error: "Not found." }, 404);
2763
+ },
2764
+ close() {
2765
+ manager.dispose();
2766
+ }
2767
+ };
2768
+ }
2769
+ });
2770
+ //#endregion
2771
+ //#region src/extensions/workspaces/sessions/(backend)/api/mcp-session/route.server.ts
2772
+ /** Mounts the session's `mcp-session` API: the reachable tool detail the context panel opens. */
2773
+ var route_server_default = defineRoute((context) => context.root.api[0]);
2774
+ //#endregion
2775
+ //#region src/extensions/workspaces/sessions/(backend)/command/mcp.server.ts
2776
+ var mcp_server_default = defineServerCommand((context) => context.root.commands[0]);
2777
+ //#endregion
2778
+ //#region src/extensions/workspaces/sessions/(backend)/method/apps.server.ts
2779
+ var apps_server_default = defineRoutedContribution((context) => context.root.appMethods, { cardinality: "many" });
2780
+ //#endregion
2781
+ //#region src/extensions/workspaces/sessions/(backend)/resource/po-use-mcp.server.ts
2782
+ var po_use_mcp_server_default = defineResource({
2783
+ name: "po-use-mcp",
2784
+ description: "Inspect domain-scoped MCP servers, authenticate, reload configuration, and troubleshoot tool availability.",
2785
+ when: DOOM_HELP_WHEN,
2786
+ kind: "skill",
2787
+ read: () => readPackageResource(import.meta.url, "src/prompts/po-use-mcp/SKILL.md")
2788
+ });
2789
+ //#endregion
2790
+ //#region generated/server.ts
2791
+ const at = (value, context) => typeof value === "function" ? value(context) : value;
2792
+ const many = (value, context) => {
2793
+ const resolved = typeof value === "function" ? value(context) : value;
2794
+ return Array.isArray(resolved) ? resolved : [resolved];
2795
+ };
2796
+ const via = (identity, value) => value === void 0 ? void 0 : {
2797
+ ...identity,
2798
+ ...value
2799
+ };
2800
+ const facet = defineServerPlugin({
2801
+ name: "@agimon-ai/po-mcp",
2802
+ providerOverrides: composeServerProviderOverrides(root_server_default),
2803
+ global: (context) => ({ get api() {
2804
+ return [at(route_server_default$1, context)];
2805
+ } }),
2806
+ workspace: (context) => ({ get api() {
2807
+ return [at(route_server_default$1, context)];
2808
+ } }),
2809
+ session: async (context) => {
2810
+ const scopeSession = await root_server_default(context);
2811
+ const contextSession = {
2812
+ ...context,
2813
+ root: scopeSession.value
2814
+ };
2815
+ return {
2816
+ ...composeRootHooks(scopeSession),
2817
+ get api() {
2818
+ return [at(route_server_default$1, context), at(route_server_default, contextSession)];
2819
+ },
2820
+ get commands() {
2821
+ return [via({ name: "mcp" }, at(mcp_server_default, contextSession))];
2822
+ },
2823
+ get methods() {
2824
+ return [...many(apps_server_default, contextSession)];
2825
+ },
2826
+ get resources() {
2827
+ return [at(po_use_mcp_server_default, contextSession)];
2828
+ },
2829
+ services: [...scopeSession.services ?? []],
2830
+ get activities() {
2831
+ return [...scopeSession.activities ?? []];
2832
+ }
2833
+ };
2834
+ }
2835
+ });
2836
+ //#endregion
2837
+ export { facet as default, facet };
2838
+
2839
+ //# sourceMappingURL=server.mjs.map