From 1093dc40a777e4cb59295b6f17664bfd0483428e Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Thu, 28 May 2026 17:54:56 +0200 Subject: [PATCH] fix(runner): correct PUBLIC_URL + mount runner-map volume Two overlapping bugs were killing OAuth discovery for every external MCP client (Claude Desktop, Cursor, etc.): 1. worker.ts injected PUBLIC_URL=http://: into the runner container even when MCP_DOMAIN was set. Result: the runner's /.well-known/oauth-protected-resource advertised an unreachable URL and the WWW-Authenticate header pointed at a non-HTTPS loopback address. Claude Desktop refused to follow the discovery chain. Now derives PUBLIC_URL from the same computePublicUrl() helper that builds the user-visible URL stored in mcp_servers.public_url, so the container's self-reported resource matches its actual route. 2. docker-compose.prod.yml never mounted /opt/buildmymcpserver/runner-map into the api / generator containers. The .conf snippet written by the generator landed in an ephemeral container path; the host inotify watcher saw an empty directory and produced an empty runner-map.combined. Result: nginx 404'd every //* request, the runner was unreachable from the public domain, and OAuth discovery couldn't even begin. Mount added to both services. Existing weather server has the wrong PUBLIC_URL baked in and must be recreated after deploy. No customers yet. export computePublicUrl from deploy.ts so worker.ts can call it. --- apps/generator/src/lib/deploy.ts | 2 +- apps/generator/src/worker.ts | 16 ++++++++++++++-- docker-compose.prod.yml | 10 ++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/generator/src/lib/deploy.ts b/apps/generator/src/lib/deploy.ts index 8515b36..d7fa4ad 100644 --- a/apps/generator/src/lib/deploy.ts +++ b/apps/generator/src/lib/deploy.ts @@ -52,7 +52,7 @@ async function removeRunnerMapEntry(slug: string): Promise { } } -function computePublicUrl(slug: string, port: number): string { +export function computePublicUrl(slug: string, port: number): string { if (config.MCP_DOMAIN) return `https://${config.MCP_DOMAIN}/${slug}`; return `http://${config.RUNNER_HOST}:${port}`; } diff --git a/apps/generator/src/worker.ts b/apps/generator/src/worker.ts index fd30f6f..32ff836 100644 --- a/apps/generator/src/worker.ts +++ b/apps/generator/src/worker.ts @@ -5,7 +5,13 @@ import { Redis } from 'ioredis'; import { config } from './config.js'; import { dockerBuild, prepareBuildContext, staticCheck } from './lib/build.js'; import { generateSpec } from './lib/claude.js'; -import { allocatePort, deployContainer, dockerAvailable, stopContainer } from './lib/deploy.js'; +import { + allocatePort, + computePublicUrl, + deployContainer, + dockerAvailable, + stopContainer, +} from './lib/deploy.js'; import { emitDone, emitError, emitLog, emitStatus } from './lib/emit.js'; import { renderServerCode } from './lib/render.js'; @@ -156,7 +162,13 @@ export const worker = new Worker( await emitStatus(buildId, 'deploying'); const port = await allocatePort(); - const publicUrl = `http://${config.RUNNER_HOST}:${port}`; + // The container's PUBLIC_URL must match what end-users (and Claude + // Desktop's DCR client) actually reach. When MCP_DOMAIN is set we + // route via https:///; the hardcoded loopback URL + // we used to inject caused the runner to advertise an unreachable + // resource_metadata URL in its WWW-Authenticate header, killing OAuth + // discovery from any external MCP client. + const publicUrl = computePublicUrl(slug, port); const envVars: Record = { ...secrets, PUBLIC_URL: publicUrl, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 35e6d53..7861dce 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -66,6 +66,13 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - bmm_keys:/app/apps/api/keys + # Per-runner nginx snippets — written by the generator, deleted by the + # api when a server is removed. The host-side systemd watcher combines + # them into runner-map.combined + reloads nginx. Without this mount the + # snippet files land in an ephemeral container path and the path-routed + # //* endpoints return 404 from nginx — breaking OAuth discovery + # for every external MCP client. + - /opt/buildmymcpserver/runner-map:/var/runner-map networks: [bmm-network] depends_on: postgres: @@ -104,6 +111,9 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - bmm_build_context:/app/build-context + # Same runner-map mount as the api — generator drops the snippet on + # deploy, watcher concatenates, nginx reloads. + - /opt/buildmymcpserver/runner-map:/var/runner-map depends_on: postgres: condition: service_healthy