Files
agent-fox/docs/superpowers/plans/2026-04-02-agent-fox-implementation.md

103 KiB

Agent Fox Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a SaaS product that serves API documentation to LLMs via MCP with multi-level retrieval to minimize token consumption.

Architecture: pnpm monorepo with 4 packages (web, server, mcp, shared). Server and MCP are independent Express processes sharing PostgreSQL via Prisma. Frontend is React SPA. All services containerized with Docker Compose.

Tech Stack: TypeScript, React 19, Vite, TailwindCSS, shadcn/ui, Express, Prisma, PostgreSQL, @modelcontextprotocol/server v2, @apidevtools/swagger-parser, JWT + Passport.js OAuth, Docker Compose.

Spec: docs/superpowers/specs/2026-04-02-agent-fox-design.md


Phase 1: Project Scaffold & Shared Package

Task 1: Initialize monorepo and workspace

Files:

  • Create: package.json

  • Create: pnpm-workspace.yaml

  • Create: tsconfig.base.json

  • Create: .gitignore

  • Create: .env.example

  • Create: packages/shared/package.json

  • Create: packages/shared/tsconfig.json

  • Create: packages/server/package.json

  • Create: packages/server/tsconfig.json

  • Create: packages/mcp/package.json

  • Create: packages/mcp/tsconfig.json

  • Create: packages/web/package.json (via Vite scaffold)

  • Step 1: Initialize git repository

cd /Users/kid/Development/Fusion/Projects/agent-fox
git init
  • Step 2: Create root package.json
{
  "name": "agent-fox",
  "private": true,
  "scripts": {
    "dev:server": "pnpm --filter @agent-fox/server dev",
    "dev:mcp": "pnpm --filter @agent-fox/mcp dev",
    "dev:web": "pnpm --filter @agent-fox/web dev",
    "build": "pnpm -r build",
    "db:generate": "pnpm --filter @agent-fox/shared db:generate",
    "db:migrate": "pnpm --filter @agent-fox/shared db:migrate",
    "db:push": "pnpm --filter @agent-fox/shared db:push"
  },
  "engines": {
    "node": ">=20"
  }
}
  • Step 3: Create pnpm-workspace.yaml
packages:
  - "packages/*"
  • Step 4: Create tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist"
  }
}
  • Step 5: Create .gitignore
node_modules/
dist/
.env
*.log
.DS_Store
  • Step 6: Create .env.example
DATABASE_URL=postgresql://agentfox:agentfox@localhost:5432/agentfox
JWT_SECRET=change-me-to-a-random-secret
JWT_REFRESH_SECRET=change-me-to-another-random-secret
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
MCP_BASE_URL=http://localhost:3001
SERVER_PORT=3000
MCP_PORT=3001
WEB_PORT=5173
REDIS_URL=redis://localhost:6379
  • Step 7: Create shared package

packages/shared/package.json:

{
  "name": "@agent-fox/shared",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "db:generate": "prisma generate",
    "db:migrate": "prisma migrate dev",
    "db:push": "prisma db push"
  },
  "dependencies": {
    "@prisma/client": "^6.0.0"
  },
  "devDependencies": {
    "prisma": "^6.0.0",
    "typescript": "^5.7.0"
  }
}

packages/shared/tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

packages/shared/src/index.ts:

export { prisma } from './db.js';
export type * from './types.js';

packages/shared/src/db.ts:

import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient();

packages/shared/src/types.ts:

// Shared types — will be populated as we build features
export type ApiResponse<T = unknown> = {
  success: boolean;
  data?: T;
  error?: {
    code: string;
    message: string;
  };
};
  • Step 8: Create server package skeleton

packages/server/package.json:

{
  "name": "@agent-fox/server",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "@agent-fox/shared": "workspace:*",
    "express": "^5.0.0",
    "cors": "^2.8.5",
    "zod": "^3.24.0"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/cors": "^2.8.17",
    "tsx": "^4.19.0",
    "typescript": "^5.7.0"
  }
}

packages/server/tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

packages/server/src/index.ts:

import express from 'express';
import cors from 'cors';

const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));

app.get('/api/health', (_req, res) => {
  res.json({ success: true, data: { status: 'ok' } });
});

const port = process.env.SERVER_PORT || 3000;
app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});
  • Step 9: Create MCP package skeleton

packages/mcp/package.json:

{
  "name": "@agent-fox/mcp",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "@agent-fox/shared": "workspace:*",
    "@modelcontextprotocol/server": "^1.12.0",
    "@modelcontextprotocol/express": "^0.1.0",
    "@modelcontextprotocol/node": "^0.1.0",
    "express": "^5.0.0",
    "cors": "^2.8.5",
    "zod": "^3.24.0"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/cors": "^2.8.17",
    "tsx": "^4.19.0",
    "typescript": "^5.7.0"
  }
}

packages/mcp/tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

packages/mcp/src/index.ts:

import express from 'express';
import cors from 'cors';

const app = express();
app.use(cors());
app.use(express.json());

app.get('/health', (_req, res) => {
  res.json({ status: 'ok' });
});

const port = process.env.MCP_PORT || 3001;
app.listen(port, () => {
  console.log(`MCP service running on port ${port}`);
});
  • Step 10: Scaffold React frontend with Vite
cd /Users/kid/Development/Fusion/Projects/agent-fox/packages
pnpm create vite web -- --template react-ts
cd web
# Install TailwindCSS and shadcn/ui dependencies (done in Task 2)
  • Step 11: Install all dependencies and verify
cd /Users/kid/Development/Fusion/Projects/agent-fox
pnpm install
pnpm --filter @agent-fox/server dev  # Should start on :3000
# Ctrl+C, verify /api/health returns {"success":true,"data":{"status":"ok"}}
  • Step 12: Commit
git add -A
git commit -m "feat: initialize monorepo with shared, server, mcp, and web packages"

Task 2: Prisma schema and database setup

Files:

  • Create: prisma/schema.prisma

  • Modify: packages/shared/src/index.ts

  • Modify: packages/shared/src/types.ts

  • Step 1: Create Prisma schema

prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id           String         @id @default(uuid())
  email        String         @unique
  passwordHash String?
  name         String
  avatarUrl    String?
  createdAt    DateTime       @default(now())
  updatedAt    DateTime       @updatedAt
  oauthAccounts OAuthAccount[]
  projects     Project[]
}

model OAuthAccount {
  id                String   @id @default(uuid())
  userId            String
  provider          String   // "github" | "google"
  providerAccountId String
  createdAt         DateTime @default(now())
  user              User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Project {
  id             String     @id @default(uuid())
  userId         String
  name           String
  description    String?
  baseUrl        String?
  openApiSpec    Json       // Full dereferenced OpenAPI document
  openApiVersion String
  apiKeyHash     String
  createdAt      DateTime   @default(now())
  updatedAt      DateTime   @updatedAt
  user           User       @relation(fields: [userId], references: [id], onDelete: Cascade)
  modules        Module[]
  endpoints      Endpoint[]
}

enum ModuleSource {
  tag
  path_prefix
  manual
}

model Module {
  id          String       @id @default(uuid())
  projectId   String
  name        String
  description String?
  sortOrder   Int          @default(0)
  source      ModuleSource
  createdAt   DateTime     @default(now())
  updatedAt   DateTime     @updatedAt
  project     Project      @relation(fields: [projectId], references: [id], onDelete: Cascade)
  endpoints   Endpoint[]

  @@index([projectId])
}

model Endpoint {
  id          String   @id @default(uuid())
  projectId   String
  moduleId    String
  method      String   // GET, POST, PUT, DELETE, PATCH, etc.
  path        String   // /api/users/{id}
  summary     String?
  description String?
  operationId String?
  parameters  Json     @default("[]")
  requestBody Json?
  responses   Json     @default("{}")
  tags        String[] @default([])
  deprecated  Boolean  @default(false)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  project     Project  @relation(fields: [projectId], references: [id], onDelete: Cascade)
  module      Module   @relation(fields: [moduleId], references: [id], onDelete: Cascade)

  @@index([projectId])
  @@index([moduleId])
  @@index([projectId, moduleId])
}
  • Step 2: Update shared package to re-export Prisma types

packages/shared/src/types.ts:

import type { User, Project, Module, Endpoint, ModuleSource } from '@prisma/client';

export type { User, Project, Module, Endpoint, ModuleSource };

export type ApiResponse<T = unknown> = {
  success: boolean;
  data?: T;
  error?: {
    code: string;
    message: string;
  };
};

export type ProjectWithStats = Project & {
  _count: { endpoints: number; modules: number };
};

export type ModuleWithCount = Module & {
  _count: { endpoints: number };
};

export type EndpointSummary = {
  id: string;
  method: string;
  path: string;
  summary: string | null;
  deprecated: boolean;
};

export type EndpointDetail = Endpoint & {
  moduleName: string;
};
  • Step 3: Create .env file from example and generate Prisma client
cd /Users/kid/Development/Fusion/Projects/agent-fox
cp .env.example .env
# Edit .env with correct DATABASE_URL if needed
pnpm --filter @agent-fox/shared db:generate
  • Step 4: Run database migration
pnpm --filter @agent-fox/shared db:migrate -- --name init

Expected: Migration created and applied. Tables User, OAuthAccount, Project, Module, Endpoint created.

  • Step 5: Verify by building shared package
pnpm --filter @agent-fox/shared build

Expected: Compiles without errors, packages/shared/dist/ created.

  • Step 6: Commit
git add prisma/ packages/shared/
git commit -m "feat: add Prisma schema with User, Project, Module, Endpoint models"

Phase 2: Backend API — Authentication

Task 3: JWT authentication middleware and auth routes

Files:

  • Create: packages/server/src/middleware/auth.ts

  • Create: packages/server/src/routes/auth.ts

  • Create: packages/server/src/lib/jwt.ts

  • Create: packages/server/src/lib/password.ts

  • Modify: packages/server/src/index.ts

  • Modify: packages/server/package.json (add bcrypt, jsonwebtoken deps)

  • Step 1: Add auth dependencies

cd /Users/kid/Development/Fusion/Projects/agent-fox
pnpm --filter @agent-fox/server add bcrypt jsonwebtoken
pnpm --filter @agent-fox/server add -D @types/bcrypt @types/jsonwebtoken
  • Step 2: Create password utilities

packages/server/src/lib/password.ts:

import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12;

export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

export async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash);
}
  • Step 3: Create JWT utilities

packages/server/src/lib/jwt.ts:

import jwt from 'jsonwebtoken';

const ACCESS_SECRET = process.env.JWT_SECRET || 'dev-secret';
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret';
const ACCESS_EXPIRY = '15m';
const REFRESH_EXPIRY = '7d';

export type TokenPayload = {
  userId: string;
  email: string;
};

export function generateAccessToken(payload: TokenPayload): string {
  return jwt.sign(payload, ACCESS_SECRET, { expiresIn: ACCESS_EXPIRY });
}

export function generateRefreshToken(payload: TokenPayload): string {
  return jwt.sign(payload, REFRESH_SECRET, { expiresIn: REFRESH_EXPIRY });
}

export function verifyAccessToken(token: string): TokenPayload {
  return jwt.verify(token, ACCESS_SECRET) as TokenPayload;
}

export function verifyRefreshToken(token: string): TokenPayload {
  return jwt.verify(token, REFRESH_SECRET) as TokenPayload;
}

export function generateTokenPair(payload: TokenPayload) {
  return {
    accessToken: generateAccessToken(payload),
    refreshToken: generateRefreshToken(payload),
  };
}
  • Step 4: Create auth middleware

packages/server/src/middleware/auth.ts:

import type { Request, Response, NextFunction } from 'express';
import { verifyAccessToken, type TokenPayload } from '../lib/jwt.js';

declare global {
  namespace Express {
    interface Request {
      user?: TokenPayload;
    }
  }
}

export function requireAuth(req: Request, res: Response, next: NextFunction): void {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } });
    return;
  }

  try {
    const token = header.slice(7);
    req.user = verifyAccessToken(token);
    next();
  } catch {
    res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' } });
  }
}
  • Step 5: Create auth routes

packages/server/src/routes/auth.ts:

import { Router } from 'express';
import { z } from 'zod';
import { prisma } from '@agent-fox/shared';
import { hashPassword, verifyPassword } from '../lib/password.js';
import { generateTokenPair, verifyRefreshToken } from '../lib/jwt.js';

const router = Router();

const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  name: z.string().min(1).max(100),
});

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string(),
});

router.post('/register', async (req, res) => {
  const parsed = registerSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
    return;
  }

  const { email, password, name } = parsed.data;

  const existing = await prisma.user.findUnique({ where: { email } });
  if (existing) {
    res.status(409).json({ success: false, error: { code: 'CONFLICT', message: 'Email already registered' } });
    return;
  }

  const passwordHash = await hashPassword(password);
  const user = await prisma.user.create({
    data: { email, passwordHash, name },
  });

  const tokens = generateTokenPair({ userId: user.id, email: user.email });
  res.status(201).json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } });
});

router.post('/login', async (req, res) => {
  const parsed = loginSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
    return;
  }

  const { email, password } = parsed.data;
  const user = await prisma.user.findUnique({ where: { email } });

  if (!user || !user.passwordHash) {
    res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
    return;
  }

  const valid = await verifyPassword(password, user.passwordHash);
  if (!valid) {
    res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
    return;
  }

  const tokens = generateTokenPair({ userId: user.id, email: user.email });
  res.json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } });
});

router.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  if (!refreshToken) {
    res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Refresh token required' } });
    return;
  }

  try {
    const payload = verifyRefreshToken(refreshToken);
    const user = await prisma.user.findUnique({ where: { id: payload.userId } });
    if (!user) {
      res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'User not found' } });
      return;
    }
    const tokens = generateTokenPair({ userId: user.id, email: user.email });
    res.json({ success: true, data: tokens });
  } catch {
    res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid refresh token' } });
  }
});

export default router;
  • Step 6: Wire auth routes into Express app

Update packages/server/src/index.ts:

import express from 'express';
import cors from 'cors';
import authRouter from './routes/auth.js';

const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));

app.get('/api/health', (_req, res) => {
  res.json({ success: true, data: { status: 'ok' } });
});

app.use('/api/auth', authRouter);

const port = process.env.SERVER_PORT || 3000;
app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});
  • Step 7: Test manually
pnpm --filter @agent-fox/server dev
# In another terminal:
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@test.com","password":"password123","name":"Test User"}'
# Expected: 201 with user + tokens

curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@test.com","password":"password123"}'
# Expected: 200 with user + tokens
  • Step 8: Commit
git add packages/server/
git commit -m "feat: add JWT authentication with register, login, and refresh endpoints"

Phase 3: Backend API — Project CRUD & OpenAPI Import

Task 4: Project CRUD routes

Files:

  • Create: packages/server/src/routes/projects.ts

  • Create: packages/server/src/lib/api-key.ts

  • Modify: packages/server/src/index.ts

  • Step 1: Create API key utilities

packages/server/src/lib/api-key.ts:

import { randomBytes } from 'node:crypto';
import bcrypt from 'bcrypt';

const PREFIX = 'afk_';

export function generateApiKey(): { raw: string; hash: string } {
  const raw = PREFIX + randomBytes(24).toString('base64url');
  // Use lower cost for API keys since they're checked per MCP request
  const hash = bcrypt.hashSync(raw, 8);
  return { raw, hash };
}

export async function verifyApiKey(raw: string, hash: string): Promise<boolean> {
  return bcrypt.compare(raw, hash);
}
  • Step 2: Create project routes

packages/server/src/routes/projects.ts:

import { Router } from 'express';
import { z } from 'zod';
import { prisma } from '@agent-fox/shared';
import { requireAuth } from '../middleware/auth.js';
import { generateApiKey } from '../lib/api-key.js';

const router = Router();
router.use(requireAuth);

router.get('/', async (req, res) => {
  const projects = await prisma.project.findMany({
    where: { userId: req.user!.userId },
    include: { _count: { select: { endpoints: true, modules: true } } },
    orderBy: { updatedAt: 'desc' },
  });
  res.json({ success: true, data: projects });
});

router.get('/:id', async (req, res) => {
  const project = await prisma.project.findFirst({
    where: { id: req.params.id, userId: req.user!.userId },
    include: {
      modules: {
        include: { _count: { select: { endpoints: true } } },
        orderBy: { sortOrder: 'asc' },
      },
      _count: { select: { endpoints: true } },
    },
  });

  if (!project) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
    return;
  }

  res.json({ success: true, data: project });
});

const updateSchema = z.object({
  name: z.string().min(1).max(200).optional(),
  description: z.string().max(1000).optional(),
  baseUrl: z.string().url().optional(),
});

router.put('/:id', async (req, res) => {
  const parsed = updateSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
    return;
  }

  const project = await prisma.project.updateMany({
    where: { id: req.params.id, userId: req.user!.userId },
    data: parsed.data,
  });

  if (project.count === 0) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
    return;
  }

  const updated = await prisma.project.findUnique({ where: { id: req.params.id } });
  res.json({ success: true, data: updated });
});

router.delete('/:id', async (req, res) => {
  const result = await prisma.project.deleteMany({
    where: { id: req.params.id, userId: req.user!.userId },
  });

  if (result.count === 0) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
    return;
  }

  res.json({ success: true, data: { deleted: true } });
});

router.post('/:id/api-key/rotate', async (req, res) => {
  const { raw, hash } = generateApiKey();

  const result = await prisma.project.updateMany({
    where: { id: req.params.id, userId: req.user!.userId },
    data: { apiKeyHash: hash },
  });

  if (result.count === 0) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
    return;
  }

  res.json({ success: true, data: { apiKey: raw } });
});

export default router;
  • Step 3: Wire project routes into Express app

Add to packages/server/src/index.ts after auth routes:

import projectRouter from './routes/projects.js';
// ...
app.use('/api/projects', projectRouter);
  • Step 4: Commit
git add packages/server/
git commit -m "feat: add project CRUD routes with API key generation"

Task 5: OpenAPI import and parsing service

Files:

  • Create: packages/server/src/services/openapi-parser.ts

  • Create: packages/server/src/routes/import.ts

  • Modify: packages/server/src/routes/projects.ts (add POST / for create with import)

  • Modify: packages/server/src/index.ts

  • Modify: packages/server/package.json (add swagger-parser dep)

  • Step 1: Install swagger-parser

pnpm --filter @agent-fox/server add @apidevtools/swagger-parser
pnpm --filter @agent-fox/server add -D @types/swagger-schema-official
  • Step 2: Create OpenAPI parser service

packages/server/src/services/openapi-parser.ts:

import SwaggerParser from '@apidevtools/swagger-parser';
import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';

type OpenApiDoc = OpenAPIV3.Document | OpenAPIV3_1.Document;

export type ParsedModule = {
  name: string;
  description: string | null;
  source: 'tag' | 'path_prefix';
};

export type ParsedEndpoint = {
  method: string;
  path: string;
  summary: string | null;
  description: string | null;
  operationId: string | null;
  parameters: unknown[];
  requestBody: unknown | null;
  responses: Record<string, unknown>;
  tags: string[];
  deprecated: boolean;
  moduleName: string; // References which module this belongs to
};

export type ParseResult = {
  name: string;
  description: string | null;
  version: string;
  openApiVersion: string;
  baseUrl: string | null;
  spec: unknown; // Full dereferenced spec
  modules: ParsedModule[];
  endpoints: ParsedEndpoint[];
};

export async function parseOpenApiDocument(input: string | object): Promise<ParseResult> {
  // Validate and dereference
  const rawApi = await SwaggerParser.validate(input);
  const api = await SwaggerParser.dereference(rawApi) as OpenApiDoc;

  const openApiVersion = 'openapi' in api ? api.openapi : 'unknown';
  const name = api.info.title;
  const description = api.info.description || null;
  const version = api.info.version;
  const baseUrl = api.servers?.[0]?.url || null;

  // Collect all tags defined at the top level
  const tagMap = new Map<string, string | null>();
  if (api.tags) {
    for (const tag of api.tags) {
      tagMap.set(tag.name, tag.description || null);
    }
  }

  // Parse paths into endpoints, collecting used tags
  const endpoints: ParsedEndpoint[] = [];
  const usedTags = new Set<string>();
  const pathPrefixes = new Set<string>();

  const paths = api.paths || {};
  for (const [pathStr, pathItem] of Object.entries(paths)) {
    if (!pathItem) continue;

    const methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] as const;
    for (const method of methods) {
      const operation = (pathItem as Record<string, unknown>)[method] as OpenAPIV3.OperationObject | undefined;
      if (!operation) continue;

      const endpointTags = operation.tags || [];
      for (const tag of endpointTags) {
        usedTags.add(tag);
        if (!tagMap.has(tag)) {
          tagMap.set(tag, null);
        }
      }

      // Extract path prefix for tagless endpoints
      const prefix = pathStr.split('/').filter(Boolean)[0] || 'default';
      pathPrefixes.add(prefix);

      const moduleName = endpointTags[0] || prefix;

      endpoints.push({
        method: method.toUpperCase(),
        path: pathStr,
        summary: operation.summary || null,
        description: operation.description || null,
        operationId: operation.operationId || null,
        parameters: (operation.parameters || []) as unknown[],
        requestBody: operation.requestBody || null,
        responses: (operation.responses || {}) as Record<string, unknown>,
        tags: endpointTags,
        deprecated: operation.deprecated || false,
        moduleName,
      });
    }
  }

  // Build modules: tags first, then path prefixes for untagged endpoints
  const modules: ParsedModule[] = [];
  const moduleNames = new Set<string>();

  // Add tag-based modules
  for (const [tagName, tagDesc] of tagMap) {
    if (usedTags.has(tagName)) {
      modules.push({ name: tagName, description: tagDesc, source: 'tag' });
      moduleNames.add(tagName);
    }
  }

  // Add path-prefix modules for endpoints that have no tags
  for (const endpoint of endpoints) {
    if (endpoint.tags.length === 0 && !moduleNames.has(endpoint.moduleName)) {
      modules.push({ name: endpoint.moduleName, description: null, source: 'path_prefix' });
      moduleNames.add(endpoint.moduleName);
    }
  }

  return {
    name,
    description,
    version,
    openApiVersion,
    baseUrl,
    spec: api,
    modules,
    endpoints,
  };
}
  • Step 3: Add project creation route with import

Add to the top of packages/server/src/routes/projects.ts:

import { parseOpenApiDocument } from '../services/openapi-parser.js';

Add this route before the existing router.get('/'):

router.post('/', async (req, res) => {
  const { spec, specUrl } = req.body;

  if (!spec && !specUrl) {
    res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON object) or specUrl (URL string)' } });
    return;
  }

  try {
    const input = specUrl || spec;
    const parsed = await parseOpenApiDocument(input);
    const { raw: apiKey, hash: apiKeyHash } = generateApiKey();

    const project = await prisma.$transaction(async (tx) => {
      const proj = await tx.project.create({
        data: {
          userId: req.user!.userId,
          name: parsed.name,
          description: parsed.description,
          baseUrl: parsed.baseUrl,
          openApiSpec: parsed.spec as any,
          openApiVersion: parsed.openApiVersion,
          apiKeyHash,
        },
      });

      // Create modules
      const moduleIdMap = new Map<string, string>();
      for (let i = 0; i < parsed.modules.length; i++) {
        const mod = parsed.modules[i];
        const created = await tx.module.create({
          data: {
            projectId: proj.id,
            name: mod.name,
            description: mod.description,
            sortOrder: i,
            source: mod.source,
          },
        });
        moduleIdMap.set(mod.name, created.id);
      }

      // Create endpoints
      for (const ep of parsed.endpoints) {
        const moduleId = moduleIdMap.get(ep.moduleName);
        if (!moduleId) continue;

        await tx.endpoint.create({
          data: {
            projectId: proj.id,
            moduleId,
            method: ep.method,
            path: ep.path,
            summary: ep.summary,
            description: ep.description,
            operationId: ep.operationId,
            parameters: ep.parameters as any,
            requestBody: ep.requestBody as any,
            responses: ep.responses as any,
            tags: ep.tags,
            deprecated: ep.deprecated,
          },
        });
      }

      return proj;
    });

    res.status(201).json({
      success: true,
      data: {
        project: { id: project.id, name: project.name },
        apiKey, // Only returned once at creation
        stats: { modules: parsed.modules.length, endpoints: parsed.endpoints.length },
      },
    });
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Failed to parse OpenAPI document';
    res.status(400).json({ success: false, error: { code: 'PARSE_ERROR', message } });
  }
});
  • Step 4: Add reimport route

Create packages/server/src/routes/import.ts:

import { Router } from 'express';
import { prisma } from '@agent-fox/shared';
import { requireAuth } from '../middleware/auth.js';
import { parseOpenApiDocument } from '../services/openapi-parser.js';

const router = Router();
router.use(requireAuth);

router.post('/:id/reimport', async (req, res) => {
  const { spec, specUrl } = req.body;

  if (!spec && !specUrl) {
    res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec or specUrl' } });
    return;
  }

  const project = await prisma.project.findFirst({
    where: { id: req.params.id, userId: req.user!.userId },
  });

  if (!project) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
    return;
  }

  try {
    const input = specUrl || spec;
    const parsed = await parseOpenApiDocument(input);

    await prisma.$transaction(async (tx) => {
      // Delete existing modules and endpoints (cascade)
      await tx.module.deleteMany({ where: { projectId: project.id } });

      // Update project
      await tx.project.update({
        where: { id: project.id },
        data: {
          name: parsed.name,
          description: parsed.description,
          baseUrl: parsed.baseUrl,
          openApiSpec: parsed.spec as any,
          openApiVersion: parsed.openApiVersion,
        },
      });

      // Recreate modules
      const moduleIdMap = new Map<string, string>();
      for (let i = 0; i < parsed.modules.length; i++) {
        const mod = parsed.modules[i];
        const created = await tx.module.create({
          data: {
            projectId: project.id,
            name: mod.name,
            description: mod.description,
            sortOrder: i,
            source: mod.source,
          },
        });
        moduleIdMap.set(mod.name, created.id);
      }

      // Recreate endpoints
      for (const ep of parsed.endpoints) {
        const moduleId = moduleIdMap.get(ep.moduleName);
        if (!moduleId) continue;
        await tx.endpoint.create({
          data: {
            projectId: project.id,
            moduleId,
            method: ep.method,
            path: ep.path,
            summary: ep.summary,
            description: ep.description,
            operationId: ep.operationId,
            parameters: ep.parameters as any,
            requestBody: ep.requestBody as any,
            responses: ep.responses as any,
            tags: ep.tags,
            deprecated: ep.deprecated,
          },
        });
      }
    });

    res.json({
      success: true,
      data: { stats: { modules: parsed.modules.length, endpoints: parsed.endpoints.length } },
    });
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Failed to parse OpenAPI document';
    res.status(400).json({ success: false, error: { code: 'PARSE_ERROR', message } });
  }
});

export default router;
  • Step 5: Wire import routes into Express app

Add to packages/server/src/index.ts:

import importRouter from './routes/import.js';
// ...
app.use('/api/projects', importRouter);
  • Step 6: Test with Petstore sample
curl -X POST http://localhost:3000/api/projects \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <access-token-from-login>" \
  -d '{"specUrl":"https://petstore3.swagger.io/api/v3/openapi.json"}'
# Expected: 201 with project ID, API key, and stats showing modules and endpoints
  • Step 7: Commit
git add packages/server/
git commit -m "feat: add OpenAPI import/parsing with module auto-grouping"

Task 6: Module and endpoint management routes

Files:

  • Create: packages/server/src/routes/modules.ts

  • Create: packages/server/src/routes/endpoints.ts

  • Modify: packages/server/src/index.ts

  • Step 1: Create module routes

packages/server/src/routes/modules.ts:

import { Router } from 'express';
import { z } from 'zod';
import { prisma } from '@agent-fox/shared';
import { requireAuth } from '../middleware/auth.js';

const router = Router();
router.use(requireAuth);

// Helper to verify project ownership
async function verifyProjectOwnership(projectId: string, userId: string) {
  return prisma.project.findFirst({ where: { id: projectId, userId } });
}

router.get('/:id/modules', async (req, res) => {
  const project = await verifyProjectOwnership(req.params.id, req.user!.userId);
  if (!project) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
    return;
  }

  const modules = await prisma.module.findMany({
    where: { projectId: req.params.id },
    include: { _count: { select: { endpoints: true } } },
    orderBy: { sortOrder: 'asc' },
  });

  res.json({ success: true, data: modules });
});

const createModuleSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().max(500).optional(),
});

router.post('/:id/modules', async (req, res) => {
  const project = await verifyProjectOwnership(req.params.id, req.user!.userId);
  if (!project) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
    return;
  }

  const parsed = createModuleSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
    return;
  }

  const maxOrder = await prisma.module.aggregate({
    where: { projectId: req.params.id },
    _max: { sortOrder: true },
  });

  const mod = await prisma.module.create({
    data: {
      projectId: req.params.id,
      name: parsed.data.name,
      description: parsed.data.description,
      sortOrder: (maxOrder._max.sortOrder || 0) + 1,
      source: 'manual',
    },
  });

  res.status(201).json({ success: true, data: mod });
});

const updateModuleSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  description: z.string().max(500).optional(),
  sortOrder: z.number().int().min(0).optional(),
});

router.put('/:id/modules/:mid', async (req, res) => {
  const project = await verifyProjectOwnership(req.params.id, req.user!.userId);
  if (!project) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
    return;
  }

  const parsed = updateModuleSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
    return;
  }

  const mod = await prisma.module.updateMany({
    where: { id: req.params.mid, projectId: req.params.id },
    data: parsed.data,
  });

  if (mod.count === 0) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Module not found' } });
    return;
  }

  const updated = await prisma.module.findUnique({ where: { id: req.params.mid } });
  res.json({ success: true, data: updated });
});

router.delete('/:id/modules/:mid', async (req, res) => {
  const project = await verifyProjectOwnership(req.params.id, req.user!.userId);
  if (!project) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
    return;
  }

  const result = await prisma.module.deleteMany({
    where: { id: req.params.mid, projectId: req.params.id },
  });

  if (result.count === 0) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Module not found' } });
    return;
  }

  res.json({ success: true, data: { deleted: true } });
});

export default router;
  • Step 2: Create endpoint routes

packages/server/src/routes/endpoints.ts:

import { Router } from 'express';
import { z } from 'zod';
import { prisma } from '@agent-fox/shared';
import { requireAuth } from '../middleware/auth.js';

const router = Router();
router.use(requireAuth);

router.get('/:id/endpoints', async (req, res) => {
  const project = await prisma.project.findFirst({
    where: { id: req.params.id, userId: req.user!.userId },
  });

  if (!project) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
    return;
  }

  const { moduleId } = req.query;
  const where: any = { projectId: req.params.id };
  if (moduleId) where.moduleId = moduleId;

  const endpoints = await prisma.endpoint.findMany({
    where,
    select: {
      id: true,
      method: true,
      path: true,
      summary: true,
      deprecated: true,
      moduleId: true,
      module: { select: { name: true } },
    },
    orderBy: [{ path: 'asc' }, { method: 'asc' }],
  });

  res.json({ success: true, data: endpoints });
});

router.get('/:id/endpoints/:eid', async (req, res) => {
  const project = await prisma.project.findFirst({
    where: { id: req.params.id, userId: req.user!.userId },
  });

  if (!project) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
    return;
  }

  const endpoint = await prisma.endpoint.findFirst({
    where: { id: req.params.eid, projectId: req.params.id },
    include: { module: { select: { name: true } } },
  });

  if (!endpoint) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Endpoint not found' } });
    return;
  }

  res.json({ success: true, data: endpoint });
});

const moveEndpointSchema = z.object({
  moduleId: z.string().uuid(),
});

router.patch('/:id/endpoints/:eid', async (req, res) => {
  const project = await prisma.project.findFirst({
    where: { id: req.params.id, userId: req.user!.userId },
  });

  if (!project) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
    return;
  }

  const parsed = moveEndpointSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
    return;
  }

  // Verify target module belongs to same project
  const targetModule = await prisma.module.findFirst({
    where: { id: parsed.data.moduleId, projectId: req.params.id },
  });

  if (!targetModule) {
    res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Target module not found in this project' } });
    return;
  }

  const result = await prisma.endpoint.updateMany({
    where: { id: req.params.eid, projectId: req.params.id },
    data: { moduleId: parsed.data.moduleId },
  });

  if (result.count === 0) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Endpoint not found' } });
    return;
  }

  res.json({ success: true, data: { moved: true } });
});

export default router;
  • Step 3: Wire routes into Express app

Add to packages/server/src/index.ts:

import moduleRouter from './routes/modules.js';
import endpointRouter from './routes/endpoints.js';
// ...
app.use('/api/projects', moduleRouter);
app.use('/api/projects', endpointRouter);
  • Step 4: Commit
git add packages/server/
git commit -m "feat: add module and endpoint management routes"

Phase 4: MCP Service

Task 7: MCP server with multi-level retrieval tools

Files:

  • Create: packages/mcp/src/tools/get-project-overview.ts

  • Create: packages/mcp/src/tools/list-modules.ts

  • Create: packages/mcp/src/tools/list-endpoints.ts

  • Create: packages/mcp/src/tools/get-endpoint-detail.ts

  • Create: packages/mcp/src/tools/search-endpoints.ts

  • Create: packages/mcp/src/server.ts

  • Create: packages/mcp/src/auth.ts

  • Modify: packages/mcp/src/index.ts

  • Step 1: Create MCP auth middleware

packages/mcp/src/auth.ts:

import type { Request, Response, NextFunction } from 'express';
import bcrypt from 'bcrypt';
import { prisma } from '@agent-fox/shared';

export async function mcpAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
  const projectId = req.params.projectId;
  const header = req.headers.authorization;

  if (!header?.startsWith('Bearer ')) {
    res.status(401).json({ error: 'Missing API key' });
    return;
  }

  const apiKey = header.slice(7);
  const project = await prisma.project.findUnique({
    where: { id: projectId },
    select: { id: true, apiKeyHash: true },
  });

  if (!project) {
    res.status(404).json({ error: 'Project not found' });
    return;
  }

  const valid = await bcrypt.compare(apiKey, project.apiKeyHash);
  if (!valid) {
    res.status(401).json({ error: 'Invalid API key' });
    return;
  }

  // Store projectId for tools to use
  (req as any).projectId = projectId;
  next();
}

Add bcrypt dependency:

pnpm --filter @agent-fox/mcp add bcrypt
pnpm --filter @agent-fox/mcp add -D @types/bcrypt
  • Step 2: Create get_project_overview tool

packages/mcp/src/tools/get-project-overview.ts:

import { prisma } from '@agent-fox/shared';
import type { CallToolResult } from '@modelcontextprotocol/server';

export async function getProjectOverview(projectId: string): Promise<CallToolResult> {
  const project = await prisma.project.findUnique({
    where: { id: projectId },
    select: {
      name: true,
      description: true,
      openApiVersion: true,
      baseUrl: true,
      modules: {
        select: { id: true, name: true, _count: { select: { endpoints: true } } },
        orderBy: { sortOrder: 'asc' },
      },
      _count: { select: { endpoints: true } },
    },
  });

  if (!project) {
    return { content: [{ type: 'text', text: 'Project not found' }], isError: true };
  }

  const overview = {
    name: project.name,
    description: project.description,
    version: project.openApiVersion,
    baseUrl: project.baseUrl,
    totalEndpoints: project._count.endpoints,
    modules: project.modules.map((m) => ({
      id: m.id,
      name: m.name,
      endpointCount: m._count.endpoints,
    })),
  };

  return { content: [{ type: 'text', text: JSON.stringify(overview, null, 2) }] };
}
  • Step 3: Create list_modules tool

packages/mcp/src/tools/list-modules.ts:

import { prisma } from '@agent-fox/shared';
import type { CallToolResult } from '@modelcontextprotocol/server';

export async function listModules(projectId: string): Promise<CallToolResult> {
  const modules = await prisma.module.findMany({
    where: { projectId },
    select: {
      id: true,
      name: true,
      description: true,
      _count: { select: { endpoints: true } },
    },
    orderBy: { sortOrder: 'asc' },
  });

  const result = modules.map((m) => ({
    id: m.id,
    name: m.name,
    description: m.description,
    endpointCount: m._count.endpoints,
  }));

  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
}
  • Step 4: Create list_endpoints tool

packages/mcp/src/tools/list-endpoints.ts:

import { prisma } from '@agent-fox/shared';
import type { CallToolResult } from '@modelcontextprotocol/server';

export async function listEndpoints(projectId: string, moduleId: string): Promise<CallToolResult> {
  // Verify module belongs to project
  const mod = await prisma.module.findFirst({
    where: { id: moduleId, projectId },
  });

  if (!mod) {
    return { content: [{ type: 'text', text: `Module "${moduleId}" not found in this project. Use get_project_overview or list_modules to see available modules.` }], isError: true };
  }

  const endpoints = await prisma.endpoint.findMany({
    where: { projectId, moduleId },
    select: {
      id: true,
      method: true,
      path: true,
      summary: true,
      deprecated: true,
    },
    orderBy: [{ path: 'asc' }, { method: 'asc' }],
  });

  const result = endpoints.map((e) => ({
    id: e.id,
    method: e.method,
    path: e.path,
    summary: e.summary,
    deprecated: e.deprecated,
  }));

  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
}
  • Step 5: Create get_endpoint_detail tool

packages/mcp/src/tools/get-endpoint-detail.ts:

import { prisma } from '@agent-fox/shared';
import type { CallToolResult } from '@modelcontextprotocol/server';

export async function getEndpointDetail(projectId: string, endpointId: string): Promise<CallToolResult> {
  const endpoint = await prisma.endpoint.findFirst({
    where: { id: endpointId, projectId },
    include: { module: { select: { name: true } } },
  });

  if (!endpoint) {
    return { content: [{ type: 'text', text: `Endpoint "${endpointId}" not found. Use list_endpoints to see available endpoints in a module.` }], isError: true };
  }

  const detail = {
    id: endpoint.id,
    method: endpoint.method,
    path: endpoint.path,
    summary: endpoint.summary,
    description: endpoint.description,
    operationId: endpoint.operationId,
    moduleName: endpoint.module.name,
    parameters: endpoint.parameters,
    requestBody: endpoint.requestBody,
    responses: endpoint.responses,
    deprecated: endpoint.deprecated,
  };

  return { content: [{ type: 'text', text: JSON.stringify(detail, null, 2) }] };
}
  • Step 6: Create search_endpoints tool

packages/mcp/src/tools/search-endpoints.ts:

import { prisma } from '@agent-fox/shared';
import type { CallToolResult } from '@modelcontextprotocol/server';

export async function searchEndpoints(
  projectId: string,
  keyword: string,
  moduleId?: string,
): Promise<CallToolResult> {
  const lowerKeyword = keyword.toLowerCase();

  const where: any = { projectId };
  if (moduleId) where.moduleId = moduleId;

  // Search across path, summary, description, operationId
  where.OR = [
    { path: { contains: lowerKeyword, mode: 'insensitive' } },
    { summary: { contains: lowerKeyword, mode: 'insensitive' } },
    { description: { contains: lowerKeyword, mode: 'insensitive' } },
    { operationId: { contains: lowerKeyword, mode: 'insensitive' } },
  ];

  const endpoints = await prisma.endpoint.findMany({
    where,
    select: {
      id: true,
      method: true,
      path: true,
      summary: true,
      deprecated: true,
      module: { select: { name: true } },
    },
    orderBy: [{ path: 'asc' }, { method: 'asc' }],
    take: 20, // Limit results to keep token count manageable
  });

  if (endpoints.length === 0) {
    return { content: [{ type: 'text', text: `No endpoints found matching "${keyword}". Try a different keyword or use list_modules to browse by module.` }] };
  }

  const result = endpoints.map((e) => ({
    id: e.id,
    method: e.method,
    path: e.path,
    summary: e.summary,
    moduleName: e.module.name,
    deprecated: e.deprecated,
  }));

  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
}
  • Step 7: Create MCP server factory

packages/mcp/src/server.ts:

import { McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';
import { getProjectOverview } from './tools/get-project-overview.js';
import { listModules } from './tools/list-modules.js';
import { listEndpoints } from './tools/list-endpoints.js';
import { getEndpointDetail } from './tools/get-endpoint-detail.js';
import { searchEndpoints } from './tools/search-endpoints.js';

export function createMcpServer(projectId: string): McpServer {
  const server = new McpServer({
    name: 'agent-fox',
    version: '0.1.0',
  });

  server.registerTool(
    'get_project_overview',
    {
      description: 'Get an overview of this API project including its name, version, base URL, and a summary of available modules with endpoint counts. Call this first to understand what the API offers.',
      inputSchema: z.object({}),
    },
    async () => getProjectOverview(projectId),
  );

  server.registerTool(
    'list_modules',
    {
      description: 'List all API modules/groups with their descriptions. Each module contains related endpoints. Use this when you need module descriptions to decide which module to explore.',
      inputSchema: z.object({}),
    },
    async () => listModules(projectId),
  );

  server.registerTool(
    'list_endpoints',
    {
      description: 'List all endpoints in a specific module. Returns method, path, and summary for each endpoint. Use get_endpoint_detail to get full information about a specific endpoint.',
      inputSchema: z.object({
        moduleId: z.string().describe('The module ID to list endpoints for. Get module IDs from get_project_overview or list_modules.'),
      }),
    },
    async ({ moduleId }) => listEndpoints(projectId, moduleId),
  );

  server.registerTool(
    'get_endpoint_detail',
    {
      description: 'Get complete details for a specific endpoint including parameters, request body schema, response schemas, and examples. Use this when you need to understand exactly how to call an endpoint.',
      inputSchema: z.object({
        endpointId: z.string().describe('The endpoint ID. Get endpoint IDs from list_endpoints or search_endpoints.'),
      }),
    },
    async ({ endpointId }) => getEndpointDetail(projectId, endpointId),
  );

  server.registerTool(
    'search_endpoints',
    {
      description: 'Search for endpoints by keyword. Searches across path, summary, description, operationId, and parameter names. Optionally filter by module. Returns matching endpoint summaries.',
      inputSchema: z.object({
        keyword: z.string().describe('Search keyword to match against endpoint path, summary, description, and operationId.'),
        moduleId: z.string().optional().describe('Optional module ID to limit search scope. Omit to search all modules.'),
      }),
    },
    async ({ keyword, moduleId }) => searchEndpoints(projectId, keyword, moduleId),
  );

  return server;
}
  • Step 8: Wire MCP server into Express with Streamable HTTP transport

packages/mcp/src/index.ts:

import { randomUUID } from 'node:crypto';
import express from 'express';
import cors from 'cors';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { isInitializeRequest } from '@modelcontextprotocol/server';
import { mcpAuth } from './auth.js';
import { createMcpServer } from './server.js';

const app = express();
app.use(cors());
app.use(express.json());

app.get('/health', (_req, res) => {
  res.json({ status: 'ok' });
});

// Session storage
const transports: Record<string, NodeStreamableHTTPServerTransport> = {};

// MCP Streamable HTTP endpoint
app.post('/mcp/:projectId', mcpAuth, async (req, res) => {
  const projectId = (req as any).projectId as string;
  const sessionId = req.headers['mcp-session-id'] as string | undefined;

  if (sessionId && transports[sessionId]) {
    await transports[sessionId].handleRequest(req, res, req.body);
    return;
  }

  if (!sessionId && isInitializeRequest(req.body)) {
    const transport = new NodeStreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (id) => {
        transports[id] = transport;
      },
    });

    transport.onclose = () => {
      if (transport.sessionId) {
        delete transports[transport.sessionId];
      }
    };

    const server = createMcpServer(projectId);
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
    return;
  }

  res.status(400).json({
    jsonrpc: '2.0',
    error: { code: -32000, message: 'Invalid session. Send an initialize request without a session ID to start a new session.' },
    id: null,
  });
});

// SSE endpoint for session resumption
app.get('/mcp/:projectId', mcpAuth, async (req, res) => {
  const sessionId = req.headers['mcp-session-id'] as string;
  if (sessionId && transports[sessionId]) {
    await transports[sessionId].handleRequest(req, res);
  } else {
    res.status(400).json({ error: 'Invalid session. Start a new session via POST.' });
  }
});

// Session termination
app.delete('/mcp/:projectId', mcpAuth, async (req, res) => {
  const sessionId = req.headers['mcp-session-id'] as string;
  if (sessionId && transports[sessionId]) {
    await transports[sessionId].close();
    delete transports[sessionId];
    res.status(204).end();
  } else {
    res.status(400).json({ error: 'Invalid session' });
  }
});

const port = process.env.MCP_PORT || 3001;
app.listen(port, () => {
  console.log(`MCP service running on port ${port}`);
});
  • Step 9: Test MCP service
pnpm --filter @agent-fox/mcp dev
# Test with MCP inspector or curl:
curl -X POST http://localhost:3001/mcp/<project-id> \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <api-key>" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
# Expected: 200 with initialize response and mcp-session-id header
  • Step 10: Commit
git add packages/mcp/
git commit -m "feat: add MCP service with 5 multi-level retrieval tools"

Phase 5: Frontend

Task 8: Frontend scaffold with routing and auth pages

Files:

  • Modify: packages/web/package.json (add deps)

  • Create: packages/web/src/lib/api.ts

  • Create: packages/web/src/lib/auth.tsx

  • Create: packages/web/src/pages/Login.tsx

  • Create: packages/web/src/pages/Register.tsx

  • Create: packages/web/src/pages/Layout.tsx

  • Modify: packages/web/src/App.tsx

  • Modify: packages/web/src/main.tsx

  • Step 1: Install frontend dependencies

cd /Users/kid/Development/Fusion/Projects/agent-fox/packages/web
pnpm add react-router-dom @tanstack/react-query
pnpm add -D tailwindcss @tailwindcss/vite
  • Step 2: Configure TailwindCSS

Add to packages/web/vite.config.ts:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  plugins: [react(), tailwindcss()],
  server: {
    port: 5173,
    proxy: {
      '/api': 'http://localhost:3000',
    },
  },
});

Replace packages/web/src/index.css:

@import "tailwindcss";
  • Step 3: Create API client

packages/web/src/lib/api.ts:

const API_BASE = '/api';

type ApiResponse<T> = {
  success: boolean;
  data?: T;
  error?: { code: string; message: string };
};

let accessToken: string | null = localStorage.getItem('accessToken');
let refreshToken: string | null = localStorage.getItem('refreshToken');

export function setTokens(access: string, refresh: string) {
  accessToken = access;
  refreshToken = refresh;
  localStorage.setItem('accessToken', access);
  localStorage.setItem('refreshToken', refresh);
}

export function clearTokens() {
  accessToken = null;
  refreshToken = null;
  localStorage.removeItem('accessToken');
  localStorage.removeItem('refreshToken');
}

export function getAccessToken() {
  return accessToken;
}

async function refreshAccessToken(): Promise<boolean> {
  if (!refreshToken) return false;
  try {
    const res = await fetch(`${API_BASE}/auth/refresh`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken }),
    });
    if (!res.ok) return false;
    const json: ApiResponse<{ accessToken: string; refreshToken: string }> = await res.json();
    if (json.success && json.data) {
      setTokens(json.data.accessToken, json.data.refreshToken);
      return true;
    }
    return false;
  } catch {
    return false;
  }
}

export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
  const headers = new Headers(options.headers);
  headers.set('Content-Type', 'application/json');
  if (accessToken) {
    headers.set('Authorization', `Bearer ${accessToken}`);
  }

  let res = await fetch(`${API_BASE}${path}`, { ...options, headers });

  // Auto-refresh on 401
  if (res.status === 401 && refreshToken) {
    const refreshed = await refreshAccessToken();
    if (refreshed) {
      headers.set('Authorization', `Bearer ${accessToken}`);
      res = await fetch(`${API_BASE}${path}`, { ...options, headers });
    }
  }

  const json: ApiResponse<T> = await res.json();
  if (!json.success) {
    throw new Error(json.error?.message || 'Request failed');
  }
  return json.data as T;
}
  • Step 4: Create auth context

packages/web/src/lib/auth.tsx:

import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { getAccessToken, clearTokens, setTokens, apiFetch } from './api';

type User = { id: string; email: string; name: string };

type AuthContextType = {
  user: User | null;
  loading: boolean;
  login: (email: string, password: string) => Promise<void>;
  register: (email: string, password: string, name: string) => Promise<void>;
  logout: () => void;
};

const AuthContext = createContext<AuthContextType | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Check if we have a valid token on mount
    if (getAccessToken()) {
      apiFetch<{ id: string; email: string; name: string }>('/auth/me')
        .then(setUser)
        .catch(() => clearTokens())
        .finally(() => setLoading(false));
    } else {
      setLoading(false);
    }
  }, []);

  const login = async (email: string, password: string) => {
    const data = await apiFetch<{ user: User; accessToken: string; refreshToken: string }>(
      '/auth/login',
      { method: 'POST', body: JSON.stringify({ email, password }) },
    );
    setTokens(data.accessToken, data.refreshToken);
    setUser(data.user);
  };

  const register = async (email: string, password: string, name: string) => {
    const data = await apiFetch<{ user: User; accessToken: string; refreshToken: string }>(
      '/auth/register',
      { method: 'POST', body: JSON.stringify({ email, password, name }) },
    );
    setTokens(data.accessToken, data.refreshToken);
    setUser(data.user);
  };

  const logout = () => {
    clearTokens();
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, loading, login, register, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used within AuthProvider');
  return ctx;
}
  • Step 5: Create Login and Register pages

packages/web/src/pages/Login.tsx:

import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../lib/auth';

export default function Login() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const { login } = useAuth();
  const navigate = useNavigate();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    try {
      await login(email, password);
      navigate('/');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Login failed');
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
        <h1 className="text-2xl font-bold text-center mb-6">Sign In to Agent Fox</h1>
        {error && <p className="text-red-500 text-sm mb-4">{error}</p>}
        <form onSubmit={handleSubmit} className="space-y-4">
          <input
            type="email"
            placeholder="Email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            className="w-full px-3 py-2 border rounded-md"
            required
          />
          <input
            type="password"
            placeholder="Password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full px-3 py-2 border rounded-md"
            required
          />
          <button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
            Sign In
          </button>
        </form>
        <p className="text-center text-sm mt-4">
          Don't have an account? <Link to="/register" className="text-blue-600 hover:underline">Sign Up</Link>
        </p>
      </div>
    </div>
  );
}

packages/web/src/pages/Register.tsx:

import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../lib/auth';

export default function Register() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const { register } = useAuth();
  const navigate = useNavigate();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    try {
      await register(email, password, name);
      navigate('/');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Registration failed');
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
        <h1 className="text-2xl font-bold text-center mb-6">Create Account</h1>
        {error && <p className="text-red-500 text-sm mb-4">{error}</p>}
        <form onSubmit={handleSubmit} className="space-y-4">
          <input
            type="text"
            placeholder="Name"
            value={name}
            onChange={(e) => setName(e.target.value)}
            className="w-full px-3 py-2 border rounded-md"
            required
          />
          <input
            type="email"
            placeholder="Email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            className="w-full px-3 py-2 border rounded-md"
            required
          />
          <input
            type="password"
            placeholder="Password (min 8 chars)"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full px-3 py-2 border rounded-md"
            minLength={8}
            required
          />
          <button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
            Create Account
          </button>
        </form>
        <p className="text-center text-sm mt-4">
          Already have an account? <Link to="/login" className="text-blue-600 hover:underline">Sign In</Link>
        </p>
      </div>
    </div>
  );
}
  • Step 6: Create Layout component

packages/web/src/pages/Layout.tsx:

import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../lib/auth';

export default function Layout() {
  const { user, loading, logout } = useAuth();

  if (loading) {
    return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
  }

  if (!user) {
    return <Navigate to="/login" replace />;
  }

  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white border-b px-6 py-3 flex items-center justify-between">
        <h1 className="text-lg font-semibold">Agent Fox</h1>
        <div className="flex items-center gap-4">
          <span className="text-sm text-gray-600">{user.name}</span>
          <button onClick={logout} className="text-sm text-red-600 hover:underline">Sign Out</button>
        </div>
      </header>
      <main className="p-6">
        <Outlet />
      </main>
    </div>
  );
}
  • Step 7: Set up routing in App.tsx

packages/web/src/App.tsx:

import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './lib/auth';
import Login from './pages/Login';
import Register from './pages/Register';
import Layout from './pages/Layout';

const queryClient = new QueryClient();

function ProjectsPage() {
  return <div>Projects list  coming next</div>;
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <BrowserRouter>
          <Routes>
            <Route path="/login" element={<Login />} />
            <Route path="/register" element={<Register />} />
            <Route element={<Layout />}>
              <Route path="/" element={<ProjectsPage />} />
            </Route>
            <Route path="*" element={<Navigate to="/" replace />} />
          </Routes>
        </BrowserRouter>
      </AuthProvider>
    </QueryClientProvider>
  );
}
  • Step 8: Add /api/auth/me endpoint to server

Add to packages/server/src/routes/auth.ts before export default router:

import { requireAuth } from '../middleware/auth.js';

router.get('/me', requireAuth, async (req, res) => {
  const user = await prisma.user.findUnique({
    where: { id: req.user!.userId },
    select: { id: true, email: true, name: true, avatarUrl: true },
  });
  if (!user) {
    res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
    return;
  }
  res.json({ success: true, data: user });
});
  • Step 9: Test and commit
pnpm --filter @agent-fox/web dev
# Open http://localhost:5173/login — should see login form
# Register a user, should redirect to / with "Projects list — coming next"
git add packages/web/ packages/server/src/routes/auth.ts
git commit -m "feat: add frontend scaffold with auth pages, routing, and API client"

Task 9: Projects list page and import flow

Files:

  • Create: packages/web/src/pages/Projects.tsx

  • Create: packages/web/src/pages/ImportDialog.tsx

  • Modify: packages/web/src/App.tsx

  • Step 1: Create Projects list page

packages/web/src/pages/Projects.tsx:

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { apiFetch } from '../lib/api';
import ImportDialog from './ImportDialog';

type ProjectSummary = {
  id: string;
  name: string;
  description: string | null;
  openApiVersion: string;
  updatedAt: string;
  _count: { endpoints: number; modules: number };
};

export default function Projects() {
  const [showImport, setShowImport] = useState(false);
  const queryClient = useQueryClient();

  const { data: projects, isLoading } = useQuery({
    queryKey: ['projects'],
    queryFn: () => apiFetch<ProjectSummary[]>('/projects'),
  });

  const deleteMutation = useMutation({
    mutationFn: (id: string) => apiFetch(`/projects/${id}`, { method: 'DELETE' }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
  });

  if (isLoading) return <div>Loading projects...</div>;

  return (
    <div>
      <div className="flex items-center justify-between mb-6">
        <h2 className="text-xl font-semibold">Projects</h2>
        <button
          onClick={() => setShowImport(true)}
          className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
        >
          Import API Doc
        </button>
      </div>

      {projects?.length === 0 && (
        <p className="text-gray-500 text-center py-12">No projects yet. Import an OpenAPI document to get started.</p>
      )}

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {projects?.map((p) => (
          <Link
            key={p.id}
            to={`/projects/${p.id}`}
            className="block p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
          >
            <h3 className="font-medium text-lg">{p.name}</h3>
            {p.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{p.description}</p>}
            <div className="mt-3 flex items-center gap-4 text-xs text-gray-400">
              <span>OpenAPI {p.openApiVersion}</span>
              <span>{p._count.modules} modules</span>
              <span>{p._count.endpoints} endpoints</span>
            </div>
            <button
              onClick={(e) => {
                e.preventDefault();
                if (confirm('Delete this project?')) deleteMutation.mutate(p.id);
              }}
              className="mt-2 text-xs text-red-500 hover:underline"
            >
              Delete
            </button>
          </Link>
        ))}
      </div>

      {showImport && <ImportDialog onClose={() => setShowImport(false)} />}
    </div>
  );
}
  • Step 2: Create ImportDialog component

packages/web/src/pages/ImportDialog.tsx:

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../lib/api';

type ImportResult = {
  project: { id: string; name: string };
  apiKey: string;
  stats: { modules: number; endpoints: number };
};

export default function ImportDialog({ onClose }: { onClose: () => void }) {
  const [mode, setMode] = useState<'url' | 'file'>('url');
  const [url, setUrl] = useState('');
  const [fileContent, setFileContent] = useState<string>('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const [result, setResult] = useState<ImportResult | null>(null);
  const navigate = useNavigate();
  const queryClient = useQueryClient();

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => setFileContent(reader.result as string);
    reader.readAsText(file);
  };

  const handleImport = async () => {
    setLoading(true);
    setError('');
    try {
      let body: Record<string, unknown>;
      if (mode === 'url') {
        body = { specUrl: url };
      } else {
        // Try parsing as JSON first, fall back to sending as string (YAML)
        try {
          body = { spec: JSON.parse(fileContent) };
        } catch {
          body = { spec: fileContent };
        }
      }

      const data = await apiFetch<ImportResult>('/projects', {
        method: 'POST',
        body: JSON.stringify(body),
      });

      setResult(data);
      queryClient.invalidateQueries({ queryKey: ['projects'] });
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Import failed');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
      <div className="bg-white rounded-lg p-6 w-full max-w-lg">
        {!result ? (
          <>
            <h2 className="text-lg font-semibold mb-4">Import OpenAPI Document</h2>

            <div className="flex gap-2 mb-4">
              <button
                onClick={() => setMode('url')}
                className={`px-3 py-1 rounded text-sm ${mode === 'url' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}
              >
                From URL
              </button>
              <button
                onClick={() => setMode('file')}
                className={`px-3 py-1 rounded text-sm ${mode === 'file' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}
              >
                Upload File
              </button>
            </div>

            {mode === 'url' ? (
              <input
                type="url"
                placeholder="https://api.example.com/openapi.json"
                value={url}
                onChange={(e) => setUrl(e.target.value)}
                className="w-full px-3 py-2 border rounded-md mb-4"
              />
            ) : (
              <input
                type="file"
                accept=".json,.yaml,.yml"
                onChange={handleFileChange}
                className="w-full mb-4"
              />
            )}

            {error && <p className="text-red-500 text-sm mb-4">{error}</p>}

            <div className="flex justify-end gap-2">
              <button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-md">
                Cancel
              </button>
              <button
                onClick={handleImport}
                disabled={loading || (mode === 'url' ? !url : !fileContent)}
                className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
              >
                {loading ? 'Importing...' : 'Import'}
              </button>
            </div>
          </>
        ) : (
          <>
            <h2 className="text-lg font-semibold mb-4 text-green-600">Import Successful!</h2>
            <div className="space-y-3 text-sm">
              <p><strong>Project:</strong> {result.project.name}</p>
              <p><strong>Modules:</strong> {result.stats.modules}</p>
              <p><strong>Endpoints:</strong> {result.stats.endpoints}</p>
              <div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
                <p className="font-medium text-yellow-800 mb-1">API Key (save it now  shown only once):</p>
                <code className="text-xs break-all">{result.apiKey}</code>
              </div>
            </div>
            <div className="flex justify-end mt-4">
              <button
                onClick={() => navigate(`/projects/${result.project.id}`)}
                className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
              >
                Go to Project
              </button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}
  • Step 3: Update App.tsx with real Projects route

Replace ProjectsPage placeholder in packages/web/src/App.tsx:

import Projects from './pages/Projects';
// Remove the placeholder function
// In routes: <Route path="/" element={<Projects />} />
  • Step 4: Commit
git add packages/web/
git commit -m "feat: add projects list page with import dialog"

Task 10: Project detail page with tabs

Files:

  • Create: packages/web/src/pages/ProjectDetail.tsx

  • Create: packages/web/src/pages/tabs/DocPreview.tsx

  • Create: packages/web/src/pages/tabs/ModuleManagement.tsx

  • Create: packages/web/src/pages/tabs/McpIntegration.tsx

  • Create: packages/web/src/pages/tabs/ProjectSettings.tsx

  • Modify: packages/web/src/App.tsx (add route)

  • Step 1: Create ProjectDetail page with tab layout

packages/web/src/pages/ProjectDetail.tsx:

import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../lib/api';
import DocPreview from './tabs/DocPreview';
import ModuleManagement from './tabs/ModuleManagement';
import McpIntegration from './tabs/McpIntegration';
import ProjectSettings from './tabs/ProjectSettings';

type ProjectData = {
  id: string;
  name: string;
  description: string | null;
  baseUrl: string | null;
  openApiVersion: string;
  modules: Array<{ id: string; name: string; description: string | null; _count: { endpoints: number } }>;
  _count: { endpoints: number };
};

const tabs = ['Documentation', 'Modules', 'MCP Integration', 'Settings'] as const;
type Tab = (typeof tabs)[number];

export default function ProjectDetail() {
  const { id } = useParams<{ id: string }>();
  const [activeTab, setActiveTab] = useState<Tab>('Documentation');

  const { data: project, isLoading } = useQuery({
    queryKey: ['project', id],
    queryFn: () => apiFetch<ProjectData>(`/projects/${id}`),
  });

  if (isLoading) return <div>Loading...</div>;
  if (!project) return <div>Project not found</div>;

  return (
    <div>
      <div className="mb-4">
        <Link to="/" className="text-sm text-blue-600 hover:underline">&larr; Back to projects</Link>
      </div>

      <div className="flex items-center justify-between mb-6">
        <div>
          <h2 className="text-xl font-semibold">{project.name}</h2>
          {project.description && <p className="text-sm text-gray-500 mt-1">{project.description}</p>}
        </div>
        <div className="text-sm text-gray-400">
          OpenAPI {project.openApiVersion} &middot; {project._count.endpoints} endpoints
        </div>
      </div>

      <div className="border-b mb-6">
        <div className="flex gap-6">
          {tabs.map((tab) => (
            <button
              key={tab}
              onClick={() => setActiveTab(tab)}
              className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
                activeTab === tab
                  ? 'border-blue-600 text-blue-600'
                  : 'border-transparent text-gray-500 hover:text-gray-700'
              }`}
            >
              {tab}
            </button>
          ))}
        </div>
      </div>

      {activeTab === 'Documentation' && <DocPreview projectId={project.id} />}
      {activeTab === 'Modules' && <ModuleManagement projectId={project.id} />}
      {activeTab === 'MCP Integration' && <McpIntegration project={project} />}
      {activeTab === 'Settings' && <ProjectSettings project={project} />}
    </div>
  );
}
  • Step 2: Create DocPreview tab

packages/web/src/pages/tabs/DocPreview.tsx:

import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';

type Module = { id: string; name: string; description: string | null; _count: { endpoints: number } };
type EndpointSummary = {
  id: string; method: string; path: string; summary: string | null;
  deprecated: boolean; module: { name: string };
};
type EndpointFull = EndpointSummary & {
  description: string | null; operationId: string | null;
  parameters: unknown; requestBody: unknown; responses: unknown;
};

const methodColors: Record<string, string> = {
  GET: 'bg-green-100 text-green-800',
  POST: 'bg-blue-100 text-blue-800',
  PUT: 'bg-yellow-100 text-yellow-800',
  DELETE: 'bg-red-100 text-red-800',
  PATCH: 'bg-purple-100 text-purple-800',
};

export default function DocPreview({ projectId }: { projectId: string }) {
  const [selectedModule, setSelectedModule] = useState<string | null>(null);
  const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);

  const { data: modules } = useQuery({
    queryKey: ['modules', projectId],
    queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
  });

  const { data: endpoints } = useQuery({
    queryKey: ['endpoints', projectId, selectedModule],
    queryFn: () =>
      apiFetch<EndpointSummary[]>(
        `/projects/${projectId}/endpoints${selectedModule ? `?moduleId=${selectedModule}` : ''}`,
      ),
  });

  const { data: endpointDetail } = useQuery({
    queryKey: ['endpoint-detail', projectId, expandedEndpoint],
    queryFn: () => apiFetch<EndpointFull>(`/projects/${projectId}/endpoints/${expandedEndpoint}`),
    enabled: !!expandedEndpoint,
  });

  return (
    <div className="flex gap-6">
      {/* Module sidebar */}
      <div className="w-56 shrink-0">
        <h3 className="text-sm font-medium text-gray-700 mb-2">Modules</h3>
        <button
          onClick={() => setSelectedModule(null)}
          className={`block w-full text-left px-3 py-2 rounded text-sm ${
            !selectedModule ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'
          }`}
        >
          All ({modules?.reduce((sum, m) => sum + m._count.endpoints, 0) || 0})
        </button>
        {modules?.map((m) => (
          <button
            key={m.id}
            onClick={() => setSelectedModule(m.id)}
            className={`block w-full text-left px-3 py-2 rounded text-sm ${
              selectedModule === m.id ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'
            }`}
          >
            {m.name} ({m._count.endpoints})
          </button>
        ))}
      </div>

      {/* Endpoint list */}
      <div className="flex-1 space-y-2">
        {endpoints?.map((ep) => (
          <div key={ep.id} className="bg-white rounded-lg border">
            <button
              onClick={() => setExpandedEndpoint(expandedEndpoint === ep.id ? null : ep.id)}
              className="w-full text-left px-4 py-3 flex items-center gap-3"
            >
              <span className={`px-2 py-0.5 rounded text-xs font-mono font-medium ${methodColors[ep.method] || 'bg-gray-100'}`}>
                {ep.method}
              </span>
              <span className="font-mono text-sm">{ep.path}</span>
              {ep.summary && <span className="text-sm text-gray-500 ml-auto truncate max-w-xs">{ep.summary}</span>}
              {ep.deprecated && <span className="text-xs text-orange-500 ml-2">deprecated</span>}
            </button>

            {expandedEndpoint === ep.id && endpointDetail && (
              <div className="border-t px-4 py-3 text-sm space-y-3">
                {endpointDetail.description && <p className="text-gray-600">{endpointDetail.description}</p>}
                {endpointDetail.parameters && (
                  <div>
                    <h4 className="font-medium mb-1">Parameters</h4>
                    <pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">
                      {JSON.stringify(endpointDetail.parameters, null, 2)}
                    </pre>
                  </div>
                )}
                {endpointDetail.requestBody && (
                  <div>
                    <h4 className="font-medium mb-1">Request Body</h4>
                    <pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">
                      {JSON.stringify(endpointDetail.requestBody, null, 2)}
                    </pre>
                  </div>
                )}
                {endpointDetail.responses && (
                  <div>
                    <h4 className="font-medium mb-1">Responses</h4>
                    <pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">
                      {JSON.stringify(endpointDetail.responses, null, 2)}
                    </pre>
                  </div>
                )}
              </div>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}
  • Step 3: Create McpIntegration tab

packages/web/src/pages/tabs/McpIntegration.tsx:

import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';

type Project = { id: string; name: string };

export default function McpIntegration({ project }: { project: Project }) {
  const [apiKey, setApiKey] = useState<string | null>(null);
  const mcpBaseUrl = import.meta.env.VITE_MCP_BASE_URL || 'http://localhost:3001';
  const mcpUrl = `${mcpBaseUrl}/mcp/${project.id}`;

  const rotateMutation = useMutation({
    mutationFn: () => apiFetch<{ apiKey: string }>(`/projects/${project.id}/api-key/rotate`, { method: 'POST' }),
    onSuccess: (data) => setApiKey(data.apiKey),
  });

  const configSnippet = JSON.stringify(
    {
      mcpServers: {
        [project.name.toLowerCase().replace(/\s+/g, '-')]: {
          url: mcpUrl,
          headers: {
            Authorization: `Bearer ${apiKey || '<your-api-key>'}`,
          },
        },
      },
    },
    null,
    2,
  );

  return (
    <div className="space-y-6 max-w-2xl">
      <div>
        <h3 className="font-medium mb-2">MCP Service URL</h3>
        <div className="flex items-center gap-2">
          <code className="flex-1 px-3 py-2 bg-gray-100 rounded text-sm font-mono">{mcpUrl}</code>
          <button
            onClick={() => navigator.clipboard.writeText(mcpUrl)}
            className="px-3 py-2 text-sm bg-gray-200 rounded hover:bg-gray-300"
          >
            Copy
          </button>
        </div>
      </div>

      <div>
        <h3 className="font-medium mb-2">API Key</h3>
        {apiKey ? (
          <div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
            <p className="text-xs text-yellow-700 mb-1">Save this key  it won't be shown again after you leave this page.</p>
            <code className="text-sm break-all">{apiKey}</code>
          </div>
        ) : (
          <p className="text-sm text-gray-500">API key is hidden. Rotate to generate a new one.</p>
        )}
        <button
          onClick={() => {
            if (confirm('This will invalidate the current API key. Continue?')) {
              rotateMutation.mutate();
            }
          }}
          className="mt-2 px-3 py-1 text-sm bg-orange-100 text-orange-700 rounded hover:bg-orange-200"
        >
          Rotate API Key
        </button>
      </div>

      <div>
        <h3 className="font-medium mb-2">Configuration for Claude Code / Cursor</h3>
        <p className="text-sm text-gray-500 mb-2">
          Add this to your MCP client configuration:
        </p>
        <div className="relative">
          <pre className="bg-gray-900 text-green-400 p-4 rounded text-sm overflow-auto">{configSnippet}</pre>
          <button
            onClick={() => navigator.clipboard.writeText(configSnippet)}
            className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-700 text-white rounded hover:bg-gray-600"
          >
            Copy
          </button>
        </div>
      </div>

      <div>
        <h3 className="font-medium mb-2">Available Tools</h3>
        <div className="space-y-2 text-sm">
          <div className="p-3 bg-gray-50 rounded">
            <code className="font-medium">get_project_overview</code>
            <p className="text-gray-500 mt-1">Get project name, version, base URL, and module summary. Call this first.</p>
          </div>
          <div className="p-3 bg-gray-50 rounded">
            <code className="font-medium">list_modules</code>
            <p className="text-gray-500 mt-1">List all modules with descriptions and endpoint counts.</p>
          </div>
          <div className="p-3 bg-gray-50 rounded">
            <code className="font-medium">list_endpoints</code>
            <p className="text-gray-500 mt-1">List endpoints in a module. Provide moduleId.</p>
          </div>
          <div className="p-3 bg-gray-50 rounded">
            <code className="font-medium">get_endpoint_detail</code>
            <p className="text-gray-500 mt-1">Get full endpoint details: parameters, request body, responses.</p>
          </div>
          <div className="p-3 bg-gray-50 rounded">
            <code className="font-medium">search_endpoints</code>
            <p className="text-gray-500 mt-1">Search by keyword across all endpoints. Optional moduleId filter.</p>
          </div>
        </div>
      </div>
    </div>
  );
}
  • Step 4: Create ModuleManagement tab

packages/web/src/pages/tabs/ModuleManagement.tsx:

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';

type Module = { id: string; name: string; description: string | null; sortOrder: number; source: string; _count: { endpoints: number } };

export default function ModuleManagement({ projectId }: { projectId: string }) {
  const [newModuleName, setNewModuleName] = useState('');
  const queryClient = useQueryClient();

  const { data: modules } = useQuery({
    queryKey: ['modules', projectId],
    queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
  });

  const createMutation = useMutation({
    mutationFn: (name: string) =>
      apiFetch(`/projects/${projectId}/modules`, { method: 'POST', body: JSON.stringify({ name }) }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['modules', projectId] });
      setNewModuleName('');
    },
  });

  const deleteMutation = useMutation({
    mutationFn: (moduleId: string) =>
      apiFetch(`/projects/${projectId}/modules/${moduleId}`, { method: 'DELETE' }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', projectId] }),
  });

  return (
    <div className="max-w-2xl space-y-4">
      <div className="flex gap-2">
        <input
          type="text"
          placeholder="New module name"
          value={newModuleName}
          onChange={(e) => setNewModuleName(e.target.value)}
          className="flex-1 px-3 py-2 border rounded-md text-sm"
        />
        <button
          onClick={() => newModuleName && createMutation.mutate(newModuleName)}
          disabled={!newModuleName}
          className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700 disabled:opacity-50"
        >
          Add Module
        </button>
      </div>

      <div className="space-y-2">
        {modules?.map((m) => (
          <div key={m.id} className="flex items-center justify-between p-3 bg-white rounded-lg border">
            <div>
              <span className="font-medium">{m.name}</span>
              <span className="text-xs text-gray-400 ml-2">({m.source})</span>
              <span className="text-xs text-gray-400 ml-2">{m._count.endpoints} endpoints</span>
            </div>
            <button
              onClick={() => {
                if (confirm(`Delete module "${m.name}"? Endpoints in this module will also be deleted.`)) {
                  deleteMutation.mutate(m.id);
                }
              }}
              className="text-xs text-red-500 hover:underline"
            >
              Delete
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}
  • Step 5: Create ProjectSettings tab

packages/web/src/pages/tabs/ProjectSettings.tsx:

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';

type Project = { id: string; name: string; description: string | null };

export default function ProjectSettings({ project }: { project: Project }) {
  const [name, setName] = useState(project.name);
  const [description, setDescription] = useState(project.description || '');
  const navigate = useNavigate();
  const queryClient = useQueryClient();

  const updateMutation = useMutation({
    mutationFn: () =>
      apiFetch(`/projects/${project.id}`, {
        method: 'PUT',
        body: JSON.stringify({ name, description: description || undefined }),
      }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['project', project.id] }),
  });

  const deleteMutation = useMutation({
    mutationFn: () => apiFetch(`/projects/${project.id}`, { method: 'DELETE' }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['projects'] });
      navigate('/');
    },
  });

  return (
    <div className="max-w-lg space-y-6">
      <div className="space-y-3">
        <div>
          <label className="block text-sm font-medium mb-1">Project Name</label>
          <input
            type="text"
            value={name}
            onChange={(e) => setName(e.target.value)}
            className="w-full px-3 py-2 border rounded-md"
          />
        </div>
        <div>
          <label className="block text-sm font-medium mb-1">Description</label>
          <textarea
            value={description}
            onChange={(e) => setDescription(e.target.value)}
            rows={3}
            className="w-full px-3 py-2 border rounded-md"
          />
        </div>
        <button
          onClick={() => updateMutation.mutate()}
          className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700"
        >
          Save Changes
        </button>
      </div>

      <div className="border-t pt-6">
        <h3 className="text-red-600 font-medium mb-2">Danger Zone</h3>
        <button
          onClick={() => {
            if (confirm('Are you sure? This will permanently delete this project and all its data.')) {
              deleteMutation.mutate();
            }
          }}
          className="px-4 py-2 bg-red-600 text-white rounded-md text-sm hover:bg-red-700"
        >
          Delete Project
        </button>
      </div>
    </div>
  );
}
  • Step 6: Add project detail route to App.tsx

Add import and route in packages/web/src/App.tsx:

import ProjectDetail from './pages/ProjectDetail';
// Inside <Route element={<Layout />}>:
//   <Route path="/" element={<Projects />} />
//   <Route path="/projects/:id" element={<ProjectDetail />} />
  • Step 7: Test and commit
pnpm --filter @agent-fox/web dev
# Navigate to a project detail page, verify all 4 tabs work
git add packages/web/
git commit -m "feat: add project detail page with doc preview, module management, MCP integration, and settings tabs"

Phase 6: Docker Compose

Task 11: Dockerfiles and Docker Compose configuration

Files:

  • Create: packages/web/Dockerfile

  • Create: packages/server/Dockerfile

  • Create: packages/mcp/Dockerfile

  • Create: docker-compose.yml

  • Create: docker-compose.dev.yml

  • Create: packages/web/nginx.conf

  • Step 1: Create server Dockerfile

packages/server/Dockerfile:

FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app

FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/shared/package.json packages/shared/
COPY packages/server/package.json packages/server/
COPY prisma/ prisma/
RUN pnpm install --frozen-lockfile

FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/server/node_modules ./packages/server/node_modules
COPY . .
RUN pnpm --filter @agent-fox/shared db:generate
RUN pnpm --filter @agent-fox/shared build
RUN pnpm --filter @agent-fox/server build

FROM base AS runtime
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=build /app/packages/shared/package.json ./packages/shared/
COPY --from=build /app/packages/server/dist ./packages/server/dist
COPY --from=build /app/packages/server/node_modules ./packages/server/node_modules
COPY --from=build /app/packages/server/package.json ./packages/server/
COPY --from=build /app/prisma ./prisma

WORKDIR /app/packages/server
EXPOSE 3000
CMD ["node", "dist/index.js"]
  • Step 2: Create MCP Dockerfile

packages/mcp/Dockerfile:

FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app

FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/shared/package.json packages/shared/
COPY packages/mcp/package.json packages/mcp/
COPY prisma/ prisma/
RUN pnpm install --frozen-lockfile

FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/mcp/node_modules ./packages/mcp/node_modules
COPY . .
RUN pnpm --filter @agent-fox/shared db:generate
RUN pnpm --filter @agent-fox/shared build
RUN pnpm --filter @agent-fox/mcp build

FROM base AS runtime
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=build /app/packages/shared/package.json ./packages/shared/
COPY --from=build /app/packages/mcp/dist ./packages/mcp/dist
COPY --from=build /app/packages/mcp/node_modules ./packages/mcp/node_modules
COPY --from=build /app/packages/mcp/package.json ./packages/mcp/
COPY --from=build /app/prisma ./prisma

WORKDIR /app/packages/mcp
EXPOSE 3001
CMD ["node", "dist/index.js"]
  • Step 3: Create web Dockerfile

packages/web/Dockerfile:

FROM node:20-alpine AS build
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app

COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/web/package.json packages/web/
RUN pnpm install --frozen-lockfile --filter @agent-fox/web

COPY packages/web/ packages/web/
ARG VITE_MCP_BASE_URL
ENV VITE_MCP_BASE_URL=$VITE_MCP_BASE_URL
RUN pnpm --filter @agent-fox/web build

FROM nginx:alpine
COPY --from=build /app/packages/web/dist /usr/share/nginx/html
COPY packages/web/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
  • Step 4: Create Nginx config

packages/web/nginx.conf:

server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    location /api/ {
        proxy_pass http://server:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location /mcp/ {
        proxy_pass http://mcp:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        proxy_buffering off;
        proxy_cache off;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}
  • Step 5: Create docker-compose.yml

docker-compose.yml:

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: agentfox
      POSTGRES_PASSWORD: agentfox
      POSTGRES_DB: agentfox
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U agentfox"]
      interval: 5s
      timeout: 5s
      retries: 5

  server:
    build:
      context: .
      dockerfile: packages/server/Dockerfile
    environment:
      DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
      JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
      JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-refresh-in-production}
      SERVER_PORT: "3000"
    ports:
      - "3000:3000"
    depends_on:
      postgres:
        condition: service_healthy

  mcp:
    build:
      context: .
      dockerfile: packages/mcp/Dockerfile
    environment:
      DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
      MCP_PORT: "3001"
    ports:
      - "3001:3001"
    depends_on:
      postgres:
        condition: service_healthy

  web:
    build:
      context: .
      dockerfile: packages/web/Dockerfile
      args:
        VITE_MCP_BASE_URL: ${MCP_BASE_URL:-http://localhost:3001}
    ports:
      - "80:80"
    depends_on:
      - server
      - mcp

volumes:
  pgdata:
  • Step 6: Create docker-compose.dev.yml

docker-compose.dev.yml:

services:
  postgres:
    ports:
      - "5432:5432"

  server:
    build:
      target: base
    command: pnpm --filter @agent-fox/server dev
    volumes:
      - ./packages/shared/src:/app/packages/shared/src
      - ./packages/server/src:/app/packages/server/src
      - ./prisma:/app/prisma
    environment:
      NODE_ENV: development

  mcp:
    build:
      target: base
    command: pnpm --filter @agent-fox/mcp dev
    volumes:
      - ./packages/shared/src:/app/packages/shared/src
      - ./packages/mcp/src:/app/packages/mcp/src
      - ./prisma:/app/prisma
    environment:
      NODE_ENV: development

  web:
    build:
      target: build
    command: pnpm --filter @agent-fox/web dev -- --host 0.0.0.0
    volumes:
      - ./packages/web/src:/app/packages/web/src
    ports:
      - "5173:5173"
    environment:
      NODE_ENV: development
  • Step 7: Add Docker entries to .gitignore

Append to .gitignore:

# Docker
.env
  • Step 8: Test Docker Compose
cd /Users/kid/Development/Fusion/Projects/agent-fox
docker compose up --build
# Verify:
# - http://localhost:80 — frontend loads
# - http://localhost:3000/api/health — backend responds
# - http://localhost:3001/health — MCP responds
  • Step 9: Commit
git add docker-compose.yml docker-compose.dev.yml packages/web/Dockerfile packages/server/Dockerfile packages/mcp/Dockerfile packages/web/nginx.conf .gitignore
git commit -m "feat: add Docker Compose setup for all services"

Phase 7: Database Migration Runner

Task 12: Add Prisma migration to Docker startup

Files:

  • Create: scripts/migrate-and-start.sh

  • Modify: packages/server/Dockerfile

  • Step 1: Create startup script

scripts/migrate-and-start.sh:

#!/bin/sh
set -e

echo "Running database migrations..."
cd /app
npx prisma migrate deploy --schema=prisma/schema.prisma

echo "Starting server..."
cd /app/packages/server
exec node dist/index.js
  • Step 2: Update server Dockerfile CMD

Replace the CMD in packages/server/Dockerfile runtime stage:

COPY --from=build /app/scripts ./scripts
RUN chmod +x scripts/migrate-and-start.sh

WORKDIR /app
EXPOSE 3000
CMD ["sh", "scripts/migrate-and-start.sh"]
  • Step 3: Commit
git add scripts/ packages/server/Dockerfile
git commit -m "feat: add automatic Prisma migration on server startup"

Verification Plan

End-to-End Test Sequence

  1. Start all services: docker compose up --build
  2. Register a user: POST /api/auth/register
  3. Import Petstore API: POST /api/projects with specUrl: "https://petstore3.swagger.io/api/v3/openapi.json"
  4. Verify modules created: GET /api/projects/:id/modules — should see pet, store, user modules
  5. Configure MCP in Claude Code:
{
  "mcpServers": {
    "petstore": {
      "url": "http://localhost:3001/mcp/<project-id>",
      "headers": { "Authorization": "Bearer <api-key>" }
    }
  }
}
  1. Test MCP tools from Claude Code: Ask Claude "What modules are available in this API?" — should call get_project_overview
  2. Test search: Ask Claude "Find the endpoint for adding a pet" — should call search_endpoints
  3. Test drill-down: Ask Claude "Show me the details for the POST /pet endpoint" — should call get_endpoint_detail