History

Implement MCP gateway and enhance development environment

- Added support for the Executor MCP gateway on port 3003, allowing for stateful elicitation/sampling over Streamable HTTP. - Updated development script to include the MCP gateway service and its environment variables. - Enhanced README documentation to guide users on running the MCP gateway and its configuration options. - Improved UI components to support new account deletion functionality, including confirmation prompts and error handling. - Refactored type handling in the executor to support new features and improve type fidelity across the application.

Rhys Sullivan committed 9 months ago

Commit f8deaef1 parent 44a2ad723 files · +2188 −145Browse files

Unified · Split

23 files
  1. dev.ts +14 −3
  2. executor/README.md +8 −0
  3. executor/apps/web/src/components/app-shell.tsx +141 −37
  4. executor/apps/web/src/components/mcp-setup-card.tsx +32 −1
  5. executor/convex/_generated/api.d.ts +14 −0
  6. executor/convex/accounts.ts +229 −0
  7. executor/convex/executor-mcp.e2e.test.ts +63 −0
  8. executor/convex/executorNode.ts +8 −2
  9. executor/convex/http.ts +89 −31
  10. executor/convex/workspace.ts +14 −0
  11. executor/lib/mcp-elicitation.demo.test.ts +95 −0
  12. executor/lib/mcp-server.test.ts +64 −0
  13. executor/lib/mcp_server.ts +283 −12
  14. executor/lib/openapi-cache.test.ts +54 −0
  15. executor/lib/openapi-real-specs.test.ts +26 −0
  16. executor/lib/tool_sources.ts +129 −24
  17. executor/lib/typechecker.test.ts +197 −22
  18. executor/lib/typechecker.ts +124 −11
  19. executor/lib/workspace-tool-cache.test.ts +89 −0
  20. executor/mcp-gateway.ts +351 −0
  21. executor/package.json +3 −1
  22. executor/tsconfig.json +3 −1
  23. executor/vscode-elicitation-server.ts +158 −0
dev.ts+14−3
@@ -8,8 +8,9 @@ · 7 unmodified lines
8 * Starts:8 * Starts:
9 * 1. Convex cloud dev function watcher ─┐9 * 1. Convex cloud dev function watcher ─┐
10 * 2. Executor web UI (port 3002) ├─ all started concurrently10 * 2. Executor web UI (port 3002) ├─ all started concurrently
11 * 3. Assistant server (port 3000) │11 * 3. Executor MCP gateway (port 3003) │
12 * 4. Discord bot ─┘12 * 4. Assistant server (port 3000) │
13 * 5. Discord bot ─┘
13 *14 *
14 * All processes are killed when this script exits (Ctrl+C).15 * All processes are killed when this script exits (Ctrl+C).
15 * PIDs are written to .dev.pids for use with `bun run kill:all`.16 * PIDs are written to .dev.pids for use with `bun run kill:all`.
@@ -23,6 +24,7 @@ const PID_FILE = join(import.meta.dir, ".dev.pids"); · 7 unmodified lines
23const colors = {24const colors = {
24 convex: "\x1b[36m", // cyan25 convex: "\x1b[36m", // cyan
25 web: "\x1b[34m", // blue26 web: "\x1b[34m", // blue
27 mcp: "\x1b[33m", // yellow
26 assistant: "\x1b[32m", // green28 assistant: "\x1b[32m", // green
27 bot: "\x1b[35m", // magenta29 bot: "\x1b[35m", // magenta
28 reset: "\x1b[0m",30 reset: "\x1b[0m",
@@ -116,7 +118,7 @@ process.on("SIGTERM", shutdown); · 87 unmodified lines
116 118
117// ── Kill stale processes from a previous run ──119// ── Kill stale processes from a previous run ──
118 120
119const DEV_PORTS = [3000, 3002];121const DEV_PORTS = [3000, 3002, 3003];
120 122
121async function killStaleProcesses() {123async function killStaleProcesses() {
122 let killed = 0;124 let killed = 0;
@@ -176,6 +178,15 @@ spawnService("web", ["bun", "run", "dev", "--", "-p", "3002"], { · 53 unmodified lines
176 cwd: "./executor/apps/web",178 cwd: "./executor/apps/web",
177});179});
178 180
181spawnService("mcp", ["bun", "run", "dev:mcp-gateway"], {
182 cwd: "./executor",
183 env: {
184 MCP_GATEWAY_REQUIRE_AUTH: "0",
185 MCP_AUTHORIZATION_SERVER: "",
186 MCP_AUTHORIZATION_SERVER_URL: "",
187 },
188});
189
179spawnService("assistant", ["bun", "run", "dev"], {190spawnService("assistant", ["bun", "run", "dev"], {
180 cwd: "./assistant/packages/server",191 cwd: "./assistant/packages/server",
181 env: {192 env: {
executor/README.md+8−0
@@ -27,6 +27,12 @@ Terminal 2: · 26 unmodified lines
27bun run dev:web27bun run dev:web
28```28```
29 29
30Optional Terminal 3 (stateful MCP gateway for elicitation/sampling over Streamable HTTP):
31
32```bash
33bun run dev:mcp-gateway
34```
35
30## Tests36## Tests
31 37
32```bash38```bash
@@ -36,6 +42,8 @@ bun test convex/database.test.ts convex/executor-mcp.e2e.test.ts · 3 unmodified lines
36## Notes42## Notes
37 43
38- MCP endpoint is served by Convex HTTP routes at `/mcp`.44- MCP endpoint is served by Convex HTTP routes at `/mcp`.
45- For a long-lived stateful MCP transport (recommended for elicitation in multi-worker environments), run the gateway at `http://localhost:3003/mcp`.
46- Local gateway auth is optional by default; set `MCP_GATEWAY_REQUIRE_AUTH=1` to enforce MCP OAuth on the gateway.
39- Set `MCP_AUTHORIZATION_SERVER` (or `MCP_AUTHORIZATION_SERVER_URL`) to enable MCP OAuth bearer-token verification.47- Set `MCP_AUTHORIZATION_SERVER` (or `MCP_AUTHORIZATION_SERVER_URL`) to enable MCP OAuth bearer-token verification.
40- When MCP OAuth is enabled, the server exposes `/.well-known/oauth-protected-resource` and proxies `/.well-known/oauth-authorization-server`.48- When MCP OAuth is enabled, the server exposes `/.well-known/oauth-protected-resource` and proxies `/.well-known/oauth-authorization-server`.
41- Internal runtime callback routes are served by Convex HTTP routes at `/internal/runs/:runId/*`.49- Internal runtime callback routes are served by Convex HTTP routes at `/internal/runs/:runId/*`.
executor/apps/web/src/components/app-shell.tsx+141−37
@@ -399,11 +399,25 @@ function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) { · 398 unmodified lines
399}399}
400 400
401function SessionInfo() {401function SessionInfo() {
402 const { loading, clientConfig, isSignedInToWorkos, workosProfile, context, workspaces } = useSession();402 const {
403 loading,
404 clientConfig,
405 isSignedInToWorkos,
406 workosProfile,
407 context,
408 workspaces,
409 resetWorkspace,
410 } = useSession();
403 const searchParams = useSearchParams();411 const searchParams = useSearchParams();
412 const deleteCurrentAccountMutation = useMutation(convexApi.accounts.deleteCurrentAccount);
413 const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
414 const [deleteConfirmText, setDeleteConfirmText] = useState("");
415 const [deleteError, setDeleteError] = useState<string | null>(null);
416 const [deletingAccount, setDeletingAccount] = useState(false);
404 const avatarUrl = workosProfile?.avatarUrl ?? null;417 const avatarUrl = workosProfile?.avatarUrl ?? null;
405 const avatarLabel = workosProfile?.name || workosProfile?.email || "User";418 const avatarLabel = workosProfile?.name || workosProfile?.email || "User";
406 const avatarInitial = (avatarLabel[0] ?? "U").toUpperCase();419 const avatarInitial = (avatarLabel[0] ?? "U").toUpperCase();
420 const canDeleteAccount = deleteConfirmText === "DELETE";
407 421
408 const activeWorkspace = context422 const activeWorkspace = context
409 ? workspaces.find((workspace) => workspace.id === context.workspaceId)423 ? workspaces.find((workspace) => workspace.id === context.workspaceId)
@@ -430,6 +444,27 @@ function SessionInfo() { · 20 unmodified lines
430 }444 }
431 const signInHref = signInParams.size > 0 ? `/sign-in?${signInParams.toString()}` : "/sign-in";445 const signInHref = signInParams.size > 0 ? `/sign-in?${signInParams.toString()}` : "/sign-in";
432 446
447 const handleDeleteAccount = async (event: FormEvent<HTMLFormElement>) => {
448 event.preventDefault();
449 if (!canDeleteAccount || deletingAccount) {
450 return;
451 }
452
453 setDeletingAccount(true);
454 setDeleteError(null);
455 try {
456 await deleteCurrentAccountMutation({
457 sessionId: context?.sessionId ?? undefined,
458 });
459 await resetWorkspace();
460 window.location.assign(workosEnabled ? "/sign-out" : "/");
461 } catch (cause) {
462 const message = cause instanceof Error ? cause.message : "Failed to delete account";
463 setDeleteError(message);
464 setDeletingAccount(false);
465 }
466 };
467
433 if (loading) {468 if (loading) {
434 return (469 return (
435 <div className="border-t border-border px-3 py-2">470 <div className="border-t border-border px-3 py-2">
@@ -441,44 +476,113 @@ function SessionInfo() { · 5 unmodified lines
441 return (476 return (
442 <div className="border-t border-border">477 <div className="border-t border-border">
443 {isSignedInToWorkos ? (478 {isSignedInToWorkos ? (
444 <DropdownMenu>479 <>
445 <DropdownMenuTrigger asChild>480 <DropdownMenu>
446 <Button481 <DropdownMenuTrigger asChild>
447 variant="ghost"482 <Button
448 className="h-14 w-full justify-between rounded-none border-0 bg-transparent px-3 py-0 text-left shadow-none hover:bg-accent/40"483 variant="ghost"
449 >484 className="h-14 w-full justify-between rounded-none border-0 bg-transparent px-3 py-0 text-left shadow-none hover:bg-accent/40"
450 <span className="flex items-center gap-2 min-w-0">485 >
451 {avatarUrl ? (486 <span className="flex items-center gap-2 min-w-0">
452 <img487 {avatarUrl ? (
453 src={avatarUrl}488 <img
454 alt={avatarLabel}489 src={avatarUrl}
455 className="h-6 w-6 rounded-full border border-border object-cover"490 alt={avatarLabel}
456 />491 className="h-6 w-6 rounded-full border border-border object-cover"
457 ) : (492 />
458 <span className="h-6 w-6 rounded-full border border-border bg-muted text-[10px] font-mono text-muted-foreground flex items-center justify-center">493 ) : (
459 {avatarInitial}494 <span className="h-6 w-6 rounded-full border border-border bg-muted text-[10px] font-mono text-muted-foreground flex items-center justify-center">
495 {avatarInitial}
496 </span>
497 )}
498 <span className="min-w-0">
499 <span className="text-[11px] font-medium truncate block">{avatarLabel}</span>
500 {workosProfile?.email ? (
501 <span className="text-[10px] text-muted-foreground truncate block">{workosProfile.email}</span>
502 ) : null}
460 </span>503 </span>
461 )}
462 <span className="min-w-0">
463 <span className="text-[11px] font-medium truncate block">{avatarLabel}</span>
464 {workosProfile?.email ? (
465 <span className="text-[10px] text-muted-foreground truncate block">{workosProfile.email}</span>
466 ) : null}
467 </span>504 </span>
468 </span>505 <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
469 <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />506 </Button>
470 </Button>507 </DropdownMenuTrigger>
471 </DropdownMenuTrigger>508 <DropdownMenuContent align="end" className="w-64">
472 <DropdownMenuContent align="end" className="w-64">509 <DropdownMenuLabel className="text-xs">
473 <DropdownMenuLabel className="text-xs">510 Account
474 Account511 </DropdownMenuLabel>
475 </DropdownMenuLabel>512 <DropdownMenuSeparator />
476 <DropdownMenuSeparator />513 <DropdownMenuItem
477 <DropdownMenuItem asChild className="text-xs">514 className="text-xs"
478 <Link href="/sign-out">Sign out</Link>515 onSelect={(event) => {
479 </DropdownMenuItem>516 event.preventDefault();
480 </DropdownMenuContent>517 setDeleteConfirmText("");
481 </DropdownMenu>518 setDeleteError(null);
519 setAccountSettingsOpen(true);
520 }}
521 >
522 Account Settings
523 </DropdownMenuItem>
524 <DropdownMenuItem asChild className="text-xs">
525 <Link href="/sign-out">Sign out</Link>
526 </DropdownMenuItem>
527 </DropdownMenuContent>
528 </DropdownMenu>
529
530 <Dialog
531 open={accountSettingsOpen}
532 onOpenChange={(open) => {
533 setAccountSettingsOpen(open);
534 if (!open) {
535 setDeleteConfirmText("");
536 setDeleteError(null);
537 setDeletingAccount(false);
538 }
539 }}
540 >
541 <DialogContent className="sm:max-w-md">
542 <form className="space-y-4" onSubmit={handleDeleteAccount}>
543 <DialogHeader>
544 <DialogTitle>Account Settings</DialogTitle>
545 <DialogDescription>
546 Deleting your account will remove organizations, workspaces, and data you created.
547 This cannot be undone.
548 </DialogDescription>
549 </DialogHeader>
550 <div className="space-y-2">
551 <label htmlFor="delete-account-confirm" className="text-xs font-medium text-foreground">
552 Type DELETE to confirm
553 </label>
554 <Input
555 id="delete-account-confirm"
556 value={deleteConfirmText}
557 onChange={(event) => setDeleteConfirmText(event.target.value)}
558 autoComplete="off"
559 spellCheck={false}
560 />
561 {deleteError ? (
562 <p className="text-xs text-destructive">{deleteError}</p>
563 ) : null}
564 </div>
565 <DialogFooter>
566 <Button
567 type="button"
568 variant="outline"
569 onClick={() => setAccountSettingsOpen(false)}
570 disabled={deletingAccount}
571 >
572 Cancel
573 </Button>
574 <Button
575 type="submit"
576 variant="destructive"
577 disabled={!canDeleteAccount || deletingAccount}
578 >
579 {deletingAccount ? "Deleting..." : "Delete account"}
580 </Button>
581 </DialogFooter>
582 </form>
583 </DialogContent>
584 </Dialog>
585 </>
482 ) : (586 ) : (
483 <div className="px-3 py-3 space-y-2">587 <div className="px-3 py-3 space-y-2">
484 {workosEnabled ? (588 {workosEnabled ? (
executor/apps/web/src/components/mcp-setup-card.tsx+32−1
@@ -41,6 +41,10 @@ function resolveMcpOrigin(windowOrigin: string): string { · 40 unmodified lines
41 return windowOrigin;41 return windowOrigin;
42}42}
43 43
44function isLocalHostname(hostname: string): boolean {
45 return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0";
46}
47
44export function McpSetupCard({48export function McpSetupCard({
45 workspaceId,49 workspaceId,
46 actorId,50 actorId,
@@ -52,12 +56,26 @@ export function McpSetupCard({ · 5 unmodified lines
52}) {56}) {
53 const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code");57 const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code");
54 const [copiedKey, setCopiedKey] = useState<string | null>(null);58 const [copiedKey, setCopiedKey] = useState<string | null>(null);
59 const [urlMode, setUrlMode] = useState<"default" | "local">(() => {
60 if (typeof window === "undefined") {
61 return "default";
62 }
63 return isLocalHostname(window.location.hostname) ? "local" : "default";
64 });
55 65
56 const origin = useMemo(() => {66 const defaultOrigin = useMemo(() => {
57 if (typeof window === "undefined") return "";67 if (typeof window === "undefined") return "";
58 return resolveMcpOrigin(window.location.origin);68 return resolveMcpOrigin(window.location.origin);
59 }, []);69 }, []);
60 70
71 const showLocalGatewayOption = useMemo(() => {
72 if (typeof window === "undefined") return false;
73 return isLocalHostname(window.location.hostname);
74 }, []);
75
76 const localGatewayOrigin = (process.env.NEXT_PUBLIC_LOCAL_MCP_ORIGIN ?? "http://localhost:3003").trim().replace(/\/$/, "");
77 const origin = urlMode === "local" ? localGatewayOrigin : defaultOrigin;
78
61 const mcpUrl = useMemo(() => {79 const mcpUrl = useMemo(() => {
62 const base = origin ? new URL("/mcp", origin) : new URL("http://localhost/mcp");80 const base = origin ? new URL("/mcp", origin) : new URL("http://localhost/mcp");
63 if (workspaceId) base.searchParams.set("workspaceId", workspaceId);81 if (workspaceId) base.searchParams.set("workspaceId", workspaceId);
@@ -94,6 +112,19 @@ export function McpSetupCard({ · 30 unmodified lines
94 <div className="space-y-5">112 <div className="space-y-5">
95 <div className="space-y-1.5">113 <div className="space-y-1.5">
96 <Label className="text-xs text-muted-foreground">MCP Server URL</Label>114 <Label className="text-xs text-muted-foreground">MCP Server URL</Label>
115 {showLocalGatewayOption ? (
116 <div className="space-y-1.5">
117 <Select value={urlMode} onValueChange={(value) => setUrlMode(value as "default" | "local")}>
118 <SelectTrigger className="h-8 text-xs bg-background">
119 <SelectValue />
120 </SelectTrigger>
121 <SelectContent>
122 <SelectItem value="local" className="text-xs">Local gateway (localhost:3003)</SelectItem>
123 <SelectItem value="default" className="text-xs">Hosted endpoint</SelectItem>
124 </SelectContent>
125 </Select>
126 </div>
127 ) : null}
97 <div className="flex items-center gap-2">128 <div className="flex items-center gap-2">
98 <Input value={mcpUrl} readOnly className="h-8 text-xs font-mono bg-background" />129 <Input value={mcpUrl} readOnly className="h-8 text-xs font-mono bg-background" />
99 <Button130 <Button
executor/convex/_generated/api.d.ts+14−0
@@ -20,6 +20,14 @@ import type { GenericId as Id } from "convex/values"; · 19 unmodified lines
20 * ```20 * ```
21 */21 */
22export declare const api: {22export declare const api: {
23 accounts: {
24 deleteCurrentAccount: FunctionReference<
25 "mutation",
26 "public",
27 { sessionId?: string },
28 any
29 >;
30 };
23 app: {31 app: {
24 getClientConfig: FunctionReference<"query", "public", {}, any>;32 getClientConfig: FunctionReference<"query", "public", {}, any>;
25 getCurrentAccount: FunctionReference<33 getCurrentAccount: FunctionReference<
@@ -287,6 +295,12 @@ export declare const api: { · 261 unmodified lines
287 { sessionId?: string; sourceId: string; workspaceId: string },295 { sessionId?: string; sourceId: string; workspaceId: string },
288 any296 any
289 >;297 >;
298 getRequestContext: FunctionReference<
299 "query",
300 "public",
301 { sessionId?: string; workspaceId: string },
302 any
303 >;
290 getTask: FunctionReference<304 getTask: FunctionReference<
291 "query",305 "query",
292 "public",306 "public",
executor/convex/accounts.ts+229−0
@@ -0,0 +1,229 @@
1import type { Id } from "./_generated/dataModel";
2import type { MutationCtx } from "./_generated/server";
3import { authedMutation } from "../lib/functionBuilders";
4
5async function deleteWorkspaceData(
6 ctx: Pick<MutationCtx, "db" | "storage">,
7 workspaceId: Id<"workspaces">,
8) {
9 const workspace = await ctx.db.get(workspaceId);
10 if (!workspace) {
11 return false;
12 }
13
14 const workspaceKey = String(workspaceId);
15
16 const taskDocs = await ctx.db
17 .query("tasks")
18 .withIndex("by_workspace_created", (q) => q.eq("workspaceId", workspaceKey))
19 .collect();
20 for (const task of taskDocs) {
21 const taskEvents = await ctx.db
22 .query("taskEvents")
23 .withIndex("by_task_sequence", (q) => q.eq("taskId", task.taskId))
24 .collect();
25 for (const event of taskEvents) {
26 await ctx.db.delete(event._id);
27 }
28 await ctx.db.delete(task._id);
29 }
30
31 const approvalDocs = await ctx.db
32 .query("approvals")
33 .withIndex("by_workspace_created", (q) => q.eq("workspaceId", workspaceKey))
34 .collect();
35 for (const approval of approvalDocs) {
36 await ctx.db.delete(approval._id);
37 }
38
39 const policyDocs = await ctx.db
40 .query("accessPolicies")
41 .withIndex("by_workspace_created", (q) => q.eq("workspaceId", workspaceKey))
42 .collect();
43 for (const policy of policyDocs) {
44 await ctx.db.delete(policy._id);
45 }
46
47 const credentialDocs = await ctx.db
48 .query("sourceCredentials")
49 .withIndex("by_workspace_created", (q) => q.eq("workspaceId", workspaceKey))
50 .collect();
51 for (const credential of credentialDocs) {
52 await ctx.db.delete(credential._id);
53 }
54
55 const toolSources = await ctx.db
56 .query("toolSources")
57 .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", workspaceKey))
58 .collect();
59 for (const source of toolSources) {
60 await ctx.db.delete(source._id);
61 }
62
63 const agentTasks = await ctx.db
64 .query("agentTasks")
65 .withIndex("by_workspace_created", (q) => q.eq("workspaceId", workspaceKey))
66 .collect();
67 for (const agentTask of agentTasks) {
68 await ctx.db.delete(agentTask._id);
69 }
70
71 const cachedToolsets = await ctx.db
72 .query("workspaceToolCache")
73 .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceKey))
74 .collect();
75 for (const cacheEntry of cachedToolsets) {
76 await ctx.storage.delete(cacheEntry.storageId).catch(() => {});
77 if (cacheEntry.dtsStorageIds) {
78 for (const dtsEntry of cacheEntry.dtsStorageIds) {
79 await ctx.storage.delete(dtsEntry.storageId).catch(() => {});
80 }
81 }
82 await ctx.db.delete(cacheEntry._id);
83 }
84
85 const workspaceMembers = await ctx.db
86 .query("workspaceMembers")
87 .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
88 .collect();
89 for (const member of workspaceMembers) {
90 await ctx.db.delete(member._id);
91 }
92
93 const anonymousSessions = await ctx.db
94 .query("anonymousSessions")
95 .withIndex("by_workspace_actor", (q) => q.eq("workspaceId", workspaceId))
96 .collect();
97 for (const session of anonymousSessions) {
98 await ctx.db.delete(session._id);
99 }
100
101 if (workspace.iconStorageId) {
102 await ctx.storage.delete(workspace.iconStorageId).catch(() => {});
103 }
104
105 await ctx.db.delete(workspace._id);
106 return true;
107}
108
109async function deleteOrganizationData(
110 ctx: Pick<MutationCtx, "db" | "storage">,
111 organizationId: Id<"organizations">,
112) {
113 const invites = await ctx.db
114 .query("invites")
115 .withIndex("by_org", (q) => q.eq("organizationId", organizationId))
116 .collect();
117 for (const invite of invites) {
118 await ctx.db.delete(invite._id);
119 }
120
121 const billingCustomers = await ctx.db
122 .query("billingCustomers")
123 .withIndex("by_org", (q) => q.eq("organizationId", organizationId))
124 .collect();
125 for (const customer of billingCustomers) {
126 await ctx.db.delete(customer._id);
127 }
128
129 const billingSubscriptions = await ctx.db
130 .query("billingSubscriptions")
131 .withIndex("by_org", (q) => q.eq("organizationId", organizationId))
132 .collect();
133 for (const subscription of billingSubscriptions) {
134 await ctx.db.delete(subscription._id);
135 }
136
137 const seatStateDocs = await ctx.db
138 .query("billingSeatState")
139 .withIndex("by_org", (q) => q.eq("organizationId", organizationId))
140 .collect();
141 for (const seatState of seatStateDocs) {
142 await ctx.db.delete(seatState._id);
143 }
144
145 const workspaces = await ctx.db
146 .query("workspaces")
147 .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId))
148 .collect();
149 for (const workspace of workspaces) {
150 await deleteWorkspaceData(ctx, workspace._id);
151 }
152
153 const orgMembers = await ctx.db
154 .query("organizationMembers")
155 .withIndex("by_org", (q) => q.eq("organizationId", organizationId))
156 .collect();
157 for (const membership of orgMembers) {
158 await ctx.db.delete(membership._id);
159 }
160
161 await ctx.db.delete(organizationId);
162}
163
164export const deleteCurrentAccount = authedMutation({
165 args: {},
166 handler: async (ctx) => {
167 const accountId = ctx.account._id;
168
169 const organizationIdsToDelete = new Set<Id<"organizations">>();
170
171 const organizationsByStatus = await Promise.all([
172 ctx.db
173 .query("organizations")
174 .withIndex("by_status_created", (q) => q.eq("status", "active"))
175 .collect(),
176 ctx.db
177 .query("organizations")
178 .withIndex("by_status_created", (q) => q.eq("status", "deleted"))
179 .collect(),
180 ]);
181
182 for (const organization of organizationsByStatus.flat()) {
183 if (organization.createdByAccountId === accountId) {
184 organizationIdsToDelete.add(organization._id);
185 }
186 }
187
188 for (const organizationId of organizationIdsToDelete) {
189 await deleteOrganizationData(ctx, organizationId);
190 }
191
192 const workspaceMemberships = await ctx.db
193 .query("workspaceMembers")
194 .withIndex("by_account", (q) => q.eq("accountId", accountId))
195 .collect();
196 for (const membership of workspaceMemberships) {
197 await ctx.db.delete(membership._id);
198 }
199
200 const organizationMemberships = await ctx.db
201 .query("organizationMembers")
202 .withIndex("by_account", (q) => q.eq("accountId", accountId))
203 .collect();
204 for (const membership of organizationMemberships) {
205 await ctx.db.delete(membership._id);
206 }
207
208 const invitedRecords = await ctx.db.query("invites").collect();
209 for (const invite of invitedRecords) {
210 if (invite.invitedByAccountId === accountId) {
211 await ctx.db.delete(invite._id);
212 }
213 }
214
215 const anonymousSessions = await ctx.db
216 .query("anonymousSessions")
217 .withIndex("by_account", (q) => q.eq("accountId", accountId))
218 .collect();
219 for (const session of anonymousSessions) {
220 await ctx.db.delete(session._id);
221 }
222
223 await ctx.db.delete(accountId);
224
225 return {
226 organizationsDeleted: organizationIdsToDelete.size,
227 };
228 },
229});
executor/convex/executor-mcp.e2e.test.ts+63−0
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; · 1 unmodified line
2import { convexTest } from "convex-test";2import { convexTest } from "convex-test";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";3import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5import { internal } from "./_generated/api";6import { internal } from "./_generated/api";
6import schema from "./schema";7import schema from "./schema";
7 8
@@ -125,6 +126,68 @@ test("MCP run_code survives delayed approval and completes", async () => { · 117 unmodified lines
125 }126 }
126}, 60_000);127}, 60_000);
127 128
129test("MCP run_code resolves approval via real server form elicitation", async () => {
130 const t = setup();
131 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
132 let elicitationCount = 0;
133
134 const client = new Client(
135 { name: "executor-e2e-elicitation", version: "0.0.1" },
136 { capabilities: { elicitation: { form: {} } } },
137 );
138 client.setRequestHandler(ElicitRequestSchema, async (request) => {
139 const mode = request.params.mode ?? "form";
140 if (mode !== "form") {
141 return { action: "decline" };
142 }
143
144 elicitationCount += 1;
145 return {
146 action: "accept",
147 content: {
148 decision: "approved",
149 reason: "approved via MCP form elicitation",
150 },
151 };
152 });
153
154 const transport = createMcpTransport(t, session.workspaceId, session.actorId, session.sessionId, "e2e-elicitation");
155
156 try {
157 await client.connect(transport);
158
159 const runCode = client.callTool({
160 name: "run_code",
161 arguments: {
162 code: `return await tools.admin.send_announcement({ channel: "general", message: "approved by real-server elicitation" });`,
163 },
164 });
165
166 const taskId = await waitForTaskId(t, session.workspaceId);
167 const runTask = t.action(internal.executorNode.runTask, { taskId });
168
169 await runTask;
170
171 const result = (await runCode) as {
172 content: Array<{ type: string; text?: string }>;
173 isError?: boolean;
174 };
175 const text = result.content.find((item) => item.type === "text")?.text ?? "";
176
177 expect(elicitationCount).toBe(1);
178 expect(result.isError).toBeFalsy();
179 expect(text).toContain("status: completed");
180 expect(text).toContain("approved by real-server elicitation");
181
182 const approvals = await t.query(internal.database.listApprovals, { workspaceId: session.workspaceId });
183 const approval = approvals.find((item: { taskId: string }) => item.taskId === taskId);
184 expect(approval?.status).toBe("approved");
185 } finally {
186 await transport.close().catch(() => {});
187 await client.close().catch(() => {});
188 }
189}, 30_000);
190
128test("MCP run_code returns denied after approval denial", async () => {191test("MCP run_code returns denied after approval denial", async () => {
129 const t = setup();192 const t = setup();
130 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});193 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
executor/convex/executorNode.ts+8−2
@@ -57,7 +57,7 @@ function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientI · 56 unmodified lines
57}57}
58 58
59function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {59function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
60 const signatureVersion = "v10";60 const signatureVersion = "v12";
61 const parts = sources61 const parts = sources
62 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)62 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
63 .sort();63 .sort();
@@ -132,7 +132,7 @@ interface DtsStorageEntry { · 68 unmodified lines
132const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;132const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
133 133
134/** Cache version — bump when PreparedOpenApiSpec shape changes. */134/** Cache version — bump when PreparedOpenApiSpec shape changes. */
135const OPENAPI_CACHE_VERSION = "v12";135const OPENAPI_CACHE_VERSION = "v14";
136 136
137async function publish(137async function publish(
138 ctx: any,138 ctx: any,
@@ -376,6 +376,12 @@ function getDecisionForContext( · 237 unmodified lines
376 context: { workspaceId: string; actorId?: string; clientId?: string },376 context: { workspaceId: string; actorId?: string; clientId?: string },
377 policies: AccessPolicyRecord[],377 policies: AccessPolicyRecord[],
378): PolicyDecision {378): PolicyDecision {
379 // `discover` is always available so models can find allowed tools dynamically.
380 // The discover implementation still filters returned results via context.isToolAllowed.
381 if (tool.path === "discover") {
382 return "allow";
383 }
384
379 const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow";385 const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow";
380 const candidates = policies386 const candidates = policies
381 .filter((policy) => {387 .filter((policy) => {
executor/convex/http.ts+89−31
@@ -9,15 +9,47 @@ import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescripto · 8 unmodified lines
9 9
10const http = httpRouter();10const http = httpRouter();
11const internalToken = process.env.EXECUTOR_INTERNAL_TOKEN ?? null;11const internalToken = process.env.EXECUTOR_INTERNAL_TOKEN ?? null;
12const mcpAuthorizationServer =12const mcpJwksByServer = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
13 process.env.MCP_AUTHORIZATION_SERVER13
14 ?? process.env.MCP_AUTHORIZATION_SERVER_URL14function getMcpAuthorizationServer(): string | null {
15 ?? process.env.WORKOS_AUTHKIT_ISSUER15 return process.env.MCP_AUTHORIZATION_SERVER
16 ?? process.env.WORKOS_AUTHKIT_DOMAIN;16 ?? process.env.MCP_AUTHORIZATION_SERVER_URL
17const mcpAuthEnabled = Boolean(mcpAuthorizationServer);17 ?? process.env.WORKOS_AUTHKIT_ISSUER
18const mcpJwks = mcpAuthorizationServer18 ?? process.env.WORKOS_AUTHKIT_DOMAIN
19 ? createRemoteJWKSet(new URL("/oauth2/jwks", mcpAuthorizationServer))19 ?? null;
20 : null;20}
21
22function getMcpAuthConfig(): {
23 enabled: boolean;
24 authorizationServer: string | null;
25 jwks: ReturnType<typeof createRemoteJWKSet> | null;
26} {
27 const authorizationServer = getMcpAuthorizationServer();
28 if (!authorizationServer) {
29 return {
30 enabled: false,
31 authorizationServer: null,
32 jwks: null,
33 };
34 }
35
36 const existingJwks = mcpJwksByServer.get(authorizationServer);
37 if (existingJwks) {
38 return {
39 enabled: true,
40 authorizationServer,
41 jwks: existingJwks,
42 };
43 }
44
45 const jwks = createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer));
46 mcpJwksByServer.set(authorizationServer, jwks);
47 return {
48 enabled: true,
49 authorizationServer,
50 jwks,
51 };
52}
21 53
22function parseBearerToken(request: Request): string | null {54function parseBearerToken(request: Request): string | null {
23 const header = request.headers.get("authorization");55 const header = request.headers.get("authorization");
@@ -49,9 +81,12 @@ function unauthorizedMcpResponse(request: Request, message: string): Response { · 25 unmodified lines
49 );81 );
50}82}
51 83
52async function verifyMcpToken(request: Request): Promise<{ subject: string } | null> {84async function verifyMcpToken(
53 if (!mcpAuthEnabled || !mcpAuthorizationServer || !mcpJwks) {85 request: Request,
54 return { subject: "" };86 config: ReturnType<typeof getMcpAuthConfig>,
87): Promise<{ subject: string } | null> {
88 if (!config.enabled || !config.authorizationServer || !config.jwks) {
89 return null;
55 }90 }
56 91
57 const token = parseBearerToken(request);92 const token = parseBearerToken(request);
@@ -60,8 +95,8 @@ async function verifyMcpToken(request: Request): Promise<{ subject: string } | n · 2 unmodified lines
60 }95 }
61 96
62 try {97 try {
63 const { payload } = await jwtVerify(token, mcpJwks, {98 const { payload } = await jwtVerify(token, config.jwks, {
64 issuer: mcpAuthorizationServer,99 issuer: config.authorizationServer,
65 });100 });
66 101
67 if (typeof payload.sub !== "string" || payload.sub.length === 0) {102 if (typeof payload.sub !== "string" || payload.sub.length === 0) {
@@ -74,13 +109,16 @@ async function verifyMcpToken(request: Request): Promise<{ subject: string } | n · 6 unmodified lines
74 }109 }
75}110}
76 111
77function parseMcpContext(url: URL, tokenSubject?: string): (McpWorkspaceContext & { sessionId?: string }) | undefined {112function parseMcpContext(url: URL): {
113 workspaceId: string;
114 clientId?: string;
115 sessionId?: string;
116} | undefined {
78 const workspaceId = url.searchParams.get("workspaceId");117 const workspaceId = url.searchParams.get("workspaceId");
79 const actorId = tokenSubject ?? url.searchParams.get("actorId");118 if (!workspaceId) return undefined;
80 if (!workspaceId || !actorId) return undefined;
81 const clientId = url.searchParams.get("clientId") ?? undefined;119 const clientId = url.searchParams.get("clientId") ?? undefined;
82 const sessionId = url.searchParams.get("sessionId") ?? undefined;120 const sessionId = url.searchParams.get("sessionId") ?? undefined;
83 return { workspaceId, actorId, clientId, sessionId };121 return { workspaceId, clientId, sessionId };
84}122}
85 123
86function isInternalAuthorized(request: Request): boolean {124function isInternalAuthorized(request: Request): boolean {
@@ -107,15 +145,11 @@ function parseInternalRunPath(pathname: string): { runId: string; endpoint: "too · 20 unmodified lines
107 145
108const mcpHandler = httpAction(async (ctx, request) => {146const mcpHandler = httpAction(async (ctx, request) => {
109 const url = new URL(request.url);147 const url = new URL(request.url);
110 const auth = await verifyMcpToken(request);148 const mcpAuthConfig = getMcpAuthConfig();
111 if (!auth) {149 const auth = await verifyMcpToken(request, mcpAuthConfig);
112 return unauthorizedMcpResponse(request, "No valid bearer token provided.");150 const requestedContext = parseMcpContext(url);
113 }
114
115 const tokenSubject = mcpAuthEnabled ? auth.subject : undefined;
116 const requestedContext = parseMcpContext(url, tokenSubject);
117 151
118 if (mcpAuthEnabled && !requestedContext) {152 if (mcpAuthConfig.enabled && !requestedContext) {
119 return Response.json(153 return Response.json(
120 { error: "workspaceId query parameter is required when MCP OAuth is enabled" },154 { error: "workspaceId query parameter is required when MCP OAuth is enabled" },
121 { status: 400 },155 { status: 400 },
@@ -125,7 +159,7 @@ const mcpHandler = httpAction(async (ctx, request) => { · 3 unmodified lines
125 let context: McpWorkspaceContext | undefined;159 let context: McpWorkspaceContext | undefined;
126 if (requestedContext) {160 if (requestedContext) {
127 try {161 try {
128 if (mcpAuthEnabled) {162 if (mcpAuthConfig.enabled && auth?.subject) {
129 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForWorkosSubject, {163 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForWorkosSubject, {
130 workspaceId: requestedContext.workspaceId,164 workspaceId: requestedContext.workspaceId,
131 subject: auth.subject,165 subject: auth.subject,
@@ -137,11 +171,22 @@ const mcpHandler = httpAction(async (ctx, request) => { · 5 unmodified lines
137 clientId: requestedContext.clientId,171 clientId: requestedContext.clientId,
138 };172 };
139 } else {173 } else {
174 if (mcpAuthConfig.enabled && !requestedContext.sessionId) {
175 return unauthorizedMcpResponse(request, "No valid bearer token provided.");
176 }
177
140 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {178 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {
141 workspaceId: requestedContext.workspaceId,179 workspaceId: requestedContext.workspaceId,
142 sessionId: requestedContext.sessionId,180 sessionId: requestedContext.sessionId,
143 });181 });
144 182
183 if (mcpAuthConfig.enabled && access.provider !== "anonymous") {
184 return unauthorizedMcpResponse(
185 request,
186 "Bearer token required for non-anonymous sessions.",
187 );
188 }
189
145 context = {190 context = {
146 workspaceId: requestedContext.workspaceId,191 workspaceId: requestedContext.workspaceId,
147 actorId: access.actorId,192 actorId: access.actorId,
@@ -188,6 +233,17 @@ const mcpHandler = httpAction(async (ctx, request) => { · 40 unmodified lines
188 233
189 return (await ctx.runAction(internal.executorNode.listToolsInternal, toolContext)) as ToolDescriptor[];234 return (await ctx.runAction(internal.executorNode.listToolsInternal, toolContext)) as ToolDescriptor[];
190 },235 },
236 listToolsForTypecheck: async (toolContext: { workspaceId: string; actorId?: string; clientId?: string }) => {
237 const result = await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, toolContext) as {
238 tools: ToolDescriptor[];
239 dtsUrls?: Record<string, string>;
240 };
241
242 return {
243 tools: result.tools,
244 dtsUrls: result.dtsUrls ?? {},
245 };
246 },
191 listPendingApprovals: async (workspaceId: string) => {247 listPendingApprovals: async (workspaceId: string) => {
192 return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[];248 return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[];
193 },249 },
@@ -206,24 +262,26 @@ const mcpHandler = httpAction(async (ctx, request) => { · 12 unmodified lines
206});262});
207 263
208const oauthProtectedResourceHandler = httpAction(async (_ctx, request) => {264const oauthProtectedResourceHandler = httpAction(async (_ctx, request) => {
209 if (!mcpAuthEnabled || !mcpAuthorizationServer) {265 const mcpAuthConfig = getMcpAuthConfig();
266 if (!mcpAuthConfig.enabled || !mcpAuthConfig.authorizationServer) {
210 return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });267 return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
211 }268 }
212 269
213 const url = new URL(request.url);270 const url = new URL(request.url);
214 return Response.json({271 return Response.json({
215 resource: `${url.origin}/mcp`,272 resource: `${url.origin}/mcp`,
216 authorization_servers: [mcpAuthorizationServer],273 authorization_servers: [mcpAuthConfig.authorizationServer],
217 bearer_methods_supported: ["header"],274 bearer_methods_supported: ["header"],
218 });275 });
219});276});
220 277
221const oauthAuthorizationServerProxyHandler = httpAction(async (_ctx, request) => {278const oauthAuthorizationServerProxyHandler = httpAction(async (_ctx, request) => {
222 if (!mcpAuthEnabled || !mcpAuthorizationServer) {279 const mcpAuthConfig = getMcpAuthConfig();
280 if (!mcpAuthConfig.enabled || !mcpAuthConfig.authorizationServer) {
223 return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });281 return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
224 }282 }
225 283
226 const upstream = new URL("/.well-known/oauth-authorization-server", mcpAuthorizationServer);284 const upstream = new URL("/.well-known/oauth-authorization-server", mcpAuthConfig.authorizationServer);
227 const response = await fetch(upstream.toString(), {285 const response = await fetch(upstream.toString(), {
228 headers: { accept: "application/json" },286 headers: { accept: "application/json" },
229 });287 });
executor/convex/workspace.ts+14−0
@@ -9,6 +9,10 @@ const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("acto · 8 unmodified lines
9const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault"));9const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault"));
10const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));10const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
11 11
12function actorIdForAccount(account: { _id: string; provider: string; providerAccountId: string }): string {
13 return account.provider === "anonymous" ? account.providerAccountId : account._id;
14}
15
12function redactCredential<T extends { secretJson: Record<string, unknown> }>(credential: T): T {16function redactCredential<T extends { secretJson: Record<string, unknown> }>(credential: T): T {
13 return {17 return {
14 ...credential,18 ...credential,
@@ -30,6 +34,16 @@ export const listRuntimeTargets = query({ · 15 unmodified lines
30 },34 },
31});35});
32 36
37export const getRequestContext = workspaceQuery({
38 args: {},
39 handler: async (ctx) => {
40 return {
41 workspaceId: ctx.workspaceId,
42 actorId: actorIdForAccount(ctx.account as { _id: string; provider: string; providerAccountId: string }),
43 };
44 },
45});
46
33export const getTask = workspaceQuery({47export const getTask = workspaceQuery({
34 args: { taskId: v.string() },48 args: { taskId: v.string() },
35 handler: async (ctx, args) => {49 handler: async (ctx, args) => {
executor/lib/mcp-elicitation.demo.test.ts+95−0
@@ -0,0 +1,95 @@
1import { expect, test } from "bun:test";
2import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
7async function createDemoPair(responseAction: "accept" | "decline" | "cancel") {
8 const server = new McpServer(
9 { name: "elicitation-demo-server", version: "0.0.1" },
10 { capabilities: {} },
11 );
12
13 server.registerTool(
14 "approval_demo",
15 {
16 description: "Demonstrates MCP form elicitation round-trip",
17 inputSchema: {},
18 },
19 async () => {
20 const response = await server.server.elicitInput({
21 mode: "form",
22 message: "Approve demo operation?",
23 requestedSchema: {
24 type: "object",
25 properties: {
26 decision: {
27 type: "string",
28 oneOf: [
29 { const: "approved", title: "Approve" },
30 { const: "denied", title: "Deny" },
31 ],
32 },
33 },
34 required: ["decision"],
35 },
36 });
37
38 const approved = response.action === "accept" && response.content?.decision === "approved";
39 return {
40 content: [{ type: "text", text: approved ? "approved" : "denied" }],
41 };
42 },
43 );
44
45 const client = new Client(
46 { name: "elicitation-demo-client", version: "0.0.1" },
47 { capabilities: { elicitation: { form: {} } } },
48 );
49
50 client.setRequestHandler(ElicitRequestSchema, async () => {
51 if (responseAction !== "accept") {
52 return { action: responseAction };
53 }
54
55 return {
56 action: "accept",
57 content: { decision: "approved" },
58 };
59 });
60
61 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
62 await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
63
64 return { server, client };
65}
66
67test("MCP form elicitation demo accepts response", async () => {
68 const { server, client } = await createDemoPair("accept");
69
70 try {
71 const result = (await client.callTool({ name: "approval_demo", arguments: {} })) as {
72 content: Array<{ type: string; text?: string }>;
73 };
74 const text = result.content.find((item) => item.type === "text")?.text ?? "";
75 expect(text).toBe("approved");
76 } finally {
77 await client.close();
78 await server.close();
79 }
80});
81
82test("MCP form elicitation demo handles decline", async () => {
83 const { server, client } = await createDemoPair("decline");
84
85 try {
86 const result = (await client.callTool({ name: "approval_demo", arguments: {} })) as {
87 content: Array<{ type: string; text?: string }>;
88 };
89 const text = result.content.find((item) => item.type === "text")?.text ?? "";
90 expect(text).toBe("denied");
91 } finally {
92 await client.close();
93 await server.close();
94 }
95});
executor/lib/mcp-server.test.ts+64−0
@@ -9,6 +9,11 @@ class FakeMcpService { · 8 unmodified lines
9 private readonly tasks = new Map<string, TaskRecord>();9 private readonly tasks = new Map<string, TaskRecord>();
10 private readonly sessions = new Map<string, AnonymousContext>();10 private readonly sessions = new Map<string, AnonymousContext>();
11 private readonly listeners = new Map<string, Set<(event: LiveTaskEvent) => void>>();11 private readonly listeners = new Map<string, Set<(event: LiveTaskEvent) => void>>();
12 private typecheckPayload?: { tools: ToolDescriptor[]; dtsUrls: Record<string, string> };
13
14 setTypecheckPayload(payload: { tools: ToolDescriptor[]; dtsUrls: Record<string, string> }) {
15 this.typecheckPayload = payload;
16 }
12 17
13 async createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }> {18 async createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }> {
14 const id = `task_${crypto.randomUUID()}`;19 const id = `task_${crypto.randomUUID()}`;
@@ -90,6 +95,18 @@ class FakeMcpService { · 75 unmodified lines
90 { path: "utils.get_time", description: "Get the current time", approval: "auto" },95 { path: "utils.get_time", description: "Get the current time", approval: "auto" },
91 ];96 ];
92 }97 }
98
99 async listToolsForTypecheck(
100 _context: { workspaceId: string; actorId?: string; clientId?: string },
101 ): Promise<{ tools: ToolDescriptor[]; dtsUrls: Record<string, string> }> {
102 if (this.typecheckPayload) {
103 return this.typecheckPayload;
104 }
105 return {
106 tools: await this.listTools(_context),
107 dtsUrls: {},
108 };
109 }
93}110}
94 111
95async function withMcpClient<T>(112async function withMcpClient<T>(
@@ -169,3 +186,50 @@ test("run_code MCP tool bootstraps anonymous context when workspace is omitted", · 73 unmodified lines
169 expect(structured?.sessionId).toBe("mcp_session_test");186 expect(structured?.sessionId).toBe("mcp_session_test");
170 });187 });
171});188});
189
190test("run_code typecheck uses OpenAPI .d.ts from typecheck context", async () => {
191 const service = new FakeMcpService();
192 service.setTypecheckPayload({
193 tools: [
194 {
195 path: "github.issues.list_for_repo",
196 description: "List issues for a repository",
197 approval: "auto",
198 source: "openapi:github",
199 operationId: "issues/list-for-repo",
200 },
201 ],
202 dtsUrls: {
203 "openapi:github":
204 "data:text/plain,"
205 + encodeURIComponent(
206 `export interface operations {
207 "issues/list-for-repo": {
208 parameters: { path: { owner: string; repo: string } };
209 responses: { 200: { content: { "application/json": { id: number }[] } } };
210 };
211}`,
212 ),
213 },
214 });
215
216 await withMcpClient(service, async (client) => {
217 const result = (await client.callTool({
218 name: "run_code",
219 arguments: {
220 code: "await tools.github.issues.list_for_repo({ owner: 123, repo: 'answeroverflow' });",
221 },
222 })) as {
223 isError?: boolean;
224 content: Array<{ type: string; text?: string }>;
225 structuredContent?: Record<string, unknown>;
226 };
227
228 expect(result.isError).toBe(true);
229 const typecheckErrors = Array.isArray(result.structuredContent?.typecheckErrors)
230 ? (result.structuredContent!.typecheckErrors as string[])
231 : [];
232 expect(typecheckErrors.length).toBeGreaterThan(0);
233 expect(typecheckErrors.some((error) => error.includes("owner") || error.includes("number"))).toBe(true);
234 });
235});
executor/lib/mcp_server.ts+283−12
@@ -25,6 +25,9 @@ interface McpExecutorService { · 24 unmodified lines
25 subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void;25 subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void;
26 bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>;26 bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>;
27 listTools(context?: { workspaceId: string; actorId?: string; clientId?: string }): Promise<ToolDescriptor[]>;27 listTools(context?: { workspaceId: string; actorId?: string; clientId?: string }): Promise<ToolDescriptor[]>;
28 listToolsForTypecheck?(
29 context: { workspaceId: string; actorId?: string; clientId?: string },
30 ): Promise<{ tools: ToolDescriptor[]; dtsUrls: Record<string, string> }>;
28 listPendingApprovals?(workspaceId: string): Promise<PendingApprovalRecord[]>;31 listPendingApprovals?(workspaceId: string): Promise<PendingApprovalRecord[]>;
29 resolveApproval?(input: {32 resolveApproval?(input: {
30 workspaceId: string;33 workspaceId: string;
@@ -73,6 +76,57 @@ function textContent(text: string): { type: "text"; text: string } { · 42 unmodified lines
73 return { type: "text", text };76 return { type: "text", text };
74}77}
75 78
79function listTopLevelToolKeys(tools: ToolDescriptor[]): string[] {
80 const keys = new Set<string>();
81 for (const tool of tools) {
82 const first = tool.path.split(".")[0];
83 if (first) keys.add(first);
84 }
85 return [...keys].sort();
86}
87
88const dtsUrlCache = new Map<string, Promise<string | null>>();
89
90async function loadSourceDtsByUrl(dtsUrls: Record<string, string>): Promise<Record<string, string>> {
91 const entries = Object.entries(dtsUrls);
92 if (entries.length === 0) {
93 return {};
94 }
95
96 const results = await Promise.all(entries.map(async ([sourceKey, url]) => {
97 if (!url) return [sourceKey, null] as const;
98
99 if (!dtsUrlCache.has(url)) {
100 dtsUrlCache.set(url, (async () => {
101 try {
102 const response = await fetch(url);
103 if (!response.ok) {
104 console.warn(`[executor] failed to fetch source .d.ts from ${url}: HTTP ${response.status}`);
105 return null;
106 }
107 return await response.text();
108 } catch (error) {
109 console.warn(
110 `[executor] failed to fetch source .d.ts from ${url}: ${error instanceof Error ? error.message : String(error)}`,
111 );
112 return null;
113 }
114 })());
115 }
116
117 const content = await dtsUrlCache.get(url)!;
118 return [sourceKey, content] as const;
119 }));
120
121 const sourceDtsBySource: Record<string, string> = {};
122 for (const [sourceKey, dts] of results) {
123 if (dts) {
124 sourceDtsBySource[sourceKey] = dts;
125 }
126 }
127 return sourceDtsBySource;
128}
129
76function summarizeTask(task: TaskRecord): string {130function summarizeTask(task: TaskRecord): string {
77 const lines = [131 const lines = [
78 `taskId: ${task.id}`,132 `taskId: ${task.id}`,
@@ -223,8 +277,97 @@ function waitForTerminalTask( · 144 unmodified lines
223function buildRunCodeDescription(tools?: ToolDescriptor[]): string {277function buildRunCodeDescription(tools?: ToolDescriptor[]): string {
224 const base =278 const base =
225 "Execute TypeScript code in a sandboxed runtime. The code has access to a `tools` object with typed methods for calling external services. Use `return` to return a value. Waits for completion and returns stdout/stderr. Code is typechecked before execution — type errors are returned without running.";279 "Execute TypeScript code in a sandboxed runtime. The code has access to a `tools` object with typed methods for calling external services. Use `return` to return a value. Waits for completion and returns stdout/stderr. Code is typechecked before execution — type errors are returned without running.";
280 const toolList = tools ?? [];
281 const topLevelKeys = listTopLevelToolKeys(toolList);
282 const rootKeysNote = topLevelKeys.length > 0
283 ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}`
284 : "";
285 const discoverNote = "\n\nTip: use `tools.discover({ query, depth?, limit? })` to find exact callable tool paths before invoking them. Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`).";
286
287 return base + rootKeysNote + discoverNote + generateToolInventory(toolList);
288}
226 289
227 return base + generateToolInventory(tools ?? []);290function formatApprovalInput(input: unknown, maxLength = 2000): string {
291 let serialized: string;
292 try {
293 serialized = JSON.stringify(input ?? {}, null, 2);
294 } catch {
295 serialized = String(input);
296 }
297
298 if (serialized.length <= maxLength) {
299 return serialized;
300 }
301
302 return `${serialized.slice(0, maxLength)}\n... [truncated ${serialized.length - maxLength} chars]`;
303}
304
305function buildApprovalPromptMessage(approval: PendingApprovalRecord): string {
306 const lines = [
307 "Approval required before tool execution can continue.",
308 `Tool: ${approval.toolPath}`,
309 `Task: ${approval.taskId}`,
310 `Runtime: ${approval.task.runtimeId}`,
311 "",
312 "Tool input:",
313 "```json",
314 formatApprovalInput(approval.input),
315 "```",
316 ];
317
318 return lines.join("\n");
319}
320
321function createMcpApprovalPrompt(mcp: McpServer): ApprovalPrompt {
322 return async (approval) => {
323 const response = await mcp.server.elicitInput({
324 mode: "form",
325 message: buildApprovalPromptMessage(approval),
326 requestedSchema: {
327 type: "object",
328 properties: {
329 decision: {
330 type: "string",
331 title: "Approval decision",
332 description: "Approve or deny this tool call",
333 oneOf: [
334 { const: "approved", title: "Approve tool call" },
335 { const: "denied", title: "Deny tool call" },
336 ],
337 default: "approved",
338 },
339 reason: {
340 type: "string",
341 title: "Reason (optional)",
342 description: "Optional note recorded with your decision",
343 maxLength: 500,
344 },
345 },
346 required: ["decision"],
347 },
348 }, { timeout: 15_000 }) as {
349 action: "accept" | "decline" | "cancel";
350 content?: Record<string, unknown>;
351 };
352
353 if (response.action !== "accept") {
354 return {
355 decision: "denied",
356 reason: response.action === "decline"
357 ? "User explicitly declined approval"
358 : "User canceled approval prompt",
359 };
360 }
361
362 const selectedDecision = response.content?.decision;
363 const decision = selectedDecision === "approved" ? "approved" : "denied";
364 const selectedReason = response.content?.reason;
365 const reason = typeof selectedReason === "string" && selectedReason.trim().length > 0
366 ? selectedReason
367 : undefined;
368
369 return { decision, reason };
370 };
228}371}
229 372
230// ---------------------------------------------------------------------------373// ---------------------------------------------------------------------------
@@ -271,20 +414,52 @@ function createRunCodeTool( · 40 unmodified lines
271 };414 };
272 }415 }
273 416
274 // Typecheck code before execution — get tool inventory for declarations417 // Typecheck code before execution — align with Monaco by loading source .d.ts
275 const toolsForContext = await service.listTools({418 // for OpenAPI tools when available.
276 workspaceId: context.workspaceId,419 let toolsForContext: ToolDescriptor[];
277 actorId: context.actorId,420 let sourceDtsBySource: Record<string, string> = {};
278 clientId: context.clientId,421 if (service.listToolsForTypecheck) {
422 const { tools, dtsUrls } = await service.listToolsForTypecheck({
423 workspaceId: context.workspaceId,
424 actorId: context.actorId,
425 clientId: context.clientId,
426 });
427 toolsForContext = tools;
428 sourceDtsBySource = await loadSourceDtsByUrl(dtsUrls ?? {});
429 } else {
430 toolsForContext = await service.listTools({
431 workspaceId: context.workspaceId,
432 actorId: context.actorId,
433 clientId: context.clientId,
434 });
435 }
436
437 const declarations = generateToolDeclarations(toolsForContext, {
438 sourceDtsBySource,
279 });439 });
280 const declarations = generateToolDeclarations(toolsForContext);
281 const typecheck = typecheckCode(input.code, declarations);440 const typecheck = typecheckCode(input.code, declarations);
282 441
283 if (!typecheck.ok) {442 if (!typecheck.ok) {
443 const topLevelKeys = listTopLevelToolKeys(toolsForContext);
444 const toolsShadowingError = typecheck.errors.some((error) =>
445 error.includes("Block-scoped variable 'tools' used before its declaration")
446 || error.includes("'tools' implicitly has type 'any'"),
447 );
448
449 const hintLines: string[] = [];
450 if (toolsShadowingError) {
451 hintLines.push("Hint: avoid declaring a local variable named `tools`.");
452 hintLines.push("Example: `const discovered = await tools.discover({ query: \"github issues\" });`");
453 }
454 if (topLevelKeys.length > 0) {
455 hintLines.push(`Available top-level tool keys: ${topLevelKeys.join(", ")}`);
456 }
457
284 const errorText = [458 const errorText = [
285 "TypeScript type errors in generated code:",459 "TypeScript type errors in generated code:",
286 "",460 "",
287 ...typecheck.errors.map((e) => ` ${e}`),461 ...typecheck.errors.map((e) => ` ${e}`),
462 ...(hintLines.length > 0 ? ["", ...hintLines] : []),
288 "",463 "",
289 "Fix the type errors and try again.",464 "Fix the type errors and try again.",
290 ].join("\n");465 ].join("\n");
@@ -403,6 +578,7 @@ async function createMcpServer( · 112 unmodified lines
403 { name: "executor", version: "0.1.0" },578 { name: "executor", version: "0.1.0" },
404 { capabilities: { tools: {} } },579 { capabilities: { tools: {} } },
405 );580 );
581 const onApprovalPrompt = createMcpApprovalPrompt(mcp);
406 582
407 // If workspace context is provided, fetch tool inventory for richer description583 // If workspace context is provided, fetch tool inventory for richer description
408 let tools: ToolDescriptor[] | undefined;584 let tools: ToolDescriptor[] | undefined;
@@ -420,17 +596,41 @@ async function createMcpServer( · 11 unmodified lines
420 description: buildRunCodeDescription(tools),596 description: buildRunCodeDescription(tools),
421 inputSchema: context ? BOUND_INPUT : FULL_INPUT,597 inputSchema: context ? BOUND_INPUT : FULL_INPUT,
422 },598 },
423 createRunCodeTool(service, context),599 createRunCodeTool(service, context, onApprovalPrompt),
424 );600 );
425 601
426 return mcp;602 return mcp;
427}603}
428 604
429// ---------------------------------------------------------------------------605function createDelegatingService(ref: { current: McpExecutorService }): McpExecutorService {
430// Request handler606 return {
431// ---------------------------------------------------------------------------607 createTask: async (input) => await ref.current.createTask(input),
608 getTask: async (taskId, workspaceId) => await ref.current.getTask(taskId, workspaceId),
609 subscribe: (taskId, listener) => ref.current.subscribe(taskId, listener),
610 bootstrapAnonymousContext: async (sessionId) => await ref.current.bootstrapAnonymousContext(sessionId),
611 listTools: async (context) => await ref.current.listTools(context),
612 listToolsForTypecheck: ref.current.listToolsForTypecheck
613 ? async (context) => await ref.current.listToolsForTypecheck!(context)
614 : undefined,
615 listPendingApprovals: ref.current.listPendingApprovals
616 ? async (workspaceId) => await ref.current.listPendingApprovals!(workspaceId)
617 : undefined,
618 resolveApproval: ref.current.resolveApproval
619 ? async (input) => await ref.current.resolveApproval!(input)
620 : undefined,
621 };
622}
432 623
433export async function handleMcpRequest(624const mcpSessionRuntimes = new Map<
625 string,
626 {
627 transport: WebStandardStreamableHTTPServerTransport;
628 mcp: McpServer;
629 serviceRef: { current: McpExecutorService };
630 }
631>();
632
633async function handleStatelessMcpRequest(
434 service: McpExecutorService,634 service: McpExecutorService,
435 request: Request,635 request: Request,
436 context?: McpWorkspaceContext,636 context?: McpWorkspaceContext,
@@ -449,3 +649,74 @@ export async function handleMcpRequest( · 12 unmodified lines
449 await mcp.close().catch(() => {});649 await mcp.close().catch(() => {});
450 }650 }
451}651}
652
653// ---------------------------------------------------------------------------
654// Request handler
655// ---------------------------------------------------------------------------
656
657export async function handleMcpRequest(
658 service: McpExecutorService,
659 request: Request,
660 context?: McpWorkspaceContext,
661): Promise<Response> {
662 const runtimeSessionId = request.headers.get("mcp-session-id") ?? undefined;
663
664 if (runtimeSessionId) {
665 const runtime = mcpSessionRuntimes.get(runtimeSessionId);
666 if (runtime) {
667 runtime.serviceRef.current = service;
668 return await runtime.transport.handleRequest(request);
669 }
670
671 const headers = new Headers(request.headers);
672 headers.delete("mcp-session-id");
673 const requestWithoutSession = new Request(request, { headers });
674 return await handleStatelessMcpRequest(service, requestWithoutSession, context);
675 }
676
677 const serviceRef = { current: service };
678 const delegatingService = createDelegatingService(serviceRef);
679 const mcp = await createMcpServer(delegatingService, context);
680
681 const transport = new WebStandardStreamableHTTPServerTransport({
682 sessionIdGenerator: () => crypto.randomUUID(),
683 enableJsonResponse: true,
684 onsessioninitialized: (sessionId) => {
685 mcpSessionRuntimes.set(sessionId, {
686 transport,
687 mcp,
688 serviceRef,
689 });
690 },
691 onsessionclosed: async (sessionId) => {
692 if (!sessionId) {
693 return;
694 }
695
696 const runtime = mcpSessionRuntimes.get(sessionId);
697 if (!runtime) {
698 return;
699 }
700
701 mcpSessionRuntimes.delete(sessionId);
702 await runtime.mcp.close().catch(() => {});
703 },
704 });
705
706 try {
707 await mcp.connect(transport);
708 const response = await transport.handleRequest(request);
709
710 const initializedSessionId = response.headers.get("mcp-session-id") ?? undefined;
711 if (!initializedSessionId) {
712 await transport.close().catch(() => {});
713 await mcp.close().catch(() => {});
714 }
715
716 return response;
717 } catch (error) {
718 await transport.close().catch(() => {});
719 await mcp.close().catch(() => {});
720 throw error;
721 }
722}
executor/lib/openapi-cache.test.ts+54−0
@@ -425,6 +425,60 @@ describe("buildOpenApiToolsFromPrepared", () => { · 424 unmodified lines
425 expect(tool!.metadata?.returnsType).not.toBe("unknown");425 expect(tool!.metadata?.returnsType).not.toBe("unknown");
426 });426 });
427 427
428 test("quotes non-identifier parameter names in args type hints", async () => {
429 const spec: Record<string, unknown> = {
430 openapi: "3.0.3",
431 info: { title: "Header names", version: "1.0.0" },
432 servers: [{ url: "https://api.example.com" }],
433 paths: {
434 "/meta": {
435 get: {
436 operationId: "meta/get",
437 tags: ["meta"],
438 parameters: [
439 {
440 name: "X-GitHub-Api-Version",
441 in: "header",
442 required: false,
443 schema: { type: "string" },
444 },
445 ],
446 responses: {
447 "200": {
448 description: "ok",
449 content: {
450 "application/json": {
451 schema: {
452 type: "object",
453 properties: {
454 ver: { type: "string" },
455 },
456 },
457 },
458 },
459 },
460 },
461 },
462 },
463 },
464 };
465
466 const prepared = await prepareOpenApiSpec(spec, "headers");
467 const tools = buildOpenApiToolsFromPrepared(
468 {
469 type: "openapi",
470 name: "github",
471 spec,
472 baseUrl: "https://api.example.com",
473 },
474 prepared,
475 );
476
477 const tool = tools.find((t) => t.metadata?.operationId === "meta/get");
478 expect(tool).toBeDefined();
479 expect(tool!.metadata?.argsType).toContain('"X-GitHub-Api-Version"?: string');
480 });
481
428 test("resolves OpenAPI component schema refs for return type hints", async () => {482 test("resolves OpenAPI component schema refs for return type hints", async () => {
429 const spec: Record<string, unknown> = {483 const spec: Record<string, unknown> = {
430 openapi: "3.0.3",484 openapi: "3.0.3",
executor/lib/openapi-real-specs.test.ts+26−0
@@ -236,6 +236,32 @@ describe("real-world OpenAPI specs", () => { · 235 unmodified lines
236 300_000,236 300_000,
237 );237 );
238 238
239 test(
240 "github: meta/get has empty object input hint",
241 async () => {
242 const githubUrl =
243 "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml";
244
245 const prepared = await prepareOpenApiSpec(githubUrl, "github");
246 const tools = buildOpenApiToolsFromPrepared(
247 {
248 type: "openapi",
249 name: "github",
250 spec: githubUrl,
251 baseUrl: prepared.servers[0] || "https://api.github.com",
252 },
253 prepared,
254 );
255
256 const tool = tools.find((t) => t.metadata?.operationId === "meta/get");
257
258 expect(tool).toBeDefined();
259 expect(tool!.path).toBe("github.meta.get");
260 expect(tool!.metadata!.argsType).toBe("{}");
261 },
262 300_000,
263 );
264
239 test(265 test(
240 "github: all budgets for org has non-unknown return hint",266 "github: all budgets for org has non-unknown return hint",
241 async () => {267 async () => {
executor/lib/tool_sources.ts+129−24
@@ -394,6 +394,15 @@ function responseTypeHintFromSchema( · 393 unmodified lines
394 return "unknown";394 return "unknown";
395}395}
396 396
397function formatTsPropertyKey(key: string): string {
398 // Keep simple identifiers unquoted for readability.
399 // Quote keys with dashes/spaces or other punctuation (e.g. headers).
400 if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)) {
401 return key;
402 }
403 return JSON.stringify(key);
404}
405
397/** Depth-limited + cycle-safe type hint generator for schemas (used as fallback). */406/** Depth-limited + cycle-safe type hint generator for schemas (used as fallback). */
398function jsonSchemaTypeHintFallback(407function jsonSchemaTypeHintFallback(
399 schema: unknown,408 schema: unknown,
@@ -477,7 +486,7 @@ function jsonSchemaTypeHintFallback( · 77 unmodified lines
477 }486 }
478 const inner = propEntries487 const inner = propEntries
479 .slice(0, 12)488 .slice(0, 12)
480 .map(([key, value]) => `${key}${required.has(key) ? "" : "?"}: ${jsonSchemaTypeHintFallback(value, depth + 1, componentSchemas, seenRefs)}`)489 .map(([key, value]) => `${formatTsPropertyKey(key)}${required.has(key) ? "" : "?"}: ${jsonSchemaTypeHintFallback(value, depth + 1, componentSchemas, seenRefs)}`)
481 .join("; ");490 .join("; ");
482 return `{ ${inner} }`;491 return `{ ${inner} }`;
483 }492 }
@@ -771,19 +780,20 @@ function compactOpenApiPaths( · 287 unmodified lines
771 780
772 if (hasGeneratedTypes) {781 if (hasGeneratedTypes) {
773 // Pre-compute lightweight type hint strings (the full schemas are in the .d.ts)782 // Pre-compute lightweight type hint strings (the full schemas are in the .d.ts)
783 const mergedParameters = normalizeParameters(operation.parameters).concat(sharedParameters);
784 const hasInputSchema =
785 mergedParameters.length > 0 || Object.keys(requestBodySchema).length > 0;
774 const combinedSchema: JsonSchema = {786 const combinedSchema: JsonSchema = {
775 type: "object",787 type: "object",
776 properties: {788 properties: {
777 ...Object.fromEntries(789 ...Object.fromEntries(
778 normalizeParameters(operation.parameters)790 mergedParameters
779 .concat(sharedParameters)
780 .map((param) => [param.name, param.schema]),791 .map((param) => [param.name, param.schema]),
781 ),792 ),
782 ...asRecord(requestBodySchema.properties),793 ...asRecord(requestBodySchema.properties),
783 },794 },
784 required: [795 required: [
785 ...normalizeParameters(operation.parameters)796 ...mergedParameters
786 .concat(sharedParameters)
787 .filter((param) => param.required)797 .filter((param) => param.required)
788 .map((param) => param.name as string),798 .map((param) => param.name as string),
789 ...((Array.isArray(requestBodySchema.required)799 ...((Array.isArray(requestBodySchema.required)
@@ -791,7 +801,9 @@ function compactOpenApiPaths( · 1 unmodified line
791 : []) as string[]),801 : []) as string[]),
792 ],802 ],
793 };803 };
794 compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas);804 compactOperation._argsTypeHint = hasInputSchema
805 ? jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas)
806 : "{}";
795 compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas);807 compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas);
796 } else {808 } else {
797 // Keep full schemas for the fallback path809 // Keep full schemas for the fallback path
@@ -998,7 +1010,9 @@ export function buildOpenApiToolsFromPrepared( · 200 unmodified lines
998 ],1010 ],
999 };1011 };
1000 1012
1001 argsType = jsonSchemaTypeHintFallback(combinedSchema);1013 const hasInputSchema = parameters.length > 0 || Object.keys(requestBodySchema).length > 0;
1014
1015 argsType = hasInputSchema ? jsonSchemaTypeHintFallback(combinedSchema) : "{}";
1002 returnsType = responseTypeHintFromSchema(responseSchema, responseStatus);1016 returnsType = responseTypeHintFromSchema(responseSchema, responseStatus);
1003 }1017 }
1004 1018
@@ -1363,6 +1377,11 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 358 unmodified lines
1363 credential: credentialSpec,1377 credential: credentialSpec,
1364 // Tag as graphql source so invokeTool knows to do dynamic path extraction1378 // Tag as graphql source so invokeTool knows to do dynamic path extraction
1365 _graphqlSource: config.name,1379 _graphqlSource: config.name,
1380 _runSpec: {
1381 kind: "graphql_raw" as const,
1382 endpoint: config.endpoint,
1383 authHeaders,
1384 },
1366 run: async (input: unknown, context) => {1385 run: async (input: unknown, context) => {
1367 const payload = asRecord(input);1386 const payload = asRecord(input);
1368 const query = String(payload.query ?? "");1387 const query = String(payload.query ?? "");
@@ -1433,6 +1452,14 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 64 unmodified lines
1433 argsType: gqlFieldArgsTypeHint(field.args, typeMap),1452 argsType: gqlFieldArgsTypeHint(field.args, typeMap),
1434 returnsType: gqlTypeToHint(field.type, typeMap),1453 returnsType: gqlTypeToHint(field.type, typeMap),
1435 },1454 },
1455 _runSpec: {
1456 kind: "graphql_field" as const,
1457 endpoint: config.endpoint,
1458 operationName: field.name,
1459 operationType,
1460 queryTemplate: exampleQuery,
1461 authHeaders,
1462 },
1436 // Pseudo-tools don't have a run — they exist for discovery and policy matching only1463 // Pseudo-tools don't have a run — they exist for discovery and policy matching only
1437 _pseudoTool: true,1464 _pseudoTool: true,
1438 run: async (input: unknown, context) => {1465 run: async (input: unknown, context) => {
@@ -1518,7 +1545,9 @@ export interface SerializedTool { · 79 unmodified lines
1518 * Data needed to reconstruct `run()`. Shape depends on source type.1545 * Data needed to reconstruct `run()`. Shape depends on source type.
1519 * OpenAPI: { kind: "openapi", baseUrl, method, pathTemplate, parameters, authHeaders }1546 * OpenAPI: { kind: "openapi", baseUrl, method, pathTemplate, parameters, authHeaders }
1520 * MCP: { kind: "mcp", url, transport?, queryParams?, toolName }1547 * MCP: { kind: "mcp", url, transport?, queryParams?, toolName }
1521 * GraphQL: { kind: "graphql", endpoint, operationName, operationType, auth? }1548 * GraphQL raw: { kind: "graphql_raw", endpoint, authHeaders }
1549 * GraphQL field: { kind: "graphql_field", endpoint, operationName, operationType, queryTemplate, authHeaders }
1550 * GraphQL (legacy compat): { kind: "graphql", endpoint, operationName, operationType, authHeaders }
1522 * Builtin: { kind: "builtin" } — run comes from DEFAULT_TOOLS1551 * Builtin: { kind: "builtin" } — run comes from DEFAULT_TOOLS
1523 */1552 */
1524 runSpec:1553 runSpec:
@@ -1537,6 +1566,19 @@ export interface SerializedTool { · 12 unmodified lines
1537 queryParams?: Record<string, string>;1566 queryParams?: Record<string, string>;
1538 toolName: string;1567 toolName: string;
1539 }1568 }
1569 | {
1570 kind: "graphql_raw";
1571 endpoint: string;
1572 authHeaders: Record<string, string>;
1573 }
1574 | {
1575 kind: "graphql_field";
1576 endpoint: string;
1577 operationName: string;
1578 operationType: "query" | "mutation";
1579 queryTemplate: string;
1580 authHeaders: Record<string, string>;
1581 }
1540 | {1582 | {
1541 kind: "graphql";1583 kind: "graphql";
1542 endpoint: string;1584 endpoint: string;
@@ -1589,6 +1631,36 @@ export function rehydrateTools( · 46 unmodified lines
1589 1631
1590 const readMethods = new Set(["get", "head", "options"]);1632 const readMethods = new Set(["get", "head", "options"]);
1591 1633
1634 async function executeGraphql(
1635 endpoint: string,
1636 authHeaders: Record<string, string>,
1637 query: string,
1638 variables: unknown,
1639 context: { credential?: { headers: Record<string, string> } },
1640 ): Promise<unknown> {
1641 const response = await fetch(endpoint, {
1642 method: "POST",
1643 headers: {
1644 "content-type": "application/json",
1645 ...authHeaders,
1646 ...(context.credential?.headers ?? {}),
1647 },
1648 body: JSON.stringify({ query, variables }),
1649 });
1650
1651 if (!response.ok) {
1652 const text = await response.text().catch(() => "");
1653 throw new Error(`GraphQL HTTP ${response.status}: ${text.slice(0, 500)}`);
1654 }
1655
1656 const result = await response.json() as { data?: unknown; errors?: unknown[] };
1657 if (result.errors && (!result.data || Object.keys(result.data as object).length === 0)) {
1658 throw new Error(`GraphQL errors: ${JSON.stringify(result.errors).slice(0, 1000)}`);
1659 }
1660 if (result.errors) return result;
1661 return result.data;
1662 }
1663
1592 return serialized.map((st) => {1664 return serialized.map((st) => {
1593 const base: Omit<ToolDefinition, "run"> = {1665 const base: Omit<ToolDefinition, "run"> = {
1594 path: st.path,1666 path: st.path,
@@ -1676,29 +1748,62 @@ export function rehydrateTools( · 81 unmodified lines
1676 };1748 };
1677 }1749 }
1678 1750
1751 if (st.runSpec.kind === "graphql_raw") {
1752 const { endpoint, authHeaders } = st.runSpec;
1753 return {
1754 ...base,
1755 run: async (input: unknown, context) => {
1756 const payload = asRecord(input);
1757 const query = typeof payload.query === "string" ? payload.query : "";
1758 if (!query.trim()) {
1759 throw new Error("GraphQL query string is required");
1760 }
1761 const variables = payload.variables;
1762 return await executeGraphql(endpoint, authHeaders, query, variables, context);
1763 },
1764 };
1765 }
1766
1767 if (st.runSpec.kind === "graphql_field") {
1768 const { endpoint, operationName, queryTemplate, authHeaders } = st.runSpec;
1769 return {
1770 ...base,
1771 run: async (input: unknown, context) => {
1772 const payload = asRecord(input);
1773 const hasExplicitQuery = typeof payload.query === "string" && payload.query.trim().length > 0;
1774 const query = hasExplicitQuery ? String(payload.query) : queryTemplate;
1775
1776 let variables = payload.variables;
1777 if (variables === undefined && !hasExplicitQuery) {
1778 const variablePayload: Record<string, unknown> = { ...payload };
1779 delete variablePayload.query;
1780 delete variablePayload.variables;
1781 variables = Object.keys(variablePayload).length > 0 ? variablePayload : undefined;
1782 }
1783
1784 const result = await executeGraphql(endpoint, authHeaders, query, variables, context);
1785 if (result && typeof result === "object") {
1786 return (result as Record<string, unknown>)[operationName];
1787 }
1788 return result;
1789 },
1790 };
1791 }
1792
1679 if (st.runSpec.kind === "graphql") {1793 if (st.runSpec.kind === "graphql") {
1794 // Legacy compat for older cached entries.
1680 const { endpoint, operationName, operationType, authHeaders } = st.runSpec;1795 const { endpoint, operationName, operationType, authHeaders } = st.runSpec;
1796 const queryTemplate = `${operationType} ${operationName}($input: JSON) { ${operationName}(input: $input) }`;
1681 return {1797 return {
1682 ...base,1798 ...base,
1683 run: async (input: unknown, context) => {1799 run: async (input: unknown, context) => {
1684 const payload = asRecord(input);1800 const payload = asRecord(input);
1685 const query = `${operationType} ${operationName}($input: JSON) { ${operationName}(input: $input) }`;1801 const variables = payload.variables ?? { input: payload };
1686 const response = await fetch(endpoint, {1802 const result = await executeGraphql(endpoint, authHeaders, queryTemplate, variables, context);
1687 method: "POST",1803 if (result && typeof result === "object") {
1688 headers: {1804 return (result as Record<string, unknown>)[operationName];
1689 "content-type": "application/json",
1690 ...authHeaders,
1691 ...(context.credential?.headers ?? {}),
1692 },
1693 body: JSON.stringify({ query, variables: { input: payload } }),
1694 });
1695 if (!response.ok) {
1696 const text = await response.text().catch(() => "");
1697 throw new Error(`GraphQL HTTP ${response.status}: ${text.slice(0, 500)}`);
1698 }1805 }
1699 const json = await response.json() as Record<string, unknown>;1806 return result;
1700 if (json.errors) throw new Error(`GraphQL errors: ${JSON.stringify(json.errors)}`);
1701 return (json.data as Record<string, unknown>)?.[operationName];
1702 },1807 },
1703 };1808 };
1704 }1809 }
executor/lib/typechecker.test.ts+197−22
@@ -1,5 +1,6 @@
1import { test, expect, describe } from "bun:test";1import { test, expect, describe } from "bun:test";
2import { generateToolDeclarations, generateToolInventory, typecheckCode, type TypecheckResult } from "./typechecker";2import { generateToolDeclarations, generateToolInventory, typecheckCode, type TypecheckResult } from "./typechecker";
3import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool_sources";
3import type { ToolDescriptor } from "./types";4import type { ToolDescriptor } from "./types";
4 5
5// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
@@ -44,7 +45,7 @@ describe("generateToolDeclarations", () => { · 38 unmodified lines
44 45
45 test("flat tool (no dots in path beyond single segment)", () => {46 test("flat tool (no dots in path beyond single segment)", () => {
46 const result = generateToolDeclarations([FLAT_TOOL]);47 const result = generateToolDeclarations([FLAT_TOOL]);
47 expect(result).toContain("get_time(input: {}): Promise<{ iso: string; unix: number }>;");48 expect(result).toContain("get_time(input?: {}): Promise<{ iso: string; unix: number }>;");
48 });49 });
49 50
50 test("nested tool (math.add)", () => {51 test("nested tool (math.add)", () => {
@@ -62,7 +63,7 @@ describe("generateToolDeclarations", () => { · 11 unmodified lines
62 expect(result).toContain("admin: {");63 expect(result).toContain("admin: {");
63 expect(result).toContain("send_announcement(input: { message: string; channel?: string }): Promise<{ sent: boolean }>;");64 expect(result).toContain("send_announcement(input: { message: string; channel?: string }): Promise<{ sent: boolean }>;");
64 // flat65 // flat
65 expect(result).toContain("get_time(input: {}): Promise<{ iso: string; unix: number }>;");66 expect(result).toContain("get_time(input?: {}): Promise<{ iso: string; unix: number }>;");
66 });67 });
67 68
68 test("missing argsType defaults to Record<string, unknown>", () => {69 test("missing argsType defaults to Record<string, unknown>", () => {
@@ -72,7 +73,7 @@ describe("generateToolDeclarations", () => { · 3 unmodified lines
72 approval: "auto",73 approval: "auto",
73 };74 };
74 const result = generateToolDeclarations([tool]);75 const result = generateToolDeclarations([tool]);
75 expect(result).toContain("bar(input: Record<string, unknown>): Promise<unknown>;");76 expect(result).toContain("bar(input?: Record<string, unknown>): Promise<unknown>;");
76 });77 });
77 78
78 test("missing returnsType defaults to unknown", () => {79 test("missing returnsType defaults to unknown", () => {
@@ -85,6 +86,41 @@ describe("generateToolDeclarations", () => { · 6 unmodified lines
85 const result = generateToolDeclarations([tool]);86 const result = generateToolDeclarations([tool]);
86 expect(result).toContain("baz(input: { x: number }): Promise<unknown>;");87 expect(result).toContain("baz(input: { x: number }): Promise<unknown>;");
87 });88 });
89
90 test("uses OpenAPI operations indexed-access types when source d.ts is provided", () => {
91 const tools: ToolDescriptor[] = [
92 {
93 path: "github.issues.list_for_repo",
94 description: "List issues for a repo",
95 approval: "auto",
96 source: "openapi:github",
97 operationId: "issues/list-for-repo",
98 argsType: "{ owner: string; repo: string }",
99 returnsType: "unknown",
100 },
101 ];
102
103 const sourceDts = `
104export interface operations {
105 "issues/list-for-repo": {
106 parameters: { path: { owner: string; repo: string } };
107 responses: { 200: { content: { "application/json": { ok: true }[] } } };
108 };
109}
110`;
111
112 const result = generateToolDeclarations(tools, {
113 sourceDtsBySource: {
114 "openapi:github": sourceDts,
115 },
116 });
117
118 expect(result).toContain("type ToolInput<Op>");
119 expect(result).toContain("interface operations");
120 expect(result).toContain(
121 "list_for_repo(input: ToolInput<operations[\"issues/list-for-repo\"]>): Promise<ToolOutput<operations[\"issues/list-for-repo\"]>>;",
122 );
123 });
88});124});
89 125
90// ---------------------------------------------------------------------------126// ---------------------------------------------------------------------------
@@ -96,29 +132,20 @@ describe("generateToolInventory", () => { · 5 unmodified lines
96 expect(generateToolInventory([])).toBe("");132 expect(generateToolInventory([])).toBe("");
97 });133 });
98 134
99 test("includes tool paths and type signatures", () => {135 test("includes namespace summary and discover guidance", () => {
100 const result = generateToolInventory(ALL_TOOLS);136 const result = generateToolInventory(ALL_TOOLS);
101 expect(result).toContain("tools.math.add(input: { a: number; b: number }): Promise<{ result: number }>");137 expect(result).toContain("You have access to these tool namespaces:");
102 expect(result).toContain("tools.admin.send_announcement(input: { message: string; channel?: string }): Promise<{ sent: boolean }>");138 expect(result).toContain("admin (1)");
103 expect(result).toContain("tools.get_time(input: {}): Promise<{ iso: string; unix: number }>");139 expect(result).toContain("math (1)");
140 expect(result).toContain("Use `tools.discover({ query, depth?, limit? })` first");
141 expect(result).toContain("Never shadow the global `tools` object");
104 });142 });
105 143
106 test("shows [approval required] for required-approval tools", () => {144 test("includes example callable paths", () => {
107 const result = generateToolInventory(ALL_TOOLS);145 const result = generateToolInventory(ALL_TOOLS);
108 expect(result).toContain("[approval required]");146 expect(result).toContain("Example callable paths:");
109 // math.add is auto — should NOT have the tag147 expect(result).toContain("tools.math.add(...)");
110 const mathLine = result.split("\n").find((l) => l.includes("tools.math.add"));148 expect(result).toContain("tools.admin.send_announcement(...)");
111 expect(mathLine).not.toContain("[approval required]");
112 // admin.send_announcement is required — should have the tag
113 const adminLine = result.split("\n").find((l) => l.includes("tools.admin.send_announcement"));
114 expect(adminLine).toContain("[approval required]");
115 });
116
117 test("includes descriptions", () => {
118 const result = generateToolInventory(ALL_TOOLS);
119 expect(result).toContain("Add two numbers");
120 expect(result).toContain("Send an announcement");
121 expect(result).toContain("Get current time");
122 });149 });
123});150});
124 151
@@ -240,6 +267,46 @@ return { sum: sum.result, sent: announcement.sent, time: time.unix }; · 115 unmodified lines
240 expect(result.ok).toBe(true);267 expect(result.ok).toBe(true);
241 });268 });
242 269
270 test("OpenAPI indexed-access declarations typecheck like Monaco", () => {
271 const tools: ToolDescriptor[] = [
272 {
273 path: "github.issues.list_for_repo",
274 description: "List issues for a repo",
275 approval: "auto",
276 source: "openapi:github",
277 operationId: "issues/list-for-repo",
278 },
279 ];
280
281 const sourceDts = `
282export interface operations {
283 "issues/list-for-repo": {
284 parameters: { path: { owner: string; repo: string }; query: { state?: "open" | "closed" } };
285 responses: { 200: { content: { "application/json": { id: number; title: string }[] } } };
286 };
287}
288`;
289
290 const declarations = generateToolDeclarations(tools, {
291 sourceDtsBySource: {
292 "openapi:github": sourceDts,
293 },
294 });
295
296 const ok = typecheckCode(
297 'const rows = await tools.github.issues.list_for_repo({ owner: "answeroverflow", repo: "answeroverflow", state: "open" }); return rows[0]?.id;',
298 declarations,
299 );
300 expect(ok.ok).toBe(true);
301
302 const bad = typecheckCode(
303 'await tools.github.issues.list_for_repo({ owner: 123, repo: "answeroverflow" });',
304 declarations,
305 );
306 expect(bad.ok).toBe(false);
307 expect(bad.errors.length).toBeGreaterThan(0);
308 });
309
243 test("optional parameter can be provided", () => {310 test("optional parameter can be provided", () => {
244 const result = typecheckCode(311 const result = typecheckCode(
245 'const r = await tools.admin.send_announcement({ message: "hi", channel: "general" }); return r.sent;',312 'const r = await tools.admin.send_announcement({ message: "hi", channel: "general" }); return r.sent;',
@@ -247,6 +314,114 @@ return { sum: sum.result, sent: announcement.sent, time: time.unix }; · 1 unmodified line
247 );314 );
248 expect(result.ok).toBe(true);315 expect(result.ok).toBe(true);
249 });316 });
317
318 test("discover remains callable when OpenAPI fallback args contain hyphenated header names", async () => {
319 const spec: Record<string, unknown> = {
320 openapi: "3.0.3",
321 info: { title: "Header names", version: "1.0.0" },
322 servers: [{ url: "https://api.example.com" }],
323 paths: {
324 "/meta": {
325 get: {
326 operationId: "meta/get",
327 tags: ["meta"],
328 parameters: [
329 {
330 name: "X-GitHub-Api-Version",
331 in: "header",
332 required: false,
333 schema: { type: "string" },
334 },
335 ],
336 responses: {
337 "200": {
338 description: "ok",
339 content: {
340 "application/json": {
341 schema: {
342 type: "object",
343 properties: {
344 ok: { type: "boolean" },
345 },
346 },
347 },
348 },
349 },
350 },
351 },
352 },
353 },
354 };
355
356 const prepared = await prepareOpenApiSpec(spec, "headers");
357 const built = buildOpenApiToolsFromPrepared(
358 {
359 type: "openapi",
360 name: "github",
361 spec,
362 baseUrl: "https://api.example.com",
363 },
364 prepared,
365 );
366
367 const descriptors: ToolDescriptor[] = built.map((tool) => ({
368 path: tool.path,
369 description: tool.description,
370 approval: tool.approval,
371 source: tool.source,
372 argsType: tool.metadata?.argsType,
373 returnsType: tool.metadata?.returnsType,
374 operationId: tool.metadata?.operationId,
375 }));
376 descriptors.push({
377 path: "discover",
378 description: "Discover tools",
379 approval: "auto",
380 source: "system",
381 argsType: "{ query: string; depth?: number; limit?: number }",
382 returnsType: "unknown",
383 });
384
385 const declarations = generateToolDeclarations(descriptors);
386 const result = typecheckCode(
387 'const found = await tools.discover({ query: "github issues" }); return found;',
388 declarations,
389 );
390
391 expect(result.ok).toBe(true);
392 expect(result.errors).toHaveLength(0);
393 });
394
395 test("invalid type hint strings are downgraded instead of corrupting declarations", () => {
396 const tools: ToolDescriptor[] = [
397 {
398 path: "github.meta.get",
399 description: "Get metadata",
400 approval: "auto",
401 // Intentionally invalid TS type expression (unquoted hyphenated key)
402 argsType: "{ X-GitHub-Api-Version?: string }",
403 returnsType: "unknown",
404 },
405 {
406 path: "discover",
407 description: "Discover tools",
408 approval: "auto",
409 argsType: "{ query: string; depth?: number; limit?: number }",
410 returnsType: "unknown",
411 },
412 ];
413
414 const declarations = generateToolDeclarations(tools);
415 expect(declarations).toContain("get(input: Record<string, unknown>): Promise<unknown>;");
416
417 const result = typecheckCode(
418 'const found = await tools.discover({ query: "github" }); return found;',
419 declarations,
420 );
421
422 expect(result.ok).toBe(true);
423 expect(result.errors).toHaveLength(0);
424 });
250});425});
251 426
252// ---------------------------------------------------------------------------427// ---------------------------------------------------------------------------
executor/lib/typechecker.ts+124−11
@@ -19,6 +19,76 @@ import type { ToolDescriptor } from "./types"; · 18 unmodified lines
19// Tool declarations generation19// Tool declarations generation
20// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
21 21
22let cachedTypeScript: typeof import("typescript") | null | undefined;
23
24function getTypeScriptModule(): typeof import("typescript") | null {
25 if (cachedTypeScript === undefined) {
26 try {
27 cachedTypeScript = require("typescript");
28 } catch {
29 cachedTypeScript = null;
30 }
31 }
32 return cachedTypeScript ?? null;
33}
34
35function isValidTypeExpression(typeExpression: string): boolean {
36 const ts = getTypeScriptModule();
37 if (!ts) {
38 // Best-effort fallback when TS isn't available.
39 return !/[\r\n`]/.test(typeExpression);
40 }
41
42 try {
43 const sourceFile = ts.createSourceFile(
44 "_type_expr_check.ts",
45 `type __T = ${typeExpression};`,
46 ts.ScriptTarget.ESNext,
47 true,
48 ts.ScriptKind.TS,
49 );
50 const diagnostics = (
51 sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] }
52 ).parseDiagnostics ?? [];
53 return diagnostics.length === 0;
54 } catch {
55 return false;
56 }
57}
58
59function safeTypeExpression(raw: string | undefined, fallback: string): string {
60 const typeExpression = raw?.trim();
61 if (!typeExpression) return fallback;
62 return isValidTypeExpression(typeExpression) ? typeExpression : fallback;
63}
64
65const OPENAPI_HELPER_TYPES = `
66type _OrEmpty<T> = [T] extends [never] ? {} : T;
67type _Simplify<T> = { [K in keyof T]: T[K] } & {};
68type ToolInput<Op> = _Simplify<
69 _OrEmpty<Op extends { parameters: { query: infer Q } } ? { [K in keyof Q]: Q[K] } : never> &
70 _OrEmpty<Op extends { parameters: { path: infer P } } ? { [K in keyof P]: P[K] } : never> &
71 _OrEmpty<Op extends { requestBody: { content: { "application/json": infer B } } } ? B : never>
72>;
73type ToolOutput<Op> =
74 Op extends { responses: { 200: { content: { "application/json": infer R } } } } ? R :
75 Op extends { responses: { 201: { content: { "application/json": infer R } } } } ? R :
76 Op extends { responses: { 202: { content: { "application/json": infer R } } } } ? R :
77 Op extends { responses: { 204: unknown } } ? void :
78 Op extends { responses: { 205: unknown } } ? void :
79 unknown;
80`;
81
82function stripExportKeywordsForTypechecker(dts: string): string {
83 // openapi-typescript emits `export interface ...`; for our single-file checker
84 // we want ambient-like declarations in script scope.
85 return dts.replace(/\bexport\s+/g, "").trim();
86}
87
88export interface GenerateToolDeclarationOptions {
89 sourceDtsBySource?: Record<string, string>;
90}
91
22/**92/**
23 * Build a `declare const tools: { ... }` block from flat tool descriptors.93 * Build a `declare const tools: { ... }` block from flat tool descriptors.
24 *94 *
@@ -29,7 +99,10 @@ import type { ToolDescriptor } from "./types"; · 4 unmodified lines
29 * Tool paths like "math.add" or "admin.send_announcement" are split on "."99 * Tool paths like "math.add" or "admin.send_announcement" are split on "."
30 * and nested into a type tree.100 * and nested into a type tree.
31 */101 */
32export function generateToolDeclarations(tools: ToolDescriptor[]): string {102export function generateToolDeclarations(
103 tools: ToolDescriptor[],
104 options?: GenerateToolDeclarationOptions,
105): string {
33 106
34 // Legacy compat: collect schemaTypes from tools that use the old format107 // Legacy compat: collect schemaTypes from tools that use the old format
35 const allSchemas = new Map<string, string>();108 const allSchemas = new Map<string, string>();
@@ -50,6 +123,7 @@ export function generateToolDeclarations(tools: ToolDescriptor[]): string { · 14 unmodified lines
50 }123 }
51 124
52 const root: TreeNode = { children: new Map() };125 const root: TreeNode = { children: new Map() };
126 const dtsSources = new Set(Object.keys(options?.sourceDtsBySource ?? {}));
53 127
54 for (const tool of tools) {128 for (const tool of tools) {
55 const segments = tool.path.split(".");129 const segments = tool.path.split(".");
@@ -73,9 +147,18 @@ export function generateToolDeclarations(tools: ToolDescriptor[]): string { · 17 unmodified lines
73 for (const [key, child] of node.children) {147 for (const [key, child] of node.children) {
74 if (child.tool) {148 if (child.tool) {
75 const tool = child.tool;149 const tool = child.tool;
76 const args = tool.argsType || "Record<string, unknown>";150 if (tool.operationId && tool.source && dtsSources.has(tool.source)) {
77 const returns = tool.returnsType || "unknown";151 const opKey = JSON.stringify(tool.operationId);
78 lines.push(`${pad}${key}(input: ${args}): Promise<${returns}>;`);152 lines.push(`${pad}${key}(input: ToolInput<operations[${opKey}]>): Promise<ToolOutput<operations[${opKey}]>>;`);
153 } else {
154 const hasArgsType = Boolean(tool.argsType?.trim());
155 const args = safeTypeExpression(tool.argsType, "Record<string, unknown>");
156 const returns = safeTypeExpression(tool.returnsType, "unknown");
157 const inputParam = !hasArgsType || args === "{}"
158 ? `input?: ${args}`
159 : `input: ${args}`;
160 lines.push(`${pad}${key}(${inputParam}): Promise<${returns}>;`);
161 }
79 } else {162 } else {
80 lines.push(`${pad}${key}: {`);163 lines.push(`${pad}${key}: {`);
81 lines.push(renderNode(child, indent + 1));164 lines.push(renderNode(child, indent + 1));
@@ -96,6 +179,17 @@ export function generateToolDeclarations(tools: ToolDescriptor[]): string { · 14 unmodified lines
96 }179 }
97 }180 }
98 181
182 const sourceDtsBySource = options?.sourceDtsBySource ?? {};
183 const dtsEntries = Object.entries(sourceDtsBySource)
184 .filter(([, dts]) => typeof dts === "string" && dts.trim().length > 0)
185 .sort(([a], [b]) => a.localeCompare(b));
186 if (dtsEntries.length > 0) {
187 parts.push(OPENAPI_HELPER_TYPES);
188 for (const [sourceKey, dts] of dtsEntries) {
189 parts.push(`// OpenAPI types from ${sourceKey}\n${stripExportKeywordsForTypechecker(dts)}`);
190 }
191 }
192
99 // The tools declaration193 // The tools declaration
100 parts.push(`declare const tools: {\n${renderNode(root, 1)}\n};`);194 parts.push(`declare const tools: {\n${renderNode(root, 1)}\n};`);
101 195
@@ -109,14 +203,33 @@ export function generateToolDeclarations(tools: ToolDescriptor[]): string { · 7 unmodified lines
109export function generateToolInventory(tools: ToolDescriptor[]): string {203export function generateToolInventory(tools: ToolDescriptor[]): string {
110 if (!tools || tools.length === 0) return "";204 if (!tools || tools.length === 0) return "";
111 205
112 const lines = tools.map((t) => {206 const namespaceCounts = new Map<string, number>();
113 const args = t.argsType || "Record<string, unknown>";207 for (const tool of tools) {
114 const returns = t.returnsType || "unknown";208 const topLevel = tool.path.split(".")[0] || tool.path;
115 const approval = t.approval === "required" ? " [approval required]" : "";209 namespaceCounts.set(topLevel, (namespaceCounts.get(topLevel) ?? 0) + 1);
116 return ` tools.${t.path}(input: ${args}): Promise<${returns}>${approval}\n ${t.description}`;210 }
117 });
118 211
119 return `\nAvailable tools in the sandbox:\n${lines.join("\n\n")}`;212 const namespaces = [...namespaceCounts.entries()]
213 .sort((a, b) => a[0].localeCompare(b[0]))
214 .map(([name, count]) => `${name} (${count})`);
215
216 const examples = tools
217 .filter((tool) => tool.path !== "discover")
218 .slice(0, 8)
219 .map((tool) => ` - tools.${tool.path}(...)`);
220
221 return [
222 "",
223 "You have access to these tool namespaces:",
224 ` ${namespaces.join(", ")}`,
225 "",
226 "Use `tools.discover({ query, depth?, limit? })` first to find the exact callable tool paths relevant to the user request.",
227 "Never shadow the global `tools` object (do NOT write `const tools = ...`).",
228 "Then call tools directly using the returned path.",
229 ...(examples.length > 0
230 ? ["", "Example callable paths:", ...examples]
231 : []),
232 ].join("\n");
120}233}
121 234
122// ---------------------------------------------------------------------------235// ---------------------------------------------------------------------------
executor/lib/workspace-tool-cache.test.ts+89−0
@@ -269,6 +269,95 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 268 unmodified lines
269 expect(restored[0]!.runSpec.kind).toBe("mcp");269 expect(restored[0]!.runSpec.kind).toBe("mcp");
270 });270 });
271 271
272 test("GraphQL raw and field tools survive cache round-trip", async () => {
273 const graphqlTools: ToolDefinition[] = [
274 {
275 path: "linear.graphql",
276 description: "Execute GraphQL",
277 approval: "auto",
278 source: "graphql:linear",
279 metadata: {
280 argsType: "{ query: string; variables?: Record<string, unknown> }",
281 returnsType: "unknown",
282 },
283 _runSpec: {
284 kind: "graphql_raw" as const,
285 endpoint: "https://linear.example/graphql",
286 authHeaders: {},
287 },
288 run: async () => ({ ok: true }),
289 },
290 {
291 path: "linear.query.teams",
292 description: "List teams",
293 approval: "auto",
294 source: "graphql:linear",
295 metadata: {
296 argsType: "{}",
297 returnsType: "unknown",
298 },
299 _pseudoTool: true,
300 _runSpec: {
301 kind: "graphql_field" as const,
302 endpoint: "https://linear.example/graphql",
303 operationName: "teams",
304 operationType: "query" as const,
305 queryTemplate: "query teams { teams { nodes { id name } } }",
306 authHeaders: {},
307 },
308 run: async () => ({ ok: true }),
309 },
310 ];
311
312 const serialized = serializeTools(graphqlTools);
313 expect(serialized[0]!.runSpec.kind).toBe("graphql_raw");
314 expect(serialized[1]!.runSpec.kind).toBe("graphql_field");
315
316 const restored = JSON.parse(JSON.stringify(serialized)) as SerializedTool[];
317 const rehydrated = rehydrateTools(restored, makeBaseTools());
318
319 const rawTool = rehydrated.find((tool) => tool.path === "linear.graphql");
320 const teamsTool = rehydrated.find((tool) => tool.path === "linear.query.teams");
321 expect(rawTool).toBeDefined();
322 expect(teamsTool).toBeDefined();
323
324 const originalFetch = globalThis.fetch;
325 const calls: Array<{ query: string; variables: unknown }> = [];
326 globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
327 const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string; variables?: unknown };
328 calls.push({ query: body.query ?? "", variables: body.variables });
329
330 if ((body.query ?? "").includes("teams")) {
331 return new Response(JSON.stringify({ data: { teams: [{ id: "team_1", name: "Core" }] } }), {
332 status: 200,
333 headers: { "content-type": "application/json" },
334 });
335 }
336
337 return new Response(JSON.stringify({ data: { viewer: { id: "user_1" } } }), {
338 status: 200,
339 headers: { "content-type": "application/json" },
340 });
341 }) as typeof fetch;
342
343 try {
344 const rawResult = await rawTool!.run(
345 { query: "query viewer { viewer { id } }" },
346 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },
347 );
348 const teamsResult = await teamsTool!.run(
349 {},
350 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },
351 );
352
353 expect(rawResult).toEqual({ viewer: { id: "user_1" } });
354 expect(teamsResult).toEqual([{ id: "team_1", name: "Core" }]);
355 expect(calls.length).toBe(2);
356 } finally {
357 globalThis.fetch = originalFetch;
358 }
359 });
360
272 test("full WorkspaceToolSnapshot round-trip", async () => {361 test("full WorkspaceToolSnapshot round-trip", async () => {
273 const prepared = await prepareOpenApiSpec(SMALL_SPEC, "widgets");362 const prepared = await prepareOpenApiSpec(SMALL_SPEC, "widgets");
274 const tools = buildOpenApiToolsFromPrepared(363 const tools = buildOpenApiToolsFromPrepared(
executor/mcp-gateway.ts+351−0
@@ -0,0 +1,351 @@
1import { ConvexHttpClient } from "convex/browser";
2import { createRemoteJWKSet, jwtVerify } from "jose";
3import { api } from "./convex/_generated/api";
4import { handleMcpRequest, type McpWorkspaceContext } from "./lib/mcp_server";
5import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescriptor } from "./lib/types";
6
7const convexUrlFromEnv = Bun.env.CONVEX_URL;
8if (!convexUrlFromEnv) {
9 throw new Error("CONVEX_URL is required.");
10}
11const convexUrl: string = convexUrlFromEnv;
12
13const mcpAuthorizationServer =
14 Bun.env.MCP_AUTHORIZATION_SERVER
15 ?? Bun.env.MCP_AUTHORIZATION_SERVER_URL
16 ?? Bun.env.WORKOS_AUTHKIT_ISSUER
17 ?? Bun.env.WORKOS_AUTHKIT_DOMAIN;
18const mcpGatewayRequireAuth = Bun.env.MCP_GATEWAY_REQUIRE_AUTH === "1";
19const mcpAuthEnabled = mcpGatewayRequireAuth && Boolean(mcpAuthorizationServer);
20const mcpJwks = mcpAuthorizationServer
21 ? createRemoteJWKSet(new URL("/oauth2/jwks", mcpAuthorizationServer))
22 : null;
23
24function parseBearerToken(request: Request): string | null {
25 const header = request.headers.get("authorization");
26 if (!header || !header.startsWith("Bearer ")) {
27 return null;
28 }
29 const token = header.slice("Bearer ".length).trim();
30 return token.length > 0 ? token : null;
31}
32
33function resourceMetadataUrl(request: Request): string {
34 const url = new URL(request.url);
35 return `${url.origin}/.well-known/oauth-protected-resource`;
36}
37
38function unauthorizedMcpResponse(request: Request, message: string): Response {
39 const challenge = [
40 'Bearer error="unauthorized"',
41 'error_description="Authorization needed"',
42 `resource_metadata="${resourceMetadataUrl(request)}"`,
43 ].join(", ");
44
45 return Response.json(
46 { error: message },
47 {
48 status: 401,
49 headers: {
50 "WWW-Authenticate": challenge,
51 },
52 },
53 );
54}
55
56async function verifyMcpToken(request: Request): Promise<boolean> {
57 if (!mcpAuthEnabled || !mcpAuthorizationServer || !mcpJwks) {
58 return true;
59 }
60
61 const token = parseBearerToken(request);
62 if (!token) {
63 return false;
64 }
65
66 try {
67 const { payload } = await jwtVerify(token, mcpJwks, {
68 issuer: mcpAuthorizationServer,
69 });
70 return typeof payload.sub === "string" && payload.sub.length > 0;
71 } catch {
72 return false;
73 }
74}
75
76async function verifyOptionalMcpToken(request: Request): Promise<string | null> {
77 if (!mcpAuthorizationServer || !mcpJwks) {
78 return null;
79 }
80
81 const token = parseBearerToken(request);
82 if (!token) {
83 return null;
84 }
85
86 const { payload } = await jwtVerify(token, mcpJwks, {
87 issuer: mcpAuthorizationServer,
88 });
89 if (typeof payload.sub !== "string" || payload.sub.length === 0) {
90 return null;
91 }
92
93 return payload.sub;
94}
95
96function parseRequestedContext(url: URL): {
97 workspaceId?: string;
98 actorId?: string;
99 clientId?: string;
100 sessionId?: string;
101} {
102 const workspaceId = url.searchParams.get("workspaceId") ?? undefined;
103 const actorId = url.searchParams.get("actorId") ?? undefined;
104 const clientId = url.searchParams.get("clientId") ?? undefined;
105 const sessionId = url.searchParams.get("sessionId") ?? undefined;
106 return { workspaceId, actorId, clientId, sessionId };
107}
108
109function createService(context?: McpWorkspaceContext, bearerToken?: string) {
110 const convex = new ConvexHttpClient(convexUrl);
111 if (bearerToken) {
112 convex.setAuth(bearerToken);
113 }
114
115 const workspaceId = context?.workspaceId;
116 const sessionId = context?.sessionId;
117
118 return {
119 createTask: async (input: {
120 code: string;
121 timeoutMs?: number;
122 runtimeId?: string;
123 metadata?: Record<string, unknown>;
124 workspaceId: string;
125 actorId: string;
126 clientId?: string;
127 }) => {
128 const created = await convex.mutation(api.executor.createTask, {
129 workspaceId: input.workspaceId,
130 sessionId,
131 code: input.code,
132 timeoutMs: input.timeoutMs,
133 runtimeId: input.runtimeId,
134 metadata: input.metadata,
135 actorId: input.actorId,
136 clientId: input.clientId,
137 });
138 return created as { task: TaskRecord };
139 },
140
141 getTask: async (taskId: string, workspace?: string) => {
142 const effectiveWorkspaceId = workspace ?? workspaceId;
143 if (!effectiveWorkspaceId) {
144 return null;
145 }
146 const task = await convex.query(api.workspace.getTaskInWorkspace, {
147 workspaceId: effectiveWorkspaceId,
148 sessionId,
149 taskId,
150 });
151 return task as TaskRecord | null;
152 },
153
154 subscribe: () => () => {},
155
156 bootstrapAnonymousContext: async (requestedSessionId?: string) => {
157 const bootstrap = await convex.mutation(api.workspace.bootstrapAnonymousSession, {
158 sessionId: requestedSessionId,
159 });
160 return bootstrap as AnonymousContext;
161 },
162
163 listTools: async (toolContext?: { workspaceId: string; actorId?: string; clientId?: string }) => {
164 if (!toolContext) {
165 return [];
166 }
167 const tools = await convex.action(api.executorNode.listTools, {
168 workspaceId: toolContext.workspaceId,
169 sessionId,
170 actorId: toolContext.actorId,
171 clientId: toolContext.clientId,
172 });
173 return tools as ToolDescriptor[];
174 },
175
176 listPendingApprovals: async (approvalWorkspaceId: string) => {
177 const approvals = await convex.query(api.workspace.listPendingApprovals, {
178 workspaceId: approvalWorkspaceId,
179 sessionId,
180 });
181 return approvals as PendingApprovalRecord[];
182 },
183
184 resolveApproval: async (input: {
185 workspaceId: string;
186 approvalId: string;
187 decision: "approved" | "denied";
188 reviewerId?: string;
189 reason?: string;
190 }) => {
191 return await convex.mutation(api.executor.resolveApproval, {
192 workspaceId: input.workspaceId,
193 sessionId,
194 approvalId: input.approvalId,
195 decision: input.decision,
196 reviewerId: input.reviewerId,
197 reason: input.reason,
198 });
199 },
200 };
201}
202
203async function resolveContext(
204 requested: {
205 workspaceId?: string;
206 actorId?: string;
207 clientId?: string;
208 sessionId?: string;
209 },
210 bearerToken?: string,
211): Promise<McpWorkspaceContext | undefined> {
212 if (!requested.workspaceId) {
213 return undefined;
214 }
215
216 if (requested.actorId) {
217 return {
218 workspaceId: requested.workspaceId,
219 actorId: requested.actorId,
220 clientId: requested.clientId,
221 sessionId: requested.sessionId,
222 };
223 }
224
225 if (!bearerToken && !requested.sessionId) {
226 throw new Error("Provide actorId+sessionId or authenticate with MCP OAuth.");
227 }
228
229 const convex = new ConvexHttpClient(convexUrl);
230 if (bearerToken) {
231 convex.setAuth(bearerToken);
232 }
233
234 const requestContext = await convex.query(api.workspace.getRequestContext, {
235 workspaceId: requested.workspaceId,
236 sessionId: requested.sessionId,
237 });
238
239 return {
240 workspaceId: requestContext.workspaceId,
241 actorId: requestContext.actorId,
242 clientId: requested.clientId,
243 sessionId: requested.sessionId,
244 };
245}
246
247async function handleMcp(request: Request): Promise<Response> {
248 let verifiedSubject: string | null = null;
249
250 if (mcpGatewayRequireAuth) {
251 const authed = await verifyMcpToken(request);
252 if (!authed) {
253 return unauthorizedMcpResponse(request, "No valid bearer token provided.");
254 }
255 } else {
256 try {
257 verifiedSubject = await verifyOptionalMcpToken(request);
258 } catch {
259 verifiedSubject = null;
260 }
261 }
262
263 const url = new URL(request.url);
264 const requested = parseRequestedContext(url);
265 if (mcpGatewayRequireAuth && !requested.workspaceId) {
266 return Response.json(
267 { error: "workspaceId query parameter is required when MCP OAuth is enabled" },
268 { status: 400 },
269 );
270 }
271
272 const bearerToken = parseBearerToken(request) ?? undefined;
273 const effectiveBearerToken = verifiedSubject && bearerToken ? bearerToken : undefined;
274 let context: McpWorkspaceContext | undefined;
275 if (requested.workspaceId) {
276 try {
277 context = await resolveContext(requested, effectiveBearerToken);
278 } catch (error) {
279 return Response.json(
280 { error: error instanceof Error ? error.message : "Workspace authorization failed" },
281 { status: 403 },
282 );
283 }
284 }
285
286 const service = createService(context, effectiveBearerToken);
287 return await handleMcpRequest(service, request, context);
288}
289
290async function handleAuthServerMetadataProxy(): Promise<Response> {
291 if (!mcpAuthEnabled || !mcpAuthorizationServer) {
292 return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
293 }
294 const authServer = mcpAuthorizationServer;
295
296 const upstream = await fetch(new URL("/.well-known/oauth-authorization-server", authServer));
297 return new Response(upstream.body, {
298 status: upstream.status,
299 headers: {
300 "content-type": upstream.headers.get("content-type") ?? "application/json",
301 "cache-control": upstream.headers.get("cache-control") ?? "public, max-age=60",
302 },
303 });
304}
305
306function handleProtectedResourceMetadata(request: Request): Response {
307 if (!mcpAuthEnabled || !mcpAuthorizationServer) {
308 return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
309 }
310
311 const url = new URL(request.url);
312 return Response.json({
313 resource: `${url.origin}/mcp`,
314 authorization_servers: [mcpAuthorizationServer],
315 bearer_methods_supported: ["header"],
316 });
317}
318
319const port = Number(Bun.env.PORT ?? 3003);
320
321Bun.serve({
322 port,
323 async fetch(request) {
324 const url = new URL(request.url);
325
326 if (url.pathname === "/health") {
327 return new Response("ok");
328 }
329
330 if (url.pathname === "/" || url.pathname === "/mcp") {
331 if (request.method === "POST" || request.method === "GET" || request.method === "DELETE") {
332 return await handleMcp(request);
333 }
334 return new Response("Method Not Allowed", { status: 405 });
335 }
336
337 if (url.pathname === "/.well-known/oauth-protected-resource") {
338 return handleProtectedResourceMetadata(request);
339 }
340
341 if (url.pathname === "/.well-known/oauth-authorization-server") {
342 return await handleAuthServerMetadataProxy();
343 }
344
345 return new Response("Not found", { status: 404 });
346 },
347});
348
349console.log(
350 `[executor-mcp-gateway] listening on http://localhost:${port}/mcp (auth ${mcpGatewayRequireAuth ? "required" : "optional"})`,
351);
executor/package.json+3−1
@@ -4,7 +4,9 @@ · 3 unmodified lines
4 "type": "module",4 "type": "module",
5 "scripts": {5 "scripts": {
6 "deploy": "bunx convex deploy",6 "deploy": "bunx convex deploy",
7 "codegen": "bunx convex codegen"7 "codegen": "bunx convex codegen",
8 "dev:mcp-gateway": "bun --hot mcp-gateway.ts",
9 "start:mcp-gateway": "bun mcp-gateway.ts"
8 },10 },
9 "dependencies": {11 "dependencies": {
10 "convex": "^1.31.7"12 "convex": "^1.31.7"
executor/tsconfig.json+3−1
@@ -18,6 +18,8 @@ · 17 unmodified lines
18 "include": [18 "include": [
19 "packages/**/*.ts",19 "packages/**/*.ts",
20 "convex/**/*.ts",20 "convex/**/*.ts",
21 "lib/**/*.ts"21 "lib/**/*.ts",
22 "mcp-gateway.ts",
23 "vscode-elicitation-server.ts"
22 ]24 ]
23}25}
executor/vscode-elicitation-server.ts+158−0
@@ -0,0 +1,158 @@
1import { randomUUID } from "node:crypto";
2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
4import { z } from "zod";
5
6type Runtime = {
7 server: McpServer;
8 transport: WebStandardStreamableHTTPServerTransport;
9};
10
11const runtimes = new Map<string, Runtime>();
12
13function createRuntime(): Runtime {
14 const server = new McpServer(
15 { name: "vscode-elicitation-demo", version: "0.0.1" },
16 { capabilities: { tools: {} } },
17 );
18
19 server.registerTool(
20 "request_approval",
21 {
22 description: "Prompts the MCP client for approval using form elicitation.",
23 inputSchema: {
24 message: z.string().default("Approve this operation?"),
25 },
26 },
27 async ({ message }) => {
28 const capabilities = server.server.getClientCapabilities?.();
29 console.log("[elicitation-demo] client capabilities:", JSON.stringify(capabilities ?? {}));
30
31 const result = await server.server.elicitInput({
32 mode: "form",
33 message,
34 requestedSchema: {
35 type: "object",
36 properties: {
37 decision: {
38 type: "string",
39 title: "Decision",
40 oneOf: [
41 { const: "approved", title: "Approve" },
42 { const: "denied", title: "Deny" },
43 ],
44 default: "approved",
45 },
46 reason: {
47 type: "string",
48 title: "Reason",
49 maxLength: 200,
50 },
51 },
52 required: ["decision"],
53 },
54 }, { timeout: 120_000 });
55
56 const action = result.action;
57 if (action !== "accept") {
58 return {
59 content: [{ type: "text", text: `Elicitation ${action}` }],
60 structuredContent: { action },
61 };
62 }
63
64 const decision = result.content?.decision === "approved" ? "approved" : "denied";
65 const reason = typeof result.content?.reason === "string" ? result.content.reason : undefined;
66
67 return {
68 content: [{ type: "text", text: `Decision: ${decision}${reason ? ` (${reason})` : ""}` }],
69 structuredContent: {
70 action,
71 decision,
72 reason,
73 },
74 };
75 },
76 );
77
78 server.registerTool(
79 "ping",
80 {
81 description: "Simple health tool.",
82 inputSchema: {},
83 },
84 async () => ({
85 content: [{ type: "text", text: "pong" }],
86 }),
87 );
88
89 const transport = new WebStandardStreamableHTTPServerTransport({
90 sessionIdGenerator: () => randomUUID(),
91 enableJsonResponse: true,
92 onsessioninitialized: (sessionId) => {
93 runtimes.set(sessionId, { server, transport });
94 console.log(`[elicitation-demo] session initialized: ${sessionId}`);
95 },
96 onsessionclosed: async (sessionId) => {
97 if (!sessionId) return;
98 const runtime = runtimes.get(sessionId);
99 if (!runtime) return;
100 runtimes.delete(sessionId);
101 await runtime.server.close().catch(() => {});
102 console.log(`[elicitation-demo] session closed: ${sessionId}`);
103 },
104 });
105
106 return { server, transport };
107}
108
109async function handleMcp(request: Request): Promise<Response> {
110 const sessionId = request.headers.get("mcp-session-id") ?? undefined;
111
112 if (sessionId) {
113 const existing = runtimes.get(sessionId);
114 if (existing) {
115 return await existing.transport.handleRequest(request);
116 }
117
118 // Graceful recovery: ignore stale session header and start a new runtime.
119 const headers = new Headers(request.headers);
120 headers.delete("mcp-session-id");
121 request = new Request(request, { headers });
122 }
123
124 const runtime = createRuntime();
125
126 try {
127 await runtime.server.connect(runtime.transport);
128 const response = await runtime.transport.handleRequest(request);
129 const initializedSessionId = response.headers.get("mcp-session-id");
130 if (!initializedSessionId) {
131 await runtime.transport.close().catch(() => {});
132 await runtime.server.close().catch(() => {});
133 }
134 return response;
135 } catch (error) {
136 await runtime.transport.close().catch(() => {});
137 await runtime.server.close().catch(() => {});
138 throw error;
139 }
140}
141
142const port = Number(Bun.env.PORT ?? 8787);
143
144Bun.serve({
145 port,
146 routes: {
147 "/": new Response("ok"),
148 "/health": new Response("ok"),
149 "/mcp": {
150 POST: async (req) => await handleMcp(req),
151 GET: async (req) => await handleMcp(req),
152 DELETE: async (req) => await handleMcp(req),
153 },
154 },
155 fetch: () => new Response("Not found", { status: 404 }),
156});
157
158console.log(`[elicitation-demo] listening on http://localhost:${port}/mcp`);