@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.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/dist/apiContracts.cjs +269 -0
- package/dist/apiContracts.cjs.map +1 -0
- package/dist/apiContracts.d.mts +137 -0
- package/dist/apiContracts.d.mts.map +1 -0
- package/dist/apiContracts.mjs +258 -0
- package/dist/apiContracts.mjs.map +1 -0
- package/dist/extensions/mcp-ui.mjs +31 -0
- package/dist/extensions/mcp-ui.mjs.map +1 -0
- package/dist/extensions/mcp.cjs +104 -0
- package/dist/extensions/mcp.cjs.map +1 -0
- package/dist/extensions/mcp.d.mts +5 -0
- package/dist/extensions/mcp.d.mts.map +1 -0
- package/dist/extensions/mcp.mjs +99 -0
- package/dist/extensions/mcp.mjs.map +1 -0
- package/dist/extensions/server.cjs +2852 -0
- package/dist/extensions/server.cjs.map +1 -0
- package/dist/extensions/server.d.mts +5 -0
- package/dist/extensions/server.d.mts.map +1 -0
- package/dist/extensions/server.mjs +2839 -0
- package/dist/extensions/server.mjs.map +1 -0
- package/dist/extensions/web.mjs +2200 -0
- package/dist/extensions/web.mjs.map +1 -0
- package/dist/index.cjs +18 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.mts +89 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +9 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mcpCatalog-6Tpt6Gqn.cjs +900 -0
- package/dist/mcpCatalog-6Tpt6Gqn.cjs.map +1 -0
- package/dist/mcpCatalog-SlqWS-sb.mjs +806 -0
- package/dist/mcpCatalog-SlqWS-sb.mjs.map +1 -0
- package/dist/mcpConfig-BYESy-zP.d.mts +57 -0
- package/dist/mcpConfig-BYESy-zP.d.mts.map +1 -0
- package/dist/projection-gyAA2jRB.mjs +43 -0
- package/dist/projection-gyAA2jRB.mjs.map +1 -0
- package/dist/projection-w3XxXmVi.cjs +51 -0
- package/dist/projection-w3XxXmVi.cjs.map +1 -0
- package/dist/projection.cjs +17 -0
- package/dist/projection.d.mts +12 -0
- package/dist/projection.d.mts.map +1 -0
- package/dist/projection.mjs +4 -0
- package/dist/rolldown-runtime-C0BPl7ul.cjs +43 -0
- package/llms.txt +9 -0
- package/package.json +157 -0
- 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
|