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 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,8 @@
1
+ node_modules/
2
+ dist/
3
+ .wrangler/
4
+ .dev.vars
5
+ *.log
6
+ .DS_Store
7
+ .env
8
+ .env.local
@@ -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
+ }