fix: live-run wiring (SDK 1.29, zod 3.25, OAUTH_ISSUER split, alt host ports, web on 3001, log level cast, pino transport)

- Bump @modelcontextprotocol/sdk from 1.0.4 to 1.29.0 in runner-template
  (1.0.4 has no McpServer or StreamableHTTPServerTransport — file not found at runtime).
- Bump zod to 3.25.76 across workspace to satisfy modern SDK peer dep.
- Split OAUTH_ISSUER (canonical, host-reachable) from CONTROL_PLANE_URL (container-reachable for JWKS).
  Runner verifies iss against OAUTH_ISSUER; fetches JWKS from CONTROL_PLANE_URL.
  Both API and runner now agree on http://localhost:4000/oauth as the issuer in dev.
- Move postgres host port 5432 to 5440, redis 6379 to 6390 to avoid collisions with
  native installs on the dev machine.
- Move web from 3000 to 3001 (3000 occupied by Gitea on dev machine).
- Drop pino-pretty transport from API to avoid runtime require of an unbundled dep.
- Cast build_logs.level (varchar) to BuildEvent's literal union in WS replay path.
- Remove unused reqBase helper in oauth.ts.
This commit is contained in:
Marco Sadjadi 2026-05-19 00:57:23 +02:00
parent ea1ec1e801
commit ab67203921
18 changed files with 3747 additions and 41 deletions

View File

@ -2,13 +2,13 @@
NODE_ENV=development NODE_ENV=development
# ---- Database ---- # ---- Database ----
DATABASE_URL=postgresql://bmm:bmm@localhost:5432/bmm DATABASE_URL=postgresql://bmm:bmm@localhost:5440/bmm
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6390
# ---- Auth (Better-Auth) ---- # ---- Auth (Better-Auth) ----
BETTER_AUTH_SECRET=replace-me-with-32-bytes-of-random-hex-1234567890abcdef BETTER_AUTH_SECRET=replace-me-with-32-bytes-of-random-hex-1234567890abcdef
BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_URL=http://localhost:3001
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:4000 NEXT_PUBLIC_API_URL=http://localhost:4000
# ---- GitHub OAuth (optional in dev) ---- # ---- GitHub OAuth (optional in dev) ----

View File

@ -21,7 +21,7 @@
"fastify": "5.2.0", "fastify": "5.2.0",
"ioredis": "5.4.1", "ioredis": "5.4.1",
"jose": "5.9.6", "jose": "5.9.6",
"zod": "3.23.8" "zod": "3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "22.10.2", "@types/node": "22.10.2",

View File

@ -5,13 +5,14 @@ const Env = z.object({
DATABASE_URL: z.string(), DATABASE_URL: z.string(),
REDIS_URL: z.string().default('redis://localhost:6379'), REDIS_URL: z.string().default('redis://localhost:6379'),
PORT: z.coerce.number().default(4000), PORT: z.coerce.number().default(4000),
NEXT_PUBLIC_APP_URL: z.string().default('http://localhost:3000'), NEXT_PUBLIC_APP_URL: z.string().default('http://localhost:3001'),
OAUTH_KEY_DIR: z.string().default('./keys'), OAUTH_KEY_DIR: z.string().default('./keys'),
ANTHROPIC_API_KEY: z.string().optional(), ANTHROPIC_API_KEY: z.string().optional(),
SECRETS_ENCRYPTION_KEY: z SECRETS_ENCRYPTION_KEY: z
.string() .string()
.min(64, '32 bytes hex required') .min(64, '32 bytes hex required')
.default('0000000000000000000000000000000000000000000000000000000000000000'), .default('0000000000000000000000000000000000000000000000000000000000000000'),
CONTROL_PLANE_PUBLIC_URL: z.string().default('http://localhost:4000'),
}); });
export const config = Env.parse({ export const config = Env.parse({

View File

@ -10,10 +10,6 @@ import { oauthRoutes } from './routes/oauth.js';
const app = Fastify({ const app = Fastify({
logger: { logger: {
level: config.NODE_ENV === 'production' ? 'info' : 'debug', level: config.NODE_ENV === 'production' ? 'info' : 'debug',
transport:
config.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true, singleLine: true } }
: undefined,
}, },
}); });

View File

@ -46,7 +46,7 @@ async function resolveServerByResource(resource: string) {
export async function oauthRoutes(app: FastifyInstance): Promise<void> { export async function oauthRoutes(app: FastifyInstance): Promise<void> {
// Authorization Server Metadata (RFC 8414) — control-plane wide // Authorization Server Metadata (RFC 8414) — control-plane wide
app.get('/oauth/.well-known/oauth-authorization-server', async (_req, reply) => { app.get('/oauth/.well-known/oauth-authorization-server', async (_req, reply) => {
const base = `${reqBase(_req)}`; const base = `${config.CONTROL_PLANE_PUBLIC_URL}`;
return reply.send({ return reply.send({
issuer: `${base}/oauth`, issuer: `${base}/oauth`,
authorization_endpoint: `${base}/oauth/authorize`, authorization_endpoint: `${base}/oauth/authorize`,
@ -215,7 +215,7 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
const accessToken = await signAccessToken({ const accessToken = await signAccessToken({
subject: row.code.userId ?? row.client.clientId, subject: row.code.userId ?? row.client.clientId,
audience: resource, audience: resource,
issuer: `${reqBase(req)}/oauth`, issuer: `${config.CONTROL_PLANE_PUBLIC_URL}/oauth`,
scope: row.code.scope ?? '', scope: row.code.scope ?? '',
ttlSeconds: 3600, ttlSeconds: 3600,
}); });
@ -249,7 +249,7 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' }); if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
const server = await resolveServerByResource(parsed.data.resource); const server = await resolveServerByResource(parsed.data.resource);
if (!server) return reply.code(404).send({ error: 'not_found' }); if (!server) return reply.code(404).send({ error: 'not_found' });
const base = reqBase(req); const base = config.CONTROL_PLANE_PUBLIC_URL;
return reply.send({ return reply.send({
resource: parsed.data.resource, resource: parsed.data.resource,
authorization_servers: [`${base}/oauth`], authorization_servers: [`${base}/oauth`],
@ -259,12 +259,3 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
}); });
} }
function reqBase(req: { protocol?: string; headers: Record<string, string | string[] | undefined> }): string {
const host =
(req.headers['x-forwarded-host'] as string | undefined) ??
(req.headers.host as string | undefined) ??
`localhost:${config.PORT}`;
const proto =
(req.headers['x-forwarded-proto'] as string | undefined) ?? req.protocol ?? 'http';
return `${proto}://${host}`;
}

View File

@ -183,10 +183,12 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
.where(eq(buildLogs.buildId, buildId)) .where(eq(buildLogs.buildId, buildId))
.orderBy(buildLogs.timestamp); .orderBy(buildLogs.timestamp);
for (const log of logs) { for (const log of logs) {
const level: 'info' | 'warn' | 'error' =
log.level === 'warn' || log.level === 'error' ? log.level : 'info';
socket.send( socket.send(
JSON.stringify({ JSON.stringify({
type: 'log', type: 'log',
level: log.level, level,
message: log.message, message: log.message,
at: log.timestamp.toISOString(), at: log.timestamp.toISOString(),
} satisfies BuildEvent), } satisfies BuildEvent),

View File

@ -16,7 +16,7 @@
"bullmq": "5.34.5", "bullmq": "5.34.5",
"drizzle-orm": "0.36.4", "drizzle-orm": "0.36.4",
"ioredis": "5.4.1", "ioredis": "5.4.1",
"zod": "3.23.8" "zod": "3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "22.10.2", "@types/node": "22.10.2",

View File

@ -9,6 +9,7 @@ const Env = z.object({
RUNNER_PORT_RANGE_END: z.coerce.number().default(4999), RUNNER_PORT_RANGE_END: z.coerce.number().default(4999),
CONTROL_PLANE_URL: z.string().default('http://host.docker.internal:4000'), CONTROL_PLANE_URL: z.string().default('http://host.docker.internal:4000'),
CONTROL_PLANE_PUBLIC_URL: z.string().default('http://localhost:4000'), CONTROL_PLANE_PUBLIC_URL: z.string().default('http://localhost:4000'),
OAUTH_ISSUER: z.string().optional(),
MODEL_GENERATE: z.string().default('claude-opus-4-7'), MODEL_GENERATE: z.string().default('claude-opus-4-7'),
MODEL_FIX: z.string().default('claude-haiku-4-5-20251001'), MODEL_FIX: z.string().default('claude-haiku-4-5-20251001'),
}); });

View File

@ -61,6 +61,7 @@ import { randomUUID } from 'node:crypto';
const PUBLIC_URL = process.env.PUBLIC_URL ?? 'http://localhost:3000'; 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 CONTROL_PLANE_URL = process.env.CONTROL_PLANE_URL ?? 'http://host.docker.internal:4000';
const OAUTH_ISSUER = process.env.OAUTH_ISSUER ?? CONTROL_PLANE_URL + '/oauth';
const PORT = Number.parseInt(process.env.PORT ?? '3000', 10); const PORT = Number.parseInt(process.env.PORT ?? '3000', 10);
const server = new McpServer( const server = new McpServer(
@ -76,7 +77,7 @@ app.get('/health', async () => ({ ok: true }));
app.get('/.well-known/oauth-protected-resource', async () => ({ app.get('/.well-known/oauth-protected-resource', async () => ({
resource: PUBLIC_URL, resource: PUBLIC_URL,
authorization_servers: [CONTROL_PLANE_URL + '/oauth'], authorization_servers: [OAUTH_ISSUER],
bearer_methods_supported: ['header'], bearer_methods_supported: ['header'],
scopes_supported: ${JSON.stringify(spec.scopes)}, scopes_supported: ${JSON.stringify(spec.scopes)},
})); }));
@ -101,7 +102,7 @@ app.all('/mcp', async (request, reply) => {
const token = auth.slice(7); const token = auth.slice(7);
try { try {
const { payload } = await jwtVerify(token, JWKS, { const { payload } = await jwtVerify(token, JWKS, {
issuer: CONTROL_PLANE_URL + '/oauth', issuer: OAUTH_ISSUER,
audience: PUBLIC_URL, audience: PUBLIC_URL,
}); });
if (payload.aud !== PUBLIC_URL) { if (payload.aud !== PUBLIC_URL) {

View File

@ -79,6 +79,7 @@ export const worker = new Worker<JobData>(
...secrets, ...secrets,
PUBLIC_URL: publicUrl, PUBLIC_URL: publicUrl,
CONTROL_PLANE_URL: config.CONTROL_PLANE_URL, CONTROL_PLANE_URL: config.CONTROL_PLANE_URL,
OAUTH_ISSUER: `${config.CONTROL_PLANE_PUBLIC_URL}/oauth`,
PORT: '3000', PORT: '3000',
SERVER_ID: serverId, SERVER_ID: serverId,
}; };

View File

@ -7,10 +7,10 @@
"start": "tsx src/server.ts" "start": "tsx src/server.ts"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "1.0.4", "@modelcontextprotocol/sdk": "1.29.0",
"fastify": "5.2.0", "fastify": "5.2.0",
"jose": "5.9.6", "jose": "5.9.6",
"zod": "3.23.8" "zod": "3.25.76"
}, },
"devDependencies": { "devDependencies": {
"tsx": "4.19.2", "tsx": "4.19.2",

View File

@ -7,7 +7,7 @@ export const metadata: Metadata = {
title: 'BuildMyMCPServer — Describe your tool. We host the server.', title: 'BuildMyMCPServer — Describe your tool. We host the server.',
description: description:
'From prompt to production MCP server in 60 seconds. OAuth 2.1, Streamable HTTP, ready for Claude, Cursor & ChatGPT.', 'From prompt to production MCP server in 60 seconds. OAuth 2.1, Streamable HTTP, ready for Claude, Cursor & ChatGPT.',
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'), metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3001'),
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {

View File

@ -1,2 +1,5 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -4,9 +4,9 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --port 3000", "dev": "next dev --port 3001",
"build": "next build", "build": "next build",
"start": "next start --port 3000", "start": "next start --port 3001",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@ -18,7 +18,7 @@
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"tailwind-merge": "2.5.5", "tailwind-merge": "2.5.5",
"zod": "3.23.8" "zod": "3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "4.0.0-beta.7", "@tailwindcss/postcss": "4.0.0-beta.7",

View File

@ -2,16 +2,34 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"jsx": "preserve", "jsx": "preserve",
"lib": ["dom", "dom.iterable", "es2022"], "lib": [
"dom",
"dom.iterable",
"es2022"
],
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"noEmit": true, "noEmit": true,
"incremental": true, "incremental": true,
"plugins": [{ "name": "next" }], "plugins": [
{
"name": "next"
}
],
"paths": { "paths": {
"@/*": ["./*"] "@/*": [
} "./*"
]
},
"allowJs": true
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }

View File

@ -7,7 +7,7 @@ services:
POSTGRES_PASSWORD: bmm POSTGRES_PASSWORD: bmm
POSTGRES_DB: bmm POSTGRES_DB: bmm
ports: ports:
- "5432:5432" - "5440:5432"
volumes: volumes:
- bmm_pg:/var/lib/postgresql/data - bmm_pg:/var/lib/postgresql/data
healthcheck: healthcheck:
@ -20,7 +20,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
container_name: bmm-redis container_name: bmm-redis
ports: ports:
- "6379:6379" - "6390:6379"
volumes: volumes:
- bmm_redis:/data - bmm_redis:/data
healthcheck: healthcheck:

View File

@ -12,7 +12,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"zod": "3.23.8" "zod": "3.25.76"
}, },
"devDependencies": { "devDependencies": {
"typescript": "5.7.2" "typescript": "5.7.2"

3692
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff