create-multicast 0.2.1 → 0.4.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 +259 -25
- package/package.json +1 -1
- package/templates/default/migrations/0001_init.sql +25 -0
- package/templates/default/src/index.ts +395 -21
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,8 @@ import { existsSync } from "node:fs";
|
|
|
7
7
|
import { resolve, join, dirname } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
10
12
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
13
|
const __dirname = dirname(__filename);
|
|
12
14
|
const TEMPLATES_DIR = resolve(__dirname, "..", "templates", "default");
|
|
@@ -70,6 +72,189 @@ async function processTemplates(targetDir, replacements) {
|
|
|
70
72
|
await unlink(tmplPath);
|
|
71
73
|
}
|
|
72
74
|
}
|
|
75
|
+
// ── OAuth Flow ───────────────────────────────────────────────
|
|
76
|
+
// Discovers OAuth metadata, registers client, opens browser for auth,
|
|
77
|
+
// receives callback with code, exchanges for tokens.
|
|
78
|
+
async function discoverOAuthMetadata(serverUrl) {
|
|
79
|
+
const baseUrl = new URL(serverUrl);
|
|
80
|
+
const wellKnownPaths = [
|
|
81
|
+
`${baseUrl.origin}/.well-known/oauth-authorization-server`,
|
|
82
|
+
`${baseUrl.origin}/.well-known/openid-configuration`,
|
|
83
|
+
];
|
|
84
|
+
for (const path of wellKnownPaths) {
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(path, { signal: AbortSignal.timeout(5000) });
|
|
87
|
+
if (res.ok) {
|
|
88
|
+
const metadata = (await res.json());
|
|
89
|
+
if (metadata.authorization_endpoint && metadata.token_endpoint) {
|
|
90
|
+
return metadata;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Try next
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
async function registerOAuthClient(registrationEndpoint, redirectUri) {
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(registrationEndpoint, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
client_name: "Multicast MCP Gateway",
|
|
107
|
+
redirect_uris: [redirectUri],
|
|
108
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
109
|
+
response_types: ["code"],
|
|
110
|
+
token_endpoint_auth_method: "none",
|
|
111
|
+
}),
|
|
112
|
+
signal: AbortSignal.timeout(10000),
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok)
|
|
115
|
+
return null;
|
|
116
|
+
return (await res.json());
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function generateCodeVerifier() {
|
|
123
|
+
return randomBytes(32).toString("base64url");
|
|
124
|
+
}
|
|
125
|
+
async function generateCodeChallenge(verifier) {
|
|
126
|
+
const { createHash } = await import("node:crypto");
|
|
127
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
128
|
+
}
|
|
129
|
+
function openBrowser(url) {
|
|
130
|
+
const cmd = process.platform === "darwin"
|
|
131
|
+
? "open"
|
|
132
|
+
: process.platform === "win32"
|
|
133
|
+
? "cmd"
|
|
134
|
+
: "xdg-open";
|
|
135
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
136
|
+
try {
|
|
137
|
+
spawnSync(cmd, args, { stdio: "ignore" });
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Browser open failed silently — URL is shown in terminal
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function runOAuthFlow(serverUrl, metadata) {
|
|
144
|
+
const port = 9876 + Math.floor(Math.random() * 100);
|
|
145
|
+
const redirectUri = `http://localhost:${port}/oauth/callback`;
|
|
146
|
+
// Dynamic Client Registration
|
|
147
|
+
let clientId = "multicast-mcp-gateway";
|
|
148
|
+
let clientSecret;
|
|
149
|
+
if (metadata.registration_endpoint) {
|
|
150
|
+
const reg = await registerOAuthClient(metadata.registration_endpoint, redirectUri);
|
|
151
|
+
if (reg) {
|
|
152
|
+
clientId = reg.client_id;
|
|
153
|
+
clientSecret = reg.client_secret;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// PKCE
|
|
157
|
+
const codeVerifier = generateCodeVerifier();
|
|
158
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
159
|
+
const state = randomBytes(16).toString("hex");
|
|
160
|
+
// Build authorization URL
|
|
161
|
+
const authUrl = new URL(metadata.authorization_endpoint);
|
|
162
|
+
authUrl.searchParams.set("response_type", "code");
|
|
163
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
164
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
165
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
166
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
167
|
+
authUrl.searchParams.set("state", state);
|
|
168
|
+
authUrl.searchParams.set("scope", "mcp");
|
|
169
|
+
authUrl.searchParams.set("resource", serverUrl);
|
|
170
|
+
return new Promise((resolve) => {
|
|
171
|
+
let resolved = false;
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
if (!resolved) {
|
|
174
|
+
resolved = true;
|
|
175
|
+
httpServer.close();
|
|
176
|
+
resolve(null);
|
|
177
|
+
}
|
|
178
|
+
}, 120000);
|
|
179
|
+
const httpServer = createServer(async (req, res) => {
|
|
180
|
+
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
181
|
+
if (url.pathname !== "/oauth/callback") {
|
|
182
|
+
res.writeHead(404);
|
|
183
|
+
res.end("Not found");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const code = url.searchParams.get("code");
|
|
187
|
+
const returnedState = url.searchParams.get("state");
|
|
188
|
+
const error = url.searchParams.get("error");
|
|
189
|
+
if (error || !code || returnedState !== state) {
|
|
190
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
191
|
+
res.end("<html><body><h2>Authorization failed</h2><p>You can close this window.</p></body></html>");
|
|
192
|
+
if (!resolved) {
|
|
193
|
+
resolved = true;
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
httpServer.close();
|
|
196
|
+
resolve(null);
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const tokenParams = new URLSearchParams({
|
|
202
|
+
grant_type: "authorization_code",
|
|
203
|
+
code,
|
|
204
|
+
redirect_uri: redirectUri,
|
|
205
|
+
client_id: clientId,
|
|
206
|
+
code_verifier: codeVerifier,
|
|
207
|
+
});
|
|
208
|
+
if (clientSecret)
|
|
209
|
+
tokenParams.set("client_secret", clientSecret);
|
|
210
|
+
const tokenRes = await fetch(metadata.token_endpoint, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
213
|
+
body: tokenParams.toString(),
|
|
214
|
+
});
|
|
215
|
+
if (!tokenRes.ok) {
|
|
216
|
+
res.writeHead(500, { "Content-Type": "text/html" });
|
|
217
|
+
res.end("<html><body><h2>Token exchange failed</h2><p>You can close this window.</p></body></html>");
|
|
218
|
+
if (!resolved) {
|
|
219
|
+
resolved = true;
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
httpServer.close();
|
|
222
|
+
resolve(null);
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const tokenData = (await tokenRes.json());
|
|
227
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
228
|
+
res.end("<html><body><h2>Authorized!</h2><p>You can close this window and return to the terminal.</p></body></html>");
|
|
229
|
+
if (!resolved) {
|
|
230
|
+
resolved = true;
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
httpServer.close();
|
|
233
|
+
resolve({
|
|
234
|
+
access_token: tokenData.access_token,
|
|
235
|
+
refresh_token: tokenData.refresh_token,
|
|
236
|
+
token_endpoint: metadata.token_endpoint,
|
|
237
|
+
client_id: clientId,
|
|
238
|
+
expires_at: Math.floor(Date.now() / 1000) + (tokenData.expires_in || 3600),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
res.writeHead(500, { "Content-Type": "text/html" });
|
|
244
|
+
res.end("<html><body><h2>Error</h2><p>You can close this window.</p></body></html>");
|
|
245
|
+
if (!resolved) {
|
|
246
|
+
resolved = true;
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
httpServer.close();
|
|
249
|
+
resolve(null);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
httpServer.listen(port, () => {
|
|
254
|
+
openBrowser(authUrl.toString());
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
73
258
|
// ── MCP Config Discovery ─────────────────────────────────────
|
|
74
259
|
// Scans known locations for existing MCP server configurations.
|
|
75
260
|
function getMcpConfigPaths() {
|
|
@@ -270,13 +455,17 @@ async function main() {
|
|
|
270
455
|
if (auth) {
|
|
271
456
|
// Auth found in existing config — use it
|
|
272
457
|
authStatus = "credentials found in config";
|
|
458
|
+
selectedServers.push({ name, url: server.url, auth });
|
|
273
459
|
}
|
|
274
460
|
else {
|
|
275
461
|
// No auth in config — probe the server
|
|
276
462
|
try {
|
|
277
463
|
const probe = await fetch(server.url, {
|
|
278
464
|
method: "POST",
|
|
279
|
-
headers: {
|
|
465
|
+
headers: {
|
|
466
|
+
"Content-Type": "application/json",
|
|
467
|
+
"Accept": "application/json, text/event-stream",
|
|
468
|
+
},
|
|
280
469
|
body: JSON.stringify({
|
|
281
470
|
jsonrpc: "2.0",
|
|
282
471
|
method: "initialize",
|
|
@@ -290,43 +479,61 @@ async function main() {
|
|
|
290
479
|
signal: AbortSignal.timeout(5000),
|
|
291
480
|
});
|
|
292
481
|
if (probe.status === 401 || probe.status === 403) {
|
|
293
|
-
// Server requires auth
|
|
482
|
+
// Server requires auth — check if it's OAuth
|
|
294
483
|
authStatus = "needs-auth";
|
|
295
484
|
}
|
|
296
485
|
else {
|
|
297
486
|
// Server responded without auth — no auth needed
|
|
298
487
|
authStatus = "no auth required";
|
|
488
|
+
selectedServers.push({ name, url: server.url });
|
|
299
489
|
}
|
|
300
490
|
}
|
|
301
491
|
catch {
|
|
302
|
-
// Can't reach server — assume no auth (will fail later with clear error)
|
|
303
492
|
authStatus = "unreachable (skipping auth)";
|
|
493
|
+
selectedServers.push({ name, url: server.url });
|
|
304
494
|
}
|
|
305
495
|
}
|
|
306
496
|
if (authStatus === "needs-auth") {
|
|
307
|
-
//
|
|
308
|
-
authSpinner.stop(`${pc.yellow("!")} ${name} requires authentication
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
497
|
+
// Check if server supports OAuth
|
|
498
|
+
authSpinner.stop(`${pc.yellow("!")} ${name} requires authentication. Checking for OAuth...`);
|
|
499
|
+
const oauthMetadata = await discoverOAuthMetadata(server.url);
|
|
500
|
+
if (oauthMetadata) {
|
|
501
|
+
// OAuth server — run the authorization flow
|
|
502
|
+
p.log.info(`${pc.bold(name)} uses OAuth. Opening browser for authorization...`);
|
|
503
|
+
p.log.info(pc.dim(`Authorization URL: ${oauthMetadata.authorization_endpoint}`));
|
|
504
|
+
const oauthResult = await runOAuthFlow(server.url, oauthMetadata);
|
|
505
|
+
if (oauthResult) {
|
|
506
|
+
p.log.success(`${name} authorized via OAuth.`);
|
|
507
|
+
selectedServers.push({
|
|
508
|
+
name,
|
|
509
|
+
url: server.url,
|
|
510
|
+
oauth: oauthResult,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
p.log.warn(`OAuth authorization failed for ${name}. Skipping.\n` +
|
|
515
|
+
` ${pc.dim("You can add it manually later or re-run the installer.")}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
// Not OAuth — ask for static auth header
|
|
520
|
+
const authResult = await p.text({
|
|
521
|
+
message: `Enter auth header for ${pc.bold(name)}:`,
|
|
522
|
+
placeholder: "Bearer your-token-here",
|
|
523
|
+
validate: (v) => {
|
|
524
|
+
if (!v.trim())
|
|
525
|
+
return "Auth header is required — server returned 401";
|
|
526
|
+
return undefined;
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
if (p.isCancel(authResult)) {
|
|
530
|
+
p.cancel("Setup cancelled.");
|
|
531
|
+
process.exit(0);
|
|
532
|
+
}
|
|
533
|
+
selectedServers.push({ name, url: server.url, auth: authResult });
|
|
321
534
|
}
|
|
322
|
-
auth = authResult;
|
|
323
535
|
authSpinner.start("Detecting authentication requirements...");
|
|
324
536
|
}
|
|
325
|
-
selectedServers.push({
|
|
326
|
-
name,
|
|
327
|
-
url: server.url,
|
|
328
|
-
auth: auth || undefined,
|
|
329
|
-
});
|
|
330
537
|
}
|
|
331
538
|
authSpinner.stop("Authentication configured.");
|
|
332
539
|
// Show auth summary
|
|
@@ -509,8 +716,35 @@ async function main() {
|
|
|
509
716
|
else {
|
|
510
717
|
p.log.success(`${server.name} URL set.`);
|
|
511
718
|
}
|
|
512
|
-
// Set auth
|
|
513
|
-
if (server.
|
|
719
|
+
// Set auth: static header OR OAuth tokens
|
|
720
|
+
if (server.oauth) {
|
|
721
|
+
// OAuth server — store tokens in D1 and set MCP_OAUTH_ flag
|
|
722
|
+
const oauthFlag = spawnSync("npx", ["wrangler", "secret", "put", `MCP_OAUTH_${envName}`], {
|
|
723
|
+
cwd: targetDir,
|
|
724
|
+
input: "true\n",
|
|
725
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
726
|
+
encoding: "utf-8",
|
|
727
|
+
});
|
|
728
|
+
if (oauthFlag.status !== 0) {
|
|
729
|
+
p.log.warn(`Failed to set OAuth flag for ${server.name}.`);
|
|
730
|
+
}
|
|
731
|
+
// Insert tokens into D1
|
|
732
|
+
const sql = `INSERT OR REPLACE INTO oauth_tokens (server_name, access_token, refresh_token, token_endpoint, client_id, expires_at) VALUES ('${server.name}', '${server.oauth.access_token}', ${server.oauth.refresh_token ? `'${server.oauth.refresh_token}'` : "NULL"}, '${server.oauth.token_endpoint}', '${server.oauth.client_id}', ${server.oauth.expires_at})`;
|
|
733
|
+
const dbInsert = spawnSync("npx", ["wrangler", "d1", "execute", `${projectName}-db`, "--remote", `--command=${sql}`], {
|
|
734
|
+
cwd: targetDir,
|
|
735
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
736
|
+
encoding: "utf-8",
|
|
737
|
+
});
|
|
738
|
+
if (dbInsert.status !== 0) {
|
|
739
|
+
p.log.warn(`Failed to store OAuth tokens for ${server.name} in D1.\n` +
|
|
740
|
+
` ${pc.dim("You may need to re-run the installer to authorize again.")}`);
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
p.log.success(`${server.name} OAuth tokens stored.`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
else if (server.auth) {
|
|
747
|
+
// Static auth header
|
|
514
748
|
const authResult = spawnSync("npx", ["wrangler", "secret", "put", `MCP_AUTH_${envName}`], {
|
|
515
749
|
cwd: targetDir,
|
|
516
750
|
input: server.auth + "\n",
|
package/package.json
CHANGED
|
@@ -28,6 +28,31 @@ CREATE TABLE IF NOT EXISTS tools (
|
|
|
28
28
|
UNIQUE(server_name, tool_name)
|
|
29
29
|
);
|
|
30
30
|
|
|
31
|
+
-- Result cache: temporary storage for large tool results
|
|
32
|
+
-- Used by the two-phase response pattern: multicast returns a reference,
|
|
33
|
+
-- fetch_result retrieves the full data. Auto-cleaned after 1 hour.
|
|
34
|
+
CREATE TABLE IF NOT EXISTS result_cache (
|
|
35
|
+
ref_id TEXT PRIMARY KEY,
|
|
36
|
+
server_name TEXT NOT NULL,
|
|
37
|
+
tool_name TEXT NOT NULL,
|
|
38
|
+
output TEXT NOT NULL,
|
|
39
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
-- OAuth tokens: cached access/refresh tokens for OAuth-based MCP servers
|
|
43
|
+
-- Installer stores initial tokens during setup. Worker refreshes automatically
|
|
44
|
+
-- when access_token expires using the refresh_token + token_endpoint.
|
|
45
|
+
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
|
46
|
+
server_name TEXT PRIMARY KEY,
|
|
47
|
+
access_token TEXT NOT NULL,
|
|
48
|
+
refresh_token TEXT,
|
|
49
|
+
token_endpoint TEXT NOT NULL,
|
|
50
|
+
client_id TEXT NOT NULL,
|
|
51
|
+
expires_at INTEGER NOT NULL, -- unix timestamp (seconds)
|
|
52
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
53
|
+
);
|
|
54
|
+
|
|
31
55
|
-- Indexes for common queries
|
|
32
56
|
CREATE INDEX IF NOT EXISTS idx_tools_server ON tools(server_name);
|
|
33
57
|
CREATE INDEX IF NOT EXISTS idx_servers_status ON servers(status);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_result_cache_created ON result_cache(created_at);
|
|
@@ -14,6 +14,16 @@ interface RegisteredServer {
|
|
|
14
14
|
name: string;
|
|
15
15
|
url: string;
|
|
16
16
|
auth?: string;
|
|
17
|
+
isOAuth?: boolean; // true if server uses OAuth (tokens in D1)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface OAuthTokenRow {
|
|
21
|
+
server_name: string;
|
|
22
|
+
access_token: string;
|
|
23
|
+
refresh_token: string | null;
|
|
24
|
+
token_endpoint: string;
|
|
25
|
+
client_id: string;
|
|
26
|
+
expires_at: number;
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
interface CallResult {
|
|
@@ -21,6 +31,9 @@ interface CallResult {
|
|
|
21
31
|
tool: string;
|
|
22
32
|
success: boolean;
|
|
23
33
|
output?: unknown;
|
|
34
|
+
output_ref?: string;
|
|
35
|
+
output_summary?: string;
|
|
36
|
+
output_size?: string;
|
|
24
37
|
error?: string;
|
|
25
38
|
duration_ms: number;
|
|
26
39
|
}
|
|
@@ -31,10 +44,15 @@ interface CachedTool {
|
|
|
31
44
|
input_schema: string;
|
|
32
45
|
}
|
|
33
46
|
|
|
47
|
+
// Max size (in chars) for inline results. Larger results get stored in D1
|
|
48
|
+
// and returned as a reference that can be fetched with fetch_result.
|
|
49
|
+
const INLINE_RESULT_MAX_CHARS = 5000;
|
|
50
|
+
|
|
34
51
|
// ── Server Registry ──────────────────────────────────────────
|
|
35
|
-
// Parses MCP_SERVER_
|
|
52
|
+
// Parses MCP_SERVER_*, MCP_AUTH_*, and MCP_OAUTH_* env vars at request time.
|
|
36
53
|
// MCP_SERVER_CONTEXT_HUB=https://... → server name: "context-hub"
|
|
37
|
-
// MCP_AUTH_CONTEXT_HUB=Bearer key... → auth header
|
|
54
|
+
// MCP_AUTH_CONTEXT_HUB=Bearer key... → static auth header
|
|
55
|
+
// MCP_OAUTH_NEOSAPIEN=true → OAuth server (tokens managed in D1)
|
|
38
56
|
|
|
39
57
|
function getRegisteredServers(env: Env): Map<string, RegisteredServer> {
|
|
40
58
|
const servers = new Map<string, RegisteredServer>();
|
|
@@ -44,23 +62,108 @@ function getRegisteredServers(env: Env): Map<string, RegisteredServer> {
|
|
|
44
62
|
const rawName = key.replace("MCP_SERVER_", "");
|
|
45
63
|
const name = rawName.toLowerCase().replace(/_/g, "-");
|
|
46
64
|
const authKey = `MCP_AUTH_${rawName}`;
|
|
65
|
+
const oauthKey = `MCP_OAUTH_${rawName}`;
|
|
47
66
|
const auth = typeof env[authKey] === "string" ? (env[authKey] as string) : undefined;
|
|
67
|
+
const isOAuth = typeof env[oauthKey] === "string" && env[oauthKey] === "true";
|
|
48
68
|
|
|
49
|
-
servers.set(name, { name, url: value, auth });
|
|
69
|
+
servers.set(name, { name, url: value, auth, isOAuth });
|
|
50
70
|
}
|
|
51
71
|
}
|
|
52
72
|
|
|
53
73
|
return servers;
|
|
54
74
|
}
|
|
55
75
|
|
|
76
|
+
// ── OAuth Token Management ───────────────────────────────────
|
|
77
|
+
// Resolves auth for OAuth servers: checks D1 for cached token,
|
|
78
|
+
// refreshes if expired, returns a valid Bearer header.
|
|
79
|
+
|
|
80
|
+
async function resolveOAuthToken(
|
|
81
|
+
serverName: string,
|
|
82
|
+
db: D1Database
|
|
83
|
+
): Promise<{ auth: string; error?: undefined } | { auth?: undefined; error: string }> {
|
|
84
|
+
const row = await db
|
|
85
|
+
.prepare("SELECT * FROM oauth_tokens WHERE server_name = ?")
|
|
86
|
+
.bind(serverName)
|
|
87
|
+
.first<OAuthTokenRow>();
|
|
88
|
+
|
|
89
|
+
if (!row) {
|
|
90
|
+
return { error: `no OAuth tokens found for "${serverName}". Re-run npx create-multicast to authorize.` };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
94
|
+
|
|
95
|
+
// Token still valid (with 60s buffer)
|
|
96
|
+
if (row.expires_at > nowSeconds + 60) {
|
|
97
|
+
return { auth: `Bearer ${row.access_token}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Token expired — try to refresh
|
|
101
|
+
if (!row.refresh_token) {
|
|
102
|
+
return { error: `OAuth token expired for "${serverName}" and no refresh token available. Re-run npx create-multicast to re-authorize.` };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const refreshResponse = await fetch(row.token_endpoint, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
109
|
+
body: new URLSearchParams({
|
|
110
|
+
grant_type: "refresh_token",
|
|
111
|
+
refresh_token: row.refresh_token,
|
|
112
|
+
client_id: row.client_id,
|
|
113
|
+
}).toString(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (!refreshResponse.ok) {
|
|
117
|
+
return { error: `OAuth token refresh failed for "${serverName}": HTTP ${refreshResponse.status}. Re-run npx create-multicast to re-authorize.` };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const tokenData = (await refreshResponse.json()) as {
|
|
121
|
+
access_token: string;
|
|
122
|
+
refresh_token?: string;
|
|
123
|
+
expires_in?: number;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const newExpiresAt = nowSeconds + (tokenData.expires_in || 3600);
|
|
127
|
+
|
|
128
|
+
// Update D1 with new tokens
|
|
129
|
+
await db
|
|
130
|
+
.prepare(
|
|
131
|
+
`UPDATE oauth_tokens
|
|
132
|
+
SET access_token = ?, refresh_token = ?, expires_at = ?, updated_at = datetime('now')
|
|
133
|
+
WHERE server_name = ?`
|
|
134
|
+
)
|
|
135
|
+
.bind(
|
|
136
|
+
tokenData.access_token,
|
|
137
|
+
tokenData.refresh_token || row.refresh_token,
|
|
138
|
+
newExpiresAt,
|
|
139
|
+
serverName
|
|
140
|
+
)
|
|
141
|
+
.run();
|
|
142
|
+
|
|
143
|
+
return { auth: `Bearer ${tokenData.access_token}` };
|
|
144
|
+
} catch (err: unknown) {
|
|
145
|
+
const message = err instanceof Error ? err.message : "unknown error";
|
|
146
|
+
return { error: `OAuth token refresh failed for "${serverName}": ${message}` };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
56
150
|
// ── Downstream MCP Client ────────────────────────────────────
|
|
57
|
-
// Calls a
|
|
151
|
+
// Calls a downstream MCP server via JSON-RPC over HTTP.
|
|
152
|
+
// Supports optional "setup" steps that run sequentially on the same
|
|
153
|
+
// session before the main tool call (e.g., select_workspace → list_services).
|
|
154
|
+
|
|
155
|
+
interface ToolStep {
|
|
156
|
+
tool: string;
|
|
157
|
+
args: Record<string, unknown>;
|
|
158
|
+
}
|
|
58
159
|
|
|
59
160
|
async function callMcpServer(
|
|
60
161
|
server: RegisteredServer,
|
|
61
162
|
tool: string,
|
|
62
163
|
args: Record<string, unknown>,
|
|
63
|
-
timeoutMs: number
|
|
164
|
+
timeoutMs: number,
|
|
165
|
+
setup?: ToolStep[],
|
|
166
|
+
db?: D1Database
|
|
64
167
|
): Promise<CallResult> {
|
|
65
168
|
const start = Date.now();
|
|
66
169
|
const controller = new AbortController();
|
|
@@ -71,7 +174,21 @@ async function callMcpServer(
|
|
|
71
174
|
"Content-Type": "application/json",
|
|
72
175
|
"Accept": "application/json, text/event-stream",
|
|
73
176
|
};
|
|
74
|
-
|
|
177
|
+
|
|
178
|
+
// Resolve auth: static header OR OAuth token from D1
|
|
179
|
+
if (server.isOAuth && db) {
|
|
180
|
+
const oauthResult = await resolveOAuthToken(server.name, db);
|
|
181
|
+
if (oauthResult.error) {
|
|
182
|
+
return {
|
|
183
|
+
server: server.name,
|
|
184
|
+
tool,
|
|
185
|
+
success: false,
|
|
186
|
+
error: oauthResult.error,
|
|
187
|
+
duration_ms: Date.now() - start,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
baseHeaders["Authorization"] = oauthResult.auth!;
|
|
191
|
+
} else if (server.auth) {
|
|
75
192
|
baseHeaders["Authorization"] = server.auth;
|
|
76
193
|
}
|
|
77
194
|
|
|
@@ -110,7 +227,54 @@ async function callMcpServer(
|
|
|
110
227
|
signal: controller.signal,
|
|
111
228
|
});
|
|
112
229
|
|
|
113
|
-
// Step 3:
|
|
230
|
+
// Step 3: Run setup commands sequentially on the same session
|
|
231
|
+
// (e.g., select_workspace before list_services)
|
|
232
|
+
if (setup && setup.length > 0) {
|
|
233
|
+
for (const step of setup) {
|
|
234
|
+
const setupResponse = await fetch(server.url, {
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: callHeaders,
|
|
237
|
+
body: JSON.stringify({
|
|
238
|
+
jsonrpc: "2.0",
|
|
239
|
+
method: "tools/call",
|
|
240
|
+
params: { name: step.tool, arguments: step.args },
|
|
241
|
+
id: "setup-" + crypto.randomUUID(),
|
|
242
|
+
}),
|
|
243
|
+
signal: controller.signal,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (!setupResponse.ok) {
|
|
247
|
+
return {
|
|
248
|
+
server: server.name,
|
|
249
|
+
tool,
|
|
250
|
+
success: false,
|
|
251
|
+
error: `setup step "${step.tool}" failed: HTTP ${setupResponse.status}`,
|
|
252
|
+
duration_ms: Date.now() - start,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const setupData = (await setupResponse.json()) as {
|
|
257
|
+
result?: { content?: Array<{ text?: string }>; isError?: boolean };
|
|
258
|
+
error?: { message?: string };
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
if (setupData.error || setupData.result?.isError) {
|
|
262
|
+
const errMsg =
|
|
263
|
+
setupData.error?.message ||
|
|
264
|
+
setupData.result?.content?.[0]?.text ||
|
|
265
|
+
`setup step "${step.tool}" failed`;
|
|
266
|
+
return {
|
|
267
|
+
server: server.name,
|
|
268
|
+
tool,
|
|
269
|
+
success: false,
|
|
270
|
+
error: `setup step "${step.tool}": ${errMsg}`,
|
|
271
|
+
duration_ms: Date.now() - start,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Step 4: Call the actual tool on the same session
|
|
114
278
|
const response = await fetch(server.url, {
|
|
115
279
|
method: "POST",
|
|
116
280
|
headers: callHeaders,
|
|
@@ -175,6 +339,46 @@ async function callMcpServer(
|
|
|
175
339
|
}
|
|
176
340
|
}
|
|
177
341
|
|
|
342
|
+
// ── Summary Generator ────────────────────────────────────────
|
|
343
|
+
// Creates a brief summary of large results so Claude can decide
|
|
344
|
+
// whether to fetch the full data.
|
|
345
|
+
|
|
346
|
+
function generateSummary(output: unknown, server: string, tool: string): string {
|
|
347
|
+
try {
|
|
348
|
+
// Handle MCP tool result format: { content: [{ type: "text", text: "..." }] }
|
|
349
|
+
const result = output as { content?: Array<{ text?: string }> };
|
|
350
|
+
const text = result?.content?.[0]?.text;
|
|
351
|
+
|
|
352
|
+
if (!text) return `Large result from ${server}/${tool}`;
|
|
353
|
+
|
|
354
|
+
// Try to parse as JSON array (common for list responses)
|
|
355
|
+
try {
|
|
356
|
+
const parsed = JSON.parse(text);
|
|
357
|
+
if (Array.isArray(parsed)) {
|
|
358
|
+
const names = parsed
|
|
359
|
+
.slice(0, 8)
|
|
360
|
+
.map((item: Record<string, unknown>) =>
|
|
361
|
+
(item.name as string) || (item.title as string) || (item.id as string) || "unnamed"
|
|
362
|
+
);
|
|
363
|
+
const suffix = parsed.length > 8 ? `, ... +${parsed.length - 8} more` : "";
|
|
364
|
+
return `${parsed.length} items: ${names.join(", ")}${suffix}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
368
|
+
const keys = Object.keys(parsed).slice(0, 5);
|
|
369
|
+
return `Object with keys: ${keys.join(", ")}${Object.keys(parsed).length > 5 ? " ..." : ""}`;
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// Not JSON — use text preview
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Plain text — return first 200 chars
|
|
376
|
+
return text.slice(0, 200) + (text.length > 200 ? "..." : "");
|
|
377
|
+
} catch {
|
|
378
|
+
return `Large result from ${server}/${tool}`;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
178
382
|
// ── Tool Discovery ───────────────────────────────────────────
|
|
179
383
|
// Calls tools/list on a downstream MCP server and caches results in D1.
|
|
180
384
|
|
|
@@ -429,13 +633,42 @@ Always returns partial results even if some calls fail.
|
|
|
429
633
|
|
|
430
634
|
Use list_servers first to discover available servers and their tools.
|
|
431
635
|
|
|
432
|
-
|
|
636
|
+
For stateful servers that need setup steps (e.g., Render's select_workspace),
|
|
637
|
+
use the "setup" field to chain commands that run sequentially on the same
|
|
638
|
+
session BEFORE the main tool call.
|
|
639
|
+
|
|
640
|
+
Example — simple parallel calls:
|
|
433
641
|
{
|
|
434
642
|
"calls": [
|
|
435
643
|
{ "server": "context-hub", "tool": "search_memories", "args": { "query": "project ideas" } },
|
|
436
644
|
{ "server": "supabase", "tool": "execute_sql", "args": { "sql": "SELECT count(*) FROM users" } }
|
|
437
645
|
]
|
|
438
|
-
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
Example — with setup steps for stateful servers:
|
|
649
|
+
{
|
|
650
|
+
"calls": [
|
|
651
|
+
{
|
|
652
|
+
"server": "render",
|
|
653
|
+
"tool": "list_services",
|
|
654
|
+
"args": {},
|
|
655
|
+
"setup": [
|
|
656
|
+
{ "tool": "select_workspace", "args": { "workspace_id": "tea-xxx" } }
|
|
657
|
+
]
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
"server": "render-personal",
|
|
661
|
+
"tool": "list_services",
|
|
662
|
+
"args": {},
|
|
663
|
+
"setup": [
|
|
664
|
+
{ "tool": "select_workspace", "args": { "workspace_id": "tea-yyy" } }
|
|
665
|
+
]
|
|
666
|
+
}
|
|
667
|
+
]
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
Setup steps run sequentially on the same session, then the main tool executes.
|
|
671
|
+
Different servers still run in parallel with each other.`,
|
|
439
672
|
{
|
|
440
673
|
calls: z
|
|
441
674
|
.array(
|
|
@@ -447,6 +680,19 @@ Example:
|
|
|
447
680
|
.optional()
|
|
448
681
|
.default({})
|
|
449
682
|
.describe("Arguments to pass to the tool"),
|
|
683
|
+
setup: z
|
|
684
|
+
.array(
|
|
685
|
+
z.object({
|
|
686
|
+
tool: z.string().describe("Setup tool to call first (e.g., select_workspace)"),
|
|
687
|
+
args: z
|
|
688
|
+
.record(z.string(), z.unknown())
|
|
689
|
+
.optional()
|
|
690
|
+
.default({})
|
|
691
|
+
.describe("Arguments for the setup tool"),
|
|
692
|
+
})
|
|
693
|
+
)
|
|
694
|
+
.optional()
|
|
695
|
+
.describe("Sequential setup steps to run on the same session before the main call"),
|
|
450
696
|
})
|
|
451
697
|
)
|
|
452
698
|
.min(1)
|
|
@@ -468,6 +714,7 @@ Example:
|
|
|
468
714
|
server: RegisteredServer;
|
|
469
715
|
tool: string;
|
|
470
716
|
args: Record<string, unknown>;
|
|
717
|
+
setup?: ToolStep[];
|
|
471
718
|
}> = [];
|
|
472
719
|
|
|
473
720
|
for (const call of calls) {
|
|
@@ -485,18 +732,32 @@ Example:
|
|
|
485
732
|
server,
|
|
486
733
|
tool: call.tool,
|
|
487
734
|
args: (call.args || {}) as Record<string, unknown>,
|
|
735
|
+
setup: call.setup?.map((s) => ({
|
|
736
|
+
tool: s.tool,
|
|
737
|
+
args: (s.args || {}) as Record<string, unknown>,
|
|
738
|
+
})),
|
|
488
739
|
});
|
|
489
740
|
}
|
|
490
741
|
}
|
|
491
742
|
|
|
492
743
|
// Execute all valid calls in parallel
|
|
744
|
+
// Each call gets its own session; setup steps run sequentially within that session
|
|
745
|
+
// Pass db for OAuth token resolution
|
|
746
|
+
const db = this.env.DB;
|
|
493
747
|
const promises = validCalls.map((call) =>
|
|
494
|
-
callMcpServer(call.server, call.tool, call.args, timeout)
|
|
748
|
+
callMcpServer(call.server, call.tool, call.args, timeout, call.setup, db)
|
|
495
749
|
);
|
|
496
750
|
|
|
497
751
|
const settled = await Promise.allSettled(promises);
|
|
498
752
|
|
|
499
|
-
|
|
753
|
+
// Clean expired cache entries (older than 1 hour)
|
|
754
|
+
await db
|
|
755
|
+
.prepare(
|
|
756
|
+
"DELETE FROM result_cache WHERE created_at < datetime('now', '-1 hour')"
|
|
757
|
+
)
|
|
758
|
+
.run();
|
|
759
|
+
|
|
760
|
+
const rawResults: CallResult[] = [
|
|
500
761
|
...validationErrors,
|
|
501
762
|
...settled.map((result, i) => {
|
|
502
763
|
if (result.status === "fulfilled") {
|
|
@@ -512,23 +773,68 @@ Example:
|
|
|
512
773
|
}),
|
|
513
774
|
];
|
|
514
775
|
|
|
776
|
+
// Two-phase response: inline small results, store large ones in D1
|
|
777
|
+
const results: CallResult[] = [];
|
|
778
|
+
let hasRefs = false;
|
|
779
|
+
|
|
780
|
+
for (const r of rawResults) {
|
|
781
|
+
if (!r.success || !r.output) {
|
|
782
|
+
results.push(r);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const outputStr = JSON.stringify(r.output);
|
|
787
|
+
|
|
788
|
+
if (outputStr.length <= INLINE_RESULT_MAX_CHARS) {
|
|
789
|
+
// Small result — include inline
|
|
790
|
+
results.push(r);
|
|
791
|
+
} else {
|
|
792
|
+
// Large result — store in D1, return reference
|
|
793
|
+
const refId = `ref_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
794
|
+
hasRefs = true;
|
|
795
|
+
|
|
796
|
+
await db
|
|
797
|
+
.prepare(
|
|
798
|
+
"INSERT INTO result_cache (ref_id, server_name, tool_name, output) VALUES (?, ?, ?, ?)"
|
|
799
|
+
)
|
|
800
|
+
.bind(refId, r.server, r.tool, outputStr)
|
|
801
|
+
.run();
|
|
802
|
+
|
|
803
|
+
// Generate a brief summary from the output
|
|
804
|
+
const summary = generateSummary(r.output, r.server, r.tool);
|
|
805
|
+
|
|
806
|
+
results.push({
|
|
807
|
+
server: r.server,
|
|
808
|
+
tool: r.tool,
|
|
809
|
+
success: true,
|
|
810
|
+
output_ref: refId,
|
|
811
|
+
output_summary: summary,
|
|
812
|
+
output_size: `${Math.round(outputStr.length / 1024)}KB`,
|
|
813
|
+
duration_ms: r.duration_ms,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
515
818
|
const completed = results.filter((r) => r.success).length;
|
|
516
819
|
const failed = results.filter((r) => !r.success).length;
|
|
517
820
|
|
|
821
|
+
const response: Record<string, unknown> = {
|
|
822
|
+
results,
|
|
823
|
+
total_ms: Date.now() - totalStart,
|
|
824
|
+
completed,
|
|
825
|
+
failed,
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
if (hasRefs) {
|
|
829
|
+
response.note =
|
|
830
|
+
"Some results were too large to include inline. Use fetch_result with the output_ref to retrieve full data.";
|
|
831
|
+
}
|
|
832
|
+
|
|
518
833
|
return {
|
|
519
834
|
content: [
|
|
520
835
|
{
|
|
521
836
|
type: "text" as const,
|
|
522
|
-
text: JSON.stringify(
|
|
523
|
-
{
|
|
524
|
-
results,
|
|
525
|
-
total_ms: Date.now() - totalStart,
|
|
526
|
-
completed,
|
|
527
|
-
failed,
|
|
528
|
-
},
|
|
529
|
-
null,
|
|
530
|
-
2
|
|
531
|
-
),
|
|
837
|
+
text: JSON.stringify(response, null, 2),
|
|
532
838
|
},
|
|
533
839
|
],
|
|
534
840
|
};
|
|
@@ -573,6 +879,74 @@ Clears the cache and re-fetches tools/list from every registered server.`,
|
|
|
573
879
|
};
|
|
574
880
|
}
|
|
575
881
|
);
|
|
882
|
+
|
|
883
|
+
// ── Tool: fetch_result ──────────────────────────────────
|
|
884
|
+
|
|
885
|
+
this.server.tool(
|
|
886
|
+
"fetch_result",
|
|
887
|
+
`Retrieve the full output of a large result that was stored by reference.
|
|
888
|
+
When multicast returns an output_ref instead of inline output, call this
|
|
889
|
+
tool with the ref ID to get the complete data. Results expire after 1 hour.`,
|
|
890
|
+
{
|
|
891
|
+
ref: z
|
|
892
|
+
.string()
|
|
893
|
+
.describe("The output_ref ID from a multicast result (e.g., ref_abc123def456)"),
|
|
894
|
+
},
|
|
895
|
+
async ({ ref }) => {
|
|
896
|
+
const db = this.env.DB;
|
|
897
|
+
|
|
898
|
+
const row = await db
|
|
899
|
+
.prepare(
|
|
900
|
+
"SELECT server_name, tool_name, output, created_at FROM result_cache WHERE ref_id = ?"
|
|
901
|
+
)
|
|
902
|
+
.bind(ref)
|
|
903
|
+
.first<{
|
|
904
|
+
server_name: string;
|
|
905
|
+
tool_name: string;
|
|
906
|
+
output: string;
|
|
907
|
+
created_at: string;
|
|
908
|
+
}>();
|
|
909
|
+
|
|
910
|
+
if (!row) {
|
|
911
|
+
return {
|
|
912
|
+
content: [
|
|
913
|
+
{
|
|
914
|
+
type: "text" as const,
|
|
915
|
+
text: JSON.stringify({
|
|
916
|
+
error: `Result not found for ref "${ref}". It may have expired (results are kept for 1 hour).`,
|
|
917
|
+
}),
|
|
918
|
+
},
|
|
919
|
+
],
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Parse the stored output back
|
|
924
|
+
let parsedOutput: unknown;
|
|
925
|
+
try {
|
|
926
|
+
parsedOutput = JSON.parse(row.output);
|
|
927
|
+
} catch {
|
|
928
|
+
parsedOutput = row.output;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
return {
|
|
932
|
+
content: [
|
|
933
|
+
{
|
|
934
|
+
type: "text" as const,
|
|
935
|
+
text: JSON.stringify(
|
|
936
|
+
{
|
|
937
|
+
server: row.server_name,
|
|
938
|
+
tool: row.tool_name,
|
|
939
|
+
output: parsedOutput,
|
|
940
|
+
cached_at: row.created_at,
|
|
941
|
+
},
|
|
942
|
+
null,
|
|
943
|
+
2
|
|
944
|
+
),
|
|
945
|
+
},
|
|
946
|
+
],
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
);
|
|
576
950
|
}
|
|
577
951
|
}
|
|
578
952
|
|