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

3552 lines
103 KiB
Markdown

# 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<T = unknown> = {
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<T = unknown> = {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
};
};
export type ProjectWithStats = Project & {
_count: { endpoints: number; modules: number };
};
export type ModuleWithCount = Module & {
_count: { endpoints: number };
};
export type EndpointSummary = {
id: string;
method: string;
path: string;
summary: string | null;
deprecated: boolean;
};
export type EndpointDetail = Endpoint & {
moduleName: string;
};
```
- [ ] **Step 3: Create .env file from example and generate Prisma client**
```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<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
```
- [ ] **Step 3: Create JWT utilities**
`packages/server/src/lib/jwt.ts`:
```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<boolean> {
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<string, unknown>;
tags: string[];
deprecated: boolean;
moduleName: string; // References which module this belongs to
};
export type ParseResult = {
name: string;
description: string | null;
version: string;
openApiVersion: string;
baseUrl: string | null;
spec: unknown; // Full dereferenced spec
modules: ParsedModule[];
endpoints: ParsedEndpoint[];
};
export async function parseOpenApiDocument(input: string | object): Promise<ParseResult> {
// Validate and dereference
const rawApi = await SwaggerParser.validate(input);
const api = await SwaggerParser.dereference(rawApi) as OpenApiDoc;
const openApiVersion = 'openapi' in api ? api.openapi : 'unknown';
const name = api.info.title;
const description = api.info.description || null;
const version = api.info.version;
const baseUrl = api.servers?.[0]?.url || null;
// Collect all tags defined at the top level
const tagMap = new Map<string, string | null>();
if (api.tags) {
for (const tag of api.tags) {
tagMap.set(tag.name, tag.description || null);
}
}
// Parse paths into endpoints, collecting used tags
const endpoints: ParsedEndpoint[] = [];
const usedTags = new Set<string>();
const pathPrefixes = new Set<string>();
const paths = api.paths || {};
for (const [pathStr, pathItem] of Object.entries(paths)) {
if (!pathItem) continue;
const methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] as const;
for (const method of methods) {
const operation = (pathItem as Record<string, unknown>)[method] as OpenAPIV3.OperationObject | undefined;
if (!operation) continue;
const endpointTags = operation.tags || [];
for (const tag of endpointTags) {
usedTags.add(tag);
if (!tagMap.has(tag)) {
tagMap.set(tag, null);
}
}
// Extract path prefix for tagless endpoints
const prefix = pathStr.split('/').filter(Boolean)[0] || 'default';
pathPrefixes.add(prefix);
const moduleName = endpointTags[0] || prefix;
endpoints.push({
method: method.toUpperCase(),
path: pathStr,
summary: operation.summary || null,
description: operation.description || null,
operationId: operation.operationId || null,
parameters: (operation.parameters || []) as unknown[],
requestBody: operation.requestBody || null,
responses: (operation.responses || {}) as Record<string, unknown>,
tags: endpointTags,
deprecated: operation.deprecated || false,
moduleName,
});
}
}
// Build modules: tags first, then path prefixes for untagged endpoints
const modules: ParsedModule[] = [];
const moduleNames = new Set<string>();
// Add tag-based modules
for (const [tagName, tagDesc] of tagMap) {
if (usedTags.has(tagName)) {
modules.push({ name: tagName, description: tagDesc, source: 'tag' });
moduleNames.add(tagName);
}
}
// Add path-prefix modules for endpoints that have no tags
for (const endpoint of endpoints) {
if (endpoint.tags.length === 0 && !moduleNames.has(endpoint.moduleName)) {
modules.push({ name: endpoint.moduleName, description: null, source: 'path_prefix' });
moduleNames.add(endpoint.moduleName);
}
}
return {
name,
description,
version,
openApiVersion,
baseUrl,
spec: api,
modules,
endpoints,
};
}
```
- [ ] **Step 3: Add project creation route with import**
Add to the top of `packages/server/src/routes/projects.ts`:
```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<string, string>();
for (let i = 0; i < parsed.modules.length; i++) {
const mod = parsed.modules[i];
const created = await tx.module.create({
data: {
projectId: proj.id,
name: mod.name,
description: mod.description,
sortOrder: i,
source: mod.source,
},
});
moduleIdMap.set(mod.name, created.id);
}
// Create endpoints
for (const ep of parsed.endpoints) {
const moduleId = moduleIdMap.get(ep.moduleName);
if (!moduleId) continue;
await tx.endpoint.create({
data: {
projectId: proj.id,
moduleId,
method: ep.method,
path: ep.path,
summary: ep.summary,
description: ep.description,
operationId: ep.operationId,
parameters: ep.parameters as any,
requestBody: ep.requestBody as any,
responses: ep.responses as any,
tags: ep.tags,
deprecated: ep.deprecated,
},
});
}
return proj;
});
res.status(201).json({
success: true,
data: {
project: { id: project.id, name: project.name },
apiKey, // Only returned once at creation
stats: { modules: parsed.modules.length, endpoints: parsed.endpoints.length },
},
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to parse OpenAPI document';
res.status(400).json({ success: false, error: { code: 'PARSE_ERROR', message } });
}
});
```
- [ ] **Step 4: Add reimport route**
Create `packages/server/src/routes/import.ts`:
```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<string, string>();
for (let i = 0; i < parsed.modules.length; i++) {
const mod = parsed.modules[i];
const created = await tx.module.create({
data: {
projectId: project.id,
name: mod.name,
description: mod.description,
sortOrder: i,
source: mod.source,
},
});
moduleIdMap.set(mod.name, created.id);
}
// Recreate endpoints
for (const ep of parsed.endpoints) {
const moduleId = moduleIdMap.get(ep.moduleName);
if (!moduleId) continue;
await tx.endpoint.create({
data: {
projectId: project.id,
moduleId,
method: ep.method,
path: ep.path,
summary: ep.summary,
description: ep.description,
operationId: ep.operationId,
parameters: ep.parameters as any,
requestBody: ep.requestBody as any,
responses: ep.responses as any,
tags: ep.tags,
deprecated: ep.deprecated,
},
});
}
});
res.json({
success: true,
data: { stats: { modules: parsed.modules.length, endpoints: parsed.endpoints.length } },
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to parse OpenAPI document';
res.status(400).json({ success: false, error: { code: 'PARSE_ERROR', message } });
}
});
export default router;
```
- [ ] **Step 5: Wire import routes into Express app**
Add to `packages/server/src/index.ts`:
```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 <access-token-from-login>" \
-d '{"specUrl":"https://petstore3.swagger.io/api/v3/openapi.json"}'
# Expected: 201 with project ID, API key, and stats showing modules and endpoints
```
- [ ] **Step 7: Commit**
```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<void> {
const projectId = req.params.projectId;
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing API key' });
return;
}
const apiKey = header.slice(7);
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { id: true, apiKeyHash: true },
});
if (!project) {
res.status(404).json({ error: 'Project not found' });
return;
}
const valid = await bcrypt.compare(apiKey, project.apiKeyHash);
if (!valid) {
res.status(401).json({ error: 'Invalid API key' });
return;
}
// Store projectId for tools to use
(req as any).projectId = projectId;
next();
}
```
Add bcrypt dependency:
```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<CallToolResult> {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: {
name: true,
description: true,
openApiVersion: true,
baseUrl: true,
modules: {
select: { id: true, name: true, _count: { select: { endpoints: true } } },
orderBy: { sortOrder: 'asc' },
},
_count: { select: { endpoints: true } },
},
});
if (!project) {
return { content: [{ type: 'text', text: 'Project not found' }], isError: true };
}
const overview = {
name: project.name,
description: project.description,
version: project.openApiVersion,
baseUrl: project.baseUrl,
totalEndpoints: project._count.endpoints,
modules: project.modules.map((m) => ({
id: m.id,
name: m.name,
endpointCount: m._count.endpoints,
})),
};
return { content: [{ type: 'text', text: JSON.stringify(overview, null, 2) }] };
}
```
- [ ] **Step 3: Create list_modules tool**
`packages/mcp/src/tools/list-modules.ts`:
```typescript
import { prisma } from '@agent-fox/shared';
import type { CallToolResult } from '@modelcontextprotocol/server';
export async function listModules(projectId: string): Promise<CallToolResult> {
const modules = await prisma.module.findMany({
where: { projectId },
select: {
id: true,
name: true,
description: true,
_count: { select: { endpoints: true } },
},
orderBy: { sortOrder: 'asc' },
});
const result = modules.map((m) => ({
id: m.id,
name: m.name,
description: m.description,
endpointCount: m._count.endpoints,
}));
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
}
```
- [ ] **Step 4: Create list_endpoints tool**
`packages/mcp/src/tools/list-endpoints.ts`:
```typescript
import { prisma } from '@agent-fox/shared';
import type { CallToolResult } from '@modelcontextprotocol/server';
export async function listEndpoints(projectId: string, moduleId: string): Promise<CallToolResult> {
// Verify module belongs to project
const mod = await prisma.module.findFirst({
where: { id: moduleId, projectId },
});
if (!mod) {
return { content: [{ type: 'text', text: `Module "${moduleId}" not found in this project. Use get_project_overview or list_modules to see available modules.` }], isError: true };
}
const endpoints = await prisma.endpoint.findMany({
where: { projectId, moduleId },
select: {
id: true,
method: true,
path: true,
summary: true,
deprecated: true,
},
orderBy: [{ path: 'asc' }, { method: 'asc' }],
});
const result = endpoints.map((e) => ({
id: e.id,
method: e.method,
path: e.path,
summary: e.summary,
deprecated: e.deprecated,
}));
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
}
```
- [ ] **Step 5: Create get_endpoint_detail tool**
`packages/mcp/src/tools/get-endpoint-detail.ts`:
```typescript
import { prisma } from '@agent-fox/shared';
import type { CallToolResult } from '@modelcontextprotocol/server';
export async function getEndpointDetail(projectId: string, endpointId: string): Promise<CallToolResult> {
const endpoint = await prisma.endpoint.findFirst({
where: { id: endpointId, projectId },
include: { module: { select: { name: true } } },
});
if (!endpoint) {
return { content: [{ type: 'text', text: `Endpoint "${endpointId}" not found. Use list_endpoints to see available endpoints in a module.` }], isError: true };
}
const detail = {
id: endpoint.id,
method: endpoint.method,
path: endpoint.path,
summary: endpoint.summary,
description: endpoint.description,
operationId: endpoint.operationId,
moduleName: endpoint.module.name,
parameters: endpoint.parameters,
requestBody: endpoint.requestBody,
responses: endpoint.responses,
deprecated: endpoint.deprecated,
};
return { content: [{ type: 'text', text: JSON.stringify(detail, null, 2) }] };
}
```
- [ ] **Step 6: Create search_endpoints tool**
`packages/mcp/src/tools/search-endpoints.ts`:
```typescript
import { prisma } from '@agent-fox/shared';
import type { CallToolResult } from '@modelcontextprotocol/server';
export async function searchEndpoints(
projectId: string,
keyword: string,
moduleId?: string,
): Promise<CallToolResult> {
const lowerKeyword = keyword.toLowerCase();
const where: any = { projectId };
if (moduleId) where.moduleId = moduleId;
// Search across path, summary, description, operationId
where.OR = [
{ path: { contains: lowerKeyword, mode: 'insensitive' } },
{ summary: { contains: lowerKeyword, mode: 'insensitive' } },
{ description: { contains: lowerKeyword, mode: 'insensitive' } },
{ operationId: { contains: lowerKeyword, mode: 'insensitive' } },
];
const endpoints = await prisma.endpoint.findMany({
where,
select: {
id: true,
method: true,
path: true,
summary: true,
deprecated: true,
module: { select: { name: true } },
},
orderBy: [{ path: 'asc' }, { method: 'asc' }],
take: 20, // Limit results to keep token count manageable
});
if (endpoints.length === 0) {
return { content: [{ type: 'text', text: `No endpoints found matching "${keyword}". Try a different keyword or use list_modules to browse by module.` }] };
}
const result = endpoints.map((e) => ({
id: e.id,
method: e.method,
path: e.path,
summary: e.summary,
moduleName: e.module.name,
deprecated: e.deprecated,
}));
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
}
```
- [ ] **Step 7: Create MCP server factory**
`packages/mcp/src/server.ts`:
```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<string, NodeStreamableHTTPServerTransport> = {};
// MCP Streamable HTTP endpoint
app.post('/mcp/:projectId', mcpAuth, async (req, res) => {
const projectId = (req as any).projectId as string;
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId && transports[sessionId]) {
await transports[sessionId].handleRequest(req, res, req.body);
return;
}
if (!sessionId && isInitializeRequest(req.body)) {
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
transports[id] = transport;
},
});
transport.onclose = () => {
if (transport.sessionId) {
delete transports[transport.sessionId];
}
};
const server = createMcpServer(projectId);
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
}
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Invalid session. Send an initialize request without a session ID to start a new session.' },
id: null,
});
});
// SSE endpoint for session resumption
app.get('/mcp/:projectId', mcpAuth, async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string;
if (sessionId && transports[sessionId]) {
await transports[sessionId].handleRequest(req, res);
} else {
res.status(400).json({ error: 'Invalid session. Start a new session via POST.' });
}
});
// Session termination
app.delete('/mcp/:projectId', mcpAuth, async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string;
if (sessionId && transports[sessionId]) {
await transports[sessionId].close();
delete transports[sessionId];
res.status(204).end();
} else {
res.status(400).json({ error: 'Invalid session' });
}
});
const port = process.env.MCP_PORT || 3001;
app.listen(port, () => {
console.log(`MCP service running on port ${port}`);
});
```
- [ ] **Step 9: Test MCP service**
```bash
pnpm --filter @agent-fox/mcp dev
# Test with MCP inspector or curl:
curl -X POST http://localhost:3001/mcp/<project-id> \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <api-key>" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
# Expected: 200 with initialize response and mcp-session-id header
```
- [ ] **Step 10: Commit**
```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<T> = {
success: boolean;
data?: T;
error?: { code: string; message: string };
};
let accessToken: string | null = localStorage.getItem('accessToken');
let refreshToken: string | null = localStorage.getItem('refreshToken');
export function setTokens(access: string, refresh: string) {
accessToken = access;
refreshToken = refresh;
localStorage.setItem('accessToken', access);
localStorage.setItem('refreshToken', refresh);
}
export function clearTokens() {
accessToken = null;
refreshToken = null;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
export function getAccessToken() {
return accessToken;
}
async function refreshAccessToken(): Promise<boolean> {
if (!refreshToken) return false;
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) return false;
const json: ApiResponse<{ accessToken: string; refreshToken: string }> = await res.json();
if (json.success && json.data) {
setTokens(json.data.accessToken, json.data.refreshToken);
return true;
}
return false;
} catch {
return false;
}
}
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
headers.set('Content-Type', 'application/json');
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
// Auto-refresh on 401
if (res.status === 401 && refreshToken) {
const refreshed = await refreshAccessToken();
if (refreshed) {
headers.set('Authorization', `Bearer ${accessToken}`);
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
}
}
const json: ApiResponse<T> = await res.json();
if (!json.success) {
throw new Error(json.error?.message || 'Request failed');
}
return json.data as T;
}
```
- [ ] **Step 4: Create auth context**
`packages/web/src/lib/auth.tsx`:
```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<void>;
register: (email: string, password: string, name: string) => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check if we have a valid token on mount
if (getAccessToken()) {
apiFetch<{ id: string; email: string; name: string }>('/auth/me')
.then(setUser)
.catch(() => clearTokens())
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = async (email: string, password: string) => {
const data = await apiFetch<{ user: User; accessToken: string; refreshToken: string }>(
'/auth/login',
{ method: 'POST', body: JSON.stringify({ email, password }) },
);
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
};
const register = async (email: string, password: string, name: string) => {
const data = await apiFetch<{ user: User; accessToken: string; refreshToken: string }>(
'/auth/register',
{ method: 'POST', body: JSON.stringify({ email, password, name }) },
);
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
};
const logout = () => {
clearTokens();
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
```
- [ ] **Step 5: Create Login and Register pages**
`packages/web/src/pages/Login.tsx`:
```tsx
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../lib/auth';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(email, password);
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
<h1 className="text-2xl font-bold text-center mb-6">Sign In to Agent Fox</h1>
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border rounded-md"
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border rounded-md"
required
/>
<button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Sign In
</button>
</form>
<p className="text-center text-sm mt-4">
Don't have an account? <Link to="/register" className="text-blue-600 hover:underline">Sign Up</Link>
</p>
</div>
</div>
);
}
```
`packages/web/src/pages/Register.tsx`:
```tsx
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../lib/auth';
export default function Register() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await register(email, password, name);
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
<h1 className="text-2xl font-bold text-center mb-6">Create Account</h1>
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded-md"
required
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border rounded-md"
required
/>
<input
type="password"
placeholder="Password (min 8 chars)"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border rounded-md"
minLength={8}
required
/>
<button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Create Account
</button>
</form>
<p className="text-center text-sm mt-4">
Already have an account? <Link to="/login" className="text-blue-600 hover:underline">Sign In</Link>
</p>
</div>
</div>
);
}
```
- [ ] **Step 6: Create Layout component**
`packages/web/src/pages/Layout.tsx`:
```tsx
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../lib/auth';
export default function Layout() {
const { user, loading, logout } = useAuth();
if (loading) {
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
}
if (!user) {
return <Navigate to="/login" replace />;
}
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-6 py-3 flex items-center justify-between">
<h1 className="text-lg font-semibold">Agent Fox</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{user.name}</span>
<button onClick={logout} className="text-sm text-red-600 hover:underline">Sign Out</button>
</div>
</header>
<main className="p-6">
<Outlet />
</main>
</div>
);
}
```
- [ ] **Step 7: Set up routing in App.tsx**
`packages/web/src/App.tsx`:
```tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './lib/auth';
import Login from './pages/Login';
import Register from './pages/Register';
import Layout from './pages/Layout';
const queryClient = new QueryClient();
function ProjectsPage() {
return <div>Projects list coming next</div>;
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route element={<Layout />}>
<Route path="/" element={<ProjectsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
);
}
```
- [ ] **Step 8: Add /api/auth/me endpoint to server**
Add to `packages/server/src/routes/auth.ts` before `export default router`:
```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<ProjectSummary[]>('/projects'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiFetch(`/projects/${id}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
});
if (isLoading) return <div>Loading projects...</div>;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">Projects</h2>
<button
onClick={() => setShowImport(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Import API Doc
</button>
</div>
{projects?.length === 0 && (
<p className="text-gray-500 text-center py-12">No projects yet. Import an OpenAPI document to get started.</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects?.map((p) => (
<Link
key={p.id}
to={`/projects/${p.id}`}
className="block p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<h3 className="font-medium text-lg">{p.name}</h3>
{p.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{p.description}</p>}
<div className="mt-3 flex items-center gap-4 text-xs text-gray-400">
<span>OpenAPI {p.openApiVersion}</span>
<span>{p._count.modules} modules</span>
<span>{p._count.endpoints} endpoints</span>
</div>
<button
onClick={(e) => {
e.preventDefault();
if (confirm('Delete this project?')) deleteMutation.mutate(p.id);
}}
className="mt-2 text-xs text-red-500 hover:underline"
>
Delete
</button>
</Link>
))}
</div>
{showImport && <ImportDialog onClose={() => setShowImport(false)} />}
</div>
);
}
```
- [ ] **Step 2: Create ImportDialog component**
`packages/web/src/pages/ImportDialog.tsx`:
```tsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../lib/api';
type ImportResult = {
project: { id: string; name: string };
apiKey: string;
stats: { modules: number; endpoints: number };
};
export default function ImportDialog({ onClose }: { onClose: () => void }) {
const [mode, setMode] = useState<'url' | 'file'>('url');
const [url, setUrl] = useState('');
const [fileContent, setFileContent] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<ImportResult | null>(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setFileContent(reader.result as string);
reader.readAsText(file);
};
const handleImport = async () => {
setLoading(true);
setError('');
try {
let body: Record<string, unknown>;
if (mode === 'url') {
body = { specUrl: url };
} else {
// Try parsing as JSON first, fall back to sending as string (YAML)
try {
body = { spec: JSON.parse(fileContent) };
} catch {
body = { spec: fileContent };
}
}
const data = await apiFetch<ImportResult>('/projects', {
method: 'POST',
body: JSON.stringify(body),
});
setResult(data);
queryClient.invalidateQueries({ queryKey: ['projects'] });
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-lg">
{!result ? (
<>
<h2 className="text-lg font-semibold mb-4">Import OpenAPI Document</h2>
<div className="flex gap-2 mb-4">
<button
onClick={() => setMode('url')}
className={`px-3 py-1 rounded text-sm ${mode === 'url' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}
>
From URL
</button>
<button
onClick={() => setMode('file')}
className={`px-3 py-1 rounded text-sm ${mode === 'file' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}
>
Upload File
</button>
</div>
{mode === 'url' ? (
<input
type="url"
placeholder="https://api.example.com/openapi.json"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="w-full px-3 py-2 border rounded-md mb-4"
/>
) : (
<input
type="file"
accept=".json,.yaml,.yml"
onChange={handleFileChange}
className="w-full mb-4"
/>
)}
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
<div className="flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-md">
Cancel
</button>
<button
onClick={handleImport}
disabled={loading || (mode === 'url' ? !url : !fileContent)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Importing...' : 'Import'}
</button>
</div>
</>
) : (
<>
<h2 className="text-lg font-semibold mb-4 text-green-600">Import Successful!</h2>
<div className="space-y-3 text-sm">
<p><strong>Project:</strong> {result.project.name}</p>
<p><strong>Modules:</strong> {result.stats.modules}</p>
<p><strong>Endpoints:</strong> {result.stats.endpoints}</p>
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
<p className="font-medium text-yellow-800 mb-1">API Key (save it now shown only once):</p>
<code className="text-xs break-all">{result.apiKey}</code>
</div>
</div>
<div className="flex justify-end mt-4">
<button
onClick={() => navigate(`/projects/${result.project.id}`)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Go to Project
</button>
</div>
</>
)}
</div>
</div>
);
}
```
- [ ] **Step 3: Update App.tsx with real Projects route**
Replace `ProjectsPage` placeholder in `packages/web/src/App.tsx`:
```tsx
import Projects from './pages/Projects';
// Remove the placeholder function
// In routes: <Route path="/" element={<Projects />} />
```
- [ ] **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<Tab>('Documentation');
const { data: project, isLoading } = useQuery({
queryKey: ['project', id],
queryFn: () => apiFetch<ProjectData>(`/projects/${id}`),
});
if (isLoading) return <div>Loading...</div>;
if (!project) return <div>Project not found</div>;
return (
<div>
<div className="mb-4">
<Link to="/" className="text-sm text-blue-600 hover:underline">&larr; Back to projects</Link>
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold">{project.name}</h2>
{project.description && <p className="text-sm text-gray-500 mt-1">{project.description}</p>}
</div>
<div className="text-sm text-gray-400">
OpenAPI {project.openApiVersion} &middot; {project._count.endpoints} endpoints
</div>
</div>
<div className="border-b mb-6">
<div className="flex gap-6">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab}
</button>
))}
</div>
</div>
{activeTab === 'Documentation' && <DocPreview projectId={project.id} />}
{activeTab === 'Modules' && <ModuleManagement projectId={project.id} />}
{activeTab === 'MCP Integration' && <McpIntegration project={project} />}
{activeTab === 'Settings' && <ProjectSettings project={project} />}
</div>
);
}
```
- [ ] **Step 2: Create DocPreview tab**
`packages/web/src/pages/tabs/DocPreview.tsx`:
```tsx
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
type Module = { id: string; name: string; description: string | null; _count: { endpoints: number } };
type EndpointSummary = {
id: string; method: string; path: string; summary: string | null;
deprecated: boolean; module: { name: string };
};
type EndpointFull = EndpointSummary & {
description: string | null; operationId: string | null;
parameters: unknown; requestBody: unknown; responses: unknown;
};
const methodColors: Record<string, string> = {
GET: 'bg-green-100 text-green-800',
POST: 'bg-blue-100 text-blue-800',
PUT: 'bg-yellow-100 text-yellow-800',
DELETE: 'bg-red-100 text-red-800',
PATCH: 'bg-purple-100 text-purple-800',
};
export default function DocPreview({ projectId }: { projectId: string }) {
const [selectedModule, setSelectedModule] = useState<string | null>(null);
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
const { data: modules } = useQuery({
queryKey: ['modules', projectId],
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
});
const { data: endpoints } = useQuery({
queryKey: ['endpoints', projectId, selectedModule],
queryFn: () =>
apiFetch<EndpointSummary[]>(
`/projects/${projectId}/endpoints${selectedModule ? `?moduleId=${selectedModule}` : ''}`,
),
});
const { data: endpointDetail } = useQuery({
queryKey: ['endpoint-detail', projectId, expandedEndpoint],
queryFn: () => apiFetch<EndpointFull>(`/projects/${projectId}/endpoints/${expandedEndpoint}`),
enabled: !!expandedEndpoint,
});
return (
<div className="flex gap-6">
{/* Module sidebar */}
<div className="w-56 shrink-0">
<h3 className="text-sm font-medium text-gray-700 mb-2">Modules</h3>
<button
onClick={() => setSelectedModule(null)}
className={`block w-full text-left px-3 py-2 rounded text-sm ${
!selectedModule ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'
}`}
>
All ({modules?.reduce((sum, m) => sum + m._count.endpoints, 0) || 0})
</button>
{modules?.map((m) => (
<button
key={m.id}
onClick={() => setSelectedModule(m.id)}
className={`block w-full text-left px-3 py-2 rounded text-sm ${
selectedModule === m.id ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'
}`}
>
{m.name} ({m._count.endpoints})
</button>
))}
</div>
{/* Endpoint list */}
<div className="flex-1 space-y-2">
{endpoints?.map((ep) => (
<div key={ep.id} className="bg-white rounded-lg border">
<button
onClick={() => setExpandedEndpoint(expandedEndpoint === ep.id ? null : ep.id)}
className="w-full text-left px-4 py-3 flex items-center gap-3"
>
<span className={`px-2 py-0.5 rounded text-xs font-mono font-medium ${methodColors[ep.method] || 'bg-gray-100'}`}>
{ep.method}
</span>
<span className="font-mono text-sm">{ep.path}</span>
{ep.summary && <span className="text-sm text-gray-500 ml-auto truncate max-w-xs">{ep.summary}</span>}
{ep.deprecated && <span className="text-xs text-orange-500 ml-2">deprecated</span>}
</button>
{expandedEndpoint === ep.id && endpointDetail && (
<div className="border-t px-4 py-3 text-sm space-y-3">
{endpointDetail.description && <p className="text-gray-600">{endpointDetail.description}</p>}
{endpointDetail.parameters && (
<div>
<h4 className="font-medium mb-1">Parameters</h4>
<pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">
{JSON.stringify(endpointDetail.parameters, null, 2)}
</pre>
</div>
)}
{endpointDetail.requestBody && (
<div>
<h4 className="font-medium mb-1">Request Body</h4>
<pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">
{JSON.stringify(endpointDetail.requestBody, null, 2)}
</pre>
</div>
)}
{endpointDetail.responses && (
<div>
<h4 className="font-medium mb-1">Responses</h4>
<pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">
{JSON.stringify(endpointDetail.responses, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
);
}
```
- [ ] **Step 3: Create McpIntegration tab**
`packages/web/src/pages/tabs/McpIntegration.tsx`:
```tsx
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
type Project = { id: string; name: string };
export default function McpIntegration({ project }: { project: Project }) {
const [apiKey, setApiKey] = useState<string | null>(null);
const mcpBaseUrl = import.meta.env.VITE_MCP_BASE_URL || 'http://localhost:3001';
const mcpUrl = `${mcpBaseUrl}/mcp/${project.id}`;
const rotateMutation = useMutation({
mutationFn: () => apiFetch<{ apiKey: string }>(`/projects/${project.id}/api-key/rotate`, { method: 'POST' }),
onSuccess: (data) => setApiKey(data.apiKey),
});
const configSnippet = JSON.stringify(
{
mcpServers: {
[project.name.toLowerCase().replace(/\s+/g, '-')]: {
url: mcpUrl,
headers: {
Authorization: `Bearer ${apiKey || '<your-api-key>'}`,
},
},
},
},
null,
2,
);
return (
<div className="space-y-6 max-w-2xl">
<div>
<h3 className="font-medium mb-2">MCP Service URL</h3>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-gray-100 rounded text-sm font-mono">{mcpUrl}</code>
<button
onClick={() => navigator.clipboard.writeText(mcpUrl)}
className="px-3 py-2 text-sm bg-gray-200 rounded hover:bg-gray-300"
>
Copy
</button>
</div>
</div>
<div>
<h3 className="font-medium mb-2">API Key</h3>
{apiKey ? (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
<p className="text-xs text-yellow-700 mb-1">Save this key it won't be shown again after you leave this page.</p>
<code className="text-sm break-all">{apiKey}</code>
</div>
) : (
<p className="text-sm text-gray-500">API key is hidden. Rotate to generate a new one.</p>
)}
<button
onClick={() => {
if (confirm('This will invalidate the current API key. Continue?')) {
rotateMutation.mutate();
}
}}
className="mt-2 px-3 py-1 text-sm bg-orange-100 text-orange-700 rounded hover:bg-orange-200"
>
Rotate API Key
</button>
</div>
<div>
<h3 className="font-medium mb-2">Configuration for Claude Code / Cursor</h3>
<p className="text-sm text-gray-500 mb-2">
Add this to your MCP client configuration:
</p>
<div className="relative">
<pre className="bg-gray-900 text-green-400 p-4 rounded text-sm overflow-auto">{configSnippet}</pre>
<button
onClick={() => navigator.clipboard.writeText(configSnippet)}
className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-700 text-white rounded hover:bg-gray-600"
>
Copy
</button>
</div>
</div>
<div>
<h3 className="font-medium mb-2">Available Tools</h3>
<div className="space-y-2 text-sm">
<div className="p-3 bg-gray-50 rounded">
<code className="font-medium">get_project_overview</code>
<p className="text-gray-500 mt-1">Get project name, version, base URL, and module summary. Call this first.</p>
</div>
<div className="p-3 bg-gray-50 rounded">
<code className="font-medium">list_modules</code>
<p className="text-gray-500 mt-1">List all modules with descriptions and endpoint counts.</p>
</div>
<div className="p-3 bg-gray-50 rounded">
<code className="font-medium">list_endpoints</code>
<p className="text-gray-500 mt-1">List endpoints in a module. Provide moduleId.</p>
</div>
<div className="p-3 bg-gray-50 rounded">
<code className="font-medium">get_endpoint_detail</code>
<p className="text-gray-500 mt-1">Get full endpoint details: parameters, request body, responses.</p>
</div>
<div className="p-3 bg-gray-50 rounded">
<code className="font-medium">search_endpoints</code>
<p className="text-gray-500 mt-1">Search by keyword across all endpoints. Optional moduleId filter.</p>
</div>
</div>
</div>
</div>
);
}
```
- [ ] **Step 4: Create ModuleManagement tab**
`packages/web/src/pages/tabs/ModuleManagement.tsx`:
```tsx
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
type Module = { id: string; name: string; description: string | null; sortOrder: number; source: string; _count: { endpoints: number } };
export default function ModuleManagement({ projectId }: { projectId: string }) {
const [newModuleName, setNewModuleName] = useState('');
const queryClient = useQueryClient();
const { data: modules } = useQuery({
queryKey: ['modules', projectId],
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
});
const createMutation = useMutation({
mutationFn: (name: string) =>
apiFetch(`/projects/${projectId}/modules`, { method: 'POST', body: JSON.stringify({ name }) }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['modules', projectId] });
setNewModuleName('');
},
});
const deleteMutation = useMutation({
mutationFn: (moduleId: string) =>
apiFetch(`/projects/${projectId}/modules/${moduleId}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', projectId] }),
});
return (
<div className="max-w-2xl space-y-4">
<div className="flex gap-2">
<input
type="text"
placeholder="New module name"
value={newModuleName}
onChange={(e) => setNewModuleName(e.target.value)}
className="flex-1 px-3 py-2 border rounded-md text-sm"
/>
<button
onClick={() => newModuleName && createMutation.mutate(newModuleName)}
disabled={!newModuleName}
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700 disabled:opacity-50"
>
Add Module
</button>
</div>
<div className="space-y-2">
{modules?.map((m) => (
<div key={m.id} className="flex items-center justify-between p-3 bg-white rounded-lg border">
<div>
<span className="font-medium">{m.name}</span>
<span className="text-xs text-gray-400 ml-2">({m.source})</span>
<span className="text-xs text-gray-400 ml-2">{m._count.endpoints} endpoints</span>
</div>
<button
onClick={() => {
if (confirm(`Delete module "${m.name}"? Endpoints in this module will also be deleted.`)) {
deleteMutation.mutate(m.id);
}
}}
className="text-xs text-red-500 hover:underline"
>
Delete
</button>
</div>
))}
</div>
</div>
);
}
```
- [ ] **Step 5: Create ProjectSettings tab**
`packages/web/src/pages/tabs/ProjectSettings.tsx`:
```tsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
type Project = { id: string; name: string; description: string | null };
export default function ProjectSettings({ project }: { project: Project }) {
const [name, setName] = useState(project.name);
const [description, setDescription] = useState(project.description || '');
const navigate = useNavigate();
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: () =>
apiFetch(`/projects/${project.id}`, {
method: 'PUT',
body: JSON.stringify({ name, description: description || undefined }),
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['project', project.id] }),
});
const deleteMutation = useMutation({
mutationFn: () => apiFetch(`/projects/${project.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
navigate('/');
},
});
return (
<div className="max-w-lg space-y-6">
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Project Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded-md"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 border rounded-md"
/>
</div>
<button
onClick={() => updateMutation.mutate()}
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700"
>
Save Changes
</button>
</div>
<div className="border-t pt-6">
<h3 className="text-red-600 font-medium mb-2">Danger Zone</h3>
<button
onClick={() => {
if (confirm('Are you sure? This will permanently delete this project and all its data.')) {
deleteMutation.mutate();
}
}}
className="px-4 py-2 bg-red-600 text-white rounded-md text-sm hover:bg-red-700"
>
Delete Project
</button>
</div>
</div>
);
}
```
- [ ] **Step 6: Add project detail route to App.tsx**
Add import and route in `packages/web/src/App.tsx`:
```tsx
import ProjectDetail from './pages/ProjectDetail';
// Inside <Route element={<Layout />}>:
// <Route path="/" element={<Projects />} />
// <Route path="/projects/:id" element={<ProjectDetail />} />
```
- [ ] **Step 7: Test and commit**
```bash
pnpm --filter @agent-fox/web dev
# Navigate to a project detail page, verify all 4 tabs work
```
```bash
git add packages/web/
git commit -m "feat: add project detail page with doc preview, module management, MCP integration, and settings tabs"
```
---
## Phase 6: Docker Compose
### Task 11: Dockerfiles and Docker Compose configuration
**Files:**
- Create: `packages/web/Dockerfile`
- Create: `packages/server/Dockerfile`
- Create: `packages/mcp/Dockerfile`
- Create: `docker-compose.yml`
- Create: `docker-compose.dev.yml`
- Create: `packages/web/nginx.conf`
- [ ] **Step 1: Create server Dockerfile**
`packages/server/Dockerfile`:
```dockerfile
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/shared/package.json packages/shared/
COPY packages/server/package.json packages/server/
COPY prisma/ prisma/
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/server/node_modules ./packages/server/node_modules
COPY . .
RUN pnpm --filter @agent-fox/shared db:generate
RUN pnpm --filter @agent-fox/shared build
RUN pnpm --filter @agent-fox/server build
FROM base AS runtime
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=build /app/packages/shared/package.json ./packages/shared/
COPY --from=build /app/packages/server/dist ./packages/server/dist
COPY --from=build /app/packages/server/node_modules ./packages/server/node_modules
COPY --from=build /app/packages/server/package.json ./packages/server/
COPY --from=build /app/prisma ./prisma
WORKDIR /app/packages/server
EXPOSE 3000
CMD ["node", "dist/index.js"]
```
- [ ] **Step 2: Create MCP Dockerfile**
`packages/mcp/Dockerfile`:
```dockerfile
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/shared/package.json packages/shared/
COPY packages/mcp/package.json packages/mcp/
COPY prisma/ prisma/
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/mcp/node_modules ./packages/mcp/node_modules
COPY . .
RUN pnpm --filter @agent-fox/shared db:generate
RUN pnpm --filter @agent-fox/shared build
RUN pnpm --filter @agent-fox/mcp build
FROM base AS runtime
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=build /app/packages/shared/package.json ./packages/shared/
COPY --from=build /app/packages/mcp/dist ./packages/mcp/dist
COPY --from=build /app/packages/mcp/node_modules ./packages/mcp/node_modules
COPY --from=build /app/packages/mcp/package.json ./packages/mcp/
COPY --from=build /app/prisma ./prisma
WORKDIR /app/packages/mcp
EXPOSE 3001
CMD ["node", "dist/index.js"]
```
- [ ] **Step 3: Create web Dockerfile**
`packages/web/Dockerfile`:
```dockerfile
FROM node:20-alpine AS build
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/web/package.json packages/web/
RUN pnpm install --frozen-lockfile --filter @agent-fox/web
COPY packages/web/ packages/web/
ARG VITE_MCP_BASE_URL
ENV VITE_MCP_BASE_URL=$VITE_MCP_BASE_URL
RUN pnpm --filter @agent-fox/web build
FROM nginx:alpine
COPY --from=build /app/packages/web/dist /usr/share/nginx/html
COPY packages/web/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
- [ ] **Step 4: Create Nginx config**
`packages/web/nginx.conf`:
```nginx
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://server:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /mcp/ {
proxy_pass http://mcp:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
}
location / {
try_files $uri $uri/ /index.html;
}
}
```
- [ ] **Step 5: Create docker-compose.yml**
`docker-compose.yml`:
```yaml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: agentfox
POSTGRES_PASSWORD: agentfox
POSTGRES_DB: agentfox
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U agentfox"]
interval: 5s
timeout: 5s
retries: 5
server:
build:
context: .
dockerfile: packages/server/Dockerfile
environment:
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-refresh-in-production}
SERVER_PORT: "3000"
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
mcp:
build:
context: .
dockerfile: packages/mcp/Dockerfile
environment:
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
MCP_PORT: "3001"
ports:
- "3001:3001"
depends_on:
postgres:
condition: service_healthy
web:
build:
context: .
dockerfile: packages/web/Dockerfile
args:
VITE_MCP_BASE_URL: ${MCP_BASE_URL:-http://localhost:3001}
ports:
- "80:80"
depends_on:
- server
- mcp
volumes:
pgdata:
```
- [ ] **Step 6: Create docker-compose.dev.yml**
`docker-compose.dev.yml`:
```yaml
services:
postgres:
ports:
- "5432:5432"
server:
build:
target: base
command: pnpm --filter @agent-fox/server dev
volumes:
- ./packages/shared/src:/app/packages/shared/src
- ./packages/server/src:/app/packages/server/src
- ./prisma:/app/prisma
environment:
NODE_ENV: development
mcp:
build:
target: base
command: pnpm --filter @agent-fox/mcp dev
volumes:
- ./packages/shared/src:/app/packages/shared/src
- ./packages/mcp/src:/app/packages/mcp/src
- ./prisma:/app/prisma
environment:
NODE_ENV: development
web:
build:
target: build
command: pnpm --filter @agent-fox/web dev -- --host 0.0.0.0
volumes:
- ./packages/web/src:/app/packages/web/src
ports:
- "5173:5173"
environment:
NODE_ENV: development
```
- [ ] **Step 7: Add Docker entries to .gitignore**
Append to `.gitignore`:
```
# Docker
.env
```
- [ ] **Step 8: Test Docker Compose**
```bash
cd /Users/kid/Development/Fusion/Projects/agent-fox
docker compose up --build
# Verify:
# - http://localhost:80 — frontend loads
# - http://localhost:3000/api/health — backend responds
# - http://localhost:3001/health — MCP responds
```
- [ ] **Step 9: Commit**
```bash
git add docker-compose.yml docker-compose.dev.yml packages/web/Dockerfile packages/server/Dockerfile packages/mcp/Dockerfile packages/web/nginx.conf .gitignore
git commit -m "feat: add Docker Compose setup for all services"
```
---
## Phase 7: Database Migration Runner
### Task 12: Add Prisma migration to Docker startup
**Files:**
- Create: `scripts/migrate-and-start.sh`
- Modify: `packages/server/Dockerfile`
- [ ] **Step 1: Create startup script**
`scripts/migrate-and-start.sh`:
```bash
#!/bin/sh
set -e
echo "Running database migrations..."
cd /app
npx prisma migrate deploy --schema=prisma/schema.prisma
echo "Starting server..."
cd /app/packages/server
exec node dist/index.js
```
- [ ] **Step 2: Update server Dockerfile CMD**
Replace the CMD in `packages/server/Dockerfile` runtime stage:
```dockerfile
COPY --from=build /app/scripts ./scripts
RUN chmod +x scripts/migrate-and-start.sh
WORKDIR /app
EXPOSE 3000
CMD ["sh", "scripts/migrate-and-start.sh"]
```
- [ ] **Step 3: Commit**
```bash
git add scripts/ packages/server/Dockerfile
git commit -m "feat: add automatic Prisma migration on server startup"
```
---
## Verification Plan
### End-to-End Test Sequence
1. **Start all services**: `docker compose up --build`
2. **Register a user**: POST `/api/auth/register`
3. **Import Petstore API**: POST `/api/projects` with `specUrl: "https://petstore3.swagger.io/api/v3/openapi.json"`
4. **Verify modules created**: GET `/api/projects/:id/modules` — should see pet, store, user modules
5. **Configure MCP in Claude Code**:
```json
{
"mcpServers": {
"petstore": {
"url": "http://localhost:3001/mcp/<project-id>",
"headers": { "Authorization": "Bearer <api-key>" }
}
}
}
```
6. **Test MCP tools from Claude Code**: Ask Claude "What modules are available in this API?" — should call `get_project_overview`
7. **Test search**: Ask Claude "Find the endpoint for adding a pet" — should call `search_endpoints`
8. **Test drill-down**: Ask Claude "Show me the details for the POST /pet endpoint" — should call `get_endpoint_detail`