Use the Convex /mcp endpoint directly in local and managed runtime paths, and update setup/docs/env wiring to remove localhost gateway-specific configuration.
Unified · Split
| @@ -25,8 +25,6 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 24 unmodified lines | |||
| 25 | # EXECUTOR_BACKEND_PORT=5410 | 25 | # EXECUTOR_BACKEND_PORT=5410 |
| 26 | # EXECUTOR_BACKEND_SITE_PORT=5411 | 26 | # EXECUTOR_BACKEND_SITE_PORT=5411 |
| 27 | # EXECUTOR_WEB_PORT=5312 | 27 | # EXECUTOR_WEB_PORT=5312 |
| 28 | # EXECUTOR_MCP_GATEWAY_PORT=5313 | ||
| 29 | # NEXT_PUBLIC_LOCAL_MCP_ORIGIN=http://localhost:4313 | ||
| 30 | 28 | ||
| 31 | # ── Discord bot (optional — omit to skip bot startup) ── | 29 | # ── Discord bot (optional — omit to skip bot startup) ── |
| 32 | # DISCORD_BOT_TOKEN=... | 30 | # DISCORD_BOT_TOKEN=... |
| @@ -63,7 +63,7 @@ bun run dev · 62 unmodified lines | |||
| 63 | 63 | ||
| 64 | - Convex dev watcher for `executor/` | 64 | - Convex dev watcher for `executor/` |
| 65 | - Executor web app (`http://localhost:4312`) | 65 | - Executor web app (`http://localhost:4312`) |
| 66 | - Executor MCP gateway (`http://localhost:4313/mcp`) | 66 | - Executor MCP endpoint (`<CONVEX_SITE_URL>/mcp`) |
| 67 | - Assistant server (`http://localhost:3000`) | 67 | - Assistant server (`http://localhost:3000`) |
| 68 | - Discord bot (only if `DISCORD_BOT_TOKEN` is set) | 68 | - Discord bot (only if `DISCORD_BOT_TOKEN` is set) |
| 69 | 69 | ||
| @@ -8,9 +8,8 @@ · 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 4312) ├─ all started concurrently | 10 | * 2. Executor web UI (port 4312) ├─ all started concurrently |
| 11 | * 3. Executor MCP gateway (port 4313) │ | 11 | * 3. Assistant server (port 3000) │ |
| 12 | * 4. Assistant server (port 3000) │ | 12 | * 4. Discord bot ─┘ |
| 13 | * 5. Discord bot ─┘ | ||
| 14 | * | 13 | * |
| 15 | * All processes are killed when this script exits (Ctrl+C). | 14 | * All processes are killed when this script exits (Ctrl+C). |
| 16 | * PIDs are written to .dev.pids for use with `bun run kill:all`. | 15 | * PIDs are written to .dev.pids for use with `bun run kill:all`. |
| @@ -24,7 +23,6 @@ const PID_FILE = join(import.meta.dir, ".dev.pids"); · 7 unmodified lines | |||
| 24 | const colors = { | 23 | const colors = { |
| 25 | convex: "\x1b[36m", // cyan | 24 | convex: "\x1b[36m", // cyan |
| 26 | web: "\x1b[34m", // blue | 25 | web: "\x1b[34m", // blue |
| 27 | mcp: "\x1b[33m", // yellow | ||
| 28 | assistant: "\x1b[32m", // green | 26 | assistant: "\x1b[32m", // green |
| 29 | bot: "\x1b[35m", // magenta | 27 | bot: "\x1b[35m", // magenta |
| 30 | reset: "\x1b[0m", | 28 | reset: "\x1b[0m", |
| @@ -119,8 +117,7 @@ process.on("SIGTERM", shutdown); · 88 unmodified lines | |||
| 119 | // ── Kill stale processes from a previous run ── | 117 | // ── Kill stale processes from a previous run ── |
| 120 | 118 | ||
| 121 | const EXECUTOR_WEB_PORT = Number(Bun.env.EXECUTOR_WEB_PORT ?? 4312); | 119 | const EXECUTOR_WEB_PORT = Number(Bun.env.EXECUTOR_WEB_PORT ?? 4312); |
| 122 | const EXECUTOR_MCP_PORT = Number(Bun.env.EXECUTOR_MCP_GATEWAY_PORT ?? 4313); | 120 | const DEV_PORTS = [3000, EXECUTOR_WEB_PORT]; |
| 123 | const DEV_PORTS = [3000, EXECUTOR_WEB_PORT, EXECUTOR_MCP_PORT]; | ||
| 124 | 121 | ||
| 125 | async function killStaleProcesses() { | 122 | async function killStaleProcesses() { |
| 126 | let killed = 0; | 123 | let killed = 0; |
| @@ -180,16 +177,6 @@ spawnService("web", ["bun", "run", "dev"], { · 53 unmodified lines | |||
| 180 | cwd: "./executor/apps/web", | 177 | cwd: "./executor/apps/web", |
| 181 | }); | 178 | }); |
| 182 | 179 | ||
| 183 | spawnService("mcp", ["bun", "run", "dev:mcp-gateway"], { | ||
| 184 | cwd: "./executor", | ||
| 185 | env: { | ||
| 186 | EXECUTOR_MCP_GATEWAY_PORT: String(EXECUTOR_MCP_PORT), | ||
| 187 | MCP_GATEWAY_REQUIRE_AUTH: "0", | ||
| 188 | MCP_AUTHORIZATION_SERVER: "", | ||
| 189 | MCP_AUTHORIZATION_SERVER_URL: "", | ||
| 190 | }, | ||
| 191 | }); | ||
| 192 | |||
| 193 | spawnService("assistant", ["bun", "run", "dev"], { | 180 | spawnService("assistant", ["bun", "run", "dev"], { |
| 194 | cwd: "./assistant/packages/server", | 181 | cwd: "./assistant/packages/server", |
| 195 | env: { | 182 | env: { |
| @@ -4,7 +4,7 @@ Executor is a Convex-native execution platform for MCP-driven agents. It provide · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | - task execution (`run_code`) with tool invocation and approval gates | 5 | - task execution (`run_code`) with tool invocation and approval gates |
| 6 | - workspace-scoped policy, credentials, and tool source management | 6 | - workspace-scoped policy, credentials, and tool source management |
| 7 | - MCP endpoints (Convex HTTP routes and a standalone Bun gateway) | 7 | - MCP endpoints (Convex HTTP routes) |
| 8 | - a Next.js web app for tasks, approvals, tools, members, and billing | 8 | - a Next.js web app for tasks, approvals, tools, members, and billing |
| 9 | - a binary-first install flow for local self-hosted runtime | 9 | - a binary-first install flow for local self-hosted runtime |
| 10 | 10 | ||
| @@ -16,7 +16,6 @@ Core components: · 5 unmodified lines | |||
| 16 | - `convex/http.ts`: HTTP routes for `/mcp`, OAuth discovery metadata, and internal runtime callbacks. | 16 | - `convex/http.ts`: HTTP routes for `/mcp`, OAuth discovery metadata, and internal runtime callbacks. |
| 17 | - `convex/executorNode.ts`: task runner action (`runTask`) and tool invocation plumbing. | 17 | - `convex/executorNode.ts`: task runner action (`runTask`) and tool invocation plumbing. |
| 18 | - `lib/`: runtime engine, typechecker, tool discovery, external source adapters (MCP/OpenAPI/GraphQL), credential provider resolvers. | 18 | - `lib/`: runtime engine, typechecker, tool discovery, external source adapters (MCP/OpenAPI/GraphQL), credential provider resolvers. |
| 19 | - `mcp-gateway.ts`: standalone stateful MCP gateway with anonymous OAuth and optional WorkOS token verification. | ||
| 20 | - `apps/web/`: operator UI (dashboard, tasks, approvals, tools, onboarding, org settings). | 19 | - `apps/web/`: operator UI (dashboard, tasks, approvals, tools, onboarding, org settings). |
| 21 | - `executor.ts`: CLI entrypoint used by local source scripts and compiled binary releases. | 20 | - `executor.ts`: CLI entrypoint used by local source scripts and compiled binary releases. |
| 22 | 21 | ||
| @@ -50,16 +49,11 @@ bun run dev:executor:convex · 27 unmodified lines | |||
| 50 | 49 | ||
| 51 | # Terminal 2: Web UI | 50 | # Terminal 2: Web UI |
| 52 | bun run dev:executor:web | 51 | bun run dev:executor:web |
| 53 | |||
| 54 | # Terminal 3 (optional but recommended for stateful MCP transport) | ||
| 55 | bun run --cwd executor dev:mcp-gateway | ||
| 56 | ``` | 52 | ``` |
| 57 | 53 | ||
| 58 | Default source-dev endpoints: | 54 | Default source-dev endpoints: |
| 59 | 55 | ||
| 60 | - Web UI: `http://localhost:4312` | 56 | - Web UI: `http://localhost:4312` |
| 61 | - MCP gateway: `http://localhost:4313/mcp` | ||
| 62 | - Gateway health: `http://localhost:4313/health` | ||
| 63 | - Convex HTTP MCP route: `<CONVEX_SITE_URL>/mcp` | 57 | - Convex HTTP MCP route: `<CONVEX_SITE_URL>/mcp` |
| 64 | 58 | ||
| 65 | ## Binary Install (No Global Bun/Node/Convex Required) | 59 | ## Binary Install (No Global Bun/Node/Convex Required) |
| @@ -83,7 +77,6 @@ executor doctor · 17 unmodified lines | |||
| 83 | executor up | 77 | executor up |
| 84 | executor backend --help | 78 | executor backend --help |
| 85 | executor web | 79 | executor web |
| 86 | executor gateway | ||
| 87 | ``` | 80 | ``` |
| 88 | 81 | ||
| 89 | Uninstall: | 82 | Uninstall: |
| @@ -97,7 +90,6 @@ Default managed-runtime ports: · 7 unmodified lines | |||
| 97 | - backend API: `5410` | 90 | - backend API: `5410` |
| 98 | - backend site proxy: `5411` | 91 | - backend site proxy: `5411` |
| 99 | - packaged web app: `5312` | 92 | - packaged web app: `5312` |
| 100 | - MCP gateway: `5313` | ||
| 101 | 93 | ||
| 102 | ## CLI Commands (executor/package.json) | 94 | ## CLI Commands (executor/package.json) |
| 103 | 95 | ||
| @@ -108,7 +100,6 @@ bun run doctor · 4 unmodified lines | |||
| 108 | bun run up | 100 | bun run up |
| 109 | bun run backend -- --help | 101 | bun run backend -- --help |
| 110 | bun run web | 102 | bun run web |
| 111 | bun run gateway | ||
| 112 | bun run codegen | 103 | bun run codegen |
| 113 | bun run deploy | 104 | bun run deploy |
| 114 | bun run build:binary | 105 | bun run build:binary |
| @@ -122,24 +113,15 @@ Notes: · 7 unmodified lines | |||
| 122 | 113 | ||
| 123 | ## MCP and OAuth Surface | 114 | ## MCP and OAuth Surface |
| 124 | 115 | ||
| 125 | Gateway routes (`mcp-gateway.ts`): | 116 | Convex HTTP routes (`convex/http.ts`) expose: |
| 126 | 117 | ||
| 127 | - `/mcp` (GET/POST/DELETE) | 118 | - `/mcp` (direct Convex MCP transport) |
| 128 | - `/.well-known/oauth-protected-resource` | 119 | - `/.well-known/oauth-protected-resource` |
| 129 | - `/.well-known/oauth-authorization-server` | 120 | - `/.well-known/oauth-authorization-server` |
| 130 | - `/oauth2/jwks` | ||
| 131 | - `/register` | ||
| 132 | - `/authorize` | ||
| 133 | - `/token` | ||
| 134 | - `/health` | ||
| 135 | |||
| 136 | Convex HTTP routes (`convex/http.ts`) also expose: | ||
| 137 | |||
| 138 | - `/mcp` (direct Convex MCP transport) | ||
| 139 | - `/internal/runs/:runId/tool-call` | 121 | - `/internal/runs/:runId/tool-call` |
| 140 | - `/internal/runs/:runId/output` | 122 | - `/internal/runs/:runId/output` |
| 141 | 123 | ||
| 142 | Anonymous OAuth is always available on the standalone gateway. WorkOS token verification is enabled when `MCP_AUTHORIZATION_SERVER` (or `MCP_AUTHORIZATION_SERVER_URL`) is configured. | 124 | MCP bearer-token verification is enabled when `MCP_AUTHORIZATION_SERVER` (or `MCP_AUTHORIZATION_SERVER_URL`) is configured. |
| 143 | 125 | ||
| 144 | ## Configuration Reference | 126 | ## Configuration Reference |
| 145 | 127 | ||
| @@ -159,14 +141,11 @@ Important env vars (see root `.env.example` for the base template): · 13 unmodified lines | |||
| 159 | - `STRIPE_PRICE_ID` | 141 | - `STRIPE_PRICE_ID` |
| 160 | - MCP auth integration: | 142 | - MCP auth integration: |
| 161 | - `MCP_AUTHORIZATION_SERVER` or `MCP_AUTHORIZATION_SERVER_URL` | 143 | - `MCP_AUTHORIZATION_SERVER` or `MCP_AUTHORIZATION_SERVER_URL` |
| 162 | - Managed runtime and gateway: | 144 | - Managed runtime: |
| 163 | - `EXECUTOR_RUNTIME_DIR` | 145 | - `EXECUTOR_RUNTIME_DIR` |
| 164 | - `EXECUTOR_BACKEND_PORT` | 146 | - `EXECUTOR_BACKEND_PORT` |
| 165 | - `EXECUTOR_BACKEND_SITE_PORT` | 147 | - `EXECUTOR_BACKEND_SITE_PORT` |
| 166 | - `EXECUTOR_WEB_PORT` | 148 | - `EXECUTOR_WEB_PORT` |
| 167 | - `EXECUTOR_MCP_GATEWAY_PORT` | ||
| 168 | - `EXECUTOR_INTERNAL_TOKEN` (recommended for Convex-backed anonymous OAuth key/client persistence) | ||
| 169 | - `NEXT_PUBLIC_LOCAL_MCP_ORIGIN` | ||
| 170 | 149 | ||
| 171 | ## Credential Providers | 150 | ## Credential Providers |
| 172 | 151 | ||
| @@ -198,17 +177,15 @@ bun run typecheck:executor · 25 unmodified lines | |||
| 198 | executor/ | 177 | executor/ |
| 199 | |- apps/web/ # Next.js operator UI | 178 | |- apps/web/ # Next.js operator UI |
| 200 | |- convex/ # Convex functions, schema, auth, HTTP routes | 179 | |- convex/ # Convex functions, schema, auth, HTTP routes |
| 201 | |- lib/ # runtime, tool loading/discovery, gateway helpers | 180 | |- lib/ # runtime, tool loading/discovery, helper modules |
| 202 | |- scripts/build-release.ts # release artifact builder | 181 | |- scripts/build-release.ts # release artifact builder |
| 203 | |- executor.ts # CLI entrypoint (compiled into binary) | 182 | |- executor.ts # CLI entrypoint (compiled into binary) |
| 204 | |- mcp-gateway.ts # standalone MCP + OAuth gateway | ||
| 205 | |- install # curl install script | 183 | |- install # curl install script |
| 206 | `- uninstall # uninstall script | 184 | `- uninstall # uninstall script |
| 207 | ``` | 185 | ``` |
| 208 | 186 | ||
| 209 | ## Troubleshooting | 187 | ## Troubleshooting |
| 210 | 188 | ||
| 211 | - `401` on `/mcp`: the gateway expects OAuth bearer tokens; complete MCP OAuth client flow first. | 189 | - `401` on `/mcp`: verify your bearer token issuer matches `MCP_AUTHORIZATION_SERVER` (or disable MCP OAuth in local dev). |
| 212 | - Web UI cannot load data: verify `CONVEX_URL` / `CONVEX_SITE_URL` and that Convex dev is running. | 190 | - Web UI cannot load data: verify `CONVEX_URL` / `CONVEX_SITE_URL` and that Convex dev is running. |
| 213 | - Gateway OAuth state resets between restarts: set `EXECUTOR_INTERNAL_TOKEN` so gateway can persist keys/clients in Convex. | ||
| 214 | - Release build missing web archive for your platform: run `bun run build:release` on that target platform. | 191 | - Release build missing web archive for your platform: run `bun run build:release` on that target platform. |
| @@ -1,5 +1,15 @@ | |||
| 1 | import type { NextConfig } from "next"; | 1 | import type { NextConfig } from "next"; |
| 2 | 2 | ||
| 3 | function toSiteUrl(convexUrl?: string): string | undefined { | ||
| 4 | if (!convexUrl) { | ||
| 5 | return undefined; | ||
| 6 | } | ||
| 7 | if (convexUrl.includes(".convex.cloud")) { | ||
| 8 | return convexUrl.replace(".convex.cloud", ".convex.site"); | ||
| 9 | } | ||
| 10 | return convexUrl; | ||
| 11 | } | ||
| 12 | |||
| 3 | const appShellRewriteExclusions = [ | 13 | const appShellRewriteExclusions = [ |
| 4 | "api(?:/|$)", | 14 | "api(?:/|$)", |
| 5 | "_next(?:/|$)", | 15 | "_next(?:/|$)", |
| @@ -21,6 +31,10 @@ const nextConfig: NextConfig = { · 15 unmodified lines | |||
| 21 | // Map canonical env vars to NEXT_PUBLIC_ so they're available client-side. | 31 | // Map canonical env vars to NEXT_PUBLIC_ so they're available client-side. |
| 22 | // This lets us keep a single root .env without NEXT_PUBLIC_ prefixes. | 32 | // This lets us keep a single root .env without NEXT_PUBLIC_ prefixes. |
| 23 | NEXT_PUBLIC_CONVEX_URL: process.env.EXECUTOR_WEB_CONVEX_URL ?? process.env.CONVEX_URL, | 33 | NEXT_PUBLIC_CONVEX_URL: process.env.EXECUTOR_WEB_CONVEX_URL ?? process.env.CONVEX_URL, |
| 34 | NEXT_PUBLIC_CONVEX_SITE_URL: | ||
| 35 | process.env.EXECUTOR_WEB_CONVEX_SITE_URL | ||
| 36 | ?? process.env.CONVEX_SITE_URL | ||
| 37 | ?? toSiteUrl(process.env.EXECUTOR_WEB_CONVEX_URL ?? process.env.CONVEX_URL), | ||
| 24 | NEXT_PUBLIC_WORKOS_CLIENT_ID: process.env.WORKOS_CLIENT_ID, | 38 | NEXT_PUBLIC_WORKOS_CLIENT_ID: process.env.WORKOS_CLIENT_ID, |
| 25 | NEXT_PUBLIC_STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID, | 39 | NEXT_PUBLIC_STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID, |
| 26 | }, | 40 | }, |
| @@ -41,10 +41,6 @@ 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 | |||
| 48 | export function McpSetupCard({ | 44 | export function McpSetupCard({ |
| 49 | workspaceId, | 45 | workspaceId, |
| 50 | actorId, | 46 | actorId, |
| @@ -56,26 +52,12 @@ export function McpSetupCard({ · 5 unmodified lines | |||
| 56 | }) { | 52 | }) { |
| 57 | const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code"); | 53 | const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code"); |
| 58 | const [copiedKey, setCopiedKey] = useState<string | null>(null); | 54 | 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 | }); | ||
| 65 | 55 | ||
| 66 | const defaultOrigin = useMemo(() => { | 56 | const origin = useMemo(() => { |
| 67 | if (typeof window === "undefined") return ""; | 57 | if (typeof window === "undefined") return ""; |
| 68 | return resolveMcpOrigin(window.location.origin); | 58 | return resolveMcpOrigin(window.location.origin); |
| 69 | }, []); | 59 | }, []); |
| 70 | 60 | ||
| 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:4313").trim().replace(/\/$/, ""); | ||
| 77 | const origin = urlMode === "local" ? localGatewayOrigin : defaultOrigin; | ||
| 78 | |||
| 79 | const mcpUrl = useMemo(() => { | 61 | const mcpUrl = useMemo(() => { |
| 80 | const base = origin ? new URL("/mcp", origin) : new URL("http://localhost/mcp"); | 62 | const base = origin ? new URL("/mcp", origin) : new URL("http://localhost/mcp"); |
| 81 | if (workspaceId) base.searchParams.set("workspaceId", workspaceId); | 63 | if (workspaceId) base.searchParams.set("workspaceId", workspaceId); |
| @@ -112,19 +94,6 @@ export function McpSetupCard({ · 30 unmodified lines | |||
| 112 | <div className="space-y-5"> | 94 | <div className="space-y-5"> |
| 113 | <div className="space-y-1.5"> | 95 | <div className="space-y-1.5"> |
| 114 | <Label className="text-xs text-muted-foreground">MCP Server URL</Label> | 96 | <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:4313)</SelectItem> | ||
| 123 | <SelectItem value="default" className="text-xs">Hosted endpoint</SelectItem> | ||
| 124 | </SelectContent> | ||
| 125 | </Select> | ||
| 126 | </div> | ||
| 127 | ) : null} | ||
| 128 | <div className="flex items-center gap-2"> | 97 | <div className="flex items-center gap-2"> |
| 129 | <Input value={mcpUrl} readOnly className="h-8 text-xs font-mono bg-background" /> | 98 | <Input value={mcpUrl} readOnly className="h-8 text-xs font-mono bg-background" /> |
| 130 | <Button | 99 | <Button |
| @@ -1,7 +1,6 @@ | |||
| 1 | #!/usr/bin/env bun | 1 | #!/usr/bin/env bun |
| 2 | 2 | ||
| 3 | import fs from "node:fs/promises"; | 3 | import fs from "node:fs/promises"; |
| 4 | import { startMcpGateway } from "./mcp-gateway"; | ||
| 5 | import { managedRuntimeDiagnostics, runManagedBackend, runManagedWeb } from "./lib/managed_runtime"; | 4 | import { managedRuntimeDiagnostics, runManagedBackend, runManagedWeb } from "./lib/managed_runtime"; |
| 6 | 5 | ||
| 7 | function printHelp(): void { | 6 | function printHelp(): void { |
| @@ -12,14 +11,12 @@ Usage: · 4 unmodified lines | |||
| 12 | executor up [backend-args] | 11 | executor up [backend-args] |
| 13 | executor backend <args> | 12 | executor backend <args> |
| 14 | executor web [--port <number>] | 13 | executor web [--port <number>] |
| 15 | executor gateway [--port <number>] | ||
| 16 | 14 | ||
| 17 | Commands: | 15 | Commands: |
| 18 | doctor Bootstrap and verify managed Convex backend runtime | 16 | doctor Bootstrap and verify managed Convex backend runtime |
| 19 | up Run managed backend and auto-bootstrap Convex functions | 17 | up Run managed backend and auto-bootstrap Convex functions |
| 20 | backend Pass through arguments to managed convex-local-backend binary | 18 | backend Pass through arguments to managed convex-local-backend binary |
| 21 | web Run packaged web UI (default port: 5312) | 19 | web Run packaged web UI (default port: 5312) |
| 22 | gateway Start Executor MCP gateway (default port: 5313) | ||
| 23 | `); | 20 | `); |
| 24 | } | 21 | } |
| 25 | 22 | ||
| @@ -75,12 +72,10 @@ async function run(): Promise<void> { · 49 unmodified lines | |||
| 75 | if (command === "doctor") { | 72 | if (command === "doctor") { |
| 76 | const info = await managedRuntimeDiagnostics(); | 73 | const info = await managedRuntimeDiagnostics(); |
| 77 | const webPort = Number(Bun.env.EXECUTOR_WEB_PORT ?? 5312); | 74 | const webPort = Number(Bun.env.EXECUTOR_WEB_PORT ?? 5312); |
| 78 | const gatewayPort = Number(Bun.env.EXECUTOR_MCP_GATEWAY_PORT ?? 5313); | ||
| 79 | const webInstalled = await pathExists(info.webServerEntry); | 75 | const webInstalled = await pathExists(info.webServerEntry); |
| 80 | const nodeInstalled = await pathExists(info.nodeBin); | 76 | const nodeInstalled = await pathExists(info.nodeBin); |
| 81 | const backendRunning = await checkHttp(`${info.convexUrl}/version`); | 77 | const backendRunning = await checkHttp(`${info.convexUrl}/version`); |
| 82 | const webRunning = await checkHttp(`http://127.0.0.1:${webPort}/`); | 78 | const webRunning = await checkHttp(`http://127.0.0.1:${webPort}/`); |
| 83 | const gatewayRunning = await checkHttp(`http://127.0.0.1:${gatewayPort}/health`); | ||
| 84 | 79 | ||
| 85 | console.log("Managed runtime ready"); | 80 | console.log("Managed runtime ready"); |
| 86 | console.log(` root: ${info.rootDir}`); | 81 | console.log(` root: ${info.rootDir}`); |
| @@ -90,8 +85,8 @@ async function run(): Promise<void> { · 3 unmodified lines | |||
| 90 | console.log(` node runtime: ${nodeInstalled ? info.nodeBin : "not installed yet (installed by 'executor web')"}`); | 85 | console.log(` node runtime: ${nodeInstalled ? info.nodeBin : "not installed yet (installed by 'executor web')"}`); |
| 91 | console.log(` web bundle: ${webInstalled ? info.webServerEntry : "not installed yet (installed by 'executor web')"}`); | 86 | console.log(` web bundle: ${webInstalled ? info.webServerEntry : "not installed yet (installed by 'executor web')"}`); |
| 92 | console.log(` web URL: http://127.0.0.1:${webPort}`); | 87 | console.log(` web URL: http://127.0.0.1:${webPort}`); |
| 93 | console.log(` gateway URL: http://127.0.0.1:${gatewayPort}/mcp`); | 88 | console.log(` mcp URL: ${info.convexSiteUrl}/mcp`); |
| 94 | console.log(` running: backend=${backendRunning ? "yes" : "no"} web=${webRunning ? "yes" : "no"} gateway=${gatewayRunning ? "yes" : "no"}`); | 89 | console.log(` running: backend=${backendRunning ? "yes" : "no"} web=${webRunning ? "yes" : "no"}`); |
| 95 | console.log(` config: ${info.configPath}`); | 90 | console.log(` config: ${info.configPath}`); |
| 96 | return; | 91 | return; |
| 97 | } | 92 | } |
| @@ -109,12 +104,6 @@ async function run(): Promise<void> { · 11 unmodified lines | |||
| 109 | process.exit(exitCode); | 104 | process.exit(exitCode); |
| 110 | } | 105 | } |
| 111 | 106 | ||
| 112 | if (command === "gateway") { | ||
| 113 | const port = parsePort(rest); | ||
| 114 | startMcpGateway(port); | ||
| 115 | return; | ||
| 116 | } | ||
| 117 | |||
| 118 | if (command === "web") { | 107 | if (command === "web") { |
| 119 | const port = parsePort(rest); | 108 | const port = parsePort(rest); |
| 120 | const exitCode = await runManagedWeb({ port }); | 109 | const exitCode = await runManagedWeb({ port }); |
| @@ -266,7 +266,6 @@ echo "Run:" · 265 unmodified lines | |||
| 266 | echo " executor doctor" | 266 | echo " executor doctor" |
| 267 | echo " executor up" | 267 | echo " executor up" |
| 268 | echo " executor web # defaults to http://localhost:5312" | 268 | echo " executor web # defaults to http://localhost:5312" |
| 269 | echo " executor gateway # defaults to http://localhost:5313/mcp" | ||
| 270 | echo "" | 269 | echo "" |
| 271 | echo "To uninstall later:" | 270 | echo "To uninstall later:" |
| 272 | echo " bash executor/uninstall --yes" | 271 | echo " bash executor/uninstall --yes" |
| @@ -642,7 +642,6 @@ export async function runManagedWeb(options: { port?: number } = {}): Promise<nu · 641 unmodified lines | |||
| 642 | await ensureNodeRuntime(info); | 642 | await ensureNodeRuntime(info); |
| 643 | await ensureWebBundle(info); | 643 | await ensureWebBundle(info); |
| 644 | 644 | ||
| 645 | const mcpPort = Number(Bun.env.EXECUTOR_MCP_GATEWAY_PORT ?? 5313); | ||
| 646 | const webPort = options.port ?? Number(Bun.env.EXECUTOR_WEB_PORT ?? 5312); | 645 | const webPort = options.port ?? Number(Bun.env.EXECUTOR_WEB_PORT ?? 5312); |
| 647 | const host = Bun.env.EXECUTOR_WEB_INTERFACE ?? "127.0.0.1"; | 646 | const host = Bun.env.EXECUTOR_WEB_INTERFACE ?? "127.0.0.1"; |
| 648 | 647 | ||
| @@ -653,7 +652,6 @@ export async function runManagedWeb(options: { port?: number } = {}): Promise<nu · 4 unmodified lines | |||
| 653 | HOSTNAME: host, | 652 | HOSTNAME: host, |
| 654 | CONVEX_URL: process.env.CONVEX_URL ?? `http://${info.config.hostInterface}:${info.config.backendPort}`, | 653 | CONVEX_URL: process.env.CONVEX_URL ?? `http://${info.config.hostInterface}:${info.config.backendPort}`, |
| 655 | CONVEX_SITE_URL: process.env.CONVEX_SITE_URL ?? `http://${info.config.hostInterface}:${info.config.siteProxyPort}`, | 654 | CONVEX_SITE_URL: process.env.CONVEX_SITE_URL ?? `http://${info.config.hostInterface}:${info.config.siteProxyPort}`, |
| 656 | NEXT_PUBLIC_LOCAL_MCP_ORIGIN: process.env.NEXT_PUBLIC_LOCAL_MCP_ORIGIN ?? `http://localhost:${mcpPort}`, | ||
| 657 | }; | 655 | }; |
| 658 | 656 | ||
| 659 | const proc = await runProcess(info.nodeBin, [info.webServerEntry], { | 657 | const proc = await runProcess(info.nodeBin, [info.webServerEntry], { |
| @@ -1,532 +0,0 @@ | |||
| 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 { AnonymousOAuthServer, OAuthBadRequest } from "./lib/anonymous-oauth"; | ||
| 6 | import { ConvexOAuthStorage } from "./lib/convex-oauth-storage"; | ||
| 7 | import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescriptor } from "./lib/types"; | ||
| 8 | import type { Id } from "./convex/_generated/dataModel"; | ||
| 9 | |||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | // Verified token result — unified across WorkOS and self-issued anonymous JWTs | ||
| 12 | // --------------------------------------------------------------------------- | ||
| 13 | |||
| 14 | interface VerifiedToken { | ||
| 15 | sub: string; | ||
| 16 | provider: "workos" | "anonymous"; | ||
| 17 | /** The raw bearer token string (needed for WorkOS tokens to forward to Convex). */ | ||
| 18 | rawToken: string; | ||
| 19 | } | ||
| 20 | |||
| 21 | // --------------------------------------------------------------------------- | ||
| 22 | // Configuration | ||
| 23 | // --------------------------------------------------------------------------- | ||
| 24 | |||
| 25 | let convexUrl: string | undefined; | ||
| 26 | let workosAuthorizationServer: string | undefined; | ||
| 27 | let workosJwks: ReturnType<typeof createRemoteJWKSet> | null = null; | ||
| 28 | let anonOAuth: AnonymousOAuthServer | null = null; | ||
| 29 | |||
| 30 | function loadConfigFromEnv(): void { | ||
| 31 | const convexUrlFromEnv = Bun.env.CONVEX_URL; | ||
| 32 | if (!convexUrlFromEnv) { | ||
| 33 | throw new Error("CONVEX_URL is required."); | ||
| 34 | } | ||
| 35 | |||
| 36 | convexUrl = convexUrlFromEnv; | ||
| 37 | workosAuthorizationServer = | ||
| 38 | Bun.env.MCP_AUTHORIZATION_SERVER | ||
| 39 | ?? Bun.env.MCP_AUTHORIZATION_SERVER_URL | ||
| 40 | ?? Bun.env.WORKOS_AUTHKIT_ISSUER | ||
| 41 | ?? Bun.env.WORKOS_AUTHKIT_DOMAIN; | ||
| 42 | workosJwks = workosAuthorizationServer | ||
| 43 | ? createRemoteJWKSet(new URL("/oauth2/jwks", workosAuthorizationServer)) | ||
| 44 | : null; | ||
| 45 | } | ||
| 46 | |||
| 47 | async function initAnonymousOAuth(issuer: string): Promise<void> { | ||
| 48 | const internalSecret = Bun.env.EXECUTOR_INTERNAL_TOKEN; | ||
| 49 | const storage = convexUrl && internalSecret | ||
| 50 | ? new ConvexOAuthStorage(convexUrl, internalSecret) | ||
| 51 | : undefined; | ||
| 52 | |||
| 53 | anonOAuth = new AnonymousOAuthServer({ issuer, storage }); | ||
| 54 | await anonOAuth.init(); | ||
| 55 | } | ||
| 56 | |||
| 57 | function requireConvexUrl(): string { | ||
| 58 | if (!convexUrl) { | ||
| 59 | throw new Error("CONVEX_URL is required."); | ||
| 60 | } | ||
| 61 | return convexUrl; | ||
| 62 | } | ||
| 63 | |||
| 64 | // --------------------------------------------------------------------------- | ||
| 65 | // Token parsing & verification | ||
| 66 | // --------------------------------------------------------------------------- | ||
| 67 | |||
| 68 | function parseBearerToken(request: Request): string | null { | ||
| 69 | const header = request.headers.get("authorization"); | ||
| 70 | if (!header || !header.startsWith("Bearer ")) { | ||
| 71 | return null; | ||
| 72 | } | ||
| 73 | const token = header.slice("Bearer ".length).trim(); | ||
| 74 | return token.length > 0 ? token : null; | ||
| 75 | } | ||
| 76 | |||
| 77 | /** | ||
| 78 | * Unified token verification. Tries the self-issued anonymous OAuth server | ||
| 79 | * first (fast, in-process), then falls back to the external WorkOS JWKS. | ||
| 80 | */ | ||
| 81 | async function verifyToken(request: Request): Promise<VerifiedToken | null> { | ||
| 82 | const rawToken = parseBearerToken(request); | ||
| 83 | if (!rawToken) { | ||
| 84 | return null; | ||
| 85 | } | ||
| 86 | |||
| 87 | // 1. Try the self-issued anonymous OAuth server | ||
| 88 | if (anonOAuth) { | ||
| 89 | const anon = await anonOAuth.verifyToken(rawToken); | ||
| 90 | if (anon) { | ||
| 91 | return { sub: anon.sub, provider: "anonymous", rawToken }; | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | // 2. Try WorkOS | ||
| 96 | if (workosAuthorizationServer && workosJwks) { | ||
| 97 | try { | ||
| 98 | const { payload } = await jwtVerify(rawToken, workosJwks, { | ||
| 99 | issuer: workosAuthorizationServer, | ||
| 100 | }); | ||
| 101 | if (typeof payload.sub === "string" && payload.sub.length > 0) { | ||
| 102 | return { sub: payload.sub, provider: "workos", rawToken }; | ||
| 103 | } | ||
| 104 | } catch { | ||
| 105 | // Not a valid WorkOS token either | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | return null; | ||
| 110 | } | ||
| 111 | |||
| 112 | // --------------------------------------------------------------------------- | ||
| 113 | // OAuth response helpers | ||
| 114 | // --------------------------------------------------------------------------- | ||
| 115 | |||
| 116 | function resourceMetadataUrl(request: Request): string { | ||
| 117 | const url = new URL(request.url); | ||
| 118 | return `${url.origin}/.well-known/oauth-protected-resource`; | ||
| 119 | } | ||
| 120 | |||
| 121 | function unauthorizedMcpResponse(request: Request, message: string): Response { | ||
| 122 | const challenge = [ | ||
| 123 | 'Bearer error="unauthorized"', | ||
| 124 | 'error_description="Authorization needed"', | ||
| 125 | `resource_metadata="${resourceMetadataUrl(request)}"`, | ||
| 126 | ].join(", "); | ||
| 127 | |||
| 128 | return Response.json( | ||
| 129 | { error: message }, | ||
| 130 | { | ||
| 131 | status: 401, | ||
| 132 | headers: { | ||
| 133 | "WWW-Authenticate": challenge, | ||
| 134 | }, | ||
| 135 | }, | ||
| 136 | ); | ||
| 137 | } | ||
| 138 | |||
| 139 | // --------------------------------------------------------------------------- | ||
| 140 | // Workspace ID parsing | ||
| 141 | // --------------------------------------------------------------------------- | ||
| 142 | |||
| 143 | /** | ||
| 144 | * Validated boundary for external workspace ID strings. | ||
| 145 | * Convex IDs are opaque strings at runtime; this cast is the single | ||
| 146 | * validated entry point so downstream code never needs ad-hoc casts. | ||
| 147 | */ | ||
| 148 | function parseWorkspaceId(raw: string): Id<"workspaces"> { | ||
| 149 | return raw as Id<"workspaces">; | ||
| 150 | } | ||
| 151 | |||
| 152 | function parseRequestedContext(url: URL): { | ||
| 153 | workspaceId?: Id<"workspaces">; | ||
| 154 | clientId?: string; | ||
| 155 | } { | ||
| 156 | const rawWorkspaceId = url.searchParams.get("workspaceId"); | ||
| 157 | const workspaceId = rawWorkspaceId ? parseWorkspaceId(rawWorkspaceId) : undefined; | ||
| 158 | const clientId = url.searchParams.get("clientId") ?? undefined; | ||
| 159 | return { workspaceId, clientId }; | ||
| 160 | } | ||
| 161 | |||
| 162 | // --------------------------------------------------------------------------- | ||
| 163 | // Service factory | ||
| 164 | // --------------------------------------------------------------------------- | ||
| 165 | |||
| 166 | function createService(context?: McpWorkspaceContext, bearerToken?: string) { | ||
| 167 | const convex = new ConvexHttpClient(requireConvexUrl()); | ||
| 168 | if (bearerToken) { | ||
| 169 | convex.setAuth(bearerToken); | ||
| 170 | } | ||
| 171 | |||
| 172 | const workspaceId = context?.workspaceId; | ||
| 173 | const sessionId = context?.sessionId; | ||
| 174 | |||
| 175 | return { | ||
| 176 | createTask: async (input: { | ||
| 177 | code: string; | ||
| 178 | timeoutMs?: number; | ||
| 179 | runtimeId?: string; | ||
| 180 | metadata?: Record<string, unknown>; | ||
| 181 | workspaceId: Id<"workspaces">; | ||
| 182 | actorId: string; | ||
| 183 | clientId?: string; | ||
| 184 | }) => { | ||
| 185 | const created = await convex.mutation(api.executor.createTask, { | ||
| 186 | workspaceId: input.workspaceId, | ||
| 187 | sessionId, | ||
| 188 | code: input.code, | ||
| 189 | timeoutMs: input.timeoutMs, | ||
| 190 | runtimeId: input.runtimeId, | ||
| 191 | metadata: input.metadata, | ||
| 192 | actorId: input.actorId, | ||
| 193 | clientId: input.clientId, | ||
| 194 | }); | ||
| 195 | return created as { task: TaskRecord }; | ||
| 196 | }, | ||
| 197 | |||
| 198 | getTask: async (taskId: string, workspace?: Id<"workspaces">) => { | ||
| 199 | const effectiveWorkspaceId = workspace ?? workspaceId; | ||
| 200 | if (!effectiveWorkspaceId) { | ||
| 201 | return null; | ||
| 202 | } | ||
| 203 | const task = await convex.query(api.workspace.getTaskInWorkspace, { | ||
| 204 | workspaceId: effectiveWorkspaceId, | ||
| 205 | sessionId, | ||
| 206 | taskId, | ||
| 207 | }); | ||
| 208 | return task as TaskRecord | null; | ||
| 209 | }, | ||
| 210 | |||
| 211 | subscribe: () => () => {}, | ||
| 212 | |||
| 213 | bootstrapAnonymousContext: async (requestedSessionId?: string) => { | ||
| 214 | const bootstrap = await convex.mutation(api.workspace.bootstrapAnonymousSession, { | ||
| 215 | sessionId: requestedSessionId, | ||
| 216 | }); | ||
| 217 | return bootstrap as AnonymousContext; | ||
| 218 | }, | ||
| 219 | |||
| 220 | listTools: async (toolContext?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => { | ||
| 221 | if (!toolContext) { | ||
| 222 | return []; | ||
| 223 | } | ||
| 224 | const tools = await convex.action(api.executorNode.listTools, { | ||
| 225 | workspaceId: toolContext.workspaceId, | ||
| 226 | sessionId, | ||
| 227 | actorId: toolContext.actorId, | ||
| 228 | clientId: toolContext.clientId, | ||
| 229 | }); | ||
| 230 | return tools as ToolDescriptor[]; | ||
| 231 | }, | ||
| 232 | |||
| 233 | listPendingApprovals: async (approvalWorkspaceId: Id<"workspaces">) => { | ||
| 234 | const approvals = await convex.query(api.workspace.listPendingApprovals, { | ||
| 235 | workspaceId: approvalWorkspaceId, | ||
| 236 | sessionId, | ||
| 237 | }); | ||
| 238 | return approvals as PendingApprovalRecord[]; | ||
| 239 | }, | ||
| 240 | |||
| 241 | resolveApproval: async (input: { | ||
| 242 | workspaceId: Id<"workspaces">; | ||
| 243 | approvalId: string; | ||
| 244 | decision: "approved" | "denied"; | ||
| 245 | reviewerId?: string; | ||
| 246 | reason?: string; | ||
| 247 | }) => { | ||
| 248 | return await convex.mutation(api.executor.resolveApproval, { | ||
| 249 | workspaceId: input.workspaceId, | ||
| 250 | sessionId, | ||
| 251 | approvalId: input.approvalId, | ||
| 252 | decision: input.decision, | ||
| 253 | reviewerId: input.reviewerId, | ||
| 254 | reason: input.reason, | ||
| 255 | }); | ||
| 256 | }, | ||
| 257 | }; | ||
| 258 | } | ||
| 259 | |||
| 260 | // --------------------------------------------------------------------------- | ||
| 261 | // Context resolution | ||
| 262 | // --------------------------------------------------------------------------- | ||
| 263 | |||
| 264 | async function resolveContextForWorkos( | ||
| 265 | workspaceId: Id<"workspaces">, | ||
| 266 | bearerToken: string, | ||
| 267 | clientId?: string, | ||
| 268 | ): Promise<McpWorkspaceContext> { | ||
| 269 | const convex = new ConvexHttpClient(requireConvexUrl()); | ||
| 270 | convex.setAuth(bearerToken); | ||
| 271 | |||
| 272 | const requestContext = await convex.query(api.workspace.getRequestContext, { | ||
| 273 | workspaceId, | ||
| 274 | }); | ||
| 275 | |||
| 276 | return { | ||
| 277 | workspaceId: requestContext.workspaceId, | ||
| 278 | actorId: requestContext.actorId, | ||
| 279 | clientId, | ||
| 280 | }; | ||
| 281 | } | ||
| 282 | |||
| 283 | // --------------------------------------------------------------------------- | ||
| 284 | // MCP handler | ||
| 285 | // --------------------------------------------------------------------------- | ||
| 286 | |||
| 287 | async function handleMcp(request: Request): Promise<Response> { | ||
| 288 | const verified = await verifyToken(request); | ||
| 289 | |||
| 290 | // No token at all — return 401 so MCP clients discover the auth server | ||
| 291 | if (!verified) { | ||
| 292 | return unauthorizedMcpResponse(request, "Authorization required."); | ||
| 293 | } | ||
| 294 | |||
| 295 | const url = new URL(request.url); | ||
| 296 | const requested = parseRequestedContext(url); | ||
| 297 | |||
| 298 | let context: McpWorkspaceContext | undefined; | ||
| 299 | |||
| 300 | if (verified.provider === "workos") { | ||
| 301 | // WorkOS-authenticated user: must provide workspaceId | ||
| 302 | if (!requested.workspaceId) { | ||
| 303 | return Response.json( | ||
| 304 | { error: "workspaceId query parameter is required for authenticated users" }, | ||
| 305 | { status: 400 }, | ||
| 306 | ); | ||
| 307 | } | ||
| 308 | |||
| 309 | try { | ||
| 310 | context = await resolveContextForWorkos( | ||
| 311 | requested.workspaceId, | ||
| 312 | verified.rawToken, | ||
| 313 | requested.clientId, | ||
| 314 | ); | ||
| 315 | } catch (error) { | ||
| 316 | return Response.json( | ||
| 317 | { error: error instanceof Error ? error.message : "Workspace authorization failed" }, | ||
| 318 | { status: 403 }, | ||
| 319 | ); | ||
| 320 | } | ||
| 321 | } | ||
| 322 | // Anonymous tokens: context is resolved lazily via bootstrapAnonymousContext | ||
| 323 | // in the MCP server's run_code handler (using the token sub as the actor). | ||
| 324 | // If workspaceId is provided, it will be passed through but the anonymous | ||
| 325 | // session bootstrap will create one if needed. | ||
| 326 | |||
| 327 | const bearerToken = verified.provider === "workos" ? verified.rawToken : undefined; | ||
| 328 | const service = createService(context, bearerToken); | ||
| 329 | return await handleMcpRequest(service, request, context); | ||
| 330 | } | ||
| 331 | |||
| 332 | // --------------------------------------------------------------------------- | ||
| 333 | // OAuth endpoints — self-issued anonymous auth server | ||
| 334 | // --------------------------------------------------------------------------- | ||
| 335 | |||
| 336 | function handleProtectedResourceMetadata(request: Request): Response { | ||
| 337 | const url = new URL(request.url); | ||
| 338 | const authorizationServers: string[] = []; | ||
| 339 | |||
| 340 | // Always advertise the self-issued anonymous auth server | ||
| 341 | if (anonOAuth) { | ||
| 342 | authorizationServers.push(anonOAuth.getIssuer()); | ||
| 343 | } | ||
| 344 | |||
| 345 | // Also advertise WorkOS if configured | ||
| 346 | if (workosAuthorizationServer) { | ||
| 347 | authorizationServers.push(workosAuthorizationServer); | ||
| 348 | } | ||
| 349 | |||
| 350 | if (authorizationServers.length === 0) { | ||
| 351 | return Response.json({ error: "No authorization servers configured" }, { status: 404 }); | ||
| 352 | } | ||
| 353 | |||
| 354 | return Response.json({ | ||
| 355 | resource: `${url.origin}/mcp`, | ||
| 356 | authorization_servers: authorizationServers, | ||
| 357 | bearer_methods_supported: ["header"], | ||
| 358 | }); | ||
| 359 | } | ||
| 360 | |||
| 361 | function handleAuthServerMetadata(): Response { | ||
| 362 | if (!anonOAuth) { | ||
| 363 | return Response.json({ error: "Anonymous OAuth server not initialized" }, { status: 500 }); | ||
| 364 | } | ||
| 365 | return Response.json(anonOAuth.getMetadata()); | ||
| 366 | } | ||
| 367 | |||
| 368 | function handleJwks(): Response { | ||
| 369 | if (!anonOAuth) { | ||
| 370 | return Response.json({ error: "Anonymous OAuth server not initialized" }, { status: 500 }); | ||
| 371 | } | ||
| 372 | return Response.json(anonOAuth.getJwks(), { | ||
| 373 | headers: { "cache-control": "public, max-age=3600" }, | ||
| 374 | }); | ||
| 375 | } | ||
| 376 | |||
| 377 | async function handleRegister(request: Request): Promise<Response> { | ||
| 378 | if (!anonOAuth) { | ||
| 379 | return Response.json({ error: "Anonymous OAuth server not initialized" }, { status: 500 }); | ||
| 380 | } | ||
| 381 | |||
| 382 | let body: unknown; | ||
| 383 | try { | ||
| 384 | body = await request.json(); | ||
| 385 | } catch { | ||
| 386 | return Response.json({ error: "Invalid JSON body" }, { status: 400 }); | ||
| 387 | } | ||
| 388 | |||
| 389 | try { | ||
| 390 | const registration = await anonOAuth.registerClient(body as any); | ||
| 391 | return Response.json(registration, { status: 201 }); | ||
| 392 | } catch (error) { | ||
| 393 | if (error instanceof OAuthBadRequest) { | ||
| 394 | return Response.json( | ||
| 395 | { error: "invalid_client_metadata", error_description: error.message }, | ||
| 396 | { status: 400 }, | ||
| 397 | ); | ||
| 398 | } | ||
| 399 | throw error; | ||
| 400 | } | ||
| 401 | } | ||
| 402 | |||
| 403 | async function handleAuthorize(request: Request): Promise<Response> { | ||
| 404 | if (!anonOAuth) { | ||
| 405 | return Response.json({ error: "Anonymous OAuth server not initialized" }, { status: 500 }); | ||
| 406 | } | ||
| 407 | |||
| 408 | const url = new URL(request.url); | ||
| 409 | try { | ||
| 410 | const { redirectTo } = await anonOAuth.authorize(url.searchParams); | ||
| 411 | return Response.redirect(redirectTo, 302); | ||
| 412 | } catch (error) { | ||
| 413 | if (error instanceof OAuthBadRequest) { | ||
| 414 | return Response.json( | ||
| 415 | { error: "invalid_request", error_description: error.message }, | ||
| 416 | { status: 400 }, | ||
| 417 | ); | ||
| 418 | } | ||
| 419 | throw error; | ||
| 420 | } | ||
| 421 | } | ||
| 422 | |||
| 423 | async function handleToken(request: Request): Promise<Response> { | ||
| 424 | if (!anonOAuth) { | ||
| 425 | return Response.json({ error: "Anonymous OAuth server not initialized" }, { status: 500 }); | ||
| 426 | } | ||
| 427 | |||
| 428 | let body: URLSearchParams; | ||
| 429 | try { | ||
| 430 | const text = await request.text(); | ||
| 431 | body = new URLSearchParams(text); | ||
| 432 | } catch { | ||
| 433 | return Response.json({ error: "Invalid request body" }, { status: 400 }); | ||
| 434 | } | ||
| 435 | |||
| 436 | try { | ||
| 437 | const tokens = await anonOAuth.exchangeToken(body); | ||
| 438 | return Response.json(tokens, { | ||
| 439 | headers: { | ||
| 440 | "cache-control": "no-store", | ||
| 441 | "pragma": "no-cache", | ||
| 442 | }, | ||
| 443 | }); | ||
| 444 | } catch (error) { | ||
| 445 | if (error instanceof OAuthBadRequest) { | ||
| 446 | return Response.json( | ||
| 447 | { error: "invalid_grant", error_description: error.message }, | ||
| 448 | { status: 400 }, | ||
| 449 | ); | ||
| 450 | } | ||
| 451 | throw error; | ||
| 452 | } | ||
| 453 | } | ||
| 454 | |||
| 455 | // --------------------------------------------------------------------------- | ||
| 456 | // Server | ||
| 457 | // --------------------------------------------------------------------------- | ||
| 458 | |||
| 459 | export async function startMcpGateway( | ||
| 460 | port = Number(Bun.env.EXECUTOR_MCP_GATEWAY_PORT ?? Bun.env.PORT ?? 5313), | ||
| 461 | ): Promise<ReturnType<typeof Bun.serve>> { | ||
| 462 | loadConfigFromEnv(); | ||
| 463 | |||
| 464 | // Initialize the self-issued anonymous OAuth server. | ||
| 465 | // The issuer must match the gateway's public origin. | ||
| 466 | const gatewayOrigin = Bun.env.MCP_GATEWAY_ORIGIN ?? `http://localhost:${port}`; | ||
| 467 | await initAnonymousOAuth(gatewayOrigin); | ||
| 468 | |||
| 469 | // Periodically purge expired authorization codes | ||
| 470 | setInterval(() => { | ||
| 471 | anonOAuth?.purgeExpiredCodes(); | ||
| 472 | }, 60_000); | ||
| 473 | |||
| 474 | const server = Bun.serve({ | ||
| 475 | port, | ||
| 476 | async fetch(request) { | ||
| 477 | const url = new URL(request.url); | ||
| 478 | |||
| 479 | if (url.pathname === "/health") { | ||
| 480 | return new Response("ok"); | ||
| 481 | } | ||
| 482 | |||
| 483 | // ── MCP endpoint ────────────────────────────────────────────────── | ||
| 484 | if (url.pathname === "/" || url.pathname === "/mcp") { | ||
| 485 | if (request.method === "POST" || request.method === "GET" || request.method === "DELETE") { | ||
| 486 | return await handleMcp(request); | ||
| 487 | } | ||
| 488 | return new Response("Method Not Allowed", { status: 405 }); | ||
| 489 | } | ||
| 490 | |||
| 491 | // ── OAuth discovery (RFC 9728 + RFC 8414) ───────────────────────── | ||
| 492 | if (url.pathname === "/.well-known/oauth-protected-resource") { | ||
| 493 | return handleProtectedResourceMetadata(request); | ||
| 494 | } | ||
| 495 | |||
| 496 | if (url.pathname === "/.well-known/oauth-authorization-server") { | ||
| 497 | return handleAuthServerMetadata(); | ||
| 498 | } | ||
| 499 | |||
| 500 | // ── Self-issued OAuth endpoints ─────────────────────────────────── | ||
| 501 | if (url.pathname === "/oauth2/jwks") { | ||
| 502 | return handleJwks(); | ||
| 503 | } | ||
| 504 | |||
| 505 | if (url.pathname === "/register" && request.method === "POST") { | ||
| 506 | return await handleRegister(request); | ||
| 507 | } | ||
| 508 | |||
| 509 | if (url.pathname === "/authorize" && request.method === "GET") { | ||
| 510 | return await handleAuthorize(request); | ||
| 511 | } | ||
| 512 | |||
| 513 | if (url.pathname === "/token" && request.method === "POST") { | ||
| 514 | return await handleToken(request); | ||
| 515 | } | ||
| 516 | |||
| 517 | return new Response("Not found", { status: 404 }); | ||
| 518 | }, | ||
| 519 | }); | ||
| 520 | |||
| 521 | const hasWorkos = Boolean(workosAuthorizationServer); | ||
| 522 | console.log( | ||
| 523 | `[executor-mcp-gateway] listening on http://localhost:${port}/mcp` | ||
| 524 | + ` (anonymous-oauth: enabled, workos: ${hasWorkos ? "enabled" : "disabled"})`, | ||
| 525 | ); | ||
| 526 | |||
| 527 | return server; | ||
| 528 | } | ||
| 529 | |||
| 530 | if (import.meta.main) { | ||
| 531 | await startMcpGateway(); | ||
| 532 | } | ||
| @@ -8,13 +8,10 @@ · 7 unmodified lines | |||
| 8 | "backend": "bun run executor.ts backend", | 8 | "backend": "bun run executor.ts backend", |
| 9 | "convex": "bun run executor.ts backend", | 9 | "convex": "bun run executor.ts backend", |
| 10 | "web": "bun run executor.ts web", | 10 | "web": "bun run executor.ts web", |
| 11 | "gateway": "bun run executor.ts gateway", | ||
| 12 | "deploy": "bunx convex deploy", | 11 | "deploy": "bunx convex deploy", |
| 13 | "codegen": "bunx convex codegen", | 12 | "codegen": "bunx convex codegen", |
| 14 | "build:binary": "bun build executor.ts --compile --outfile dist/executor", | 13 | "build:binary": "bun build executor.ts --compile --outfile dist/executor", |
| 15 | "build:release": "bun run scripts/build-release.ts", | 14 | "build:release": "bun run scripts/build-release.ts" |
| 16 | "dev:mcp-gateway": "EXECUTOR_MCP_GATEWAY_PORT=4313 bun --hot mcp-gateway.ts", | ||
| 17 | "start:mcp-gateway": "bun mcp-gateway.ts" | ||
| 18 | }, | 15 | }, |
| 19 | "dependencies": { | 16 | "dependencies": { |
| 20 | "convex": "^1.31.7" | 17 | "convex": "^1.31.7" |
| @@ -81,7 +81,7 @@ async function buildWebArtifact(rootDir: string, releaseDir: string, checksums: · 80 unmodified lines | |||
| 81 | const webBuildEnv = { | 81 | const webBuildEnv = { |
| 82 | ...process.env, | 82 | ...process.env, |
| 83 | EXECUTOR_WEB_CONVEX_URL: "http://127.0.0.1:5410", | 83 | EXECUTOR_WEB_CONVEX_URL: "http://127.0.0.1:5410", |
| 84 | NEXT_PUBLIC_LOCAL_MCP_ORIGIN: "http://localhost:5313", | 84 | EXECUTOR_WEB_CONVEX_SITE_URL: "http://127.0.0.1:5411", |
| 85 | }; | 85 | }; |
| 86 | 86 | ||
| 87 | await runCommand(["bunx", "next", "build"], { | 87 | await runCommand(["bunx", "next", "build"], { |
| @@ -21,7 +21,6 @@ · 20 unmodified lines | |||
| 21 | "lib/**/*.ts", | 21 | "lib/**/*.ts", |
| 22 | "scripts/**/*.ts", | 22 | "scripts/**/*.ts", |
| 23 | "executor.ts", | 23 | "executor.ts", |
| 24 | "mcp-gateway.ts", | ||
| 25 | "vscode-elicitation-server.ts" | 24 | "vscode-elicitation-server.ts" |
| 26 | ] | 25 | ] |
| 27 | } | 26 | } |