diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e7190b8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,472 @@ +# Agent Instructions + +This file provides guidance to coding agents like Claude Code (claude.ai/code) and OpenCode when working with code in this repository. + +## Git Workflow + +**Always commit and push changes after completing a task.** Follow these rules: + +1. After making code changes, always commit with a descriptive message +2. Push commits to the current feature branch +3. **NEVER push directly to `main` or `test` branches** - always use feature branches and PRs +4. Before pushing, verify the current branch is not `main` or `test` +5. **Open PRs against the `test` branch**, not `main` +6. After pushing, check if a PR exists for the branch. If not, create one with `gh pr create --base test` +7. **After creating a PR, always wait for explicit user approval before merging.** Never merge PRs autonomously. + +### Starting a New Task + +When starting a new task, **first sync the `test` branch with `main`**: + +```bash +git checkout test && git pull origin test && git fetch origin main && git merge origin/main && git push origin test +``` + +Then checkout main, pull latest, and create your feature branch from there. + +This is the **only** time you should push directly to `test`. + +## Build Commands + +```bash +pnpm install # Install dependencies +pnpm dev # Start dev server +pnpm build # Production build +pnpm test # Run vitest +pnpm test:watch # Watch mode +pnpm lint # Fix lint issues +pnpm lint:check # Check for lint issues +pnpm format # Run prettier +pnpm format:check # Check formatting +``` + +## Architecture + +- **Next.js 16** API service with App Router +- **x402-next** middleware for crypto payments on Base network +- `app/api/` - API routes (image generation, artists, accounts, etc.) +- `lib/` - Business logic organized by domain: + - `lib/ai/` - AI/LLM integrations + - `lib/emails/` - Email handling (Resend) + - `lib/supabase/` - Database operations + - `lib/trigger/` - Trigger.dev task triggers + - `lib/x402/` - Payment middleware utilities + +## Supabase Database Operations + +**CRITICAL: NEVER import `@/lib/supabase/serverClient` outside of `lib/supabase/` directory.** + +All Supabase database calls **must** be in `lib/supabase/[table_name]/[function].ts`. + +If you need database access in `lib/auth/`, `lib/chats/`, or any other domain folder: +1. **First** check if a function already exists in `lib/supabase/[table_name]/` +2. If not, **create** a new function in `lib/supabase/[table_name]/` first +3. **Then** import and use that function in your domain code + +❌ **WRONG** - Direct Supabase call in domain code: +```typescript +// lib/auth/someFunction.ts +import supabase from "@/lib/supabase/serverClient"; // NEVER DO THIS +const { data } = await supabase.from("accounts").select("*"); +``` + +✅ **CORRECT** - Import from supabase lib: +```typescript +// lib/auth/someFunction.ts +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; +const accounts = await selectAccounts(); +``` + +### Directory Structure + +``` +lib/supabase/ +├── serverClient.ts # Supabase client instance +├── accounts/ +│ ├── selectAccounts.ts +│ ├── insertAccount.ts +│ └── updateAccount.ts +├── account_api_keys/ +│ ├── selectAccountApiKeys.ts +│ ├── insertApiKey.ts +│ └── deleteApiKey.ts +├── account_organization_ids/ +│ ├── getAccountOrganizations.ts +│ └── addAccountToOrganization.ts +└── [table_name]/ + └── [action][TableName].ts +``` + +### Naming Conventions + +- `select[TableName].ts` - Basic SELECT queries +- `insert[TableName].ts` - INSERT queries +- `update[TableName].ts` - UPDATE queries +- `delete[TableName].ts` - DELETE queries +- `get[Descriptive].ts` - Complex queries with joins + +### Pattern + +```typescript +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Select rows from table_name with optional filters. + */ +export async function selectTableName({ + filter, +}: { + filter?: string; +} = {}): Promise[] | null> { + let query = supabase.from("table_name").select("*"); + + if (filter) { + query = query.eq("column", filter); + } + + const { data, error } = await query; + + if (error) { + console.error("Error fetching table_name:", error); + return null; + } + + return data || []; +} +``` + +## Code Principles + +- **SRP (Single Responsibility Principle)**: One exported function per file. Each file should do one thing well. +- **DRY (Don't Repeat Yourself)**: Extract shared logic into reusable utilities. +- **KISS (Keep It Simple)**: Prefer simple solutions over clever ones. +- All API routes should have JSDoc comments +- Run `pnpm lint` before committing + +### Terminology + +Use **"account"** terminology, never "entity" or "user". All entities in the system (individuals, artists, workspaces, organizations) are "accounts". When referring to specific types, use the specific name: + +- ✅ `account_id`, "artist", "workspace", "organization" +- ❌ `entity_id`, "entity", "user" + +### API Response Shapes + +Keep response bodies **flat** — put fields at the root level, not nested inside a `data` wrapper: + +```typescript +// ✅ Correct — flat response +{ success: true, connectors: [...] } + +// ❌ Wrong — unnecessary nesting +{ success: true, data: { connectors: [...] } } +``` + +## Test-Driven Development (TDD) + +**CRITICAL: Always write tests BEFORE implementing new features or fixing bugs.** + +### TDD Workflow + +1. **Write failing tests first** - Create tests in `lib/[domain]/__tests__/[filename].test.ts` that describe the expected behavior +2. **Run tests to verify they fail** - `pnpm test path/to/test.ts` +3. **Implement the code** - Write the minimum code needed to make tests pass +4. **Run tests to verify they pass** - All tests should be green +5. **Refactor if needed** - Clean up while keeping tests green + +### Test File Location + +Tests live alongside the code they test: +``` +lib/ +├── chats/ +│ ├── __tests__/ +│ │ └── updateChatHandler.test.ts +│ ├── updateChatHandler.ts +│ └── validateUpdateChatBody.ts +``` + +### Test Pattern + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +// Mock dependencies +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +describe("functionName", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful cases", () => { + it("does something when condition is met", async () => { + // Arrange + vi.mocked(dependency).mockResolvedValue(mockData); + + // Act + const result = await functionName(input); + + // Assert + expect(result.status).toBe(200); + }); + }); + + describe("error cases", () => { + it("returns 400 when validation fails", async () => { + // Test error handling + }); + }); +}); +``` + +### When to Write Tests + +- **New API endpoints**: Write tests for all success and error paths +- **New handlers**: Test business logic with mocked dependencies +- **Bug fixes**: Write a failing test that reproduces the bug, then fix it +- **Validation functions**: Test all valid and invalid input combinations + +## Authentication + +**Never use `account_id` in request bodies or tool schemas.** Always derive the account ID from authentication: + +- **API routes**: Use `validateAuthContext()` (supports both `x-api-key` and `Authorization: Bearer` tokens) +- **MCP tools**: Use `extra.authInfo` via `resolveAccountId()` + +Both API keys and Privy access tokens resolve to an `accountId`. Never accept `account_id` as user input. + +### API Routes + +**CRITICAL: Always use `validateAuthContext()` for authentication.** This function supports both `x-api-key` header AND `Authorization: Bearer` token authentication. Never use `getApiKeyAccountId()` directly in route handlers - it only supports API keys and will reject Bearer tokens from the frontend. + +```typescript +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +const authResult = await validateAuthContext(request, { + accountId: body.account_id, // Optional: for account_id override + organizationId: body.organization_id, // Optional: for org context +}); + +if (authResult instanceof NextResponse) { + return authResult; +} + +const { accountId, orgId, authToken } = authResult; +``` + +`validateAuthContext` handles: +- Both `x-api-key` and `Authorization: Bearer` authentication +- Account ID override validation (org keys can access member accounts) +- Organization access validation + +### MCP Tools + +**CRITICAL: Never manually extract `accountId` from `extra.authInfo` (e.g. `authInfo?.extra?.accountId`).** Always use `resolveAccountId()` — it handles validation, org-key overrides, and access control in one place. + +```typescript +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; + +const authInfo = extra.authInfo as McpAuthInfo | undefined; +const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, +}); + +if (error) { + return getToolResultError(error); +} + +if (!accountId) { + return getToolResultError("Failed to resolve account ID"); +} +``` + +This ensures: +- Callers cannot impersonate other accounts +- Authentication is always enforced +- Account ID is derived from validated credentials +- Frontend apps using Bearer tokens work correctly + +## Input Validation + +All API endpoints should use a **validate function** for input parsing. Use Zod for schema validation. + +### Pattern + +Create a `validateBody.ts` or `validateQuery.ts` file: + +```typescript +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +// Define the schema +export const createExampleBodySchema = z.object({ + name: z.string({ message: "name is required" }).min(1, "name cannot be empty"), + id: z.string().uuid("id must be a valid UUID").optional(), +}); + +// Export the inferred type +export type CreateExampleBody = z.infer; + +/** + * Validates request body for POST /api/example. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateCreateExampleBody(body: unknown): NextResponse | CreateExampleBody { + const result = createExampleBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} +``` + +### Usage in Handler + +```typescript +const validated = validateCreateExampleBody(body); +if (validated instanceof NextResponse) { + return validated; +} +// validated is now typed as CreateExampleBody +``` + +### Naming Convention + +- `validateBody.ts` - For POST/PUT request bodies +- `validateQuery.ts` - For GET query parameters + +## MCP Tools Architecture (DRY with API Endpoints) + +MCP tools and REST API endpoints share business logic through domain-specific functions. This ensures DRY compliance and consistent behavior across all interfaces. + +### Directory Structure + +``` +lib/mcp/tools/ +├── index.ts # registerAllTools() - central registration +├── [domain]/ +│ ├── index.ts # registerAll[Domain]Tools() +│ └── register[ToolName]Tool.ts # Individual tool registration +``` + +### DRY Pattern: Shared Logic Between MCP Tools and API Routes + +Both MCP tools and API routes should use the **same domain functions**: + +``` +┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐ +│ API Route Handler │ │ MCP Tool Handler │ +│ (app/api/endpoint/route.ts) │ │ (lib/mcp/tools/*/register*.ts) │ +│ │ │ │ +│ validateRequest() ──┐ │ │ Extract args from schema ──┐ │ +│ ↓ │ │ ↓ │ +│ ┌───────────────────────┴─────┴───────────────────────┐ │ +│ │ Shared Domain Logic (lib/[domain]/) │ │ +│ │ - buildParams functions (auth/access control) │ │ +│ │ - process functions (business logic) │ │ +│ │ - Supabase queries (lib/supabase/[table]/) │ │ +│ └───────────────────────┬─────┬───────────────────────┘ │ +│ ↓ │ │ ↓ │ +│ Return NextResponse │ │ Return getToolResultSuccess() │ +└─────────────────────────────────────┘ └─────────────────────────────────────┘ +``` + +### Examples of DRY Implementation + +| Feature | API Route | MCP Tool | Shared Logic | +|---------|-----------|----------|--------------| +| Get Chats | `GET /api/chats` | `get_chats` | `buildGetChatsParams`, `selectRooms` | +| Get Pulses | `GET /api/pulses` | `get_pulses` | `buildGetPulsesParams`, `selectPulseAccounts` | +| Create Artist | `POST /api/artists` | `create_new_artist` | `createArtistInDb`, `copyRoom` | +| Compact Chats | `POST /api/chats/compact` | `compact_chats` | `processCompactChatRequest` | + +### Creating a New MCP Tool (Following DRY) + +1. **Identify shared logic** - Check if an API endpoint exists with reusable functions +2. **Create the tool file** - `lib/mcp/tools/[domain]/register[ToolName]Tool.ts` +3. **Import shared functions** - Use the same domain logic as the API route +4. **Register in index** - Add to `lib/mcp/tools/[domain]/index.ts` + +### Tool Registration Pattern + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +// Import shared domain logic +import { sharedDomainFunction } from "@/lib/[domain]/sharedDomainFunction"; + +const toolSchema = z.object({ + param: z.string().describe("Description for the AI."), +}); + +export function registerToolNameTool(server: McpServer): void { + server.registerTool( + "tool_name", + { + description: "Tool description for the AI.", + inputSchema: toolSchema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const accountId = authInfo?.extra?.accountId; + const orgId = authInfo?.extra?.orgId ?? null; + + if (!accountId) { + return getToolResultError("Authentication required."); + } + + // Use shared domain logic (same as API route) + const result = await sharedDomainFunction({ accountId, orgId, ...args }); + + if (!result) { + return getToolResultError("Operation failed"); + } + + return getToolResultSuccess(result); + }, + ); +} +``` + +### Key Utilities + +- `getToolResultSuccess(data)` - Wrap successful responses +- `getToolResultError(message)` - Wrap error responses +- `resolveAccountId({ authInfo, accountIdOverride })` - Resolve account from auth + +## Constants (`lib/const.ts`) + +All shared constants live in `lib/const.ts`: + +- `INBOUND_EMAIL_DOMAIN` - `@mail.recoupable.com` (where emails are received) +- `OUTBOUND_EMAIL_DOMAIN` - `@recoupable.com` (where emails are sent from) +- `SUPABASE_STORAGE_BUCKET` - Storage bucket name +- Wallet addresses, model names, API keys diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 8aef7bf..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,472 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Git Workflow - -**Always commit and push changes after completing a task.** Follow these rules: - -1. After making code changes, always commit with a descriptive message -2. Push commits to the current feature branch -3. **NEVER push directly to `main` or `test` branches** - always use feature branches and PRs -4. Before pushing, verify the current branch is not `main` or `test` -5. **Open PRs against the `test` branch**, not `main` -6. After pushing, check if a PR exists for the branch. If not, create one with `gh pr create --base test` -7. **After creating a PR, always wait for explicit user approval before merging.** Never merge PRs autonomously. - -### Starting a New Task - -When starting a new task, **first sync the `test` branch with `main`**: - -```bash -git checkout test && git pull origin test && git fetch origin main && git merge origin/main && git push origin test -``` - -Then checkout main, pull latest, and create your feature branch from there. - -This is the **only** time you should push directly to `test`. - -## Build Commands - -```bash -pnpm install # Install dependencies -pnpm dev # Start dev server -pnpm build # Production build -pnpm test # Run vitest -pnpm test:watch # Watch mode -pnpm lint # Fix lint issues -pnpm lint:check # Check for lint issues -pnpm format # Run prettier -pnpm format:check # Check formatting -``` - -## Architecture - -- **Next.js 16** API service with App Router -- **x402-next** middleware for crypto payments on Base network -- `app/api/` - API routes (image generation, artists, accounts, etc.) -- `lib/` - Business logic organized by domain: - - `lib/ai/` - AI/LLM integrations - - `lib/emails/` - Email handling (Resend) - - `lib/supabase/` - Database operations - - `lib/trigger/` - Trigger.dev task triggers - - `lib/x402/` - Payment middleware utilities - -## Supabase Database Operations - -**CRITICAL: NEVER import `@/lib/supabase/serverClient` outside of `lib/supabase/` directory.** - -All Supabase database calls **must** be in `lib/supabase/[table_name]/[function].ts`. - -If you need database access in `lib/auth/`, `lib/chats/`, or any other domain folder: -1. **First** check if a function already exists in `lib/supabase/[table_name]/` -2. If not, **create** a new function in `lib/supabase/[table_name]/` first -3. **Then** import and use that function in your domain code - -❌ **WRONG** - Direct Supabase call in domain code: -```typescript -// lib/auth/someFunction.ts -import supabase from "@/lib/supabase/serverClient"; // NEVER DO THIS -const { data } = await supabase.from("accounts").select("*"); -``` - -✅ **CORRECT** - Import from supabase lib: -```typescript -// lib/auth/someFunction.ts -import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; -const accounts = await selectAccounts(); -``` - -### Directory Structure - -``` -lib/supabase/ -├── serverClient.ts # Supabase client instance -├── accounts/ -│ ├── selectAccounts.ts -│ ├── insertAccount.ts -│ └── updateAccount.ts -├── account_api_keys/ -│ ├── selectAccountApiKeys.ts -│ ├── insertApiKey.ts -│ └── deleteApiKey.ts -├── account_organization_ids/ -│ ├── getAccountOrganizations.ts -│ └── addAccountToOrganization.ts -└── [table_name]/ - └── [action][TableName].ts -``` - -### Naming Conventions - -- `select[TableName].ts` - Basic SELECT queries -- `insert[TableName].ts` - INSERT queries -- `update[TableName].ts` - UPDATE queries -- `delete[TableName].ts` - DELETE queries -- `get[Descriptive].ts` - Complex queries with joins - -### Pattern - -```typescript -import supabase from "@/lib/supabase/serverClient"; -import type { Tables } from "@/types/database.types"; - -/** - * Select rows from table_name with optional filters. - */ -export async function selectTableName({ - filter, -}: { - filter?: string; -} = {}): Promise[] | null> { - let query = supabase.from("table_name").select("*"); - - if (filter) { - query = query.eq("column", filter); - } - - const { data, error } = await query; - - if (error) { - console.error("Error fetching table_name:", error); - return null; - } - - return data || []; -} -``` - -## Code Principles - -- **SRP (Single Responsibility Principle)**: One exported function per file. Each file should do one thing well. -- **DRY (Don't Repeat Yourself)**: Extract shared logic into reusable utilities. -- **KISS (Keep It Simple)**: Prefer simple solutions over clever ones. -- All API routes should have JSDoc comments -- Run `pnpm lint` before committing - -### Terminology - -Use **"account"** terminology, never "entity" or "user". All entities in the system (individuals, artists, workspaces, organizations) are "accounts". When referring to specific types, use the specific name: - -- ✅ `account_id`, "artist", "workspace", "organization" -- ❌ `entity_id`, "entity", "user" - -### API Response Shapes - -Keep response bodies **flat** — put fields at the root level, not nested inside a `data` wrapper: - -```typescript -// ✅ Correct — flat response -{ success: true, connectors: [...] } - -// ❌ Wrong — unnecessary nesting -{ success: true, data: { connectors: [...] } } -``` - -## Test-Driven Development (TDD) - -**CRITICAL: Always write tests BEFORE implementing new features or fixing bugs.** - -### TDD Workflow - -1. **Write failing tests first** - Create tests in `lib/[domain]/__tests__/[filename].test.ts` that describe the expected behavior -2. **Run tests to verify they fail** - `pnpm test path/to/test.ts` -3. **Implement the code** - Write the minimum code needed to make tests pass -4. **Run tests to verify they pass** - All tests should be green -5. **Refactor if needed** - Clean up while keeping tests green - -### Test File Location - -Tests live alongside the code they test: -``` -lib/ -├── chats/ -│ ├── __tests__/ -│ │ └── updateChatHandler.test.ts -│ ├── updateChatHandler.ts -│ └── validateUpdateChatBody.ts -``` - -### Test Pattern - -```typescript -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -// Mock dependencies -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -describe("functionName", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("successful cases", () => { - it("does something when condition is met", async () => { - // Arrange - vi.mocked(dependency).mockResolvedValue(mockData); - - // Act - const result = await functionName(input); - - // Assert - expect(result.status).toBe(200); - }); - }); - - describe("error cases", () => { - it("returns 400 when validation fails", async () => { - // Test error handling - }); - }); -}); -``` - -### When to Write Tests - -- **New API endpoints**: Write tests for all success and error paths -- **New handlers**: Test business logic with mocked dependencies -- **Bug fixes**: Write a failing test that reproduces the bug, then fix it -- **Validation functions**: Test all valid and invalid input combinations - -## Authentication - -**Never use `account_id` in request bodies or tool schemas.** Always derive the account ID from authentication: - -- **API routes**: Use `validateAuthContext()` (supports both `x-api-key` and `Authorization: Bearer` tokens) -- **MCP tools**: Use `extra.authInfo` via `resolveAccountId()` - -Both API keys and Privy access tokens resolve to an `accountId`. Never accept `account_id` as user input. - -### API Routes - -**CRITICAL: Always use `validateAuthContext()` for authentication.** This function supports both `x-api-key` header AND `Authorization: Bearer` token authentication. Never use `getApiKeyAccountId()` directly in route handlers - it only supports API keys and will reject Bearer tokens from the frontend. - -```typescript -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; - -const authResult = await validateAuthContext(request, { - accountId: body.account_id, // Optional: for account_id override - organizationId: body.organization_id, // Optional: for org context -}); - -if (authResult instanceof NextResponse) { - return authResult; -} - -const { accountId, orgId, authToken } = authResult; -``` - -`validateAuthContext` handles: -- Both `x-api-key` and `Authorization: Bearer` authentication -- Account ID override validation (org keys can access member accounts) -- Organization access validation - -### MCP Tools - -**CRITICAL: Never manually extract `accountId` from `extra.authInfo` (e.g. `authInfo?.extra?.accountId`).** Always use `resolveAccountId()` — it handles validation, org-key overrides, and access control in one place. - -```typescript -import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; - -const authInfo = extra.authInfo as McpAuthInfo | undefined; -const { accountId, error } = await resolveAccountId({ - authInfo, - accountIdOverride: undefined, -}); - -if (error) { - return getToolResultError(error); -} - -if (!accountId) { - return getToolResultError("Failed to resolve account ID"); -} -``` - -This ensures: -- Callers cannot impersonate other accounts -- Authentication is always enforced -- Account ID is derived from validated credentials -- Frontend apps using Bearer tokens work correctly - -## Input Validation - -All API endpoints should use a **validate function** for input parsing. Use Zod for schema validation. - -### Pattern - -Create a `validateBody.ts` or `validateQuery.ts` file: - -```typescript -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { z } from "zod"; - -// Define the schema -export const createExampleBodySchema = z.object({ - name: z.string({ message: "name is required" }).min(1, "name cannot be empty"), - id: z.string().uuid("id must be a valid UUID").optional(), -}); - -// Export the inferred type -export type CreateExampleBody = z.infer; - -/** - * Validates request body for POST /api/example. - * - * @param body - The request body - * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. - */ -export function validateCreateExampleBody(body: unknown): NextResponse | CreateExampleBody { - const result = createExampleBodySchema.safeParse(body); - - if (!result.success) { - const firstError = result.error.issues[0]; - return NextResponse.json( - { - status: "error", - missing_fields: firstError.path, - error: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } - - return result.data; -} -``` - -### Usage in Handler - -```typescript -const validated = validateCreateExampleBody(body); -if (validated instanceof NextResponse) { - return validated; -} -// validated is now typed as CreateExampleBody -``` - -### Naming Convention - -- `validateBody.ts` - For POST/PUT request bodies -- `validateQuery.ts` - For GET query parameters - -## MCP Tools Architecture (DRY with API Endpoints) - -MCP tools and REST API endpoints share business logic through domain-specific functions. This ensures DRY compliance and consistent behavior across all interfaces. - -### Directory Structure - -``` -lib/mcp/tools/ -├── index.ts # registerAllTools() - central registration -├── [domain]/ -│ ├── index.ts # registerAll[Domain]Tools() -│ └── register[ToolName]Tool.ts # Individual tool registration -``` - -### DRY Pattern: Shared Logic Between MCP Tools and API Routes - -Both MCP tools and API routes should use the **same domain functions**: - -``` -┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐ -│ API Route Handler │ │ MCP Tool Handler │ -│ (app/api/endpoint/route.ts) │ │ (lib/mcp/tools/*/register*.ts) │ -│ │ │ │ -│ validateRequest() ──┐ │ │ Extract args from schema ──┐ │ -│ ↓ │ │ ↓ │ -│ ┌───────────────────────┴─────┴───────────────────────┐ │ -│ │ Shared Domain Logic (lib/[domain]/) │ │ -│ │ - buildParams functions (auth/access control) │ │ -│ │ - process functions (business logic) │ │ -│ │ - Supabase queries (lib/supabase/[table]/) │ │ -│ └───────────────────────┬─────┬───────────────────────┘ │ -│ ↓ │ │ ↓ │ -│ Return NextResponse │ │ Return getToolResultSuccess() │ -└─────────────────────────────────────┘ └─────────────────────────────────────┘ -``` - -### Examples of DRY Implementation - -| Feature | API Route | MCP Tool | Shared Logic | -|---------|-----------|----------|--------------| -| Get Chats | `GET /api/chats` | `get_chats` | `buildGetChatsParams`, `selectRooms` | -| Get Pulses | `GET /api/pulses` | `get_pulses` | `buildGetPulsesParams`, `selectPulseAccounts` | -| Create Artist | `POST /api/artists` | `create_new_artist` | `createArtistInDb`, `copyRoom` | -| Compact Chats | `POST /api/chats/compact` | `compact_chats` | `processCompactChatRequest` | - -### Creating a New MCP Tool (Following DRY) - -1. **Identify shared logic** - Check if an API endpoint exists with reusable functions -2. **Create the tool file** - `lib/mcp/tools/[domain]/register[ToolName]Tool.ts` -3. **Import shared functions** - Use the same domain logic as the API route -4. **Register in index** - Add to `lib/mcp/tools/[domain]/index.ts` - -### Tool Registration Pattern - -```typescript -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; -import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -// Import shared domain logic -import { sharedDomainFunction } from "@/lib/[domain]/sharedDomainFunction"; - -const toolSchema = z.object({ - param: z.string().describe("Description for the AI."), -}); - -export function registerToolNameTool(server: McpServer): void { - server.registerTool( - "tool_name", - { - description: "Tool description for the AI.", - inputSchema: toolSchema, - }, - async (args, extra: RequestHandlerExtra) => { - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const accountId = authInfo?.extra?.accountId; - const orgId = authInfo?.extra?.orgId ?? null; - - if (!accountId) { - return getToolResultError("Authentication required."); - } - - // Use shared domain logic (same as API route) - const result = await sharedDomainFunction({ accountId, orgId, ...args }); - - if (!result) { - return getToolResultError("Operation failed"); - } - - return getToolResultSuccess(result); - }, - ); -} -``` - -### Key Utilities - -- `getToolResultSuccess(data)` - Wrap successful responses -- `getToolResultError(message)` - Wrap error responses -- `resolveAccountId({ authInfo, accountIdOverride })` - Resolve account from auth - -## Constants (`lib/const.ts`) - -All shared constants live in `lib/const.ts`: - -- `INBOUND_EMAIL_DOMAIN` - `@mail.recoupable.com` (where emails are received) -- `OUTBOUND_EMAIL_DOMAIN` - `@recoupable.com` (where emails are sent from) -- `SUPABASE_STORAGE_BUCKET` - Storage bucket name -- Wallet addresses, model names, API keys diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file