History

Remove standalone MCP gateway from dev flow

Use the Convex /mcp endpoint directly in local and managed runtime paths, and update setup/docs/env wiring to remove localhost gateway-specific configuration.

Rhys Sullivan committed 9 months ago

Commit 9ab952d1 parent 7e1dea913 files · +30 −635Browse files

Unified · Split

13 files
  1. .env.example +0 −2
  2. README.md +1 −1
  3. dev.ts +3 −16
  4. executor/README.md +7 −30
  5. executor/apps/web/next.config.ts +14 −0
  6. executor/apps/web/src/components/mcp-setup-card.tsx +1 −32
  7. executor/executor.ts +2 −13
  8. executor/install +0 −1
  9. executor/lib/managed_runtime.ts +0 −2
  10. executor/mcp-gateway.ts +0 −532
  11. executor/package.json +1 −4
  12. executor/scripts/build-release.ts +1 −1
  13. executor/tsconfig.json +0 −1
.env.example+0−2
@@ -25,8 +25,6 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 24 unmodified lines
25# EXECUTOR_BACKEND_PORT=541025# EXECUTOR_BACKEND_PORT=5410
26# EXECUTOR_BACKEND_SITE_PORT=541126# EXECUTOR_BACKEND_SITE_PORT=5411
27# EXECUTOR_WEB_PORT=531227# 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=...
README.md+1−1
@@ -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
dev.ts+3−16
@@ -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 concurrently10 * 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
24const colors = {23const colors = {
25 convex: "\x1b[36m", // cyan24 convex: "\x1b[36m", // cyan
26 web: "\x1b[34m", // blue25 web: "\x1b[34m", // blue
27 mcp: "\x1b[33m", // yellow
28 assistant: "\x1b[32m", // green26 assistant: "\x1b[32m", // green
29 bot: "\x1b[35m", // magenta27 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
121const EXECUTOR_WEB_PORT = Number(Bun.env.EXECUTOR_WEB_PORT ?? 4312);119const EXECUTOR_WEB_PORT = Number(Bun.env.EXECUTOR_WEB_PORT ?? 4312);
122const EXECUTOR_MCP_PORT = Number(Bun.env.EXECUTOR_MCP_GATEWAY_PORT ?? 4313);120const DEV_PORTS = [3000, EXECUTOR_WEB_PORT];
123const DEV_PORTS = [3000, EXECUTOR_WEB_PORT, EXECUTOR_MCP_PORT];
124 121
125async function killStaleProcesses() {122async 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
183spawnService("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
193spawnService("assistant", ["bun", "run", "dev"], {180spawnService("assistant", ["bun", "run", "dev"], {
194 cwd: "./assistant/packages/server",181 cwd: "./assistant/packages/server",
195 env: {182 env: {
executor/README.md+7−30
@@ -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 gates5- task execution (`run_code`) with tool invocation and approval gates
6- workspace-scoped policy, credentials, and tool source management6- 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 billing8- a Next.js web app for tasks, approvals, tools, members, and billing
9- a binary-first install flow for local self-hosted runtime9- 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 UI50# Terminal 2: Web UI
52bun run dev:executor:web51bun run dev:executor:web
53
54# Terminal 3 (optional but recommended for stateful MCP transport)
55bun run --cwd executor dev:mcp-gateway
56```52```
57 53
58Default source-dev endpoints:54Default 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
83executor up77executor up
84executor backend --help78executor backend --help
85executor web79executor web
86executor gateway
87```80```
88 81
89Uninstall:82Uninstall:
@@ -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
108bun run up100bun run up
109bun run backend -- --help101bun run backend -- --help
110bun run web102bun run web
111bun run gateway
112bun run codegen103bun run codegen
113bun run deploy104bun run deploy
114bun run build:binary105bun run build:binary
@@ -122,24 +113,15 @@ Notes: · 7 unmodified lines
122 113
123## MCP and OAuth Surface114## MCP and OAuth Surface
124 115
125Gateway routes (`mcp-gateway.ts`):116Convex 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
136Convex 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
142Anonymous OAuth is always available on the standalone gateway. WorkOS token verification is enabled when `MCP_AUTHORIZATION_SERVER` (or `MCP_AUTHORIZATION_SERVER_URL`) is configured.124MCP bearer-token verification is enabled when `MCP_AUTHORIZATION_SERVER` (or `MCP_AUTHORIZATION_SERVER_URL`) is configured.
143 125
144## Configuration Reference126## 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 Providers150## Credential Providers
172 151
@@ -198,17 +177,15 @@ bun run typecheck:executor · 25 unmodified lines
198executor/177executor/
199|- apps/web/ # Next.js operator UI178|- apps/web/ # Next.js operator UI
200|- convex/ # Convex functions, schema, auth, HTTP routes179|- convex/ # Convex functions, schema, auth, HTTP routes
201|- lib/ # runtime, tool loading/discovery, gateway helpers180|- lib/ # runtime, tool loading/discovery, helper modules
202|- scripts/build-release.ts # release artifact builder181|- 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 script183|- install # curl install script
206`- uninstall # uninstall script184`- uninstall # uninstall script
207```185```
208 186
209## Troubleshooting187## 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.
executor/apps/web/next.config.ts+14−0
@@ -1,5 +1,15 @@
1import type { NextConfig } from "next";1import type { NextConfig } from "next";
2 2
3function 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
3const appShellRewriteExclusions = [13const 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 },
executor/apps/web/src/components/mcp-setup-card.tsx+1−32
@@ -41,10 +41,6 @@ function resolveMcpOrigin(windowOrigin: string): string { · 40 unmodified lines
41 return windowOrigin;41 return windowOrigin;
42}42}
43 43
44function isLocalHostname(hostname: string): boolean {
45 return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0";
46}
47
48export function McpSetupCard({44export 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 <Button99 <Button
executor/executor.ts+2−13
@@ -1,7 +1,6 @@
1#!/usr/bin/env bun1#!/usr/bin/env bun
2 2
3import fs from "node:fs/promises";3import fs from "node:fs/promises";
4import { startMcpGateway } from "./mcp-gateway";
5import { managedRuntimeDiagnostics, runManagedBackend, runManagedWeb } from "./lib/managed_runtime";4import { managedRuntimeDiagnostics, runManagedBackend, runManagedWeb } from "./lib/managed_runtime";
6 5
7function printHelp(): void {6function 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
17Commands:15Commands:
18 doctor Bootstrap and verify managed Convex backend runtime16 doctor Bootstrap and verify managed Convex backend runtime
19 up Run managed backend and auto-bootstrap Convex functions17 up Run managed backend and auto-bootstrap Convex functions
20 backend Pass through arguments to managed convex-local-backend binary18 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 });
executor/install+0−1
@@ -266,7 +266,6 @@ echo "Run:" · 265 unmodified lines
266echo " executor doctor"266echo " executor doctor"
267echo " executor up"267echo " executor up"
268echo " executor web # defaults to http://localhost:5312"268echo " executor web # defaults to http://localhost:5312"
269echo " executor gateway # defaults to http://localhost:5313/mcp"
270echo ""269echo ""
271echo "To uninstall later:"270echo "To uninstall later:"
272echo " bash executor/uninstall --yes"271echo " bash executor/uninstall --yes"
executor/lib/managed_runtime.ts+0−2
@@ -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], {
executor/mcp-gateway.ts+0−532
@@ -1,532 +0,0 @@
1import { ConvexHttpClient } from "convex/browser";
2import { createRemoteJWKSet, jwtVerify } from "jose";
3import { api } from "./convex/_generated/api";
4import { handleMcpRequest, type McpWorkspaceContext } from "./lib/mcp_server";
5import { AnonymousOAuthServer, OAuthBadRequest } from "./lib/anonymous-oauth";
6import { ConvexOAuthStorage } from "./lib/convex-oauth-storage";
7import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescriptor } from "./lib/types";
8import type { Id } from "./convex/_generated/dataModel";
9
10// ---------------------------------------------------------------------------
11// Verified token result — unified across WorkOS and self-issued anonymous JWTs
12// ---------------------------------------------------------------------------
13
14interface 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
25let convexUrl: string | undefined;
26let workosAuthorizationServer: string | undefined;
27let workosJwks: ReturnType<typeof createRemoteJWKSet> | null = null;
28let anonOAuth: AnonymousOAuthServer | null = null;
29
30function 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
47async 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
57function 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
68function 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 */
81async 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
116function resourceMetadataUrl(request: Request): string {
117 const url = new URL(request.url);
118 return `${url.origin}/.well-known/oauth-protected-resource`;
119}
120
121function 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 */
148function parseWorkspaceId(raw: string): Id<"workspaces"> {
149 return raw as Id<"workspaces">;
150}
151
152function 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
166function 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
264async 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
287async 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
336function 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
361function 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
368function 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
377async 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
403async 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
423async 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
459export 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
530if (import.meta.main) {
531 await startMcpGateway();
532}
executor/package.json+1−4
@@ -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"
executor/scripts/build-release.ts+1−1
@@ -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"], {
executor/tsconfig.json+0−1
@@ -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}