@aprovan/mcp-app-server 0.1.0-dev.c1ac1dc

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 (41) hide show
  1. package/.turbo/turbo-build.log +20 -0
  2. package/E2E_TESTING.md +198 -0
  3. package/LICENSE +373 -0
  4. package/README.md +134 -0
  5. package/dist/index.d.ts +108 -0
  6. package/dist/index.js +1592 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/server.d.ts +2 -0
  9. package/dist/server.js +1841 -0
  10. package/dist/server.js.map +1 -0
  11. package/package.json +52 -0
  12. package/src/__tests__/cache.test.ts +119 -0
  13. package/src/__tests__/cdn-plugin.test.ts +86 -0
  14. package/src/__tests__/compile.test.ts +93 -0
  15. package/src/__tests__/e2e-pipeline.test.ts +417 -0
  16. package/src/__tests__/live-update.test.ts +158 -0
  17. package/src/__tests__/local-backend.test.ts +100 -0
  18. package/src/__tests__/memory-backend.ts +144 -0
  19. package/src/__tests__/registry-backend.test.ts +256 -0
  20. package/src/__tests__/services.test.ts +153 -0
  21. package/src/__tests__/shim.test.ts +132 -0
  22. package/src/__tests__/widget-store.test.ts +183 -0
  23. package/src/compiler/cache.ts +68 -0
  24. package/src/compiler/cdn-plugin.ts +197 -0
  25. package/src/compiler/compile.ts +306 -0
  26. package/src/compiler/index.ts +3 -0
  27. package/src/hello-world.html +79 -0
  28. package/src/html.d.ts +4 -0
  29. package/src/index.ts +641 -0
  30. package/src/live-update.ts +149 -0
  31. package/src/reference-widgets/live-dashboard.ts +162 -0
  32. package/src/registry-backend.ts +304 -0
  33. package/src/server.ts +178 -0
  34. package/src/services.ts +251 -0
  35. package/src/shim.ts +247 -0
  36. package/src/widget-store/index.ts +10 -0
  37. package/src/widget-store/local-backend.ts +77 -0
  38. package/src/widget-store/store.ts +198 -0
  39. package/src/widget-store/types.ts +50 -0
  40. package/tsconfig.json +10 -0
  41. package/tsup.config.ts +14 -0
package/src/index.ts ADDED
@@ -0,0 +1,641 @@
1
+ import {
2
+ createProjectFromFiles,
3
+ type Manifest,
4
+ type VirtualFile,
5
+ type VirtualProject,
6
+ } from "@aprovan/patchwork-compiler";
7
+ import {
8
+ registerAppTool,
9
+ registerAppResource,
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
+ compileWidget,
16
+ allEntries,
17
+ type CompileWidgetResult,
18
+ } from "./compiler/index.js";
19
+ import HELLO_WORLD_HTML from "./hello-world.html";
20
+ import {
21
+ subscribeSession,
22
+ unsubscribeSession as _unsubscribeSession,
23
+ getEvents,
24
+ pushStreamUpdate,
25
+ currentSeq,
26
+ type StreamEvent,
27
+ } from "./live-update.js";
28
+ import {
29
+ ServiceBridge,
30
+ type ServiceBackend,
31
+ type ServiceToolInfo,
32
+ type ServiceBridgeConfig,
33
+ } from "./services.js";
34
+ import {
35
+ type WidgetStore,
36
+ getWidgetStore,
37
+ } from "./widget-store/index.js";
38
+
39
+ export type { ServiceBackend, ServiceToolInfo, ServiceBridgeConfig };
40
+ export type { StreamEvent };
41
+ export { pushStreamUpdate };
42
+
43
+ const HELLO_WORLD_RESOURCE_URI = "ui://hello-world/view.html";
44
+
45
+ const MANIFEST_DEFAULTS: Manifest = {
46
+ name: "widget",
47
+ version: "0.1.0",
48
+ platform: "browser",
49
+ image: "@aprovan/patchwork-image-shadcn",
50
+ };
51
+
52
+ function buildManifest(input?: Record<string, unknown>): Manifest {
53
+ return {
54
+ name: (input?.["name"] as string) ?? MANIFEST_DEFAULTS.name,
55
+ version: (input?.["version"] as string) ?? MANIFEST_DEFAULTS.version,
56
+ platform: "browser",
57
+ image:
58
+ (input?.["image"] as string) ?? MANIFEST_DEFAULTS.image,
59
+ services: (input?.["services"] as string[] | undefined),
60
+ };
61
+ }
62
+
63
+ function buildVirtualProject(
64
+ source?: string,
65
+ files?: Array<{ path: string; content: string }>,
66
+ entry?: string,
67
+ ): string | VirtualProject {
68
+ if (files && files.length > 0) {
69
+ const virtualFiles: VirtualFile[] = files.map((f) => ({
70
+ path: f.path,
71
+ content: f.content,
72
+ }));
73
+ return createProjectFromFiles(virtualFiles, entry);
74
+ }
75
+ return source ?? 'export default function Widget() { return <div>Hello Patchwork</div>; }';
76
+ }
77
+
78
+ async function registerStoredWidgetResources(
79
+ server: McpServer,
80
+ store: WidgetStore,
81
+ ): Promise<void> {
82
+ const widgets = await store.loadAll();
83
+ for (const widget of widgets) {
84
+ registerAppResource(
85
+ server,
86
+ `Widget ${widget.manifest.name}`,
87
+ widget.resourceUri,
88
+ {
89
+ description: widget.manifest.description ?? `Persisted widget: ${widget.manifest.name}`,
90
+ },
91
+ async () => ({
92
+ contents: [
93
+ {
94
+ uri: widget.resourceUri,
95
+ mimeType: RESOURCE_MIME_TYPE,
96
+ text: widget.html,
97
+ },
98
+ ],
99
+ }),
100
+ );
101
+ }
102
+ }
103
+
104
+ function registerCachedWidgetResources(server: McpServer): void {
105
+ for (const [, entry] of allEntries()) {
106
+ registerAppResource(
107
+ server,
108
+ `Widget ${entry.manifest.name}`,
109
+ entry.resourceUri,
110
+ {
111
+ description: entry.manifest.description ?? `Compiled widget: ${entry.manifest.name}`,
112
+ },
113
+ async () => ({
114
+ contents: [
115
+ {
116
+ uri: entry.resourceUri,
117
+ mimeType: RESOURCE_MIME_TYPE,
118
+ text: entry.html,
119
+ },
120
+ ],
121
+ }),
122
+ );
123
+ }
124
+ }
125
+
126
+ export interface McpAppServerOptions {
127
+ services?: ServiceBridgeConfig;
128
+ }
129
+
130
+ export function createMcpAppServer(options: McpAppServerOptions = {}): McpServer {
131
+ const server = new McpServer({
132
+ name: "patchwork-mcp-app-server",
133
+ version: "0.1.0",
134
+ });
135
+
136
+ const serviceBridge = options.services
137
+ ? new ServiceBridge(options.services)
138
+ : null;
139
+
140
+ const store = getWidgetStore();
141
+
142
+ registerAppTool(
143
+ server,
144
+ "hello_world",
145
+ {
146
+ description:
147
+ "Display a hello-world widget inline in the conversation. " +
148
+ "Returns a static greeting card rendered as an MCP App.",
149
+ _meta: { ui: { resourceUri: HELLO_WORLD_RESOURCE_URI } },
150
+ },
151
+ async () => ({
152
+ content: [
153
+ {
154
+ type: "text" as const,
155
+ text: "Hello, world! The widget is rendered inline above.",
156
+ },
157
+ ],
158
+ }),
159
+ );
160
+
161
+ registerAppResource(
162
+ server,
163
+ "Hello World View",
164
+ HELLO_WORLD_RESOURCE_URI,
165
+ {
166
+ description:
167
+ "Hello-world HTML widget for the Patchwork MCP App Server demo.",
168
+ },
169
+ async () => ({
170
+ contents: [
171
+ {
172
+ uri: HELLO_WORLD_RESOURCE_URI,
173
+ mimeType: RESOURCE_MIME_TYPE,
174
+ text: HELLO_WORLD_HTML,
175
+ },
176
+ ],
177
+ }),
178
+ );
179
+
180
+ registerAppTool(
181
+ server,
182
+ "compile_widget",
183
+ {
184
+ description:
185
+ "Compile a JSX/TSX widget into a self-contained HTML page served as an MCP App resource. " +
186
+ "Pass source code for a single-file widget, or a files array for a multi-file project. " +
187
+ "The compiled widget is cached in memory and persisted to the VFS widget store.",
188
+ inputSchema: {
189
+ source: z
190
+ .string()
191
+ .optional()
192
+ .describe(
193
+ "JSX/TSX source code for a single-file widget. Must export a default React component.",
194
+ ),
195
+ files: z
196
+ .array(
197
+ z.object({
198
+ path: z.string().describe("File path relative to project root (e.g. 'main.tsx')"),
199
+ content: z.string().describe("File contents"),
200
+ }),
201
+ )
202
+ .optional()
203
+ .describe(
204
+ "Array of files for a multi-file widget project. At least one file should be the entry point (main.tsx or index.tsx).",
205
+ ),
206
+ entry: z
207
+ .string()
208
+ .optional()
209
+ .describe("Entry point file path. Defaults to auto-detection (main.tsx, index.tsx)."),
210
+ name: z
211
+ .string()
212
+ .optional()
213
+ .describe("Widget name for the manifest. Defaults to 'widget'."),
214
+ image: z
215
+ .string()
216
+ .optional()
217
+ .describe(
218
+ "Patchwork image package to use. Defaults to '@aprovan/patchwork-image-shadcn'.",
219
+ ),
220
+ services: z
221
+ .array(z.string())
222
+ .optional()
223
+ .describe(
224
+ "Service namespaces the widget calls (e.g., ['weather', 'stripe']). " +
225
+ "A proxy shim is injected so widget code can call namespace.procedure(args) directly.",
226
+ ),
227
+ },
228
+ _meta: {
229
+ ui: { resourceUri: "ui://widget/{hash}/view.html" },
230
+ },
231
+ },
232
+ async (args) => {
233
+ const source = args?.["source"] as string | undefined;
234
+ const files = args?.["files"] as
235
+ | Array<{ path: string; content: string }>
236
+ | undefined;
237
+ const entry = args?.["entry"] as string | undefined;
238
+ const requestedServices = args?.["services"] as string[] | undefined;
239
+
240
+ const manifestInput: Record<string, unknown> = {};
241
+ if (args?.["name"]) manifestInput["name"] = args["name"];
242
+ if (args?.["image"]) manifestInput["image"] = args["image"];
243
+ if (requestedServices) manifestInput["services"] = requestedServices;
244
+
245
+ const manifest = buildManifest(manifestInput);
246
+ const project = buildVirtualProject(source, files, entry);
247
+
248
+ let compileServices: string[] | undefined;
249
+ if (requestedServices && requestedServices.length > 0 && serviceBridge) {
250
+ const availableNamespaces = serviceBridge.getNamespaces();
251
+ compileServices = requestedServices.filter((ns) =>
252
+ availableNamespaces.includes(ns),
253
+ );
254
+ const unavailable = requestedServices.filter(
255
+ (ns) => !availableNamespaces.includes(ns),
256
+ );
257
+ if (unavailable.length > 0) {
258
+ console.warn(
259
+ `[mcp-app-server] Requested services not available: ${unavailable.join(", ")}. Available: ${availableNamespaces.join(", ")}`,
260
+ );
261
+ }
262
+ }
263
+
264
+ try {
265
+ const result: CompileWidgetResult = await compileWidget(
266
+ project,
267
+ manifest,
268
+ compileServices ? { services: compileServices } : {},
269
+ );
270
+
271
+ const entryPath = typeof project === "string" ? undefined : project.entry;
272
+ await store.saveWidget(result.hash, result.html, manifest, entryPath);
273
+
274
+ const storedUri = store.resourceUriFor(manifest.name, result.hash);
275
+ registerAppResource(
276
+ server,
277
+ `Widget ${manifest.name}`,
278
+ storedUri,
279
+ {
280
+ description: manifest.description ?? `Persisted widget: ${manifest.name}`,
281
+ },
282
+ async () => ({
283
+ contents: [
284
+ {
285
+ uri: storedUri,
286
+ mimeType: RESOURCE_MIME_TYPE,
287
+ text: result.html,
288
+ },
289
+ ],
290
+ }),
291
+ );
292
+
293
+ registerAppResource(
294
+ server,
295
+ `Widget ${manifest.name} (cached)`,
296
+ result.resourceUri,
297
+ {
298
+ description: `Compiled widget: ${manifest.name}`,
299
+ },
300
+ async () => ({
301
+ contents: [
302
+ {
303
+ uri: result.resourceUri,
304
+ mimeType: RESOURCE_MIME_TYPE,
305
+ text: result.html,
306
+ },
307
+ ],
308
+ }),
309
+ );
310
+
311
+ return {
312
+ content: [
313
+ {
314
+ type: "text" as const,
315
+ text: `Widget compiled and persisted. Resource URI: ${storedUri}`,
316
+ },
317
+ {
318
+ type: "text" as const,
319
+ text: `Cache key: ${result.hash}`,
320
+ },
321
+ ],
322
+ _meta: {
323
+ ui: { resourceUri: result.resourceUri },
324
+ },
325
+ };
326
+ } catch (error) {
327
+ const message =
328
+ error instanceof Error ? error.message : String(error);
329
+ return {
330
+ content: [
331
+ {
332
+ type: "text" as const,
333
+ text: `Compilation failed: ${message}`,
334
+ },
335
+ ],
336
+ isError: true,
337
+ };
338
+ }
339
+ },
340
+ );
341
+
342
+ if (serviceBridge) {
343
+ serviceBridge.registerTools(server);
344
+ serviceBridge.registerSearchServices(server);
345
+ }
346
+
347
+ registerAppTool(
348
+ server,
349
+ "list_widgets",
350
+ {
351
+ description:
352
+ "List all persisted widgets in the VFS widget store. " +
353
+ "Returns each widget's name, version, description, path, and resource URI.",
354
+ _meta: { ui: { resourceUri: "ui://widgets/list" } },
355
+ },
356
+ async () => {
357
+ const widgets = await store.listWidgets();
358
+
359
+ if (widgets.length === 0) {
360
+ return {
361
+ content: [
362
+ {
363
+ type: "text" as const,
364
+ text: "No widgets stored in the VFS widget store.",
365
+ },
366
+ ],
367
+ };
368
+ }
369
+
370
+ const lines = widgets.map((w) => {
371
+ const parts = [`- **${w.name}** (v${w.version})`];
372
+ if (w.description) parts.push(` ${w.description}`);
373
+ parts.push(` Path: ${w.path}`);
374
+ parts.push(` URI: ${w.resourceUri}`);
375
+ if (w.entry) parts.push(` Entry: ${w.entry}`);
376
+ if (w.services && w.services.length > 0) parts.push(` Services: ${w.services.join(", ")}`);
377
+ return parts.join("\n");
378
+ });
379
+
380
+ return {
381
+ content: [
382
+ {
383
+ type: "text" as const,
384
+ text: `Stored widgets (${widgets.length}):\n\n${lines.join("\n\n")}`,
385
+ },
386
+ ],
387
+ };
388
+ },
389
+ );
390
+
391
+ registerAppTool(
392
+ server,
393
+ "render_widget",
394
+ {
395
+ description:
396
+ "Render a persisted widget by its name and hash. " +
397
+ "Serves the compiled widget as an MCP App resource rendered inline in the conversation.",
398
+ inputSchema: {
399
+ name: z
400
+ .string()
401
+ .describe("Widget name (as stored in the VFS widget store)."),
402
+ hash: z
403
+ .string()
404
+ .optional()
405
+ .describe("Widget content hash. If omitted, renders the most recent version of the named widget."),
406
+ },
407
+ _meta: {
408
+ ui: { resourceUri: "ui://widgets/{name}/{hash}/view.html" },
409
+ },
410
+ },
411
+ async (args) => {
412
+ const name = args?.["name"] as string;
413
+ const hashInput = args?.["hash"] as string | undefined;
414
+
415
+ if (!name) {
416
+ return {
417
+ content: [
418
+ {
419
+ type: "text" as const,
420
+ text: "Widget name is required.",
421
+ },
422
+ ],
423
+ isError: true,
424
+ };
425
+ }
426
+
427
+ let hash = hashInput;
428
+ if (!hash) {
429
+ const widgets = await store.listWidgets();
430
+ const match = widgets.find((w) => w.name === name);
431
+ if (!match) {
432
+ return {
433
+ content: [
434
+ {
435
+ type: "text" as const,
436
+ text: `No stored widget found with name "${name}".`,
437
+ },
438
+ ],
439
+ isError: true,
440
+ };
441
+ }
442
+ const resourcePath = match.resourceUri.replace("ui://widgets/", "").replace("/view.html", "");
443
+ const parts = resourcePath.split("/");
444
+ hash = parts[1] ?? parts[0] ?? "";
445
+ }
446
+
447
+ if (!hash) {
448
+ return {
449
+ content: [
450
+ {
451
+ type: "text" as const,
452
+ text: `Could not determine hash for widget "${name}".`,
453
+ },
454
+ ],
455
+ isError: true,
456
+ };
457
+ }
458
+
459
+ const widget = await store.getWidget(name, hash);
460
+ if (!widget) {
461
+ return {
462
+ content: [
463
+ {
464
+ type: "text" as const,
465
+ text: `Widget "${name}" with hash "${hash}" not found in the VFS store.`,
466
+ },
467
+ ],
468
+ isError: true,
469
+ };
470
+ }
471
+
472
+ registerAppResource(
473
+ server,
474
+ `Widget ${widget.manifest.name}`,
475
+ widget.resourceUri,
476
+ {
477
+ description: widget.manifest.description ?? `Persisted widget: ${widget.manifest.name}`,
478
+ },
479
+ async () => ({
480
+ contents: [
481
+ {
482
+ uri: widget.resourceUri,
483
+ mimeType: RESOURCE_MIME_TYPE,
484
+ text: widget.html,
485
+ },
486
+ ],
487
+ }),
488
+ );
489
+
490
+ return {
491
+ content: [
492
+ {
493
+ type: "text" as const,
494
+ text: `Rendering widget "${name}" (hash: ${hash}).`,
495
+ },
496
+ ],
497
+ _meta: {
498
+ ui: { resourceUri: widget.resourceUri },
499
+ },
500
+ };
501
+ },
502
+ );
503
+
504
+ registerCachedWidgetResources(server);
505
+ void registerStoredWidgetResources(server, store);
506
+ registerLiveUpdateTools(server);
507
+
508
+ return server;
509
+ }
510
+
511
+ export { WidgetStore, getWidgetStore, resetWidgetStore } from "./widget-store/index.js";
512
+ export type { StoredWidget, StoredWidgetInfo, WidgetStoreOptions } from "./widget-store/types.js";
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Live-update tools
516
+ // ---------------------------------------------------------------------------
517
+
518
+ /**
519
+ * Register the three tools that power the live-update channel:
520
+ *
521
+ * - `subscribe_stream` — widget declares interest in a named data stream.
522
+ * - `poll_updates` — widget fetches buffered events since a given seq.
523
+ * - `push_update` — backend/LLM pushes new data onto a stream.
524
+ *
525
+ * The session ID is extracted from the MCP request's `_meta` extra or from the
526
+ * internal `RequestHandlerExtra`. Tools that need the session ID use the
527
+ * `extra.meta?.sessionId` field that the SDK populates from the transport.
528
+ */
529
+ function registerLiveUpdateTools(server: McpServer): void {
530
+ // subscribe_stream — widget registers interest in a named data stream.
531
+ server.registerTool(
532
+ "subscribe_stream",
533
+ {
534
+ description:
535
+ "Subscribe this widget session to a named data stream. " +
536
+ "The server will send `notifications/tools/list_changed` whenever new " +
537
+ "events arrive; the widget should then call `poll_updates` to fetch them. " +
538
+ "Returns the current sequence number so the widget knows where to start polling.",
539
+ inputSchema: {
540
+ stream: z.string().describe("Name of the data stream to subscribe to."),
541
+ session_id: z
542
+ .string()
543
+ .optional()
544
+ .describe(
545
+ "MCP session ID. Widgets should pass the value returned in the " +
546
+ "Mcp-Session-Id response header during initialization.",
547
+ ),
548
+ },
549
+ },
550
+ (args, extra) => {
551
+ const stream = (args as Record<string, unknown>)["stream"] as string;
552
+ // Prefer an explicit session_id arg; fall back to the transport session
553
+ const sessionId =
554
+ ((args as Record<string, unknown>)["session_id"] as string | undefined) ??
555
+ (extra as Record<string, unknown>)["sessionId"] as string | undefined;
556
+
557
+ if (sessionId) {
558
+ subscribeSession(sessionId, stream);
559
+ }
560
+
561
+ const seq = currentSeq();
562
+ return {
563
+ content: [
564
+ {
565
+ type: "text" as const,
566
+ text: JSON.stringify({ success: true, stream, seq }),
567
+ },
568
+ ],
569
+ };
570
+ },
571
+ );
572
+
573
+ // poll_updates — returns buffered events for a stream since afterSeq.
574
+ server.registerTool(
575
+ "poll_updates",
576
+ {
577
+ description:
578
+ "Fetch buffered events for a data stream that arrived after the given " +
579
+ "sequence number. Call this after receiving a `notifications/tools/list_changed` " +
580
+ "notification (which the server sends when new data is available). " +
581
+ "Pass the highest `seq` value from the last successful poll to avoid duplicates.",
582
+ inputSchema: {
583
+ stream: z.string().describe("Name of the data stream to poll."),
584
+ after_seq: z
585
+ .number()
586
+ .int()
587
+ .default(0)
588
+ .describe(
589
+ "Return only events with seq > after_seq. Pass 0 to retrieve all buffered events.",
590
+ ),
591
+ },
592
+ },
593
+ (args) => {
594
+ const stream = (args as Record<string, unknown>)["stream"] as string;
595
+ const afterSeq = ((args as Record<string, unknown>)["after_seq"] as number) ?? 0;
596
+
597
+ const events = getEvents(stream, afterSeq);
598
+ return {
599
+ content: [
600
+ {
601
+ type: "text" as const,
602
+ text: JSON.stringify({ success: true, stream, events }),
603
+ },
604
+ ],
605
+ };
606
+ },
607
+ );
608
+
609
+ // push_update — backend or LLM pushes new data onto a stream.
610
+ server.registerTool(
611
+ "push_update",
612
+ {
613
+ description:
614
+ "Push a data update onto a named stream, broadcasting it to all " +
615
+ "subscribed widget sessions. Subscribing widgets will receive a " +
616
+ "`notifications/tools/list_changed` signal and then call `poll_updates` " +
617
+ "to retrieve the new data. Use this tool from server-side code or as an " +
618
+ "LLM tool to drive real-time widget updates.",
619
+ inputSchema: {
620
+ stream: z.string().describe("Name of the data stream to push to."),
621
+ data: z
622
+ .record(z.unknown())
623
+ .describe("Arbitrary JSON-serialisable payload to push to subscribers."),
624
+ },
625
+ },
626
+ async (args) => {
627
+ const stream = (args as Record<string, unknown>)["stream"] as string;
628
+ const data = (args as Record<string, unknown>)["data"];
629
+
630
+ const seq = await pushStreamUpdate(stream, data);
631
+ return {
632
+ content: [
633
+ {
634
+ type: "text" as const,
635
+ text: JSON.stringify({ success: true, stream, seq }),
636
+ },
637
+ ],
638
+ };
639
+ },
640
+ );
641
+ }