@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/server.ts ADDED
@@ -0,0 +1,178 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
+ import cors from 'cors';
5
+ import { registerSession, unregisterSession } from './live-update.js';
6
+ import { createRegistryBackend, type RegistryBackend } from './registry-backend.js';
7
+ import { createMcpAppServer, type McpAppServerOptions } from './index.js';
8
+
9
+ const PORT = Number(process.env['PORT'] ?? 3000);
10
+ const HOST = process.env['HOST'] ?? '0.0.0.0';
11
+
12
+ async function startServer(): Promise<void> {
13
+ const serverOptions: McpAppServerOptions = {};
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Registry auto-configuration
17
+ //
18
+ // Set REGISTRY_PROVIDERS to a comma-separated list of @utdk providers
19
+ // (e.g. "github,slack,stripe") to automatically spawn the Registry MCP server
20
+ // and make all its tools available to widgets.
21
+ //
22
+ // The Registry process inherits all current environment variables, so provider
23
+ // credentials (GITHUB_TOKEN, STRIPE_SECRET_KEY, etc.) do not need to be
24
+ // duplicated — just set them once in this process's environment.
25
+ //
26
+ // Optional overrides:
27
+ // REGISTRY_COMMAND Executable to run (default: "npx")
28
+ // REGISTRY_ARGS Space-separated extra args appended after "@utdk/mcp-server"
29
+ // ---------------------------------------------------------------------------
30
+ const REGISTRY_PROVIDERS = process.env['REGISTRY_PROVIDERS'];
31
+
32
+ let registryBackend: RegistryBackend | null = null;
33
+
34
+ if (REGISTRY_PROVIDERS) {
35
+ const command = process.env['REGISTRY_COMMAND'] ?? 'npx';
36
+ const extraArgs = process.env['REGISTRY_ARGS']?.split(' ').filter(Boolean) ?? [];
37
+ const args = ['@utdk/mcp-server', ...extraArgs];
38
+
39
+ console.log(
40
+ `[mcp-app-server] Connecting to Registry MCP server (providers: ${REGISTRY_PROVIDERS})...`,
41
+ );
42
+
43
+ try {
44
+ registryBackend = await createRegistryBackend({
45
+ command,
46
+ args,
47
+ providers: REGISTRY_PROVIDERS,
48
+ });
49
+
50
+ const toolInfos = registryBackend.getToolInfos();
51
+ serverOptions.services = {
52
+ backend: registryBackend,
53
+ tools: toolInfos,
54
+ };
55
+
56
+ const namespaces = [...new Set(toolInfos.map((t) => t.namespace))];
57
+ console.log(
58
+ `[mcp-app-server] Registry ready: ${toolInfos.length} tools across namespaces: ${namespaces.join(', ')}`,
59
+ );
60
+ } catch (err) {
61
+ console.error('[mcp-app-server] Failed to connect to Registry MCP server:', err);
62
+ console.error('[mcp-app-server] Starting without Registry service backend.');
63
+ }
64
+ }
65
+
66
+ const app = createMcpExpressApp({ host: HOST });
67
+
68
+ // Allow cross-origin requests so Claude web (behind cloudflared) can reach the server
69
+ app.use(cors());
70
+
71
+ /**
72
+ * Session store for stateful MCP connections.
73
+ *
74
+ * Each MCP session gets its own McpServer + StreamableHTTPServerTransport pair.
75
+ * The session ID is minted on initialization and echoed in the Mcp-Session-Id
76
+ * response header so the host can route subsequent requests and the standalone
77
+ * GET SSE stream back to the correct server instance.
78
+ *
79
+ * Stateful sessions are required for server-initiated push: calling
80
+ * `mcpServer.server.notification(...)` on a live session delivers the
81
+ * message through the session's SSE stream to the host, which forwards it to
82
+ * the widget iframe.
83
+ */
84
+ const sessionStore = new Map<
85
+ string,
86
+ { server: ReturnType<typeof createMcpAppServer>; transport: StreamableHTTPServerTransport }
87
+ >();
88
+
89
+ /**
90
+ * MCP endpoint — stateful session mode.
91
+ *
92
+ * First request (no Mcp-Session-Id header): creates a new session.
93
+ * Subsequent requests carry the session ID and are routed to the existing
94
+ * server + transport pair.
95
+ */
96
+ app.all('/mcp', async (req, res) => {
97
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
98
+
99
+ if (sessionId) {
100
+ const existing = sessionStore.get(sessionId);
101
+ if (existing) {
102
+ try {
103
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
104
+ await existing.transport.handleRequest(req, res, req.body);
105
+ } catch (err) {
106
+ console.error('[mcp] session request error', err);
107
+ if (!res.headersSent) {
108
+ res.status(500).json({ error: 'Internal server error' });
109
+ }
110
+ }
111
+ return;
112
+ }
113
+ // Unknown session ID — fall through to create a fresh session.
114
+ }
115
+
116
+ // New session
117
+ const mcpServer = createMcpAppServer(serverOptions);
118
+ const transport = new StreamableHTTPServerTransport({
119
+ sessionIdGenerator: () => randomUUID(),
120
+ onsessioninitialized: (id) => {
121
+ sessionStore.set(id, { server: mcpServer, transport });
122
+ registerSession(id, mcpServer);
123
+ },
124
+ onsessionclosed: (id) => {
125
+ sessionStore.delete(id);
126
+ unregisterSession(id);
127
+ },
128
+ });
129
+
130
+ // Clean up when the response finishes (handles SSE streams that close early)
131
+ res.on('close', () => {
132
+ const id = transport.sessionId;
133
+ if (id && !sessionStore.has(id)) {
134
+ void mcpServer.close();
135
+ }
136
+ });
137
+
138
+ try {
139
+ await mcpServer.connect(transport);
140
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
141
+ await transport.handleRequest(req, res, req.body);
142
+ } catch (err) {
143
+ console.error('[mcp] new session error', err);
144
+ if (!res.headersSent) {
145
+ res.status(500).json({ error: 'Internal server error' });
146
+ }
147
+ }
148
+ });
149
+
150
+ /** Health-check endpoint — useful for cloudflared and load-balancer probes. */
151
+ app.get('/health', (_req, res) => {
152
+ res.json({ status: 'ok', service: 'patchwork-mcp-app-server' });
153
+ });
154
+
155
+ app.listen(PORT, HOST, () => {
156
+ console.log(`MCP App Server listening on http://${HOST}:${PORT}`);
157
+ console.log(` POST /mcp — MCP Streamable HTTP endpoint (stateful sessions)`);
158
+ console.log(` GET /health — health check`);
159
+ console.log();
160
+ console.log('To expose locally via cloudflared:');
161
+ console.log(` cloudflared tunnel --url http://localhost:${PORT}`);
162
+ });
163
+
164
+ // Clean up the Registry child process on shutdown.
165
+ const shutdown = () => {
166
+ if (registryBackend) {
167
+ registryBackend.close().catch(() => {/* ignore close errors on exit */});
168
+ }
169
+ process.exit(0);
170
+ };
171
+ process.on('SIGTERM', shutdown);
172
+ process.on('SIGINT', shutdown);
173
+ }
174
+
175
+ startServer().catch((err: unknown) => {
176
+ console.error('[mcp-app-server] Fatal startup error:', err);
177
+ process.exit(1);
178
+ });
@@ -0,0 +1,251 @@
1
+ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+
4
+ export interface ServiceBackend {
5
+ call(namespace: string, procedure: string, args: unknown[]): Promise<unknown>;
6
+ }
7
+
8
+ export interface ServiceToolInfo {
9
+ name: string;
10
+ namespace: string;
11
+ procedure: string;
12
+ description?: string;
13
+ parameters?: Record<string, unknown>;
14
+ }
15
+
16
+ export interface ServiceBridgeConfig {
17
+ backend: ServiceBackend;
18
+ tools: ServiceToolInfo[];
19
+ }
20
+
21
+ const TOOL_SEPARATOR = "__";
22
+
23
+ function toMcpToolName(namespace: string, procedure: string): string {
24
+ return `${namespace}${TOOL_SEPARATOR}${procedure}`;
25
+ }
26
+
27
+ function jsonSchemaToZodShape(
28
+ schema?: Record<string, unknown>,
29
+ ): Record<string, z.ZodTypeAny> {
30
+ const properties = (schema?.["properties"] ?? {}) as Record<
31
+ string,
32
+ { type?: string; description?: string }
33
+ >;
34
+ const required = new Set((schema?.["required"] ?? []) as string[]);
35
+
36
+ const shape: Record<string, z.ZodTypeAny> = {};
37
+ for (const [key, prop] of Object.entries(properties)) {
38
+ let field: z.ZodTypeAny;
39
+ switch (prop.type) {
40
+ case "number":
41
+ case "integer":
42
+ field = z.number();
43
+ break;
44
+ case "boolean":
45
+ field = z.boolean();
46
+ break;
47
+ case "array":
48
+ field = z.array(z.unknown());
49
+ break;
50
+ case "object":
51
+ field = z.record(z.unknown());
52
+ break;
53
+ default:
54
+ field = z.string();
55
+ break;
56
+ }
57
+ if (prop.description) {
58
+ field = field.describe(prop.description);
59
+ }
60
+ if (!required.has(key)) {
61
+ field = field.optional();
62
+ }
63
+ shape[key] = field;
64
+ }
65
+
66
+ return shape;
67
+ }
68
+
69
+ export class ServiceBridge {
70
+ private backend: ServiceBackend;
71
+ private tools: Map<string, ServiceToolInfo> = new Map();
72
+
73
+ constructor(config: ServiceBridgeConfig) {
74
+ this.backend = config.backend;
75
+ for (const tool of config.tools) {
76
+ this.tools.set(tool.name, tool);
77
+ }
78
+ }
79
+
80
+ registerTools(server: McpServer): void {
81
+ for (const [, info] of this.tools) {
82
+ const mcpToolName = toMcpToolName(info.namespace, info.procedure);
83
+ const inputShape = jsonSchemaToZodShape(info.parameters);
84
+
85
+ server.registerTool(
86
+ mcpToolName,
87
+ {
88
+ description:
89
+ info.description ??
90
+ `Call ${info.namespace}.${info.procedure}`,
91
+ inputSchema: inputShape,
92
+ },
93
+ async (args) => {
94
+ try {
95
+ const result = await this.backend.call(
96
+ info.namespace,
97
+ info.procedure,
98
+ [args ?? {}],
99
+ );
100
+ return {
101
+ content: [
102
+ {
103
+ type: "text" as const,
104
+ text:
105
+ typeof result === "string"
106
+ ? result
107
+ : JSON.stringify(result),
108
+ },
109
+ ],
110
+ };
111
+ } catch (error) {
112
+ const message =
113
+ error instanceof Error ? error.message : String(error);
114
+ return {
115
+ content: [
116
+ {
117
+ type: "text" as const,
118
+ text: `Service call failed: ${message}`,
119
+ },
120
+ ],
121
+ isError: true,
122
+ };
123
+ }
124
+ },
125
+ );
126
+ }
127
+ }
128
+
129
+ registerSearchServices(server: McpServer): void {
130
+ server.registerTool(
131
+ "search_services",
132
+ {
133
+ description:
134
+ "Search for available service tools that widgets can call. " +
135
+ "Returns matching services with their namespaces, procedures, and parameter schemas.",
136
+ inputSchema: {
137
+ query: z
138
+ .string()
139
+ .optional()
140
+ .describe(
141
+ 'Natural language description of what you want to do (e.g., "get weather forecast")',
142
+ ),
143
+ namespace: z
144
+ .string()
145
+ .optional()
146
+ .describe(
147
+ 'Filter results to a specific service namespace (e.g., "weather")',
148
+ ),
149
+ tool_name: z
150
+ .string()
151
+ .optional()
152
+ .describe("Get detailed info about a specific tool by name"),
153
+ limit: z
154
+ .number()
155
+ .optional()
156
+ .describe("Maximum number of results to return"),
157
+ },
158
+ },
159
+ async (args) => {
160
+ const query = args?.["query"] as string | undefined;
161
+ const namespace = args?.["namespace"] as string | undefined;
162
+ const toolName = args?.["tool_name"] as string | undefined;
163
+ const limit = (args?.["limit"] as number) ?? 10;
164
+
165
+ if (toolName) {
166
+ const dotName = toolName.replace(/__/g, ".");
167
+ const info =
168
+ this.tools.get(toolName) ?? this.tools.get(dotName);
169
+ if (!info) {
170
+ return {
171
+ content: [
172
+ {
173
+ type: "text" as const,
174
+ text: JSON.stringify({
175
+ success: false,
176
+ error: `Tool '${toolName}' not found`,
177
+ }),
178
+ },
179
+ ],
180
+ };
181
+ }
182
+ return {
183
+ content: [
184
+ {
185
+ type: "text" as const,
186
+ text: JSON.stringify({ success: true, tool: info }),
187
+ },
188
+ ],
189
+ };
190
+ }
191
+
192
+ let results = Array.from(this.tools.values());
193
+
194
+ if (namespace) {
195
+ results = results.filter(
196
+ (info) => info.namespace === namespace,
197
+ );
198
+ }
199
+
200
+ if (query) {
201
+ const queryLower = query.toLowerCase();
202
+ const keywords = queryLower.split(/\s+/).filter(Boolean);
203
+ results = results
204
+ .map((info) => {
205
+ const searchText =
206
+ `${info.name} ${info.namespace} ${info.procedure} ${info.description ?? ""}`.toLowerCase();
207
+ const matchCount = keywords.filter((kw) =>
208
+ searchText.includes(kw),
209
+ ).length;
210
+ return { info, score: matchCount / keywords.length };
211
+ })
212
+ .filter(({ score }) => score > 0)
213
+ .sort((a, b) => b.score - a.score)
214
+ .map(({ info }) => info);
215
+ }
216
+
217
+ results = results.slice(0, limit);
218
+
219
+ return {
220
+ content: [
221
+ {
222
+ type: "text" as const,
223
+ text: JSON.stringify({
224
+ success: true,
225
+ count: results.length,
226
+ tools: results,
227
+ namespaces: this.getNamespaces(),
228
+ }),
229
+ },
230
+ ],
231
+ };
232
+ },
233
+ );
234
+ }
235
+
236
+ getNamespaces(): string[] {
237
+ const namespaces = new Set<string>();
238
+ for (const info of this.tools.values()) {
239
+ namespaces.add(info.namespace);
240
+ }
241
+ return Array.from(namespaces);
242
+ }
243
+
244
+ getToolInfos(): ServiceToolInfo[] {
245
+ return Array.from(this.tools.values());
246
+ }
247
+
248
+ has(namespace: string, procedure: string): boolean {
249
+ return this.tools.has(`${namespace}.${procedure}`);
250
+ }
251
+ }
package/src/shim.ts ADDED
@@ -0,0 +1,247 @@
1
+ export interface ShimOptions {
2
+ namespaces: string[];
3
+ extAppsVersion?: string;
4
+ }
5
+
6
+ export interface LiveUpdateShimOptions {
7
+ /** @default "^1.7.3" */
8
+ extAppsVersion?: string;
9
+ }
10
+
11
+ const DEFAULT_EXT_APPS_VERSION = "^1.7.3";
12
+
13
+ /**
14
+ * Generate the service-proxy shim that maps `namespace.procedure(args)` calls
15
+ * to `app.callServerTool({ name: "namespace__procedure", arguments: args })`.
16
+ *
17
+ * Returns an empty string when `namespaces` is empty (nothing to proxy).
18
+ */
19
+ export function generateServiceShim(options: ShimOptions): string {
20
+ const {
21
+ namespaces,
22
+ extAppsVersion = DEFAULT_EXT_APPS_VERSION,
23
+ } = options;
24
+
25
+ if (namespaces.length === 0) return "";
26
+
27
+ const namespacesJson = JSON.stringify(namespaces);
28
+ const importUrl = `https://esm.sh/@modelcontextprotocol/ext-apps@${extAppsVersion}`;
29
+
30
+ return `
31
+ import { App } from '${importUrl}';
32
+
33
+ const __patchwork_app = new App({ name: 'patchwork-widget', version: '0.1.0' });
34
+ const __patchwork_ready = __patchwork_app.connect().catch(function(err) {
35
+ console.error('[patchwork] Failed to connect to host:', err);
36
+ });
37
+
38
+ function __patchwork_createNamespaceProxy(namespace) {
39
+ return new Proxy({}, {
40
+ get: function(target, prop) {
41
+ if (typeof prop !== 'string') return undefined;
42
+ return function __patchwork_serviceCall(args) {
43
+ var toolName = namespace + '__' + prop;
44
+ return __patchwork_ready.then(function() {
45
+ return __patchwork_app.callServerTool({ name: toolName, arguments: args || {} });
46
+ }).then(function(result) {
47
+ if (result.isError) {
48
+ var errorMsg = result.content && result.content[0] && result.content[0].text
49
+ ? result.content[0].text
50
+ : 'Service call failed: ' + namespace + '.' + prop;
51
+ throw new Error(errorMsg);
52
+ }
53
+ var textContent = result.content && result.content.find(function(c) { return c.type === 'text'; });
54
+ if (textContent) {
55
+ try { return JSON.parse(textContent.text); }
56
+ catch (e) { return textContent.text; }
57
+ }
58
+ return result;
59
+ });
60
+ };
61
+ }
62
+ });
63
+ }
64
+
65
+ var __patchwork_namespaces = ${namespacesJson};
66
+ for (var __i = 0; __i < __patchwork_namespaces.length; __i++) {
67
+ var __ns = __patchwork_namespaces[__i];
68
+ window[__ns] = __patchwork_createNamespaceProxy(__ns);
69
+ }
70
+ `;
71
+ }
72
+
73
+ /**
74
+ * Generate the live-update shim that wires up `window.patchwork`:
75
+ *
76
+ * - `patchwork.subscribe(stream, callback)` — subscribe to a named data
77
+ * stream. Calls `subscribe_stream` on connect, then calls `callback(data)`
78
+ * each time new events arrive via `poll_updates`.
79
+ * - `patchwork.updateContext(content)` — push widget state back to the model
80
+ * via `app.updateModelContext()`.
81
+ * - `patchwork.fireEvent(toolName, args)` — convenience wrapper around
82
+ * `app.callServerTool()` for user interaction events.
83
+ *
84
+ * The shim uses a single shared `App` instance (exposed as
85
+ * `window.__patchwork_app`) so it composes correctly with the service shim.
86
+ * If the service shim is also injected it must use the same variable name —
87
+ * both shims are concatenated in the compiler, and the first one to run
88
+ * initialises the App; the second one skips the initialisation guard.
89
+ */
90
+ export function generateLiveUpdateShim(
91
+ options: LiveUpdateShimOptions = {},
92
+ ): string {
93
+ const { extAppsVersion = DEFAULT_EXT_APPS_VERSION } = options;
94
+ const importUrl = `https://esm.sh/@modelcontextprotocol/ext-apps@${extAppsVersion}`;
95
+
96
+ return `
97
+ import { App } from '${importUrl}';
98
+
99
+ // Shared App instance — the service shim may have already created this.
100
+ if (!window.__patchwork_app) {
101
+ window.__patchwork_app = new App({ name: 'patchwork-widget', version: '0.1.0' });
102
+ window.__patchwork_ready = window.__patchwork_app.connect().catch(function(err) {
103
+ console.error('[patchwork] Failed to connect to host:', err);
104
+ });
105
+ }
106
+
107
+ (function() {
108
+ var __app = window.__patchwork_app;
109
+ var __ready = window.__patchwork_ready;
110
+
111
+ // Per-stream state: { seq, callbacks }
112
+ var __streams = {};
113
+ // MCP session ID captured after connect
114
+ var __sessionId = null;
115
+
116
+ function __parseResult(result) {
117
+ if (!result || !result.content) return result;
118
+ var textContent = result.content.find(function(c) { return c.type === 'text'; });
119
+ if (textContent) {
120
+ try { return JSON.parse(textContent.text); }
121
+ catch (e) { return textContent.text; }
122
+ }
123
+ return result;
124
+ }
125
+
126
+ function __pollStream(stream) {
127
+ var state = __streams[stream];
128
+ if (!state) return;
129
+ var afterSeq = state.seq;
130
+ __app.callServerTool({ name: 'poll_updates', arguments: { stream: stream, after_seq: afterSeq } })
131
+ .then(function(result) {
132
+ var parsed = __parseResult(result);
133
+ if (!parsed || !parsed.success || !parsed.events || !parsed.events.length) return;
134
+ var events = parsed.events;
135
+ for (var i = 0; i < events.length; i++) {
136
+ var ev = events[i];
137
+ if (ev.seq > state.seq) {
138
+ state.seq = ev.seq;
139
+ }
140
+ for (var j = 0; j < state.callbacks.length; j++) {
141
+ try { state.callbacks[j](ev.data, ev.seq, stream); }
142
+ catch (cbErr) { console.error('[patchwork] subscribe callback error:', cbErr); }
143
+ }
144
+ }
145
+ })
146
+ .catch(function(err) {
147
+ console.warn('[patchwork] poll_updates failed for stream ' + stream + ':', err);
148
+ });
149
+ }
150
+
151
+ // When tools/list_changed fires, poll all subscribed streams.
152
+ __app.setNotificationHandler(
153
+ { method: 'notifications/tools/list_changed', params: {} },
154
+ function() {
155
+ var streamNames = Object.keys(__streams);
156
+ for (var i = 0; i < streamNames.length; i++) {
157
+ __pollStream(streamNames[i]);
158
+ }
159
+ return Promise.resolve();
160
+ }
161
+ );
162
+
163
+ // Expose the public patchwork API on window.
164
+ window.patchwork = {
165
+ /**
166
+ * Subscribe to a named data stream.
167
+ *
168
+ * @param {string} stream - Stream name (e.g. "price_feed").
169
+ * @param {function} callback - Called with (data, seq, stream) for each event.
170
+ * @returns {function} Unsubscribe function.
171
+ */
172
+ subscribe: function(stream, callback) {
173
+ if (!__streams[stream]) {
174
+ __streams[stream] = { seq: 0, callbacks: [] };
175
+ // Tell the server about this subscription once the App is connected.
176
+ __ready.then(function() {
177
+ var args = { stream: stream };
178
+ if (__sessionId) args.session_id = __sessionId;
179
+ return __app.callServerTool({ name: 'subscribe_stream', arguments: args });
180
+ }).then(function(result) {
181
+ var parsed = __parseResult(result);
182
+ if (parsed && typeof parsed.seq === 'number') {
183
+ // Start polling from the server's current seq.
184
+ if (__streams[stream]) {
185
+ __streams[stream].seq = parsed.seq;
186
+ }
187
+ }
188
+ }).catch(function(err) {
189
+ console.warn('[patchwork] subscribe_stream failed:', err);
190
+ });
191
+ }
192
+ __streams[stream].callbacks.push(callback);
193
+ return function unsubscribe() {
194
+ if (!__streams[stream]) return;
195
+ var idx = __streams[stream].callbacks.indexOf(callback);
196
+ if (idx !== -1) __streams[stream].callbacks.splice(idx, 1);
197
+ };
198
+ },
199
+
200
+ /**
201
+ * Push the widget's current state into the conversation context.
202
+ * The model will include this in its next response.
203
+ *
204
+ * @param {string|Array} content - Text string or array of MCP ContentBlock objects.
205
+ */
206
+ updateContext: function(content) {
207
+ return __ready.then(function() {
208
+ var params;
209
+ if (typeof content === 'string') {
210
+ params = { content: [{ type: 'text', text: content }] };
211
+ } else if (Array.isArray(content)) {
212
+ params = { content: content };
213
+ } else if (content && typeof content === 'object') {
214
+ params = { structuredContent: content };
215
+ } else {
216
+ params = { content: [{ type: 'text', text: String(content) }] };
217
+ }
218
+ return __app.request({ method: 'ui/update-model-context', params: params }, {});
219
+ });
220
+ },
221
+
222
+ /**
223
+ * Fire a client event as an MCP tool call. Use this to surface user
224
+ * interactions so the LLM can observe and react to them.
225
+ *
226
+ * @param {string} toolName - MCP tool name on the server.
227
+ * @param {object} args - Tool arguments.
228
+ */
229
+ fireEvent: function(toolName, args) {
230
+ return __ready.then(function() {
231
+ return __app.callServerTool({ name: toolName, arguments: args || {} });
232
+ }).then(function(result) {
233
+ return __parseResult(result);
234
+ });
235
+ },
236
+ };
237
+
238
+ // Capture the session ID from the host context on connect.
239
+ __ready.then(function() {
240
+ // The host context is available via getHostContext(); we can't get the
241
+ // session ID directly, but it's passed in subscribe_stream args from
242
+ // user code. Expose a setter so widgets can supply it explicitly.
243
+ window.patchwork._setSessionId = function(id) { __sessionId = id; };
244
+ }).catch(function() {});
245
+ })();
246
+ `;
247
+ }
@@ -0,0 +1,10 @@
1
+ export { WidgetStore, getWidgetStore, resetWidgetStore } from "./store.js";
2
+ export { LocalFileBackend } from "./local-backend.js";
3
+ export type {
4
+ FSProvider,
5
+ FileStats,
6
+ DirEntry,
7
+ StoredWidget,
8
+ StoredWidgetInfo,
9
+ WidgetStoreOptions,
10
+ } from "./types.js";