create-multicast 0.1.0
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/dist/cli.js +583 -0
- package/package.json +39 -0
- package/templates/default/gitignore +8 -0
- package/templates/default/migrations/0001_init.sql +33 -0
- package/templates/default/package.json.tmpl +23 -0
- package/templates/default/src/index.ts +583 -0
- package/templates/default/tsconfig.json +17 -0
- package/templates/default/wrangler.json.tmpl +32 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
5
|
+
import { cp, readdir, readFile, writeFile, unlink, rename } from "node:fs/promises";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { resolve, join, dirname } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
const TEMPLATES_DIR = resolve(__dirname, "..", "templates", "default");
|
|
13
|
+
// ── Command Helpers ──────────────────────────────────────────
|
|
14
|
+
function runCommand(cmd, args, cwd) {
|
|
15
|
+
try {
|
|
16
|
+
const output = execFileSync(cmd, args, {
|
|
17
|
+
cwd,
|
|
18
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
19
|
+
encoding: "utf-8",
|
|
20
|
+
});
|
|
21
|
+
return { success: true, output: output ?? "" };
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
const error = err;
|
|
25
|
+
const parts = [error.stdout, error.stderr, error.message].filter(Boolean);
|
|
26
|
+
return {
|
|
27
|
+
success: false,
|
|
28
|
+
output: parts.join("\n").trim() || "Unknown error",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function runCommandLive(cmd, args, cwd) {
|
|
33
|
+
try {
|
|
34
|
+
const result = spawnSync(cmd, args, {
|
|
35
|
+
cwd,
|
|
36
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
37
|
+
encoding: "utf-8",
|
|
38
|
+
});
|
|
39
|
+
return { success: result.status === 0, output: "" };
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
const error = err;
|
|
43
|
+
return { success: false, output: error.message ?? "" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function parseDatabaseId(output) {
|
|
47
|
+
const match = output.match(/database_id\s*=\s*"([^"]+)"/) ??
|
|
48
|
+
output.match(/"database_id"\s*:\s*"([^"]+)"/);
|
|
49
|
+
return match?.[1] ?? null;
|
|
50
|
+
}
|
|
51
|
+
function parseDeployUrl(output) {
|
|
52
|
+
const match = output.match(/(https:\/\/[^\s]+\.workers\.dev)/);
|
|
53
|
+
return match?.[1] ?? null;
|
|
54
|
+
}
|
|
55
|
+
async function processTemplates(targetDir, replacements) {
|
|
56
|
+
const entries = await readdir(targetDir, {
|
|
57
|
+
recursive: true,
|
|
58
|
+
withFileTypes: true,
|
|
59
|
+
});
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (!entry.isFile() || !entry.name.endsWith(".tmpl"))
|
|
62
|
+
continue;
|
|
63
|
+
const tmplPath = join(entry.parentPath ?? entry.path, entry.name);
|
|
64
|
+
let content = await readFile(tmplPath, "utf-8");
|
|
65
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
66
|
+
content = content.replaceAll(`{{${key}}}`, value);
|
|
67
|
+
}
|
|
68
|
+
const finalPath = tmplPath.replace(/\.tmpl$/, "");
|
|
69
|
+
await writeFile(finalPath, content, "utf-8");
|
|
70
|
+
await unlink(tmplPath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// ── MCP Config Discovery ─────────────────────────────────────
|
|
74
|
+
// Scans known locations for existing MCP server configurations.
|
|
75
|
+
function getMcpConfigPaths() {
|
|
76
|
+
const home = homedir();
|
|
77
|
+
const paths = [];
|
|
78
|
+
// Claude Code
|
|
79
|
+
const claudeJson = join(home, ".claude.json");
|
|
80
|
+
if (existsSync(claudeJson)) {
|
|
81
|
+
paths.push({ path: claudeJson, label: "Claude Code (~/.claude.json)" });
|
|
82
|
+
}
|
|
83
|
+
// Claude Desktop (macOS)
|
|
84
|
+
const claudeDesktopMac = join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
85
|
+
if (existsSync(claudeDesktopMac)) {
|
|
86
|
+
paths.push({ path: claudeDesktopMac, label: "Claude Desktop (macOS)" });
|
|
87
|
+
}
|
|
88
|
+
// Claude Desktop (Windows via WSL or native)
|
|
89
|
+
const appdata = process.env.APPDATA || join(home, "AppData", "Roaming");
|
|
90
|
+
const claudeDesktopWin = join(appdata, "Claude", "claude_desktop_config.json");
|
|
91
|
+
if (existsSync(claudeDesktopWin)) {
|
|
92
|
+
paths.push({ path: claudeDesktopWin, label: "Claude Desktop (Windows)" });
|
|
93
|
+
}
|
|
94
|
+
// Cursor
|
|
95
|
+
const cursorConfig = join(home, ".cursor", "mcp.json");
|
|
96
|
+
if (existsSync(cursorConfig)) {
|
|
97
|
+
paths.push({ path: cursorConfig, label: "Cursor (~/.cursor/mcp.json)" });
|
|
98
|
+
}
|
|
99
|
+
// VS Code (project-level)
|
|
100
|
+
const vscodeConfig = join(process.cwd(), ".vscode", "mcp.json");
|
|
101
|
+
if (existsSync(vscodeConfig)) {
|
|
102
|
+
paths.push({ path: vscodeConfig, label: "VS Code (.vscode/mcp.json)" });
|
|
103
|
+
}
|
|
104
|
+
return paths;
|
|
105
|
+
}
|
|
106
|
+
function classifyServer(name, config, source) {
|
|
107
|
+
// HTTP server — has a url field
|
|
108
|
+
if (typeof config.url === "string" && config.url.startsWith("http")) {
|
|
109
|
+
const server = {
|
|
110
|
+
name,
|
|
111
|
+
url: config.url,
|
|
112
|
+
transport: "http",
|
|
113
|
+
source,
|
|
114
|
+
};
|
|
115
|
+
// Try to find auth
|
|
116
|
+
if (config.headers && typeof config.headers === "object") {
|
|
117
|
+
const headers = config.headers;
|
|
118
|
+
if (headers.Authorization) {
|
|
119
|
+
server.auth = headers.Authorization;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return server;
|
|
123
|
+
}
|
|
124
|
+
// Check env vars for URL patterns (some stdio configs have HTTP URLs in env)
|
|
125
|
+
if (config.env && typeof config.env === "object") {
|
|
126
|
+
const env = config.env;
|
|
127
|
+
for (const [key, value] of Object.entries(env)) {
|
|
128
|
+
if (typeof value === "string" && value.startsWith("http")) {
|
|
129
|
+
// Found an HTTP URL in env — might be usable
|
|
130
|
+
const server = {
|
|
131
|
+
name,
|
|
132
|
+
url: value,
|
|
133
|
+
transport: "http",
|
|
134
|
+
source,
|
|
135
|
+
};
|
|
136
|
+
// Look for auth tokens in env
|
|
137
|
+
for (const [authKey, authVal] of Object.entries(env)) {
|
|
138
|
+
if (typeof authVal === "string" &&
|
|
139
|
+
(authKey.includes("KEY") ||
|
|
140
|
+
authKey.includes("TOKEN") ||
|
|
141
|
+
authKey.includes("SECRET") ||
|
|
142
|
+
authKey.includes("AUTH"))) {
|
|
143
|
+
server.auth = `Bearer ${authVal}`;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return server;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// stdio server
|
|
152
|
+
return {
|
|
153
|
+
name,
|
|
154
|
+
command: typeof config.command === "string" ? config.command : "unknown",
|
|
155
|
+
transport: "stdio",
|
|
156
|
+
source,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
async function discoverMcpServers() {
|
|
160
|
+
const configs = getMcpConfigPaths();
|
|
161
|
+
const servers = [];
|
|
162
|
+
const seenNames = new Set();
|
|
163
|
+
for (const { path: configPath, label } of configs) {
|
|
164
|
+
try {
|
|
165
|
+
const content = await readFile(configPath, "utf-8");
|
|
166
|
+
const parsed = JSON.parse(content);
|
|
167
|
+
const mcpServers = parsed.mcpServers || parsed.servers || {};
|
|
168
|
+
for (const [name, config] of Object.entries(mcpServers)) {
|
|
169
|
+
if (seenNames.has(name))
|
|
170
|
+
continue; // skip duplicates across configs
|
|
171
|
+
seenNames.add(name);
|
|
172
|
+
const server = classifyServer(name, config, label);
|
|
173
|
+
servers.push(server);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Skip unparseable configs silently
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return servers;
|
|
181
|
+
}
|
|
182
|
+
// ── Main CLI ─────────────────────────────────────────────────
|
|
183
|
+
async function main() {
|
|
184
|
+
p.intro(pc.bgCyan(pc.black(" create-multicast ")));
|
|
185
|
+
p.log.info(`MCP gateway for Claude.ai — one integration, all your servers, parallel execution.\n` +
|
|
186
|
+
`${pc.dim("Runs on Cloudflare Workers (free tier). Costs $0/month.")}`);
|
|
187
|
+
// Step 1: Project name
|
|
188
|
+
const argName = process.argv[2];
|
|
189
|
+
let projectName;
|
|
190
|
+
if (argName && !argName.startsWith("-")) {
|
|
191
|
+
projectName = argName;
|
|
192
|
+
p.log.info(`Project name: ${pc.cyan(projectName)}`);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
const nameResult = await p.text({
|
|
196
|
+
message: "What should your project be called?",
|
|
197
|
+
placeholder: "my-multicast",
|
|
198
|
+
defaultValue: "my-multicast",
|
|
199
|
+
validate: (value) => {
|
|
200
|
+
if (!value.trim())
|
|
201
|
+
return "Project name is required";
|
|
202
|
+
if (!/^[a-z0-9-]+$/.test(value))
|
|
203
|
+
return "Use lowercase letters, numbers, and hyphens only";
|
|
204
|
+
return undefined;
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
if (p.isCancel(nameResult)) {
|
|
208
|
+
p.cancel("Setup cancelled.");
|
|
209
|
+
process.exit(0);
|
|
210
|
+
}
|
|
211
|
+
projectName = nameResult;
|
|
212
|
+
}
|
|
213
|
+
const targetDir = resolve(process.cwd(), projectName);
|
|
214
|
+
if (existsSync(targetDir)) {
|
|
215
|
+
p.log.error(`Directory ${pc.red(projectName)} already exists.`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
// Step 2: Discover existing MCP servers
|
|
219
|
+
p.log.step("Scanning for existing MCP configurations...");
|
|
220
|
+
const discovered = await discoverMcpServers();
|
|
221
|
+
const httpServers = discovered.filter((s) => s.transport === "http");
|
|
222
|
+
const stdioServers = discovered.filter((s) => s.transport === "stdio");
|
|
223
|
+
if (discovered.length === 0) {
|
|
224
|
+
p.log.warn("No MCP configurations found on this machine.");
|
|
225
|
+
p.log.info(`You can add servers manually after setup using:\n` +
|
|
226
|
+
` ${pc.cyan("npx wrangler secret put MCP_SERVER_MYSERVER")}`);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
// Display discovered servers
|
|
230
|
+
p.log.info(`Found ${pc.bold(String(discovered.length))} MCP servers:\n`);
|
|
231
|
+
for (const server of httpServers) {
|
|
232
|
+
p.log.message(` ${pc.green("✓")} ${pc.bold(server.name)} ${pc.dim(server.url || "")} ${pc.cyan("[HTTP]")} ${pc.dim(`from ${server.source}`)}`);
|
|
233
|
+
}
|
|
234
|
+
for (const server of stdioServers) {
|
|
235
|
+
p.log.message(` ${pc.red("✗")} ${pc.bold(server.name)} ${pc.dim(`command: ${server.command}`)} ${pc.yellow("[stdio — skipped]")}`);
|
|
236
|
+
}
|
|
237
|
+
if (stdioServers.length > 0) {
|
|
238
|
+
p.log.info(`\n ${pc.dim(`${stdioServers.length} stdio server(s) skipped — they run locally and can't be called from a cloud worker.`)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Step 3: Select servers to register
|
|
242
|
+
let selectedServers = [];
|
|
243
|
+
if (httpServers.length > 0) {
|
|
244
|
+
const selections = await p.multiselect({
|
|
245
|
+
message: "Select servers to register with Multicast:",
|
|
246
|
+
options: httpServers.map((s) => ({
|
|
247
|
+
value: s.name,
|
|
248
|
+
label: s.name,
|
|
249
|
+
hint: s.url,
|
|
250
|
+
})),
|
|
251
|
+
initialValues: httpServers.map((s) => s.name), // all selected by default
|
|
252
|
+
required: false,
|
|
253
|
+
});
|
|
254
|
+
if (p.isCancel(selections)) {
|
|
255
|
+
p.cancel("Setup cancelled.");
|
|
256
|
+
process.exit(0);
|
|
257
|
+
}
|
|
258
|
+
const selectedNames = selections;
|
|
259
|
+
// Confirm/collect auth for each selected server
|
|
260
|
+
for (const name of selectedNames) {
|
|
261
|
+
const server = httpServers.find((s) => s.name === name);
|
|
262
|
+
let auth = server.auth;
|
|
263
|
+
if (auth) {
|
|
264
|
+
const useExisting = await p.confirm({
|
|
265
|
+
message: `${name} — found auth credentials. Use them?`,
|
|
266
|
+
initialValue: true,
|
|
267
|
+
});
|
|
268
|
+
if (p.isCancel(useExisting)) {
|
|
269
|
+
p.cancel("Setup cancelled.");
|
|
270
|
+
process.exit(0);
|
|
271
|
+
}
|
|
272
|
+
if (!useExisting)
|
|
273
|
+
auth = undefined;
|
|
274
|
+
}
|
|
275
|
+
if (!auth) {
|
|
276
|
+
const needsAuth = await p.confirm({
|
|
277
|
+
message: `${name} — does this server require authentication?`,
|
|
278
|
+
initialValue: false,
|
|
279
|
+
});
|
|
280
|
+
if (!p.isCancel(needsAuth) && needsAuth) {
|
|
281
|
+
const authResult = await p.text({
|
|
282
|
+
message: `Enter auth header for ${name}:`,
|
|
283
|
+
placeholder: "Bearer your-token-here",
|
|
284
|
+
});
|
|
285
|
+
if (p.isCancel(authResult)) {
|
|
286
|
+
p.cancel("Setup cancelled.");
|
|
287
|
+
process.exit(0);
|
|
288
|
+
}
|
|
289
|
+
auth = authResult;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
selectedServers.push({
|
|
293
|
+
name,
|
|
294
|
+
url: server.url,
|
|
295
|
+
auth: auth || undefined,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Option to add servers manually
|
|
300
|
+
let addMore = true;
|
|
301
|
+
while (addMore) {
|
|
302
|
+
const shouldAdd = await p.confirm({
|
|
303
|
+
message: selectedServers.length === 0
|
|
304
|
+
? "Add an MCP server manually?"
|
|
305
|
+
: "Add another MCP server?",
|
|
306
|
+
initialValue: selectedServers.length === 0,
|
|
307
|
+
});
|
|
308
|
+
if (p.isCancel(shouldAdd) || !shouldAdd) {
|
|
309
|
+
addMore = false;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
const manualName = await p.text({
|
|
313
|
+
message: "Server name:",
|
|
314
|
+
placeholder: "my-server",
|
|
315
|
+
validate: (v) => {
|
|
316
|
+
if (!v.trim())
|
|
317
|
+
return "Name is required";
|
|
318
|
+
if (!/^[a-z0-9-]+$/.test(v))
|
|
319
|
+
return "Use lowercase letters, numbers, and hyphens";
|
|
320
|
+
return undefined;
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
if (p.isCancel(manualName))
|
|
324
|
+
break;
|
|
325
|
+
const manualUrl = await p.text({
|
|
326
|
+
message: "Server URL (MCP endpoint):",
|
|
327
|
+
placeholder: "https://my-server.example.com/mcp",
|
|
328
|
+
validate: (v) => {
|
|
329
|
+
if (!v.startsWith("http"))
|
|
330
|
+
return "URL must start with http:// or https://";
|
|
331
|
+
return undefined;
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
if (p.isCancel(manualUrl))
|
|
335
|
+
break;
|
|
336
|
+
const manualNeedsAuth = await p.confirm({
|
|
337
|
+
message: "Does this server require authentication?",
|
|
338
|
+
initialValue: false,
|
|
339
|
+
});
|
|
340
|
+
let manualAuth;
|
|
341
|
+
if (!p.isCancel(manualNeedsAuth) && manualNeedsAuth) {
|
|
342
|
+
const authInput = await p.text({
|
|
343
|
+
message: "Auth header value:",
|
|
344
|
+
placeholder: "Bearer your-token-here",
|
|
345
|
+
});
|
|
346
|
+
if (!p.isCancel(authInput)) {
|
|
347
|
+
manualAuth = authInput;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
selectedServers.push({
|
|
351
|
+
name: manualName,
|
|
352
|
+
url: manualUrl,
|
|
353
|
+
auth: manualAuth,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
if (selectedServers.length === 0) {
|
|
357
|
+
p.log.warn("No servers registered. You can add them later with:\n" +
|
|
358
|
+
` ${pc.cyan("npx wrangler secret put MCP_SERVER_MYSERVER")}`);
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
p.log.success(`${selectedServers.length} server(s) will be registered.`);
|
|
362
|
+
}
|
|
363
|
+
// Step 4: Scaffold project
|
|
364
|
+
const scaffoldSpinner = p.spinner();
|
|
365
|
+
scaffoldSpinner.start("Scaffolding project files...");
|
|
366
|
+
await cp(TEMPLATES_DIR, targetDir, { recursive: true });
|
|
367
|
+
const gitignorePath = join(targetDir, "gitignore");
|
|
368
|
+
if (existsSync(gitignorePath)) {
|
|
369
|
+
await rename(gitignorePath, join(targetDir, ".gitignore"));
|
|
370
|
+
}
|
|
371
|
+
await processTemplates(targetDir, {
|
|
372
|
+
PROJECT_NAME: projectName,
|
|
373
|
+
DB_ID: "PLACEHOLDER",
|
|
374
|
+
});
|
|
375
|
+
scaffoldSpinner.stop("Project scaffolded.");
|
|
376
|
+
// Step 5: Install dependencies
|
|
377
|
+
p.log.step("Installing dependencies...");
|
|
378
|
+
const installResult = runCommandLive("npm", ["install"], targetDir);
|
|
379
|
+
if (!installResult.success) {
|
|
380
|
+
p.log.warn(`npm install failed. Run manually: ${pc.cyan(`cd ${projectName} && npm install`)}`);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
p.log.success("Dependencies installed.");
|
|
384
|
+
}
|
|
385
|
+
// Step 6: Cloudflare setup
|
|
386
|
+
const setupCloudflare = await p.confirm({
|
|
387
|
+
message: "Set up Cloudflare? (login, create database, deploy)",
|
|
388
|
+
initialValue: true,
|
|
389
|
+
});
|
|
390
|
+
if (p.isCancel(setupCloudflare) || !setupCloudflare) {
|
|
391
|
+
printManualSteps(projectName, selectedServers);
|
|
392
|
+
p.outro(pc.green("Project created! Follow the steps above to finish setup."));
|
|
393
|
+
process.exit(0);
|
|
394
|
+
}
|
|
395
|
+
// Step 7: Wrangler login
|
|
396
|
+
const loginSpinner = p.spinner();
|
|
397
|
+
loginSpinner.start("Checking Cloudflare authentication...");
|
|
398
|
+
const whoami = runCommand("npx", ["wrangler", "whoami"], targetDir);
|
|
399
|
+
if (whoami.success && !whoami.output.includes("not authenticated")) {
|
|
400
|
+
loginSpinner.stop("Already logged in to Cloudflare.");
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
loginSpinner.stop("Need to log in to Cloudflare.");
|
|
404
|
+
p.log.info("Opening browser for Cloudflare login...");
|
|
405
|
+
const loginResult = runCommandLive("npx", ["wrangler", "login"], targetDir);
|
|
406
|
+
if (!loginResult.success) {
|
|
407
|
+
p.log.error("Cloudflare login failed.");
|
|
408
|
+
printManualSteps(projectName, selectedServers, "login");
|
|
409
|
+
p.outro(pc.yellow("Fix the login issue and continue with the steps above."));
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
|
412
|
+
p.log.success("Logged in to Cloudflare.");
|
|
413
|
+
}
|
|
414
|
+
// Step 8: Create D1 database
|
|
415
|
+
const dbName = `${projectName}-db`;
|
|
416
|
+
const dbSpinner = p.spinner();
|
|
417
|
+
dbSpinner.start(`Creating D1 database "${dbName}"...`);
|
|
418
|
+
const dbResult = runCommand("npx", ["wrangler", "d1", "create", dbName], targetDir);
|
|
419
|
+
if (!dbResult.success) {
|
|
420
|
+
dbSpinner.stop(pc.red("Failed to create D1 database."));
|
|
421
|
+
p.log.error(dbResult.output);
|
|
422
|
+
printManualSteps(projectName, selectedServers, "db");
|
|
423
|
+
p.outro(pc.yellow("Fix the issue and continue with the steps above."));
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
426
|
+
const databaseId = parseDatabaseId(dbResult.output);
|
|
427
|
+
if (!databaseId) {
|
|
428
|
+
dbSpinner.stop(pc.yellow("Database created but couldn't parse database_id."));
|
|
429
|
+
p.log.warn("Check the output above and manually update wrangler.json.");
|
|
430
|
+
p.log.message(dbResult.output);
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
dbSpinner.stop(`Database created: ${pc.cyan(databaseId)}`);
|
|
434
|
+
// Update wrangler.json with actual DB ID
|
|
435
|
+
const wranglerPath = join(targetDir, "wrangler.json");
|
|
436
|
+
let wranglerContent = await readFile(wranglerPath, "utf-8");
|
|
437
|
+
wranglerContent = wranglerContent.replace("PLACEHOLDER", databaseId);
|
|
438
|
+
await writeFile(wranglerPath, wranglerContent, "utf-8");
|
|
439
|
+
p.log.success("Updated wrangler.json with database ID.");
|
|
440
|
+
}
|
|
441
|
+
// Step 9: Run migration
|
|
442
|
+
p.log.step("Running database migration...");
|
|
443
|
+
const migrateResult = runCommandLive("npx", ["wrangler", "d1", "execute", dbName, "--remote", "--file=./migrations/0001_init.sql"], targetDir);
|
|
444
|
+
if (!migrateResult.success) {
|
|
445
|
+
p.log.warn(`Migration failed. Run manually:\n ${pc.cyan(`cd ${projectName} && npx wrangler d1 execute ${dbName} --remote --file=./migrations/0001_init.sql`)}`);
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
p.log.success("Database migration complete.");
|
|
449
|
+
}
|
|
450
|
+
// Step 10: Set MCP server secrets
|
|
451
|
+
if (selectedServers.length > 0) {
|
|
452
|
+
p.log.step("Setting MCP server credentials...");
|
|
453
|
+
for (const server of selectedServers) {
|
|
454
|
+
const envName = server.name.toUpperCase().replace(/-/g, "_");
|
|
455
|
+
// Set server URL
|
|
456
|
+
const urlResult = spawnSync("npx", ["wrangler", "secret", "put", `MCP_SERVER_${envName}`], {
|
|
457
|
+
cwd: targetDir,
|
|
458
|
+
input: server.url + "\n",
|
|
459
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
460
|
+
encoding: "utf-8",
|
|
461
|
+
});
|
|
462
|
+
if (urlResult.status !== 0) {
|
|
463
|
+
p.log.warn(`Failed to set URL for ${server.name}. Run manually:\n` +
|
|
464
|
+
` ${pc.cyan(`npx wrangler secret put MCP_SERVER_${envName}`)}`);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
p.log.success(`${server.name} URL set.`);
|
|
468
|
+
}
|
|
469
|
+
// Set auth if present
|
|
470
|
+
if (server.auth) {
|
|
471
|
+
const authResult = spawnSync("npx", ["wrangler", "secret", "put", `MCP_AUTH_${envName}`], {
|
|
472
|
+
cwd: targetDir,
|
|
473
|
+
input: server.auth + "\n",
|
|
474
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
475
|
+
encoding: "utf-8",
|
|
476
|
+
});
|
|
477
|
+
if (authResult.status !== 0) {
|
|
478
|
+
p.log.warn(`Failed to set auth for ${server.name}. Run manually:\n` +
|
|
479
|
+
` ${pc.cyan(`npx wrangler secret put MCP_AUTH_${envName}`)}`);
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
p.log.success(`${server.name} auth set.`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// Step 11: Deploy
|
|
488
|
+
p.log.step("Deploying to Cloudflare Workers...");
|
|
489
|
+
const deployResult = runCommand("npx", ["wrangler", "deploy"], targetDir);
|
|
490
|
+
let deployedUrl = null;
|
|
491
|
+
if (!deployResult.success) {
|
|
492
|
+
p.log.warn(`Deploy failed. Run manually: ${pc.cyan(`cd ${projectName} && npx wrangler deploy`)}`);
|
|
493
|
+
p.log.message(deployResult.output);
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
deployedUrl = parseDeployUrl(deployResult.output);
|
|
497
|
+
if (deployedUrl) {
|
|
498
|
+
p.log.success(`Deployed to ${pc.cyan(deployedUrl)}`);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
p.log.success("Deployed successfully.");
|
|
502
|
+
p.log.message(deployResult.output);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// Step 12: Claude Code connection
|
|
506
|
+
if (deployedUrl) {
|
|
507
|
+
const mcpUrl = `${deployedUrl}/mcp`;
|
|
508
|
+
const setupClaude = await p.confirm({
|
|
509
|
+
message: "Configure Claude Code connection?",
|
|
510
|
+
initialValue: true,
|
|
511
|
+
});
|
|
512
|
+
if (!p.isCancel(setupClaude) && setupClaude) {
|
|
513
|
+
const claudeSpinner = p.spinner();
|
|
514
|
+
claudeSpinner.start("Adding MCP server to Claude Code...");
|
|
515
|
+
const claudeResult = spawnSync("claude", ["mcp", "add", "--transport", "http", "--scope", "user", "multicast", mcpUrl], {
|
|
516
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
517
|
+
encoding: "utf-8",
|
|
518
|
+
});
|
|
519
|
+
if (claudeResult.status !== 0) {
|
|
520
|
+
claudeSpinner.stop(pc.yellow("Couldn't configure Claude Code automatically."));
|
|
521
|
+
p.log.warn(`Run manually:\n` +
|
|
522
|
+
` ${pc.cyan(`claude mcp add --transport http --scope user multicast ${mcpUrl}`)}`);
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
claudeSpinner.stop("Claude Code connected.");
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// Claude.ai connection instructions
|
|
529
|
+
p.log.step(pc.bold("Connect to Claude.ai:"));
|
|
530
|
+
p.log.message(` 1. Go to ${pc.cyan("claude.ai/settings/connectors")}\n` +
|
|
531
|
+
` 2. Click ${pc.bold('"Add custom connector"')}\n` +
|
|
532
|
+
` 3. Enter URL: ${pc.cyan(mcpUrl)}\n` +
|
|
533
|
+
` 4. Click ${pc.bold("Add")} then ${pc.bold("Connect")}`);
|
|
534
|
+
}
|
|
535
|
+
// Step 13: Summary
|
|
536
|
+
p.log.step(pc.bold("Summary"));
|
|
537
|
+
const summaryLines = [
|
|
538
|
+
` ${pc.green("✓")} Project: ${pc.cyan(targetDir)}`,
|
|
539
|
+
];
|
|
540
|
+
if (deployedUrl) {
|
|
541
|
+
summaryLines.push(` ${pc.green("✓")} URL: ${pc.cyan(deployedUrl)}`);
|
|
542
|
+
summaryLines.push(` ${pc.green("✓")} MCP: ${pc.cyan(deployedUrl + "/mcp")}`);
|
|
543
|
+
}
|
|
544
|
+
summaryLines.push(` ${pc.green("✓")} Servers: ${pc.cyan(String(selectedServers.length))} registered`);
|
|
545
|
+
for (const server of selectedServers) {
|
|
546
|
+
summaryLines.push(` ${pc.dim("•")} ${server.name} ${pc.dim(server.url)}`);
|
|
547
|
+
}
|
|
548
|
+
summaryLines.push(`\n ${pc.dim("Add more servers later:")}`, ` ${pc.cyan("npx wrangler secret put MCP_SERVER_NEWNAME")}`, ` ${pc.cyan("npx wrangler deploy")}`);
|
|
549
|
+
p.log.message(summaryLines.join("\n"));
|
|
550
|
+
p.outro(pc.green("Multicast is live!") +
|
|
551
|
+
pc.dim(" Use list_servers in Claude to see your connected servers."));
|
|
552
|
+
}
|
|
553
|
+
// ── Manual Steps ─────────────────────────────────────────────
|
|
554
|
+
function printManualSteps(projectName, servers, from) {
|
|
555
|
+
const steps = [];
|
|
556
|
+
let n = 1;
|
|
557
|
+
if (!from || from === "login") {
|
|
558
|
+
steps.push(` ${n++}. ${pc.cyan(`cd ${projectName}`)}`);
|
|
559
|
+
steps.push(` ${n++}. ${pc.cyan("npx wrangler login")}`);
|
|
560
|
+
}
|
|
561
|
+
if (!from || from === "login" || from === "db") {
|
|
562
|
+
steps.push(` ${n++}. ${pc.cyan(`npx wrangler d1 create ${projectName}-db`)}`);
|
|
563
|
+
steps.push(` ${n++}. Update ${pc.bold("wrangler.json")} with the database_id`);
|
|
564
|
+
steps.push(` ${n++}. ${pc.cyan(`npx wrangler d1 execute ${projectName}-db --remote --file=./migrations/0001_init.sql`)}`);
|
|
565
|
+
}
|
|
566
|
+
// Server secrets
|
|
567
|
+
for (const server of servers) {
|
|
568
|
+
const envName = server.name.toUpperCase().replace(/-/g, "_");
|
|
569
|
+
steps.push(` ${n++}. ${pc.cyan(`npx wrangler secret put MCP_SERVER_${envName}`)}`);
|
|
570
|
+
steps.push(` ${pc.dim(`Enter: ${server.url}`)}`);
|
|
571
|
+
if (server.auth) {
|
|
572
|
+
steps.push(` ${n++}. ${pc.cyan(`npx wrangler secret put MCP_AUTH_${envName}`)}`);
|
|
573
|
+
steps.push(` ${pc.dim("Enter your auth header")}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
steps.push(` ${n++}. ${pc.cyan("npx wrangler deploy")}`);
|
|
577
|
+
p.log.step(pc.bold("Remaining steps:"));
|
|
578
|
+
p.log.message(steps.join("\n"));
|
|
579
|
+
}
|
|
580
|
+
main().catch((err) => {
|
|
581
|
+
p.log.error(err instanceof Error ? err.message : String(err));
|
|
582
|
+
process.exit(1);
|
|
583
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-multicast",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create a Multicast MCP gateway — one command to scaffold, configure, and deploy your parallel MCP server.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": "./dist/cli.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist/",
|
|
9
|
+
"templates/"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"prepublishOnly": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"create",
|
|
17
|
+
"multicast",
|
|
18
|
+
"mcp",
|
|
19
|
+
"claude",
|
|
20
|
+
"claude-ai",
|
|
21
|
+
"parallel",
|
|
22
|
+
"gateway",
|
|
23
|
+
"cloudflare-workers",
|
|
24
|
+
"model-context-protocol"
|
|
25
|
+
],
|
|
26
|
+
"author": "Mayank Bohra",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@clack/prompts": "^0.9.1",
|
|
30
|
+
"picocolors": "^1.1.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"typescript": "^5.7.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- Multicast MCP Gateway - Initial Schema
|
|
2
|
+
-- Stores server registry and cached tool discovery data
|
|
3
|
+
|
|
4
|
+
-- Servers: registered downstream MCP servers
|
|
5
|
+
-- URL + auth come from env vars (MCP_SERVER_*, MCP_AUTH_*)
|
|
6
|
+
-- This table stores metadata discovered via tools/list
|
|
7
|
+
CREATE TABLE IF NOT EXISTS servers (
|
|
8
|
+
name TEXT PRIMARY KEY,
|
|
9
|
+
url TEXT NOT NULL,
|
|
10
|
+
description TEXT DEFAULT '',
|
|
11
|
+
tool_count INTEGER DEFAULT 0,
|
|
12
|
+
status TEXT DEFAULT 'active', -- active, unreachable, error
|
|
13
|
+
last_error TEXT DEFAULT NULL,
|
|
14
|
+
last_discovered_at TEXT DEFAULT NULL,
|
|
15
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
16
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
-- Tools: cached tools/list responses from downstream servers
|
|
20
|
+
-- Refreshed periodically (24h TTL) or on-demand
|
|
21
|
+
CREATE TABLE IF NOT EXISTS tools (
|
|
22
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
23
|
+
server_name TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE,
|
|
24
|
+
tool_name TEXT NOT NULL,
|
|
25
|
+
description TEXT DEFAULT '',
|
|
26
|
+
input_schema TEXT DEFAULT '{}',
|
|
27
|
+
cached_at TEXT DEFAULT (datetime('now')),
|
|
28
|
+
UNIQUE(server_name, tool_name)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
-- Indexes for common queries
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_tools_server ON tools(server_name);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_servers_status ON servers(status);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Multicast MCP gateway — one integration, all your MCP servers, parallel execution.",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "wrangler dev",
|
|
8
|
+
"deploy": "wrangler deploy",
|
|
9
|
+
"db:migrate": "wrangler d1 execute {{PROJECT_NAME}}-db --local --file=./migrations/0001_init.sql",
|
|
10
|
+
"db:migrate:remote": "wrangler d1 execute {{PROJECT_NAME}}-db --remote --file=./migrations/0001_init.sql",
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "1.25.2",
|
|
16
|
+
"agents": "^0.3.6"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@cloudflare/workers-types": "^4.20250313.0",
|
|
20
|
+
"typescript": "^5.7.0",
|
|
21
|
+
"wrangler": "^4.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { McpAgent } from "agents/mcp";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
// ── Types ────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface Env {
|
|
8
|
+
DB: D1Database;
|
|
9
|
+
MULTICAST: DurableObjectNamespace;
|
|
10
|
+
[key: string]: unknown; // MCP_SERVER_* and MCP_AUTH_* env vars
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface RegisteredServer {
|
|
14
|
+
name: string;
|
|
15
|
+
url: string;
|
|
16
|
+
auth?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CallResult {
|
|
20
|
+
server: string;
|
|
21
|
+
tool: string;
|
|
22
|
+
success: boolean;
|
|
23
|
+
output?: unknown;
|
|
24
|
+
error?: string;
|
|
25
|
+
duration_ms: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CachedTool {
|
|
29
|
+
tool_name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
input_schema: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Server Registry ──────────────────────────────────────────
|
|
35
|
+
// Parses MCP_SERVER_* and MCP_AUTH_* env vars at request time.
|
|
36
|
+
// MCP_SERVER_CONTEXT_HUB=https://... → server name: "context-hub"
|
|
37
|
+
// MCP_AUTH_CONTEXT_HUB=Bearer key... → auth header for "context-hub"
|
|
38
|
+
|
|
39
|
+
function getRegisteredServers(env: Env): Map<string, RegisteredServer> {
|
|
40
|
+
const servers = new Map<string, RegisteredServer>();
|
|
41
|
+
|
|
42
|
+
for (const [key, value] of Object.entries(env)) {
|
|
43
|
+
if (key.startsWith("MCP_SERVER_") && typeof value === "string") {
|
|
44
|
+
const rawName = key.replace("MCP_SERVER_", "");
|
|
45
|
+
const name = rawName.toLowerCase().replace(/_/g, "-");
|
|
46
|
+
const authKey = `MCP_AUTH_${rawName}`;
|
|
47
|
+
const auth = typeof env[authKey] === "string" ? (env[authKey] as string) : undefined;
|
|
48
|
+
|
|
49
|
+
servers.set(name, { name, url: value, auth });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return servers;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Downstream MCP Client ────────────────────────────────────
|
|
57
|
+
// Calls a single downstream MCP server via JSON-RPC over HTTP.
|
|
58
|
+
|
|
59
|
+
async function callMcpServer(
|
|
60
|
+
server: RegisteredServer,
|
|
61
|
+
tool: string,
|
|
62
|
+
args: Record<string, unknown>,
|
|
63
|
+
timeoutMs: number
|
|
64
|
+
): Promise<CallResult> {
|
|
65
|
+
const start = Date.now();
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const headers: Record<string, string> = {
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
};
|
|
73
|
+
if (server.auth) {
|
|
74
|
+
headers["Authorization"] = server.auth;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const response = await fetch(server.url, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers,
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
jsonrpc: "2.0",
|
|
82
|
+
method: "tools/call",
|
|
83
|
+
params: { name: tool, arguments: args },
|
|
84
|
+
id: crypto.randomUUID(),
|
|
85
|
+
}),
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
return {
|
|
91
|
+
server: server.name,
|
|
92
|
+
tool,
|
|
93
|
+
success: false,
|
|
94
|
+
error: `HTTP ${response.status}: ${response.statusText}`,
|
|
95
|
+
duration_ms: Date.now() - start,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const data = (await response.json()) as {
|
|
100
|
+
result?: unknown;
|
|
101
|
+
error?: { message?: string; code?: number };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (data.error) {
|
|
105
|
+
return {
|
|
106
|
+
server: server.name,
|
|
107
|
+
tool,
|
|
108
|
+
success: false,
|
|
109
|
+
error: data.error.message || `JSON-RPC error ${data.error.code}`,
|
|
110
|
+
duration_ms: Date.now() - start,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
server: server.name,
|
|
116
|
+
tool,
|
|
117
|
+
success: true,
|
|
118
|
+
output: data.result,
|
|
119
|
+
duration_ms: Date.now() - start,
|
|
120
|
+
};
|
|
121
|
+
} catch (err: unknown) {
|
|
122
|
+
const message =
|
|
123
|
+
err instanceof Error
|
|
124
|
+
? err.name === "AbortError"
|
|
125
|
+
? `timeout after ${timeoutMs}ms`
|
|
126
|
+
: err.message
|
|
127
|
+
: "unknown error";
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
server: server.name,
|
|
131
|
+
tool,
|
|
132
|
+
success: false,
|
|
133
|
+
error: message,
|
|
134
|
+
duration_ms: Date.now() - start,
|
|
135
|
+
};
|
|
136
|
+
} finally {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Tool Discovery ───────────────────────────────────────────
|
|
142
|
+
// Calls tools/list on a downstream MCP server and caches results in D1.
|
|
143
|
+
|
|
144
|
+
async function discoverServerTools(
|
|
145
|
+
server: RegisteredServer,
|
|
146
|
+
db: D1Database
|
|
147
|
+
): Promise<{ tool_count: number; error?: string }> {
|
|
148
|
+
try {
|
|
149
|
+
const headers: Record<string, string> = {
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
};
|
|
152
|
+
if (server.auth) {
|
|
153
|
+
headers["Authorization"] = server.auth;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// MCP initialize handshake
|
|
157
|
+
const initResponse = await fetch(server.url, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers,
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
jsonrpc: "2.0",
|
|
162
|
+
method: "initialize",
|
|
163
|
+
params: {
|
|
164
|
+
protocolVersion: "2024-11-05",
|
|
165
|
+
capabilities: {},
|
|
166
|
+
clientInfo: { name: "multicast", version: "0.1.0" },
|
|
167
|
+
},
|
|
168
|
+
id: "init-" + crypto.randomUUID(),
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (!initResponse.ok) {
|
|
173
|
+
// Some servers don't require initialize — try tools/list directly
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Request tools list
|
|
177
|
+
const response = await fetch(server.url, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: {
|
|
180
|
+
...headers,
|
|
181
|
+
// Pass session ID from init response if available
|
|
182
|
+
...(initResponse.headers.get("mcp-session-id")
|
|
183
|
+
? { "mcp-session-id": initResponse.headers.get("mcp-session-id")! }
|
|
184
|
+
: {}),
|
|
185
|
+
},
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
jsonrpc: "2.0",
|
|
188
|
+
method: "tools/list",
|
|
189
|
+
params: {},
|
|
190
|
+
id: "discover-" + crypto.randomUUID(),
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (!response.ok) {
|
|
195
|
+
const errText = `HTTP ${response.status}`;
|
|
196
|
+
await db
|
|
197
|
+
.prepare(
|
|
198
|
+
`INSERT OR REPLACE INTO servers (name, url, status, last_error, updated_at)
|
|
199
|
+
VALUES (?, ?, 'error', ?, datetime('now'))`
|
|
200
|
+
)
|
|
201
|
+
.bind(server.name, server.url, errText)
|
|
202
|
+
.run();
|
|
203
|
+
return { tool_count: 0, error: errText };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const data = (await response.json()) as {
|
|
207
|
+
result?: { tools?: Array<{ name: string; description?: string; inputSchema?: unknown }> };
|
|
208
|
+
error?: { message?: string };
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
if (data.error) {
|
|
212
|
+
const errMsg = data.error.message || "tools/list failed";
|
|
213
|
+
await db
|
|
214
|
+
.prepare(
|
|
215
|
+
`INSERT OR REPLACE INTO servers (name, url, status, last_error, updated_at)
|
|
216
|
+
VALUES (?, ?, 'error', ?, datetime('now'))`
|
|
217
|
+
)
|
|
218
|
+
.bind(server.name, server.url, errMsg)
|
|
219
|
+
.run();
|
|
220
|
+
return { tool_count: 0, error: errMsg };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const tools = data.result?.tools || [];
|
|
224
|
+
|
|
225
|
+
// Clear old tools for this server
|
|
226
|
+
await db
|
|
227
|
+
.prepare("DELETE FROM tools WHERE server_name = ?")
|
|
228
|
+
.bind(server.name)
|
|
229
|
+
.run();
|
|
230
|
+
|
|
231
|
+
// Insert discovered tools
|
|
232
|
+
for (const tool of tools) {
|
|
233
|
+
await db
|
|
234
|
+
.prepare(
|
|
235
|
+
`INSERT INTO tools (server_name, tool_name, description, input_schema, cached_at)
|
|
236
|
+
VALUES (?, ?, ?, ?, datetime('now'))`
|
|
237
|
+
)
|
|
238
|
+
.bind(
|
|
239
|
+
server.name,
|
|
240
|
+
tool.name,
|
|
241
|
+
tool.description || "",
|
|
242
|
+
JSON.stringify(tool.inputSchema || {})
|
|
243
|
+
)
|
|
244
|
+
.run();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Update server metadata
|
|
248
|
+
await db
|
|
249
|
+
.prepare(
|
|
250
|
+
`INSERT OR REPLACE INTO servers (name, url, description, tool_count, status, last_error, last_discovered_at, updated_at)
|
|
251
|
+
VALUES (?, ?, '', ?, 'active', NULL, datetime('now'), datetime('now'))`
|
|
252
|
+
)
|
|
253
|
+
.bind(server.name, server.url, tools.length)
|
|
254
|
+
.run();
|
|
255
|
+
|
|
256
|
+
return { tool_count: tools.length };
|
|
257
|
+
} catch (err: unknown) {
|
|
258
|
+
const message = err instanceof Error ? err.message : "discovery failed";
|
|
259
|
+
await db
|
|
260
|
+
.prepare(
|
|
261
|
+
`INSERT OR REPLACE INTO servers (name, url, status, last_error, updated_at)
|
|
262
|
+
VALUES (?, ?, 'unreachable', ?, datetime('now'))`
|
|
263
|
+
)
|
|
264
|
+
.bind(server.name, server.url, message)
|
|
265
|
+
.run();
|
|
266
|
+
return { tool_count: 0, error: message };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Check if tool cache is stale (older than 24 hours)
|
|
271
|
+
async function isCacheStale(db: D1Database, serverName: string): Promise<boolean> {
|
|
272
|
+
const row = await db
|
|
273
|
+
.prepare("SELECT last_discovered_at FROM servers WHERE name = ?")
|
|
274
|
+
.bind(serverName)
|
|
275
|
+
.first<{ last_discovered_at: string | null }>();
|
|
276
|
+
|
|
277
|
+
if (!row || !row.last_discovered_at) return true;
|
|
278
|
+
|
|
279
|
+
const discoveredAt = new Date(row.last_discovered_at + "Z").getTime();
|
|
280
|
+
const hoursSince = (Date.now() - discoveredAt) / (1000 * 60 * 60);
|
|
281
|
+
return hoursSince > 24;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── MCP Gateway Agent ────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
export class Multicast extends McpAgent<Env> {
|
|
287
|
+
server = new McpServer({
|
|
288
|
+
name: "Multicast",
|
|
289
|
+
version: "0.1.0",
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
async init() {
|
|
293
|
+
// ── Tool: list_servers ──────────────────────────────────
|
|
294
|
+
|
|
295
|
+
this.server.tool(
|
|
296
|
+
"list_servers",
|
|
297
|
+
`List all registered MCP servers and their available tools.
|
|
298
|
+
Returns server names, descriptions, tool counts, and individual tool details from the discovery cache.
|
|
299
|
+
Use this to understand what servers and tools are available before calling multicast.
|
|
300
|
+
If the cache is stale (>24h), it will be refreshed automatically in the background.`,
|
|
301
|
+
{},
|
|
302
|
+
async () => {
|
|
303
|
+
const db = this.env.DB;
|
|
304
|
+
const registry = getRegisteredServers(this.env);
|
|
305
|
+
|
|
306
|
+
// Sync registry: ensure all env var servers are in D1
|
|
307
|
+
for (const [name, server] of registry) {
|
|
308
|
+
const existing = await db
|
|
309
|
+
.prepare("SELECT name FROM servers WHERE name = ?")
|
|
310
|
+
.bind(name)
|
|
311
|
+
.first();
|
|
312
|
+
|
|
313
|
+
if (!existing) {
|
|
314
|
+
// New server — discover its tools
|
|
315
|
+
await discoverServerTools(server, db);
|
|
316
|
+
} else if (await isCacheStale(db, name)) {
|
|
317
|
+
// Stale cache — refresh
|
|
318
|
+
await discoverServerTools(server, db);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Fetch all servers and their tools
|
|
323
|
+
const servers = await db
|
|
324
|
+
.prepare(
|
|
325
|
+
"SELECT name, url, description, tool_count, status, last_error, last_discovered_at FROM servers WHERE name IN (" +
|
|
326
|
+
Array.from(registry.keys())
|
|
327
|
+
.map(() => "?")
|
|
328
|
+
.join(",") +
|
|
329
|
+
")"
|
|
330
|
+
)
|
|
331
|
+
.bind(...Array.from(registry.keys()))
|
|
332
|
+
.all<{
|
|
333
|
+
name: string;
|
|
334
|
+
url: string;
|
|
335
|
+
description: string;
|
|
336
|
+
tool_count: number;
|
|
337
|
+
status: string;
|
|
338
|
+
last_error: string | null;
|
|
339
|
+
last_discovered_at: string | null;
|
|
340
|
+
}>();
|
|
341
|
+
|
|
342
|
+
const result = [];
|
|
343
|
+
|
|
344
|
+
for (const server of servers.results) {
|
|
345
|
+
const tools = await db
|
|
346
|
+
.prepare(
|
|
347
|
+
"SELECT tool_name, description FROM tools WHERE server_name = ?"
|
|
348
|
+
)
|
|
349
|
+
.bind(server.name)
|
|
350
|
+
.all<{ tool_name: string; description: string }>();
|
|
351
|
+
|
|
352
|
+
result.push({
|
|
353
|
+
name: server.name,
|
|
354
|
+
status: server.status,
|
|
355
|
+
tool_count: server.tool_count,
|
|
356
|
+
last_error: server.last_error,
|
|
357
|
+
last_discovered_at: server.last_discovered_at,
|
|
358
|
+
tools: tools.results.map((t) => ({
|
|
359
|
+
name: t.tool_name,
|
|
360
|
+
description: t.description,
|
|
361
|
+
})),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
content: [
|
|
367
|
+
{
|
|
368
|
+
type: "text" as const,
|
|
369
|
+
text: JSON.stringify(
|
|
370
|
+
{
|
|
371
|
+
servers: result,
|
|
372
|
+
total_servers: result.length,
|
|
373
|
+
total_tools: result.reduce((sum, s) => sum + s.tool_count, 0),
|
|
374
|
+
},
|
|
375
|
+
null,
|
|
376
|
+
2
|
|
377
|
+
),
|
|
378
|
+
},
|
|
379
|
+
],
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// ── Tool: multicast ─────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
this.server.tool(
|
|
387
|
+
"multicast",
|
|
388
|
+
`Call multiple MCP servers in parallel and return all results at once.
|
|
389
|
+
Each call runs independently — failures in one server don't block others.
|
|
390
|
+
Always returns partial results even if some calls fail.
|
|
391
|
+
|
|
392
|
+
Use list_servers first to discover available servers and their tools.
|
|
393
|
+
|
|
394
|
+
Example:
|
|
395
|
+
{
|
|
396
|
+
"calls": [
|
|
397
|
+
{ "server": "context-hub", "tool": "search_memories", "args": { "query": "project ideas" } },
|
|
398
|
+
{ "server": "supabase", "tool": "execute_sql", "args": { "sql": "SELECT count(*) FROM users" } }
|
|
399
|
+
]
|
|
400
|
+
}`,
|
|
401
|
+
{
|
|
402
|
+
calls: z
|
|
403
|
+
.array(
|
|
404
|
+
z.object({
|
|
405
|
+
server: z.string().describe("Registered server name (from list_servers)"),
|
|
406
|
+
tool: z.string().describe("Tool name to call on that server"),
|
|
407
|
+
args: z
|
|
408
|
+
.record(z.string(), z.unknown())
|
|
409
|
+
.optional()
|
|
410
|
+
.default({})
|
|
411
|
+
.describe("Arguments to pass to the tool"),
|
|
412
|
+
})
|
|
413
|
+
)
|
|
414
|
+
.min(1)
|
|
415
|
+
.describe("Array of MCP server calls to execute in parallel"),
|
|
416
|
+
timeout_ms: z
|
|
417
|
+
.number()
|
|
418
|
+
.optional()
|
|
419
|
+
.default(15000)
|
|
420
|
+
.describe("Max timeout per call in milliseconds (default: 15000)"),
|
|
421
|
+
},
|
|
422
|
+
async ({ calls, timeout_ms }) => {
|
|
423
|
+
const registry = getRegisteredServers(this.env);
|
|
424
|
+
const timeout = timeout_ms ?? 15000;
|
|
425
|
+
const totalStart = Date.now();
|
|
426
|
+
|
|
427
|
+
// Validate all servers exist before executing
|
|
428
|
+
const validationErrors: CallResult[] = [];
|
|
429
|
+
const validCalls: Array<{
|
|
430
|
+
server: RegisteredServer;
|
|
431
|
+
tool: string;
|
|
432
|
+
args: Record<string, unknown>;
|
|
433
|
+
}> = [];
|
|
434
|
+
|
|
435
|
+
for (const call of calls) {
|
|
436
|
+
const server = registry.get(call.server);
|
|
437
|
+
if (!server) {
|
|
438
|
+
validationErrors.push({
|
|
439
|
+
server: call.server,
|
|
440
|
+
tool: call.tool,
|
|
441
|
+
success: false,
|
|
442
|
+
error: `unknown server "${call.server}". Use list_servers to see available servers.`,
|
|
443
|
+
duration_ms: 0,
|
|
444
|
+
});
|
|
445
|
+
} else {
|
|
446
|
+
validCalls.push({
|
|
447
|
+
server,
|
|
448
|
+
tool: call.tool,
|
|
449
|
+
args: (call.args || {}) as Record<string, unknown>,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Execute all valid calls in parallel
|
|
455
|
+
const promises = validCalls.map((call) =>
|
|
456
|
+
callMcpServer(call.server, call.tool, call.args, timeout)
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const settled = await Promise.allSettled(promises);
|
|
460
|
+
|
|
461
|
+
const results: CallResult[] = [
|
|
462
|
+
...validationErrors,
|
|
463
|
+
...settled.map((result, i) => {
|
|
464
|
+
if (result.status === "fulfilled") {
|
|
465
|
+
return result.value;
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
server: validCalls[i].server.name,
|
|
469
|
+
tool: validCalls[i].tool,
|
|
470
|
+
success: false,
|
|
471
|
+
error: result.reason?.message || "unexpected error",
|
|
472
|
+
duration_ms: Date.now() - totalStart,
|
|
473
|
+
};
|
|
474
|
+
}),
|
|
475
|
+
];
|
|
476
|
+
|
|
477
|
+
const completed = results.filter((r) => r.success).length;
|
|
478
|
+
const failed = results.filter((r) => !r.success).length;
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
content: [
|
|
482
|
+
{
|
|
483
|
+
type: "text" as const,
|
|
484
|
+
text: JSON.stringify(
|
|
485
|
+
{
|
|
486
|
+
results,
|
|
487
|
+
total_ms: Date.now() - totalStart,
|
|
488
|
+
completed,
|
|
489
|
+
failed,
|
|
490
|
+
},
|
|
491
|
+
null,
|
|
492
|
+
2
|
|
493
|
+
),
|
|
494
|
+
},
|
|
495
|
+
],
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
// ── Tool: refresh_servers ────────────────────────────────
|
|
501
|
+
|
|
502
|
+
this.server.tool(
|
|
503
|
+
"refresh_servers",
|
|
504
|
+
`Force re-discovery of all registered MCP servers' tools.
|
|
505
|
+
Use this if you've added new servers or if tools seem outdated.
|
|
506
|
+
Clears the cache and re-fetches tools/list from every registered server.`,
|
|
507
|
+
{},
|
|
508
|
+
async () => {
|
|
509
|
+
const db = this.env.DB;
|
|
510
|
+
const registry = getRegisteredServers(this.env);
|
|
511
|
+
const results: Array<{ server: string; tool_count: number; error?: string }> = [];
|
|
512
|
+
|
|
513
|
+
for (const [name, server] of registry) {
|
|
514
|
+
const result = await discoverServerTools(server, db);
|
|
515
|
+
results.push({ server: name, ...result });
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const totalTools = results.reduce((sum, r) => sum + r.tool_count, 0);
|
|
519
|
+
const errors = results.filter((r) => r.error);
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
content: [
|
|
523
|
+
{
|
|
524
|
+
type: "text" as const,
|
|
525
|
+
text: JSON.stringify(
|
|
526
|
+
{
|
|
527
|
+
message: `Refreshed ${results.length} servers. ${totalTools} tools cached.${errors.length > 0 ? ` ${errors.length} server(s) had errors.` : ""}`,
|
|
528
|
+
servers: results,
|
|
529
|
+
},
|
|
530
|
+
null,
|
|
531
|
+
2
|
|
532
|
+
),
|
|
533
|
+
},
|
|
534
|
+
],
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ── Worker Entry Point ───────────────────────────────────────
|
|
542
|
+
// McpAgent.serve() handles Durable Object routing, session management,
|
|
543
|
+
// and MCP protocol negotiation (Streamable HTTP) automatically.
|
|
544
|
+
|
|
545
|
+
const mcpHandler = Multicast.serve("/mcp", {
|
|
546
|
+
binding: "MULTICAST",
|
|
547
|
+
corsOptions: {
|
|
548
|
+
origin: "*",
|
|
549
|
+
methods: "GET, POST, OPTIONS, DELETE",
|
|
550
|
+
headers: "Content-Type, Authorization, mcp-session-id",
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
export default {
|
|
555
|
+
async fetch(
|
|
556
|
+
request: Request,
|
|
557
|
+
env: Env,
|
|
558
|
+
ctx: ExecutionContext
|
|
559
|
+
): Promise<Response> {
|
|
560
|
+
const url = new URL(request.url);
|
|
561
|
+
|
|
562
|
+
// Health check
|
|
563
|
+
if (url.pathname === "/" || url.pathname === "/health") {
|
|
564
|
+
const registry = getRegisteredServers(env);
|
|
565
|
+
return new Response(
|
|
566
|
+
JSON.stringify({
|
|
567
|
+
name: "Multicast",
|
|
568
|
+
version: "0.1.0",
|
|
569
|
+
description: "MCP gateway — one integration, all your servers, parallel execution",
|
|
570
|
+
status: "ok",
|
|
571
|
+
registered_servers: Array.from(registry.keys()),
|
|
572
|
+
endpoints: ["/mcp"],
|
|
573
|
+
}),
|
|
574
|
+
{
|
|
575
|
+
headers: { "Content-Type": "application/json" },
|
|
576
|
+
}
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Delegate MCP traffic
|
|
581
|
+
return mcpHandler.fetch(request, env, ctx);
|
|
582
|
+
},
|
|
583
|
+
} satisfies ExportedHandler<Env>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"types": ["@cloudflare/workers-types"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"resolveJsonModule": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts"],
|
|
16
|
+
"exclude": ["node_modules"]
|
|
17
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
3
|
+
"name": "{{PROJECT_NAME}}",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"compatibility_date": "2025-03-14",
|
|
6
|
+
"compatibility_flags": [
|
|
7
|
+
"nodejs_compat"
|
|
8
|
+
],
|
|
9
|
+
"d1_databases": [
|
|
10
|
+
{
|
|
11
|
+
"binding": "DB",
|
|
12
|
+
"database_name": "{{PROJECT_NAME}}-db",
|
|
13
|
+
"database_id": "{{DB_ID}}"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"durable_objects": {
|
|
17
|
+
"bindings": [
|
|
18
|
+
{
|
|
19
|
+
"name": "MULTICAST",
|
|
20
|
+
"class_name": "Multicast"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"migrations": [
|
|
25
|
+
{
|
|
26
|
+
"tag": "v1",
|
|
27
|
+
"new_sqlite_classes": [
|
|
28
|
+
"Multicast"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|