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
88 * Starts:
99 * 1. Convex cloud dev function watcher ─┐
1010 * 2. Executor web UI (port 3002) ├─ all started concurrently
11− * 3. Assistant server (port 3000) │
12− * 4. Discord bot ─┘
11+ * 3. Executor MCP gateway (port 3003) │
12+ * 4. Assistant server (port 3000) │
13+ * 5. Discord bot ─┘
1314 *
1415 * All processes are killed when this script exits (Ctrl+C).
1516 * 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
2324 const colors = {
2425 convex: "\x1b[36m", // cyan
2526 web: "\x1b[34m", // blue
27+ mcp: "\x1b[33m", // yellow
2628 assistant: "\x1b[32m", // green
2729 bot: "\x1b[35m", // magenta
2830 reset: "\x1b[0m",
@@ -116,7 +118,7 @@ process.on("SIGTERM", shutdown); · 87 unmodified lines
116118
117119 // ── Kill stale processes from a previous run ──
118120
119−const DEV_PORTS = [3000, 3002];
121+const DEV_PORTS = [3000, 3002, 3003];
120122
121123 async function killStaleProcesses() {
122124 let killed = 0;
@@ -176,6 +178,15 @@ spawnService("web", ["bun", "run", "dev", "--", "-p", "3002"], { · 53 unmodified lines
176178 cwd: "./executor/apps/web",
177179 });
178180
181+spawnService("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+
179190 spawnService("assistant", ["bun", "run", "dev"], {
180191 cwd: "./assistant/packages/server",
181192 env: {
executor/README.md+8−0
@@ -27,6 +27,12 @@ Terminal 2: · 26 unmodified lines
2727 bun run dev:web
2828 ```
2929
30+Optional Terminal 3 (stateful MCP gateway for elicitation/sampling over Streamable HTTP):
31+
32+```bash
33+bun run dev:mcp-gateway
34+```
35+
3036 ## Tests
3137
3238 ```bash
@@ -36,6 +42,8 @@ bun test convex/database.test.ts convex/executor-mcp.e2e.test.ts · 3 unmodified lines
3642 ## Notes
3743
3844 - 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.
3947 - Set `MCP_AUTHORIZATION_SERVER` (or `MCP_AUTHORIZATION_SERVER_URL`) to enable MCP OAuth bearer-token verification.
4048 - When MCP OAuth is enabled, the server exposes `/.well-known/oauth-protected-resource` and proxies `/.well-known/oauth-authorization-server`.
4149 - 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
399399 }
400400
401401 function 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();
403411 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);
404417 const avatarUrl = workosProfile?.avatarUrl ?? null;
405418 const avatarLabel = workosProfile?.name || workosProfile?.email || "User";
406419 const avatarInitial = (avatarLabel[0] ?? "U").toUpperCase();
420+ const canDeleteAccount = deleteConfirmText === "DELETE";
407421
408422 const activeWorkspace = context
409423 ? workspaces.find((workspace) => workspace.id === context.workspaceId)
@@ -430,6 +444,27 @@ function SessionInfo() { · 20 unmodified lines
430444 }
431445 const signInHref = signInParams.size > 0 ? `/sign-in?${signInParams.toString()}` : "/sign-in";
432446
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+
433468 if (loading) {
434469 return (
435470 <div className="border-t border-border px-3 py-2">
@@ -441,44 +476,113 @@ function SessionInfo() { · 5 unmodified lines
441476 return (
442477 <div className="border-t border-border">
443478 {isSignedInToWorkos ? (
444− <DropdownMenu>
445− <DropdownMenuTrigger asChild>
446− <Button
447− variant="ghost"
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"
449− >
450− <span className="flex items-center gap-2 min-w-0">
451− {avatarUrl ? (
452− <img
453− src={avatarUrl}
454− alt={avatarLabel}
455− className="h-6 w-6 rounded-full border border-border object-cover"
456− />
457− ) : (
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">
459− {avatarInitial}
479+ <>
480+ <DropdownMenu>
481+ <DropdownMenuTrigger asChild>
482+ <Button
483+ variant="ghost"
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"
485+ >
486+ <span className="flex items-center gap-2 min-w-0">
487+ {avatarUrl ? (
488+ <img
489+ src={avatarUrl}
490+ alt={avatarLabel}
491+ className="h-6 w-6 rounded-full border border-border object-cover"
492+ />
493+ ) : (
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}
460503 </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}
467504 </span>
468− </span>
469− <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
470− </Button>
471− </DropdownMenuTrigger>
472− <DropdownMenuContent align="end" className="w-64">
473− <DropdownMenuLabel className="text-xs">
474− Account
475− </DropdownMenuLabel>
476− <DropdownMenuSeparator />
477− <DropdownMenuItem asChild className="text-xs">
478− <Link href="/sign-out">Sign out</Link>
479− </DropdownMenuItem>
480− </DropdownMenuContent>
481− </DropdownMenu>
505+ <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
506+ </Button>
507+ </DropdownMenuTrigger>
508+ <DropdownMenuContent align="end" className="w-64">
509+ <DropdownMenuLabel className="text-xs">
510+ Account
511+ </DropdownMenuLabel>
512+ <DropdownMenuSeparator />
513+ <DropdownMenuItem
514+ className="text-xs"
515+ onSelect={(event) => {
516+ event.preventDefault();
517+ setDeleteConfirmText("");
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+ </>
482586 ) : (
483587 <div className="px-3 py-3 space-y-2">
484588 {workosEnabled ? (
executor/apps/web/src/components/mcp-setup-card.tsx+32−1
@@ -41,6 +41,10 @@ function resolveMcpOrigin(windowOrigin: string): string { · 40 unmodified lines
4141 return windowOrigin;
4242 }
4343
44+function isLocalHostname(hostname: string): boolean {
45+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0";
46+}
47+
4448 export function McpSetupCard({
4549 workspaceId,
4650 actorId,
@@ -52,12 +56,26 @@ export function McpSetupCard({ · 5 unmodified lines
5256 }) {
5357 const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code");
5458 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+ });
5565
56− const origin = useMemo(() => {
66+ const defaultOrigin = useMemo(() => {
5767 if (typeof window === "undefined") return "";
5868 return resolveMcpOrigin(window.location.origin);
5969 }, []);
6070
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+
6179 const mcpUrl = useMemo(() => {
6280 const base = origin ? new URL("/mcp", origin) : new URL("http://localhost/mcp");
6381 if (workspaceId) base.searchParams.set("workspaceId", workspaceId);
@@ -94,6 +112,19 @@ export function McpSetupCard({ · 30 unmodified lines
94112 <div className="space-y-5">
95113 <div className="space-y-1.5">
96114 <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}
97128 <div className="flex items-center gap-2">
98129 <Input value={mcpUrl} readOnly className="h-8 text-xs font-mono bg-background" />
99130 <Button
executor/convex/_generated/api.d.ts+14−0
@@ -20,6 +20,14 @@ import type { GenericId as Id } from "convex/values"; · 19 unmodified lines
2020 * ```
2121 */
2222 export declare const api: {
23+ accounts: {
24+ deleteCurrentAccount: FunctionReference<
25+ "mutation",
26+ "public",
27+ { sessionId?: string },
28+ any
29+ >;
30+ };
2331 app: {
2432 getClientConfig: FunctionReference<"query", "public", {}, any>;
2533 getCurrentAccount: FunctionReference<
@@ -287,6 +295,12 @@ export declare const api: { · 261 unmodified lines
287295 { sessionId?: string; sourceId: string; workspaceId: string },
288296 any
289297 >;
298+ getRequestContext: FunctionReference<
299+ "query",
300+ "public",
301+ { sessionId?: string; workspaceId: string },
302+ any
303+ >;
290304 getTask: FunctionReference<
291305 "query",
292306 "public",
executor/convex/accounts.ts+229−0
@@ -0,0 +1,229 @@
1+import type { Id } from "./_generated/dataModel";
2+import type { MutationCtx } from "./_generated/server";
3+import { authedMutation } from "../lib/functionBuilders";
4+
5+async 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+
109+async 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+
164+export 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
22 import { convexTest } from "convex-test";
33 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
44 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5+import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
56 import { internal } from "./_generated/api";
67 import schema from "./schema";
78
@@ -125,6 +126,68 @@ test("MCP run_code survives delayed approval and completes", async () => { · 117 unmodified lines
125126 }
126127 }, 60_000);
127128
129+test("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+
128191 test("MCP run_code returns denied after approval denial", async () => {
129192 const t = setup();
130193 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
5757 }
5858
5959 function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
60− const signatureVersion = "v10";
60+ const signatureVersion = "v12";
6161 const parts = sources
6262 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
6363 .sort();
@@ -132,7 +132,7 @@ interface DtsStorageEntry { · 68 unmodified lines
132132 const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
133133
134134 /** Cache version — bump when PreparedOpenApiSpec shape changes. */
135−const OPENAPI_CACHE_VERSION = "v12";
135+const OPENAPI_CACHE_VERSION = "v14";
136136
137137 async function publish(
138138 ctx: any,
@@ -376,6 +376,12 @@ function getDecisionForContext( · 237 unmodified lines
376376 context: { workspaceId: string; actorId?: string; clientId?: string },
377377 policies: AccessPolicyRecord[],
378378 ): 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+
379385 const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow";
380386 const candidates = policies
381387 .filter((policy) => {
executor/convex/http.ts+89−31
@@ -9,15 +9,47 @@ import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescripto · 8 unmodified lines
99
1010 const http = httpRouter();
1111 const internalToken = process.env.EXECUTOR_INTERNAL_TOKEN ?? null;
12−const mcpAuthorizationServer =
13− process.env.MCP_AUTHORIZATION_SERVER
14− ?? process.env.MCP_AUTHORIZATION_SERVER_URL
15− ?? process.env.WORKOS_AUTHKIT_ISSUER
16− ?? process.env.WORKOS_AUTHKIT_DOMAIN;
17−const mcpAuthEnabled = Boolean(mcpAuthorizationServer);
18−const mcpJwks = mcpAuthorizationServer
19− ? createRemoteJWKSet(new URL("/oauth2/jwks", mcpAuthorizationServer))
20− : null;
12+const mcpJwksByServer = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
13+
14+function getMcpAuthorizationServer(): string | null {
15+ return process.env.MCP_AUTHORIZATION_SERVER
16+ ?? process.env.MCP_AUTHORIZATION_SERVER_URL
17+ ?? process.env.WORKOS_AUTHKIT_ISSUER
18+ ?? process.env.WORKOS_AUTHKIT_DOMAIN
19+ ?? null;
20+}
21+
22+function 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+}
2153
2254 function parseBearerToken(request: Request): string | null {
2355 const header = request.headers.get("authorization");
@@ -49,9 +81,12 @@ function unauthorizedMcpResponse(request: Request, message: string): Response { · 25 unmodified lines
4981 );
5082 }
5183
52−async function verifyMcpToken(request: Request): Promise<{ subject: string } | null> {
53− if (!mcpAuthEnabled || !mcpAuthorizationServer || !mcpJwks) {
54− return { subject: "" };
84+async function verifyMcpToken(
85+ request: Request,
86+ config: ReturnType<typeof getMcpAuthConfig>,
87+): Promise<{ subject: string } | null> {
88+ if (!config.enabled || !config.authorizationServer || !config.jwks) {
89+ return null;
5590 }
5691
5792 const token = parseBearerToken(request);
@@ -60,8 +95,8 @@ async function verifyMcpToken(request: Request): Promise<{ subject: string } | n · 2 unmodified lines
6095 }
6196
6297 try {
63− const { payload } = await jwtVerify(token, mcpJwks, {
64− issuer: mcpAuthorizationServer,
98+ const { payload } = await jwtVerify(token, config.jwks, {
99+ issuer: config.authorizationServer,
65100 });
66101
67102 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
74109 }
75110 }
76111
77−function parseMcpContext(url: URL, tokenSubject?: string): (McpWorkspaceContext & { sessionId?: string }) | undefined {
112+function parseMcpContext(url: URL): {
113+ workspaceId: string;
114+ clientId?: string;
115+ sessionId?: string;
116+} | undefined {
78117 const workspaceId = url.searchParams.get("workspaceId");
79− const actorId = tokenSubject ?? url.searchParams.get("actorId");
80− if (!workspaceId || !actorId) return undefined;
118+ if (!workspaceId) return undefined;
81119 const clientId = url.searchParams.get("clientId") ?? undefined;
82120 const sessionId = url.searchParams.get("sessionId") ?? undefined;
83− return { workspaceId, actorId, clientId, sessionId };
121+ return { workspaceId, clientId, sessionId };
84122 }
85123
86124 function isInternalAuthorized(request: Request): boolean {
@@ -107,15 +145,11 @@ function parseInternalRunPath(pathname: string): { runId: string; endpoint: "too · 20 unmodified lines
107145
108146 const mcpHandler = httpAction(async (ctx, request) => {
109147 const url = new URL(request.url);
110− const auth = await verifyMcpToken(request);
111− if (!auth) {
112− return unauthorizedMcpResponse(request, "No valid bearer token provided.");
113− }
114−
115− const tokenSubject = mcpAuthEnabled ? auth.subject : undefined;
116− const requestedContext = parseMcpContext(url, tokenSubject);
148+ const mcpAuthConfig = getMcpAuthConfig();
149+ const auth = await verifyMcpToken(request, mcpAuthConfig);
150+ const requestedContext = parseMcpContext(url);
117151
118− if (mcpAuthEnabled && !requestedContext) {
152+ if (mcpAuthConfig.enabled && !requestedContext) {
119153 return Response.json(
120154 { error: "workspaceId query parameter is required when MCP OAuth is enabled" },
121155 { status: 400 },
@@ -125,7 +159,7 @@ const mcpHandler = httpAction(async (ctx, request) => { · 3 unmodified lines
125159 let context: McpWorkspaceContext | undefined;
126160 if (requestedContext) {
127161 try {
128− if (mcpAuthEnabled) {
162+ if (mcpAuthConfig.enabled && auth?.subject) {
129163 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForWorkosSubject, {
130164 workspaceId: requestedContext.workspaceId,
131165 subject: auth.subject,
@@ -137,11 +171,22 @@ const mcpHandler = httpAction(async (ctx, request) => { · 5 unmodified lines
137171 clientId: requestedContext.clientId,
138172 };
139173 } else {
174+ if (mcpAuthConfig.enabled && !requestedContext.sessionId) {
175+ return unauthorizedMcpResponse(request, "No valid bearer token provided.");
176+ }
177+
140178 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {
141179 workspaceId: requestedContext.workspaceId,
142180 sessionId: requestedContext.sessionId,
143181 });
144182
183+ if (mcpAuthConfig.enabled && access.provider !== "anonymous") {
184+ return unauthorizedMcpResponse(
185+ request,
186+ "Bearer token required for non-anonymous sessions.",
187+ );
188+ }
189+
145190 context = {
146191 workspaceId: requestedContext.workspaceId,
147192 actorId: access.actorId,
@@ -188,6 +233,17 @@ const mcpHandler = httpAction(async (ctx, request) => { · 40 unmodified lines
188233
189234 return (await ctx.runAction(internal.executorNode.listToolsInternal, toolContext)) as ToolDescriptor[];
190235 },
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+ },
191247 listPendingApprovals: async (workspaceId: string) => {
192248 return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[];
193249 },
@@ -206,24 +262,26 @@ const mcpHandler = httpAction(async (ctx, request) => { · 12 unmodified lines
206262 });
207263
208264 const oauthProtectedResourceHandler = httpAction(async (_ctx, request) => {
209− if (!mcpAuthEnabled || !mcpAuthorizationServer) {
265+ const mcpAuthConfig = getMcpAuthConfig();
266+ if (!mcpAuthConfig.enabled || !mcpAuthConfig.authorizationServer) {
210267 return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
211268 }
212269
213270 const url = new URL(request.url);
214271 return Response.json({
215272 resource: `${url.origin}/mcp`,
216− authorization_servers: [mcpAuthorizationServer],
273+ authorization_servers: [mcpAuthConfig.authorizationServer],
217274 bearer_methods_supported: ["header"],
218275 });
219276 });
220277
221278 const oauthAuthorizationServerProxyHandler = httpAction(async (_ctx, request) => {
222− if (!mcpAuthEnabled || !mcpAuthorizationServer) {
279+ const mcpAuthConfig = getMcpAuthConfig();
280+ if (!mcpAuthConfig.enabled || !mcpAuthConfig.authorizationServer) {
223281 return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
224282 }
225283
226− const upstream = new URL("/.well-known/oauth-authorization-server", mcpAuthorizationServer);
284+ const upstream = new URL("/.well-known/oauth-authorization-server", mcpAuthConfig.authorizationServer);
227285 const response = await fetch(upstream.toString(), {
228286 headers: { accept: "application/json" },
229287 });
executor/convex/workspace.ts+14−0
@@ -9,6 +9,10 @@ const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("acto · 8 unmodified lines
99 const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault"));
1010 const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
1111
12+function actorIdForAccount(account: { _id: string; provider: string; providerAccountId: string }): string {
13+ return account.provider === "anonymous" ? account.providerAccountId : account._id;
14+}
15+
1216 function redactCredential<T extends { secretJson: Record<string, unknown> }>(credential: T): T {
1317 return {
1418 ...credential,
@@ -30,6 +34,16 @@ export const listRuntimeTargets = query({ · 15 unmodified lines
3034 },
3135 });
3236
37+export 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+
3347 export const getTask = workspaceQuery({
3448 args: { taskId: v.string() },
3549 handler: async (ctx, args) => {
executor/lib/mcp-elicitation.demo.test.ts+95−0
@@ -0,0 +1,95 @@
1+import { expect, test } from "bun:test";
2+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5+import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6+
7+async 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+
67+test("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+
82+test("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
99 private readonly tasks = new Map<string, TaskRecord>();
1010 private readonly sessions = new Map<string, AnonymousContext>();
1111 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+ }
1217
1318 async createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }> {
1419 const id = `task_${crypto.randomUUID()}`;
@@ -90,6 +95,18 @@ class FakeMcpService { · 75 unmodified lines
9095 { path: "utils.get_time", description: "Get the current time", approval: "auto" },
9196 ];
9297 }
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+ }
93110 }
94111
95112 async function withMcpClient<T>(
@@ -169,3 +186,50 @@ test("run_code MCP tool bootstraps anonymous context when workspace is omitted", · 73 unmodified lines
169186 expect(structured?.sessionId).toBe("mcp_session_test");
170187 });
171188 });
189+
190+test("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
2525 subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void;
2626 bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>;
2727 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> }>;
2831 listPendingApprovals?(workspaceId: string): Promise<PendingApprovalRecord[]>;
2932 resolveApproval?(input: {
3033 workspaceId: string;
@@ -73,6 +76,57 @@ function textContent(text: string): { type: "text"; text: string } { · 42 unmodified lines
7376 return { type: "text", text };
7477 }
7578
79+function 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+
88+const dtsUrlCache = new Map<string, Promise<string | null>>();
89+
90+async 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+
76130 function summarizeTask(task: TaskRecord): string {
77131 const lines = [
78132 `taskId: ${task.id}`,
@@ -223,8 +277,97 @@ function waitForTerminalTask( · 144 unmodified lines
223277 function buildRunCodeDescription(tools?: ToolDescriptor[]): string {
224278 const base =
225279 "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+}
226289
227− return base + generateToolInventory(tools ?? []);
290+function 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+
305+function 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+
321+function 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+ };
228371 }
229372
230373 // ---------------------------------------------------------------------------
@@ -271,20 +414,52 @@ function createRunCodeTool( · 40 unmodified lines
271414 };
272415 }
273416
274− // Typecheck code before execution — get tool inventory for declarations
275− const toolsForContext = await service.listTools({
276− workspaceId: context.workspaceId,
277− actorId: context.actorId,
278− clientId: context.clientId,
417+ // Typecheck code before execution — align with Monaco by loading source .d.ts
418+ // for OpenAPI tools when available.
419+ let toolsForContext: ToolDescriptor[];
420+ let sourceDtsBySource: Record<string, string> = {};
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,
279439 });
280− const declarations = generateToolDeclarations(toolsForContext);
281440 const typecheck = typecheckCode(input.code, declarations);
282441
283442 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+
284458 const errorText = [
285459 "TypeScript type errors in generated code:",
286460 "",
287461 ...typecheck.errors.map((e) => ` ${e}`),
462+ ...(hintLines.length > 0 ? ["", ...hintLines] : []),
288463 "",
289464 "Fix the type errors and try again.",
290465 ].join("\n");
@@ -403,6 +578,7 @@ async function createMcpServer( · 112 unmodified lines
403578 { name: "executor", version: "0.1.0" },
404579 { capabilities: { tools: {} } },
405580 );
581+ const onApprovalPrompt = createMcpApprovalPrompt(mcp);
406582
407583 // If workspace context is provided, fetch tool inventory for richer description
408584 let tools: ToolDescriptor[] | undefined;
@@ -420,17 +596,41 @@ async function createMcpServer( · 11 unmodified lines
420596 description: buildRunCodeDescription(tools),
421597 inputSchema: context ? BOUND_INPUT : FULL_INPUT,
422598 },
423− createRunCodeTool(service, context),
599+ createRunCodeTool(service, context, onApprovalPrompt),
424600 );
425601
426602 return mcp;
427603 }
428604
429−// ---------------------------------------------------------------------------
430−// Request handler
431−// ---------------------------------------------------------------------------
605+function createDelegatingService(ref: { current: McpExecutorService }): McpExecutorService {
606+ return {
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+}
432623
433−export async function handleMcpRequest(
624+const mcpSessionRuntimes = new Map<
625+ string,
626+ {
627+ transport: WebStandardStreamableHTTPServerTransport;
628+ mcp: McpServer;
629+ serviceRef: { current: McpExecutorService };
630+ }
631+>();
632+
633+async function handleStatelessMcpRequest(
434634 service: McpExecutorService,
435635 request: Request,
436636 context?: McpWorkspaceContext,
@@ -449,3 +649,74 @@ export async function handleMcpRequest( · 12 unmodified lines
449649 await mcp.close().catch(() => {});
450650 }
451651 }
652+
653+// ---------------------------------------------------------------------------
654+// Request handler
655+// ---------------------------------------------------------------------------
656+
657+export 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
425425 expect(tool!.metadata?.returnsType).not.toBe("unknown");
426426 });
427427
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+
428482 test("resolves OpenAPI component schema refs for return type hints", async () => {
429483 const spec: Record<string, unknown> = {
430484 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
236236 300_000,
237237 );
238238
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+
239265 test(
240266 "github: all budgets for org has non-unknown return hint",
241267 async () => {
executor/lib/tool_sources.ts+129−24
@@ -394,6 +394,15 @@ function responseTypeHintFromSchema( · 393 unmodified lines
394394 return "unknown";
395395 }
396396
397+function 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+
397406 /** Depth-limited + cycle-safe type hint generator for schemas (used as fallback). */
398407 function jsonSchemaTypeHintFallback(
399408 schema: unknown,
@@ -477,7 +486,7 @@ function jsonSchemaTypeHintFallback( · 77 unmodified lines
477486 }
478487 const inner = propEntries
479488 .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)}`)
481490 .join("; ");
482491 return `{ ${inner} }`;
483492 }
@@ -771,19 +780,20 @@ function compactOpenApiPaths( · 287 unmodified lines
771780
772781 if (hasGeneratedTypes) {
773782 // 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;
774786 const combinedSchema: JsonSchema = {
775787 type: "object",
776788 properties: {
777789 ...Object.fromEntries(
778− normalizeParameters(operation.parameters)
779− .concat(sharedParameters)
790+ mergedParameters
780791 .map((param) => [param.name, param.schema]),
781792 ),
782793 ...asRecord(requestBodySchema.properties),
783794 },
784795 required: [
785− ...normalizeParameters(operation.parameters)
786− .concat(sharedParameters)
796+ ...mergedParameters
787797 .filter((param) => param.required)
788798 .map((param) => param.name as string),
789799 ...((Array.isArray(requestBodySchema.required)
@@ -791,7 +801,9 @@ function compactOpenApiPaths( · 1 unmodified line
791801 : []) as string[]),
792802 ],
793803 };
794− compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas);
804+ compactOperation._argsTypeHint = hasInputSchema
805+ ? jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas)
806+ : "{}";
795807 compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas);
796808 } else {
797809 // Keep full schemas for the fallback path
@@ -998,7 +1010,9 @@ export function buildOpenApiToolsFromPrepared( · 200 unmodified lines
9981010 ],
9991011 };
10001012
1001− argsType = jsonSchemaTypeHintFallback(combinedSchema);
1013+ const hasInputSchema = parameters.length > 0 || Object.keys(requestBodySchema).length > 0;
1014+
1015+ argsType = hasInputSchema ? jsonSchemaTypeHintFallback(combinedSchema) : "{}";
10021016 returnsType = responseTypeHintFromSchema(responseSchema, responseStatus);
10031017 }
10041018
@@ -1363,6 +1377,11 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 358 unmodified lines
13631377 credential: credentialSpec,
13641378 // Tag as graphql source so invokeTool knows to do dynamic path extraction
13651379 _graphqlSource: config.name,
1380+ _runSpec: {
1381+ kind: "graphql_raw" as const,
1382+ endpoint: config.endpoint,
1383+ authHeaders,
1384+ },
13661385 run: async (input: unknown, context) => {
13671386 const payload = asRecord(input);
13681387 const query = String(payload.query ?? "");
@@ -1433,6 +1452,14 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 64 unmodified lines
14331452 argsType: gqlFieldArgsTypeHint(field.args, typeMap),
14341453 returnsType: gqlTypeToHint(field.type, typeMap),
14351454 },
1455+ _runSpec: {
1456+ kind: "graphql_field" as const,
1457+ endpoint: config.endpoint,
1458+ operationName: field.name,
1459+ operationType,
1460+ queryTemplate: exampleQuery,
1461+ authHeaders,
1462+ },
14361463 // Pseudo-tools don't have a run — they exist for discovery and policy matching only
14371464 _pseudoTool: true,
14381465 run: async (input: unknown, context) => {
@@ -1518,7 +1545,9 @@ export interface SerializedTool { · 79 unmodified lines
15181545 * Data needed to reconstruct `run()`. Shape depends on source type.
15191546 * OpenAPI: { kind: "openapi", baseUrl, method, pathTemplate, parameters, authHeaders }
15201547 * 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 }
15221551 * Builtin: { kind: "builtin" } — run comes from DEFAULT_TOOLS
15231552 */
15241553 runSpec:
@@ -1537,6 +1566,19 @@ export interface SerializedTool { · 12 unmodified lines
15371566 queryParams?: Record<string, string>;
15381567 toolName: string;
15391568 }
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+ }
15401582 | {
15411583 kind: "graphql";
15421584 endpoint: string;
@@ -1589,6 +1631,36 @@ export function rehydrateTools( · 46 unmodified lines
15891631
15901632 const readMethods = new Set(["get", "head", "options"]);
15911633
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+
15921664 return serialized.map((st) => {
15931665 const base: Omit<ToolDefinition, "run"> = {
15941666 path: st.path,
@@ -1676,29 +1748,62 @@ export function rehydrateTools( · 81 unmodified lines
16761748 };
16771749 }
16781750
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+
16791793 if (st.runSpec.kind === "graphql") {
1794+ // Legacy compat for older cached entries.
16801795 const { endpoint, operationName, operationType, authHeaders } = st.runSpec;
1796+ const queryTemplate = `${operationType} ${operationName}($input: JSON) { ${operationName}(input: $input) }`;
16811797 return {
16821798 ...base,
16831799 run: async (input: unknown, context) => {
16841800 const payload = asRecord(input);
1685− const query = `${operationType} ${operationName}($input: JSON) { ${operationName}(input: $input) }`;
1686− const response = await fetch(endpoint, {
1687− method: "POST",
1688− headers: {
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)}`);
1801+ const variables = payload.variables ?? { input: payload };
1802+ const result = await executeGraphql(endpoint, authHeaders, queryTemplate, variables, context);
1803+ if (result && typeof result === "object") {
1804+ return (result as Record<string, unknown>)[operationName];
16981805 }
1699− const json = await response.json() as Record<string, unknown>;
1700− if (json.errors) throw new Error(`GraphQL errors: ${JSON.stringify(json.errors)}`);
1701− return (json.data as Record<string, unknown>)?.[operationName];
1806+ return result;
17021807 },
17031808 };
17041809 }
executor/lib/typechecker.test.ts+197−22
@@ -1,5 +1,6 @@
11 import { test, expect, describe } from "bun:test";
22 import { generateToolDeclarations, generateToolInventory, typecheckCode, type TypecheckResult } from "./typechecker";
3+import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool_sources";
34 import type { ToolDescriptor } from "./types";
45
56 // ---------------------------------------------------------------------------
@@ -44,7 +45,7 @@ describe("generateToolDeclarations", () => { · 38 unmodified lines
4445
4546 test("flat tool (no dots in path beyond single segment)", () => {
4647 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 }>;");
4849 });
4950
5051 test("nested tool (math.add)", () => {
@@ -62,7 +63,7 @@ describe("generateToolDeclarations", () => { · 11 unmodified lines
6263 expect(result).toContain("admin: {");
6364 expect(result).toContain("send_announcement(input: { message: string; channel?: string }): Promise<{ sent: boolean }>;");
6465 // 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 }>;");
6667 });
6768
6869 test("missing argsType defaults to Record<string, unknown>", () => {
@@ -72,7 +73,7 @@ describe("generateToolDeclarations", () => { · 3 unmodified lines
7273 approval: "auto",
7374 };
7475 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>;");
7677 });
7778
7879 test("missing returnsType defaults to unknown", () => {
@@ -85,6 +86,41 @@ describe("generateToolDeclarations", () => { · 6 unmodified lines
8586 const result = generateToolDeclarations([tool]);
8687 expect(result).toContain("baz(input: { x: number }): Promise<unknown>;");
8788 });
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 = `
104+export 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+ });
88124 });
89125
90126 // ---------------------------------------------------------------------------
@@ -96,29 +132,20 @@ describe("generateToolInventory", () => { · 5 unmodified lines
96132 expect(generateToolInventory([])).toBe("");
97133 });
98134
99− test("includes tool paths and type signatures", () => {
135+ test("includes namespace summary and discover guidance", () => {
100136 const result = generateToolInventory(ALL_TOOLS);
101− expect(result).toContain("tools.math.add(input: { a: number; b: number }): Promise<{ result: number }>");
102− expect(result).toContain("tools.admin.send_announcement(input: { message: string; channel?: string }): Promise<{ sent: boolean }>");
103− expect(result).toContain("tools.get_time(input: {}): Promise<{ iso: string; unix: number }>");
137+ expect(result).toContain("You have access to these tool namespaces:");
138+ expect(result).toContain("admin (1)");
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");
104142 });
105143
106− test("shows [approval required] for required-approval tools", () => {
144+ test("includes example callable paths", () => {
107145 const result = generateToolInventory(ALL_TOOLS);
108− expect(result).toContain("[approval required]");
109− // math.add is auto — should NOT have the tag
110− const mathLine = result.split("\n").find((l) => l.includes("tools.math.add"));
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");
146+ expect(result).toContain("Example callable paths:");
147+ expect(result).toContain("tools.math.add(...)");
148+ expect(result).toContain("tools.admin.send_announcement(...)");
122149 });
123150 });
124151
@@ -240,6 +267,46 @@ return { sum: sum.result, sent: announcement.sent, time: time.unix }; · 115 unmodified lines
240267 expect(result.ok).toBe(true);
241268 });
242269
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 = `
282+export 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+
243310 test("optional parameter can be provided", () => {
244311 const result = typecheckCode(
245312 '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
247314 );
248315 expect(result.ok).toBe(true);
249316 });
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+ });
250425 });
251426
252427 // ---------------------------------------------------------------------------
executor/lib/typechecker.ts+124−11
@@ -19,6 +19,76 @@ import type { ToolDescriptor } from "./types"; · 18 unmodified lines
1919 // Tool declarations generation
2020 // ---------------------------------------------------------------------------
2121
22+let cachedTypeScript: typeof import("typescript") | null | undefined;
23+
24+function 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+
35+function 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+
59+function 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+
65+const OPENAPI_HELPER_TYPES = `
66+type _OrEmpty<T> = [T] extends [never] ? {} : T;
67+type _Simplify<T> = { [K in keyof T]: T[K] } & {};
68+type 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+>;
73+type 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+
82+function 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+
88+export interface GenerateToolDeclarationOptions {
89+ sourceDtsBySource?: Record<string, string>;
90+}
91+
2292 /**
2393 * Build a `declare const tools: { ... }` block from flat tool descriptors.
2494 *
@@ -29,7 +99,10 @@ import type { ToolDescriptor } from "./types"; · 4 unmodified lines
2999 * Tool paths like "math.add" or "admin.send_announcement" are split on "."
30100 * and nested into a type tree.
31101 */
32−export function generateToolDeclarations(tools: ToolDescriptor[]): string {
102+export function generateToolDeclarations(
103+ tools: ToolDescriptor[],
104+ options?: GenerateToolDeclarationOptions,
105+): string {
33106
34107 // Legacy compat: collect schemaTypes from tools that use the old format
35108 const allSchemas = new Map<string, string>();
@@ -50,6 +123,7 @@ export function generateToolDeclarations(tools: ToolDescriptor[]): string { · 14 unmodified lines
50123 }
51124
52125 const root: TreeNode = { children: new Map() };
126+ const dtsSources = new Set(Object.keys(options?.sourceDtsBySource ?? {}));
53127
54128 for (const tool of tools) {
55129 const segments = tool.path.split(".");
@@ -73,9 +147,18 @@ export function generateToolDeclarations(tools: ToolDescriptor[]): string { · 17 unmodified lines
73147 for (const [key, child] of node.children) {
74148 if (child.tool) {
75149 const tool = child.tool;
76− const args = tool.argsType || "Record<string, unknown>";
77− const returns = tool.returnsType || "unknown";
78− lines.push(`${pad}${key}(input: ${args}): Promise<${returns}>;`);
150+ if (tool.operationId && tool.source && dtsSources.has(tool.source)) {
151+ const opKey = JSON.stringify(tool.operationId);
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+ }
79162 } else {
80163 lines.push(`${pad}${key}: {`);
81164 lines.push(renderNode(child, indent + 1));
@@ -96,6 +179,17 @@ export function generateToolDeclarations(tools: ToolDescriptor[]): string { · 14 unmodified lines
96179 }
97180 }
98181
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+
99193 // The tools declaration
100194 parts.push(`declare const tools: {\n${renderNode(root, 1)}\n};`);
101195
@@ -109,14 +203,33 @@ export function generateToolDeclarations(tools: ToolDescriptor[]): string { · 7 unmodified lines
109203 export function generateToolInventory(tools: ToolDescriptor[]): string {
110204 if (!tools || tools.length === 0) return "";
111205
112− const lines = tools.map((t) => {
113− const args = t.argsType || "Record<string, unknown>";
114− const returns = t.returnsType || "unknown";
115− const approval = t.approval === "required" ? " [approval required]" : "";
116− return ` tools.${t.path}(input: ${args}): Promise<${returns}>${approval}\n ${t.description}`;
117− });
206+ const namespaceCounts = new Map<string, number>();
207+ for (const tool of tools) {
208+ const topLevel = tool.path.split(".")[0] || tool.path;
209+ namespaceCounts.set(topLevel, (namespaceCounts.get(topLevel) ?? 0) + 1);
210+ }
118211
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");
120233 }
121234
122235 // ---------------------------------------------------------------------------
executor/lib/workspace-tool-cache.test.ts+89−0
@@ -269,6 +269,95 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 268 unmodified lines
269269 expect(restored[0]!.runSpec.kind).toBe("mcp");
270270 });
271271
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+
272361 test("full WorkspaceToolSnapshot round-trip", async () => {
273362 const prepared = await prepareOpenApiSpec(SMALL_SPEC, "widgets");
274363 const tools = buildOpenApiToolsFromPrepared(
executor/mcp-gateway.ts+351−0
@@ -0,0 +1,351 @@
1+import { ConvexHttpClient } from "convex/browser";
2+import { createRemoteJWKSet, jwtVerify } from "jose";
3+import { api } from "./convex/_generated/api";
4+import { handleMcpRequest, type McpWorkspaceContext } from "./lib/mcp_server";
5+import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescriptor } from "./lib/types";
6+
7+const convexUrlFromEnv = Bun.env.CONVEX_URL;
8+if (!convexUrlFromEnv) {
9+ throw new Error("CONVEX_URL is required.");
10+}
11+const convexUrl: string = convexUrlFromEnv;
12+
13+const 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;
18+const mcpGatewayRequireAuth = Bun.env.MCP_GATEWAY_REQUIRE_AUTH === "1";
19+const mcpAuthEnabled = mcpGatewayRequireAuth && Boolean(mcpAuthorizationServer);
20+const mcpJwks = mcpAuthorizationServer
21+ ? createRemoteJWKSet(new URL("/oauth2/jwks", mcpAuthorizationServer))
22+ : null;
23+
24+function 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+
33+function resourceMetadataUrl(request: Request): string {
34+ const url = new URL(request.url);
35+ return `${url.origin}/.well-known/oauth-protected-resource`;
36+}
37+
38+function 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+
56+async 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+
76+async 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+
96+function 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+
109+function 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+
203+async 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+
247+async 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+
290+async 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+
306+function 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+
319+const port = Number(Bun.env.PORT ?? 3003);
320+
321+Bun.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+
349+console.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
44 "type": "module",
55 "scripts": {
66 "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"
810 },
911 "dependencies": {
1012 "convex": "^1.31.7"
executor/tsconfig.json+3−1
@@ -18,6 +18,8 @@ · 17 unmodified lines
1818 "include": [
1919 "packages/**/*.ts",
2020 "convex/**/*.ts",
21− "lib/**/*.ts"
21+ "lib/**/*.ts",
22+ "mcp-gateway.ts",
23+ "vscode-elicitation-server.ts"
2224 ]
2325 }
executor/vscode-elicitation-server.ts+158−0
@@ -0,0 +1,158 @@
1+import { randomUUID } from "node:crypto";
2+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3+import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
4+import { z } from "zod";
5+
6+type Runtime = {
7+ server: McpServer;
8+ transport: WebStandardStreamableHTTPServerTransport;
9+};
10+
11+const runtimes = new Map<string, Runtime>();
12+
13+function 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+
109+async 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+
142+const port = Number(Bun.env.PORT ?? 8787);
143+
144+Bun.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+
158+console.log(`[elicitation-demo] listening on http://localhost:${port}/mcp`);