diff --git a/apps/api/src/routes/oauth.ts b/apps/api/src/routes/oauth.ts index 00add85..f2959fb 100644 --- a/apps/api/src/routes/oauth.ts +++ b/apps/api/src/routes/oauth.ts @@ -41,24 +41,9 @@ function pkceVerify(verifier: string, challenge: string, method: string): boolea async function resolveServerByResource(resource: string) { const url = new URL(resource); - // Path routing (the prod topology on mcp.buildmymcpserver.com): the slug - // is the first path segment. Has to be checked BEFORE the subdomain - // heuristic below, otherwise we extract "mcp" from "mcp.example.com/" - // and look up the wrong (or no) server. Claude Desktop's RFC 8707 resource - // parameter matches the `resource` field we publish in /.well-known/ - // oauth-protected-resource, which is exactly the path-routed public URL. - const firstSegment = url.pathname.split('/').filter(Boolean)[0]; - if (firstSegment) { - const [s] = await db - .select() - .from(mcpServers) - .where(eq(mcpServers.slug, firstSegment)) - .limit(1); - if (s) return s; - } - - // Port-based lookup — used in local dev where the runner is reached - // directly at http://:. + // Local direct runner URLs are addressed by host port, e.g. + // http://localhost:4103/mcp. Resolve those before path routing so the + // transport endpoint segment is not treated as a tenant slug. const port = url.port ? Number(url.port) : null; if (port !== null) { const [s] = await db @@ -69,6 +54,22 @@ async function resolveServerByResource(resource: string) { if (s) return s; } + // Path routing (the prod topology on mcp.buildmymcpserver.com): the slug + // is the first path segment. Has to be checked BEFORE the subdomain + // heuristic below, otherwise we extract "mcp" from "mcp.example.com//mcp" + // and look up the wrong server. Claude Desktop's RFC 8707 resource parameter + // matches the `resource` field we publish in RFC 9728 protected resource + // metadata, which is the path-routed MCP endpoint URL. + const firstSegment = url.pathname.split('/').filter(Boolean)[0]; + if (firstSegment) { + const [s] = await db + .select() + .from(mcpServers) + .where(eq(mcpServers.slug, firstSegment)) + .limit(1); + if (s) return s; + } + // Subdomain routing — legacy / future .mcp.example.com setup. const slug = url.hostname.split('.')[0]; if (slug && slug !== 'mcp') { @@ -119,6 +120,7 @@ export async function oauthRoutes(app: FastifyInstance): Promise { 'none', ], scopes_supported: ['mcp:read', 'mcp:write'], + resource_parameter_supported: true, }; }; const asMetadataHandler = async (_req: unknown, reply: { send: (body: unknown) => unknown }) => @@ -439,4 +441,3 @@ export async function oauthRoutes(app: FastifyInstance): Promise { }); }); } - diff --git a/apps/generator/src/lib/render.ts b/apps/generator/src/lib/render.ts index bb92400..179a104 100644 --- a/apps/generator/src/lib/render.ts +++ b/apps/generator/src/lib/render.ts @@ -59,9 +59,24 @@ import Fastify from 'fastify'; import { createRemoteJWKSet, jwtVerify } from 'jose'; import { randomUUID } from 'node:crypto'; -const PUBLIC_URL = process.env.PUBLIC_URL ?? 'http://localhost:3000'; -const CONTROL_PLANE_URL = process.env.CONTROL_PLANE_URL ?? 'http://host.docker.internal:4000'; -const OAUTH_ISSUER = process.env.OAUTH_ISSUER ?? CONTROL_PLANE_URL + '/oauth'; +function stripTrailingSlash(value) { + return value.replace(/\\/$/, ''); +} + +function protectedResourceMetadataUrl(resourceUrl) { + const url = new URL(resourceUrl); + const resourcePath = url.pathname === '/' ? '' : url.pathname; + url.pathname = '/.well-known/oauth-protected-resource' + resourcePath; + url.hash = ''; + return url.toString(); +} + +const PUBLIC_URL = stripTrailingSlash(process.env.PUBLIC_URL ?? 'http://localhost:3000'); +const CONTROL_PLANE_URL = stripTrailingSlash(process.env.CONTROL_PLANE_URL ?? 'http://host.docker.internal:4000'); +const OAUTH_ISSUER = stripTrailingSlash(process.env.OAUTH_ISSUER ?? CONTROL_PLANE_URL + '/oauth'); +const MCP_RESOURCE_URL = PUBLIC_URL + '/mcp'; +const PROTECTED_RESOURCE_METADATA_URL = protectedResourceMetadataUrl(MCP_RESOURCE_URL); +const EXPECTED_AUDIENCES = Array.from(new Set([MCP_RESOURCE_URL, PUBLIC_URL])); const PORT = Number.parseInt(process.env.PORT ?? '3000', 10); const server = new McpServer( @@ -75,15 +90,18 @@ const app = Fastify({ logger: { level: 'info' } }); app.get('/health', async () => ({ ok: true })); -app.get('/.well-known/oauth-protected-resource', async () => ({ - resource: PUBLIC_URL, +const protectedResourceMetadata = async () => ({ + resource: MCP_RESOURCE_URL, authorization_servers: [OAUTH_ISSUER], bearer_methods_supported: ['header'], scopes_supported: ${JSON.stringify(spec.scopes)}, -})); +}); + +app.get('/.well-known/oauth-protected-resource', protectedResourceMetadata); +app.get('/.well-known/oauth-protected-resource/*', protectedResourceMetadata); app.get('/.well-known/oauth-authorization-server', async () => { - const r = await fetch(CONTROL_PLANE_URL + '/oauth/.well-known/oauth-authorization-server'); + const r = await fetch(CONTROL_PLANE_URL + '/.well-known/oauth-authorization-server/oauth'); return await r.json(); }); @@ -96,16 +114,17 @@ app.all('/mcp', async (request, reply) => { if (!auth || !auth.startsWith('Bearer ')) { return reply .code(401) - .header('WWW-Authenticate', \`Bearer resource_metadata="\${PUBLIC_URL}/.well-known/oauth-protected-resource"\`) + .header('WWW-Authenticate', \`Bearer resource_metadata="\${PROTECTED_RESOURCE_METADATA_URL}"\`) .send({ error: 'unauthorized' }); } const token = auth.slice(7); try { const { payload } = await jwtVerify(token, JWKS, { issuer: OAUTH_ISSUER, - audience: PUBLIC_URL, + audience: EXPECTED_AUDIENCES, }); - if (payload.aud !== PUBLIC_URL) { + const audiences = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : []; + if (!audiences.some((aud) => EXPECTED_AUDIENCES.includes(aud))) { return reply.code(403).send({ error: 'invalid_audience' }); } } catch (e) { diff --git a/apps/web/app/docs/api-reference/page.tsx b/apps/web/app/docs/api-reference/page.tsx index 5121752..747b65e 100644 --- a/apps/web/app/docs/api-reference/page.tsx +++ b/apps/web/app/docs/api-reference/page.tsx @@ -60,7 +60,7 @@ export default function ApiReference() { OAuth (clients of generated servers, not dashboard) - GET /oauth/.well-known/oauth-authorization-server — RFC 8414 metadata. + GET /.well-known/oauth-authorization-server/oauth — RFC 8414 metadata. GET /oauth/jwks — RS256 public key for verifying access tokens. POST /oauth/register — RFC 7591 dynamic client registration. diff --git a/apps/web/app/docs/oauth/page.tsx b/apps/web/app/docs/oauth/page.tsx index 817155c..25bf7d1 100644 --- a/apps/web/app/docs/oauth/page.tsx +++ b/apps/web/app/docs/oauth/page.tsx @@ -24,8 +24,8 @@ export default function OAuthDocs() { Standards we follow OAuth 2.1 draft (draft-ietf-oauth-v2-1) — no implicit, mandatory PKCE - RFC 8414 — Authorization Server Metadata at /.well-known/oauth-authorization-server - RFC 9728 — Protected Resource Metadata at /.well-known/oauth-protected-resource + RFC 8414 — Authorization Server Metadata at /.well-known/oauth-authorization-server/oauth + RFC 9728 — Protected Resource Metadata at /.well-known/oauth-protected-resource/<server-path> RFC 8707 — Resource Indicators (audience binding) RFC 7591 — Dynamic Client Registration @@ -41,7 +41,7 @@ export default function OAuthDocs() { code={`$ curl -i http://localhost:4103/mcp -d '{}' -H 'content-type: application/json' HTTP/1.1 401 Unauthorized -www-authenticate: Bearer resource_metadata="http://localhost:4103/.well-known/oauth-protected-resource" +www-authenticate: Bearer resource_metadata="http://localhost:4103/.well-known/oauth-protected-resource/mcp" content-type: application/json {"error":"unauthorized"}`} @@ -53,10 +53,10 @@ content-type: application/json Subsequent /mcp calls carry the JWT. The runner verifies the signature against the AS's JWKS, checks the iss, the aud - (RFC 8707 — must match the runner's own public URL), and the expiry. No token + (RFC 8707 — must match the runner's MCP resource URL), and the expiry. No token passthrough; the runner never forwards the client's token to a downstream API. diff --git a/scripts/bmm-mcp-runners.nginx b/scripts/bmm-mcp-runners.nginx new file mode 100644 index 0000000..43f218b --- /dev/null +++ b/scripts/bmm-mcp-runners.nginx @@ -0,0 +1,80 @@ +# BMM per-runner proxy via PATH routing on mcp.buildmymcpserver.com. +# The combined snippet file (regenerated by the bmm-api and bmm-generator +# containers + reloaded by the systemd inotify watcher) is a sequence of +# `if ($bmm_slug = "") { set $bmm_port ; }` lines that map the +# slug captured from the URL path to the local runner port. + +server { + listen 80; + listen [::]:80; + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name mcp.buildmymcpserver.com; + + ssl_certificate /etc/ssl/buildmymcpserver/mcp-runners.crt; + ssl_certificate_key /etc/ssl/buildmymcpserver/mcp-runners.key; + + client_max_body_size 4M; + + # Cheap health probe for monitoring — doesn't go through the slug router. + location = /health { + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + # RFC 9728 for path-routed resources: + # //mcp derives metadata at /.well-known/oauth-protected-resource//mcp. + # Route that well-known URL back to the same runner while preserving only the + # resource sub-path after the slug. + location ~ ^/\.well-known/oauth-protected-resource/(?[a-z0-9][a-z0-9-]*)(?/.*)?$ { + set $bmm_port ""; + include /opt/buildmymcpserver/runner-map.combined; + + if ($bmm_port = "") { + return 404; + } + + proxy_pass http://127.0.0.1:$bmm_port/.well-known/oauth-protected-resource$bmm_path; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 600s; + } + + # // → 127.0.0.1:/ + location ~ ^/(?[a-z0-9][a-z0-9-]*)(?/.*)?$ { + set $bmm_port ""; + include /opt/buildmymcpserver/runner-map.combined; + + # Unknown slug — return 404 instead of a confusing default. + if ($bmm_port = "") { + return 404; + } + + # Default sub-path to / when client asked for / with no trailing slash. + set $bmm_target_path $bmm_path; + if ($bmm_target_path = "") { + set $bmm_target_path "/"; + } + + proxy_pass http://127.0.0.1:$bmm_port$bmm_target_path; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + # MCP uses Streamable HTTP — disable buffering so response chunks flow. + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 600s; + } + + # Root of mcp.buildmymcpserver.com — nothing to serve here. + location = / { + return 404; + } +} diff --git a/scripts/setup-runner-tls.sh b/scripts/setup-runner-tls.sh index 310e2cd..de0e0f6 100644 --- a/scripts/setup-runner-tls.sh +++ b/scripts/setup-runner-tls.sh @@ -105,6 +105,29 @@ server { add_header Content-Type text/plain; } + # RFC 9728 for path-routed resources: + # //mcp derives metadata at /.well-known/oauth-protected-resource//mcp. + # Route that well-known URL back to the same runner while preserving only the + # resource sub-path after the slug. + location ~ ^/\.well-known/oauth-protected-resource/(?[a-z0-9][a-z0-9-]*)(?/.*)?$ { + set $bmm_port ""; + include /opt/buildmymcpserver/runner-map.combined; + + if ($bmm_port = "") { + return 404; + } + + proxy_pass http://127.0.0.1:$bmm_port/.well-known/oauth-protected-resource$bmm_path; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 600s; + } + # // → 127.0.0.1:/ location ~ ^/(?[a-z0-9][a-z0-9-]*)(?/.*)?$ { set $bmm_port "";