create-multicast 0.2.0 → 0.3.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/package.json
CHANGED
|
@@ -28,6 +28,18 @@ 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
|
+
|
|
31
42
|
-- Indexes for common queries
|
|
32
43
|
CREATE INDEX IF NOT EXISTS idx_tools_server ON tools(server_name);
|
|
33
44
|
CREATE INDEX IF NOT EXISTS idx_servers_status ON servers(status);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_result_cache_created ON result_cache(created_at);
|
|
@@ -21,6 +21,9 @@ interface CallResult {
|
|
|
21
21
|
tool: string;
|
|
22
22
|
success: boolean;
|
|
23
23
|
output?: unknown;
|
|
24
|
+
output_ref?: string;
|
|
25
|
+
output_summary?: string;
|
|
26
|
+
output_size?: string;
|
|
24
27
|
error?: string;
|
|
25
28
|
duration_ms: number;
|
|
26
29
|
}
|
|
@@ -31,6 +34,10 @@ interface CachedTool {
|
|
|
31
34
|
input_schema: string;
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
// Max size (in chars) for inline results. Larger results get stored in D1
|
|
38
|
+
// and returned as a reference that can be fetched with fetch_result.
|
|
39
|
+
const INLINE_RESULT_MAX_CHARS = 5000;
|
|
40
|
+
|
|
34
41
|
// ── Server Registry ──────────────────────────────────────────
|
|
35
42
|
// Parses MCP_SERVER_* and MCP_AUTH_* env vars at request time.
|
|
36
43
|
// MCP_SERVER_CONTEXT_HUB=https://... → server name: "context-hub"
|
|
@@ -54,13 +61,21 @@ function getRegisteredServers(env: Env): Map<string, RegisteredServer> {
|
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
// ── Downstream MCP Client ────────────────────────────────────
|
|
57
|
-
// Calls a
|
|
64
|
+
// Calls a downstream MCP server via JSON-RPC over HTTP.
|
|
65
|
+
// Supports optional "setup" steps that run sequentially on the same
|
|
66
|
+
// session before the main tool call (e.g., select_workspace → list_services).
|
|
67
|
+
|
|
68
|
+
interface ToolStep {
|
|
69
|
+
tool: string;
|
|
70
|
+
args: Record<string, unknown>;
|
|
71
|
+
}
|
|
58
72
|
|
|
59
73
|
async function callMcpServer(
|
|
60
74
|
server: RegisteredServer,
|
|
61
75
|
tool: string,
|
|
62
76
|
args: Record<string, unknown>,
|
|
63
|
-
timeoutMs: number
|
|
77
|
+
timeoutMs: number,
|
|
78
|
+
setup?: ToolStep[]
|
|
64
79
|
): Promise<CallResult> {
|
|
65
80
|
const start = Date.now();
|
|
66
81
|
const controller = new AbortController();
|
|
@@ -110,7 +125,54 @@ async function callMcpServer(
|
|
|
110
125
|
signal: controller.signal,
|
|
111
126
|
});
|
|
112
127
|
|
|
113
|
-
// Step 3:
|
|
128
|
+
// Step 3: Run setup commands sequentially on the same session
|
|
129
|
+
// (e.g., select_workspace before list_services)
|
|
130
|
+
if (setup && setup.length > 0) {
|
|
131
|
+
for (const step of setup) {
|
|
132
|
+
const setupResponse = await fetch(server.url, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: callHeaders,
|
|
135
|
+
body: JSON.stringify({
|
|
136
|
+
jsonrpc: "2.0",
|
|
137
|
+
method: "tools/call",
|
|
138
|
+
params: { name: step.tool, arguments: step.args },
|
|
139
|
+
id: "setup-" + crypto.randomUUID(),
|
|
140
|
+
}),
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (!setupResponse.ok) {
|
|
145
|
+
return {
|
|
146
|
+
server: server.name,
|
|
147
|
+
tool,
|
|
148
|
+
success: false,
|
|
149
|
+
error: `setup step "${step.tool}" failed: HTTP ${setupResponse.status}`,
|
|
150
|
+
duration_ms: Date.now() - start,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const setupData = (await setupResponse.json()) as {
|
|
155
|
+
result?: { content?: Array<{ text?: string }>; isError?: boolean };
|
|
156
|
+
error?: { message?: string };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (setupData.error || setupData.result?.isError) {
|
|
160
|
+
const errMsg =
|
|
161
|
+
setupData.error?.message ||
|
|
162
|
+
setupData.result?.content?.[0]?.text ||
|
|
163
|
+
`setup step "${step.tool}" failed`;
|
|
164
|
+
return {
|
|
165
|
+
server: server.name,
|
|
166
|
+
tool,
|
|
167
|
+
success: false,
|
|
168
|
+
error: `setup step "${step.tool}": ${errMsg}`,
|
|
169
|
+
duration_ms: Date.now() - start,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Step 4: Call the actual tool on the same session
|
|
114
176
|
const response = await fetch(server.url, {
|
|
115
177
|
method: "POST",
|
|
116
178
|
headers: callHeaders,
|
|
@@ -175,6 +237,46 @@ async function callMcpServer(
|
|
|
175
237
|
}
|
|
176
238
|
}
|
|
177
239
|
|
|
240
|
+
// ── Summary Generator ────────────────────────────────────────
|
|
241
|
+
// Creates a brief summary of large results so Claude can decide
|
|
242
|
+
// whether to fetch the full data.
|
|
243
|
+
|
|
244
|
+
function generateSummary(output: unknown, server: string, tool: string): string {
|
|
245
|
+
try {
|
|
246
|
+
// Handle MCP tool result format: { content: [{ type: "text", text: "..." }] }
|
|
247
|
+
const result = output as { content?: Array<{ text?: string }> };
|
|
248
|
+
const text = result?.content?.[0]?.text;
|
|
249
|
+
|
|
250
|
+
if (!text) return `Large result from ${server}/${tool}`;
|
|
251
|
+
|
|
252
|
+
// Try to parse as JSON array (common for list responses)
|
|
253
|
+
try {
|
|
254
|
+
const parsed = JSON.parse(text);
|
|
255
|
+
if (Array.isArray(parsed)) {
|
|
256
|
+
const names = parsed
|
|
257
|
+
.slice(0, 8)
|
|
258
|
+
.map((item: Record<string, unknown>) =>
|
|
259
|
+
(item.name as string) || (item.title as string) || (item.id as string) || "unnamed"
|
|
260
|
+
);
|
|
261
|
+
const suffix = parsed.length > 8 ? `, ... +${parsed.length - 8} more` : "";
|
|
262
|
+
return `${parsed.length} items: ${names.join(", ")}${suffix}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
266
|
+
const keys = Object.keys(parsed).slice(0, 5);
|
|
267
|
+
return `Object with keys: ${keys.join(", ")}${Object.keys(parsed).length > 5 ? " ..." : ""}`;
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// Not JSON — use text preview
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Plain text — return first 200 chars
|
|
274
|
+
return text.slice(0, 200) + (text.length > 200 ? "..." : "");
|
|
275
|
+
} catch {
|
|
276
|
+
return `Large result from ${server}/${tool}`;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
178
280
|
// ── Tool Discovery ───────────────────────────────────────────
|
|
179
281
|
// Calls tools/list on a downstream MCP server and caches results in D1.
|
|
180
282
|
|
|
@@ -429,13 +531,42 @@ Always returns partial results even if some calls fail.
|
|
|
429
531
|
|
|
430
532
|
Use list_servers first to discover available servers and their tools.
|
|
431
533
|
|
|
432
|
-
|
|
534
|
+
For stateful servers that need setup steps (e.g., Render's select_workspace),
|
|
535
|
+
use the "setup" field to chain commands that run sequentially on the same
|
|
536
|
+
session BEFORE the main tool call.
|
|
537
|
+
|
|
538
|
+
Example — simple parallel calls:
|
|
433
539
|
{
|
|
434
540
|
"calls": [
|
|
435
541
|
{ "server": "context-hub", "tool": "search_memories", "args": { "query": "project ideas" } },
|
|
436
542
|
{ "server": "supabase", "tool": "execute_sql", "args": { "sql": "SELECT count(*) FROM users" } }
|
|
437
543
|
]
|
|
438
|
-
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
Example — with setup steps for stateful servers:
|
|
547
|
+
{
|
|
548
|
+
"calls": [
|
|
549
|
+
{
|
|
550
|
+
"server": "render",
|
|
551
|
+
"tool": "list_services",
|
|
552
|
+
"args": {},
|
|
553
|
+
"setup": [
|
|
554
|
+
{ "tool": "select_workspace", "args": { "workspace_id": "tea-xxx" } }
|
|
555
|
+
]
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
"server": "render-personal",
|
|
559
|
+
"tool": "list_services",
|
|
560
|
+
"args": {},
|
|
561
|
+
"setup": [
|
|
562
|
+
{ "tool": "select_workspace", "args": { "workspace_id": "tea-yyy" } }
|
|
563
|
+
]
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
Setup steps run sequentially on the same session, then the main tool executes.
|
|
569
|
+
Different servers still run in parallel with each other.`,
|
|
439
570
|
{
|
|
440
571
|
calls: z
|
|
441
572
|
.array(
|
|
@@ -447,6 +578,19 @@ Example:
|
|
|
447
578
|
.optional()
|
|
448
579
|
.default({})
|
|
449
580
|
.describe("Arguments to pass to the tool"),
|
|
581
|
+
setup: z
|
|
582
|
+
.array(
|
|
583
|
+
z.object({
|
|
584
|
+
tool: z.string().describe("Setup tool to call first (e.g., select_workspace)"),
|
|
585
|
+
args: z
|
|
586
|
+
.record(z.string(), z.unknown())
|
|
587
|
+
.optional()
|
|
588
|
+
.default({})
|
|
589
|
+
.describe("Arguments for the setup tool"),
|
|
590
|
+
})
|
|
591
|
+
)
|
|
592
|
+
.optional()
|
|
593
|
+
.describe("Sequential setup steps to run on the same session before the main call"),
|
|
450
594
|
})
|
|
451
595
|
)
|
|
452
596
|
.min(1)
|
|
@@ -468,6 +612,7 @@ Example:
|
|
|
468
612
|
server: RegisteredServer;
|
|
469
613
|
tool: string;
|
|
470
614
|
args: Record<string, unknown>;
|
|
615
|
+
setup?: ToolStep[];
|
|
471
616
|
}> = [];
|
|
472
617
|
|
|
473
618
|
for (const call of calls) {
|
|
@@ -485,18 +630,32 @@ Example:
|
|
|
485
630
|
server,
|
|
486
631
|
tool: call.tool,
|
|
487
632
|
args: (call.args || {}) as Record<string, unknown>,
|
|
633
|
+
setup: call.setup?.map((s) => ({
|
|
634
|
+
tool: s.tool,
|
|
635
|
+
args: (s.args || {}) as Record<string, unknown>,
|
|
636
|
+
})),
|
|
488
637
|
});
|
|
489
638
|
}
|
|
490
639
|
}
|
|
491
640
|
|
|
492
641
|
// Execute all valid calls in parallel
|
|
642
|
+
// Each call gets its own session; setup steps run sequentially within that session
|
|
493
643
|
const promises = validCalls.map((call) =>
|
|
494
|
-
callMcpServer(call.server, call.tool, call.args, timeout)
|
|
644
|
+
callMcpServer(call.server, call.tool, call.args, timeout, call.setup)
|
|
495
645
|
);
|
|
496
646
|
|
|
497
647
|
const settled = await Promise.allSettled(promises);
|
|
498
648
|
|
|
499
|
-
const
|
|
649
|
+
const db = this.env.DB;
|
|
650
|
+
|
|
651
|
+
// Clean expired cache entries (older than 1 hour)
|
|
652
|
+
await db
|
|
653
|
+
.prepare(
|
|
654
|
+
"DELETE FROM result_cache WHERE created_at < datetime('now', '-1 hour')"
|
|
655
|
+
)
|
|
656
|
+
.run();
|
|
657
|
+
|
|
658
|
+
const rawResults: CallResult[] = [
|
|
500
659
|
...validationErrors,
|
|
501
660
|
...settled.map((result, i) => {
|
|
502
661
|
if (result.status === "fulfilled") {
|
|
@@ -512,23 +671,68 @@ Example:
|
|
|
512
671
|
}),
|
|
513
672
|
];
|
|
514
673
|
|
|
674
|
+
// Two-phase response: inline small results, store large ones in D1
|
|
675
|
+
const results: CallResult[] = [];
|
|
676
|
+
let hasRefs = false;
|
|
677
|
+
|
|
678
|
+
for (const r of rawResults) {
|
|
679
|
+
if (!r.success || !r.output) {
|
|
680
|
+
results.push(r);
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const outputStr = JSON.stringify(r.output);
|
|
685
|
+
|
|
686
|
+
if (outputStr.length <= INLINE_RESULT_MAX_CHARS) {
|
|
687
|
+
// Small result — include inline
|
|
688
|
+
results.push(r);
|
|
689
|
+
} else {
|
|
690
|
+
// Large result — store in D1, return reference
|
|
691
|
+
const refId = `ref_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
692
|
+
hasRefs = true;
|
|
693
|
+
|
|
694
|
+
await db
|
|
695
|
+
.prepare(
|
|
696
|
+
"INSERT INTO result_cache (ref_id, server_name, tool_name, output) VALUES (?, ?, ?, ?)"
|
|
697
|
+
)
|
|
698
|
+
.bind(refId, r.server, r.tool, outputStr)
|
|
699
|
+
.run();
|
|
700
|
+
|
|
701
|
+
// Generate a brief summary from the output
|
|
702
|
+
const summary = generateSummary(r.output, r.server, r.tool);
|
|
703
|
+
|
|
704
|
+
results.push({
|
|
705
|
+
server: r.server,
|
|
706
|
+
tool: r.tool,
|
|
707
|
+
success: true,
|
|
708
|
+
output_ref: refId,
|
|
709
|
+
output_summary: summary,
|
|
710
|
+
output_size: `${Math.round(outputStr.length / 1024)}KB`,
|
|
711
|
+
duration_ms: r.duration_ms,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
515
716
|
const completed = results.filter((r) => r.success).length;
|
|
516
717
|
const failed = results.filter((r) => !r.success).length;
|
|
517
718
|
|
|
719
|
+
const response: Record<string, unknown> = {
|
|
720
|
+
results,
|
|
721
|
+
total_ms: Date.now() - totalStart,
|
|
722
|
+
completed,
|
|
723
|
+
failed,
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
if (hasRefs) {
|
|
727
|
+
response.note =
|
|
728
|
+
"Some results were too large to include inline. Use fetch_result with the output_ref to retrieve full data.";
|
|
729
|
+
}
|
|
730
|
+
|
|
518
731
|
return {
|
|
519
732
|
content: [
|
|
520
733
|
{
|
|
521
734
|
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
|
-
),
|
|
735
|
+
text: JSON.stringify(response, null, 2),
|
|
532
736
|
},
|
|
533
737
|
],
|
|
534
738
|
};
|
|
@@ -573,6 +777,74 @@ Clears the cache and re-fetches tools/list from every registered server.`,
|
|
|
573
777
|
};
|
|
574
778
|
}
|
|
575
779
|
);
|
|
780
|
+
|
|
781
|
+
// ── Tool: fetch_result ──────────────────────────────────
|
|
782
|
+
|
|
783
|
+
this.server.tool(
|
|
784
|
+
"fetch_result",
|
|
785
|
+
`Retrieve the full output of a large result that was stored by reference.
|
|
786
|
+
When multicast returns an output_ref instead of inline output, call this
|
|
787
|
+
tool with the ref ID to get the complete data. Results expire after 1 hour.`,
|
|
788
|
+
{
|
|
789
|
+
ref: z
|
|
790
|
+
.string()
|
|
791
|
+
.describe("The output_ref ID from a multicast result (e.g., ref_abc123def456)"),
|
|
792
|
+
},
|
|
793
|
+
async ({ ref }) => {
|
|
794
|
+
const db = this.env.DB;
|
|
795
|
+
|
|
796
|
+
const row = await db
|
|
797
|
+
.prepare(
|
|
798
|
+
"SELECT server_name, tool_name, output, created_at FROM result_cache WHERE ref_id = ?"
|
|
799
|
+
)
|
|
800
|
+
.bind(ref)
|
|
801
|
+
.first<{
|
|
802
|
+
server_name: string;
|
|
803
|
+
tool_name: string;
|
|
804
|
+
output: string;
|
|
805
|
+
created_at: string;
|
|
806
|
+
}>();
|
|
807
|
+
|
|
808
|
+
if (!row) {
|
|
809
|
+
return {
|
|
810
|
+
content: [
|
|
811
|
+
{
|
|
812
|
+
type: "text" as const,
|
|
813
|
+
text: JSON.stringify({
|
|
814
|
+
error: `Result not found for ref "${ref}". It may have expired (results are kept for 1 hour).`,
|
|
815
|
+
}),
|
|
816
|
+
},
|
|
817
|
+
],
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Parse the stored output back
|
|
822
|
+
let parsedOutput: unknown;
|
|
823
|
+
try {
|
|
824
|
+
parsedOutput = JSON.parse(row.output);
|
|
825
|
+
} catch {
|
|
826
|
+
parsedOutput = row.output;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return {
|
|
830
|
+
content: [
|
|
831
|
+
{
|
|
832
|
+
type: "text" as const,
|
|
833
|
+
text: JSON.stringify(
|
|
834
|
+
{
|
|
835
|
+
server: row.server_name,
|
|
836
|
+
tool: row.tool_name,
|
|
837
|
+
output: parsedOutput,
|
|
838
|
+
cached_at: row.created_at,
|
|
839
|
+
},
|
|
840
|
+
null,
|
|
841
|
+
2
|
|
842
|
+
),
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
);
|
|
576
848
|
}
|
|
577
849
|
}
|
|
578
850
|
|