@aprovan/patchwork-mcp 0.1.0-dev.030eb3d
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/LICENSE +373 -0
- package/README.md +15 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +989 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/dist/runtime/assets/index-CSnUKUMa.js +1058 -0
- package/dist/runtime/index.html +38 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +1400 -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 +55 -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__/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 +47 -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 +399 -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/src/server.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
9
|
+
import cors from "cors";
|
|
10
|
+
import express from "express";
|
|
11
|
+
import { registerSession, unregisterSession } from "./live-update.js";
|
|
12
|
+
import { log, error } from "./logger.js";
|
|
13
|
+
import { createRegistryBackend, type RegistryBackend } from "./registry-backend.js";
|
|
14
|
+
import { startTunnel, stopTunnel } from "./tunnel.js";
|
|
15
|
+
import { getWidgetStore } from "./widget-store/index.js";
|
|
16
|
+
import { createMcpAppServer, type McpAppServerOptions } from "./index.js";
|
|
17
|
+
import type { Request, Response } from "express";
|
|
18
|
+
|
|
19
|
+
const PORT = Number(process.env["PORT"] ?? 3000);
|
|
20
|
+
const WIDGET_PORT = Number(process.env["WIDGET_PORT"] ?? 3002);
|
|
21
|
+
const HOST = process.env["HOST"] ?? "0.0.0.0";
|
|
22
|
+
const TRANSPORT = process.env["TRANSPORT"] ?? (process.argv.includes("--stdio") ? "stdio" : "http");
|
|
23
|
+
const WIDGET_TUNNEL = process.env["WIDGET_TUNNEL"] === "true" || process.argv.includes("--tunnel");
|
|
24
|
+
|
|
25
|
+
type SessionEntry = {
|
|
26
|
+
server: ReturnType<typeof createMcpAppServer>;
|
|
27
|
+
transport: StreamableHTTPServerTransport;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Cross-process coordination for the shared widget host.
|
|
32
|
+
*
|
|
33
|
+
* Every stdio spawn of this server (Claude Desktop respawns it freely) would
|
|
34
|
+
* otherwise start its own widget server and its own cloudflared quick tunnel.
|
|
35
|
+
* Only one can bind {@link WIDGET_PORT}; the rest orphan extra tunnels and, worse,
|
|
36
|
+
* each render returns a *different* hostname — and any instance whose ephemeral
|
|
37
|
+
* tunnel has dropped serves a dead URL (Cloudflare 1033) into the widget HTML.
|
|
38
|
+
*
|
|
39
|
+
* We elect a single owner via the port bind: whoever binds the port establishes
|
|
40
|
+
* (and verifies) the tunnel and publishes its base URL to a temp file keyed by
|
|
41
|
+
* port; every other instance reuses that URL instead of starting a tunnel.
|
|
42
|
+
*/
|
|
43
|
+
interface WidgetHostState {
|
|
44
|
+
baseUrl: string;
|
|
45
|
+
pid: number;
|
|
46
|
+
updatedAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const WIDGET_STATE_FILE = join(tmpdir(), `patchwork-widget-${WIDGET_PORT}.json`);
|
|
50
|
+
|
|
51
|
+
function readWidgetHostState(): WidgetHostState | null {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(WIDGET_STATE_FILE, "utf8")) as WidgetHostState;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function publishWidgetHostState(baseUrl: string): void {
|
|
60
|
+
try {
|
|
61
|
+
const state: WidgetHostState = { baseUrl, pid: process.pid, updatedAt: Date.now() };
|
|
62
|
+
writeFileSync(WIDGET_STATE_FILE, JSON.stringify(state));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
error("mcp-app-server", "Failed to publish widget host state:", err);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isProcessAlive(pid: number): boolean {
|
|
69
|
+
try {
|
|
70
|
+
process.kill(pid, 0);
|
|
71
|
+
return true;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// ESRCH = no such process; EPERM = exists but not ours (still alive).
|
|
74
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Wait for the owning instance to publish its (verified) base URL. Only adopt
|
|
80
|
+
* state whose owning pid is still alive, so a non-owner never reuses a dead
|
|
81
|
+
* previous owner's (now-1033) hostname.
|
|
82
|
+
*/
|
|
83
|
+
async function awaitPublishedWidgetBaseUrl(timeoutMs = 35000): Promise<string | null> {
|
|
84
|
+
const deadline = Date.now() + timeoutMs;
|
|
85
|
+
while (Date.now() < deadline) {
|
|
86
|
+
const state = readWidgetHostState();
|
|
87
|
+
if (state?.baseUrl && isProcessAlive(state.pid)) return state.baseUrl;
|
|
88
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Locate a built browser bundle dir (dist/<name>) from either dist or src. */
|
|
94
|
+
function resolveDistDir(name: string): string {
|
|
95
|
+
const candidates = [
|
|
96
|
+
fileURLToPath(new URL(`./${name}`, import.meta.url)), // built: dist/<name>
|
|
97
|
+
fileURLToPath(new URL(`../dist/${name}`, import.meta.url)), // dev via tsx: src → dist
|
|
98
|
+
];
|
|
99
|
+
return candidates.find((dir) => existsSync(dir)) ?? candidates[0]!;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Locate the built runtime bundle (dist/runtime). */
|
|
103
|
+
export function resolveRuntimeDir(): string {
|
|
104
|
+
return resolveDistDir("runtime");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Locate the built MCP App shell bundle (dist/shell). */
|
|
108
|
+
export function resolveShellDir(): string {
|
|
109
|
+
return resolveDistDir("shell");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function setupRegistryBackend(): Promise<{
|
|
113
|
+
registryBackend: RegistryBackend | null;
|
|
114
|
+
serverOptions: McpAppServerOptions;
|
|
115
|
+
}> {
|
|
116
|
+
const toolboxUrl = process.env["TOOLBOX_MCP_URL"];
|
|
117
|
+
|
|
118
|
+
if (!toolboxUrl) {
|
|
119
|
+
return { registryBackend: null, serverOptions: {} };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
log("patchwork-mcp", `Connecting to toolbox at ${toolboxUrl}`);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const registryBackend = await createRegistryBackend({
|
|
126
|
+
name: "toolbox",
|
|
127
|
+
url: toolboxUrl,
|
|
128
|
+
headers: process.env["APROVAN_WORKSPACE_ID"]
|
|
129
|
+
? { "X-Aprovan-Workspace": process.env["APROVAN_WORKSPACE_ID"] }
|
|
130
|
+
: undefined,
|
|
131
|
+
auth: process.env["TOOLBOX_TOKEN"]
|
|
132
|
+
? { type: "bearer", token: process.env["TOOLBOX_TOKEN"] }
|
|
133
|
+
: { type: "none" },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const toolInfos = registryBackend.getToolInfos();
|
|
137
|
+
const namespaces = [...new Set(toolInfos.map((t) => t.namespace))];
|
|
138
|
+
log(
|
|
139
|
+
"patchwork-mcp",
|
|
140
|
+
`Registry ready: ${toolInfos.length} tools across namespaces: ${namespaces.join(", ")}`
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
registryBackend,
|
|
145
|
+
serverOptions: {
|
|
146
|
+
services: {
|
|
147
|
+
backend: registryBackend,
|
|
148
|
+
tools: toolInfos,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
} catch (err) {
|
|
153
|
+
error("patchwork-mcp", "Failed to connect to toolbox:", err);
|
|
154
|
+
error("patchwork-mcp", "Starting without toolbox services.");
|
|
155
|
+
return { registryBackend: null, serverOptions: {} };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function handleExistingSession(existing: SessionEntry, req: Request, res: Response): void {
|
|
160
|
+
try {
|
|
161
|
+
existing.transport.handleRequest(req, res, req.body);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
error("mcp", "session request error", err);
|
|
164
|
+
if (!res.headersSent) {
|
|
165
|
+
res.status(500).json({ error: "Internal server error" });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function createNewSession(
|
|
171
|
+
serverOptions: McpAppServerOptions,
|
|
172
|
+
sessionStore: Map<string, SessionEntry>,
|
|
173
|
+
req: Request,
|
|
174
|
+
res: Response
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
const mcpServer = createMcpAppServer(serverOptions);
|
|
177
|
+
const transport = new StreamableHTTPServerTransport({
|
|
178
|
+
sessionIdGenerator: () => randomUUID(),
|
|
179
|
+
onsessioninitialized: (id) => {
|
|
180
|
+
sessionStore.set(id, { server: mcpServer, transport });
|
|
181
|
+
registerSession(id, mcpServer);
|
|
182
|
+
},
|
|
183
|
+
onsessionclosed: (id) => {
|
|
184
|
+
sessionStore.delete(id);
|
|
185
|
+
unregisterSession(id);
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
res.on("close", () => {
|
|
190
|
+
const id = transport.sessionId;
|
|
191
|
+
if (id && !sessionStore.has(id)) {
|
|
192
|
+
void mcpServer.close();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await mcpServer.connect(transport);
|
|
198
|
+
await transport.handleRequest(req, res, req.body);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
error("mcp", "new session error", err);
|
|
201
|
+
if (!res.headersSent) {
|
|
202
|
+
res.status(500).json({ error: "Internal server error" });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function registerMcpEndpoint(
|
|
208
|
+
app: ReturnType<typeof createMcpExpressApp>,
|
|
209
|
+
serverOptions: McpAppServerOptions
|
|
210
|
+
): Map<string, SessionEntry> {
|
|
211
|
+
const sessionStore = new Map<string, SessionEntry>();
|
|
212
|
+
|
|
213
|
+
app.all("/mcp", async (req, res) => {
|
|
214
|
+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
|
215
|
+
|
|
216
|
+
if (sessionId) {
|
|
217
|
+
const existing = sessionStore.get(sessionId);
|
|
218
|
+
if (existing) {
|
|
219
|
+
handleExistingSession(existing, req, res);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await createNewSession(serverOptions, sessionStore, req, res);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return sessionStore;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function registerShutdownHandlers(registryBackend: RegistryBackend | null): void {
|
|
231
|
+
const shutdown = () => {
|
|
232
|
+
if (registryBackend) {
|
|
233
|
+
registryBackend.close().catch(() => {
|
|
234
|
+
/* ignore close errors on exit */
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
process.exit(0);
|
|
238
|
+
};
|
|
239
|
+
process.on("SIGTERM", shutdown);
|
|
240
|
+
process.on("SIGINT", shutdown);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function startServer(): Promise<void> {
|
|
244
|
+
const { registryBackend, serverOptions } = await setupRegistryBackend();
|
|
245
|
+
|
|
246
|
+
const app = createMcpExpressApp({ host: HOST });
|
|
247
|
+
app.use(cors());
|
|
248
|
+
|
|
249
|
+
registerMcpEndpoint(app, serverOptions);
|
|
250
|
+
|
|
251
|
+
app.get("/health", (_req, res) => {
|
|
252
|
+
res.json({ status: "ok", service: "patchwork-mcp-app-server" });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
app.listen(PORT, HOST, () => {
|
|
256
|
+
log("mcp-app-server", `MCP App Server listening on http://${HOST}:${PORT}`);
|
|
257
|
+
log("mcp-app-server", ` POST /mcp — MCP Streamable HTTP endpoint (stateful sessions)`);
|
|
258
|
+
log("mcp-app-server", ` GET /health — health check`);
|
|
259
|
+
log("mcp-app-server", "");
|
|
260
|
+
log("mcp-app-server", "To expose locally via cloudflared:");
|
|
261
|
+
log("mcp-app-server", ` cloudflared tunnel --url http://localhost:${PORT}`);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
registerShutdownHandlers(registryBackend);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function startWidgetServer(): Promise<string> {
|
|
268
|
+
const widgetApp = express();
|
|
269
|
+
widgetApp.use(cors());
|
|
270
|
+
|
|
271
|
+
const store = getWidgetStore();
|
|
272
|
+
|
|
273
|
+
// Serve the MCP App shell (resource document's external script) and the shared
|
|
274
|
+
// browser runtime bundle (compiles widgets in-browser). Both resolve to dist
|
|
275
|
+
// whether running built (dist/server.js) or via tsx (src).
|
|
276
|
+
widgetApp.use("/shell", express.static(resolveShellDir()));
|
|
277
|
+
widgetApp.use("/runtime", express.static(resolveRuntimeDir()));
|
|
278
|
+
|
|
279
|
+
// Raw widget source files — fetched by the runtime and compiled in-browser.
|
|
280
|
+
widgetApp.get("/widget/:name/:hash/files", async (req, res) => {
|
|
281
|
+
const { name, hash } = req.params;
|
|
282
|
+
try {
|
|
283
|
+
const widget = await store.getWidget(name, hash);
|
|
284
|
+
if (!widget) {
|
|
285
|
+
res.status(404).json({ error: "Widget not found" });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
res.json({ files: widget.files, entry: widget.entry, manifest: widget.manifest });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
error("widget-server", "Error serving widget files:", err);
|
|
291
|
+
res.status(500).json({ error: "Internal server error" });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Convenience redirect: /widget/:name/:hash → runtime host with the widget preselected.
|
|
296
|
+
widgetApp.get("/widget/:name/:hash", (req, res) => {
|
|
297
|
+
const { name, hash } = req.params;
|
|
298
|
+
res.redirect(
|
|
299
|
+
302,
|
|
300
|
+
`/runtime/?widget=${encodeURIComponent(name)}/${encodeURIComponent(hash)}`,
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
widgetApp.get("/health", (_req, res) => {
|
|
305
|
+
res.json({ status: "ok", service: "patchwork-widget-server" });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Elect the widget-host owner via the port bind. A failed bind (EADDRINUSE)
|
|
309
|
+
// means another instance already owns the widget server + tunnel.
|
|
310
|
+
const isOwner = await new Promise<boolean>((resolve) => {
|
|
311
|
+
const httpServer = widgetApp.listen(WIDGET_PORT, HOST, () => {
|
|
312
|
+
log("mcp-app-server", `Widget server listening on http://${HOST}:${WIDGET_PORT}`);
|
|
313
|
+
resolve(true);
|
|
314
|
+
});
|
|
315
|
+
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
|
316
|
+
if (err.code === "EADDRINUSE") {
|
|
317
|
+
log(
|
|
318
|
+
"mcp-app-server",
|
|
319
|
+
`Widget port ${WIDGET_PORT} already owned by another instance; reusing its widget host.`,
|
|
320
|
+
);
|
|
321
|
+
} else {
|
|
322
|
+
error("mcp-app-server", "Widget server failed to bind:", err);
|
|
323
|
+
}
|
|
324
|
+
resolve(false);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
if (!isOwner) {
|
|
329
|
+
// Reuse the owner's verified, published base URL so every instance hands out
|
|
330
|
+
// the same live hostname instead of orphaning another (possibly dead) tunnel.
|
|
331
|
+
const shared = await awaitPublishedWidgetBaseUrl();
|
|
332
|
+
if (shared) {
|
|
333
|
+
log("mcp-app-server", `Reusing shared widget host: ${shared}`);
|
|
334
|
+
return shared;
|
|
335
|
+
}
|
|
336
|
+
error(
|
|
337
|
+
"mcp-app-server",
|
|
338
|
+
"Owner instance never published a base URL; falling back to localhost (widgets may not load in remote hosts).",
|
|
339
|
+
);
|
|
340
|
+
return `http://localhost:${WIDGET_PORT}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// We own the widget server. Establish (and verify) the tunnel, then publish
|
|
344
|
+
// the base URL for sibling instances.
|
|
345
|
+
let widgetBaseUrl = `http://localhost:${WIDGET_PORT}`;
|
|
346
|
+
|
|
347
|
+
if (WIDGET_TUNNEL) {
|
|
348
|
+
try {
|
|
349
|
+
widgetBaseUrl = await startTunnel(WIDGET_PORT);
|
|
350
|
+
log("mcp-app-server", `Widgets accessible at: ${widgetBaseUrl}`);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
error("mcp-app-server", "Failed to start tunnel, using localhost:", err);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
publishWidgetHostState(widgetBaseUrl);
|
|
357
|
+
return widgetBaseUrl;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function startStdioServer(): Promise<void> {
|
|
361
|
+
const { registryBackend, serverOptions } = await setupRegistryBackend();
|
|
362
|
+
|
|
363
|
+
log("mcp-app-server", "Starting MCP App Server in stdio mode...");
|
|
364
|
+
|
|
365
|
+
// Start widget server and optionally tunnel
|
|
366
|
+
const widgetBaseUrl = await startWidgetServer();
|
|
367
|
+
|
|
368
|
+
// Create MCP server with widget base URL
|
|
369
|
+
const mcpServer = createMcpAppServer({
|
|
370
|
+
...serverOptions,
|
|
371
|
+
widgetBaseUrl,
|
|
372
|
+
});
|
|
373
|
+
const transport = new StdioServerTransport();
|
|
374
|
+
|
|
375
|
+
await mcpServer.connect(transport);
|
|
376
|
+
|
|
377
|
+
const shutdown = () => {
|
|
378
|
+
stopTunnel();
|
|
379
|
+
if (registryBackend) {
|
|
380
|
+
registryBackend.close().catch(() => {});
|
|
381
|
+
}
|
|
382
|
+
process.exit(0);
|
|
383
|
+
};
|
|
384
|
+
process.on("SIGTERM", shutdown);
|
|
385
|
+
process.on("SIGINT", shutdown);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function main(): Promise<void> {
|
|
389
|
+
if (TRANSPORT === "stdio") {
|
|
390
|
+
await startStdioServer();
|
|
391
|
+
} else {
|
|
392
|
+
await startServer();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
main().catch((err: unknown) => {
|
|
397
|
+
error("mcp-app-server", "Fatal startup error:", err);
|
|
398
|
+
process.exit(1);
|
|
399
|
+
});
|
package/src/services.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
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(schema?: Record<string, unknown>): Record<string, z.ZodTypeAny> {
|
|
28
|
+
const properties = (schema?.["properties"] ?? {}) as Record<
|
|
29
|
+
string,
|
|
30
|
+
{ type?: string; description?: string }
|
|
31
|
+
>;
|
|
32
|
+
const required = new Set((schema?.["required"] ?? []) as string[]);
|
|
33
|
+
|
|
34
|
+
const shape: Record<string, z.ZodTypeAny> = {};
|
|
35
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
36
|
+
let field: z.ZodTypeAny;
|
|
37
|
+
switch (prop.type) {
|
|
38
|
+
case "number":
|
|
39
|
+
case "integer":
|
|
40
|
+
field = z.number();
|
|
41
|
+
break;
|
|
42
|
+
case "boolean":
|
|
43
|
+
field = z.boolean();
|
|
44
|
+
break;
|
|
45
|
+
case "array":
|
|
46
|
+
field = z.array(z.unknown());
|
|
47
|
+
break;
|
|
48
|
+
case "object":
|
|
49
|
+
field = z.record(z.unknown());
|
|
50
|
+
break;
|
|
51
|
+
default:
|
|
52
|
+
field = z.string();
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
if (prop.description) {
|
|
56
|
+
field = field.describe(prop.description);
|
|
57
|
+
}
|
|
58
|
+
if (!required.has(key)) {
|
|
59
|
+
field = field.optional();
|
|
60
|
+
}
|
|
61
|
+
shape[key] = field;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return shape;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class ServiceBridge {
|
|
68
|
+
private backend: ServiceBackend;
|
|
69
|
+
private tools: Map<string, ServiceToolInfo> = new Map();
|
|
70
|
+
|
|
71
|
+
constructor(config: ServiceBridgeConfig) {
|
|
72
|
+
this.backend = config.backend;
|
|
73
|
+
for (const tool of config.tools) {
|
|
74
|
+
this.tools.set(tool.name, tool);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
registerTools(server: McpServer): void {
|
|
79
|
+
for (const [, info] of this.tools) {
|
|
80
|
+
const mcpToolName = toMcpToolName(info.namespace, info.procedure);
|
|
81
|
+
const inputShape = jsonSchemaToZodShape(info.parameters);
|
|
82
|
+
|
|
83
|
+
server.registerTool(
|
|
84
|
+
mcpToolName,
|
|
85
|
+
{
|
|
86
|
+
description: info.description ?? `Call ${info.namespace}.${info.procedure}`,
|
|
87
|
+
inputSchema: inputShape,
|
|
88
|
+
},
|
|
89
|
+
async (args) => {
|
|
90
|
+
try {
|
|
91
|
+
const result = await this.backend.call(info.namespace, info.procedure, [args ?? {}]);
|
|
92
|
+
return {
|
|
93
|
+
content: [
|
|
94
|
+
{
|
|
95
|
+
type: "text" as const,
|
|
96
|
+
text: typeof result === "string" ? result : JSON.stringify(result),
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
102
|
+
return {
|
|
103
|
+
content: [
|
|
104
|
+
{
|
|
105
|
+
type: "text" as const,
|
|
106
|
+
text: `Service call failed: ${message}`,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
isError: true,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
registerSearchServices(server: McpServer): void {
|
|
118
|
+
server.registerTool(
|
|
119
|
+
"search_services",
|
|
120
|
+
{
|
|
121
|
+
description:
|
|
122
|
+
"Search for available service tools that widgets can call. " +
|
|
123
|
+
"Returns matching services with their namespaces, procedures, and parameter schemas.",
|
|
124
|
+
inputSchema: {
|
|
125
|
+
query: z
|
|
126
|
+
.string()
|
|
127
|
+
.optional()
|
|
128
|
+
.describe(
|
|
129
|
+
'Natural language description of what you want to do (e.g., "get weather forecast")'
|
|
130
|
+
),
|
|
131
|
+
namespace: z
|
|
132
|
+
.string()
|
|
133
|
+
.optional()
|
|
134
|
+
.describe('Filter results to a specific service namespace (e.g., "weather")'),
|
|
135
|
+
tool_name: z
|
|
136
|
+
.string()
|
|
137
|
+
.optional()
|
|
138
|
+
.describe("Get detailed info about a specific tool by name"),
|
|
139
|
+
limit: z.number().optional().describe("Maximum number of results to return"),
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
async (args) => {
|
|
143
|
+
const query = args?.["query"] as string | undefined;
|
|
144
|
+
const namespace = args?.["namespace"] as string | undefined;
|
|
145
|
+
const toolName = args?.["tool_name"] as string | undefined;
|
|
146
|
+
const limit = (args?.["limit"] as number) ?? 10;
|
|
147
|
+
|
|
148
|
+
if (toolName) {
|
|
149
|
+
const dotName = toolName.replace(/__/g, ".");
|
|
150
|
+
const info = this.tools.get(toolName) ?? this.tools.get(dotName);
|
|
151
|
+
if (!info) {
|
|
152
|
+
return {
|
|
153
|
+
content: [
|
|
154
|
+
{
|
|
155
|
+
type: "text" as const,
|
|
156
|
+
text: JSON.stringify({
|
|
157
|
+
success: false,
|
|
158
|
+
error: `Tool '${toolName}' not found`,
|
|
159
|
+
}),
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
content: [
|
|
166
|
+
{
|
|
167
|
+
type: "text" as const,
|
|
168
|
+
text: JSON.stringify({ success: true, tool: info }),
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let results = Array.from(this.tools.values());
|
|
175
|
+
|
|
176
|
+
if (namespace) {
|
|
177
|
+
results = results.filter((info) => info.namespace === namespace);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (query) {
|
|
181
|
+
const queryLower = query.toLowerCase();
|
|
182
|
+
const keywords = queryLower.split(/\s+/).filter(Boolean);
|
|
183
|
+
results = results
|
|
184
|
+
.map((info) => {
|
|
185
|
+
const searchText =
|
|
186
|
+
`${info.name} ${info.namespace} ${info.procedure} ${info.description ?? ""}`.toLowerCase();
|
|
187
|
+
const matchCount = keywords.filter((kw) => searchText.includes(kw)).length;
|
|
188
|
+
return { info, score: matchCount / keywords.length };
|
|
189
|
+
})
|
|
190
|
+
.filter(({ score }) => score > 0)
|
|
191
|
+
.sort((a, b) => b.score - a.score)
|
|
192
|
+
.map(({ info }) => info);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
results = results.slice(0, limit);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
content: [
|
|
199
|
+
{
|
|
200
|
+
type: "text" as const,
|
|
201
|
+
text: JSON.stringify({
|
|
202
|
+
success: true,
|
|
203
|
+
count: results.length,
|
|
204
|
+
tools: results,
|
|
205
|
+
namespaces: this.getNamespaces(),
|
|
206
|
+
}),
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// call_service: Execute any service by namespace/procedure
|
|
214
|
+
server.registerTool(
|
|
215
|
+
"call_service",
|
|
216
|
+
{
|
|
217
|
+
description:
|
|
218
|
+
"Call a service tool by namespace and procedure. Use search_services to discover available tools first.",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
namespace: z.string().describe('Service namespace (e.g., "github", "stripe")'),
|
|
221
|
+
procedure: z.string().describe('Procedure name (e.g., "repos_list", "customers_create")'),
|
|
222
|
+
args: z.record(z.unknown()).optional().describe("Arguments to pass to the procedure"),
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
async (params) => {
|
|
226
|
+
const namespace = params?.["namespace"] as string;
|
|
227
|
+
const procedure = params?.["procedure"] as string;
|
|
228
|
+
const args = (params?.["args"] ?? {}) as Record<string, unknown>;
|
|
229
|
+
|
|
230
|
+
if (!namespace || !procedure) {
|
|
231
|
+
return {
|
|
232
|
+
content: [
|
|
233
|
+
{
|
|
234
|
+
type: "text" as const,
|
|
235
|
+
text: JSON.stringify({
|
|
236
|
+
success: false,
|
|
237
|
+
error: "namespace and procedure are required",
|
|
238
|
+
}),
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
isError: true,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Look up tool info for validation
|
|
246
|
+
const toolKey = `${namespace}.${procedure}`;
|
|
247
|
+
const info = this.tools.get(toolKey);
|
|
248
|
+
if (!info) {
|
|
249
|
+
return {
|
|
250
|
+
content: [
|
|
251
|
+
{
|
|
252
|
+
type: "text" as const,
|
|
253
|
+
text: JSON.stringify({
|
|
254
|
+
success: false,
|
|
255
|
+
error: `Tool '${namespace}.${procedure}' not found. Use search_services to discover available tools.`,
|
|
256
|
+
}),
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
isError: true,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const result = await this.backend.call(namespace, procedure, [args]);
|
|
265
|
+
return {
|
|
266
|
+
content: [
|
|
267
|
+
{
|
|
268
|
+
type: "text" as const,
|
|
269
|
+
text: typeof result === "string" ? result : JSON.stringify(result),
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
};
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
275
|
+
return {
|
|
276
|
+
content: [
|
|
277
|
+
{
|
|
278
|
+
type: "text" as const,
|
|
279
|
+
text: JSON.stringify({
|
|
280
|
+
success: false,
|
|
281
|
+
error: `Service call failed: ${message}`,
|
|
282
|
+
}),
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
isError: true,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
getNamespaces(): string[] {
|
|
293
|
+
const namespaces = new Set<string>();
|
|
294
|
+
for (const info of this.tools.values()) {
|
|
295
|
+
namespaces.add(info.namespace);
|
|
296
|
+
}
|
|
297
|
+
return Array.from(namespaces);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
getToolInfos(): ServiceToolInfo[] {
|
|
301
|
+
return Array.from(this.tools.values());
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
has(namespace: string, procedure: string): boolean {
|
|
305
|
+
return this.tools.has(`${namespace}.${procedure}`);
|
|
306
|
+
}
|
|
307
|
+
}
|