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
2525 # EXECUTOR_BACKEND_PORT=5410
2626 # EXECUTOR_BACKEND_SITE_PORT=5411
2727 # EXECUTOR_WEB_PORT=5312
28−# EXECUTOR_MCP_GATEWAY_PORT=5313
29−# NEXT_PUBLIC_LOCAL_MCP_ORIGIN=http://localhost:4313
3028
3129 # ── Discord bot (optional — omit to skip bot startup) ──
3230 # DISCORD_BOT_TOKEN=...
README.md+1−1
@@ -63,7 +63,7 @@ bun run dev · 62 unmodified lines
6363
6464 - Convex dev watcher for `executor/`
6565 - Executor web app (`http://localhost:4312`)
66−- Executor MCP gateway (`http://localhost:4313/mcp`)
66+- Executor MCP endpoint (`<CONVEX_SITE_URL>/mcp`)
6767 - Assistant server (`http://localhost:3000`)
6868 - Discord bot (only if `DISCORD_BOT_TOKEN` is set)
6969
dev.ts+3−16
@@ -8,9 +8,8 @@ · 7 unmodified lines
88 * Starts:
99 * 1. Convex cloud dev function watcher ─┐
1010 * 2. Executor web UI (port 4312) ├─ all started concurrently
11− * 3. Executor MCP gateway (port 4313) │
12− * 4. Assistant server (port 3000) │
13− * 5. Discord bot ─┘
11+ * 3. Assistant server (port 3000) │
12+ * 4. Discord bot ─┘
1413 *
1514 * All processes are killed when this script exits (Ctrl+C).
1615 * 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
2423 const colors = {
2524 convex: "\x1b[36m", // cyan
2625 web: "\x1b[34m", // blue
27− mcp: "\x1b[33m", // yellow
2826 assistant: "\x1b[32m", // green
2927 bot: "\x1b[35m", // magenta
3028 reset: "\x1b[0m",
@@ -119,8 +117,7 @@ process.on("SIGTERM", shutdown); · 88 unmodified lines
119117 // ── Kill stale processes from a previous run ──
120118
121119 const EXECUTOR_WEB_PORT = Number(Bun.env.EXECUTOR_WEB_PORT ?? 4312);
122−const EXECUTOR_MCP_PORT = Number(Bun.env.EXECUTOR_MCP_GATEWAY_PORT ?? 4313);
123−const DEV_PORTS = [3000, EXECUTOR_WEB_PORT, EXECUTOR_MCP_PORT];
120+const DEV_PORTS = [3000, EXECUTOR_WEB_PORT];
124121
125122 async function killStaleProcesses() {
126123 let killed = 0;
@@ -180,16 +177,6 @@ spawnService("web", ["bun", "run", "dev"], { · 53 unmodified lines
180177 cwd: "./executor/apps/web",
181178 });
182179
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−
193180 spawnService("assistant", ["bun", "run", "dev"], {
194181 cwd: "./assistant/packages/server",
195182 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
44
55 - task execution (`run_code`) with tool invocation and approval gates
66 - 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)
88 - a Next.js web app for tasks, approvals, tools, members, and billing
99 - a binary-first install flow for local self-hosted runtime
1010
@@ -16,7 +16,6 @@ Core components: · 5 unmodified lines
1616 - `convex/http.ts`: HTTP routes for `/mcp`, OAuth discovery metadata, and internal runtime callbacks.
1717 - `convex/executorNode.ts`: task runner action (`runTask`) and tool invocation plumbing.
1818 - `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.
2019 - `apps/web/`: operator UI (dashboard, tasks, approvals, tools, onboarding, org settings).
2120 - `executor.ts`: CLI entrypoint used by local source scripts and compiled binary releases.
2221
@@ -50,16 +49,11 @@ bun run dev:executor:convex · 27 unmodified lines
5049
5150 # Terminal 2: Web UI
5251 bun run dev:executor:web
53−
54−# Terminal 3 (optional but recommended for stateful MCP transport)
55−bun run --cwd executor dev:mcp-gateway
5652 ```
5753
5854 Default source-dev endpoints:
5955
6056 - Web UI: `http://localhost:4312`
61−- MCP gateway: `http://localhost:4313/mcp`
62−- Gateway health: `http://localhost:4313/health`
6357 - Convex HTTP MCP route: `<CONVEX_SITE_URL>/mcp`
6458
6559 ## Binary Install (No Global Bun/Node/Convex Required)
@@ -83,7 +77,6 @@ executor doctor · 17 unmodified lines
8377 executor up
8478 executor backend --help
8579 executor web
86−executor gateway
8780 ```
8881
8982 Uninstall:
@@ -97,7 +90,6 @@ Default managed-runtime ports: · 7 unmodified lines
9790 - backend API: `5410`
9891 - backend site proxy: `5411`
9992 - packaged web app: `5312`
100−- MCP gateway: `5313`
10193
10294 ## CLI Commands (executor/package.json)
10395
@@ -108,7 +100,6 @@ bun run doctor · 4 unmodified lines
108100 bun run up
109101 bun run backend -- --help
110102 bun run web
111−bun run gateway
112103 bun run codegen
113104 bun run deploy
114105 bun run build:binary
@@ -122,24 +113,15 @@ Notes: · 7 unmodified lines
122113
123114 ## MCP and OAuth Surface
124115
125−Gateway routes (`mcp-gateway.ts`):
116+Convex HTTP routes (`convex/http.ts`) expose:
126117
127−- `/mcp` (GET/POST/DELETE)
118+- `/mcp` (direct Convex MCP transport)
128119 - `/.well-known/oauth-protected-resource`
129120 - `/.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)
139121 - `/internal/runs/:runId/tool-call`
140122 - `/internal/runs/:runId/output`
141123
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.
143125
144126 ## Configuration Reference
145127
@@ -159,14 +141,11 @@ Important env vars (see root `.env.example` for the base template): · 13 unmodified lines
159141 - `STRIPE_PRICE_ID`
160142 - MCP auth integration:
161143 - `MCP_AUTHORIZATION_SERVER` or `MCP_AUTHORIZATION_SERVER_URL`
162−- Managed runtime and gateway:
144+- Managed runtime:
163145 - `EXECUTOR_RUNTIME_DIR`
164146 - `EXECUTOR_BACKEND_PORT`
165147 - `EXECUTOR_BACKEND_SITE_PORT`
166148 - `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`
170149
171150 ## Credential Providers
172151
@@ -198,17 +177,15 @@ bun run typecheck:executor · 25 unmodified lines
198177 executor/
199178 |- apps/web/ # Next.js operator UI
200179 |- convex/ # Convex functions, schema, auth, HTTP routes
201−|- lib/ # runtime, tool loading/discovery, gateway helpers
180+|- lib/ # runtime, tool loading/discovery, helper modules
202181 |- scripts/build-release.ts # release artifact builder
203182 |- executor.ts # CLI entrypoint (compiled into binary)
204−|- mcp-gateway.ts # standalone MCP + OAuth gateway
205183 |- install # curl install script
206184 `- uninstall # uninstall script
207185 ```
208186
209187 ## Troubleshooting
210188
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).
212190 - 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.
214191 - 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 @@
11 import type { NextConfig } from "next";
22
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+
313 const appShellRewriteExclusions = [
414 "api(?:/|$)",
515 "_next(?:/|$)",
@@ -21,6 +31,10 @@ const nextConfig: NextConfig = { · 15 unmodified lines
2131 // Map canonical env vars to NEXT_PUBLIC_ so they're available client-side.
2232 // This lets us keep a single root .env without NEXT_PUBLIC_ prefixes.
2333 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),
2438 NEXT_PUBLIC_WORKOS_CLIENT_ID: process.env.WORKOS_CLIENT_ID,
2539 NEXT_PUBLIC_STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID,
2640 },
executor/apps/web/src/components/mcp-setup-card.tsx+1−32
@@ -41,10 +41,6 @@ function resolveMcpOrigin(windowOrigin: string): string { · 40 unmodified lines
4141 return windowOrigin;
4242 }
4343
44−function isLocalHostname(hostname: string): boolean {
45− return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0";
46−}
47−
4844 export function McpSetupCard({
4945 workspaceId,
5046 actorId,
@@ -56,26 +52,12 @@ export function McpSetupCard({ · 5 unmodified lines
5652 }) {
5753 const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code");
5854 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− });
6555
66− const defaultOrigin = useMemo(() => {
56+ const origin = useMemo(() => {
6757 if (typeof window === "undefined") return "";
6858 return resolveMcpOrigin(window.location.origin);
6959 }, []);
7060
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−
7961 const mcpUrl = useMemo(() => {
8062 const base = origin ? new URL("/mcp", origin) : new URL("http://localhost/mcp");
8163 if (workspaceId) base.searchParams.set("workspaceId", workspaceId);
@@ -112,19 +94,6 @@ export function McpSetupCard({ · 30 unmodified lines
11294 <div className="space-y-5">
11395 <div className="space-y-1.5">
11496 <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}
12897 <div className="flex items-center gap-2">
12998 <Input value={mcpUrl} readOnly className="h-8 text-xs font-mono bg-background" />
13099 <Button
executor/executor.ts+2−13
@@ -1,7 +1,6 @@
11 #!/usr/bin/env bun
22
33 import fs from "node:fs/promises";
4−import { startMcpGateway } from "./mcp-gateway";
54 import { managedRuntimeDiagnostics, runManagedBackend, runManagedWeb } from "./lib/managed_runtime";
65
76 function printHelp(): void {
@@ -12,14 +11,12 @@ Usage: · 4 unmodified lines
1211 executor up [backend-args]
1312 executor backend <args>
1413 executor web [--port <number>]
15− executor gateway [--port <number>]
1614
1715 Commands:
1816 doctor Bootstrap and verify managed Convex backend runtime
1917 up Run managed backend and auto-bootstrap Convex functions
2018 backend Pass through arguments to managed convex-local-backend binary
2119 web Run packaged web UI (default port: 5312)
22− gateway Start Executor MCP gateway (default port: 5313)
2320 `);
2421 }
2522
@@ -75,12 +72,10 @@ async function run(): Promise<void> { · 49 unmodified lines
7572 if (command === "doctor") {
7673 const info = await managedRuntimeDiagnostics();
7774 const webPort = Number(Bun.env.EXECUTOR_WEB_PORT ?? 5312);
78− const gatewayPort = Number(Bun.env.EXECUTOR_MCP_GATEWAY_PORT ?? 5313);
7975 const webInstalled = await pathExists(info.webServerEntry);
8076 const nodeInstalled = await pathExists(info.nodeBin);
8177 const backendRunning = await checkHttp(`${info.convexUrl}/version`);
8278 const webRunning = await checkHttp(`http://127.0.0.1:${webPort}/`);
83− const gatewayRunning = await checkHttp(`http://127.0.0.1:${gatewayPort}/health`);
8479
8580 console.log("Managed runtime ready");
8681 console.log(` root: ${info.rootDir}`);
@@ -90,8 +85,8 @@ async function run(): Promise<void> { · 3 unmodified lines
9085 console.log(` node runtime: ${nodeInstalled ? info.nodeBin : "not installed yet (installed by 'executor web')"}`);
9186 console.log(` web bundle: ${webInstalled ? info.webServerEntry : "not installed yet (installed by 'executor web')"}`);
9287 console.log(` web URL: http://127.0.0.1:${webPort}`);
93− console.log(` gateway URL: http://127.0.0.1:${gatewayPort}/mcp`);
94− console.log(` running: backend=${backendRunning ? "yes" : "no"} web=${webRunning ? "yes" : "no"} gateway=${gatewayRunning ? "yes" : "no"}`);
88+ console.log(` mcp URL: ${info.convexSiteUrl}/mcp`);
89+ console.log(` running: backend=${backendRunning ? "yes" : "no"} web=${webRunning ? "yes" : "no"}`);
9590 console.log(` config: ${info.configPath}`);
9691 return;
9792 }
@@ -109,12 +104,6 @@ async function run(): Promise<void> { · 11 unmodified lines
109104 process.exit(exitCode);
110105 }
111106
112− if (command === "gateway") {
113− const port = parsePort(rest);
114− startMcpGateway(port);
115− return;
116− }
117−
118107 if (command === "web") {
119108 const port = parsePort(rest);
120109 const exitCode = await runManagedWeb({ port });
executor/install+0−1
@@ -266,7 +266,6 @@ echo "Run:" · 265 unmodified lines
266266 echo " executor doctor"
267267 echo " executor up"
268268 echo " executor web # defaults to http://localhost:5312"
269−echo " executor gateway # defaults to http://localhost:5313/mcp"
270269 echo ""
271270 echo "To uninstall later:"
272271 echo " 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
642642 await ensureNodeRuntime(info);
643643 await ensureWebBundle(info);
644644
645− const mcpPort = Number(Bun.env.EXECUTOR_MCP_GATEWAY_PORT ?? 5313);
646645 const webPort = options.port ?? Number(Bun.env.EXECUTOR_WEB_PORT ?? 5312);
647646 const host = Bun.env.EXECUTOR_WEB_INTERFACE ?? "127.0.0.1";
648647
@@ -653,7 +652,6 @@ export async function runManagedWeb(options: { port?: number } = {}): Promise<nu · 4 unmodified lines
653652 HOSTNAME: host,
654653 CONVEX_URL: process.env.CONVEX_URL ?? `http://${info.config.hostInterface}:${info.config.backendPort}`,
655654 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}`,
657655 };
658656
659657 const proc = await runProcess(info.nodeBin, [info.webServerEntry], {
executor/mcp-gateway.ts+0−532
@@ -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−}
executor/package.json+1−4
@@ -8,13 +8,10 @@ · 7 unmodified lines
88 "backend": "bun run executor.ts backend",
99 "convex": "bun run executor.ts backend",
1010 "web": "bun run executor.ts web",
11− "gateway": "bun run executor.ts gateway",
1211 "deploy": "bunx convex deploy",
1312 "codegen": "bunx convex codegen",
1413 "build:binary": "bun build executor.ts --compile --outfile dist/executor",
15− "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"
14+ "build:release": "bun run scripts/build-release.ts"
1815 },
1916 "dependencies": {
2017 "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
8181 const webBuildEnv = {
8282 ...process.env,
8383 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",
8585 };
8686
8787 await runCommand(["bunx", "next", "build"], {
executor/tsconfig.json+0−1
@@ -21,7 +21,6 @@ · 20 unmodified lines
2121 "lib/**/*.ts",
2222 "scripts/**/*.ts",
2323 "executor.ts",
24− "mcp-gateway.ts",
2524 "vscode-elicitation-server.ts"
2625 ]
2726 }