3552 lines
103 KiB
Markdown
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">← 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} · {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`
|