chore(dev): bootstrap script wires docker + drizzle push + turbo dev
This commit is contained in:
parent
b07de86db6
commit
648427000d
89
README.md
89
README.md
@ -2,45 +2,96 @@
|
|||||||
|
|
||||||
> Describe your tool. We host the server. AI uses it.
|
> 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
|
```bash
|
||||||
# 1. Install deps
|
# 1. Install
|
||||||
pnpm 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
|
cp .env.example .env
|
||||||
|
|
||||||
# 3. Boot everything (postgres, redis, web, api, generator)
|
# 3. Boot everything
|
||||||
pnpm dev
|
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:
|
Then open:
|
||||||
|
|
||||||
- Dashboard: <http://localhost:3000>
|
- Dashboard: <http://localhost:3000>
|
||||||
- API: <http://localhost:4000>
|
- API: <http://localhost:4000/health>
|
||||||
|
|
||||||
Sign in with the magic link printed to the API stdout, click **New Server**, paste a
|
Click **Start building**, enter your email, copy the magic-link URL printed to the
|
||||||
prompt, watch the build stream live over WebSocket.
|
**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
|
## Architecture
|
||||||
|
|
||||||
See `BuildMyMCPServer_MASTER_PROMPT.md` for the full spec and `CHOICES.md` for the
|
See `BuildMyMCPServer_MASTER_PROMPT.md` for the full specification and `CHOICES.md`
|
||||||
decisions made during Sprints 1–3.
|
for decisions made during this Sprints 1–3 build.
|
||||||
|
|
||||||
## Workspace layout
|
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/
|
apps/
|
||||||
web/ Next.js 15 dashboard + marketing
|
web/ Next.js 15 dashboard + marketing landing
|
||||||
api/ Fastify control plane
|
api/ Fastify control plane (auth, server CRUD, OAuth 2.1 AS, JWKS, WS stream)
|
||||||
generator/ BullMQ worker — Claude → build → deploy
|
generator/ BullMQ worker — Claude → spec → render → docker build → local deploy
|
||||||
runner-template/ Hosted MCP server template
|
runner-template/ Hosted MCP server template (Streamable HTTP + OAuth 2.1 RS)
|
||||||
packages/
|
packages/
|
||||||
db/ Drizzle schema + client
|
db/ Drizzle schema + client
|
||||||
auth/ Better-Auth wrapper
|
auth/ Magic-link + session
|
||||||
types/ Shared Zod types
|
types/ Shared Zod contracts
|
||||||
ui/ Shared React primitives
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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:<port>/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.
|
||||||
|
|||||||
@ -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 = `${req_base(_req as never)}`;
|
const base = `${reqBase(_req)}`;
|
||||||
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: `${req_base(req as never)}/oauth`,
|
issuer: `${reqBase(req)}/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 = req_base(req as never);
|
const base = reqBase(req);
|
||||||
return reply.send({
|
return reply.send({
|
||||||
resource: parsed.data.resource,
|
resource: parsed.data.resource,
|
||||||
authorization_servers: [`${base}/oauth`],
|
authorization_servers: [`${base}/oauth`],
|
||||||
@ -261,8 +261,12 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
void config;
|
void config;
|
||||||
}
|
}
|
||||||
|
|
||||||
function req_base(req: { protocol?: string; hostname?: string; headers: Record<string, string | string[] | undefined> }): string {
|
function reqBase(req: { protocol?: string; headers: Record<string, string | string[] | undefined> }): string {
|
||||||
const host = (req.headers['x-forwarded-host'] as string) ?? req.headers.host ?? `localhost:${config.PORT}`;
|
const host =
|
||||||
const proto = (req.headers['x-forwarded-proto'] as string) ?? req.protocol ?? 'http';
|
(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}`;
|
return `${proto}://${host}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
transpilePackages: ['@bmm/types'],
|
||||||
experimental: {
|
experimental: {
|
||||||
typedRoutes: false,
|
typedRoutes: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,8 +7,9 @@
|
|||||||
"node": ">=20.11.0"
|
"node": ">=20.11.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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:no-docker": "turbo run dev --parallel",
|
||||||
|
"dev:turbo": "turbo run dev --parallel",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
|
|||||||
55
scripts/dev-bootstrap.mjs
Normal file
55
scripts/dev-bootstrap.mjs
Normal file
@ -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'));
|
||||||
Loading…
Reference in New Issue
Block a user