From ccf76fea9588fcce4e09fc43318b8246ca390f21 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Thu, 2 Apr 2026 16:10:43 +0800 Subject: [PATCH] docs: add README.md, update .gitignore, include design and implementation docs --- .gitignore | 30 + README.md | 108 + .../2026-04-02-agent-fox-implementation.md | 3551 +++++++++++++++++ .../specs/2026-04-02-agent-fox-design.md | 335 ++ 4 files changed, 4024 insertions(+) create mode 100644 README.md create mode 100644 docs/superpowers/plans/2026-04-02-agent-fox-implementation.md create mode 100644 docs/superpowers/specs/2026-04-02-agent-fox-design.md diff --git a/.gitignore b/.gitignore index e52f9d5..f659418 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,38 @@ +# Dependencies node_modules/ + +# Build output dist/ + +# Environment .env +.env.local +.env.*.local + +# Logs *.log +npm-debug.log* +pnpm-debug.log* + +# OS .DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo # Docker docker-compose.override.yml + +# Claude Code +.claude/ + +# Prisma +prisma/*.db +prisma/*.db-journal + +# Test coverage +coverage/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..82d647a --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# Agent Fox + +API Documentation MCP Service — 让 LLM 高效检索 API 文档,而非一次性灌入全部内容。 + +## 它是什么 + +Agent Fox 是一个面向开发者的 SaaS 产品。导入 OpenAPI / Swagger 文档后,它会生成一个 MCP 服务端点,供 Claude、GPT 等大模型通过多级检索按需获取接口信息,最小化 token 消耗。 + +**一次典型检索仅需 ~1,300 tokens,而非全量文档的 10,000+ tokens。** + +## 核心功能 + +- **导入 OpenAPI 文档** — 支持 OpenAPI 3.x 和 Swagger 2.0,URL 或文件上传 +- **自动分组** — 按 tags 或 URL 路径前缀自动归类为模块,支持手动调整 +- **MCP 多级检索** — 5 个工具逐层深入:概览 → 模块 → 接口列表 → 接口详情 → 搜索 +- **项目管理** — 多项目、独立 API Key、配置一键复制 + +## 技术栈 + +| 层级 | 技术 | +|------|------| +| 前端 | React 19, Vite, TailwindCSS | +| 后端 | Express 5, TypeScript, Zod | +| MCP | @modelcontextprotocol/sdk, Streamable HTTP | +| 数据库 | PostgreSQL 16, Prisma ORM | +| 部署 | Docker Compose | + +## 快速开始 + +### 前置条件 + +- Node.js >= 20 +- pnpm +- Docker & Docker Compose + +### 启动 + +```bash +# 克隆项目 +git clone agent-fox +cd agent-fox + +# 复制环境变量 +cp .env.example .env + +# 一键启动(开发模式) +docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build + +# 首次运行需要执行数据库迁移 +DATABASE_URL=postgresql://agentfox:agentfox@localhost:5432/agentfox \ + npx prisma migrate deploy --schema=prisma/schema.prisma +``` + +访问 `http://localhost:5173` 使用前端。 + +### 生产模式 + +```bash +docker compose up --build +``` + +生产模式下 server 容器会自动执行数据库迁移,前端通过 Nginx 在 80 端口提供服务。 + +## MCP 接入 + +在 Agent Fox 中导入 API 文档后,将生成的配置添加到你的 MCP 客户端: + +```json +{ + "mcpServers": { + "my-api": { + "type": "http", + "url": "http://localhost:3001/mcp/", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +LLM 即可通过以下工具按需检索文档: + +| 工具 | 说明 | ~Tokens | +|------|------|---------| +| `get_project_overview` | 项目概览 + 模块统计 | 200 | +| `list_modules` | 模块列表含描述 | 100-300 | +| `list_endpoints` | 模块内接口摘要 | 200-500 | +| `get_endpoint_detail` | 完整接口详情 | 500-2000 | +| `search_endpoints` | 关键字搜索 | 200-500 | + +## 项目结构 + +``` +agent-fox/ +├── packages/ +│ ├── web/ # React 前端 +│ ├── server/ # Express 后端 API (端口 3000) +│ ├── mcp/ # MCP 服务 (端口 3001) +│ └── shared/ # Prisma Client + 共享类型 +├── prisma/ # 数据库 Schema + 迁移 +├── docker-compose.yml +└── docker-compose.dev.yml +``` + +## License + +MIT diff --git a/docs/superpowers/plans/2026-04-02-agent-fox-implementation.md b/docs/superpowers/plans/2026-04-02-agent-fox-implementation.md new file mode 100644 index 0000000..32a352f --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-agent-fox-implementation.md @@ -0,0 +1,3551 @@ +# 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** + +```bash +cd /Users/kid/Development/Fusion/Projects/agent-fox +git init +``` + +- [ ] **Step 2: Create root package.json** + +```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** + +```yaml +packages: + - "packages/*" +``` + +- [ ] **Step 4: Create tsconfig.base.json** + +```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** + +```env +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`: +```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`: +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} +``` + +`packages/shared/src/index.ts`: +```typescript +export { prisma } from './db.js'; +export type * from './types.js'; +``` + +`packages/shared/src/db.ts`: +```typescript +import { PrismaClient } from '@prisma/client'; + +export const prisma = new PrismaClient(); +``` + +`packages/shared/src/types.ts`: +```typescript +// Shared types — will be populated as we build features +export type ApiResponse = { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + }; +}; +``` + +- [ ] **Step 8: Create server package skeleton** + +`packages/server/package.json`: +```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`: +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} +``` + +`packages/server/src/index.ts`: +```typescript +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`: +```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`: +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} +``` + +`packages/mcp/src/index.ts`: +```typescript +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** + +```bash +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** + +```bash +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** + +```bash +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`: +```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`: +```typescript +import type { User, Project, Module, Endpoint, ModuleSource } from '@prisma/client'; + +export type { User, Project, Module, Endpoint, ModuleSource }; + +export type ApiResponse = { + 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** + +```bash +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** + +```bash +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** + +```bash +pnpm --filter @agent-fox/shared build +``` +Expected: Compiles without errors, `packages/shared/dist/` created. + +- [ ] **Step 6: Commit** + +```bash +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** + +```bash +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`: +```typescript +import bcrypt from 'bcrypt'; + +const SALT_ROUNDS = 12; + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); +} +``` + +- [ ] **Step 3: Create JWT utilities** + +`packages/server/src/lib/jwt.ts`: +```typescript +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`: +```typescript +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`: +```typescript +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`: +```typescript +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** + +```bash +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** + +```bash +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`: +```typescript +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 { + return bcrypt.compare(raw, hash); +} +``` + +- [ ] **Step 2: Create project routes** + +`packages/server/src/routes/projects.ts`: +```typescript +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: +```typescript +import projectRouter from './routes/projects.js'; +// ... +app.use('/api/projects', projectRouter); +``` + +- [ ] **Step 4: Commit** + +```bash +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** + +```bash +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`: +```typescript +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; + 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 { + // 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(); + 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(); + const pathPrefixes = new Set(); + + 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)[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, + tags: endpointTags, + deprecated: operation.deprecated || false, + moduleName, + }); + } + } + + // Build modules: tags first, then path prefixes for untagged endpoints + const modules: ParsedModule[] = []; + const moduleNames = new Set(); + + // 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`: +```typescript +import { parseOpenApiDocument } from '../services/openapi-parser.js'; +``` + +Add this route before the existing `router.get('/')`: +```typescript +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(); + 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`: +```typescript +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(); + 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`: +```typescript +import importRouter from './routes/import.js'; +// ... +app.use('/api/projects', importRouter); +``` + +- [ ] **Step 6: Test with Petstore sample** + +```bash +curl -X POST http://localhost:3000/api/projects \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -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** + +```bash +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`: +```typescript +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`: +```typescript +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`: +```typescript +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** + +```bash +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`: +```typescript +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 { + 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: +```bash +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`: +```typescript +import { prisma } from '@agent-fox/shared'; +import type { CallToolResult } from '@modelcontextprotocol/server'; + +export async function getProjectOverview(projectId: string): Promise { + 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`: +```typescript +import { prisma } from '@agent-fox/shared'; +import type { CallToolResult } from '@modelcontextprotocol/server'; + +export async function listModules(projectId: string): Promise { + 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`: +```typescript +import { prisma } from '@agent-fox/shared'; +import type { CallToolResult } from '@modelcontextprotocol/server'; + +export async function listEndpoints(projectId: string, moduleId: string): Promise { + // 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`: +```typescript +import { prisma } from '@agent-fox/shared'; +import type { CallToolResult } from '@modelcontextprotocol/server'; + +export async function getEndpointDetail(projectId: string, endpointId: string): Promise { + 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`: +```typescript +import { prisma } from '@agent-fox/shared'; +import type { CallToolResult } from '@modelcontextprotocol/server'; + +export async function searchEndpoints( + projectId: string, + keyword: string, + moduleId?: string, +): Promise { + 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`: +```typescript +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`: +```typescript +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 = {}; + +// 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** + +```bash +pnpm --filter @agent-fox/mcp dev +# Test with MCP inspector or curl: +curl -X POST http://localhost:3001/mcp/ \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -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** + +```bash +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** + +```bash +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`: +```typescript +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`: +```css +@import "tailwindcss"; +``` + +- [ ] **Step 3: Create API client** + +`packages/web/src/lib/api.ts`: +```typescript +const API_BASE = '/api'; + +type ApiResponse = { + 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 { + 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(path: string, options: RequestInit = {}): Promise { + 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 = 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`: +```typescript +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; + register: (email: string, password: string, name: string) => Promise; + logout: () => void; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(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 ( + + {children} + + ); +} + +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`: +```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 ( +
+
+

Sign In to Agent Fox

+ {error &&

{error}

} +
+ setEmail(e.target.value)} + className="w-full px-3 py-2 border rounded-md" + required + /> + setPassword(e.target.value)} + className="w-full px-3 py-2 border rounded-md" + required + /> + +
+

+ Don't have an account? Sign Up +

+
+
+ ); +} +``` + +`packages/web/src/pages/Register.tsx`: +```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 ( +
+
+

Create Account

+ {error &&

{error}

} +
+ setName(e.target.value)} + className="w-full px-3 py-2 border rounded-md" + required + /> + setEmail(e.target.value)} + className="w-full px-3 py-2 border rounded-md" + required + /> + setPassword(e.target.value)} + className="w-full px-3 py-2 border rounded-md" + minLength={8} + required + /> + +
+

+ Already have an account? Sign In +

+
+
+ ); +} +``` + +- [ ] **Step 6: Create Layout component** + +`packages/web/src/pages/Layout.tsx`: +```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
Loading...
; + } + + if (!user) { + return ; + } + + return ( +
+
+

Agent Fox

+
+ {user.name} + +
+
+
+ +
+
+ ); +} +``` + +- [ ] **Step 7: Set up routing in App.tsx** + +`packages/web/src/App.tsx`: +```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
Projects list — coming next
; +} + +export default function App() { + return ( + + + + + } /> + } /> + }> + } /> + + } /> + + + + + ); +} +``` + +- [ ] **Step 8: Add /api/auth/me endpoint to server** + +Add to `packages/server/src/routes/auth.ts` before `export default router`: +```typescript +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** + +```bash +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" +``` + +```bash +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`: +```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('/projects'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiFetch(`/projects/${id}`, { method: 'DELETE' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }), + }); + + if (isLoading) return
Loading projects...
; + + return ( +
+
+

Projects

+ +
+ + {projects?.length === 0 && ( +

No projects yet. Import an OpenAPI document to get started.

+ )} + +
+ {projects?.map((p) => ( + +

{p.name}

+ {p.description &&

{p.description}

} +
+ OpenAPI {p.openApiVersion} + {p._count.modules} modules + {p._count.endpoints} endpoints +
+ + + ))} +
+ + {showImport && setShowImport(false)} />} +
+ ); +} +``` + +- [ ] **Step 2: Create ImportDialog component** + +`packages/web/src/pages/ImportDialog.tsx`: +```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(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [result, setResult] = useState(null); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const handleFileChange = (e: React.ChangeEvent) => { + 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; + 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('/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 ( +
+
+ {!result ? ( + <> +

Import OpenAPI Document

+ +
+ + +
+ + {mode === 'url' ? ( + setUrl(e.target.value)} + className="w-full px-3 py-2 border rounded-md mb-4" + /> + ) : ( + + )} + + {error &&

{error}

} + +
+ + +
+ + ) : ( + <> +

Import Successful!

+
+

Project: {result.project.name}

+

Modules: {result.stats.modules}

+

Endpoints: {result.stats.endpoints}

+
+

API Key (save it now — shown only once):

+ {result.apiKey} +
+
+
+ +
+ + )} +
+
+ ); +} +``` + +- [ ] **Step 3: Update App.tsx with real Projects route** + +Replace `ProjectsPage` placeholder in `packages/web/src/App.tsx`: +```tsx +import Projects from './pages/Projects'; +// Remove the placeholder function +// In routes: } /> +``` + +- [ ] **Step 4: Commit** + +```bash +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`: +```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('Documentation'); + + const { data: project, isLoading } = useQuery({ + queryKey: ['project', id], + queryFn: () => apiFetch(`/projects/${id}`), + }); + + if (isLoading) return
Loading...
; + if (!project) return
Project not found
; + + return ( +
+
+ ← Back to projects +
+ +
+
+

{project.name}

+ {project.description &&

{project.description}

} +
+
+ OpenAPI {project.openApiVersion} · {project._count.endpoints} endpoints +
+
+ +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ + {activeTab === 'Documentation' && } + {activeTab === 'Modules' && } + {activeTab === 'MCP Integration' && } + {activeTab === 'Settings' && } +
+ ); +} +``` + +- [ ] **Step 2: Create DocPreview tab** + +`packages/web/src/pages/tabs/DocPreview.tsx`: +```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 = { + 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(null); + const [expandedEndpoint, setExpandedEndpoint] = useState(null); + + const { data: modules } = useQuery({ + queryKey: ['modules', projectId], + queryFn: () => apiFetch(`/projects/${projectId}/modules`), + }); + + const { data: endpoints } = useQuery({ + queryKey: ['endpoints', projectId, selectedModule], + queryFn: () => + apiFetch( + `/projects/${projectId}/endpoints${selectedModule ? `?moduleId=${selectedModule}` : ''}`, + ), + }); + + const { data: endpointDetail } = useQuery({ + queryKey: ['endpoint-detail', projectId, expandedEndpoint], + queryFn: () => apiFetch(`/projects/${projectId}/endpoints/${expandedEndpoint}`), + enabled: !!expandedEndpoint, + }); + + return ( +
+ {/* Module sidebar */} +
+

Modules

+ + {modules?.map((m) => ( + + ))} +
+ + {/* Endpoint list */} +
+ {endpoints?.map((ep) => ( +
+ + + {expandedEndpoint === ep.id && endpointDetail && ( +
+ {endpointDetail.description &&

{endpointDetail.description}

} + {endpointDetail.parameters && ( +
+

Parameters

+
+                      {JSON.stringify(endpointDetail.parameters, null, 2)}
+                    
+
+ )} + {endpointDetail.requestBody && ( +
+

Request Body

+
+                      {JSON.stringify(endpointDetail.requestBody, null, 2)}
+                    
+
+ )} + {endpointDetail.responses && ( +
+

Responses

+
+                      {JSON.stringify(endpointDetail.responses, null, 2)}
+                    
+
+ )} +
+ )} +
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 3: Create McpIntegration tab** + +`packages/web/src/pages/tabs/McpIntegration.tsx`: +```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(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 || ''}`, + }, + }, + }, + }, + null, + 2, + ); + + return ( +
+
+

MCP Service URL

+
+ {mcpUrl} + +
+
+ +
+

API Key

+ {apiKey ? ( +
+

Save this key — it won't be shown again after you leave this page.

+ {apiKey} +
+ ) : ( +

API key is hidden. Rotate to generate a new one.

+ )} + +
+ +
+

Configuration for Claude Code / Cursor

+

+ Add this to your MCP client configuration: +

+
+
{configSnippet}
+ +
+
+ +
+

Available Tools

+
+
+ get_project_overview +

Get project name, version, base URL, and module summary. Call this first.

+
+
+ list_modules +

List all modules with descriptions and endpoint counts.

+
+
+ list_endpoints +

List endpoints in a module. Provide moduleId.

+
+
+ get_endpoint_detail +

Get full endpoint details: parameters, request body, responses.

+
+
+ search_endpoints +

Search by keyword across all endpoints. Optional moduleId filter.

+
+
+
+
+ ); +} +``` + +- [ ] **Step 4: Create ModuleManagement tab** + +`packages/web/src/pages/tabs/ModuleManagement.tsx`: +```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(`/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 ( +
+
+ setNewModuleName(e.target.value)} + className="flex-1 px-3 py-2 border rounded-md text-sm" + /> + +
+ +
+ {modules?.map((m) => ( +
+
+ {m.name} + ({m.source}) + {m._count.endpoints} endpoints +
+ +
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 5: Create ProjectSettings tab** + +`packages/web/src/pages/tabs/ProjectSettings.tsx`: +```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 ( +
+
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 border rounded-md" + /> +
+
+ +