- 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.
Unified · Split
| @@ -8,8 +8,9 @@ · 7 unmodified lines | ||
| 8 | 8 | * Starts: |
| 9 | 9 | * 1. Convex cloud dev function watcher ─┐ |
| 10 | 10 | * 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 ─┘ | |
| 13 | 14 | * |
| 14 | 15 | * All processes are killed when this script exits (Ctrl+C). |
| 15 | 16 | * PIDs are written to .dev.pids for use with `bun run kill:all`. |
| @@ -23,6 +24,7 @@ const PID_FILE = join(import.meta.dir, ".dev.pids"); · 7 unmodified lines | ||
| 23 | 24 | const colors = { |
| 24 | 25 | convex: "\x1b[36m", // cyan |
| 25 | 26 | web: "\x1b[34m", // blue |
| 27 | + mcp: "\x1b[33m", // yellow | |
| 26 | 28 | assistant: "\x1b[32m", // green |
| 27 | 29 | bot: "\x1b[35m", // magenta |
| 28 | 30 | reset: "\x1b[0m", |
| @@ -116,7 +118,7 @@ process.on("SIGTERM", shutdown); · 87 unmodified lines | ||
| 116 | 118 | |
| 117 | 119 | // ── Kill stale processes from a previous run ── |
| 118 | 120 | |
| 119 | −const DEV_PORTS = [3000, 3002]; | |
| 121 | +const DEV_PORTS = [3000, 3002, 3003]; | |
| 120 | 122 | |
| 121 | 123 | async function killStaleProcesses() { |
| 122 | 124 | let killed = 0; |
| @@ -176,6 +178,15 @@ spawnService("web", ["bun", "run", "dev", "--", "-p", "3002"], { · 53 unmodified lines | ||
| 176 | 178 | cwd: "./executor/apps/web", |
| 177 | 179 | }); |
| 178 | 180 | |
| 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 | + | |
| 179 | 190 | spawnService("assistant", ["bun", "run", "dev"], { |
| 180 | 191 | cwd: "./assistant/packages/server", |
| 181 | 192 | env: { |
| @@ -27,6 +27,12 @@ Terminal 2: · 26 unmodified lines | ||
| 27 | 27 | bun run dev:web |
| 28 | 28 | ``` |
| 29 | 29 | |
| 30 | +Optional Terminal 3 (stateful MCP gateway for elicitation/sampling over Streamable HTTP): | |
| 31 | + | |
| 32 | +```bash | |
| 33 | +bun run dev:mcp-gateway | |
| 34 | +``` | |
| 35 | + | |
| 30 | 36 | ## Tests |
| 31 | 37 | |
| 32 | 38 | ```bash |
| @@ -36,6 +42,8 @@ bun test convex/database.test.ts convex/executor-mcp.e2e.test.ts · 3 unmodified lines | ||
| 36 | 42 | ## Notes |
| 37 | 43 | |
| 38 | 44 | - MCP endpoint is served by Convex HTTP routes at `/mcp`. |
| 45 | +- For a long-lived stateful MCP transport (recommended for elicitation in multi-worker environments), run the gateway at `http://localhost:3003/mcp`. | |
| 46 | +- Local gateway auth is optional by default; set `MCP_GATEWAY_REQUIRE_AUTH=1` to enforce MCP OAuth on the gateway. | |
| 39 | 47 | - Set `MCP_AUTHORIZATION_SERVER` (or `MCP_AUTHORIZATION_SERVER_URL`) to enable MCP OAuth bearer-token verification. |
| 40 | 48 | - When MCP OAuth is enabled, the server exposes `/.well-known/oauth-protected-resource` and proxies `/.well-known/oauth-authorization-server`. |
| 41 | 49 | - Internal runtime callback routes are served by Convex HTTP routes at `/internal/runs/:runId/*`. |
| @@ -399,11 +399,25 @@ function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) { · 398 unmodified lines | ||
| 399 | 399 | } |
| 400 | 400 | |
| 401 | 401 | 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(); | |
| 403 | 411 | const searchParams = useSearchParams(); |
| 412 | + const deleteCurrentAccountMutation = useMutation(convexApi.accounts.deleteCurrentAccount); | |
| 413 | + const [accountSettingsOpen, setAccountSettingsOpen] = useState(false); | |
| 414 | + const [deleteConfirmText, setDeleteConfirmText] = useState(""); | |
| 415 | + const [deleteError, setDeleteError] = useState<string | null>(null); | |
| 416 | + const [deletingAccount, setDeletingAccount] = useState(false); | |
| 404 | 417 | const avatarUrl = workosProfile?.avatarUrl ?? null; |
| 405 | 418 | const avatarLabel = workosProfile?.name || workosProfile?.email || "User"; |
| 406 | 419 | const avatarInitial = (avatarLabel[0] ?? "U").toUpperCase(); |
| 420 | + const canDeleteAccount = deleteConfirmText === "DELETE"; | |
| 407 | 421 | |
| 408 | 422 | const activeWorkspace = context |
| 409 | 423 | ? workspaces.find((workspace) => workspace.id === context.workspaceId) |
| @@ -430,6 +444,27 @@ function SessionInfo() { · 20 unmodified lines | ||
| 430 | 444 | } |
| 431 | 445 | const signInHref = signInParams.size > 0 ? `/sign-in?${signInParams.toString()}` : "/sign-in"; |
| 432 | 446 | |
| 447 | + const handleDeleteAccount = async (event: FormEvent<HTMLFormElement>) => { | |
| 448 | + event.preventDefault(); | |
| 449 | + if (!canDeleteAccount || deletingAccount) { | |
| 450 | + return; | |
| 451 | + } | |
| 452 | + | |
| 453 | + setDeletingAccount(true); | |
| 454 | + setDeleteError(null); | |
| 455 | + try { | |
| 456 | + await deleteCurrentAccountMutation({ | |
| 457 | + sessionId: context?.sessionId ?? undefined, | |
| 458 | + }); | |
| 459 | + await resetWorkspace(); | |
| 460 | + window.location.assign(workosEnabled ? "/sign-out" : "/"); | |
| 461 | + } catch (cause) { | |
| 462 | + const message = cause instanceof Error ? cause.message : "Failed to delete account"; | |
| 463 | + setDeleteError(message); | |
| 464 | + setDeletingAccount(false); | |
| 465 | + } | |
| 466 | + }; | |
| 467 | + | |
| 433 | 468 | if (loading) { |
| 434 | 469 | return ( |
| 435 | 470 | <div className="border-t border-border px-3 py-2"> |
| @@ -441,44 +476,113 @@ function SessionInfo() { · 5 unmodified lines | ||
| 441 | 476 | return ( |
| 442 | 477 | <div className="border-t border-border"> |
| 443 | 478 | {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} | |
| 460 | 503 | </span> |
| 461 | − )} | |
| 462 | − <span className="min-w-0"> | |
| 463 | − <span className="text-[11px] font-medium truncate block">{avatarLabel}</span> | |
| 464 | − {workosProfile?.email ? ( | |
| 465 | − <span className="text-[10px] text-muted-foreground truncate block">{workosProfile.email}</span> | |
| 466 | − ) : null} | |
| 467 | 504 | </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 | + </> | |
| 482 | 586 | ) : ( |
| 483 | 587 | <div className="px-3 py-3 space-y-2"> |
| 484 | 588 | {workosEnabled ? ( |
| @@ -41,6 +41,10 @@ function resolveMcpOrigin(windowOrigin: string): string { · 40 unmodified lines | ||
| 41 | 41 | return windowOrigin; |
| 42 | 42 | } |
| 43 | 43 | |
| 44 | +function isLocalHostname(hostname: string): boolean { | |
| 45 | + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0"; | |
| 46 | +} | |
| 47 | + | |
| 44 | 48 | export function McpSetupCard({ |
| 45 | 49 | workspaceId, |
| 46 | 50 | actorId, |
| @@ -52,12 +56,26 @@ export function McpSetupCard({ · 5 unmodified lines | ||
| 52 | 56 | }) { |
| 53 | 57 | const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code"); |
| 54 | 58 | const [copiedKey, setCopiedKey] = useState<string | null>(null); |
| 59 | + const [urlMode, setUrlMode] = useState<"default" | "local">(() => { | |
| 60 | + if (typeof window === "undefined") { | |
| 61 | + return "default"; | |
| 62 | + } | |
| 63 | + return isLocalHostname(window.location.hostname) ? "local" : "default"; | |
| 64 | + }); | |
| 55 | 65 | |
| 56 | − const origin = useMemo(() => { | |
| 66 | + const defaultOrigin = useMemo(() => { | |
| 57 | 67 | if (typeof window === "undefined") return ""; |
| 58 | 68 | return resolveMcpOrigin(window.location.origin); |
| 59 | 69 | }, []); |
| 60 | 70 | |
| 71 | + const showLocalGatewayOption = useMemo(() => { | |
| 72 | + if (typeof window === "undefined") return false; | |
| 73 | + return isLocalHostname(window.location.hostname); | |
| 74 | + }, []); | |
| 75 | + | |
| 76 | + const localGatewayOrigin = (process.env.NEXT_PUBLIC_LOCAL_MCP_ORIGIN ?? "http://localhost:3003").trim().replace(/\/$/, ""); | |
| 77 | + const origin = urlMode === "local" ? localGatewayOrigin : defaultOrigin; | |
| 78 | + | |
| 61 | 79 | const mcpUrl = useMemo(() => { |
| 62 | 80 | const base = origin ? new URL("/mcp", origin) : new URL("http://localhost/mcp"); |
| 63 | 81 | if (workspaceId) base.searchParams.set("workspaceId", workspaceId); |
| @@ -94,6 +112,19 @@ export function McpSetupCard({ · 30 unmodified lines | ||
| 94 | 112 | <div className="space-y-5"> |
| 95 | 113 | <div className="space-y-1.5"> |
| 96 | 114 | <Label className="text-xs text-muted-foreground">MCP Server URL</Label> |
| 115 | + {showLocalGatewayOption ? ( | |
| 116 | + <div className="space-y-1.5"> | |
| 117 | + <Select value={urlMode} onValueChange={(value) => setUrlMode(value as "default" | "local")}> | |
| 118 | + <SelectTrigger className="h-8 text-xs bg-background"> | |
| 119 | + <SelectValue /> | |
| 120 | + </SelectTrigger> | |
| 121 | + <SelectContent> | |
| 122 | + <SelectItem value="local" className="text-xs">Local gateway (localhost:3003)</SelectItem> | |
| 123 | + <SelectItem value="default" className="text-xs">Hosted endpoint</SelectItem> | |
| 124 | + </SelectContent> | |
| 125 | + </Select> | |
| 126 | + </div> | |
| 127 | + ) : null} | |
| 97 | 128 | <div className="flex items-center gap-2"> |
| 98 | 129 | <Input value={mcpUrl} readOnly className="h-8 text-xs font-mono bg-background" /> |
| 99 | 130 | <Button |
| @@ -20,6 +20,14 @@ import type { GenericId as Id } from "convex/values"; · 19 unmodified lines | ||
| 20 | 20 | * ``` |
| 21 | 21 | */ |
| 22 | 22 | export declare const api: { |
| 23 | + accounts: { | |
| 24 | + deleteCurrentAccount: FunctionReference< | |
| 25 | + "mutation", | |
| 26 | + "public", | |
| 27 | + { sessionId?: string }, | |
| 28 | + any | |
| 29 | + >; | |
| 30 | + }; | |
| 23 | 31 | app: { |
| 24 | 32 | getClientConfig: FunctionReference<"query", "public", {}, any>; |
| 25 | 33 | getCurrentAccount: FunctionReference< |
| @@ -287,6 +295,12 @@ export declare const api: { · 261 unmodified lines | ||
| 287 | 295 | { sessionId?: string; sourceId: string; workspaceId: string }, |
| 288 | 296 | any |
| 289 | 297 | >; |
| 298 | + getRequestContext: FunctionReference< | |
| 299 | + "query", | |
| 300 | + "public", | |
| 301 | + { sessionId?: string; workspaceId: string }, | |
| 302 | + any | |
| 303 | + >; | |
| 290 | 304 | getTask: FunctionReference< |
| 291 | 305 | "query", |
| 292 | 306 | "public", |
| @@ -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 | +}); | |
| @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; · 1 unmodified line | ||
| 2 | 2 | import { convexTest } from "convex-test"; |
| 3 | 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 4 | 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 5 | +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | |
| 5 | 6 | import { internal } from "./_generated/api"; |
| 6 | 7 | import schema from "./schema"; |
| 7 | 8 | |
| @@ -125,6 +126,68 @@ test("MCP run_code survives delayed approval and completes", async () => { · 117 unmodified lines | ||
| 125 | 126 | } |
| 126 | 127 | }, 60_000); |
| 127 | 128 | |
| 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 | + | |
| 128 | 191 | test("MCP run_code returns denied after approval denial", async () => { |
| 129 | 192 | const t = setup(); |
| 130 | 193 | const session = await t.mutation(internal.database.bootstrapAnonymousSession, {}); |
| @@ -57,7 +57,7 @@ function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientI · 56 unmodified lines | ||
| 57 | 57 | } |
| 58 | 58 | |
| 59 | 59 | function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { |
| 60 | − const signatureVersion = "v10"; | |
| 60 | + const signatureVersion = "v12"; | |
| 61 | 61 | const parts = sources |
| 62 | 62 | .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`) |
| 63 | 63 | .sort(); |
| @@ -132,7 +132,7 @@ interface DtsStorageEntry { · 68 unmodified lines | ||
| 132 | 132 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; |
| 133 | 133 | |
| 134 | 134 | /** Cache version — bump when PreparedOpenApiSpec shape changes. */ |
| 135 | −const OPENAPI_CACHE_VERSION = "v12"; | |
| 135 | +const OPENAPI_CACHE_VERSION = "v14"; | |
| 136 | 136 | |
| 137 | 137 | async function publish( |
| 138 | 138 | ctx: any, |
| @@ -376,6 +376,12 @@ function getDecisionForContext( · 237 unmodified lines | ||
| 376 | 376 | context: { workspaceId: string; actorId?: string; clientId?: string }, |
| 377 | 377 | policies: AccessPolicyRecord[], |
| 378 | 378 | ): PolicyDecision { |
| 379 | + // `discover` is always available so models can find allowed tools dynamically. | |
| 380 | + // The discover implementation still filters returned results via context.isToolAllowed. | |
| 381 | + if (tool.path === "discover") { | |
| 382 | + return "allow"; | |
| 383 | + } | |
| 384 | + | |
| 379 | 385 | const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow"; |
| 380 | 386 | const candidates = policies |
| 381 | 387 | .filter((policy) => { |
| @@ -9,15 +9,47 @@ import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescripto · 8 unmodified lines | ||
| 9 | 9 | |
| 10 | 10 | const http = httpRouter(); |
| 11 | 11 | 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 | +} | |
| 21 | 53 | |
| 22 | 54 | function parseBearerToken(request: Request): string | null { |
| 23 | 55 | const header = request.headers.get("authorization"); |
| @@ -49,9 +81,12 @@ function unauthorizedMcpResponse(request: Request, message: string): Response { · 25 unmodified lines | ||
| 49 | 81 | ); |
| 50 | 82 | } |
| 51 | 83 | |
| 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; | |
| 55 | 90 | } |
| 56 | 91 | |
| 57 | 92 | const token = parseBearerToken(request); |
| @@ -60,8 +95,8 @@ async function verifyMcpToken(request: Request): Promise<{ subject: string } | n · 2 unmodified lines | ||
| 60 | 95 | } |
| 61 | 96 | |
| 62 | 97 | try { |
| 63 | − const { payload } = await jwtVerify(token, mcpJwks, { | |
| 64 | − issuer: mcpAuthorizationServer, | |
| 98 | + const { payload } = await jwtVerify(token, config.jwks, { | |
| 99 | + issuer: config.authorizationServer, | |
| 65 | 100 | }); |
| 66 | 101 | |
| 67 | 102 | if (typeof payload.sub !== "string" || payload.sub.length === 0) { |
| @@ -74,13 +109,16 @@ async function verifyMcpToken(request: Request): Promise<{ subject: string } | n · 6 unmodified lines | ||
| 74 | 109 | } |
| 75 | 110 | } |
| 76 | 111 | |
| 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 { | |
| 78 | 117 | 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; | |
| 81 | 119 | const clientId = url.searchParams.get("clientId") ?? undefined; |
| 82 | 120 | const sessionId = url.searchParams.get("sessionId") ?? undefined; |
| 83 | − return { workspaceId, actorId, clientId, sessionId }; | |
| 121 | + return { workspaceId, clientId, sessionId }; | |
| 84 | 122 | } |
| 85 | 123 | |
| 86 | 124 | function isInternalAuthorized(request: Request): boolean { |
| @@ -107,15 +145,11 @@ function parseInternalRunPath(pathname: string): { runId: string; endpoint: "too · 20 unmodified lines | ||
| 107 | 145 | |
| 108 | 146 | const mcpHandler = httpAction(async (ctx, request) => { |
| 109 | 147 | 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); | |
| 117 | 151 | |
| 118 | − if (mcpAuthEnabled && !requestedContext) { | |
| 152 | + if (mcpAuthConfig.enabled && !requestedContext) { | |
| 119 | 153 | return Response.json( |
| 120 | 154 | { error: "workspaceId query parameter is required when MCP OAuth is enabled" }, |
| 121 | 155 | { status: 400 }, |
| @@ -125,7 +159,7 @@ const mcpHandler = httpAction(async (ctx, request) => { · 3 unmodified lines | ||
| 125 | 159 | let context: McpWorkspaceContext | undefined; |
| 126 | 160 | if (requestedContext) { |
| 127 | 161 | try { |
| 128 | − if (mcpAuthEnabled) { | |
| 162 | + if (mcpAuthConfig.enabled && auth?.subject) { | |
| 129 | 163 | const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForWorkosSubject, { |
| 130 | 164 | workspaceId: requestedContext.workspaceId, |
| 131 | 165 | subject: auth.subject, |
| @@ -137,11 +171,22 @@ const mcpHandler = httpAction(async (ctx, request) => { · 5 unmodified lines | ||
| 137 | 171 | clientId: requestedContext.clientId, |
| 138 | 172 | }; |
| 139 | 173 | } else { |
| 174 | + if (mcpAuthConfig.enabled && !requestedContext.sessionId) { | |
| 175 | + return unauthorizedMcpResponse(request, "No valid bearer token provided."); | |
| 176 | + } | |
| 177 | + | |
| 140 | 178 | const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, { |
| 141 | 179 | workspaceId: requestedContext.workspaceId, |
| 142 | 180 | sessionId: requestedContext.sessionId, |
| 143 | 181 | }); |
| 144 | 182 | |
| 183 | + if (mcpAuthConfig.enabled && access.provider !== "anonymous") { | |
| 184 | + return unauthorizedMcpResponse( | |
| 185 | + request, | |
| 186 | + "Bearer token required for non-anonymous sessions.", | |
| 187 | + ); | |
| 188 | + } | |
| 189 | + | |
| 145 | 190 | context = { |
| 146 | 191 | workspaceId: requestedContext.workspaceId, |
| 147 | 192 | actorId: access.actorId, |
| @@ -188,6 +233,17 @@ const mcpHandler = httpAction(async (ctx, request) => { · 40 unmodified lines | ||
| 188 | 233 | |
| 189 | 234 | return (await ctx.runAction(internal.executorNode.listToolsInternal, toolContext)) as ToolDescriptor[]; |
| 190 | 235 | }, |
| 236 | + listToolsForTypecheck: async (toolContext: { workspaceId: string; actorId?: string; clientId?: string }) => { | |
| 237 | + const result = await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, toolContext) as { | |
| 238 | + tools: ToolDescriptor[]; | |
| 239 | + dtsUrls?: Record<string, string>; | |
| 240 | + }; | |
| 241 | + | |
| 242 | + return { | |
| 243 | + tools: result.tools, | |
| 244 | + dtsUrls: result.dtsUrls ?? {}, | |
| 245 | + }; | |
| 246 | + }, | |
| 191 | 247 | listPendingApprovals: async (workspaceId: string) => { |
| 192 | 248 | return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[]; |
| 193 | 249 | }, |
| @@ -206,24 +262,26 @@ const mcpHandler = httpAction(async (ctx, request) => { · 12 unmodified lines | ||
| 206 | 262 | }); |
| 207 | 263 | |
| 208 | 264 | const oauthProtectedResourceHandler = httpAction(async (_ctx, request) => { |
| 209 | − if (!mcpAuthEnabled || !mcpAuthorizationServer) { | |
| 265 | + const mcpAuthConfig = getMcpAuthConfig(); | |
| 266 | + if (!mcpAuthConfig.enabled || !mcpAuthConfig.authorizationServer) { | |
| 210 | 267 | return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 }); |
| 211 | 268 | } |
| 212 | 269 | |
| 213 | 270 | const url = new URL(request.url); |
| 214 | 271 | return Response.json({ |
| 215 | 272 | resource: `${url.origin}/mcp`, |
| 216 | − authorization_servers: [mcpAuthorizationServer], | |
| 273 | + authorization_servers: [mcpAuthConfig.authorizationServer], | |
| 217 | 274 | bearer_methods_supported: ["header"], |
| 218 | 275 | }); |
| 219 | 276 | }); |
| 220 | 277 | |
| 221 | 278 | const oauthAuthorizationServerProxyHandler = httpAction(async (_ctx, request) => { |
| 222 | − if (!mcpAuthEnabled || !mcpAuthorizationServer) { | |
| 279 | + const mcpAuthConfig = getMcpAuthConfig(); | |
| 280 | + if (!mcpAuthConfig.enabled || !mcpAuthConfig.authorizationServer) { | |
| 223 | 281 | return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 }); |
| 224 | 282 | } |
| 225 | 283 | |
| 226 | − const upstream = new URL("/.well-known/oauth-authorization-server", mcpAuthorizationServer); | |
| 284 | + const upstream = new URL("/.well-known/oauth-authorization-server", mcpAuthConfig.authorizationServer); | |
| 227 | 285 | const response = await fetch(upstream.toString(), { |
| 228 | 286 | headers: { accept: "application/json" }, |
| 229 | 287 | }); |
| @@ -9,6 +9,10 @@ const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("acto · 8 unmodified lines | ||
| 9 | 9 | const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault")); |
| 10 | 10 | const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); |
| 11 | 11 | |
| 12 | +function actorIdForAccount(account: { _id: string; provider: string; providerAccountId: string }): string { | |
| 13 | + return account.provider === "anonymous" ? account.providerAccountId : account._id; | |
| 14 | +} | |
| 15 | + | |
| 12 | 16 | function redactCredential<T extends { secretJson: Record<string, unknown> }>(credential: T): T { |
| 13 | 17 | return { |
| 14 | 18 | ...credential, |
| @@ -30,6 +34,16 @@ export const listRuntimeTargets = query({ · 15 unmodified lines | ||
| 30 | 34 | }, |
| 31 | 35 | }); |
| 32 | 36 | |
| 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 | + | |
| 33 | 47 | export const getTask = workspaceQuery({ |
| 34 | 48 | args: { taskId: v.string() }, |
| 35 | 49 | handler: async (ctx, args) => { |
| @@ -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 | +}); | |
| @@ -9,6 +9,11 @@ class FakeMcpService { · 8 unmodified lines | ||
| 9 | 9 | private readonly tasks = new Map<string, TaskRecord>(); |
| 10 | 10 | private readonly sessions = new Map<string, AnonymousContext>(); |
| 11 | 11 | private readonly listeners = new Map<string, Set<(event: LiveTaskEvent) => void>>(); |
| 12 | + private typecheckPayload?: { tools: ToolDescriptor[]; dtsUrls: Record<string, string> }; | |
| 13 | + | |
| 14 | + setTypecheckPayload(payload: { tools: ToolDescriptor[]; dtsUrls: Record<string, string> }) { | |
| 15 | + this.typecheckPayload = payload; | |
| 16 | + } | |
| 12 | 17 | |
| 13 | 18 | async createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }> { |
| 14 | 19 | const id = `task_${crypto.randomUUID()}`; |
| @@ -90,6 +95,18 @@ class FakeMcpService { · 75 unmodified lines | ||
| 90 | 95 | { path: "utils.get_time", description: "Get the current time", approval: "auto" }, |
| 91 | 96 | ]; |
| 92 | 97 | } |
| 98 | + | |
| 99 | + async listToolsForTypecheck( | |
| 100 | + _context: { workspaceId: string; actorId?: string; clientId?: string }, | |
| 101 | + ): Promise<{ tools: ToolDescriptor[]; dtsUrls: Record<string, string> }> { | |
| 102 | + if (this.typecheckPayload) { | |
| 103 | + return this.typecheckPayload; | |
| 104 | + } | |
| 105 | + return { | |
| 106 | + tools: await this.listTools(_context), | |
| 107 | + dtsUrls: {}, | |
| 108 | + }; | |
| 109 | + } | |
| 93 | 110 | } |
| 94 | 111 | |
| 95 | 112 | async function withMcpClient<T>( |
| @@ -169,3 +186,50 @@ test("run_code MCP tool bootstraps anonymous context when workspace is omitted", · 73 unmodified lines | ||
| 169 | 186 | expect(structured?.sessionId).toBe("mcp_session_test"); |
| 170 | 187 | }); |
| 171 | 188 | }); |
| 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 | +}); | |
| @@ -25,6 +25,9 @@ interface McpExecutorService { · 24 unmodified lines | ||
| 25 | 25 | subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void; |
| 26 | 26 | bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>; |
| 27 | 27 | listTools(context?: { workspaceId: string; actorId?: string; clientId?: string }): Promise<ToolDescriptor[]>; |
| 28 | + listToolsForTypecheck?( | |
| 29 | + context: { workspaceId: string; actorId?: string; clientId?: string }, | |
| 30 | + ): Promise<{ tools: ToolDescriptor[]; dtsUrls: Record<string, string> }>; | |
| 28 | 31 | listPendingApprovals?(workspaceId: string): Promise<PendingApprovalRecord[]>; |
| 29 | 32 | resolveApproval?(input: { |
| 30 | 33 | workspaceId: string; |
| @@ -73,6 +76,57 @@ function textContent(text: string): { type: "text"; text: string } { · 42 unmodified lines | ||
| 73 | 76 | return { type: "text", text }; |
| 74 | 77 | } |
| 75 | 78 | |
| 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 | + | |
| 76 | 130 | function summarizeTask(task: TaskRecord): string { |
| 77 | 131 | const lines = [ |
| 78 | 132 | `taskId: ${task.id}`, |
| @@ -223,8 +277,97 @@ function waitForTerminalTask( · 144 unmodified lines | ||
| 223 | 277 | function buildRunCodeDescription(tools?: ToolDescriptor[]): string { |
| 224 | 278 | const base = |
| 225 | 279 | "Execute TypeScript code in a sandboxed runtime. The code has access to a `tools` object with typed methods for calling external services. Use `return` to return a value. Waits for completion and returns stdout/stderr. Code is typechecked before execution — type errors are returned without running."; |
| 280 | + const toolList = tools ?? []; | |
| 281 | + const topLevelKeys = listTopLevelToolKeys(toolList); | |
| 282 | + const rootKeysNote = topLevelKeys.length > 0 | |
| 283 | + ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}` | |
| 284 | + : ""; | |
| 285 | + const discoverNote = "\n\nTip: use `tools.discover({ query, depth?, limit? })` to find exact callable tool paths before invoking them. Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`)."; | |
| 286 | + | |
| 287 | + return base + rootKeysNote + discoverNote + generateToolInventory(toolList); | |
| 288 | +} | |
| 226 | 289 | |
| 227 | − return base + generateToolInventory(tools ?? []); | |
| 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 | + }; | |
| 228 | 371 | } |
| 229 | 372 | |
| 230 | 373 | // --------------------------------------------------------------------------- |
| @@ -271,20 +414,52 @@ function createRunCodeTool( · 40 unmodified lines | ||
| 271 | 414 | }; |
| 272 | 415 | } |
| 273 | 416 | |
| 274 | − // Typecheck code before execution — get tool inventory for 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, | |
| 279 | 439 | }); |
| 280 | − const declarations = generateToolDeclarations(toolsForContext); | |
| 281 | 440 | const typecheck = typecheckCode(input.code, declarations); |
| 282 | 441 | |
| 283 | 442 | if (!typecheck.ok) { |
| 443 | + const topLevelKeys = listTopLevelToolKeys(toolsForContext); | |
| 444 | + const toolsShadowingError = typecheck.errors.some((error) => | |
| 445 | + error.includes("Block-scoped variable 'tools' used before its declaration") | |
| 446 | + || error.includes("'tools' implicitly has type 'any'"), | |
| 447 | + ); | |
| 448 | + | |
| 449 | + const hintLines: string[] = []; | |
| 450 | + if (toolsShadowingError) { | |
| 451 | + hintLines.push("Hint: avoid declaring a local variable named `tools`."); | |
| 452 | + hintLines.push("Example: `const discovered = await tools.discover({ query: \"github issues\" });`"); | |
| 453 | + } | |
| 454 | + if (topLevelKeys.length > 0) { | |
| 455 | + hintLines.push(`Available top-level tool keys: ${topLevelKeys.join(", ")}`); | |
| 456 | + } | |
| 457 | + | |
| 284 | 458 | const errorText = [ |
| 285 | 459 | "TypeScript type errors in generated code:", |
| 286 | 460 | "", |
| 287 | 461 | ...typecheck.errors.map((e) => ` ${e}`), |
| 462 | + ...(hintLines.length > 0 ? ["", ...hintLines] : []), | |
| 288 | 463 | "", |
| 289 | 464 | "Fix the type errors and try again.", |
| 290 | 465 | ].join("\n"); |
| @@ -403,6 +578,7 @@ async function createMcpServer( · 112 unmodified lines | ||
| 403 | 578 | { name: "executor", version: "0.1.0" }, |
| 404 | 579 | { capabilities: { tools: {} } }, |
| 405 | 580 | ); |
| 581 | + const onApprovalPrompt = createMcpApprovalPrompt(mcp); | |
| 406 | 582 | |
| 407 | 583 | // If workspace context is provided, fetch tool inventory for richer description |
| 408 | 584 | let tools: ToolDescriptor[] | undefined; |
| @@ -420,17 +596,41 @@ async function createMcpServer( · 11 unmodified lines | ||
| 420 | 596 | description: buildRunCodeDescription(tools), |
| 421 | 597 | inputSchema: context ? BOUND_INPUT : FULL_INPUT, |
| 422 | 598 | }, |
| 423 | − createRunCodeTool(service, context), | |
| 599 | + createRunCodeTool(service, context, onApprovalPrompt), | |
| 424 | 600 | ); |
| 425 | 601 | |
| 426 | 602 | return mcp; |
| 427 | 603 | } |
| 428 | 604 | |
| 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 | +} | |
| 432 | 623 | |
| 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( | |
| 434 | 634 | service: McpExecutorService, |
| 435 | 635 | request: Request, |
| 436 | 636 | context?: McpWorkspaceContext, |
| @@ -449,3 +649,74 @@ export async function handleMcpRequest( · 12 unmodified lines | ||
| 449 | 649 | await mcp.close().catch(() => {}); |
| 450 | 650 | } |
| 451 | 651 | } |
| 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 | +} | |
| @@ -425,6 +425,60 @@ describe("buildOpenApiToolsFromPrepared", () => { · 424 unmodified lines | ||
| 425 | 425 | expect(tool!.metadata?.returnsType).not.toBe("unknown"); |
| 426 | 426 | }); |
| 427 | 427 | |
| 428 | + test("quotes non-identifier parameter names in args type hints", async () => { | |
| 429 | + const spec: Record<string, unknown> = { | |
| 430 | + openapi: "3.0.3", | |
| 431 | + info: { title: "Header names", version: "1.0.0" }, | |
| 432 | + servers: [{ url: "https://api.example.com" }], | |
| 433 | + paths: { | |
| 434 | + "/meta": { | |
| 435 | + get: { | |
| 436 | + operationId: "meta/get", | |
| 437 | + tags: ["meta"], | |
| 438 | + parameters: [ | |
| 439 | + { | |
| 440 | + name: "X-GitHub-Api-Version", | |
| 441 | + in: "header", | |
| 442 | + required: false, | |
| 443 | + schema: { type: "string" }, | |
| 444 | + }, | |
| 445 | + ], | |
| 446 | + responses: { | |
| 447 | + "200": { | |
| 448 | + description: "ok", | |
| 449 | + content: { | |
| 450 | + "application/json": { | |
| 451 | + schema: { | |
| 452 | + type: "object", | |
| 453 | + properties: { | |
| 454 | + ver: { type: "string" }, | |
| 455 | + }, | |
| 456 | + }, | |
| 457 | + }, | |
| 458 | + }, | |
| 459 | + }, | |
| 460 | + }, | |
| 461 | + }, | |
| 462 | + }, | |
| 463 | + }, | |
| 464 | + }; | |
| 465 | + | |
| 466 | + const prepared = await prepareOpenApiSpec(spec, "headers"); | |
| 467 | + const tools = buildOpenApiToolsFromPrepared( | |
| 468 | + { | |
| 469 | + type: "openapi", | |
| 470 | + name: "github", | |
| 471 | + spec, | |
| 472 | + baseUrl: "https://api.example.com", | |
| 473 | + }, | |
| 474 | + prepared, | |
| 475 | + ); | |
| 476 | + | |
| 477 | + const tool = tools.find((t) => t.metadata?.operationId === "meta/get"); | |
| 478 | + expect(tool).toBeDefined(); | |
| 479 | + expect(tool!.metadata?.argsType).toContain('"X-GitHub-Api-Version"?: string'); | |
| 480 | + }); | |
| 481 | + | |
| 428 | 482 | test("resolves OpenAPI component schema refs for return type hints", async () => { |
| 429 | 483 | const spec: Record<string, unknown> = { |
| 430 | 484 | openapi: "3.0.3", |
| @@ -236,6 +236,32 @@ describe("real-world OpenAPI specs", () => { · 235 unmodified lines | ||
| 236 | 236 | 300_000, |
| 237 | 237 | ); |
| 238 | 238 | |
| 239 | + test( | |
| 240 | + "github: meta/get has empty object input hint", | |
| 241 | + async () => { | |
| 242 | + const githubUrl = | |
| 243 | + "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml"; | |
| 244 | + | |
| 245 | + const prepared = await prepareOpenApiSpec(githubUrl, "github"); | |
| 246 | + const tools = buildOpenApiToolsFromPrepared( | |
| 247 | + { | |
| 248 | + type: "openapi", | |
| 249 | + name: "github", | |
| 250 | + spec: githubUrl, | |
| 251 | + baseUrl: prepared.servers[0] || "https://api.github.com", | |
| 252 | + }, | |
| 253 | + prepared, | |
| 254 | + ); | |
| 255 | + | |
| 256 | + const tool = tools.find((t) => t.metadata?.operationId === "meta/get"); | |
| 257 | + | |
| 258 | + expect(tool).toBeDefined(); | |
| 259 | + expect(tool!.path).toBe("github.meta.get"); | |
| 260 | + expect(tool!.metadata!.argsType).toBe("{}"); | |
| 261 | + }, | |
| 262 | + 300_000, | |
| 263 | + ); | |
| 264 | + | |
| 239 | 265 | test( |
| 240 | 266 | "github: all budgets for org has non-unknown return hint", |
| 241 | 267 | async () => { |
| @@ -394,6 +394,15 @@ function responseTypeHintFromSchema( · 393 unmodified lines | ||
| 394 | 394 | return "unknown"; |
| 395 | 395 | } |
| 396 | 396 | |
| 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 | + | |
| 397 | 406 | /** Depth-limited + cycle-safe type hint generator for schemas (used as fallback). */ |
| 398 | 407 | function jsonSchemaTypeHintFallback( |
| 399 | 408 | schema: unknown, |
| @@ -477,7 +486,7 @@ function jsonSchemaTypeHintFallback( · 77 unmodified lines | ||
| 477 | 486 | } |
| 478 | 487 | const inner = propEntries |
| 479 | 488 | .slice(0, 12) |
| 480 | − .map(([key, value]) => `${key}${required.has(key) ? "" : "?"}: ${jsonSchemaTypeHintFallback(value, depth + 1, componentSchemas, seenRefs)}`) | |
| 489 | + .map(([key, value]) => `${formatTsPropertyKey(key)}${required.has(key) ? "" : "?"}: ${jsonSchemaTypeHintFallback(value, depth + 1, componentSchemas, seenRefs)}`) | |
| 481 | 490 | .join("; "); |
| 482 | 491 | return `{ ${inner} }`; |
| 483 | 492 | } |
| @@ -771,19 +780,20 @@ function compactOpenApiPaths( · 287 unmodified lines | ||
| 771 | 780 | |
| 772 | 781 | if (hasGeneratedTypes) { |
| 773 | 782 | // Pre-compute lightweight type hint strings (the full schemas are in the .d.ts) |
| 783 | + const mergedParameters = normalizeParameters(operation.parameters).concat(sharedParameters); | |
| 784 | + const hasInputSchema = | |
| 785 | + mergedParameters.length > 0 || Object.keys(requestBodySchema).length > 0; | |
| 774 | 786 | const combinedSchema: JsonSchema = { |
| 775 | 787 | type: "object", |
| 776 | 788 | properties: { |
| 777 | 789 | ...Object.fromEntries( |
| 778 | − normalizeParameters(operation.parameters) | |
| 779 | − .concat(sharedParameters) | |
| 790 | + mergedParameters | |
| 780 | 791 | .map((param) => [param.name, param.schema]), |
| 781 | 792 | ), |
| 782 | 793 | ...asRecord(requestBodySchema.properties), |
| 783 | 794 | }, |
| 784 | 795 | required: [ |
| 785 | − ...normalizeParameters(operation.parameters) | |
| 786 | − .concat(sharedParameters) | |
| 796 | + ...mergedParameters | |
| 787 | 797 | .filter((param) => param.required) |
| 788 | 798 | .map((param) => param.name as string), |
| 789 | 799 | ...((Array.isArray(requestBodySchema.required) |
| @@ -791,7 +801,9 @@ function compactOpenApiPaths( · 1 unmodified line | ||
| 791 | 801 | : []) as string[]), |
| 792 | 802 | ], |
| 793 | 803 | }; |
| 794 | − compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas); | |
| 804 | + compactOperation._argsTypeHint = hasInputSchema | |
| 805 | + ? jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas) | |
| 806 | + : "{}"; | |
| 795 | 807 | compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas); |
| 796 | 808 | } else { |
| 797 | 809 | // Keep full schemas for the fallback path |
| @@ -998,7 +1010,9 @@ export function buildOpenApiToolsFromPrepared( · 200 unmodified lines | ||
| 998 | 1010 | ], |
| 999 | 1011 | }; |
| 1000 | 1012 | |
| 1001 | − argsType = jsonSchemaTypeHintFallback(combinedSchema); | |
| 1013 | + const hasInputSchema = parameters.length > 0 || Object.keys(requestBodySchema).length > 0; | |
| 1014 | + | |
| 1015 | + argsType = hasInputSchema ? jsonSchemaTypeHintFallback(combinedSchema) : "{}"; | |
| 1002 | 1016 | returnsType = responseTypeHintFromSchema(responseSchema, responseStatus); |
| 1003 | 1017 | } |
| 1004 | 1018 | |
| @@ -1363,6 +1377,11 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 358 unmodified lines | ||
| 1363 | 1377 | credential: credentialSpec, |
| 1364 | 1378 | // Tag as graphql source so invokeTool knows to do dynamic path extraction |
| 1365 | 1379 | _graphqlSource: config.name, |
| 1380 | + _runSpec: { | |
| 1381 | + kind: "graphql_raw" as const, | |
| 1382 | + endpoint: config.endpoint, | |
| 1383 | + authHeaders, | |
| 1384 | + }, | |
| 1366 | 1385 | run: async (input: unknown, context) => { |
| 1367 | 1386 | const payload = asRecord(input); |
| 1368 | 1387 | const query = String(payload.query ?? ""); |
| @@ -1433,6 +1452,14 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 64 unmodified lines | ||
| 1433 | 1452 | argsType: gqlFieldArgsTypeHint(field.args, typeMap), |
| 1434 | 1453 | returnsType: gqlTypeToHint(field.type, typeMap), |
| 1435 | 1454 | }, |
| 1455 | + _runSpec: { | |
| 1456 | + kind: "graphql_field" as const, | |
| 1457 | + endpoint: config.endpoint, | |
| 1458 | + operationName: field.name, | |
| 1459 | + operationType, | |
| 1460 | + queryTemplate: exampleQuery, | |
| 1461 | + authHeaders, | |
| 1462 | + }, | |
| 1436 | 1463 | // Pseudo-tools don't have a run — they exist for discovery and policy matching only |
| 1437 | 1464 | _pseudoTool: true, |
| 1438 | 1465 | run: async (input: unknown, context) => { |
| @@ -1518,7 +1545,9 @@ export interface SerializedTool { · 79 unmodified lines | ||
| 1518 | 1545 | * Data needed to reconstruct `run()`. Shape depends on source type. |
| 1519 | 1546 | * OpenAPI: { kind: "openapi", baseUrl, method, pathTemplate, parameters, authHeaders } |
| 1520 | 1547 | * MCP: { kind: "mcp", url, transport?, queryParams?, toolName } |
| 1521 | − * GraphQL: { kind: "graphql", endpoint, operationName, operationType, auth? } | |
| 1548 | + * GraphQL raw: { kind: "graphql_raw", endpoint, authHeaders } | |
| 1549 | + * GraphQL field: { kind: "graphql_field", endpoint, operationName, operationType, queryTemplate, authHeaders } | |
| 1550 | + * GraphQL (legacy compat): { kind: "graphql", endpoint, operationName, operationType, authHeaders } | |
| 1522 | 1551 | * Builtin: { kind: "builtin" } — run comes from DEFAULT_TOOLS |
| 1523 | 1552 | */ |
| 1524 | 1553 | runSpec: |
| @@ -1537,6 +1566,19 @@ export interface SerializedTool { · 12 unmodified lines | ||
| 1537 | 1566 | queryParams?: Record<string, string>; |
| 1538 | 1567 | toolName: string; |
| 1539 | 1568 | } |
| 1569 | + | { | |
| 1570 | + kind: "graphql_raw"; | |
| 1571 | + endpoint: string; | |
| 1572 | + authHeaders: Record<string, string>; | |
| 1573 | + } | |
| 1574 | + | { | |
| 1575 | + kind: "graphql_field"; | |
| 1576 | + endpoint: string; | |
| 1577 | + operationName: string; | |
| 1578 | + operationType: "query" | "mutation"; | |
| 1579 | + queryTemplate: string; | |
| 1580 | + authHeaders: Record<string, string>; | |
| 1581 | + } | |
| 1540 | 1582 | | { |
| 1541 | 1583 | kind: "graphql"; |
| 1542 | 1584 | endpoint: string; |
| @@ -1589,6 +1631,36 @@ export function rehydrateTools( · 46 unmodified lines | ||
| 1589 | 1631 | |
| 1590 | 1632 | const readMethods = new Set(["get", "head", "options"]); |
| 1591 | 1633 | |
| 1634 | + async function executeGraphql( | |
| 1635 | + endpoint: string, | |
| 1636 | + authHeaders: Record<string, string>, | |
| 1637 | + query: string, | |
| 1638 | + variables: unknown, | |
| 1639 | + context: { credential?: { headers: Record<string, string> } }, | |
| 1640 | + ): Promise<unknown> { | |
| 1641 | + const response = await fetch(endpoint, { | |
| 1642 | + method: "POST", | |
| 1643 | + headers: { | |
| 1644 | + "content-type": "application/json", | |
| 1645 | + ...authHeaders, | |
| 1646 | + ...(context.credential?.headers ?? {}), | |
| 1647 | + }, | |
| 1648 | + body: JSON.stringify({ query, variables }), | |
| 1649 | + }); | |
| 1650 | + | |
| 1651 | + if (!response.ok) { | |
| 1652 | + const text = await response.text().catch(() => ""); | |
| 1653 | + throw new Error(`GraphQL HTTP ${response.status}: ${text.slice(0, 500)}`); | |
| 1654 | + } | |
| 1655 | + | |
| 1656 | + const result = await response.json() as { data?: unknown; errors?: unknown[] }; | |
| 1657 | + if (result.errors && (!result.data || Object.keys(result.data as object).length === 0)) { | |
| 1658 | + throw new Error(`GraphQL errors: ${JSON.stringify(result.errors).slice(0, 1000)}`); | |
| 1659 | + } | |
| 1660 | + if (result.errors) return result; | |
| 1661 | + return result.data; | |
| 1662 | + } | |
| 1663 | + | |
| 1592 | 1664 | return serialized.map((st) => { |
| 1593 | 1665 | const base: Omit<ToolDefinition, "run"> = { |
| 1594 | 1666 | path: st.path, |
| @@ -1676,29 +1748,62 @@ export function rehydrateTools( · 81 unmodified lines | ||
| 1676 | 1748 | }; |
| 1677 | 1749 | } |
| 1678 | 1750 | |
| 1751 | + if (st.runSpec.kind === "graphql_raw") { | |
| 1752 | + const { endpoint, authHeaders } = st.runSpec; | |
| 1753 | + return { | |
| 1754 | + ...base, | |
| 1755 | + run: async (input: unknown, context) => { | |
| 1756 | + const payload = asRecord(input); | |
| 1757 | + const query = typeof payload.query === "string" ? payload.query : ""; | |
| 1758 | + if (!query.trim()) { | |
| 1759 | + throw new Error("GraphQL query string is required"); | |
| 1760 | + } | |
| 1761 | + const variables = payload.variables; | |
| 1762 | + return await executeGraphql(endpoint, authHeaders, query, variables, context); | |
| 1763 | + }, | |
| 1764 | + }; | |
| 1765 | + } | |
| 1766 | + | |
| 1767 | + if (st.runSpec.kind === "graphql_field") { | |
| 1768 | + const { endpoint, operationName, queryTemplate, authHeaders } = st.runSpec; | |
| 1769 | + return { | |
| 1770 | + ...base, | |
| 1771 | + run: async (input: unknown, context) => { | |
| 1772 | + const payload = asRecord(input); | |
| 1773 | + const hasExplicitQuery = typeof payload.query === "string" && payload.query.trim().length > 0; | |
| 1774 | + const query = hasExplicitQuery ? String(payload.query) : queryTemplate; | |
| 1775 | + | |
| 1776 | + let variables = payload.variables; | |
| 1777 | + if (variables === undefined && !hasExplicitQuery) { | |
| 1778 | + const variablePayload: Record<string, unknown> = { ...payload }; | |
| 1779 | + delete variablePayload.query; | |
| 1780 | + delete variablePayload.variables; | |
| 1781 | + variables = Object.keys(variablePayload).length > 0 ? variablePayload : undefined; | |
| 1782 | + } | |
| 1783 | + | |
| 1784 | + const result = await executeGraphql(endpoint, authHeaders, query, variables, context); | |
| 1785 | + if (result && typeof result === "object") { | |
| 1786 | + return (result as Record<string, unknown>)[operationName]; | |
| 1787 | + } | |
| 1788 | + return result; | |
| 1789 | + }, | |
| 1790 | + }; | |
| 1791 | + } | |
| 1792 | + | |
| 1679 | 1793 | if (st.runSpec.kind === "graphql") { |
| 1794 | + // Legacy compat for older cached entries. | |
| 1680 | 1795 | const { endpoint, operationName, operationType, authHeaders } = st.runSpec; |
| 1796 | + const queryTemplate = `${operationType} ${operationName}($input: JSON) { ${operationName}(input: $input) }`; | |
| 1681 | 1797 | return { |
| 1682 | 1798 | ...base, |
| 1683 | 1799 | run: async (input: unknown, context) => { |
| 1684 | 1800 | 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]; | |
| 1698 | 1805 | } |
| 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; | |
| 1702 | 1807 | }, |
| 1703 | 1808 | }; |
| 1704 | 1809 | } |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { test, expect, describe } from "bun:test"; |
| 2 | 2 | import { generateToolDeclarations, generateToolInventory, typecheckCode, type TypecheckResult } from "./typechecker"; |
| 3 | +import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool_sources"; | |
| 3 | 4 | import type { ToolDescriptor } from "./types"; |
| 4 | 5 | |
| 5 | 6 | // --------------------------------------------------------------------------- |
| @@ -44,7 +45,7 @@ describe("generateToolDeclarations", () => { · 38 unmodified lines | ||
| 44 | 45 | |
| 45 | 46 | test("flat tool (no dots in path beyond single segment)", () => { |
| 46 | 47 | const result = generateToolDeclarations([FLAT_TOOL]); |
| 47 | − expect(result).toContain("get_time(input: {}): Promise<{ iso: string; unix: number }>;"); | |
| 48 | + expect(result).toContain("get_time(input?: {}): Promise<{ iso: string; unix: number }>;"); | |
| 48 | 49 | }); |
| 49 | 50 | |
| 50 | 51 | test("nested tool (math.add)", () => { |
| @@ -62,7 +63,7 @@ describe("generateToolDeclarations", () => { · 11 unmodified lines | ||
| 62 | 63 | expect(result).toContain("admin: {"); |
| 63 | 64 | expect(result).toContain("send_announcement(input: { message: string; channel?: string }): Promise<{ sent: boolean }>;"); |
| 64 | 65 | // flat |
| 65 | − expect(result).toContain("get_time(input: {}): Promise<{ iso: string; unix: number }>;"); | |
| 66 | + expect(result).toContain("get_time(input?: {}): Promise<{ iso: string; unix: number }>;"); | |
| 66 | 67 | }); |
| 67 | 68 | |
| 68 | 69 | test("missing argsType defaults to Record<string, unknown>", () => { |
| @@ -72,7 +73,7 @@ describe("generateToolDeclarations", () => { · 3 unmodified lines | ||
| 72 | 73 | approval: "auto", |
| 73 | 74 | }; |
| 74 | 75 | const result = generateToolDeclarations([tool]); |
| 75 | − expect(result).toContain("bar(input: Record<string, unknown>): Promise<unknown>;"); | |
| 76 | + expect(result).toContain("bar(input?: Record<string, unknown>): Promise<unknown>;"); | |
| 76 | 77 | }); |
| 77 | 78 | |
| 78 | 79 | test("missing returnsType defaults to unknown", () => { |
| @@ -85,6 +86,41 @@ describe("generateToolDeclarations", () => { · 6 unmodified lines | ||
| 85 | 86 | const result = generateToolDeclarations([tool]); |
| 86 | 87 | expect(result).toContain("baz(input: { x: number }): Promise<unknown>;"); |
| 87 | 88 | }); |
| 89 | + | |
| 90 | + test("uses OpenAPI operations indexed-access types when source d.ts is provided", () => { | |
| 91 | + const tools: ToolDescriptor[] = [ | |
| 92 | + { | |
| 93 | + path: "github.issues.list_for_repo", | |
| 94 | + description: "List issues for a repo", | |
| 95 | + approval: "auto", | |
| 96 | + source: "openapi:github", | |
| 97 | + operationId: "issues/list-for-repo", | |
| 98 | + argsType: "{ owner: string; repo: string }", | |
| 99 | + returnsType: "unknown", | |
| 100 | + }, | |
| 101 | + ]; | |
| 102 | + | |
| 103 | + const sourceDts = ` | |
| 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 | + }); | |
| 88 | 124 | }); |
| 89 | 125 | |
| 90 | 126 | // --------------------------------------------------------------------------- |
| @@ -96,29 +132,20 @@ describe("generateToolInventory", () => { · 5 unmodified lines | ||
| 96 | 132 | expect(generateToolInventory([])).toBe(""); |
| 97 | 133 | }); |
| 98 | 134 | |
| 99 | − test("includes tool paths and type signatures", () => { | |
| 135 | + test("includes namespace summary and discover guidance", () => { | |
| 100 | 136 | 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"); | |
| 104 | 142 | }); |
| 105 | 143 | |
| 106 | − test("shows [approval required] for required-approval tools", () => { | |
| 144 | + test("includes example callable paths", () => { | |
| 107 | 145 | 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(...)"); | |
| 122 | 149 | }); |
| 123 | 150 | }); |
| 124 | 151 | |
| @@ -240,6 +267,46 @@ return { sum: sum.result, sent: announcement.sent, time: time.unix }; · 115 unmodified lines | ||
| 240 | 267 | expect(result.ok).toBe(true); |
| 241 | 268 | }); |
| 242 | 269 | |
| 270 | + test("OpenAPI indexed-access declarations typecheck like Monaco", () => { | |
| 271 | + const tools: ToolDescriptor[] = [ | |
| 272 | + { | |
| 273 | + path: "github.issues.list_for_repo", | |
| 274 | + description: "List issues for a repo", | |
| 275 | + approval: "auto", | |
| 276 | + source: "openapi:github", | |
| 277 | + operationId: "issues/list-for-repo", | |
| 278 | + }, | |
| 279 | + ]; | |
| 280 | + | |
| 281 | + const sourceDts = ` | |
| 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 | + | |
| 243 | 310 | test("optional parameter can be provided", () => { |
| 244 | 311 | const result = typecheckCode( |
| 245 | 312 | 'const r = await tools.admin.send_announcement({ message: "hi", channel: "general" }); return r.sent;', |
| @@ -247,6 +314,114 @@ return { sum: sum.result, sent: announcement.sent, time: time.unix }; · 1 unmodified line | ||
| 247 | 314 | ); |
| 248 | 315 | expect(result.ok).toBe(true); |
| 249 | 316 | }); |
| 317 | + | |
| 318 | + test("discover remains callable when OpenAPI fallback args contain hyphenated header names", async () => { | |
| 319 | + const spec: Record<string, unknown> = { | |
| 320 | + openapi: "3.0.3", | |
| 321 | + info: { title: "Header names", version: "1.0.0" }, | |
| 322 | + servers: [{ url: "https://api.example.com" }], | |
| 323 | + paths: { | |
| 324 | + "/meta": { | |
| 325 | + get: { | |
| 326 | + operationId: "meta/get", | |
| 327 | + tags: ["meta"], | |
| 328 | + parameters: [ | |
| 329 | + { | |
| 330 | + name: "X-GitHub-Api-Version", | |
| 331 | + in: "header", | |
| 332 | + required: false, | |
| 333 | + schema: { type: "string" }, | |
| 334 | + }, | |
| 335 | + ], | |
| 336 | + responses: { | |
| 337 | + "200": { | |
| 338 | + description: "ok", | |
| 339 | + content: { | |
| 340 | + "application/json": { | |
| 341 | + schema: { | |
| 342 | + type: "object", | |
| 343 | + properties: { | |
| 344 | + ok: { type: "boolean" }, | |
| 345 | + }, | |
| 346 | + }, | |
| 347 | + }, | |
| 348 | + }, | |
| 349 | + }, | |
| 350 | + }, | |
| 351 | + }, | |
| 352 | + }, | |
| 353 | + }, | |
| 354 | + }; | |
| 355 | + | |
| 356 | + const prepared = await prepareOpenApiSpec(spec, "headers"); | |
| 357 | + const built = buildOpenApiToolsFromPrepared( | |
| 358 | + { | |
| 359 | + type: "openapi", | |
| 360 | + name: "github", | |
| 361 | + spec, | |
| 362 | + baseUrl: "https://api.example.com", | |
| 363 | + }, | |
| 364 | + prepared, | |
| 365 | + ); | |
| 366 | + | |
| 367 | + const descriptors: ToolDescriptor[] = built.map((tool) => ({ | |
| 368 | + path: tool.path, | |
| 369 | + description: tool.description, | |
| 370 | + approval: tool.approval, | |
| 371 | + source: tool.source, | |
| 372 | + argsType: tool.metadata?.argsType, | |
| 373 | + returnsType: tool.metadata?.returnsType, | |
| 374 | + operationId: tool.metadata?.operationId, | |
| 375 | + })); | |
| 376 | + descriptors.push({ | |
| 377 | + path: "discover", | |
| 378 | + description: "Discover tools", | |
| 379 | + approval: "auto", | |
| 380 | + source: "system", | |
| 381 | + argsType: "{ query: string; depth?: number; limit?: number }", | |
| 382 | + returnsType: "unknown", | |
| 383 | + }); | |
| 384 | + | |
| 385 | + const declarations = generateToolDeclarations(descriptors); | |
| 386 | + const result = typecheckCode( | |
| 387 | + 'const found = await tools.discover({ query: "github issues" }); return found;', | |
| 388 | + declarations, | |
| 389 | + ); | |
| 390 | + | |
| 391 | + expect(result.ok).toBe(true); | |
| 392 | + expect(result.errors).toHaveLength(0); | |
| 393 | + }); | |
| 394 | + | |
| 395 | + test("invalid type hint strings are downgraded instead of corrupting declarations", () => { | |
| 396 | + const tools: ToolDescriptor[] = [ | |
| 397 | + { | |
| 398 | + path: "github.meta.get", | |
| 399 | + description: "Get metadata", | |
| 400 | + approval: "auto", | |
| 401 | + // Intentionally invalid TS type expression (unquoted hyphenated key) | |
| 402 | + argsType: "{ X-GitHub-Api-Version?: string }", | |
| 403 | + returnsType: "unknown", | |
| 404 | + }, | |
| 405 | + { | |
| 406 | + path: "discover", | |
| 407 | + description: "Discover tools", | |
| 408 | + approval: "auto", | |
| 409 | + argsType: "{ query: string; depth?: number; limit?: number }", | |
| 410 | + returnsType: "unknown", | |
| 411 | + }, | |
| 412 | + ]; | |
| 413 | + | |
| 414 | + const declarations = generateToolDeclarations(tools); | |
| 415 | + expect(declarations).toContain("get(input: Record<string, unknown>): Promise<unknown>;"); | |
| 416 | + | |
| 417 | + const result = typecheckCode( | |
| 418 | + 'const found = await tools.discover({ query: "github" }); return found;', | |
| 419 | + declarations, | |
| 420 | + ); | |
| 421 | + | |
| 422 | + expect(result.ok).toBe(true); | |
| 423 | + expect(result.errors).toHaveLength(0); | |
| 424 | + }); | |
| 250 | 425 | }); |
| 251 | 426 | |
| 252 | 427 | // --------------------------------------------------------------------------- |
| @@ -19,6 +19,76 @@ import type { ToolDescriptor } from "./types"; · 18 unmodified lines | ||
| 19 | 19 | // Tool declarations generation |
| 20 | 20 | // --------------------------------------------------------------------------- |
| 21 | 21 | |
| 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 | + | |
| 22 | 92 | /** |
| 23 | 93 | * Build a `declare const tools: { ... }` block from flat tool descriptors. |
| 24 | 94 | * |
| @@ -29,7 +99,10 @@ import type { ToolDescriptor } from "./types"; · 4 unmodified lines | ||
| 29 | 99 | * Tool paths like "math.add" or "admin.send_announcement" are split on "." |
| 30 | 100 | * and nested into a type tree. |
| 31 | 101 | */ |
| 32 | −export function generateToolDeclarations(tools: ToolDescriptor[]): string { | |
| 102 | +export function generateToolDeclarations( | |
| 103 | + tools: ToolDescriptor[], | |
| 104 | + options?: GenerateToolDeclarationOptions, | |
| 105 | +): string { | |
| 33 | 106 | |
| 34 | 107 | // Legacy compat: collect schemaTypes from tools that use the old format |
| 35 | 108 | const allSchemas = new Map<string, string>(); |
| @@ -50,6 +123,7 @@ export function generateToolDeclarations(tools: ToolDescriptor[]): string { · 14 unmodified lines | ||
| 50 | 123 | } |
| 51 | 124 | |
| 52 | 125 | const root: TreeNode = { children: new Map() }; |
| 126 | + const dtsSources = new Set(Object.keys(options?.sourceDtsBySource ?? {})); | |
| 53 | 127 | |
| 54 | 128 | for (const tool of tools) { |
| 55 | 129 | const segments = tool.path.split("."); |
| @@ -73,9 +147,18 @@ export function generateToolDeclarations(tools: ToolDescriptor[]): string { · 17 unmodified lines | ||
| 73 | 147 | for (const [key, child] of node.children) { |
| 74 | 148 | if (child.tool) { |
| 75 | 149 | 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 | + } | |
| 79 | 162 | } else { |
| 80 | 163 | lines.push(`${pad}${key}: {`); |
| 81 | 164 | lines.push(renderNode(child, indent + 1)); |
| @@ -96,6 +179,17 @@ export function generateToolDeclarations(tools: ToolDescriptor[]): string { · 14 unmodified lines | ||
| 96 | 179 | } |
| 97 | 180 | } |
| 98 | 181 | |
| 182 | + const sourceDtsBySource = options?.sourceDtsBySource ?? {}; | |
| 183 | + const dtsEntries = Object.entries(sourceDtsBySource) | |
| 184 | + .filter(([, dts]) => typeof dts === "string" && dts.trim().length > 0) | |
| 185 | + .sort(([a], [b]) => a.localeCompare(b)); | |
| 186 | + if (dtsEntries.length > 0) { | |
| 187 | + parts.push(OPENAPI_HELPER_TYPES); | |
| 188 | + for (const [sourceKey, dts] of dtsEntries) { | |
| 189 | + parts.push(`// OpenAPI types from ${sourceKey}\n${stripExportKeywordsForTypechecker(dts)}`); | |
| 190 | + } | |
| 191 | + } | |
| 192 | + | |
| 99 | 193 | // The tools declaration |
| 100 | 194 | parts.push(`declare const tools: {\n${renderNode(root, 1)}\n};`); |
| 101 | 195 | |
| @@ -109,14 +203,33 @@ export function generateToolDeclarations(tools: ToolDescriptor[]): string { · 7 unmodified lines | ||
| 109 | 203 | export function generateToolInventory(tools: ToolDescriptor[]): string { |
| 110 | 204 | if (!tools || tools.length === 0) return ""; |
| 111 | 205 | |
| 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 | + } | |
| 118 | 211 | |
| 119 | − return `\nAvailable tools in the sandbox:\n${lines.join("\n\n")}`; | |
| 212 | + const namespaces = [...namespaceCounts.entries()] | |
| 213 | + .sort((a, b) => a[0].localeCompare(b[0])) | |
| 214 | + .map(([name, count]) => `${name} (${count})`); | |
| 215 | + | |
| 216 | + const examples = tools | |
| 217 | + .filter((tool) => tool.path !== "discover") | |
| 218 | + .slice(0, 8) | |
| 219 | + .map((tool) => ` - tools.${tool.path}(...)`); | |
| 220 | + | |
| 221 | + return [ | |
| 222 | + "", | |
| 223 | + "You have access to these tool namespaces:", | |
| 224 | + ` ${namespaces.join(", ")}`, | |
| 225 | + "", | |
| 226 | + "Use `tools.discover({ query, depth?, limit? })` first to find the exact callable tool paths relevant to the user request.", | |
| 227 | + "Never shadow the global `tools` object (do NOT write `const tools = ...`).", | |
| 228 | + "Then call tools directly using the returned path.", | |
| 229 | + ...(examples.length > 0 | |
| 230 | + ? ["", "Example callable paths:", ...examples] | |
| 231 | + : []), | |
| 232 | + ].join("\n"); | |
| 120 | 233 | } |
| 121 | 234 | |
| 122 | 235 | // --------------------------------------------------------------------------- |
| @@ -269,6 +269,95 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 268 unmodified lines | ||
| 269 | 269 | expect(restored[0]!.runSpec.kind).toBe("mcp"); |
| 270 | 270 | }); |
| 271 | 271 | |
| 272 | + test("GraphQL raw and field tools survive cache round-trip", async () => { | |
| 273 | + const graphqlTools: ToolDefinition[] = [ | |
| 274 | + { | |
| 275 | + path: "linear.graphql", | |
| 276 | + description: "Execute GraphQL", | |
| 277 | + approval: "auto", | |
| 278 | + source: "graphql:linear", | |
| 279 | + metadata: { | |
| 280 | + argsType: "{ query: string; variables?: Record<string, unknown> }", | |
| 281 | + returnsType: "unknown", | |
| 282 | + }, | |
| 283 | + _runSpec: { | |
| 284 | + kind: "graphql_raw" as const, | |
| 285 | + endpoint: "https://linear.example/graphql", | |
| 286 | + authHeaders: {}, | |
| 287 | + }, | |
| 288 | + run: async () => ({ ok: true }), | |
| 289 | + }, | |
| 290 | + { | |
| 291 | + path: "linear.query.teams", | |
| 292 | + description: "List teams", | |
| 293 | + approval: "auto", | |
| 294 | + source: "graphql:linear", | |
| 295 | + metadata: { | |
| 296 | + argsType: "{}", | |
| 297 | + returnsType: "unknown", | |
| 298 | + }, | |
| 299 | + _pseudoTool: true, | |
| 300 | + _runSpec: { | |
| 301 | + kind: "graphql_field" as const, | |
| 302 | + endpoint: "https://linear.example/graphql", | |
| 303 | + operationName: "teams", | |
| 304 | + operationType: "query" as const, | |
| 305 | + queryTemplate: "query teams { teams { nodes { id name } } }", | |
| 306 | + authHeaders: {}, | |
| 307 | + }, | |
| 308 | + run: async () => ({ ok: true }), | |
| 309 | + }, | |
| 310 | + ]; | |
| 311 | + | |
| 312 | + const serialized = serializeTools(graphqlTools); | |
| 313 | + expect(serialized[0]!.runSpec.kind).toBe("graphql_raw"); | |
| 314 | + expect(serialized[1]!.runSpec.kind).toBe("graphql_field"); | |
| 315 | + | |
| 316 | + const restored = JSON.parse(JSON.stringify(serialized)) as SerializedTool[]; | |
| 317 | + const rehydrated = rehydrateTools(restored, makeBaseTools()); | |
| 318 | + | |
| 319 | + const rawTool = rehydrated.find((tool) => tool.path === "linear.graphql"); | |
| 320 | + const teamsTool = rehydrated.find((tool) => tool.path === "linear.query.teams"); | |
| 321 | + expect(rawTool).toBeDefined(); | |
| 322 | + expect(teamsTool).toBeDefined(); | |
| 323 | + | |
| 324 | + const originalFetch = globalThis.fetch; | |
| 325 | + const calls: Array<{ query: string; variables: unknown }> = []; | |
| 326 | + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { | |
| 327 | + const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string; variables?: unknown }; | |
| 328 | + calls.push({ query: body.query ?? "", variables: body.variables }); | |
| 329 | + | |
| 330 | + if ((body.query ?? "").includes("teams")) { | |
| 331 | + return new Response(JSON.stringify({ data: { teams: [{ id: "team_1", name: "Core" }] } }), { | |
| 332 | + status: 200, | |
| 333 | + headers: { "content-type": "application/json" }, | |
| 334 | + }); | |
| 335 | + } | |
| 336 | + | |
| 337 | + return new Response(JSON.stringify({ data: { viewer: { id: "user_1" } } }), { | |
| 338 | + status: 200, | |
| 339 | + headers: { "content-type": "application/json" }, | |
| 340 | + }); | |
| 341 | + }) as typeof fetch; | |
| 342 | + | |
| 343 | + try { | |
| 344 | + const rawResult = await rawTool!.run( | |
| 345 | + { query: "query viewer { viewer { id } }" }, | |
| 346 | + { taskId: "t", workspaceId: "w", isToolAllowed: () => true }, | |
| 347 | + ); | |
| 348 | + const teamsResult = await teamsTool!.run( | |
| 349 | + {}, | |
| 350 | + { taskId: "t", workspaceId: "w", isToolAllowed: () => true }, | |
| 351 | + ); | |
| 352 | + | |
| 353 | + expect(rawResult).toEqual({ viewer: { id: "user_1" } }); | |
| 354 | + expect(teamsResult).toEqual([{ id: "team_1", name: "Core" }]); | |
| 355 | + expect(calls.length).toBe(2); | |
| 356 | + } finally { | |
| 357 | + globalThis.fetch = originalFetch; | |
| 358 | + } | |
| 359 | + }); | |
| 360 | + | |
| 272 | 361 | test("full WorkspaceToolSnapshot round-trip", async () => { |
| 273 | 362 | const prepared = await prepareOpenApiSpec(SMALL_SPEC, "widgets"); |
| 274 | 363 | const tools = buildOpenApiToolsFromPrepared( |
| @@ -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 | +); | |
| @@ -4,7 +4,9 @@ · 3 unmodified lines | ||
| 4 | 4 | "type": "module", |
| 5 | 5 | "scripts": { |
| 6 | 6 | "deploy": "bunx convex deploy", |
| 7 | − "codegen": "bunx convex codegen" | |
| 7 | + "codegen": "bunx convex codegen", | |
| 8 | + "dev:mcp-gateway": "bun --hot mcp-gateway.ts", | |
| 9 | + "start:mcp-gateway": "bun mcp-gateway.ts" | |
| 8 | 10 | }, |
| 9 | 11 | "dependencies": { |
| 10 | 12 | "convex": "^1.31.7" |
| @@ -18,6 +18,8 @@ · 17 unmodified lines | ||
| 18 | 18 | "include": [ |
| 19 | 19 | "packages/**/*.ts", |
| 20 | 20 | "convex/**/*.ts", |
| 21 | − "lib/**/*.ts" | |
| 21 | + "lib/**/*.ts", | |
| 22 | + "mcp-gateway.ts", | |
| 23 | + "vscode-elicitation-server.ts" | |
| 22 | 24 | ] |
| 23 | 25 | } |
| @@ -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`); | |