# 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" />