zam-core 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,520 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createServer } from "node:http";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ CanvasError,
7
+ createCanvas,
8
+ joinSession,
9
+ } from "@github/copilot-sdk/extension";
10
+ import { connectZam } from "./mcp-client.bundle.mjs";
11
+
12
+ const extensionDir = dirname(fileURLToPath(import.meta.url));
13
+ const hostBundlePromise = readFile(
14
+ join(extensionDir, "host.bundle.js"),
15
+ "utf8",
16
+ );
17
+
18
+ const APP_CONFIG = {
19
+ studio: {
20
+ title: "ZAM Studio",
21
+ toolName: "zam_open_studio",
22
+ allowedTools: new Set(["zam_studio_bridge"]),
23
+ },
24
+ recall: {
25
+ title: "ZAM Recall",
26
+ toolName: "zam_open_recall",
27
+ allowedTools: new Set(["zam_get_reviews", "zam_submit_review"]),
28
+ },
29
+ graph: {
30
+ title: "ZAM Graph",
31
+ toolName: "zam_show_graph",
32
+ allowedTools: new Set(["zam_studio_bridge"]),
33
+ },
34
+ settings: {
35
+ title: "ZAM Settings",
36
+ toolName: "zam_open_settings",
37
+ allowedTools: new Set(["zam_studio_bridge"]),
38
+ },
39
+ };
40
+
41
+ const servers = new Map();
42
+ const modelContexts = new Map();
43
+ let mcpConnectionPromise;
44
+
45
+ async function getLaunchConfig() {
46
+ if (process.env.ZAM_BIN) {
47
+ return { command: process.env.ZAM_BIN, args: ["mcp"] };
48
+ }
49
+
50
+ const path = join(extensionDir, "launch.json");
51
+ const config = JSON.parse(await readFile(path, "utf8"));
52
+ if (
53
+ !config ||
54
+ typeof config.command !== "string" ||
55
+ !Array.isArray(config.args) ||
56
+ !config.args.every((arg) => typeof arg === "string")
57
+ ) {
58
+ throw new Error(`Invalid ZAM launch configuration: ${path}`);
59
+ }
60
+ return { command: config.command, args: config.args };
61
+ }
62
+
63
+ async function getMcpClient() {
64
+ if (!mcpConnectionPromise) {
65
+ mcpConnectionPromise = getLaunchConfig()
66
+ .then(connectZam)
67
+ .catch((error) => {
68
+ mcpConnectionPromise = undefined;
69
+ throw error;
70
+ });
71
+ }
72
+ return (await mcpConnectionPromise).client;
73
+ }
74
+
75
+ function compactObject(value) {
76
+ return Object.fromEntries(
77
+ Object.entries(value).filter(([, entry]) => entry !== undefined),
78
+ );
79
+ }
80
+
81
+ function buildToolArguments(kind, input) {
82
+ const common = { user: input?.user };
83
+ if (kind === "recall") {
84
+ return compactObject({ ...common, domain: input?.domain });
85
+ }
86
+ if (kind === "graph") {
87
+ return compactObject({ ...common, focus: input?.focus });
88
+ }
89
+ return compactObject(common);
90
+ }
91
+
92
+ function toolUiResourceUri(tool) {
93
+ const nested = tool?._meta?.ui?.resourceUri;
94
+ const legacy = tool?._meta?.["ui/resourceUri"];
95
+ return typeof nested === "string"
96
+ ? nested
97
+ : typeof legacy === "string"
98
+ ? legacy
99
+ : undefined;
100
+ }
101
+
102
+ async function prepareApp(kind, input) {
103
+ const config = APP_CONFIG[kind];
104
+ const client = await getMcpClient();
105
+ const { tools } = await client.listTools();
106
+ const tool = tools.find((candidate) => candidate.name === config.toolName);
107
+ if (!tool) {
108
+ throw new Error(`ZAM MCP tool not found: ${config.toolName}`);
109
+ }
110
+
111
+ const resourceUri = toolUiResourceUri(tool);
112
+ if (!resourceUri) {
113
+ throw new Error(
114
+ `${config.toolName} does not advertise an MCP App resource`,
115
+ );
116
+ }
117
+
118
+ const toolArguments = buildToolArguments(kind, input);
119
+ const [toolResult, resourceResult] = await Promise.all([
120
+ client.callTool({
121
+ name: config.toolName,
122
+ arguments: toolArguments,
123
+ }),
124
+ client.readResource({ uri: resourceUri }),
125
+ ]);
126
+
127
+ if (toolResult.isError) {
128
+ const text = toolResult.content?.find((item) => item.type === "text")?.text;
129
+ throw new Error(text || `${config.toolName} failed`);
130
+ }
131
+
132
+ const resource =
133
+ resourceResult.contents?.find((item) => item.uri === resourceUri) ??
134
+ resourceResult.contents?.[0];
135
+ const appHtml =
136
+ typeof resource?.text === "string"
137
+ ? resource.text
138
+ : typeof resource?.blob === "string"
139
+ ? Buffer.from(resource.blob, "base64").toString("utf8")
140
+ : "";
141
+ if (!appHtml) {
142
+ throw new Error(`MCP App resource is empty: ${resourceUri}`);
143
+ }
144
+
145
+ return {
146
+ appHtml,
147
+ config,
148
+ kind,
149
+ resourceUri,
150
+ tool,
151
+ toolArguments,
152
+ toolResult,
153
+ };
154
+ }
155
+
156
+ function send(response, status, contentType, body, headers = {}) {
157
+ response.writeHead(status, {
158
+ "Cache-Control": "no-store",
159
+ "Content-Type": contentType,
160
+ "X-Content-Type-Options": "nosniff",
161
+ ...headers,
162
+ });
163
+ response.end(body);
164
+ }
165
+
166
+ function sendJson(response, status, value) {
167
+ send(
168
+ response,
169
+ status,
170
+ "application/json; charset=utf-8",
171
+ JSON.stringify(value),
172
+ );
173
+ }
174
+
175
+ function sendError(response, error, status = 500) {
176
+ sendJson(response, status, {
177
+ error: error instanceof Error ? error.message : String(error),
178
+ });
179
+ }
180
+
181
+ async function readJsonBody(request) {
182
+ const chunks = [];
183
+ let size = 0;
184
+ for await (const chunk of request) {
185
+ size += chunk.length;
186
+ if (size > 1_000_000) {
187
+ throw new Error("Request body exceeds 1 MB");
188
+ }
189
+ chunks.push(chunk);
190
+ }
191
+ const text = Buffer.concat(chunks).toString("utf8");
192
+ return text ? JSON.parse(text) : {};
193
+ }
194
+
195
+ function escapeHtml(value) {
196
+ return value
197
+ .replaceAll("&", "&")
198
+ .replaceAll("<", "&lt;")
199
+ .replaceAll(">", "&gt;");
200
+ }
201
+
202
+ function renderHostHtml(title) {
203
+ const escapedTitle = escapeHtml(title);
204
+ return `<!doctype html>
205
+ <html lang="en">
206
+ <head>
207
+ <meta charset="utf-8" />
208
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
209
+ <title>${escapedTitle}</title>
210
+ <style>
211
+ :root { color-scheme: light dark; }
212
+ * { box-sizing: border-box; }
213
+ html, body { width: 100%; height: 100%; margin: 0; }
214
+ body {
215
+ overflow: hidden;
216
+ background: var(--background-color-default, Canvas);
217
+ color: var(--text-color-default, CanvasText);
218
+ font-family: var(--font-sans, system-ui, sans-serif);
219
+ }
220
+ #status {
221
+ position: absolute;
222
+ inset: 0;
223
+ display: grid;
224
+ place-items: center;
225
+ color: var(--text-color-muted, #656d76);
226
+ font-size: var(--text-body-medium, 14px);
227
+ }
228
+ #app {
229
+ display: block;
230
+ width: 100%;
231
+ height: 100%;
232
+ border: 0;
233
+ background: transparent;
234
+ }
235
+ body.ready #status { display: none; }
236
+ </style>
237
+ </head>
238
+ <body>
239
+ <div id="status" role="status">Connecting MCP App...</div>
240
+ <iframe
241
+ id="app"
242
+ title="${escapedTitle}"
243
+ sandbox="allow-scripts allow-forms"
244
+ ></iframe>
245
+ <script type="module" src="/host.bundle.js"></script>
246
+ </body>
247
+ </html>`;
248
+ }
249
+
250
+ async function startServer(instanceId, app) {
251
+ const hostBundle = await hostBundlePromise;
252
+ const client = await getMcpClient();
253
+ const hostStatus = { phase: "server-ready" };
254
+ const server = createServer(async (request, response) => {
255
+ const url = new URL(request.url || "/", "http://127.0.0.1");
256
+ try {
257
+ if (request.method === "GET" && url.pathname === "/") {
258
+ send(
259
+ response,
260
+ 200,
261
+ "text/html; charset=utf-8",
262
+ renderHostHtml(app.config.title),
263
+ {
264
+ "Content-Security-Policy":
265
+ "default-src 'none'; script-src 'self'; frame-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'",
266
+ },
267
+ );
268
+ return;
269
+ }
270
+ if (request.method === "GET" && url.pathname === "/host.bundle.js") {
271
+ send(response, 200, "text/javascript; charset=utf-8", hostBundle);
272
+ return;
273
+ }
274
+ if (request.method === "GET" && url.pathname === "/app") {
275
+ send(response, 200, "text/html; charset=utf-8", app.appHtml);
276
+ return;
277
+ }
278
+ if (request.method === "GET" && url.pathname === "/api/bootstrap") {
279
+ sendJson(response, 200, {
280
+ resourceUri: app.resourceUri,
281
+ title: app.config.title,
282
+ tool: app.tool,
283
+ toolArguments: app.toolArguments,
284
+ toolResult: app.toolResult,
285
+ });
286
+ return;
287
+ }
288
+ if (request.method === "POST" && url.pathname === "/api/tool") {
289
+ const body = await readJsonBody(request);
290
+ if (
291
+ typeof body.name !== "string" ||
292
+ !app.config.allowedTools.has(body.name)
293
+ ) {
294
+ sendError(
295
+ response,
296
+ new Error(`Tool is not allowed for ${app.kind}: ${body.name}`),
297
+ 403,
298
+ );
299
+ return;
300
+ }
301
+ const result = await client.callTool({
302
+ name: body.name,
303
+ arguments:
304
+ body.arguments &&
305
+ typeof body.arguments === "object" &&
306
+ !Array.isArray(body.arguments)
307
+ ? body.arguments
308
+ : {},
309
+ });
310
+ sendJson(response, 200, result);
311
+ return;
312
+ }
313
+ if (request.method === "POST" && url.pathname === "/api/model-context") {
314
+ const body = await readJsonBody(request);
315
+ modelContexts.set(instanceId, body);
316
+ sendJson(response, 200, {});
317
+ return;
318
+ }
319
+ if (request.method === "POST" && url.pathname === "/api/host-status") {
320
+ const body = await readJsonBody(request);
321
+ Object.assign(hostStatus, body, {
322
+ updatedAt: new Date().toISOString(),
323
+ });
324
+ sendJson(response, 200, {});
325
+ return;
326
+ }
327
+ sendJson(response, 404, { error: "Not found" });
328
+ } catch (error) {
329
+ sendError(response, error);
330
+ }
331
+ });
332
+
333
+ await new Promise((resolve, reject) => {
334
+ server.once("error", reject);
335
+ server.listen(0, "127.0.0.1", () => {
336
+ server.off("error", reject);
337
+ resolve();
338
+ });
339
+ });
340
+
341
+ const address = server.address();
342
+ const port = typeof address === "object" && address ? address.port : 0;
343
+ return {
344
+ app,
345
+ hostStatus,
346
+ server,
347
+ url: `http://127.0.0.1:${port}/`,
348
+ };
349
+ }
350
+
351
+ async function openApp(kind, context) {
352
+ let entry = servers.get(context.instanceId);
353
+ if (!entry) {
354
+ try {
355
+ const app = await prepareApp(kind, context.input ?? {});
356
+ entry = await startServer(context.instanceId, app);
357
+ servers.set(context.instanceId, entry);
358
+ } catch (error) {
359
+ throw new CanvasError(
360
+ "zam_mcp_app_open_failed",
361
+ error instanceof Error ? error.message : String(error),
362
+ );
363
+ }
364
+ }
365
+ return {
366
+ title: APP_CONFIG[kind].title,
367
+ status: `Connected via ${entry.app.resourceUri}`,
368
+ url: entry.url,
369
+ };
370
+ }
371
+
372
+ async function closeApp(context) {
373
+ const entry = servers.get(context.instanceId);
374
+ if (!entry) return;
375
+ servers.delete(context.instanceId);
376
+ modelContexts.delete(context.instanceId);
377
+ await new Promise((resolve) => entry.server.close(resolve));
378
+ }
379
+
380
+ const commonInputProperties = {
381
+ user: {
382
+ type: "string",
383
+ description: "ZAM user ID; defaults to the configured ZAM identity.",
384
+ },
385
+ };
386
+
387
+ function connectionStatusAction() {
388
+ return {
389
+ name: "connection_status",
390
+ description:
391
+ "Report whether the embedded ZAM MCP App completed its host handshake.",
392
+ handler: (context) => {
393
+ const entry = servers.get(context.instanceId);
394
+ if (!entry) {
395
+ throw new CanvasError(
396
+ "zam_mcp_app_not_open",
397
+ "This ZAM MCP App canvas is not open.",
398
+ );
399
+ }
400
+ return {
401
+ ...entry.hostStatus,
402
+ resourceUri: entry.app.resourceUri,
403
+ toolName: entry.app.config.toolName,
404
+ };
405
+ },
406
+ };
407
+ }
408
+
409
+ let shutdownPromise;
410
+
411
+ function shutdown() {
412
+ if (!shutdownPromise) {
413
+ shutdownPromise = (async () => {
414
+ const entries = [...servers.values()];
415
+ servers.clear();
416
+ modelContexts.clear();
417
+ await Promise.all(
418
+ entries.map(
419
+ (entry) => new Promise((resolve) => entry.server.close(resolve)),
420
+ ),
421
+ );
422
+ if (mcpConnectionPromise) {
423
+ const connection = await mcpConnectionPromise.catch(() => undefined);
424
+ await connection?.client.close().catch(() => {});
425
+ }
426
+ })();
427
+ }
428
+ return shutdownPromise;
429
+ }
430
+
431
+ function exitAfterShutdown() {
432
+ void shutdown().finally(() => process.exit(0));
433
+ }
434
+
435
+ process.once("SIGTERM", exitAfterShutdown);
436
+ process.once("SIGINT", exitAfterShutdown);
437
+
438
+ await joinSession({
439
+ canvases: [
440
+ createCanvas({
441
+ id: "zam-studio",
442
+ displayName: "ZAM Studio",
443
+ description:
444
+ "Open the original ZAM Studio MCP App in a hosted Copilot canvas.",
445
+ inputSchema: {
446
+ type: "object",
447
+ properties: commonInputProperties,
448
+ additionalProperties: false,
449
+ },
450
+ actions: [connectionStatusAction()],
451
+ open: (context) => openApp("studio", context),
452
+ onClose: closeApp,
453
+ }),
454
+ createCanvas({
455
+ id: "zam-recall",
456
+ displayName: "ZAM Recall",
457
+ description:
458
+ "Open the original spoiler-free ZAM Recall MCP App in a hosted Copilot canvas.",
459
+ inputSchema: {
460
+ type: "object",
461
+ properties: {
462
+ ...commonInputProperties,
463
+ domain: {
464
+ type: "string",
465
+ description: "Optional domain prefix for the recall queue.",
466
+ },
467
+ },
468
+ additionalProperties: false,
469
+ },
470
+ actions: [connectionStatusAction()],
471
+ open: (context) => openApp("recall", context),
472
+ onClose: closeApp,
473
+ }),
474
+ createCanvas({
475
+ id: "zam-graph",
476
+ displayName: "ZAM Graph",
477
+ description:
478
+ "Open the original interactive ZAM knowledge-graph MCP App in a hosted Copilot canvas.",
479
+ inputSchema: {
480
+ type: "object",
481
+ properties: {
482
+ ...commonInputProperties,
483
+ focus: {
484
+ type: "string",
485
+ description: "Token slug to center the graph on.",
486
+ },
487
+ },
488
+ additionalProperties: false,
489
+ },
490
+ actions: [connectionStatusAction()],
491
+ open: (context) => openApp("graph", context),
492
+ onClose: closeApp,
493
+ }),
494
+ createCanvas({
495
+ id: "zam-settings",
496
+ displayName: "ZAM Settings",
497
+ description:
498
+ "Open the original ZAM Settings MCP App in a hosted Copilot canvas.",
499
+ inputSchema: {
500
+ type: "object",
501
+ properties: commonInputProperties,
502
+ additionalProperties: false,
503
+ },
504
+ actions: [connectionStatusAction()],
505
+ open: (context) => openApp("settings", context),
506
+ onClose: closeApp,
507
+ }),
508
+ ],
509
+ hooks: {
510
+ onUserPromptSubmitted: async () => {
511
+ if (modelContexts.size === 0) return;
512
+ const context = [...modelContexts.values()]
513
+ .map((value) => JSON.stringify(value))
514
+ .join("\n");
515
+ return {
516
+ additionalContext: `Current state from open ZAM MCP Apps:\n${context}`,
517
+ };
518
+ },
519
+ },
520
+ });