@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.
- package/.turbo/turbo-build.log +23 -0
- package/E2E_TESTING.md +224 -0
- package/LICENSE +373 -0
- package/README.md +164 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +990 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/dist/runtime/assets/index-tRNuU9ap.js +1059 -0
- package/dist/runtime/index.html +38 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +1511 -0
- package/dist/server.js.map +1 -0
- package/dist/shell/shell.js +67 -0
- package/docs/widget-preview.png +0 -0
- package/e2e/__snapshots__/.gitkeep +0 -0
- package/e2e/global-setup.ts +114 -0
- package/e2e/global-teardown.ts +15 -0
- package/e2e/visual-regression.test.ts +86 -0
- package/e2e/widget-smoke.test.ts +109 -0
- package/index.html +32 -0
- package/package.json +51 -0
- package/playwright.config.ts +43 -0
- package/src/__tests__/live-update.test.ts +158 -0
- package/src/__tests__/local-backend.test.ts +100 -0
- package/src/__tests__/memory-backend.ts +144 -0
- package/src/__tests__/registry-backend.test.ts +256 -0
- package/src/__tests__/services.test.ts +153 -0
- package/src/__tests__/shim.test.ts +60 -0
- package/src/__tests__/widget-store.test.ts +188 -0
- package/src/e2e-visual.ts +148 -0
- package/src/index.ts +608 -0
- package/src/live-update.ts +150 -0
- package/src/logger.ts +44 -0
- package/src/reference-widgets/live-dashboard.ts +162 -0
- package/src/registry-backend.ts +306 -0
- package/src/runtime/index.html +38 -0
- package/src/runtime/main.ts +164 -0
- package/src/scripts/export-artifacts.ts +51 -0
- package/src/server.ts +398 -0
- package/src/services.ts +307 -0
- package/src/shell/main.ts +172 -0
- package/src/shim.ts +106 -0
- package/src/tunnel.ts +123 -0
- package/src/widget-store/index.ts +10 -0
- package/src/widget-store/local-backend.ts +77 -0
- package/src/widget-store/store.ts +242 -0
- package/src/widget-store/types.ts +53 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +14 -0
- package/vite.runtime.config.ts +28 -0
- package/vite.shell.config.ts +30 -0
- package/vitest.config.ts +7 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1511 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { join as join3 } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import express from "express";
|
|
8
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
9
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import cors from "cors";
|
|
12
|
+
|
|
13
|
+
// src/logger.ts
|
|
14
|
+
var CURRENT_LEVEL = process.env["LOG_LEVEL"] ?? "info";
|
|
15
|
+
var LEVEL_RANK = {
|
|
16
|
+
debug: 0,
|
|
17
|
+
info: 1,
|
|
18
|
+
warn: 2,
|
|
19
|
+
error: 3
|
|
20
|
+
};
|
|
21
|
+
function shouldLog(level) {
|
|
22
|
+
return LEVEL_RANK[level] >= LEVEL_RANK[CURRENT_LEVEL];
|
|
23
|
+
}
|
|
24
|
+
function formatMessage(tag, ...args) {
|
|
25
|
+
return [`[${tag}]`, ...args];
|
|
26
|
+
}
|
|
27
|
+
function log(tag, ...args) {
|
|
28
|
+
if (shouldLog("info")) {
|
|
29
|
+
console.error(...formatMessage(tag, ...args));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function warn(tag, ...args) {
|
|
33
|
+
if (shouldLog("warn")) {
|
|
34
|
+
console.error(...formatMessage(tag, ...args));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function error(tag, ...args) {
|
|
38
|
+
if (shouldLog("error")) {
|
|
39
|
+
console.error(...formatMessage(tag, ...args));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/live-update.ts
|
|
44
|
+
var RING_BUFFER_MAX = 100;
|
|
45
|
+
var streamBuffers = /* @__PURE__ */ new Map();
|
|
46
|
+
var globalSeq = 0;
|
|
47
|
+
function getOrCreateBuffer(stream) {
|
|
48
|
+
let buf = streamBuffers.get(stream);
|
|
49
|
+
if (!buf) {
|
|
50
|
+
buf = [];
|
|
51
|
+
streamBuffers.set(stream, buf);
|
|
52
|
+
}
|
|
53
|
+
return buf;
|
|
54
|
+
}
|
|
55
|
+
function getEvents(stream, afterSeq) {
|
|
56
|
+
const buf = streamBuffers.get(stream);
|
|
57
|
+
if (!buf) return [];
|
|
58
|
+
return buf.filter((e) => e.seq > afterSeq);
|
|
59
|
+
}
|
|
60
|
+
function appendEvent(stream, data) {
|
|
61
|
+
const buf = getOrCreateBuffer(stream);
|
|
62
|
+
const event = {
|
|
63
|
+
seq: ++globalSeq,
|
|
64
|
+
data,
|
|
65
|
+
timestamp: Date.now()
|
|
66
|
+
};
|
|
67
|
+
buf.push(event);
|
|
68
|
+
if (buf.length > RING_BUFFER_MAX) {
|
|
69
|
+
buf.shift();
|
|
70
|
+
}
|
|
71
|
+
return event;
|
|
72
|
+
}
|
|
73
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
74
|
+
function registerSession(sessionId, server) {
|
|
75
|
+
if (!sessions.has(sessionId)) {
|
|
76
|
+
sessions.set(sessionId, { server, streams: /* @__PURE__ */ new Set() });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function unregisterSession(sessionId) {
|
|
80
|
+
sessions.delete(sessionId);
|
|
81
|
+
}
|
|
82
|
+
function subscribeSession(sessionId, stream) {
|
|
83
|
+
const entry = sessions.get(sessionId);
|
|
84
|
+
if (entry) {
|
|
85
|
+
entry.streams.add(stream);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function pushStreamUpdate(stream, data) {
|
|
89
|
+
const event = appendEvent(stream, data);
|
|
90
|
+
const pushPromises = [];
|
|
91
|
+
for (const [, entry] of sessions) {
|
|
92
|
+
if (entry.streams.has(stream)) {
|
|
93
|
+
const notifyPromise = entry.server.server.notification({ method: "notifications/tools/list_changed" }).catch((err) => {
|
|
94
|
+
warn("live-update", "Failed to notify session:", err);
|
|
95
|
+
});
|
|
96
|
+
pushPromises.push(notifyPromise);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
await Promise.all(pushPromises);
|
|
100
|
+
return event.seq;
|
|
101
|
+
}
|
|
102
|
+
function currentSeq() {
|
|
103
|
+
return globalSeq;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/registry-backend.ts
|
|
107
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
108
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
109
|
+
function parseRegistryToolName(mcpName) {
|
|
110
|
+
const idx = mcpName.indexOf("__");
|
|
111
|
+
if (idx === -1) {
|
|
112
|
+
return { namespace: mcpName, procedure: "call" };
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
namespace: mcpName.slice(0, idx),
|
|
116
|
+
procedure: mcpName.slice(idx + 2)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async function loadRegistryToolInfos(client) {
|
|
120
|
+
const listResult = await client.callTool({
|
|
121
|
+
name: "list_tools",
|
|
122
|
+
arguments: {}
|
|
123
|
+
});
|
|
124
|
+
const listText = listResult.content.filter((c) => c.type === "text").map((c) => c.text).join("");
|
|
125
|
+
let toolNames;
|
|
126
|
+
try {
|
|
127
|
+
toolNames = JSON.parse(listText);
|
|
128
|
+
} catch {
|
|
129
|
+
warn("registry-backend", "Failed to parse tool list response from Registry");
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
if (!Array.isArray(toolNames)) {
|
|
133
|
+
warn("registry-backend", "Unexpected tool list format from Registry (expected array)");
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
const mcpNames = toolNames.filter((n) => typeof n === "string");
|
|
137
|
+
const CHUNK_SIZE = 20;
|
|
138
|
+
const toolInfos = [];
|
|
139
|
+
for (let i = 0; i < mcpNames.length; i += CHUNK_SIZE) {
|
|
140
|
+
const chunk = mcpNames.slice(i, i + CHUNK_SIZE);
|
|
141
|
+
const results = await Promise.all(
|
|
142
|
+
chunk.map(async (mcpName) => {
|
|
143
|
+
try {
|
|
144
|
+
const infoResult = await client.callTool({
|
|
145
|
+
name: "tool_info",
|
|
146
|
+
arguments: { tool_name: mcpName }
|
|
147
|
+
});
|
|
148
|
+
const infoText = infoResult.content.filter((c) => c.type === "text").map((c) => c.text).join("");
|
|
149
|
+
const raw = JSON.parse(infoText);
|
|
150
|
+
const { namespace, procedure } = parseRegistryToolName(mcpName);
|
|
151
|
+
return {
|
|
152
|
+
name: `${namespace}.${procedure}`,
|
|
153
|
+
namespace,
|
|
154
|
+
procedure,
|
|
155
|
+
description: raw.description ?? `Call ${namespace}.${procedure}`,
|
|
156
|
+
parameters: raw.inputSchema
|
|
157
|
+
};
|
|
158
|
+
} catch (err) {
|
|
159
|
+
warn("registry-backend", `Failed to fetch tool info for '${mcpName}': ${err}`);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
for (const info of results) {
|
|
165
|
+
if (info !== null) {
|
|
166
|
+
toolInfos.push(info);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const providerCount = new Set(toolInfos.map((t) => t.namespace)).size;
|
|
171
|
+
log(
|
|
172
|
+
"registry-backend",
|
|
173
|
+
`Loaded ${toolInfos.length} tools from ${providerCount} provider(s)`
|
|
174
|
+
);
|
|
175
|
+
return toolInfos;
|
|
176
|
+
}
|
|
177
|
+
async function createRegistryBackend(options) {
|
|
178
|
+
const inheritedEnv = {};
|
|
179
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
180
|
+
if (v !== void 0) inheritedEnv[k] = v;
|
|
181
|
+
}
|
|
182
|
+
const env = {
|
|
183
|
+
...inheritedEnv,
|
|
184
|
+
UTDK_PROVIDERS: options.providers,
|
|
185
|
+
...options.env
|
|
186
|
+
};
|
|
187
|
+
const transport = new StdioClientTransport({
|
|
188
|
+
command: options.command,
|
|
189
|
+
args: options.args ?? [],
|
|
190
|
+
env,
|
|
191
|
+
// Route Registry stderr to the parent so operators can see provider logs.
|
|
192
|
+
stderr: "inherit"
|
|
193
|
+
});
|
|
194
|
+
const client = new Client({
|
|
195
|
+
name: "patchwork-mcp-app-server",
|
|
196
|
+
version: "0.1.0"
|
|
197
|
+
});
|
|
198
|
+
await client.connect(transport);
|
|
199
|
+
const toolInfos = await loadRegistryToolInfos(client);
|
|
200
|
+
const backend = {
|
|
201
|
+
/**
|
|
202
|
+
* Execute a Registry-backed service tool.
|
|
203
|
+
*
|
|
204
|
+
* The call is forwarded as a Registry `call_tool` meta-tool invocation:
|
|
205
|
+
* namespace + "__" + procedure → Registry MCP tool name
|
|
206
|
+
*
|
|
207
|
+
* The first element of `args` is treated as the tool's argument object.
|
|
208
|
+
*/
|
|
209
|
+
async call(namespace, procedure, args) {
|
|
210
|
+
const toolName = `${namespace}__${procedure}`;
|
|
211
|
+
const toolArgs = typeof args[0] === "object" && args[0] !== null ? args[0] : {};
|
|
212
|
+
const result = await client.callTool({
|
|
213
|
+
name: "call_tool",
|
|
214
|
+
arguments: {
|
|
215
|
+
tool_name: toolName,
|
|
216
|
+
arguments: toolArgs
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
if (result.isError) {
|
|
220
|
+
const message = result.content.filter((c) => c.type === "text").map((c) => c.text).join("\n");
|
|
221
|
+
throw new Error(message || `Registry call failed for '${toolName}'`);
|
|
222
|
+
}
|
|
223
|
+
const textContent = result.content.find(
|
|
224
|
+
(c) => c.type === "text"
|
|
225
|
+
);
|
|
226
|
+
if (textContent) {
|
|
227
|
+
try {
|
|
228
|
+
return JSON.parse(textContent.text);
|
|
229
|
+
} catch {
|
|
230
|
+
return textContent.text;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return result;
|
|
234
|
+
},
|
|
235
|
+
getToolInfos() {
|
|
236
|
+
return toolInfos;
|
|
237
|
+
},
|
|
238
|
+
async close() {
|
|
239
|
+
await client.close();
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
return backend;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/index.ts
|
|
246
|
+
import {
|
|
247
|
+
createProjectFromFiles,
|
|
248
|
+
createSingleFileProject
|
|
249
|
+
} from "@aprovan/patchwork-compiler";
|
|
250
|
+
import {
|
|
251
|
+
registerAppTool,
|
|
252
|
+
RESOURCE_MIME_TYPE
|
|
253
|
+
} from "@modelcontextprotocol/ext-apps/server";
|
|
254
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
255
|
+
import { z as z2 } from "zod";
|
|
256
|
+
|
|
257
|
+
// src/services.ts
|
|
258
|
+
import { z } from "zod";
|
|
259
|
+
var TOOL_SEPARATOR = "__";
|
|
260
|
+
function toMcpToolName(namespace, procedure) {
|
|
261
|
+
return `${namespace}${TOOL_SEPARATOR}${procedure}`;
|
|
262
|
+
}
|
|
263
|
+
function jsonSchemaToZodShape(schema) {
|
|
264
|
+
const properties = schema?.["properties"] ?? {};
|
|
265
|
+
const required = new Set(schema?.["required"] ?? []);
|
|
266
|
+
const shape = {};
|
|
267
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
268
|
+
let field;
|
|
269
|
+
switch (prop.type) {
|
|
270
|
+
case "number":
|
|
271
|
+
case "integer":
|
|
272
|
+
field = z.number();
|
|
273
|
+
break;
|
|
274
|
+
case "boolean":
|
|
275
|
+
field = z.boolean();
|
|
276
|
+
break;
|
|
277
|
+
case "array":
|
|
278
|
+
field = z.array(z.unknown());
|
|
279
|
+
break;
|
|
280
|
+
case "object":
|
|
281
|
+
field = z.record(z.unknown());
|
|
282
|
+
break;
|
|
283
|
+
default:
|
|
284
|
+
field = z.string();
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
if (prop.description) {
|
|
288
|
+
field = field.describe(prop.description);
|
|
289
|
+
}
|
|
290
|
+
if (!required.has(key)) {
|
|
291
|
+
field = field.optional();
|
|
292
|
+
}
|
|
293
|
+
shape[key] = field;
|
|
294
|
+
}
|
|
295
|
+
return shape;
|
|
296
|
+
}
|
|
297
|
+
var ServiceBridge = class {
|
|
298
|
+
backend;
|
|
299
|
+
tools = /* @__PURE__ */ new Map();
|
|
300
|
+
constructor(config) {
|
|
301
|
+
this.backend = config.backend;
|
|
302
|
+
for (const tool of config.tools) {
|
|
303
|
+
this.tools.set(tool.name, tool);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
registerTools(server) {
|
|
307
|
+
for (const [, info] of this.tools) {
|
|
308
|
+
const mcpToolName = toMcpToolName(info.namespace, info.procedure);
|
|
309
|
+
const inputShape = jsonSchemaToZodShape(info.parameters);
|
|
310
|
+
server.registerTool(
|
|
311
|
+
mcpToolName,
|
|
312
|
+
{
|
|
313
|
+
description: info.description ?? `Call ${info.namespace}.${info.procedure}`,
|
|
314
|
+
inputSchema: inputShape
|
|
315
|
+
},
|
|
316
|
+
async (args) => {
|
|
317
|
+
try {
|
|
318
|
+
const result = await this.backend.call(info.namespace, info.procedure, [args ?? {}]);
|
|
319
|
+
return {
|
|
320
|
+
content: [
|
|
321
|
+
{
|
|
322
|
+
type: "text",
|
|
323
|
+
text: typeof result === "string" ? result : JSON.stringify(result)
|
|
324
|
+
}
|
|
325
|
+
]
|
|
326
|
+
};
|
|
327
|
+
} catch (error2) {
|
|
328
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
329
|
+
return {
|
|
330
|
+
content: [
|
|
331
|
+
{
|
|
332
|
+
type: "text",
|
|
333
|
+
text: `Service call failed: ${message}`
|
|
334
|
+
}
|
|
335
|
+
],
|
|
336
|
+
isError: true
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
registerSearchServices(server) {
|
|
344
|
+
server.registerTool(
|
|
345
|
+
"search_services",
|
|
346
|
+
{
|
|
347
|
+
description: "Search for available service tools that widgets can call. Returns matching services with their namespaces, procedures, and parameter schemas.",
|
|
348
|
+
inputSchema: {
|
|
349
|
+
query: z.string().optional().describe(
|
|
350
|
+
'Natural language description of what you want to do (e.g., "get weather forecast")'
|
|
351
|
+
),
|
|
352
|
+
namespace: z.string().optional().describe('Filter results to a specific service namespace (e.g., "weather")'),
|
|
353
|
+
tool_name: z.string().optional().describe("Get detailed info about a specific tool by name"),
|
|
354
|
+
limit: z.number().optional().describe("Maximum number of results to return")
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
async (args) => {
|
|
358
|
+
const query = args?.["query"];
|
|
359
|
+
const namespace = args?.["namespace"];
|
|
360
|
+
const toolName = args?.["tool_name"];
|
|
361
|
+
const limit = args?.["limit"] ?? 10;
|
|
362
|
+
if (toolName) {
|
|
363
|
+
const dotName = toolName.replace(/__/g, ".");
|
|
364
|
+
const info = this.tools.get(toolName) ?? this.tools.get(dotName);
|
|
365
|
+
if (!info) {
|
|
366
|
+
return {
|
|
367
|
+
content: [
|
|
368
|
+
{
|
|
369
|
+
type: "text",
|
|
370
|
+
text: JSON.stringify({
|
|
371
|
+
success: false,
|
|
372
|
+
error: `Tool '${toolName}' not found`
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
]
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
content: [
|
|
380
|
+
{
|
|
381
|
+
type: "text",
|
|
382
|
+
text: JSON.stringify({ success: true, tool: info })
|
|
383
|
+
}
|
|
384
|
+
]
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
let results = Array.from(this.tools.values());
|
|
388
|
+
if (namespace) {
|
|
389
|
+
results = results.filter((info) => info.namespace === namespace);
|
|
390
|
+
}
|
|
391
|
+
if (query) {
|
|
392
|
+
const queryLower = query.toLowerCase();
|
|
393
|
+
const keywords = queryLower.split(/\s+/).filter(Boolean);
|
|
394
|
+
results = results.map((info) => {
|
|
395
|
+
const searchText = `${info.name} ${info.namespace} ${info.procedure} ${info.description ?? ""}`.toLowerCase();
|
|
396
|
+
const matchCount = keywords.filter((kw) => searchText.includes(kw)).length;
|
|
397
|
+
return { info, score: matchCount / keywords.length };
|
|
398
|
+
}).filter(({ score }) => score > 0).sort((a, b) => b.score - a.score).map(({ info }) => info);
|
|
399
|
+
}
|
|
400
|
+
results = results.slice(0, limit);
|
|
401
|
+
return {
|
|
402
|
+
content: [
|
|
403
|
+
{
|
|
404
|
+
type: "text",
|
|
405
|
+
text: JSON.stringify({
|
|
406
|
+
success: true,
|
|
407
|
+
count: results.length,
|
|
408
|
+
tools: results,
|
|
409
|
+
namespaces: this.getNamespaces()
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
]
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
);
|
|
416
|
+
server.registerTool(
|
|
417
|
+
"call_service",
|
|
418
|
+
{
|
|
419
|
+
description: "Call a service tool by namespace and procedure. Use search_services to discover available tools first.",
|
|
420
|
+
inputSchema: {
|
|
421
|
+
namespace: z.string().describe('Service namespace (e.g., "github", "stripe")'),
|
|
422
|
+
procedure: z.string().describe('Procedure name (e.g., "repos_list", "customers_create")'),
|
|
423
|
+
args: z.record(z.unknown()).optional().describe("Arguments to pass to the procedure")
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
async (params) => {
|
|
427
|
+
const namespace = params?.["namespace"];
|
|
428
|
+
const procedure = params?.["procedure"];
|
|
429
|
+
const args = params?.["args"] ?? {};
|
|
430
|
+
if (!namespace || !procedure) {
|
|
431
|
+
return {
|
|
432
|
+
content: [
|
|
433
|
+
{
|
|
434
|
+
type: "text",
|
|
435
|
+
text: JSON.stringify({
|
|
436
|
+
success: false,
|
|
437
|
+
error: "namespace and procedure are required"
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
],
|
|
441
|
+
isError: true
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
const toolKey = `${namespace}.${procedure}`;
|
|
445
|
+
const info = this.tools.get(toolKey);
|
|
446
|
+
if (!info) {
|
|
447
|
+
return {
|
|
448
|
+
content: [
|
|
449
|
+
{
|
|
450
|
+
type: "text",
|
|
451
|
+
text: JSON.stringify({
|
|
452
|
+
success: false,
|
|
453
|
+
error: `Tool '${namespace}.${procedure}' not found. Use search_services to discover available tools.`
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
],
|
|
457
|
+
isError: true
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
const result = await this.backend.call(namespace, procedure, [args]);
|
|
462
|
+
return {
|
|
463
|
+
content: [
|
|
464
|
+
{
|
|
465
|
+
type: "text",
|
|
466
|
+
text: typeof result === "string" ? result : JSON.stringify(result)
|
|
467
|
+
}
|
|
468
|
+
]
|
|
469
|
+
};
|
|
470
|
+
} catch (error2) {
|
|
471
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
472
|
+
return {
|
|
473
|
+
content: [
|
|
474
|
+
{
|
|
475
|
+
type: "text",
|
|
476
|
+
text: JSON.stringify({
|
|
477
|
+
success: false,
|
|
478
|
+
error: `Service call failed: ${message}`
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
],
|
|
482
|
+
isError: true
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
getNamespaces() {
|
|
489
|
+
const namespaces = /* @__PURE__ */ new Set();
|
|
490
|
+
for (const info of this.tools.values()) {
|
|
491
|
+
namespaces.add(info.namespace);
|
|
492
|
+
}
|
|
493
|
+
return Array.from(namespaces);
|
|
494
|
+
}
|
|
495
|
+
getToolInfos() {
|
|
496
|
+
return Array.from(this.tools.values());
|
|
497
|
+
}
|
|
498
|
+
has(namespace, procedure) {
|
|
499
|
+
return this.tools.has(`${namespace}.${procedure}`);
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
// src/widget-store/store.ts
|
|
504
|
+
import { join as join2, resolve } from "path";
|
|
505
|
+
import { homedir } from "os";
|
|
506
|
+
|
|
507
|
+
// src/widget-store/local-backend.ts
|
|
508
|
+
import { readFile, writeFile, unlink, stat, mkdir, readdir, rm, access } from "fs/promises";
|
|
509
|
+
import { join, dirname, normalize } from "path";
|
|
510
|
+
function createDirEntry(name, isDir) {
|
|
511
|
+
return {
|
|
512
|
+
name,
|
|
513
|
+
isFile: () => !isDir,
|
|
514
|
+
isDirectory: () => isDir
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
function createFileStats(size, mtime, isDir) {
|
|
518
|
+
return {
|
|
519
|
+
size,
|
|
520
|
+
mtime,
|
|
521
|
+
isFile: () => !isDir,
|
|
522
|
+
isDirectory: () => isDir
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
var LocalFileBackend = class {
|
|
526
|
+
constructor(basePath) {
|
|
527
|
+
this.basePath = basePath;
|
|
528
|
+
}
|
|
529
|
+
basePath;
|
|
530
|
+
resolve(path) {
|
|
531
|
+
return join(this.basePath, normalize(path));
|
|
532
|
+
}
|
|
533
|
+
async readFile(path) {
|
|
534
|
+
return readFile(this.resolve(path), "utf-8");
|
|
535
|
+
}
|
|
536
|
+
async writeFile(path, content) {
|
|
537
|
+
const fullPath = this.resolve(path);
|
|
538
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
539
|
+
await writeFile(fullPath, content, "utf-8");
|
|
540
|
+
}
|
|
541
|
+
async unlink(path) {
|
|
542
|
+
await unlink(this.resolve(path));
|
|
543
|
+
}
|
|
544
|
+
async stat(path) {
|
|
545
|
+
const fullPath = this.resolve(path);
|
|
546
|
+
const s = await stat(fullPath);
|
|
547
|
+
return createFileStats(s.size, s.mtime, s.isDirectory());
|
|
548
|
+
}
|
|
549
|
+
async mkdir(path, options) {
|
|
550
|
+
await mkdir(this.resolve(path), options);
|
|
551
|
+
}
|
|
552
|
+
async readdir(path) {
|
|
553
|
+
const fullPath = this.resolve(path);
|
|
554
|
+
const entries = await readdir(fullPath, { withFileTypes: true });
|
|
555
|
+
return entries.map(
|
|
556
|
+
(e) => createDirEntry(e.name, e.isDirectory())
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
async rmdir(path, options) {
|
|
560
|
+
if (options?.recursive) {
|
|
561
|
+
await rm(this.resolve(path), { recursive: true, force: true });
|
|
562
|
+
} else {
|
|
563
|
+
await rm(this.resolve(path), { force: true });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
async exists(path) {
|
|
567
|
+
try {
|
|
568
|
+
await access(this.resolve(path));
|
|
569
|
+
return true;
|
|
570
|
+
} catch {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// src/widget-store/store.ts
|
|
577
|
+
var WIDGETS_PREFIX = "widgets";
|
|
578
|
+
var FILES_SUBDIR = "files";
|
|
579
|
+
var RESOURCE_URI_PREFIX = "ui://widgets/";
|
|
580
|
+
function getDefaultStorageDir() {
|
|
581
|
+
return process.env["WIDGET_STORE_PATH"] ?? join2(homedir(), ".patchwork", "widget-store");
|
|
582
|
+
}
|
|
583
|
+
var WidgetStore = class {
|
|
584
|
+
provider;
|
|
585
|
+
storageDir;
|
|
586
|
+
constructor(options = {}) {
|
|
587
|
+
this.storageDir = resolve(options.storageDir ?? getDefaultStorageDir());
|
|
588
|
+
this.provider = options.backend ?? new LocalFileBackend(this.storageDir);
|
|
589
|
+
}
|
|
590
|
+
fullPath(virtualPath) {
|
|
591
|
+
return join2(WIDGETS_PREFIX, virtualPath);
|
|
592
|
+
}
|
|
593
|
+
async readFilesRecursive(dir, base = "") {
|
|
594
|
+
const files = [];
|
|
595
|
+
let entries;
|
|
596
|
+
try {
|
|
597
|
+
entries = await this.provider.readdir(dir);
|
|
598
|
+
} catch {
|
|
599
|
+
return files;
|
|
600
|
+
}
|
|
601
|
+
for (const entry of entries) {
|
|
602
|
+
const childDir = join2(dir, entry.name);
|
|
603
|
+
const relPath = base ? `${base}/${entry.name}` : entry.name;
|
|
604
|
+
if (entry.isDirectory()) {
|
|
605
|
+
files.push(...await this.readFilesRecursive(childDir, relPath));
|
|
606
|
+
} else {
|
|
607
|
+
const content = await this.provider.readFile(childDir);
|
|
608
|
+
files.push({ path: relPath, content });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return files;
|
|
612
|
+
}
|
|
613
|
+
async saveWidget(hash, files, manifest, entry) {
|
|
614
|
+
const widgetDir = `${manifest.name}/${hash}`;
|
|
615
|
+
const createdAt = Date.now();
|
|
616
|
+
await Promise.all(
|
|
617
|
+
files.map(
|
|
618
|
+
(file) => this.provider.writeFile(
|
|
619
|
+
this.fullPath(`${widgetDir}/${FILES_SUBDIR}/${file.path}`),
|
|
620
|
+
file.content
|
|
621
|
+
)
|
|
622
|
+
)
|
|
623
|
+
);
|
|
624
|
+
const storedManifest = { ...manifest, entry, createdAt };
|
|
625
|
+
await this.provider.writeFile(
|
|
626
|
+
this.fullPath(`${widgetDir}/manifest.json`),
|
|
627
|
+
JSON.stringify(storedManifest)
|
|
628
|
+
);
|
|
629
|
+
return {
|
|
630
|
+
path: this.fullPath(widgetDir),
|
|
631
|
+
resourceUri: `${RESOURCE_URI_PREFIX}${widgetDir}/view.html`,
|
|
632
|
+
files,
|
|
633
|
+
entry,
|
|
634
|
+
manifest,
|
|
635
|
+
createdAt
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
async getWidget(name, hash) {
|
|
639
|
+
const widgetDir = `${name}/${hash}`;
|
|
640
|
+
const manifestVirtualPath = this.fullPath(`${widgetDir}/manifest.json`);
|
|
641
|
+
const exists = await this.provider.exists(manifestVirtualPath);
|
|
642
|
+
if (!exists) return null;
|
|
643
|
+
const files = await this.readFilesRecursive(this.fullPath(`${widgetDir}/${FILES_SUBDIR}`));
|
|
644
|
+
let manifest;
|
|
645
|
+
let entry = files[0]?.path ?? "main.tsx";
|
|
646
|
+
let createdAt = Date.now();
|
|
647
|
+
try {
|
|
648
|
+
const raw = await this.provider.readFile(manifestVirtualPath);
|
|
649
|
+
const parsed = JSON.parse(raw);
|
|
650
|
+
manifest = {
|
|
651
|
+
name: parsed.name ?? name,
|
|
652
|
+
version: parsed.version ?? "0.1.0",
|
|
653
|
+
platform: parsed.platform ?? "browser",
|
|
654
|
+
image: parsed.image ?? "@aprovan/patchwork-image-shadcn",
|
|
655
|
+
description: parsed.description,
|
|
656
|
+
services: parsed.services
|
|
657
|
+
};
|
|
658
|
+
if (parsed.entry) entry = parsed.entry;
|
|
659
|
+
if (typeof parsed.createdAt === "number") createdAt = parsed.createdAt;
|
|
660
|
+
} catch {
|
|
661
|
+
manifest = {
|
|
662
|
+
name,
|
|
663
|
+
version: "0.1.0",
|
|
664
|
+
platform: "browser",
|
|
665
|
+
image: "@aprovan/patchwork-image-shadcn"
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
path: this.fullPath(widgetDir),
|
|
670
|
+
resourceUri: `${RESOURCE_URI_PREFIX}${widgetDir}/view.html`,
|
|
671
|
+
files,
|
|
672
|
+
entry,
|
|
673
|
+
manifest,
|
|
674
|
+
createdAt
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
async listWidgets() {
|
|
678
|
+
const results = [];
|
|
679
|
+
const rootPath = this.fullPath("");
|
|
680
|
+
try {
|
|
681
|
+
const names = await this.provider.readdir(rootPath);
|
|
682
|
+
for (const nameEntry of names) {
|
|
683
|
+
if (!nameEntry.isDirectory()) continue;
|
|
684
|
+
const name = nameEntry.name;
|
|
685
|
+
try {
|
|
686
|
+
const hashes = await this.provider.readdir(join2(rootPath, name));
|
|
687
|
+
for (const hashEntry of hashes) {
|
|
688
|
+
if (!hashEntry.isDirectory()) continue;
|
|
689
|
+
const hash = hashEntry.name;
|
|
690
|
+
const manifestVirtualPath = this.fullPath(`${name}/${hash}/manifest.json`);
|
|
691
|
+
let manifest = {};
|
|
692
|
+
try {
|
|
693
|
+
const raw = await this.provider.readFile(manifestVirtualPath);
|
|
694
|
+
manifest = JSON.parse(raw);
|
|
695
|
+
} catch {
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
results.push({
|
|
699
|
+
path: this.fullPath(`${name}/${hash}`),
|
|
700
|
+
resourceUri: `${RESOURCE_URI_PREFIX}${name}/${hash}/view.html`,
|
|
701
|
+
name: manifest.name ?? name,
|
|
702
|
+
version: manifest.version ?? "0.1.0",
|
|
703
|
+
description: manifest.description,
|
|
704
|
+
services: manifest.services,
|
|
705
|
+
entry: manifest.entry,
|
|
706
|
+
createdAt: manifest.createdAt ?? 0
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
} catch {
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
} catch {
|
|
714
|
+
return results;
|
|
715
|
+
}
|
|
716
|
+
return results.sort((a, b) => b.createdAt - a.createdAt);
|
|
717
|
+
}
|
|
718
|
+
async deleteWidget(name, hash) {
|
|
719
|
+
const widgetDir = this.fullPath(`${name}/${hash}`);
|
|
720
|
+
const exists = await this.provider.exists(widgetDir);
|
|
721
|
+
if (!exists) return false;
|
|
722
|
+
await this.provider.rmdir(widgetDir, { recursive: true });
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
async hasWidget(name, hash) {
|
|
726
|
+
return this.provider.exists(this.fullPath(`${name}/${hash}/manifest.json`));
|
|
727
|
+
}
|
|
728
|
+
resourceUriFor(name, hash) {
|
|
729
|
+
return `${RESOURCE_URI_PREFIX}${name}/${hash}/view.html`;
|
|
730
|
+
}
|
|
731
|
+
async loadAll() {
|
|
732
|
+
const infos = await this.listWidgets();
|
|
733
|
+
const widgets = [];
|
|
734
|
+
for (const info of infos) {
|
|
735
|
+
const uriPath = info.resourceUri.replace(RESOURCE_URI_PREFIX, "").replace(/\/view\.html$/, "");
|
|
736
|
+
const parts = uriPath.split("/");
|
|
737
|
+
const name = parts[0];
|
|
738
|
+
const hash = parts[1];
|
|
739
|
+
if (!name || !hash) continue;
|
|
740
|
+
const widget = await this.getWidget(name, hash);
|
|
741
|
+
if (widget) widgets.push(widget);
|
|
742
|
+
}
|
|
743
|
+
return widgets;
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
var _instance = null;
|
|
747
|
+
function getWidgetStore(options) {
|
|
748
|
+
if (!_instance) {
|
|
749
|
+
_instance = new WidgetStore(options);
|
|
750
|
+
}
|
|
751
|
+
return _instance;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/index.ts
|
|
755
|
+
var DEFAULT_WIDGET_PORT = Number(process.env["WIDGET_PORT"] ?? 3002);
|
|
756
|
+
var DEFAULT_WIDGET_HOST = process.env["WIDGET_HOST"] ?? "localhost";
|
|
757
|
+
var DEFAULT_WIDGET_BASE_URL = `http://${DEFAULT_WIDGET_HOST}:${DEFAULT_WIDGET_PORT}`;
|
|
758
|
+
function generateResourceHtml(shellUrl, runtimeUrl, widget, inputs) {
|
|
759
|
+
const config = JSON.stringify({
|
|
760
|
+
runtime: runtimeUrl,
|
|
761
|
+
widget: `${widget.name}/${widget.hash}`,
|
|
762
|
+
inputs
|
|
763
|
+
});
|
|
764
|
+
const configB64 = Buffer.from(config, "utf-8").toString("base64");
|
|
765
|
+
return `<!DOCTYPE html>
|
|
766
|
+
<html lang="en">
|
|
767
|
+
<head>
|
|
768
|
+
<meta charset="utf-8" />
|
|
769
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
770
|
+
<title>${widget.name}</title>
|
|
771
|
+
<style>
|
|
772
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
773
|
+
html, body { width: 100%; }
|
|
774
|
+
#pw-root { width: 100%; }
|
|
775
|
+
</style>
|
|
776
|
+
</head>
|
|
777
|
+
<body>
|
|
778
|
+
<div id="pw-root"></div>
|
|
779
|
+
<script src="${shellUrl}" data-config="${configB64}"></script>
|
|
780
|
+
</body>
|
|
781
|
+
</html>`;
|
|
782
|
+
}
|
|
783
|
+
function hashFiles(files, manifest) {
|
|
784
|
+
const input = JSON.stringify({
|
|
785
|
+
name: manifest.name,
|
|
786
|
+
image: manifest.image,
|
|
787
|
+
files: files.map((f) => [f.path, f.content])
|
|
788
|
+
});
|
|
789
|
+
let hash = 0;
|
|
790
|
+
for (let i = 0; i < input.length; i++) {
|
|
791
|
+
hash = (hash << 5) - hash + input.charCodeAt(i) | 0;
|
|
792
|
+
}
|
|
793
|
+
return Math.abs(hash).toString(16).padStart(8, "0");
|
|
794
|
+
}
|
|
795
|
+
var MANIFEST_DEFAULTS = {
|
|
796
|
+
name: "widget",
|
|
797
|
+
version: "0.1.0",
|
|
798
|
+
platform: "browser",
|
|
799
|
+
image: "@aprovan/patchwork-image-shadcn"
|
|
800
|
+
};
|
|
801
|
+
function buildCspConfig(widgetBaseUrl) {
|
|
802
|
+
try {
|
|
803
|
+
const url = new URL(widgetBaseUrl);
|
|
804
|
+
const origin = url.port ? `${url.protocol}//${url.hostname}:${url.port}` : `${url.protocol}//${url.hostname}`;
|
|
805
|
+
return { frameDomains: [origin], resourceDomains: [origin] };
|
|
806
|
+
} catch {
|
|
807
|
+
return void 0;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
function buildManifest(input) {
|
|
811
|
+
return {
|
|
812
|
+
name: input?.["name"] ?? MANIFEST_DEFAULTS.name,
|
|
813
|
+
version: input?.["version"] ?? MANIFEST_DEFAULTS.version,
|
|
814
|
+
platform: "browser",
|
|
815
|
+
image: input?.["image"] ?? MANIFEST_DEFAULTS.image,
|
|
816
|
+
services: input?.["services"]
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
var DEFAULT_WIDGET_SOURCE = "export default function Widget() { return <div>Hello Patchwork</div>; }";
|
|
820
|
+
function buildProject(name, source, files, entry) {
|
|
821
|
+
if (files && files.length > 0) {
|
|
822
|
+
const virtualFiles = files.map((f) => ({
|
|
823
|
+
path: f.path,
|
|
824
|
+
content: f.content
|
|
825
|
+
}));
|
|
826
|
+
const project = createProjectFromFiles(virtualFiles, name);
|
|
827
|
+
if (entry) project.entry = entry;
|
|
828
|
+
return project;
|
|
829
|
+
}
|
|
830
|
+
return createSingleFileProject(source ?? DEFAULT_WIDGET_SOURCE, entry ?? "main.tsx", name);
|
|
831
|
+
}
|
|
832
|
+
function createMcpAppServer(options = {}) {
|
|
833
|
+
const server = new McpServer({
|
|
834
|
+
name: "patchwork-mcp-app-server",
|
|
835
|
+
version: "0.1.0"
|
|
836
|
+
});
|
|
837
|
+
const serviceBridge = options.services ? new ServiceBridge(options.services) : null;
|
|
838
|
+
const widgetBaseUrl = options.widgetBaseUrl ?? DEFAULT_WIDGET_BASE_URL;
|
|
839
|
+
const runtimeUrl = `${widgetBaseUrl}/runtime/`;
|
|
840
|
+
const shellUrl = `${widgetBaseUrl}/shell/shell.js`;
|
|
841
|
+
const directUrl = (name, hash) => `${runtimeUrl}?widget=${encodeURIComponent(name)}/${encodeURIComponent(hash)}`;
|
|
842
|
+
const store = getWidgetStore();
|
|
843
|
+
const renderResource = (ref, inputs) => {
|
|
844
|
+
const csp = buildCspConfig(widgetBaseUrl);
|
|
845
|
+
return {
|
|
846
|
+
type: "resource",
|
|
847
|
+
resource: {
|
|
848
|
+
uri: store.resourceUriFor(ref.name, ref.hash),
|
|
849
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
850
|
+
text: generateResourceHtml(shellUrl, runtimeUrl, ref, inputs),
|
|
851
|
+
...csp ? { _meta: { ui: { csp } } } : {}
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
};
|
|
855
|
+
registerAppTool(
|
|
856
|
+
server,
|
|
857
|
+
"save_widget",
|
|
858
|
+
{
|
|
859
|
+
description: "Save a JSX/TSX widget's raw source files for reuse and render it as an MCP App resource. Pass source code for a single-file widget, or a files array for a multi-file project. The widget is stored uncompiled and compiled in the browser by the shared Patchwork runtime when rendered \u2014 pass `inputs` to supply startup props to the widget.",
|
|
860
|
+
inputSchema: {
|
|
861
|
+
source: z2.string().optional().describe(
|
|
862
|
+
"JSX/TSX source code for a single-file widget. Must export a default React component."
|
|
863
|
+
),
|
|
864
|
+
files: z2.array(
|
|
865
|
+
z2.object({
|
|
866
|
+
path: z2.string().describe("File path relative to project root (e.g. 'main.tsx')"),
|
|
867
|
+
content: z2.string().describe("File contents")
|
|
868
|
+
})
|
|
869
|
+
).optional().describe(
|
|
870
|
+
"Array of files for a multi-file widget project. At least one file should be the entry point (main.tsx or index.tsx)."
|
|
871
|
+
),
|
|
872
|
+
entry: z2.string().optional().describe("Entry point file path. Defaults to auto-detection (main.tsx, index.tsx)."),
|
|
873
|
+
name: z2.string().optional().describe("Widget name for the manifest. Defaults to 'widget'."),
|
|
874
|
+
image: z2.string().optional().describe(
|
|
875
|
+
"Patchwork image package to use. Defaults to '@aprovan/patchwork-image-shadcn'."
|
|
876
|
+
),
|
|
877
|
+
services: z2.array(z2.string()).optional().describe(
|
|
878
|
+
"Service namespaces the widget calls (e.g., ['weather', 'stripe']). A proxy shim is injected so widget code can call namespace.procedure(args) directly."
|
|
879
|
+
),
|
|
880
|
+
inputs: z2.record(z2.unknown()).optional().describe("Startup props passed to the widget's default export when it is rendered.")
|
|
881
|
+
},
|
|
882
|
+
_meta: {
|
|
883
|
+
ui: { resourceUri: "ui://widgets/{name}/{hash}/view.html" }
|
|
884
|
+
}
|
|
885
|
+
},
|
|
886
|
+
async (args) => {
|
|
887
|
+
const source = args?.["source"];
|
|
888
|
+
const files = args?.["files"];
|
|
889
|
+
const entry = args?.["entry"];
|
|
890
|
+
const requestedServices = args?.["services"];
|
|
891
|
+
const inputs = args?.["inputs"] ?? {};
|
|
892
|
+
const manifestInput = {};
|
|
893
|
+
if (args?.["name"]) manifestInput["name"] = args["name"];
|
|
894
|
+
if (args?.["image"]) manifestInput["image"] = args["image"];
|
|
895
|
+
let services = requestedServices ?? [];
|
|
896
|
+
if (services.length > 0 && serviceBridge) {
|
|
897
|
+
const availableNamespaces = serviceBridge.getNamespaces();
|
|
898
|
+
const unavailable = services.filter((ns) => !availableNamespaces.includes(ns));
|
|
899
|
+
if (unavailable.length > 0) {
|
|
900
|
+
warn(
|
|
901
|
+
"mcp-app-server",
|
|
902
|
+
`Requested services not available: ${unavailable.join(", ")}. Available: ${availableNamespaces.join(", ")}`
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
services = services.filter((ns) => availableNamespaces.includes(ns));
|
|
906
|
+
}
|
|
907
|
+
if (services.length > 0) manifestInput["services"] = services;
|
|
908
|
+
const manifest = buildManifest(manifestInput);
|
|
909
|
+
const project = buildProject(manifest.name, source, files, entry);
|
|
910
|
+
const projectFiles = Array.from(project.files.values());
|
|
911
|
+
try {
|
|
912
|
+
const hash = hashFiles(projectFiles, manifest);
|
|
913
|
+
await store.saveWidget(hash, projectFiles, manifest, project.entry);
|
|
914
|
+
const ref = { name: manifest.name, hash, entry: project.entry };
|
|
915
|
+
return {
|
|
916
|
+
content: [
|
|
917
|
+
renderResource(ref, inputs),
|
|
918
|
+
{
|
|
919
|
+
type: "text",
|
|
920
|
+
text: `Widget "${manifest.name}" saved. Hash: ${hash}
|
|
921
|
+
Compiled in-browser at: ${directUrl(manifest.name, hash)}`
|
|
922
|
+
}
|
|
923
|
+
]
|
|
924
|
+
};
|
|
925
|
+
} catch (error2) {
|
|
926
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
927
|
+
return {
|
|
928
|
+
content: [
|
|
929
|
+
{
|
|
930
|
+
type: "text",
|
|
931
|
+
text: `Failed to save widget: ${message}`
|
|
932
|
+
}
|
|
933
|
+
],
|
|
934
|
+
isError: true
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
);
|
|
939
|
+
if (serviceBridge) {
|
|
940
|
+
serviceBridge.registerSearchServices(server);
|
|
941
|
+
}
|
|
942
|
+
registerAppTool(
|
|
943
|
+
server,
|
|
944
|
+
"list_widgets",
|
|
945
|
+
{
|
|
946
|
+
description: "List all persisted widgets in the VFS widget store. Returns each widget's name, version, description, path, and resource URI.",
|
|
947
|
+
_meta: { ui: { resourceUri: "ui://widgets/list" } }
|
|
948
|
+
},
|
|
949
|
+
async () => {
|
|
950
|
+
const widgets = await store.listWidgets();
|
|
951
|
+
if (widgets.length === 0) {
|
|
952
|
+
return {
|
|
953
|
+
content: [
|
|
954
|
+
{
|
|
955
|
+
type: "text",
|
|
956
|
+
text: "No widgets stored in the VFS widget store."
|
|
957
|
+
}
|
|
958
|
+
]
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
const lines = widgets.map((w) => {
|
|
962
|
+
const parts = [`- **${w.name}** (v${w.version})`];
|
|
963
|
+
if (w.description) parts.push(` ${w.description}`);
|
|
964
|
+
parts.push(` Path: ${w.path}`);
|
|
965
|
+
parts.push(` URI: ${w.resourceUri}`);
|
|
966
|
+
if (w.entry) parts.push(` Entry: ${w.entry}`);
|
|
967
|
+
if (w.services && w.services.length > 0) parts.push(` Services: ${w.services.join(", ")}`);
|
|
968
|
+
return parts.join("\n");
|
|
969
|
+
});
|
|
970
|
+
return {
|
|
971
|
+
content: [
|
|
972
|
+
{
|
|
973
|
+
type: "text",
|
|
974
|
+
text: `Stored widgets (${widgets.length}):
|
|
975
|
+
|
|
976
|
+
${lines.join("\n\n")}`
|
|
977
|
+
}
|
|
978
|
+
]
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
);
|
|
982
|
+
registerAppTool(
|
|
983
|
+
server,
|
|
984
|
+
"render_widget",
|
|
985
|
+
{
|
|
986
|
+
description: "Render a persisted widget by its name and hash. Serves the saved widget as an MCP App resource that compiles in the browser, optionally supplying startup props via `inputs`.",
|
|
987
|
+
inputSchema: {
|
|
988
|
+
name: z2.string().describe("Widget name (as stored in the VFS widget store)."),
|
|
989
|
+
hash: z2.string().optional().describe(
|
|
990
|
+
"Widget content hash. If omitted, renders the most recent version of the named widget."
|
|
991
|
+
),
|
|
992
|
+
inputs: z2.record(z2.unknown()).optional().describe("Startup props passed to the widget's default export when it is rendered.")
|
|
993
|
+
},
|
|
994
|
+
_meta: {
|
|
995
|
+
ui: { resourceUri: "ui://widgets/{name}/{hash}/view.html" }
|
|
996
|
+
}
|
|
997
|
+
},
|
|
998
|
+
async (args) => {
|
|
999
|
+
const name = args?.["name"];
|
|
1000
|
+
const hashInput = args?.["hash"];
|
|
1001
|
+
const inputs = args?.["inputs"] ?? {};
|
|
1002
|
+
if (!name) {
|
|
1003
|
+
return {
|
|
1004
|
+
content: [
|
|
1005
|
+
{
|
|
1006
|
+
type: "text",
|
|
1007
|
+
text: "Widget name is required."
|
|
1008
|
+
}
|
|
1009
|
+
],
|
|
1010
|
+
isError: true
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
let hash = hashInput;
|
|
1014
|
+
if (!hash) {
|
|
1015
|
+
const widgets = await store.listWidgets();
|
|
1016
|
+
const match = widgets.find((w) => w.name === name);
|
|
1017
|
+
if (!match) {
|
|
1018
|
+
return {
|
|
1019
|
+
content: [
|
|
1020
|
+
{
|
|
1021
|
+
type: "text",
|
|
1022
|
+
text: `No stored widget found with name "${name}".`
|
|
1023
|
+
}
|
|
1024
|
+
],
|
|
1025
|
+
isError: true
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
const resourcePath = match.resourceUri.replace("ui://widgets/", "").replace("/view.html", "");
|
|
1029
|
+
const parts = resourcePath.split("/");
|
|
1030
|
+
hash = parts[1] ?? parts[0] ?? "";
|
|
1031
|
+
}
|
|
1032
|
+
if (!hash) {
|
|
1033
|
+
return {
|
|
1034
|
+
content: [
|
|
1035
|
+
{
|
|
1036
|
+
type: "text",
|
|
1037
|
+
text: `Could not determine hash for widget "${name}".`
|
|
1038
|
+
}
|
|
1039
|
+
],
|
|
1040
|
+
isError: true
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
const widget = await store.getWidget(name, hash);
|
|
1044
|
+
if (!widget) {
|
|
1045
|
+
return {
|
|
1046
|
+
content: [
|
|
1047
|
+
{
|
|
1048
|
+
type: "text",
|
|
1049
|
+
text: `Widget "${name}" with hash "${hash}" not found in the VFS store.`
|
|
1050
|
+
}
|
|
1051
|
+
],
|
|
1052
|
+
isError: true
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
const ref = { name, hash, entry: widget.entry };
|
|
1056
|
+
return {
|
|
1057
|
+
content: [
|
|
1058
|
+
renderResource(ref, inputs),
|
|
1059
|
+
{
|
|
1060
|
+
type: "text",
|
|
1061
|
+
text: `Rendered widget "${name}" (hash: ${hash}).
|
|
1062
|
+
Compiled in-browser at: ${directUrl(name, hash)}`
|
|
1063
|
+
}
|
|
1064
|
+
]
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
);
|
|
1068
|
+
registerLiveUpdateTools(server);
|
|
1069
|
+
return server;
|
|
1070
|
+
}
|
|
1071
|
+
function registerLiveUpdateTools(server) {
|
|
1072
|
+
server.registerTool(
|
|
1073
|
+
"subscribe_stream",
|
|
1074
|
+
{
|
|
1075
|
+
description: "Subscribe this widget session to a named data stream. The server will send `notifications/tools/list_changed` whenever new events arrive; the widget should then call `poll_updates` to fetch them. Returns the current sequence number so the widget knows where to start polling.",
|
|
1076
|
+
inputSchema: {
|
|
1077
|
+
stream: z2.string().describe("Name of the data stream to subscribe to."),
|
|
1078
|
+
session_id: z2.string().optional().describe(
|
|
1079
|
+
"MCP session ID. Widgets should pass the value returned in the Mcp-Session-Id response header during initialization."
|
|
1080
|
+
)
|
|
1081
|
+
}
|
|
1082
|
+
},
|
|
1083
|
+
(args, extra) => {
|
|
1084
|
+
const stream = args["stream"];
|
|
1085
|
+
const sessionId = args["session_id"] ?? extra["sessionId"];
|
|
1086
|
+
if (sessionId) {
|
|
1087
|
+
subscribeSession(sessionId, stream);
|
|
1088
|
+
}
|
|
1089
|
+
const seq = currentSeq();
|
|
1090
|
+
return {
|
|
1091
|
+
content: [
|
|
1092
|
+
{
|
|
1093
|
+
type: "text",
|
|
1094
|
+
text: JSON.stringify({ success: true, stream, seq })
|
|
1095
|
+
}
|
|
1096
|
+
]
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
);
|
|
1100
|
+
server.registerTool(
|
|
1101
|
+
"poll_updates",
|
|
1102
|
+
{
|
|
1103
|
+
description: "Fetch buffered events for a data stream that arrived after the given sequence number. Call this after receiving a `notifications/tools/list_changed` notification (which the server sends when new data is available). Pass the highest `seq` value from the last successful poll to avoid duplicates.",
|
|
1104
|
+
inputSchema: {
|
|
1105
|
+
stream: z2.string().describe("Name of the data stream to poll."),
|
|
1106
|
+
after_seq: z2.number().int().default(0).describe(
|
|
1107
|
+
"Return only events with seq > after_seq. Pass 0 to retrieve all buffered events."
|
|
1108
|
+
)
|
|
1109
|
+
}
|
|
1110
|
+
},
|
|
1111
|
+
(args) => {
|
|
1112
|
+
const stream = args["stream"];
|
|
1113
|
+
const afterSeq = args["after_seq"] ?? 0;
|
|
1114
|
+
const events = getEvents(stream, afterSeq);
|
|
1115
|
+
return {
|
|
1116
|
+
content: [
|
|
1117
|
+
{
|
|
1118
|
+
type: "text",
|
|
1119
|
+
text: JSON.stringify({ success: true, stream, events })
|
|
1120
|
+
}
|
|
1121
|
+
]
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
);
|
|
1125
|
+
server.registerTool(
|
|
1126
|
+
"push_update",
|
|
1127
|
+
{
|
|
1128
|
+
description: "Push a data update onto a named stream, broadcasting it to all subscribed widget sessions. Subscribing widgets will receive a `notifications/tools/list_changed` signal and then call `poll_updates` to retrieve the new data. Use this tool from server-side code or as an LLM tool to drive real-time widget updates.",
|
|
1129
|
+
inputSchema: {
|
|
1130
|
+
stream: z2.string().describe("Name of the data stream to push to."),
|
|
1131
|
+
data: z2.record(z2.unknown()).describe("Arbitrary JSON-serialisable payload to push to subscribers.")
|
|
1132
|
+
}
|
|
1133
|
+
},
|
|
1134
|
+
async (args) => {
|
|
1135
|
+
const stream = args["stream"];
|
|
1136
|
+
const data = args["data"];
|
|
1137
|
+
const seq = await pushStreamUpdate(stream, data);
|
|
1138
|
+
return {
|
|
1139
|
+
content: [
|
|
1140
|
+
{
|
|
1141
|
+
type: "text",
|
|
1142
|
+
text: JSON.stringify({ success: true, stream, seq })
|
|
1143
|
+
}
|
|
1144
|
+
]
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// src/tunnel.ts
|
|
1151
|
+
import { spawn } from "child_process";
|
|
1152
|
+
var tunnelProcess = null;
|
|
1153
|
+
var tunnelUrl = null;
|
|
1154
|
+
async function waitForTunnelLive(url, timeoutMs = 3e4) {
|
|
1155
|
+
const deadline = Date.now() + timeoutMs;
|
|
1156
|
+
while (Date.now() < deadline) {
|
|
1157
|
+
try {
|
|
1158
|
+
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5e3) });
|
|
1159
|
+
if (res.ok) return true;
|
|
1160
|
+
} catch {
|
|
1161
|
+
}
|
|
1162
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
1163
|
+
}
|
|
1164
|
+
return false;
|
|
1165
|
+
}
|
|
1166
|
+
async function startTunnel(port) {
|
|
1167
|
+
if (tunnelUrl) return tunnelUrl;
|
|
1168
|
+
const detectedUrl = await new Promise((resolve2, reject) => {
|
|
1169
|
+
const args = ["tunnel", "--url", `http://localhost:${port}`];
|
|
1170
|
+
log("tunnel", `Starting cloudflared tunnel for port ${port}...`);
|
|
1171
|
+
tunnelProcess = spawn("cloudflared", args, {
|
|
1172
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1173
|
+
});
|
|
1174
|
+
let resolved = false;
|
|
1175
|
+
const timeout = setTimeout(() => {
|
|
1176
|
+
if (!resolved) {
|
|
1177
|
+
resolved = true;
|
|
1178
|
+
reject(new Error("Tunnel startup timed out after 30s"));
|
|
1179
|
+
}
|
|
1180
|
+
}, 3e4);
|
|
1181
|
+
const handleOutput = (data) => {
|
|
1182
|
+
const text = data.toString();
|
|
1183
|
+
const urlMatch = text.match(/https:\/\/[^\s]+\.trycloudflare\.com\/?/);
|
|
1184
|
+
if (urlMatch && !resolved) {
|
|
1185
|
+
resolved = true;
|
|
1186
|
+
clearTimeout(timeout);
|
|
1187
|
+
resolve2(urlMatch[0].replace(/\/$/, ""));
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
tunnelProcess.stdout?.on("data", handleOutput);
|
|
1191
|
+
tunnelProcess.stderr?.on("data", handleOutput);
|
|
1192
|
+
tunnelProcess.on("error", (err) => {
|
|
1193
|
+
if (!resolved) {
|
|
1194
|
+
resolved = true;
|
|
1195
|
+
clearTimeout(timeout);
|
|
1196
|
+
error("tunnel", "Failed to start cloudflared:", err);
|
|
1197
|
+
reject(err);
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
tunnelProcess.on("exit", (code) => {
|
|
1201
|
+
if (!resolved) {
|
|
1202
|
+
resolved = true;
|
|
1203
|
+
clearTimeout(timeout);
|
|
1204
|
+
reject(new Error(`cloudflared exited with code ${code}`));
|
|
1205
|
+
}
|
|
1206
|
+
tunnelProcess = null;
|
|
1207
|
+
tunnelUrl = null;
|
|
1208
|
+
});
|
|
1209
|
+
});
|
|
1210
|
+
log("tunnel", `Tunnel hostname detected (${detectedUrl}); verifying reachability...`);
|
|
1211
|
+
const live = await waitForTunnelLive(detectedUrl);
|
|
1212
|
+
if (!live) {
|
|
1213
|
+
error(
|
|
1214
|
+
"tunnel",
|
|
1215
|
+
`Tunnel ${detectedUrl} did not become reachable within 30s \u2014 publishing anyway, but widgets may fail to load until the edge route propagates.`
|
|
1216
|
+
);
|
|
1217
|
+
} else {
|
|
1218
|
+
log("tunnel", `Tunnel verified reachable: ${detectedUrl}`);
|
|
1219
|
+
}
|
|
1220
|
+
tunnelUrl = detectedUrl;
|
|
1221
|
+
return tunnelUrl;
|
|
1222
|
+
}
|
|
1223
|
+
function stopTunnel() {
|
|
1224
|
+
if (tunnelProcess) {
|
|
1225
|
+
tunnelProcess.kill();
|
|
1226
|
+
tunnelProcess = null;
|
|
1227
|
+
tunnelUrl = null;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// src/server.ts
|
|
1232
|
+
var PORT = Number(process.env["PORT"] ?? 3e3);
|
|
1233
|
+
var WIDGET_PORT = Number(process.env["WIDGET_PORT"] ?? 3002);
|
|
1234
|
+
var HOST = process.env["HOST"] ?? "0.0.0.0";
|
|
1235
|
+
var TRANSPORT = process.env["TRANSPORT"] ?? (process.argv.includes("--stdio") ? "stdio" : "http");
|
|
1236
|
+
var WIDGET_TUNNEL = process.env["WIDGET_TUNNEL"] === "true" || process.argv.includes("--tunnel");
|
|
1237
|
+
var WIDGET_STATE_FILE = join3(tmpdir(), `patchwork-widget-${WIDGET_PORT}.json`);
|
|
1238
|
+
function readWidgetHostState() {
|
|
1239
|
+
try {
|
|
1240
|
+
return JSON.parse(readFileSync(WIDGET_STATE_FILE, "utf8"));
|
|
1241
|
+
} catch {
|
|
1242
|
+
return null;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
function publishWidgetHostState(baseUrl) {
|
|
1246
|
+
try {
|
|
1247
|
+
const state = { baseUrl, pid: process.pid, updatedAt: Date.now() };
|
|
1248
|
+
writeFileSync(WIDGET_STATE_FILE, JSON.stringify(state));
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
error("mcp-app-server", "Failed to publish widget host state:", err);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
function isProcessAlive(pid) {
|
|
1254
|
+
try {
|
|
1255
|
+
process.kill(pid, 0);
|
|
1256
|
+
return true;
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
return err.code === "EPERM";
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
async function awaitPublishedWidgetBaseUrl(timeoutMs = 35e3) {
|
|
1262
|
+
const deadline = Date.now() + timeoutMs;
|
|
1263
|
+
while (Date.now() < deadline) {
|
|
1264
|
+
const state = readWidgetHostState();
|
|
1265
|
+
if (state?.baseUrl && isProcessAlive(state.pid)) return state.baseUrl;
|
|
1266
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1267
|
+
}
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
function resolveDistDir(name) {
|
|
1271
|
+
const candidates = [
|
|
1272
|
+
fileURLToPath(new URL(`./${name}`, import.meta.url)),
|
|
1273
|
+
// built: dist/<name>
|
|
1274
|
+
fileURLToPath(new URL(`../dist/${name}`, import.meta.url))
|
|
1275
|
+
// dev via tsx: src → dist
|
|
1276
|
+
];
|
|
1277
|
+
return candidates.find((dir) => existsSync(dir)) ?? candidates[0];
|
|
1278
|
+
}
|
|
1279
|
+
function resolveRuntimeDir() {
|
|
1280
|
+
return resolveDistDir("runtime");
|
|
1281
|
+
}
|
|
1282
|
+
function resolveShellDir() {
|
|
1283
|
+
return resolveDistDir("shell");
|
|
1284
|
+
}
|
|
1285
|
+
async function setupRegistryBackend() {
|
|
1286
|
+
const REGISTRY_PROVIDERS = process.env["REGISTRY_PROVIDERS"];
|
|
1287
|
+
if (!REGISTRY_PROVIDERS) {
|
|
1288
|
+
return { registryBackend: null, serverOptions: {} };
|
|
1289
|
+
}
|
|
1290
|
+
const command = process.env["REGISTRY_COMMAND"] ?? "npx";
|
|
1291
|
+
const extraArgs = process.env["REGISTRY_ARGS"]?.split(" ").filter(Boolean) ?? [];
|
|
1292
|
+
const args = ["@utdk/mcp", ...extraArgs];
|
|
1293
|
+
log("mcp-app-server", `Connecting to Registry MCP server (providers: ${REGISTRY_PROVIDERS})...`);
|
|
1294
|
+
try {
|
|
1295
|
+
const registryBackend = await createRegistryBackend({
|
|
1296
|
+
command,
|
|
1297
|
+
args,
|
|
1298
|
+
providers: REGISTRY_PROVIDERS
|
|
1299
|
+
});
|
|
1300
|
+
const toolInfos = registryBackend.getToolInfos();
|
|
1301
|
+
const namespaces = [...new Set(toolInfos.map((t) => t.namespace))];
|
|
1302
|
+
log(
|
|
1303
|
+
"mcp-app-server",
|
|
1304
|
+
`Registry ready: ${toolInfos.length} tools across namespaces: ${namespaces.join(", ")}`
|
|
1305
|
+
);
|
|
1306
|
+
return {
|
|
1307
|
+
registryBackend,
|
|
1308
|
+
serverOptions: {
|
|
1309
|
+
services: {
|
|
1310
|
+
backend: registryBackend,
|
|
1311
|
+
tools: toolInfos
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
};
|
|
1315
|
+
} catch (err) {
|
|
1316
|
+
error("mcp-app-server", "Failed to connect to Registry MCP server:", err);
|
|
1317
|
+
error("mcp-app-server", "Starting without Registry service backend.");
|
|
1318
|
+
return { registryBackend: null, serverOptions: {} };
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
function handleExistingSession(existing, req, res) {
|
|
1322
|
+
try {
|
|
1323
|
+
existing.transport.handleRequest(req, res, req.body);
|
|
1324
|
+
} catch (err) {
|
|
1325
|
+
error("mcp", "session request error", err);
|
|
1326
|
+
if (!res.headersSent) {
|
|
1327
|
+
res.status(500).json({ error: "Internal server error" });
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
async function createNewSession(serverOptions, sessionStore, req, res) {
|
|
1332
|
+
const mcpServer = createMcpAppServer(serverOptions);
|
|
1333
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1334
|
+
sessionIdGenerator: () => randomUUID(),
|
|
1335
|
+
onsessioninitialized: (id) => {
|
|
1336
|
+
sessionStore.set(id, { server: mcpServer, transport });
|
|
1337
|
+
registerSession(id, mcpServer);
|
|
1338
|
+
},
|
|
1339
|
+
onsessionclosed: (id) => {
|
|
1340
|
+
sessionStore.delete(id);
|
|
1341
|
+
unregisterSession(id);
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
res.on("close", () => {
|
|
1345
|
+
const id = transport.sessionId;
|
|
1346
|
+
if (id && !sessionStore.has(id)) {
|
|
1347
|
+
void mcpServer.close();
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
try {
|
|
1351
|
+
await mcpServer.connect(transport);
|
|
1352
|
+
await transport.handleRequest(req, res, req.body);
|
|
1353
|
+
} catch (err) {
|
|
1354
|
+
error("mcp", "new session error", err);
|
|
1355
|
+
if (!res.headersSent) {
|
|
1356
|
+
res.status(500).json({ error: "Internal server error" });
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
function registerMcpEndpoint(app, serverOptions) {
|
|
1361
|
+
const sessionStore = /* @__PURE__ */ new Map();
|
|
1362
|
+
app.all("/mcp", async (req, res) => {
|
|
1363
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
1364
|
+
if (sessionId) {
|
|
1365
|
+
const existing = sessionStore.get(sessionId);
|
|
1366
|
+
if (existing) {
|
|
1367
|
+
handleExistingSession(existing, req, res);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
await createNewSession(serverOptions, sessionStore, req, res);
|
|
1372
|
+
});
|
|
1373
|
+
return sessionStore;
|
|
1374
|
+
}
|
|
1375
|
+
function registerShutdownHandlers(registryBackend) {
|
|
1376
|
+
const shutdown = () => {
|
|
1377
|
+
if (registryBackend) {
|
|
1378
|
+
registryBackend.close().catch(() => {
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
process.exit(0);
|
|
1382
|
+
};
|
|
1383
|
+
process.on("SIGTERM", shutdown);
|
|
1384
|
+
process.on("SIGINT", shutdown);
|
|
1385
|
+
}
|
|
1386
|
+
async function startServer() {
|
|
1387
|
+
const { registryBackend, serverOptions } = await setupRegistryBackend();
|
|
1388
|
+
const app = createMcpExpressApp({ host: HOST });
|
|
1389
|
+
app.use(cors());
|
|
1390
|
+
registerMcpEndpoint(app, serverOptions);
|
|
1391
|
+
app.get("/health", (_req, res) => {
|
|
1392
|
+
res.json({ status: "ok", service: "patchwork-mcp-app-server" });
|
|
1393
|
+
});
|
|
1394
|
+
app.listen(PORT, HOST, () => {
|
|
1395
|
+
log("mcp-app-server", `MCP App Server listening on http://${HOST}:${PORT}`);
|
|
1396
|
+
log("mcp-app-server", ` POST /mcp \u2014 MCP Streamable HTTP endpoint (stateful sessions)`);
|
|
1397
|
+
log("mcp-app-server", ` GET /health \u2014 health check`);
|
|
1398
|
+
log("mcp-app-server", "");
|
|
1399
|
+
log("mcp-app-server", "To expose locally via cloudflared:");
|
|
1400
|
+
log("mcp-app-server", ` cloudflared tunnel --url http://localhost:${PORT}`);
|
|
1401
|
+
});
|
|
1402
|
+
registerShutdownHandlers(registryBackend);
|
|
1403
|
+
}
|
|
1404
|
+
async function startWidgetServer() {
|
|
1405
|
+
const widgetApp = express();
|
|
1406
|
+
widgetApp.use(cors());
|
|
1407
|
+
const store = getWidgetStore();
|
|
1408
|
+
widgetApp.use("/shell", express.static(resolveShellDir()));
|
|
1409
|
+
widgetApp.use("/runtime", express.static(resolveRuntimeDir()));
|
|
1410
|
+
widgetApp.get("/widget/:name/:hash/files", async (req, res) => {
|
|
1411
|
+
const { name, hash } = req.params;
|
|
1412
|
+
try {
|
|
1413
|
+
const widget = await store.getWidget(name, hash);
|
|
1414
|
+
if (!widget) {
|
|
1415
|
+
res.status(404).json({ error: "Widget not found" });
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
res.json({ files: widget.files, entry: widget.entry, manifest: widget.manifest });
|
|
1419
|
+
} catch (err) {
|
|
1420
|
+
error("widget-server", "Error serving widget files:", err);
|
|
1421
|
+
res.status(500).json({ error: "Internal server error" });
|
|
1422
|
+
}
|
|
1423
|
+
});
|
|
1424
|
+
widgetApp.get("/widget/:name/:hash", (req, res) => {
|
|
1425
|
+
const { name, hash } = req.params;
|
|
1426
|
+
res.redirect(
|
|
1427
|
+
302,
|
|
1428
|
+
`/runtime/?widget=${encodeURIComponent(name)}/${encodeURIComponent(hash)}`
|
|
1429
|
+
);
|
|
1430
|
+
});
|
|
1431
|
+
widgetApp.get("/health", (_req, res) => {
|
|
1432
|
+
res.json({ status: "ok", service: "patchwork-widget-server" });
|
|
1433
|
+
});
|
|
1434
|
+
const isOwner = await new Promise((resolve2) => {
|
|
1435
|
+
const httpServer = widgetApp.listen(WIDGET_PORT, HOST, () => {
|
|
1436
|
+
log("mcp-app-server", `Widget server listening on http://${HOST}:${WIDGET_PORT}`);
|
|
1437
|
+
resolve2(true);
|
|
1438
|
+
});
|
|
1439
|
+
httpServer.on("error", (err) => {
|
|
1440
|
+
if (err.code === "EADDRINUSE") {
|
|
1441
|
+
log(
|
|
1442
|
+
"mcp-app-server",
|
|
1443
|
+
`Widget port ${WIDGET_PORT} already owned by another instance; reusing its widget host.`
|
|
1444
|
+
);
|
|
1445
|
+
} else {
|
|
1446
|
+
error("mcp-app-server", "Widget server failed to bind:", err);
|
|
1447
|
+
}
|
|
1448
|
+
resolve2(false);
|
|
1449
|
+
});
|
|
1450
|
+
});
|
|
1451
|
+
if (!isOwner) {
|
|
1452
|
+
const shared = await awaitPublishedWidgetBaseUrl();
|
|
1453
|
+
if (shared) {
|
|
1454
|
+
log("mcp-app-server", `Reusing shared widget host: ${shared}`);
|
|
1455
|
+
return shared;
|
|
1456
|
+
}
|
|
1457
|
+
error(
|
|
1458
|
+
"mcp-app-server",
|
|
1459
|
+
"Owner instance never published a base URL; falling back to localhost (widgets may not load in remote hosts)."
|
|
1460
|
+
);
|
|
1461
|
+
return `http://localhost:${WIDGET_PORT}`;
|
|
1462
|
+
}
|
|
1463
|
+
let widgetBaseUrl = `http://localhost:${WIDGET_PORT}`;
|
|
1464
|
+
if (WIDGET_TUNNEL) {
|
|
1465
|
+
try {
|
|
1466
|
+
widgetBaseUrl = await startTunnel(WIDGET_PORT);
|
|
1467
|
+
log("mcp-app-server", `Widgets accessible at: ${widgetBaseUrl}`);
|
|
1468
|
+
} catch (err) {
|
|
1469
|
+
error("mcp-app-server", "Failed to start tunnel, using localhost:", err);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
publishWidgetHostState(widgetBaseUrl);
|
|
1473
|
+
return widgetBaseUrl;
|
|
1474
|
+
}
|
|
1475
|
+
async function startStdioServer() {
|
|
1476
|
+
const { registryBackend, serverOptions } = await setupRegistryBackend();
|
|
1477
|
+
log("mcp-app-server", "Starting MCP App Server in stdio mode...");
|
|
1478
|
+
const widgetBaseUrl = await startWidgetServer();
|
|
1479
|
+
const mcpServer = createMcpAppServer({
|
|
1480
|
+
...serverOptions,
|
|
1481
|
+
widgetBaseUrl
|
|
1482
|
+
});
|
|
1483
|
+
const transport = new StdioServerTransport();
|
|
1484
|
+
await mcpServer.connect(transport);
|
|
1485
|
+
const shutdown = () => {
|
|
1486
|
+
stopTunnel();
|
|
1487
|
+
if (registryBackend) {
|
|
1488
|
+
registryBackend.close().catch(() => {
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
process.exit(0);
|
|
1492
|
+
};
|
|
1493
|
+
process.on("SIGTERM", shutdown);
|
|
1494
|
+
process.on("SIGINT", shutdown);
|
|
1495
|
+
}
|
|
1496
|
+
async function main() {
|
|
1497
|
+
if (TRANSPORT === "stdio") {
|
|
1498
|
+
await startStdioServer();
|
|
1499
|
+
} else {
|
|
1500
|
+
await startServer();
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
main().catch((err) => {
|
|
1504
|
+
error("mcp-app-server", "Fatal startup error:", err);
|
|
1505
|
+
process.exit(1);
|
|
1506
|
+
});
|
|
1507
|
+
export {
|
|
1508
|
+
resolveRuntimeDir,
|
|
1509
|
+
resolveShellDir
|
|
1510
|
+
};
|
|
1511
|
+
//# sourceMappingURL=server.js.map
|