@aprovan/mcp-app-server 0.1.0-dev.4d82df8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +23 -0
  2. package/E2E_TESTING.md +224 -0
  3. package/LICENSE +373 -0
  4. package/README.md +164 -0
  5. package/dist/index.d.ts +128 -0
  6. package/dist/index.js +990 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/runtime/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  9. package/dist/runtime/assets/index-tRNuU9ap.js +1059 -0
  10. package/dist/runtime/index.html +38 -0
  11. package/dist/server.d.ts +6 -0
  12. package/dist/server.js +1511 -0
  13. package/dist/server.js.map +1 -0
  14. package/dist/shell/shell.js +67 -0
  15. package/docs/widget-preview.png +0 -0
  16. package/e2e/__snapshots__/.gitkeep +0 -0
  17. package/e2e/global-setup.ts +114 -0
  18. package/e2e/global-teardown.ts +15 -0
  19. package/e2e/visual-regression.test.ts +86 -0
  20. package/e2e/widget-smoke.test.ts +109 -0
  21. package/index.html +32 -0
  22. package/package.json +51 -0
  23. package/playwright.config.ts +43 -0
  24. package/src/__tests__/live-update.test.ts +158 -0
  25. package/src/__tests__/local-backend.test.ts +100 -0
  26. package/src/__tests__/memory-backend.ts +144 -0
  27. package/src/__tests__/registry-backend.test.ts +256 -0
  28. package/src/__tests__/services.test.ts +153 -0
  29. package/src/__tests__/shim.test.ts +60 -0
  30. package/src/__tests__/widget-store.test.ts +188 -0
  31. package/src/e2e-visual.ts +148 -0
  32. package/src/index.ts +608 -0
  33. package/src/live-update.ts +150 -0
  34. package/src/logger.ts +44 -0
  35. package/src/reference-widgets/live-dashboard.ts +162 -0
  36. package/src/registry-backend.ts +306 -0
  37. package/src/runtime/index.html +38 -0
  38. package/src/runtime/main.ts +164 -0
  39. package/src/scripts/export-artifacts.ts +51 -0
  40. package/src/server.ts +398 -0
  41. package/src/services.ts +307 -0
  42. package/src/shell/main.ts +172 -0
  43. package/src/shim.ts +106 -0
  44. package/src/tunnel.ts +123 -0
  45. package/src/widget-store/index.ts +10 -0
  46. package/src/widget-store/local-backend.ts +77 -0
  47. package/src/widget-store/store.ts +242 -0
  48. package/src/widget-store/types.ts +53 -0
  49. package/tsconfig.json +10 -0
  50. package/tsup.config.ts +14 -0
  51. package/vite.runtime.config.ts +28 -0
  52. package/vite.shell.config.ts +30 -0
  53. package/vitest.config.ts +7 -0
package/src/index.ts ADDED
@@ -0,0 +1,608 @@
1
+ import {
2
+ createProjectFromFiles,
3
+ createSingleFileProject,
4
+ type Manifest,
5
+ type VirtualFile,
6
+ type VirtualProject,
7
+ } from "@aprovan/patchwork-compiler";
8
+ import {
9
+ registerAppTool,
10
+ RESOURCE_MIME_TYPE,
11
+ } from "@modelcontextprotocol/ext-apps/server";
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ import { z } from "zod";
14
+ import {
15
+ subscribeSession,
16
+ unsubscribeSession as _unsubscribeSession,
17
+ getEvents,
18
+ pushStreamUpdate,
19
+ currentSeq,
20
+ type StreamEvent,
21
+ } from "./live-update.js";
22
+ import { warn } from "./logger.js";
23
+ import {
24
+ ServiceBridge,
25
+ type ServiceBackend,
26
+ type ServiceToolInfo,
27
+ type ServiceBridgeConfig,
28
+ } from "./services.js";
29
+ import { getWidgetStore } from "./widget-store/index.js";
30
+
31
+ export type { ServiceBackend, ServiceToolInfo, ServiceBridgeConfig };
32
+ export type { StreamEvent };
33
+ export { pushStreamUpdate };
34
+
35
+ const DEFAULT_WIDGET_PORT = Number(process.env["WIDGET_PORT"] ?? 3002);
36
+ const DEFAULT_WIDGET_HOST = process.env["WIDGET_HOST"] ?? "localhost";
37
+ const DEFAULT_WIDGET_BASE_URL = `http://${DEFAULT_WIDGET_HOST}:${DEFAULT_WIDGET_PORT}`;
38
+
39
+ interface WidgetRef {
40
+ name: string;
41
+ hash: string;
42
+ entry: string;
43
+ }
44
+
45
+ /**
46
+ * Generate the MCP App resource document.
47
+ *
48
+ * Per the MCP Apps protocol the resource document itself must be the app that
49
+ * connects to the host, and it runs under a strict CSP with no `unsafe-eval` —
50
+ * so esbuild-wasm cannot run here. The resource therefore loads the bundled
51
+ * ext-apps "shell" (served from the widget host, allow-listed via
52
+ * `resourceDomains`) which connects to the host and embeds the CSP-free runtime
53
+ * iframe that actually compiles the widget. The widget + inputs are passed to
54
+ * the shell via a base64 `data-config` attribute (no inline script → CSP-safe).
55
+ */
56
+ function generateResourceHtml(
57
+ shellUrl: string,
58
+ runtimeUrl: string,
59
+ widget: WidgetRef,
60
+ inputs: Record<string, unknown>,
61
+ ): string {
62
+ const config = JSON.stringify({
63
+ runtime: runtimeUrl,
64
+ widget: `${widget.name}/${widget.hash}`,
65
+ inputs,
66
+ });
67
+ const configB64 = Buffer.from(config, "utf-8").toString("base64");
68
+ return `<!DOCTYPE html>
69
+ <html lang="en">
70
+ <head>
71
+ <meta charset="utf-8" />
72
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
73
+ <title>${widget.name}</title>
74
+ <style>
75
+ * { margin: 0; padding: 0; box-sizing: border-box; }
76
+ html, body { width: 100%; }
77
+ #pw-root { width: 100%; }
78
+ </style>
79
+ </head>
80
+ <body>
81
+ <div id="pw-root"></div>
82
+ <script src="${shellUrl}" data-config="${configB64}"></script>
83
+ </body>
84
+ </html>`;
85
+ }
86
+
87
+ /** Stable, non-cryptographic content hash used as the widget store key. */
88
+ function hashFiles(files: VirtualFile[], manifest: Manifest): string {
89
+ const input = JSON.stringify({
90
+ name: manifest.name,
91
+ image: manifest.image,
92
+ files: files.map((f) => [f.path, f.content]),
93
+ });
94
+ let hash = 0;
95
+ for (let i = 0; i < input.length; i++) {
96
+ hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
97
+ }
98
+ return Math.abs(hash).toString(16).padStart(8, "0");
99
+ }
100
+
101
+ const MANIFEST_DEFAULTS: Manifest = {
102
+ name: "widget",
103
+ version: "0.1.0",
104
+ platform: "browser",
105
+ image: "@aprovan/patchwork-image-shadcn",
106
+ };
107
+
108
+ /**
109
+ * Build the CSP the host applies to the resource document.
110
+ *
111
+ * - `resourceDomains` (→ `script-src`) must allow the external shell bundle.
112
+ * - `frameDomains` (→ `frame-src`) must allow the nested runtime iframe.
113
+ *
114
+ * Both live on the widget host origin, so a single allow-listed origin covers
115
+ * them. Entries are scheme-qualified per the CSP spec.
116
+ */
117
+ function buildCspConfig(
118
+ widgetBaseUrl: string,
119
+ ): { frameDomains: string[]; resourceDomains: string[] } | undefined {
120
+ try {
121
+ const url = new URL(widgetBaseUrl);
122
+ // Use the exact origin (scheme + host + port). The shell bundle and the
123
+ // nested runtime iframe both live on this single origin, so one entry covers
124
+ // script-src (resourceDomains) and frame-src (frameDomains). Hosts enforce
125
+ // the resource CSP strictly and drop broad wildcard hosts like
126
+ // `https://*.trycloudflare.com`, which would leave script-src without the
127
+ // shell origin and block the bootstrap script — so never wildcard.
128
+ const origin = url.port
129
+ ? `${url.protocol}//${url.hostname}:${url.port}`
130
+ : `${url.protocol}//${url.hostname}`;
131
+ return { frameDomains: [origin], resourceDomains: [origin] };
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ }
136
+
137
+ function buildManifest(input?: Record<string, unknown>): Manifest {
138
+ return {
139
+ name: (input?.["name"] as string) ?? MANIFEST_DEFAULTS.name,
140
+ version: (input?.["version"] as string) ?? MANIFEST_DEFAULTS.version,
141
+ platform: "browser",
142
+ image: (input?.["image"] as string) ?? MANIFEST_DEFAULTS.image,
143
+ services: input?.["services"] as string[] | undefined,
144
+ };
145
+ }
146
+
147
+ const DEFAULT_WIDGET_SOURCE =
148
+ "export default function Widget() { return <div>Hello Patchwork</div>; }";
149
+
150
+ function buildProject(
151
+ name: string,
152
+ source?: string,
153
+ files?: Array<{ path: string; content: string }>,
154
+ entry?: string
155
+ ): VirtualProject {
156
+ if (files && files.length > 0) {
157
+ const virtualFiles: VirtualFile[] = files.map((f) => ({
158
+ path: f.path,
159
+ content: f.content,
160
+ }));
161
+ const project = createProjectFromFiles(virtualFiles, name);
162
+ if (entry) project.entry = entry;
163
+ return project;
164
+ }
165
+ return createSingleFileProject(source ?? DEFAULT_WIDGET_SOURCE, entry ?? "main.tsx", name);
166
+ }
167
+
168
+ export interface McpAppServerOptions {
169
+ services?: ServiceBridgeConfig;
170
+ /** Base URL for serving widgets (e.g., tunnel URL). Defaults to localhost:3002. */
171
+ widgetBaseUrl?: string;
172
+ }
173
+
174
+ export function createMcpAppServer(options: McpAppServerOptions = {}): McpServer {
175
+ const server = new McpServer({
176
+ name: "patchwork-mcp-app-server",
177
+ version: "0.1.0",
178
+ });
179
+
180
+ const serviceBridge = options.services ? new ServiceBridge(options.services) : null;
181
+ const widgetBaseUrl = options.widgetBaseUrl ?? DEFAULT_WIDGET_BASE_URL;
182
+
183
+ const runtimeUrl = `${widgetBaseUrl}/runtime/`;
184
+ const shellUrl = `${widgetBaseUrl}/shell/shell.js`;
185
+ const directUrl = (name: string, hash: string): string =>
186
+ `${runtimeUrl}?widget=${encodeURIComponent(name)}/${encodeURIComponent(hash)}`;
187
+
188
+ const store = getWidgetStore();
189
+
190
+ /** Build the MCP App resource document that renders a stored widget. */
191
+ const renderResource = (ref: WidgetRef, inputs: Record<string, unknown>) => {
192
+ const csp = buildCspConfig(widgetBaseUrl);
193
+ return {
194
+ type: "resource" as const,
195
+ resource: {
196
+ uri: store.resourceUriFor(ref.name, ref.hash),
197
+ mimeType: RESOURCE_MIME_TYPE,
198
+ text: generateResourceHtml(shellUrl, runtimeUrl, ref, inputs),
199
+ ...(csp ? { _meta: { ui: { csp } } } : {}),
200
+ },
201
+ };
202
+ };
203
+
204
+ registerAppTool(
205
+ server,
206
+ "save_widget",
207
+ {
208
+ description:
209
+ "Save a JSX/TSX widget's raw source files for reuse and render it as an MCP App resource. " +
210
+ "Pass source code for a single-file widget, or a files array for a multi-file project. " +
211
+ "The widget is stored uncompiled and compiled in the browser by the shared Patchwork " +
212
+ "runtime when rendered — pass `inputs` to supply startup props to the widget.",
213
+ inputSchema: {
214
+ source: z
215
+ .string()
216
+ .optional()
217
+ .describe(
218
+ "JSX/TSX source code for a single-file widget. Must export a default React component."
219
+ ),
220
+ files: z
221
+ .array(
222
+ z.object({
223
+ path: z.string().describe("File path relative to project root (e.g. 'main.tsx')"),
224
+ content: z.string().describe("File contents"),
225
+ })
226
+ )
227
+ .optional()
228
+ .describe(
229
+ "Array of files for a multi-file widget project. At least one file should be the entry point (main.tsx or index.tsx)."
230
+ ),
231
+ entry: z
232
+ .string()
233
+ .optional()
234
+ .describe("Entry point file path. Defaults to auto-detection (main.tsx, index.tsx)."),
235
+ name: z.string().optional().describe("Widget name for the manifest. Defaults to 'widget'."),
236
+ image: z
237
+ .string()
238
+ .optional()
239
+ .describe(
240
+ "Patchwork image package to use. Defaults to '@aprovan/patchwork-image-shadcn'."
241
+ ),
242
+ services: z
243
+ .array(z.string())
244
+ .optional()
245
+ .describe(
246
+ "Service namespaces the widget calls (e.g., ['weather', 'stripe']). " +
247
+ "A proxy shim is injected so widget code can call namespace.procedure(args) directly."
248
+ ),
249
+ inputs: z
250
+ .record(z.unknown())
251
+ .optional()
252
+ .describe("Startup props passed to the widget's default export when it is rendered."),
253
+ },
254
+ _meta: {
255
+ ui: { resourceUri: "ui://widgets/{name}/{hash}/view.html" },
256
+ },
257
+ },
258
+ async (args) => {
259
+ const source = args?.["source"] as string | undefined;
260
+ const files = args?.["files"] as Array<{ path: string; content: string }> | undefined;
261
+ const entry = args?.["entry"] as string | undefined;
262
+ const requestedServices = args?.["services"] as string[] | undefined;
263
+ const inputs = (args?.["inputs"] as Record<string, unknown> | undefined) ?? {};
264
+
265
+ const manifestInput: Record<string, unknown> = {};
266
+ if (args?.["name"]) manifestInput["name"] = args["name"];
267
+ if (args?.["image"]) manifestInput["image"] = args["image"];
268
+
269
+ // Validate requested services against the connected backend.
270
+ let services = requestedServices ?? [];
271
+ if (services.length > 0 && serviceBridge) {
272
+ const availableNamespaces = serviceBridge.getNamespaces();
273
+ const unavailable = services.filter((ns) => !availableNamespaces.includes(ns));
274
+ if (unavailable.length > 0) {
275
+ warn(
276
+ "mcp-app-server",
277
+ `Requested services not available: ${unavailable.join(", ")}. Available: ${availableNamespaces.join(", ")}`
278
+ );
279
+ }
280
+ services = services.filter((ns) => availableNamespaces.includes(ns));
281
+ }
282
+ if (services.length > 0) manifestInput["services"] = services;
283
+
284
+ const manifest = buildManifest(manifestInput);
285
+ const project = buildProject(manifest.name, source, files, entry);
286
+ const projectFiles = Array.from(project.files.values());
287
+
288
+ try {
289
+ const hash = hashFiles(projectFiles, manifest);
290
+ await store.saveWidget(hash, projectFiles, manifest, project.entry);
291
+
292
+ const ref: WidgetRef = { name: manifest.name, hash, entry: project.entry };
293
+
294
+ return {
295
+ content: [
296
+ renderResource(ref, inputs),
297
+ {
298
+ type: "text" as const,
299
+ text:
300
+ `Widget "${manifest.name}" saved. Hash: ${hash}\n` +
301
+ `Compiled in-browser at: ${directUrl(manifest.name, hash)}`,
302
+ },
303
+ ],
304
+ };
305
+ } catch (error) {
306
+ const message = error instanceof Error ? error.message : String(error);
307
+ return {
308
+ content: [
309
+ {
310
+ type: "text" as const,
311
+ text: `Failed to save widget: ${message}`,
312
+ },
313
+ ],
314
+ isError: true,
315
+ };
316
+ }
317
+ }
318
+ );
319
+
320
+ if (serviceBridge) {
321
+ // Register search_services and call_service tools only (not individual service tools)
322
+ // This avoids exposing hundreds of tools with names that may exceed 64 chars
323
+ serviceBridge.registerSearchServices(server);
324
+ }
325
+
326
+ registerAppTool(
327
+ server,
328
+ "list_widgets",
329
+ {
330
+ description:
331
+ "List all persisted widgets in the VFS widget store. " +
332
+ "Returns each widget's name, version, description, path, and resource URI.",
333
+ _meta: { ui: { resourceUri: "ui://widgets/list" } },
334
+ },
335
+ async () => {
336
+ const widgets = await store.listWidgets();
337
+
338
+ if (widgets.length === 0) {
339
+ return {
340
+ content: [
341
+ {
342
+ type: "text" as const,
343
+ text: "No widgets stored in the VFS widget store.",
344
+ },
345
+ ],
346
+ };
347
+ }
348
+
349
+ const lines = widgets.map((w) => {
350
+ const parts = [`- **${w.name}** (v${w.version})`];
351
+ if (w.description) parts.push(` ${w.description}`);
352
+ parts.push(` Path: ${w.path}`);
353
+ parts.push(` URI: ${w.resourceUri}`);
354
+ if (w.entry) parts.push(` Entry: ${w.entry}`);
355
+ if (w.services && w.services.length > 0) parts.push(` Services: ${w.services.join(", ")}`);
356
+ return parts.join("\n");
357
+ });
358
+
359
+ return {
360
+ content: [
361
+ {
362
+ type: "text" as const,
363
+ text: `Stored widgets (${widgets.length}):\n\n${lines.join("\n\n")}`,
364
+ },
365
+ ],
366
+ };
367
+ }
368
+ );
369
+
370
+ registerAppTool(
371
+ server,
372
+ "render_widget",
373
+ {
374
+ description:
375
+ "Render a persisted widget by its name and hash. " +
376
+ "Serves the saved widget as an MCP App resource that compiles in the browser, " +
377
+ "optionally supplying startup props via `inputs`.",
378
+ inputSchema: {
379
+ name: z.string().describe("Widget name (as stored in the VFS widget store)."),
380
+ hash: z
381
+ .string()
382
+ .optional()
383
+ .describe(
384
+ "Widget content hash. If omitted, renders the most recent version of the named widget."
385
+ ),
386
+ inputs: z
387
+ .record(z.unknown())
388
+ .optional()
389
+ .describe("Startup props passed to the widget's default export when it is rendered."),
390
+ },
391
+ _meta: {
392
+ ui: { resourceUri: "ui://widgets/{name}/{hash}/view.html" },
393
+ },
394
+ },
395
+ async (args) => {
396
+ const name = args?.["name"] as string;
397
+ const hashInput = args?.["hash"] as string | undefined;
398
+ const inputs = (args?.["inputs"] as Record<string, unknown> | undefined) ?? {};
399
+
400
+ if (!name) {
401
+ return {
402
+ content: [
403
+ {
404
+ type: "text" as const,
405
+ text: "Widget name is required.",
406
+ },
407
+ ],
408
+ isError: true,
409
+ };
410
+ }
411
+
412
+ let hash = hashInput;
413
+ if (!hash) {
414
+ const widgets = await store.listWidgets();
415
+ const match = widgets.find((w) => w.name === name);
416
+ if (!match) {
417
+ return {
418
+ content: [
419
+ {
420
+ type: "text" as const,
421
+ text: `No stored widget found with name "${name}".`,
422
+ },
423
+ ],
424
+ isError: true,
425
+ };
426
+ }
427
+ const resourcePath = match.resourceUri
428
+ .replace("ui://widgets/", "")
429
+ .replace("/view.html", "");
430
+ const parts = resourcePath.split("/");
431
+ hash = parts[1] ?? parts[0] ?? "";
432
+ }
433
+
434
+ if (!hash) {
435
+ return {
436
+ content: [
437
+ {
438
+ type: "text" as const,
439
+ text: `Could not determine hash for widget "${name}".`,
440
+ },
441
+ ],
442
+ isError: true,
443
+ };
444
+ }
445
+
446
+ const widget = await store.getWidget(name, hash);
447
+ if (!widget) {
448
+ return {
449
+ content: [
450
+ {
451
+ type: "text" as const,
452
+ text: `Widget "${name}" with hash "${hash}" not found in the VFS store.`,
453
+ },
454
+ ],
455
+ isError: true,
456
+ };
457
+ }
458
+
459
+ const ref: WidgetRef = { name, hash, entry: widget.entry };
460
+
461
+ return {
462
+ content: [
463
+ renderResource(ref, inputs),
464
+ {
465
+ type: "text" as const,
466
+ text: `Rendered widget "${name}" (hash: ${hash}).\nCompiled in-browser at: ${directUrl(name, hash)}`,
467
+ },
468
+ ],
469
+ };
470
+ }
471
+ );
472
+
473
+ registerLiveUpdateTools(server);
474
+
475
+ return server;
476
+ }
477
+
478
+ export { WidgetStore, getWidgetStore, resetWidgetStore } from "./widget-store/index.js";
479
+ export type { StoredWidget, StoredWidgetInfo, WidgetStoreOptions } from "./widget-store/types.js";
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // Live-update tools
483
+ // ---------------------------------------------------------------------------
484
+
485
+ /**
486
+ * Register the three tools that power the live-update channel:
487
+ *
488
+ * - `subscribe_stream` — widget declares interest in a named data stream.
489
+ * - `poll_updates` — widget fetches buffered events since a given seq.
490
+ * - `push_update` — backend/LLM pushes new data onto a stream.
491
+ *
492
+ * The session ID is extracted from the MCP request's `_meta` extra or from the
493
+ * internal `RequestHandlerExtra`. Tools that need the session ID use the
494
+ * `extra.meta?.sessionId` field that the SDK populates from the transport.
495
+ */
496
+ function registerLiveUpdateTools(server: McpServer): void {
497
+ // subscribe_stream — widget registers interest in a named data stream.
498
+ server.registerTool(
499
+ "subscribe_stream",
500
+ {
501
+ description:
502
+ "Subscribe this widget session to a named data stream. " +
503
+ "The server will send `notifications/tools/list_changed` whenever new " +
504
+ "events arrive; the widget should then call `poll_updates` to fetch them. " +
505
+ "Returns the current sequence number so the widget knows where to start polling.",
506
+ inputSchema: {
507
+ stream: z.string().describe("Name of the data stream to subscribe to."),
508
+ session_id: z
509
+ .string()
510
+ .optional()
511
+ .describe(
512
+ "MCP session ID. Widgets should pass the value returned in the " +
513
+ "Mcp-Session-Id response header during initialization."
514
+ ),
515
+ },
516
+ },
517
+ (args, extra) => {
518
+ const stream = (args as Record<string, unknown>)["stream"] as string;
519
+ // Prefer an explicit session_id arg; fall back to the transport session
520
+ const sessionId =
521
+ ((args as Record<string, unknown>)["session_id"] as string | undefined) ??
522
+ ((extra as Record<string, unknown>)["sessionId"] as string | undefined);
523
+
524
+ if (sessionId) {
525
+ subscribeSession(sessionId, stream);
526
+ }
527
+
528
+ const seq = currentSeq();
529
+ return {
530
+ content: [
531
+ {
532
+ type: "text" as const,
533
+ text: JSON.stringify({ success: true, stream, seq }),
534
+ },
535
+ ],
536
+ };
537
+ }
538
+ );
539
+
540
+ // poll_updates — returns buffered events for a stream since afterSeq.
541
+ server.registerTool(
542
+ "poll_updates",
543
+ {
544
+ description:
545
+ "Fetch buffered events for a data stream that arrived after the given " +
546
+ "sequence number. Call this after receiving a `notifications/tools/list_changed` " +
547
+ "notification (which the server sends when new data is available). " +
548
+ "Pass the highest `seq` value from the last successful poll to avoid duplicates.",
549
+ inputSchema: {
550
+ stream: z.string().describe("Name of the data stream to poll."),
551
+ after_seq: z
552
+ .number()
553
+ .int()
554
+ .default(0)
555
+ .describe(
556
+ "Return only events with seq > after_seq. Pass 0 to retrieve all buffered events."
557
+ ),
558
+ },
559
+ },
560
+ (args) => {
561
+ const stream = (args as Record<string, unknown>)["stream"] as string;
562
+ const afterSeq = ((args as Record<string, unknown>)["after_seq"] as number) ?? 0;
563
+
564
+ const events = getEvents(stream, afterSeq);
565
+ return {
566
+ content: [
567
+ {
568
+ type: "text" as const,
569
+ text: JSON.stringify({ success: true, stream, events }),
570
+ },
571
+ ],
572
+ };
573
+ }
574
+ );
575
+
576
+ // push_update — backend or LLM pushes new data onto a stream.
577
+ server.registerTool(
578
+ "push_update",
579
+ {
580
+ description:
581
+ "Push a data update onto a named stream, broadcasting it to all " +
582
+ "subscribed widget sessions. Subscribing widgets will receive a " +
583
+ "`notifications/tools/list_changed` signal and then call `poll_updates` " +
584
+ "to retrieve the new data. Use this tool from server-side code or as an " +
585
+ "LLM tool to drive real-time widget updates.",
586
+ inputSchema: {
587
+ stream: z.string().describe("Name of the data stream to push to."),
588
+ data: z
589
+ .record(z.unknown())
590
+ .describe("Arbitrary JSON-serialisable payload to push to subscribers."),
591
+ },
592
+ },
593
+ async (args) => {
594
+ const stream = (args as Record<string, unknown>)["stream"] as string;
595
+ const data = (args as Record<string, unknown>)["data"];
596
+
597
+ const seq = await pushStreamUpdate(stream, data);
598
+ return {
599
+ content: [
600
+ {
601
+ type: "text" as const,
602
+ text: JSON.stringify({ success: true, stream, seq }),
603
+ },
604
+ ],
605
+ };
606
+ }
607
+ );
608
+ }