diff --git a/README.md b/README.md index a31bbdb..22a7139 100644 --- a/README.md +++ b/README.md @@ -2,45 +2,96 @@ > Describe your tool. We host the server. AI uses it. -Prompt-to-production MCP servers with OAuth 2.1 and Streamable HTTP. +Prompt-to-production MCP servers with OAuth 2.1 and Streamable HTTP. Production-grade +infrastructure for hosting Model Context Protocol servers your AI clients (Claude +Desktop, Cursor, ChatGPT) can install with a copy-paste snippet. -## Run locally +## Quick start ```bash -# 1. Install deps +# 1. Install pnpm install -# 2. Copy env and fill ANTHROPIC_API_KEY if you want real generation +# 2. Copy env. Defaults work for local dev. Set ANTHROPIC_API_KEY if you want real generation. cp .env.example .env -# 3. Boot everything (postgres, redis, web, api, generator) +# 3. Boot everything pnpm dev ``` +`pnpm dev` will: +1. Load `.env`. +2. `docker compose up -d --wait` postgres + redis. +3. Push the Drizzle schema (`drizzle-kit push --force`). +4. Start the full stack in parallel: web (Next.js, :3000), api (Fastify, :4000), + generator (BullMQ worker). + Then open: - Dashboard: -- API: +- API: -Sign in with the magic link printed to the API stdout, click **New Server**, paste a -prompt, watch the build stream live over WebSocket. +Click **Start building**, enter your email, copy the magic-link URL printed to the +**api** terminal output, paste it in your browser. You land on `/dashboard`. Click +**New server**, paste a prompt, and watch the build stream live over WebSocket. + +If `ANTHROPIC_API_KEY` is unset, the generator returns a deterministic mock spec +(an `echo` and a `now` tool) so the full end-to-end flow stays demoable. + +If Docker is unavailable, the build will fail at the deploy step with a clear error. +Otherwise: a fresh container is launched on a host port from +`RUNNER_PORT_RANGE_START…RUNNER_PORT_RANGE_END`, the server is marked `live`, and the +dashboard renders install snippets for Claude Desktop, Cursor and ChatGPT. ## Architecture -See `BuildMyMCPServer_MASTER_PROMPT.md` for the full spec and `CHOICES.md` for the -decisions made during Sprints 1–3. - -## Workspace layout +See `BuildMyMCPServer_MASTER_PROMPT.md` for the full specification and `CHOICES.md` +for decisions made during this Sprints 1–3 build. ``` apps/ - web/ Next.js 15 dashboard + marketing - api/ Fastify control plane - generator/ BullMQ worker — Claude → build → deploy - runner-template/ Hosted MCP server template + web/ Next.js 15 dashboard + marketing landing + api/ Fastify control plane (auth, server CRUD, OAuth 2.1 AS, JWKS, WS stream) + generator/ BullMQ worker — Claude → spec → render → docker build → local deploy + runner-template/ Hosted MCP server template (Streamable HTTP + OAuth 2.1 RS) packages/ db/ Drizzle schema + client - auth/ Better-Auth wrapper - types/ Shared Zod types - ui/ Shared React primitives + auth/ Magic-link + session + types/ Shared Zod contracts ``` + +## Scripts + +| Command | Effect | +| ------------------ | ------------------------------------------------------------- | +| `pnpm dev` | Bootstrap + parallel dev for web, api, generator | +| `pnpm dev:no-docker` | Skip docker-compose (assumes postgres + redis already up) | +| `pnpm build` | Turbo build all apps | +| `pnpm typecheck` | Turbo typecheck all apps | +| `pnpm lint` | Biome check | +| `pnpm lint:fix` | Biome check --write | +| `pnpm db:push` | Push schema to postgres (drizzle-kit) | +| `pnpm db:generate` | Generate SQL migration files | +| `pnpm db:migrate` | Apply pending migrations | +| `pnpm stop` | docker compose down | + +## Acceptance check + +After `pnpm dev` is up: + +- [x] `http://localhost:3000` renders the landing page. +- [x] `http://localhost:4000/health` returns `{ "ok": true }`. +- [x] Sign in via magic link (URL printed in the api terminal). +- [x] New Server → paste prompt → live WebSocket stream `queued → generating → building → deploying → live`. +- [x] If Docker is running, a container is launched and `http://localhost:/mcp` + responds **401 + WWW-Authenticate** without a token, **200** with a valid token + issued by `/oauth/token`. +- [x] Install snippets render with copy buttons for Claude Desktop, Cursor, ChatGPT. + +## Repo conventions + +- TypeScript strict, zero `any` (Biome lints `noExplicitAny` as error). +- ESM-only, Node 20 LTS. +- Conventional commits. +- Tailwind v4 (`@import 'tailwindcss'`). +- Geist + Geist Mono. diff --git a/apps/api/src/routes/oauth.ts b/apps/api/src/routes/oauth.ts index b62fd65..8c8b43b 100644 --- a/apps/api/src/routes/oauth.ts +++ b/apps/api/src/routes/oauth.ts @@ -46,7 +46,7 @@ async function resolveServerByResource(resource: string) { export async function oauthRoutes(app: FastifyInstance): Promise { // Authorization Server Metadata (RFC 8414) — control-plane wide app.get('/oauth/.well-known/oauth-authorization-server', async (_req, reply) => { - const base = `${req_base(_req as never)}`; + const base = `${reqBase(_req)}`; return reply.send({ issuer: `${base}/oauth`, authorization_endpoint: `${base}/oauth/authorize`, @@ -215,7 +215,7 @@ export async function oauthRoutes(app: FastifyInstance): Promise { const accessToken = await signAccessToken({ subject: row.code.userId ?? row.client.clientId, audience: resource, - issuer: `${req_base(req as never)}/oauth`, + issuer: `${reqBase(req)}/oauth`, scope: row.code.scope ?? '', ttlSeconds: 3600, }); @@ -249,7 +249,7 @@ export async function oauthRoutes(app: FastifyInstance): Promise { if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' }); const server = await resolveServerByResource(parsed.data.resource); if (!server) return reply.code(404).send({ error: 'not_found' }); - const base = req_base(req as never); + const base = reqBase(req); return reply.send({ resource: parsed.data.resource, authorization_servers: [`${base}/oauth`], @@ -261,8 +261,12 @@ export async function oauthRoutes(app: FastifyInstance): Promise { void config; } -function req_base(req: { protocol?: string; hostname?: string; headers: Record }): string { - const host = (req.headers['x-forwarded-host'] as string) ?? req.headers.host ?? `localhost:${config.PORT}`; - const proto = (req.headers['x-forwarded-proto'] as string) ?? req.protocol ?? 'http'; +function reqBase(req: { protocol?: string; headers: Record }): 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}`; } diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 2277d85..cc6f583 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,6 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + transpilePackages: ['@bmm/types'], experimental: { typedRoutes: false, }, diff --git a/package.json b/package.json index 1f682df..415b373 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "node": ">=20.11.0" }, "scripts": { - "dev": "docker compose up -d postgres redis && turbo run dev --parallel", + "dev": "node scripts/dev-bootstrap.mjs", "dev:no-docker": "turbo run dev --parallel", + "dev:turbo": "turbo run dev --parallel", "build": "turbo run build", "typecheck": "turbo run typecheck", "lint": "biome check .", diff --git a/scripts/dev-bootstrap.mjs b/scripts/dev-bootstrap.mjs new file mode 100644 index 0000000..a596bce --- /dev/null +++ b/scripts/dev-bootstrap.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// Boots postgres + redis, waits for healthy, then runs drizzle push and exits. +// Used by `pnpm dev` before turbo takes over. + +import { spawnSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Load .env at repo root so children inherit it. +const envPath = path.resolve(process.cwd(), '.env'); +if (fs.existsSync(envPath)) { + for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) { + const m = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/); + if (!m) continue; + let value = m[2]; + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!(m[1] in process.env)) process.env[m[1]] = value; + } + console.log('[bootstrap] loaded .env'); +} else { + console.warn('[bootstrap] no .env at repo root — copy .env.example to .env'); +} + +function run(cmd, args, opts = {}) { + return spawnSync(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32', ...opts }); +} + +console.log('[bootstrap] starting docker compose services...'); +const up = run('docker', ['compose', 'up', '-d', '--wait', 'postgres', 'redis']); +if (up.status !== 0) { + console.error('[bootstrap] docker compose failed. Is Docker Desktop running?'); + process.exit(up.status ?? 1); +} + +console.log('[bootstrap] pushing schema via drizzle-kit...'); +const push = run('pnpm', ['--filter', '@bmm/db', 'exec', 'drizzle-kit', 'push', '--force']); +if (push.status !== 0) { + console.warn('[bootstrap] drizzle-kit push --force failed; falling back to interactive push'); + const push2 = run('pnpm', ['--filter', '@bmm/db', 'exec', 'drizzle-kit', 'push']); + if (push2.status !== 0) { + console.error('[bootstrap] schema push failed.'); + process.exit(push2.status ?? 1); + } +} + +console.log('[bootstrap] done. starting turbo dev...'); +const turbo = spawn('pnpm', ['turbo', 'run', 'dev', '--parallel'], { + stdio: 'inherit', + shell: process.platform === 'win32', +}); +turbo.on('exit', (code) => process.exit(code ?? 0)); +process.on('SIGINT', () => turbo.kill('SIGINT')); +process.on('SIGTERM', () => turbo.kill('SIGTERM'));