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