diff --git a/.claude/worktrees/agent-a44ac837a6e139022 b/.claude/worktrees/agent-a44ac837a6e139022 new file mode 160000 index 0000000..00373d2 --- /dev/null +++ b/.claude/worktrees/agent-a44ac837a6e139022 @@ -0,0 +1 @@ +Subproject commit 00373d2b27a1969ae6ef44aa9b49bfc3bdb9917e diff --git a/.claude/worktrees/agent-a802aa2e60d491032 b/.claude/worktrees/agent-a802aa2e60d491032 new file mode 160000 index 0000000..1a28cc5 --- /dev/null +++ b/.claude/worktrees/agent-a802aa2e60d491032 @@ -0,0 +1 @@ +Subproject commit 1a28cc50985538425a4d057666fa18e3fac91b1a diff --git a/.claude/worktrees/fix-healthcheck-unifi b/.claude/worktrees/fix-healthcheck-unifi new file mode 160000 index 0000000..732ca98 --- /dev/null +++ b/.claude/worktrees/fix-healthcheck-unifi @@ -0,0 +1 @@ +Subproject commit 732ca98ccc8c243e277d8efbbebafb960ba8f750 diff --git a/.taskmaster/tasks/task_025.md b/.taskmaster/tasks/task_025.md new file mode 100644 index 0000000..9e26fc5 --- /dev/null +++ b/.taskmaster/tasks/task_025.md @@ -0,0 +1,35 @@ +# Task ID: 25 + +**Title:** Rename local-proxy to mcplocal + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Rename the src/local-proxy directory to src/mcplocal and update all package references, imports, and build configurations throughout the monorepo. + +**Details:** + +1. Rename directory: `mv src/local-proxy src/mcplocal` +2. Update package.json name from `@mcpctl/local-proxy` to `@mcpctl/mcplocal` +3. Update pnpm-workspace.yaml if needed +4. Update all imports in other packages that reference local-proxy: + - Search for `@mcpctl/local-proxy` and replace with `@mcpctl/mcplocal` + - Check tsconfig references and path mappings +5. Update any scripts in package.json root that reference local-proxy +6. Update docker-compose files in deploy/ if they reference local-proxy +7. Update documentation and README references +8. Run `pnpm install` to regenerate lockfile with new package name +9. Verify TypeScript compilation succeeds: `pnpm build` +10. Run existing tests to ensure nothing broke: `pnpm test` + +**Test Strategy:** + +1. Verify directory rename completed: `ls src/mcplocal` +2. Verify package.json has correct name +3. Run `pnpm install` - should complete without errors +4. Run `pnpm build` - all packages should compile +5. Run `pnpm test` - all existing tests should pass +6. Grep codebase for 'local-proxy' - should find no stale references except git history diff --git a/.taskmaster/tasks/task_026.md b/.taskmaster/tasks/task_026.md new file mode 100644 index 0000000..17ada91 --- /dev/null +++ b/.taskmaster/tasks/task_026.md @@ -0,0 +1,46 @@ +# Task ID: 26 + +**Title:** Add HTTP REST server to mcplocal + +**Status:** done + +**Dependencies:** 25 ✓ + +**Priority:** high + +**Description:** Add a Fastify HTTP server to mcplocal that runs alongside the existing stdio server, providing REST endpoints for mcpctl management commands. + +**Details:** + +1. Add Fastify dependency to mcplocal package.json: `@fastify/cors`, `fastify` +2. Create `src/mcplocal/src/http/server.ts` with Fastify app setup: + ```typescript + import Fastify from 'fastify'; + import cors from '@fastify/cors'; + + export async function createHttpServer(config: HttpServerConfig) { + const app = Fastify({ logger: true }); + await app.register(cors, { origin: true }); + // Register routes + return app; + } + ``` +3. Create `src/mcplocal/src/http/routes/` directory structure +4. Create health check endpoint: `GET /health` +5. Create config types in `src/mcplocal/src/config.ts`: + - `httpPort`: number (default 3200) + - `httpHost`: string (default '127.0.0.1') + - `mcpdUrl`: string (default 'http://localhost:3100') +6. Update mcplocal entry point to start both servers: + - stdio server for Claude MCP protocol + - HTTP server for mcpctl REST API +7. Add graceful shutdown handling for both servers + +**Test Strategy:** + +1. Unit test: HTTP server starts on configured port +2. Unit test: Health endpoint returns 200 OK +3. Integration test: Both stdio and HTTP servers can run simultaneously +4. Test graceful shutdown stops both servers cleanly +5. Test CORS headers are present on responses +6. Manual test: curl http://localhost:3200/health diff --git a/.taskmaster/tasks/task_027.md b/.taskmaster/tasks/task_027.md new file mode 100644 index 0000000..f20e25b --- /dev/null +++ b/.taskmaster/tasks/task_027.md @@ -0,0 +1,130 @@ +# Task ID: 27 + +**Title:** Implement mcplocal management proxy routes + +**Status:** done + +**Dependencies:** 26 ✓ + +**Priority:** high + +**Description:** Add REST endpoints to mcplocal that mirror mcpd's API and proxy management requests to mcpd without LLM processing. All requests must include proper authentication to mcpd using a Bearer token read from mcplocal config. + +**Details:** + +1. Create HTTP client for mcpd communication with auth: `src/local-proxy/src/http/mcpd-client.ts` + ```typescript + export class McpdClient { + private token: string; + + constructor(private baseUrl: string, token: string) { + this.token = token; + } + + private getHeaders(): Record { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.token}` + }; + } + + async get(path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'GET', + headers: this.getHeaders() + }); + await this.handleAuthError(response); + return response.json(); + } + + async post(path: string, body: unknown): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(body) + }); + await this.handleAuthError(response); + return response.json(); + } + + async put(path: string, body: unknown): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'PUT', + headers: this.getHeaders(), + body: JSON.stringify(body) + }); + await this.handleAuthError(response); + return response.json(); + } + + async delete(path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'DELETE', + headers: this.getHeaders() + }); + await this.handleAuthError(response); + return response.json(); + } + + private async handleAuthError(response: Response): Promise { + if (response.status === 401) { + throw new AuthenticationError('Invalid or expired token. Please check mcplocal config.'); + } + } + } + + export class AuthenticationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthenticationError'; + } + } + ``` +2. Add token to mcplocal config type (extend ProxyConfig or similar): + ```typescript + export interface McpdAuthConfig { + /** Bearer token for mcpd API authentication */ + mcpdToken: string; + } + ``` +3. Create proxy routes in `src/local-proxy/src/http/routes/`: + - `servers.ts`: GET/POST /api/v1/servers, GET/PUT/DELETE /api/v1/servers/:id + - `profiles.ts`: GET/POST /api/v1/profiles, GET/PUT/DELETE /api/v1/profiles/:id + - `instances.ts`: GET/POST /api/v1/instances, GET/POST/DELETE /api/v1/instances/:id, etc. + - `projects.ts`: GET/POST /api/v1/projects, etc. + - `audit.ts`: GET /api/v1/audit-logs + - `backup.ts`: POST /api/v1/backup, POST /api/v1/restore +4. Each route handler forwards to mcpd with auth: + ```typescript + app.get('/api/v1/servers', async (req, reply) => { + try { + const result = await mcpdClient.get('/api/v1/servers'); + return result; + } catch (error) { + if (error instanceof AuthenticationError) { + return reply.status(401).send({ error: error.message }); + } + throw error; + } + }); + ``` +5. Add comprehensive error handling: + - If mcpd is unreachable, return 503 Service Unavailable + - If mcpd returns 401, return 401 with clear message about token configuration + - Forward other HTTP errors from mcpd with appropriate status codes +6. Add request/response logging for debugging + +**Test Strategy:** + +1. Unit test: McpdClient attaches Authorization header to all request methods (GET, POST, PUT, DELETE) +2. Unit test: McpdClient throws AuthenticationError on 401 response from mcpd +3. Unit test: Each proxy route forwards requests correctly with auth headers +4. Unit test: Error handling when mcpd is unreachable (503 response) +5. Unit test: Error handling when mcpd returns 401 (clear error message returned) +6. Integration test: Full request flow mcpctl -> mcplocal -> mcpd with valid token +7. Integration test: Full request flow with invalid token returns 401 +8. Test query parameters are forwarded correctly +9. Test request body is forwarded correctly for POST/PUT +10. Test path parameters (:id) are passed through correctly +11. Mock mcpd responses and verify mcplocal returns them unchanged +12. Test token is read correctly from mcplocal config diff --git a/.taskmaster/tasks/task_028.md b/.taskmaster/tasks/task_028.md new file mode 100644 index 0000000..c50fd00 --- /dev/null +++ b/.taskmaster/tasks/task_028.md @@ -0,0 +1,149 @@ +# Task ID: 28 + +**Title:** Add MCP proxy endpoint to mcpd + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Create a new endpoint in mcpd at /api/v1/mcp/proxy that accepts MCP tool call requests and executes them on managed MCP server instances. Also add authentication endpoints (login/logout) that mcpctl will use to authenticate users. + +**Details:** + +## MCP Proxy Endpoint + +1. Create new route file: `src/mcpd/src/routes/mcp-proxy.ts` +2. Define request schema: + ```typescript + interface McpProxyRequest { + serverId: string; // or instanceId + method: string; // e.g., 'tools/call', 'resources/read' + params: Record; + } + ``` +3. Create McpProxyService in `src/mcpd/src/services/mcp-proxy-service.ts`: + - Look up instance by serverId (auto-start if profile allows) + - Connect to the container via stdio or HTTP (depending on transport type) + - Execute the MCP JSON-RPC call + - Return the result +4. Handle MCP JSON-RPC protocol: + ```typescript + async executeCall(instanceId: string, method: string, params: unknown) { + const instance = await this.instanceService.getInstance(instanceId); + const connection = await this.getOrCreateConnection(instance); + const result = await connection.call(method, params); + return result; + } + ``` +5. Connection pooling: maintain persistent connections to running instances +6. Add route: `POST /api/v1/mcp/proxy` (must be behind auth middleware) +7. Add audit logging for all MCP proxy calls - include authenticated userId from request.userId +8. Handle errors: instance not found, instance not running, MCP call failed + +## Authentication Endpoints + +9. Create auth routes file: `src/mcpd/src/routes/auth.ts` +10. Implement `POST /api/v1/auth/login`: + - Request body: `{ username: string, password: string }` + - Validate credentials against User table (use bcrypt for password comparison) + - Create new Session record with token (use crypto.randomUUID or similar) + - Response: `{ token: string, expiresAt: string }` +11. Implement `POST /api/v1/auth/logout`: + - Requires Bearer token in Authorization header + - Delete/invalidate the Session record + - Response: `{ success: true }` + +## Auth Integration Notes + +- Existing auth middleware in `src/mcpd/src/middleware/auth.ts` validates Bearer tokens against Session table +- It sets `request.userId` on successful authentication +- MCP proxy endpoint MUST use this auth middleware +- Auth endpoints (login) should NOT require auth middleware +- Logout endpoint SHOULD require auth middleware to validate the session being invalidated + +**Test Strategy:** + +1. Unit test: Proxy service looks up correct instance +2. Unit test: JSON-RPC call is formatted correctly +3. Integration test: Full flow with a mock MCP server container +4. Test error handling: non-existent server returns 404 +5. Test error handling: stopped instance returns appropriate error +6. Test audit log entries include authenticated userId +7. Test connection reuse for multiple calls to same instance +8. Test login endpoint: valid credentials return session token +9. Test login endpoint: invalid credentials return 401 +10. Test logout endpoint: valid session is invalidated +11. Test logout endpoint: invalid/missing token returns 401 +12. Test MCP proxy endpoint without auth token returns 401 +13. Test MCP proxy endpoint with expired token returns 401 +14. Test MCP proxy endpoint with valid token succeeds and logs userId in audit + +## Subtasks + +### 28.1. Create auth routes with login/logout endpoints + +**Status:** pending +**Dependencies:** None + +Create src/mcpd/src/routes/auth.ts with POST /api/v1/auth/login and POST /api/v1/auth/logout endpoints for mcpctl authentication. + +**Details:** + +Implement login endpoint: validate username/password against User table using bcrypt, create Session record with generated token and expiry. Implement logout endpoint: require auth middleware, delete/invalidate Session record. Login does NOT require auth, logout DOES require auth. Export registerAuthRoutes function and update routes/index.ts. + +### 28.2. Create MCP proxy route file with auth middleware + +**Status:** pending +**Dependencies:** 28.1 + +Create src/mcpd/src/routes/mcp-proxy.ts with POST /api/v1/mcp/proxy endpoint protected by auth middleware. + +**Details:** + +Define McpProxyRequest interface (serverId, method, params). Register route handler that extracts userId from request.userId (set by auth middleware). Apply auth middleware using preHandler hook. Validate request body schema. + +### 28.3. Create McpProxyService for instance lookup and connection + +**Status:** pending +**Dependencies:** None + +Create src/mcpd/src/services/mcp-proxy-service.ts to handle instance lookup, connection management, and MCP call execution. + +**Details:** + +Implement getInstance to look up by serverId, auto-start if profile allows. Implement getOrCreateConnection for connection pooling. Handle both stdio and HTTP transports. Implement executeCall method that formats JSON-RPC call and returns result. + +### 28.4. Implement MCP JSON-RPC call execution + +**Status:** pending +**Dependencies:** 28.3 + +Implement the core JSON-RPC call logic in McpProxyService to execute tool calls on MCP server instances. + +**Details:** + +Format JSON-RPC 2.0 request with method and params. Send request over established connection (stdio/HTTP). Parse JSON-RPC response and handle errors. Return result or throw appropriate error for failed calls. + +### 28.5. Add audit logging with userId for MCP proxy calls + +**Status:** pending +**Dependencies:** 28.2, 28.4 + +Ensure all MCP proxy calls are logged to audit log including the authenticated userId from the session. + +**Details:** + +Use existing audit middleware/service. Include userId from request.userId in audit log entry. Log serverId, method, and outcome (success/failure). Log any errors that occur during MCP call execution. + +### 28.6. Integrate auth and proxy routes into server.ts + +**Status:** pending +**Dependencies:** 28.1, 28.2, 28.5 + +Register the new auth and mcp-proxy routes in the Fastify server with proper auth middleware wiring. + +**Details:** + +Update server.ts to register auth routes (no auth required for login). Register mcp-proxy routes with auth middleware. Ensure auth middleware is wired with findSession dependency from Prisma. Update routes/index.ts exports. diff --git a/.taskmaster/tasks/task_029.md b/.taskmaster/tasks/task_029.md new file mode 100644 index 0000000..542a805 --- /dev/null +++ b/.taskmaster/tasks/task_029.md @@ -0,0 +1,74 @@ +# Task ID: 29 + +**Title:** Implement LLM pre-processing pipeline in mcplocal + +**Status:** done + +**Dependencies:** 25 ✓, 27 ✓, 28 ✓ + +**Priority:** high + +**Description:** Create the core LLM pre-processing pipeline that intercepts MCP tool calls, uses a local LLM to optimize requests before sending to mcpd, and filters responses before returning to Claude. + +**Details:** + +1. Create `src/mcplocal/src/llm/processor.ts` - the core pipeline: + ```typescript + export class LlmProcessor { + constructor( + private providerRegistry: ProviderRegistry, + private config: LlmProcessorConfig + ) {} + + async preprocessRequest(toolName: string, params: unknown): Promise { + // Use LLM to interpret and optimize the request + const prompt = this.buildRequestPrompt(toolName, params); + const result = await this.providerRegistry.getActiveProvider().complete({ + systemPrompt: REQUEST_OPTIMIZATION_SYSTEM_PROMPT, + userPrompt: prompt + }); + return this.parseOptimizedRequest(result); + } + + async filterResponse(toolName: string, originalRequest: unknown, rawResponse: unknown): Promise { + // Use LLM to filter/summarize the response + const prompt = this.buildFilterPrompt(toolName, originalRequest, rawResponse); + const result = await this.providerRegistry.getActiveProvider().complete({ + systemPrompt: RESPONSE_FILTER_SYSTEM_PROMPT, + userPrompt: prompt + }); + return this.parseFilteredResponse(result); + } + } + ``` +2. Create system prompts in `src/mcplocal/src/llm/prompts.ts`: + - REQUEST_OPTIMIZATION_SYSTEM_PROMPT: instruct LLM to generate optimal queries + - RESPONSE_FILTER_SYSTEM_PROMPT: instruct LLM to extract relevant information +3. Integrate into router.ts - wrap tools/call handler: + ```typescript + async handleToolsCall(request: JsonRpcRequest) { + if (this.shouldPreprocess(request.params.name)) { + const processed = await this.llmProcessor.preprocessRequest(...); + // Call mcpd with processed request + const rawResponse = await this.callMcpd(processed); + const filtered = await this.llmProcessor.filterResponse(...); + return filtered; + } + return this.callMcpd(request.params); + } + ``` +4. Add configuration options: + - `enablePreprocessing`: boolean + - `preprocessingExclude`: string[] (tool names to skip) + - `preferredProvider`: string (ollama, gemini, deepseek, etc.) +5. Add bypass logic for simple operations (list, create, delete) + +**Test Strategy:** + +1. Unit test: Request preprocessing generates optimized queries +2. Unit test: Response filtering reduces data volume +3. Unit test: Bypass logic works for excluded tools +4. Integration test: Full pipeline with mock LLM provider +5. Test error handling: LLM failure falls back to unfiltered pass-through +6. Test configuration options are respected +7. Measure: response size reduction percentage diff --git a/.taskmaster/tasks/task_030.md b/.taskmaster/tasks/task_030.md new file mode 100644 index 0000000..fe90d4d --- /dev/null +++ b/.taskmaster/tasks/task_030.md @@ -0,0 +1,61 @@ +# Task ID: 30 + +**Title:** Add Gemini CLI LLM provider + +**Status:** done + +**Dependencies:** 25 ✓ + +**Priority:** medium + +**Description:** Implement a new LLM provider that uses the Gemini CLI binary for local, free LLM inference as the preferred provider for pre-processing. + +**Details:** + +1. Create `src/mcplocal/src/providers/gemini-cli.ts`: + ```typescript + import { spawn } from 'child_process'; + + export class GeminiCliProvider implements LlmProvider { + readonly name = 'gemini-cli'; + private binaryPath: string; + + constructor(config: GeminiCliConfig) { + this.binaryPath = config.binaryPath || 'gemini'; + } + + async isAvailable(): Promise { + // Check if gemini binary exists and is executable + try { + await this.runCommand(['--version']); + return true; + } catch { + return false; + } + } + + async complete(options: CompletionOptions): Promise { + const input = this.formatPrompt(options); + const output = await this.runCommand(['--prompt', input]); + return { content: output, model: 'gemini-cli' }; + } + + private async runCommand(args: string[]): Promise { + // Spawn gemini CLI process and capture output + } + } + ``` +2. Research actual Gemini CLI interface and adjust implementation +3. Add to provider registry with high priority (prefer over API providers) +4. Add configuration: `geminiCliBinaryPath` +5. Handle timeout for slow inference +6. Add fallback to next provider if Gemini CLI fails + +**Test Strategy:** + +1. Unit test: Provider correctly detects CLI availability +2. Unit test: Prompt formatting is correct +3. Unit test: Output parsing handles various formats +4. Integration test: Full completion with actual Gemini CLI (if available) +5. Test timeout handling for slow responses +6. Test fallback when CLI is not installed diff --git a/.taskmaster/tasks/task_031.md b/.taskmaster/tasks/task_031.md new file mode 100644 index 0000000..42ab8cd --- /dev/null +++ b/.taskmaster/tasks/task_031.md @@ -0,0 +1,59 @@ +# Task ID: 31 + +**Title:** Add DeepSeek API LLM provider + +**Status:** done + +**Dependencies:** 25 ✓ + +**Priority:** medium + +**Description:** Implement DeepSeek API provider as a cheap cloud-based fallback when local LLMs are unavailable. + +**Details:** + +1. Create `src/mcplocal/src/providers/deepseek.ts`: + ```typescript + export class DeepSeekProvider implements LlmProvider { + readonly name = 'deepseek'; + private apiKey: string; + private baseUrl = 'https://api.deepseek.com/v1'; + + constructor(config: DeepSeekConfig) { + this.apiKey = config.apiKey || process.env.DEEPSEEK_API_KEY; + } + + async isAvailable(): Promise { + return !!this.apiKey; + } + + async complete(options: CompletionOptions): Promise { + // DeepSeek uses OpenAI-compatible API + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'deepseek-chat', + messages: [{ role: 'user', content: options.userPrompt }] + }) + }); + // Parse and return + } + } + ``` +2. Add DEEPSEEK_API_KEY to configuration +3. Register in provider registry with medium priority +4. Support both deepseek-chat and deepseek-coder models +5. Add rate limiting handling + +**Test Strategy:** + +1. Unit test: Provider correctly checks API key availability +2. Unit test: Request formatting matches DeepSeek API spec +3. Unit test: Response parsing handles all fields +4. Integration test: Full completion with actual API (with valid key) +5. Test error handling for rate limits +6. Test error handling for invalid API key diff --git a/.taskmaster/tasks/task_032.md b/.taskmaster/tasks/task_032.md new file mode 100644 index 0000000..7609b13 --- /dev/null +++ b/.taskmaster/tasks/task_032.md @@ -0,0 +1,70 @@ +# Task ID: 32 + +**Title:** Implement smart context optimization + +**Status:** done + +**Dependencies:** 29 ✓ + +**Priority:** medium + +**Description:** Add token counting and decision logic to intelligently skip LLM filtering when responses are small enough, and cache filtering decisions for repeated queries. + +**Details:** + +1. Create `src/mcplocal/src/llm/token-counter.ts`: + ```typescript + export function estimateTokens(text: string): number { + // Simple estimation: ~4 chars per token for English + // More accurate: use tiktoken or similar library + return Math.ceil(text.length / 4); + } + ``` +2. Create `src/mcplocal/src/llm/filter-cache.ts`: + ```typescript + export class FilterCache { + private cache: LRUCache; + + shouldFilter(toolName: string, params: unknown, responseSize: number): boolean { + const key = this.computeKey(toolName, params); + const cached = this.cache.get(key); + if (cached) return cached.shouldFilter; + // No cache hit - use default threshold logic + return responseSize > this.tokenThreshold; + } + + recordDecision(toolName: string, params: unknown, decision: FilterDecision): void { + const key = this.computeKey(toolName, params); + this.cache.set(key, decision); + } + } + ``` +3. Add configuration options: + - `tokenThreshold`: number (default 1000 tokens) + - `filterCacheSize`: number (default 1000 entries) + - `filterCacheTtl`: number (default 3600 seconds) +4. Integrate into LlmProcessor: + ```typescript + async filterResponse(...) { + const tokens = estimateTokens(JSON.stringify(rawResponse)); + if (tokens < this.config.tokenThreshold) { + // Not worth filtering - return as-is + return { filtered: false, response: rawResponse }; + } + // Proceed with LLM filtering + } + ``` +5. Add metrics tracking: + - Total tokens processed + - Tokens saved by filtering + - Filter cache hit rate + - Average latency added by filtering + +**Test Strategy:** + +1. Unit test: Token estimation is reasonably accurate +2. Unit test: Cache correctly stores and retrieves decisions +3. Unit test: Threshold logic skips filtering for small responses +4. Unit test: Cache TTL expiration works correctly +5. Integration test: Metrics are recorded accurately +6. Performance test: Cache improves latency for repeated queries diff --git a/.taskmaster/tasks/task_033.md b/.taskmaster/tasks/task_033.md new file mode 100644 index 0000000..a9315b2 --- /dev/null +++ b/.taskmaster/tasks/task_033.md @@ -0,0 +1,171 @@ +# Task ID: 33 + +**Title:** Update mcpctl to use mcplocal as daemon + +**Status:** done + +**Dependencies:** 27 ✓ + +**Priority:** high + +**Description:** Modify mcpctl CLI to connect to mcplocal instead of mcpd directly, update configuration options, add dual connectivity status checking, and implement authentication commands (login/logout) with secure credential storage. + +**Details:** + +1. Update `src/cli/src/config/schema.ts`: + ```typescript + export interface McpctlConfig { + mcplocalUrl: string; // NEW: default 'http://localhost:3200' + mcpdUrl: string; // Keep for reference/direct access if needed + // ... other fields + } + ``` +2. Update `src/cli/src/config/defaults.ts`: + - Change default daemonUrl to http://localhost:3200 (mcplocal) +3. Update `src/cli/src/api-client.ts`: + - Default baseUrl now points to mcplocal +4. Add new config commands in `src/cli/src/commands/config.ts`: + ```typescript + .command('set-mcplocal-url ') + .command('set-mcpd-url ') + .command('get-mcplocal-url') + .command('get-mcpd-url') + ``` +5. Update `src/cli/src/commands/status.ts` to show both connections and auth status: + ``` + $ mcpctl status + mcplocal: connected (localhost:3200) + mcpd: connected (nas.local:3100) via mcplocal + Auth: logged in as user@example.com + LLM Provider: ollama (llama3.2) + Token savings: 45% (last 24h) + ``` +6. Update CLI --daemon-url flag to point to mcplocal +7. Add --direct flag to bypass mcplocal and talk to mcpd directly (for debugging) +8. Create `src/cli/src/commands/auth.ts` with login/logout commands: + - `mcpctl login`: Prompt for mcpd URL (if not configured) and credentials + - Call POST /api/v1/auth/login with { email, password } + - Store session token in ~/.mcpctl/credentials with 0600 permissions + - `mcpctl logout`: Invalidate session and delete stored token +9. Create `src/cli/src/auth/credentials.ts` for secure token storage: + - Use fs.chmod to set 0600 permissions on credentials file + - Token format: { token: string, mcpdUrl: string, user: string, expiresAt?: string } +10. Update api-client.ts to include stored token in requests to mcplocal + - mcplocal passes this token to mcpd for authentication + +**Test Strategy:** + +1. Unit test: Default config points to mcplocal URL +2. Unit test: Config commands update correct fields +3. Integration test: CLI commands work through mcplocal proxy +4. Test status command shows both mcplocal and mcpd status +5. Test --direct flag bypasses mcplocal +6. Test backward compatibility with existing config files +7. Unit test: login command stores token with correct permissions (0600) +8. Unit test: logout command removes credentials file +9. Integration test: login flow with POST /api/v1/auth/login +10. Test status command shows auth status (logged in as user) +11. Test token is passed to mcplocal in API requests +12. Test invalid credentials return appropriate error message +13. Test expired token handling + +## Subtasks + +### 33.1. Update config schema for mcplocal and mcpd URLs + +**Status:** pending +**Dependencies:** None + +Modify McpctlConfigSchema in src/cli/src/config/schema.ts to include separate mcplocalUrl and mcpdUrl fields with appropriate defaults. + +**Details:** + +Update the Zod schema to add mcplocalUrl (default: http://localhost:3200) and mcpdUrl (default: http://localhost:3100). Update DEFAULT_CONFIG and ensure backward compatibility with existing daemonUrl field by mapping it to mcplocalUrl. + +### 33.2. Create auth credentials storage module + +**Status:** pending +**Dependencies:** None + +Create src/cli/src/auth/credentials.ts to handle secure storage and retrieval of session tokens in ~/.mcpctl/credentials. + +**Details:** + +Implement saveCredentials(token, mcpdUrl, user), loadCredentials(), and deleteCredentials() functions. Use fs.chmod to set 0600 permissions. Store JSON format: { token, mcpdUrl, user, expiresAt }. Handle file not found gracefully in loadCredentials. + +### 33.3. Implement login command + +**Status:** pending +**Dependencies:** 33.2 + +Create src/cli/src/commands/auth.ts with mcpctl login command that prompts for mcpd URL and credentials, calls POST /api/v1/auth/login, and stores the session token. + +**Details:** + +Use inquirer or prompts library for interactive credential input (email, password). If mcpdUrl not configured, prompt for it. Call POST /api/v1/auth/login with credentials. On success, save token using credentials module. Display 'Logged in as {user}' on success. Handle errors (invalid credentials, network errors) with clear messages. + +### 33.4. Implement logout command + +**Status:** pending +**Dependencies:** 33.2 + +Add mcpctl logout command to auth.ts that invalidates the session and removes stored credentials. + +**Details:** + +Load stored credentials, optionally call a logout endpoint on mcpd to invalidate server-side session, then delete the local credentials file. Display 'Logged out successfully' or 'Not logged in' as appropriate. + +### 33.5. Update api-client to include auth token + +**Status:** pending +**Dependencies:** 33.2 + +Modify src/cli/src/api-client.ts to load and include stored session token in Authorization header for requests to mcplocal. + +**Details:** + +Import loadCredentials from auth module. Add Authorization: Bearer {token} header to requests when credentials exist. Handle expired token by returning appropriate error suggesting re-login. + +### 33.6. Update status command to show auth status + +**Status:** pending +**Dependencies:** 33.2, 33.5 + +Modify src/cli/src/commands/status.ts to display authentication status (logged in as user X or not logged in) along with mcplocal and mcpd connectivity. + +**Details:** + +Load credentials and display auth status line: 'Auth: logged in as {user}' or 'Auth: not logged in'. Update status output format to show mcplocal and mcpd status separately with the auth info. + +### 33.7. Add config commands for mcplocal and mcpd URLs + +**Status:** pending +**Dependencies:** 33.1 + +Add set-mcplocal-url, set-mcpd-url, get-mcplocal-url, and get-mcpd-url commands to src/cli/src/commands/config.ts. + +**Details:** + +Add four new subcommands to the config command for setting and getting the mcplocal and mcpd URLs independently. Update the generic 'set' command to handle these new schema fields. + +### 33.8. Add --direct flag for mcpd bypass + +**Status:** pending +**Dependencies:** 33.1, 33.5 + +Add --direct flag to CLI commands that bypasses mcplocal and connects directly to mcpd for debugging purposes. + +**Details:** + +Add global --direct option to the main CLI. When set, api-client uses mcpdUrl instead of mcplocalUrl. Useful for debugging connectivity issues between mcplocal and mcpd. + +### 33.9. Register auth commands in CLI entry point + +**Status:** pending +**Dependencies:** 33.3, 33.4 + +Import and register the login and logout commands in src/cli/src/index.ts. + +**Details:** + +Import createAuthCommand from commands/auth.ts and add it to the main program with program.addCommand(createAuthCommand()). diff --git a/.taskmaster/tasks/task_034.md b/.taskmaster/tasks/task_034.md new file mode 100644 index 0000000..14e0216 --- /dev/null +++ b/.taskmaster/tasks/task_034.md @@ -0,0 +1,63 @@ +# Task ID: 34 + +**Title:** Connect mcplocal MCP router to mcpd proxy endpoint + +**Status:** done + +**Dependencies:** 28 ✓, 29 ✓ + +**Priority:** high + +**Description:** Update mcplocal's MCP router to forward tool calls to mcpd's new /api/v1/mcp/proxy endpoint instead of connecting to MCP servers directly. + +**Details:** + +1. Update `src/mcplocal/src/router.ts` to use mcpd proxy: + ```typescript + class Router { + private mcpdClient: McpdClient; + + async handleToolsCall(request: JsonRpcRequest) { + const { name, arguments: args } = request.params; + const [serverName, toolName] = name.split('/'); + + // Pre-process with LLM if enabled + const processedArgs = this.config.enablePreprocessing + ? await this.llmProcessor.preprocessRequest(toolName, args) + : args; + + // Call mcpd proxy endpoint + const result = await this.mcpdClient.post('/api/v1/mcp/proxy', { + serverId: serverName, + method: 'tools/call', + params: { name: toolName, arguments: processedArgs } + }); + + // Post-process response with LLM if enabled + return this.config.enablePreprocessing + ? await this.llmProcessor.filterResponse(toolName, args, result) + : result; + } + } + ``` +2. Update upstream configuration: + - Remove direct upstream connections for managed servers + - Keep option for local/unmanaged upstreams +3. Add server discovery from mcpd: + ```typescript + async refreshServerList() { + const servers = await this.mcpdClient.get('/api/v1/servers'); + this.updateAvailableTools(servers); + } + ``` +4. Handle tools/list by aggregating from mcpd servers +5. Handle resources/list and prompts/list similarly + +**Test Strategy:** + +1. Unit test: Tool calls are forwarded to mcpd proxy correctly +2. Unit test: Server name is extracted from namespaced tool name +3. Integration test: Full flow Claude -> mcplocal -> mcpd -> container +4. Test tools/list aggregates from all mcpd servers +5. Test error handling when mcpd is unreachable +6. Test LLM preprocessing is applied when enabled diff --git a/.taskmaster/tasks/task_035.md b/.taskmaster/tasks/task_035.md new file mode 100644 index 0000000..0513976 --- /dev/null +++ b/.taskmaster/tasks/task_035.md @@ -0,0 +1,64 @@ +# Task ID: 35 + +**Title:** Implement health monitoring across all tiers + +**Status:** done + +**Dependencies:** 33 ✓, 34 ✓ + +**Priority:** medium + +**Description:** Extend health monitoring to track connectivity and status across mcplocal, mcpd, and individual MCP server instances. + +**Details:** + +1. Update mcplocal health monitor in `src/mcplocal/src/health.ts`: + ```typescript + export class TieredHealthMonitor { + async checkHealth(): Promise { + return { + mcplocal: { + status: 'healthy', + llmProvider: await this.checkLlmProvider(), + uptime: process.uptime() + }, + mcpd: await this.checkMcpdHealth(), + instances: await this.checkInstancesHealth() + }; + } + + private async checkMcpdHealth(): Promise { + try { + const health = await this.mcpdClient.get('/api/v1/health'); + return { status: 'connected', ...health }; + } catch { + return { status: 'disconnected' }; + } + } + + private async checkInstancesHealth(): Promise { + const instances = await this.mcpdClient.get('/api/v1/instances'); + return instances.map(i => ({ + name: i.name, + status: i.status, + lastHealthCheck: i.lastHealthCheck + })); + } + } + ``` +2. Add health endpoint to mcplocal HTTP server: `GET /health` +3. Update mcpctl status command to display tiered health +4. Add degraded state detection: + - LLM provider unavailable but mcpd reachable + - Some instances down but others healthy +5. Add health event notifications for state transitions +6. Add configurable health check intervals + +**Test Strategy:** + +1. Unit test: Health check correctly identifies all states +2. Unit test: Degraded state is detected correctly +3. Integration test: Full health check across all tiers +4. Test health endpoint returns correct format +5. Test mcpctl status displays health correctly +6. Test state transition events are emitted diff --git a/.taskmaster/tasks/task_036.md b/.taskmaster/tasks/task_036.md new file mode 100644 index 0000000..de3ebcf --- /dev/null +++ b/.taskmaster/tasks/task_036.md @@ -0,0 +1,58 @@ +# Task ID: 36 + +**Title:** End-to-end integration testing + +**Status:** done + +**Dependencies:** 29 ✓, 33 ✓, 34 ✓, 35 ✓ + +**Priority:** high + +**Description:** Create comprehensive integration tests that validate the full data flow from mcpctl through mcplocal to mcpd to MCP server containers and back. + +**Details:** + +1. Create test fixtures in `src/mcplocal/test/fixtures/`: + - Mock MCP server that returns predictable responses + - Test configuration files + - Sample tool call payloads +2. Create integration test suite in `src/mcplocal/test/integration/`: + ```typescript + describe('End-to-end flow', () => { + it('mcpctl -> mcplocal -> mcpd -> mcp_server', async () => { + // Start mock MCP server + // Start mcpd with test config + // Start mcplocal pointing to mcpd + // Execute mcpctl command + // Verify response flows back correctly + }); + + it('LLM pre-processing reduces response size', async () => { + // Send query that returns large dataset + // Verify LLM filtering reduces token count + // Verify relevant data is preserved + }); + + it('credentials never leave mcpd', async () => { + // Monitor all traffic from mcplocal + // Verify no credentials appear in requests/responses + }); + }); + ``` +3. Test scenarios: + - Management commands (get servers, instances, etc.) + - MCP tool calls with LLM preprocessing + - MCP tool calls without preprocessing + - Error handling (mcpd down, instance down, LLM failure) + - Health monitoring accuracy +4. Add CI integration test workflow +5. Create docker-compose.test.yml for test environment + +**Test Strategy:** + +1. All integration tests pass in CI environment +2. Test coverage includes happy path and error scenarios +3. Performance benchmarks: measure latency at each tier +4. Security test: verify credential isolation +5. Load test: multiple concurrent requests +6. Chaos test: random component failures diff --git a/.taskmaster/tasks/task_037.md b/.taskmaster/tasks/task_037.md new file mode 100644 index 0000000..3f81b4a --- /dev/null +++ b/.taskmaster/tasks/task_037.md @@ -0,0 +1,36 @@ +# Task ID: 37 + +**Title:** Add priority, summary, chapters, and linkTarget fields to Prompt schema + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Extend the Prisma schema for the Prompt model to include priority (integer 1-10, default 5), summary (nullable string), chapters (nullable JSON array), and linkTarget (nullable string for prompt links). + +**Details:** + +1. Update `/src/db/prisma/schema.prisma` to add fields to the Prompt model: + - `priority Int @default(5)` with check constraint 1-10 + - `summary String? @db.Text` + - `chapters Json?` (stored as JSON array of strings) + - `linkTarget String?` (format: `project/server:resource-uri`) + +2. Create Prisma migration: + ```bash + pnpm --filter db exec prisma migrate dev --name add-prompt-priority-summary-chapters-link + ``` + +3. Update TypeScript types in shared package to reflect new fields + +4. Add validation for priority range (1-10) at the database level if possible, otherwise enforce in application layer + +**Test Strategy:** + +- Unit test: Verify migration creates columns with correct types and defaults +- Unit test: Verify priority default is 5 +- Unit test: Verify nullable fields accept null +- Unit test: Verify chapters stores/retrieves JSON arrays correctly +- Integration test: Create prompt with all new fields, retrieve and verify values diff --git a/.taskmaster/tasks/task_038.md b/.taskmaster/tasks/task_038.md new file mode 100644 index 0000000..5b2703c --- /dev/null +++ b/.taskmaster/tasks/task_038.md @@ -0,0 +1,35 @@ +# Task ID: 38 + +**Title:** Add priority field to PromptRequest schema + +**Status:** done + +**Dependencies:** 37 ✓ + +**Priority:** high + +**Description:** Extend the Prisma schema for the PromptRequest model to include the priority field (integer 1-10, default 5) to match the Prompt model. + +**Details:** + +1. Update `/src/db/prisma/schema.prisma` to add to PromptRequest: + - `priority Int @default(5)` + +2. Create Prisma migration: + ```bash + pnpm --filter db exec prisma migrate dev --name add-promptrequest-priority + ``` + +3. Update the `CreatePromptRequestSchema` in `/src/mcpd/src/validation/prompt.schema.ts` to include priority validation: + ```typescript + priority: z.number().int().min(1).max(10).default(5).optional(), + ``` + +4. Update TypeScript types in shared package + +**Test Strategy:** + +- Unit test: Migration creates priority column with default 5 +- Unit test: PromptRequest creation with explicit priority +- Unit test: PromptRequest creation uses default priority when not specified +- Unit test: Validation rejects priority outside 1-10 range diff --git a/.taskmaster/tasks/task_039.md b/.taskmaster/tasks/task_039.md new file mode 100644 index 0000000..4827a37 --- /dev/null +++ b/.taskmaster/tasks/task_039.md @@ -0,0 +1,38 @@ +# Task ID: 39 + +**Title:** Add gated field to Project schema + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Extend the Prisma schema for the Project model to include the gated boolean field (default true) that controls whether sessions go through the keyword-driven prompt retrieval flow. + +**Details:** + +1. Update `/src/db/prisma/schema.prisma` to add to Project: + - `gated Boolean @default(true)` + +2. Create Prisma migration: + ```bash + pnpm --filter db exec prisma migrate dev --name add-project-gated + ``` + +3. Update project-related TypeScript types + +4. Update project validation schemas to include gated field: + ```typescript + gated: z.boolean().default(true).optional(), + ``` + +5. Update project API routes to accept and return the gated field + +**Test Strategy:** + +- Unit test: Migration creates gated column with default true +- Unit test: Project creation with gated=false +- Unit test: Project creation uses default gated=true when not specified +- Unit test: Project update can toggle gated field +- Integration test: GET /api/v1/projects/:name returns gated field diff --git a/.taskmaster/tasks/task_040.md b/.taskmaster/tasks/task_040.md new file mode 100644 index 0000000..bfb3570 --- /dev/null +++ b/.taskmaster/tasks/task_040.md @@ -0,0 +1,49 @@ +# Task ID: 40 + +**Title:** Update Prompt CRUD API to handle priority and linkTarget + +**Status:** done + +**Dependencies:** 37 ✓ + +**Priority:** high + +**Description:** Modify prompt API endpoints to accept, validate, and return the priority and linkTarget fields. LinkTarget should be immutable after creation. + +**Details:** + +1. Update `/src/mcpd/src/validation/prompt.schema.ts`: + ```typescript + export const CreatePromptSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + content: z.string().min(1).max(50000), + projectId: z.string().optional(), + priority: z.number().int().min(1).max(10).default(5).optional(), + linkTarget: z.string().regex(/^[a-z0-9-]+\/[a-z0-9-]+:[\S]+$/).optional(), + }); + + export const UpdatePromptSchema = z.object({ + content: z.string().min(1).max(50000).optional(), + priority: z.number().int().min(1).max(10).optional(), + // Note: linkTarget is NOT included - links are immutable + }); + ``` + +2. Update `/src/mcpd/src/routes/prompts.ts`: + - POST /api/v1/prompts: Accept priority, linkTarget + - PUT /api/v1/prompts/:id: Accept priority only (not linkTarget) + - GET endpoints: Return priority, linkTarget in response + +3. Update repository layer to handle new fields + +4. Add linkTarget format validation: `project/server:resource-uri` + +**Test Strategy:** + +- Unit test: POST /api/v1/prompts with priority creates prompt with correct priority +- Unit test: POST /api/v1/prompts with linkTarget creates linked prompt +- Unit test: PUT /api/v1/prompts/:id with priority updates priority +- Unit test: PUT /api/v1/prompts/:id rejects linkTarget (immutable) +- Unit test: GET /api/v1/prompts returns priority and linkTarget fields +- Unit test: Invalid linkTarget format rejected (validation error) +- Unit test: Priority outside 1-10 range rejected diff --git a/.taskmaster/tasks/task_041.md b/.taskmaster/tasks/task_041.md new file mode 100644 index 0000000..f6f1dce --- /dev/null +++ b/.taskmaster/tasks/task_041.md @@ -0,0 +1,42 @@ +# Task ID: 41 + +**Title:** Update PromptRequest API to handle priority + +**Status:** done + +**Dependencies:** 38 ✓ + +**Priority:** high + +**Description:** Modify prompt request API endpoints to accept, validate, and return the priority field for proposed prompts. + +**Details:** + +1. Update validation in `/src/mcpd/src/validation/prompt.schema.ts`: + ```typescript + export const CreatePromptRequestSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + content: z.string().min(1).max(50000), + projectId: z.string().optional(), + createdBySession: z.string().optional(), + createdByUserId: z.string().optional(), + priority: z.number().int().min(1).max(10).default(5).optional(), + }); + ``` + +2. Update `/src/mcpd/src/routes/prompts.ts` for PromptRequest endpoints: + - POST /api/v1/promptrequests: Accept priority + - GET /api/v1/promptrequests: Return priority + - POST /api/v1/promptrequests/:id/approve: Preserve priority when creating Prompt + +3. Update PromptService.approve() to copy priority from request to prompt + +4. Update repository layer + +**Test Strategy:** + +- Unit test: POST /api/v1/promptrequests with priority creates request with correct priority +- Unit test: POST /api/v1/promptrequests uses default priority 5 when not specified +- Unit test: GET /api/v1/promptrequests returns priority field +- Unit test: Approve preserves priority from request to created prompt +- Unit test: Priority validation (1-10 range) diff --git a/.taskmaster/tasks/task_042.md b/.taskmaster/tasks/task_042.md new file mode 100644 index 0000000..f860d9b --- /dev/null +++ b/.taskmaster/tasks/task_042.md @@ -0,0 +1,58 @@ +# Task ID: 42 + +**Title:** Implement prompt summary generation service + +**Status:** done + +**Dependencies:** 37 ✓ + +**Priority:** high + +**Description:** Create a service that auto-generates summary (20 words) and chapters (key sections) for prompts, using fast LLM when available or regex fallback. + +**Details:** + +1. Create `/src/mcpd/src/services/prompt-summary.service.ts`: + ```typescript + export class PromptSummaryService { + constructor( + private llmClient: LlmClient | null, + private promptRepo: IPromptRepository + ) {} + + async generateSummary(content: string): Promise<{ summary: string; chapters: string[] }> { + if (this.llmClient) { + return this.generateWithLlm(content); + } + return this.generateWithRegex(content); + } + + private async generateWithLlm(content: string): Promise<...> { + // Send content to fast LLM with prompt: + // "Generate a 20-word summary and extract key section topics..." + } + + private generateWithRegex(content: string): { summary: string; chapters: string[] } { + // summary: first sentence of content (truncated to ~20 words) + // chapters: extract markdown headings via regex /^#+\s+(.+)$/gm + } + } + ``` + +2. Integrate with PromptService: + - Call generateSummary on prompt create + - Call generateSummary on prompt update (when content changes) + - Cache results on the prompt record + +3. Handle LLM availability check via existing LlmConfig patterns + +**Test Strategy:** + +- Unit test: generateWithRegex extracts first sentence as summary +- Unit test: generateWithRegex extracts markdown headings as chapters +- Unit test: generateWithLlm calls LLM with correct prompt (mock LLM) +- Unit test: generateSummary uses LLM when available +- Unit test: generateSummary falls back to regex when no LLM +- Unit test: Empty content handled gracefully +- Unit test: Content without headings returns empty chapters array +- Integration test: Creating prompt triggers summary generation diff --git a/.taskmaster/tasks/task_043.md b/.taskmaster/tasks/task_043.md new file mode 100644 index 0000000..3ef95b9 --- /dev/null +++ b/.taskmaster/tasks/task_043.md @@ -0,0 +1,41 @@ +# Task ID: 43 + +**Title:** Add regenerate-summary API endpoint + +**Status:** done + +**Dependencies:** 42 ✓ + +**Priority:** medium + +**Description:** Create POST /api/v1/prompts/:id/regenerate-summary endpoint to force re-generation of summary and chapters for a prompt. + +**Details:** + +1. Add route in `/src/mcpd/src/routes/prompts.ts`: + ```typescript + fastify.post('/api/v1/prompts/:id/regenerate-summary', async (request, reply) => { + const { id } = request.params as { id: string }; + const prompt = await promptService.findById(id); + if (!prompt) { + return reply.status(404).send({ error: 'Prompt not found' }); + } + + const { summary, chapters } = await summaryService.generateSummary(prompt.content); + const updated = await promptService.updateSummary(id, summary, chapters); + + return reply.send(updated); + }); + ``` + +2. Add `updateSummary(id, summary, chapters)` method to PromptRepository and PromptService + +3. Return the updated prompt with new summary/chapters in response + +**Test Strategy:** + +- Unit test: POST to valid prompt ID regenerates summary +- Unit test: Returns updated prompt with new summary/chapters +- Unit test: 404 for non-existent prompt ID +- Unit test: Uses LLM when available, regex fallback otherwise +- Integration test: End-to-end regeneration updates database diff --git a/.taskmaster/tasks/task_044.md b/.taskmaster/tasks/task_044.md new file mode 100644 index 0000000..d2c9482 --- /dev/null +++ b/.taskmaster/tasks/task_044.md @@ -0,0 +1,48 @@ +# Task ID: 44 + +**Title:** Create prompt-index API endpoint + +**Status:** done + +**Dependencies:** 42 ✓ + +**Priority:** medium + +**Description:** Create GET /api/v1/projects/:name/prompt-index endpoint that returns a compact index of prompts (name, priority, summary, chapters) for a project. + +**Details:** + +1. Add route in `/src/mcpd/src/routes/prompts.ts`: + ```typescript + fastify.get('/api/v1/projects/:name/prompt-index', async (request, reply) => { + const { name } = request.params as { name: string }; + const project = await projectService.findByName(name); + if (!project) { + return reply.status(404).send({ error: 'Project not found' }); + } + + const prompts = await promptService.findByProject(project.id); + const index = prompts.map(p => ({ + name: p.name, + priority: p.priority, + summary: p.summary, + chapters: p.chapters, + linkTarget: p.linkTarget, + })); + + return reply.send({ prompts: index }); + }); + ``` + +2. Consider adding global prompts to the index (inherited by all projects) + +3. Sort by priority descending in response + +**Test Strategy:** + +- Unit test: Returns compact index for valid project +- Unit test: Index contains name, priority, summary, chapters, linkTarget +- Unit test: 404 for non-existent project +- Unit test: Empty array for project with no prompts +- Unit test: Results sorted by priority descending +- Integration test: End-to-end retrieval matches database state diff --git a/.taskmaster/tasks/task_045.md b/.taskmaster/tasks/task_045.md new file mode 100644 index 0000000..dad88a5 --- /dev/null +++ b/.taskmaster/tasks/task_045.md @@ -0,0 +1,73 @@ +# Task ID: 45 + +**Title:** Implement tag-matching algorithm for prompt selection + +**Status:** done + +**Dependencies:** 44 ✓ + +**Priority:** high + +**Description:** Create a deterministic keyword-based tag matching algorithm as the no-LLM fallback for prompt selection, with byte-budget allocation and priority weighting. + +**Details:** + +1. Create `/src/mcplocal/src/services/tag-matcher.service.ts`: + ```typescript + interface MatchedPrompt { + prompt: PromptIndex; + score: number; + matchedTags: string[]; + } + + export class TagMatcherService { + constructor(private byteBudget: number = 8192) {} + + matchPrompts(tags: string[], promptIndex: PromptIndex[]): { + fullContent: PromptIndex[]; // Prompts to include in full + indexOnly: PromptIndex[]; // Prompts to include as index entries + remaining: PromptIndex[]; // Non-matched prompts (names only) + } { + // 1. Priority 10 prompts: always included (score = Infinity) + // 2. For each prompt, compute score: + // - Check tags against summary + chapters (case-insensitive substring) + // - score = matching_tags_count * priority + // 3. Sort by score descending + // 4. Fill byte budget from top: + // - Include full content until budget exhausted + // - Remaining matched: include as index entries + // - Non-matched: names only + } + + private computeScore(tags: string[], prompt: PromptIndex): number { + if (prompt.priority === 10) return Infinity; + const matchingTags = tags.filter(tag => + this.matchesPrompt(tag.toLowerCase(), prompt) + ); + return matchingTags.length * prompt.priority; + } + + private matchesPrompt(tag: string, prompt: PromptIndex): boolean { + const searchText = [ + prompt.summary || '', + ...(prompt.chapters || []) + ].join(' ').toLowerCase(); + return searchText.includes(tag); + } + } + ``` + +2. Handle edge cases: empty tags, no prompts, all priority 10, etc. + +**Test Strategy:** + +- Unit test: Priority 10 prompts always included regardless of tags +- Unit test: Score calculation: matching_tags * priority +- Unit test: Case-insensitive matching +- Unit test: Substring matching in summary and chapters +- Unit test: Byte budget exhaustion stops full content inclusion +- Unit test: Matched prompts beyond budget become index entries +- Unit test: Non-matched prompts listed as names only +- Unit test: Sorting by score descending +- Unit test: Empty tags returns priority 10 only +- Unit test: No prompts returns empty result diff --git a/.taskmaster/tasks/task_046.md b/.taskmaster/tasks/task_046.md new file mode 100644 index 0000000..6887b6e --- /dev/null +++ b/.taskmaster/tasks/task_046.md @@ -0,0 +1,59 @@ +# Task ID: 46 + +**Title:** Implement LLM-based prompt selection service + +**Status:** done + +**Dependencies:** 45 ✓ + +**Priority:** high + +**Description:** Create a service that uses the heavy LLM to intelligently select relevant prompts based on tags and the full prompt index, understanding synonyms and context. + +**Details:** + +1. Create `/src/mcplocal/src/services/llm-prompt-selector.service.ts`: + ```typescript + export class LlmPromptSelectorService { + constructor( + private llmClient: LlmClient, + private fastLlmClient: LlmClient | null, + private tagMatcher: TagMatcherService // fallback + ) {} + + async selectPrompts(tags: string[], promptIndex: PromptIndex[]): Promise<{ + selected: Array<{ name: string; reason: string }>; + priority10: PromptIndex[]; // Always included + }> { + // 1. Extract priority 10 prompts (always included) + // 2. Generate missing summaries using fast LLM if needed + // 3. Send to heavy LLM: + const prompt = ` + Given these keywords: ${tags.join(', ')} + And this prompt index: + ${promptIndex.map(p => `- ${p.name}: ${p.summary}`).join('\n')} + + Select the most relevant prompts for someone working on tasks + related to these keywords. Consider synonyms and related concepts. + Return a ranked JSON array: [{name: string, reason: string}] + `; + // 4. Parse LLM response + // 5. On LLM error, fall back to tag matcher + } + } + ``` + +2. Handle LLM timeouts and errors gracefully with fallback + +3. Validate LLM response format + +**Test Strategy:** + +- Unit test: Priority 10 prompts always returned regardless of LLM selection +- Unit test: LLM called with correct prompt format (mock) +- Unit test: LLM response parsed correctly +- Unit test: Invalid LLM response falls back to tag matcher +- Unit test: LLM timeout falls back to tag matcher +- Unit test: Missing summaries trigger fast LLM generation +- Unit test: No LLM available uses tag matcher directly +- Integration test: End-to-end selection with mock LLM diff --git a/.taskmaster/tasks/task_047.md b/.taskmaster/tasks/task_047.md new file mode 100644 index 0000000..d277ed1 --- /dev/null +++ b/.taskmaster/tasks/task_047.md @@ -0,0 +1,61 @@ +# Task ID: 47 + +**Title:** Implement session state management for gating + +**Status:** done + +**Dependencies:** 39 ✓ + +**Priority:** high + +**Description:** Extend the McpRouter to track per-session gating state including gated status, accumulated tags, and retrieved prompts set. + +**Details:** + +1. Update `/src/mcplocal/src/router.ts` to add session state: + ```typescript + interface SessionState { + gated: boolean; // starts true if project is gated + tags: string[]; // accumulated from begin_session + read_prompts + retrievedPrompts: Set; // prompts already sent (avoid duplicates) + } + + export class McpRouter { + private sessionStates: Map = new Map(); + + getSessionState(sessionId: string): SessionState { + if (!this.sessionStates.has(sessionId)) { + this.sessionStates.set(sessionId, { + gated: this.projectConfig?.gated ?? true, + tags: [], + retrievedPrompts: new Set(), + }); + } + return this.sessionStates.get(sessionId)!; + } + + ungateSession(sessionId: string): void { + const state = this.getSessionState(sessionId); + state.gated = false; + } + + addRetrievedPrompts(sessionId: string, names: string[]): void { + const state = this.getSessionState(sessionId); + names.forEach(n => state.retrievedPrompts.add(n)); + } + } + ``` + +2. Clean up session state when session closes + +3. Handle session state for non-gated projects (gated=false from start) + +**Test Strategy:** + +- Unit test: New session starts with gated=true for gated project +- Unit test: New session starts with gated=false for non-gated project +- Unit test: ungateSession changes gated to false +- Unit test: addRetrievedPrompts adds to set +- Unit test: retrievedPrompts prevents duplicates +- Unit test: Session state isolated per sessionId +- Unit test: Session cleanup removes state diff --git a/.taskmaster/tasks/task_048.md b/.taskmaster/tasks/task_048.md new file mode 100644 index 0000000..5d77fec --- /dev/null +++ b/.taskmaster/tasks/task_048.md @@ -0,0 +1,67 @@ +# Task ID: 48 + +**Title:** Implement begin_session tool for gated sessions + +**Status:** done + +**Dependencies:** 46 ✓, 47 ✓ + +**Priority:** high + +**Description:** Create the begin_session MCP tool that accepts 5 keywords, triggers prompt selection, returns matched content with encouragement, and ungates the session. + +**Details:** + +1. Add begin_session tool definition in `/src/mcplocal/src/router.ts`: + ```typescript + private getBeginSessionTool(): Tool { + return { + name: 'begin_session', + description: 'Start your session by providing 5 keywords that describe your current task. You\'ll receive relevant project context, policies, and guidelines. Required before using other tools.', + inputSchema: { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + maxItems: 10, + description: '5 keywords describing your current task' + } + }, + required: ['tags'] + } + }; + } + ``` + +2. Implement begin_session handler: + - Validate tags array (1-10 items) + - Call LlmPromptSelector or TagMatcher based on LLM availability + - Fetch full content for selected prompts + - Build response with matched content + index + encouragement + - Ungate session + - Send `notifications/tools/list_changed` + +3. Response format: + ``` + [Priority 10 prompts - full content] + + [Tag-matched prompts - full content, priority-ordered] + + Other prompts available that may become relevant... + - name: summary + ... + If any seem related, request them with read_prompts({ tags: [...] }). + ``` + +**Test Strategy:** + +- Unit test: begin_session with valid tags returns matched prompts +- Unit test: begin_session includes priority 10 prompts always +- Unit test: begin_session response includes encouragement text +- Unit test: begin_session response includes prompt index +- Unit test: Session ungated after successful begin_session +- Unit test: notifications/tools/list_changed sent after ungating +- Unit test: Empty tags handled (returns priority 10 only) +- Unit test: Invalid tags rejected with error +- Unit test: begin_session while already ungated returns error diff --git a/.taskmaster/tasks/task_049.md b/.taskmaster/tasks/task_049.md new file mode 100644 index 0000000..ccafc04 --- /dev/null +++ b/.taskmaster/tasks/task_049.md @@ -0,0 +1,58 @@ +# Task ID: 49 + +**Title:** Implement read_prompts tool for ongoing retrieval + +**Status:** done + +**Dependencies:** 48 ✓ + +**Priority:** high + +**Description:** Create the read_prompts MCP tool that allows clients to request additional context by keywords after the session is ungated. + +**Details:** + +1. Add read_prompts tool definition: + ```typescript + private getReadPromptsTool(): Tool { + return { + name: 'read_prompts', + description: 'Request additional project context by keywords. Use this whenever you need guidelines, policies, or conventions related to your current work.', + inputSchema: { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + description: 'Keywords describing what context you need' + } + }, + required: ['tags'] + } + }; + } + ``` + +2. Implement read_prompts handler: + - Always use keyword matching (not LLM) for precision + - Exclude already-retrieved prompts from response + - Add newly retrieved prompts to session state + - Include reminder about more prompts available + +3. Response format: + ``` + [Matched prompt content - deduplicated] + + Remember: you can request more prompts at any time with read_prompts({ tags: [...] }). + The project may have additional guidelines relevant to your current approach. + ``` + +**Test Strategy:** + +- Unit test: read_prompts returns matched prompts by keyword +- Unit test: Already retrieved prompts excluded from response +- Unit test: Newly retrieved prompts added to session state +- Unit test: Response includes reminder text +- Unit test: read_prompts while gated returns error +- Unit test: Empty tags returns empty response +- Unit test: Uses keyword matching not LLM diff --git a/.taskmaster/tasks/task_050.md b/.taskmaster/tasks/task_050.md new file mode 100644 index 0000000..2a0c13e --- /dev/null +++ b/.taskmaster/tasks/task_050.md @@ -0,0 +1,55 @@ +# Task ID: 50 + +**Title:** Implement progressive tool exposure for gated sessions + +**Status:** done + +**Dependencies:** 48 ✓, 49 ✓ + +**Priority:** high + +**Description:** Modify tools/list behavior to only expose begin_session while gated, and expose all tools plus read_prompts after ungating. + +**Details:** + +1. Update tools/list handling in `/src/mcplocal/src/router.ts`: + ```typescript + async handleToolsList(sessionId: string): Promise { + const state = this.getSessionState(sessionId); + + if (state.gated) { + // Only show begin_session while gated + return [this.getBeginSessionTool()]; + } + + // After ungating: all upstream tools + read_prompts + const upstreamTools = await this.discoverTools(); + return [...upstreamTools, this.getReadPromptsTool()]; + } + ``` + +2. Block direct tool calls while gated: + ```typescript + async handleToolCall(sessionId: string, toolName: string, args: any): Promise { + const state = this.getSessionState(sessionId); + + if (state.gated && toolName !== 'begin_session') { + // Intercept: extract keywords, match prompts, inject briefing + return this.handleInterceptedCall(sessionId, toolName, args); + } + + // Normal routing + return this.routeToolCall(toolName, args); + } + ``` + +3. Ensure notifications/tools/list_changed is sent after ungating + +**Test Strategy:** + +- Unit test: tools/list while gated returns only begin_session +- Unit test: tools/list after ungating returns all tools + read_prompts +- Unit test: begin_session not visible after ungating +- Unit test: Tool call while gated (not begin_session) triggers intercept +- Unit test: Tool call after ungating routes normally +- Unit test: notifications/tools/list_changed sent on ungate diff --git a/.taskmaster/tasks/task_051.md b/.taskmaster/tasks/task_051.md new file mode 100644 index 0000000..957bca5 --- /dev/null +++ b/.taskmaster/tasks/task_051.md @@ -0,0 +1,54 @@ +# Task ID: 51 + +**Title:** Implement keyword extraction from tool calls + +**Status:** done + +**Dependencies:** None + +**Priority:** medium + +**Description:** Create a service that extracts keywords from tool names and arguments for the intercept fallback path when clients skip begin_session. + +**Details:** + +1. Create `/src/mcplocal/src/services/keyword-extractor.service.ts`: + ```typescript + export class KeywordExtractorService { + extractKeywords(toolName: string, args: Record): string[] { + const keywords: string[] = []; + + // Extract from tool name (split on / and -) + // e.g., "home-assistant/get_entities" -> ["home", "assistant", "get", "entities"] + keywords.push(...this.extractFromName(toolName)); + + // Extract from argument values + // e.g., { domain: "light", entity_id: "light.kitchen" } -> ["light", "kitchen"] + keywords.push(...this.extractFromArgs(args)); + + // Deduplicate and sanitize + return [...new Set(keywords.map(k => this.sanitize(k)))]; + } + + private sanitize(keyword: string): string { + // Remove special characters, lowercase, limit length + return keyword.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 50); + } + } + ``` + +2. Handle various argument types: strings, arrays, nested objects + +3. Prevent injection by sanitizing extracted keywords + +**Test Strategy:** + +- Unit test: Extracts keywords from tool name with / +- Unit test: Extracts keywords from tool name with - +- Unit test: Extracts keywords from string argument values +- Unit test: Extracts keywords from array argument values +- Unit test: Handles nested object arguments +- Unit test: Sanitizes special characters +- Unit test: Deduplicates keywords +- Unit test: Handles empty arguments +- Unit test: Limits keyword length to prevent abuse diff --git a/.taskmaster/tasks/task_052.md b/.taskmaster/tasks/task_052.md new file mode 100644 index 0000000..511f46d --- /dev/null +++ b/.taskmaster/tasks/task_052.md @@ -0,0 +1,63 @@ +# Task ID: 52 + +**Title:** Implement tool call intercept with briefing injection + +**Status:** done + +**Dependencies:** 50 ✓, 51 ✓ + +**Priority:** high + +**Description:** When a gated session calls a tool without first calling begin_session, intercept the call, extract keywords, match prompts, and inject the briefing alongside the real tool result. + +**Details:** + +1. Implement handleInterceptedCall in `/src/mcplocal/src/router.ts`: + ```typescript + async handleInterceptedCall( + sessionId: string, + toolName: string, + args: any + ): Promise { + // 1. Extract keywords from tool call + const keywords = this.keywordExtractor.extractKeywords(toolName, args); + + // 2. Match prompts using keywords + const { fullContent, indexOnly, remaining } = + await this.promptSelector.selectPrompts(keywords, this.promptIndex); + + // 3. Execute the actual tool call + const actualResult = await this.routeToolCall(toolName, args); + + // 4. Build briefing with intercept preamble + const briefing = this.buildBriefing(fullContent, indexOnly, remaining, 'intercept'); + + // 5. Ungate session + this.ungateSession(sessionId); + + // 6. Send notifications/tools/list_changed + await this.sendToolsListChanged(); + + // 7. Return combined result + return { + content: [{ + type: 'text', + text: `${briefing}\n\n---\n\n${actualResult.content[0].text}` + }] + }; + } + ``` + +2. Use gate-intercept-preamble system prompt for the briefing prefix + +**Test Strategy:** + +- Unit test: Tool call while gated triggers intercept +- Unit test: Keywords extracted from tool name and args +- Unit test: Prompts matched using extracted keywords +- Unit test: Actual tool still executes and returns result +- Unit test: Briefing prepended to tool result +- Unit test: Session ungated after intercept +- Unit test: notifications/tools/list_changed sent +- Unit test: Intercept preamble included in briefing +- Integration test: End-to-end intercept flow diff --git a/.taskmaster/tasks/task_053.md b/.taskmaster/tasks/task_053.md new file mode 100644 index 0000000..5b24c7a --- /dev/null +++ b/.taskmaster/tasks/task_053.md @@ -0,0 +1,54 @@ +# Task ID: 53 + +**Title:** Add prompt index to initialize instructions + +**Status:** done + +**Dependencies:** 47 ✓, 44 ✓ + +**Priority:** high + +**Description:** Modify the initialize handler to include the compact prompt index and gate message in instructions for gated projects. + +**Details:** + +1. Update initialize handling in `/src/mcplocal/src/router.ts`: + ```typescript + async handleInitialize(sessionId: string): Promise { + const state = this.getSessionState(sessionId); + + let instructions = this.projectConfig.prompt || ''; + + if (state.gated) { + // Add gate instructions + const gateInstructions = await this.getSystemPrompt('gate-instructions'); + + // Build prompt index (cap at 50, priority 7+ if over) + const index = this.buildPromptIndex(); + + instructions += `\n\n${gateInstructions.replace('{{prompt_index}}', index)}`; + } + + return { + protocolVersion: '2024-11-05', + capabilities: { ... }, + serverInfo: { ... }, + instructions, + }; + } + ``` + +2. Build prompt index with cap: + - If <= 50 prompts: include all + - If > 50 prompts: include only priority 7+ + - Format: `- : ` (~100 chars per entry) + +**Test Strategy:** + +- Unit test: Gated project includes gate instructions in initialize +- Unit test: Prompt index included in instructions +- Unit test: Index capped at 50 entries +- Unit test: Over 50 prompts shows priority 7+ only +- Unit test: Non-gated project skips gate instructions +- Unit test: {{prompt_index}} template replaced +- Integration test: End-to-end initialize with gated project diff --git a/.taskmaster/tasks/task_054.md b/.taskmaster/tasks/task_054.md new file mode 100644 index 0000000..f58de01 --- /dev/null +++ b/.taskmaster/tasks/task_054.md @@ -0,0 +1,58 @@ +# Task ID: 54 + +**Title:** Create mcpctl-system project with system prompts + +**Status:** done + +**Dependencies:** 40 ✓, 39 ✓ + +**Priority:** high + +**Description:** Implement bootstrap logic to create the mcpctl-system project and its required system prompts on first startup, with protection against deletion. + +**Details:** + +1. Create seed migration or startup hook: + ```typescript + async function bootstrapSystemProject() { + const systemProject = await projectRepo.findByName('mcpctl-system'); + if (systemProject) return; // Already exists + + // Create mcpctl-system project + const project = await projectRepo.create({ + name: 'mcpctl-system', + description: 'System prompts for mcpctl gating and encouragement', + gated: false, // System project is not gated + ownerId: SYSTEM_USER_ID, + }); + + // Create required system prompts + const systemPrompts = [ + { name: 'gate-instructions', priority: 10, content: GATE_INSTRUCTIONS }, + { name: 'gate-encouragement', priority: 10, content: GATE_ENCOURAGEMENT }, + { name: 'read-prompts-reminder', priority: 10, content: READ_PROMPTS_REMINDER }, + { name: 'gate-intercept-preamble', priority: 10, content: GATE_INTERCEPT_PREAMBLE }, + { name: 'session-greeting', priority: 10, content: SESSION_GREETING }, + ]; + + for (const p of systemPrompts) { + await promptRepo.create({ ...p, projectId: project.id }); + } + } + ``` + +2. Add delete protection in prompt delete endpoint: + - Check if prompt belongs to mcpctl-system + - Return 403 error if attempting to delete system prompt + +3. Define default content for each system prompt per PRD + +**Test Strategy:** + +- Unit test: System project created on first startup +- Unit test: All 5 system prompts created +- Unit test: Subsequent startups don't duplicate +- Unit test: Delete system prompt returns 403 +- Unit test: System prompts have priority 10 +- Unit test: mcpctl-system project has gated=false +- Integration test: End-to-end bootstrap flow diff --git a/.taskmaster/tasks/task_055.md b/.taskmaster/tasks/task_055.md new file mode 100644 index 0000000..e4d32ec --- /dev/null +++ b/.taskmaster/tasks/task_055.md @@ -0,0 +1,61 @@ +# Task ID: 55 + +**Title:** Implement system prompt fetching and caching in mcplocal + +**Status:** done + +**Dependencies:** 54 ✓ + +**Priority:** high + +**Description:** Add functionality to mcplocal router to fetch system prompts from mcpd and cache them with 60s TTL, supporting template variable replacement. + +**Details:** + +1. Add system prompt fetching in `/src/mcplocal/src/router.ts`: + ```typescript + private systemPromptCache: Map = new Map(); + + async getSystemPrompt(name: string): Promise { + const cached = this.systemPromptCache.get(name); + if (cached && cached.expiresAt > Date.now()) { + return cached.content; + } + + const prompts = await this.mcpdClient.fetch( + '/api/v1/projects/mcpctl-system/prompts/visible' + ); + const prompt = prompts.find(p => p.name === name); + if (!prompt) { + throw new Error(`System prompt not found: ${name}`); + } + + this.systemPromptCache.set(name, { + content: prompt.content, + expiresAt: Date.now() + 60000, // 60s TTL + }); + + return prompt.content; + } + ``` + +2. Add template variable replacement: + ```typescript + replaceTemplateVariables(content: string, vars: Record): string { + return content + .replace(/\{\{prompt_index\}\}/g, vars.prompt_index || '') + .replace(/\{\{project_name\}\}/g, vars.project_name || '') + .replace(/\{\{matched_prompts\}\}/g, vars.matched_prompts || '') + .replace(/\{\{remaining_prompts\}\}/g, vars.remaining_prompts || ''); + } + ``` + +**Test Strategy:** + +- Unit test: System prompt fetched from mcpd +- Unit test: Cached prompt returned within TTL +- Unit test: Cache miss triggers fresh fetch +- Unit test: Missing system prompt throws error +- Unit test: Template variables replaced correctly +- Unit test: Unknown template variables left as-is +- Integration test: End-to-end fetch and cache diff --git a/.taskmaster/tasks/task_056.md b/.taskmaster/tasks/task_056.md new file mode 100644 index 0000000..629b83a --- /dev/null +++ b/.taskmaster/tasks/task_056.md @@ -0,0 +1,67 @@ +# Task ID: 56 + +**Title:** Implement prompt link resolution service + +**Status:** done + +**Dependencies:** 40 ✓ + +**Priority:** medium + +**Description:** Create a service that fetches linked prompt content from source MCP servers using the project's service account, with dead link detection. + +**Details:** + +1. Create `/src/mcplocal/src/services/link-resolver.service.ts`: + ```typescript + export class LinkResolverService { + constructor(private mcpdClient: McpdClient) {} + + async resolveLink(linkTarget: string): Promise<{ + content: string | null; + status: 'alive' | 'dead' | 'unknown'; + error?: string; + }> { + // Parse linkTarget: project/server:resource-uri + const { project, server, uri } = this.parseLink(linkTarget); + + try { + // Use service account for source project + const content = await this.fetchResource(project, server, uri); + return { content, status: 'alive' }; + } catch (error) { + this.logDeadLink(linkTarget, error); + return { + content: null, + status: 'dead', + error: error.message + }; + } + } + + private parseLink(linkTarget: string): { project: string; server: string; uri: string } { + const match = linkTarget.match(/^([^/]+)\/([^:]+):(.+)$/); + if (!match) throw new Error('Invalid link format'); + return { project: match[1], server: match[2], uri: match[3] }; + } + + private async fetchResource(project: string, server: string, uri: string): Promise { + // Call mcpd to fetch resource via service account + // mcpd routes to the source project's MCP server + } + } + ``` + +2. Log dead links as errors + +3. Cache resolution results + +**Test Strategy:** + +- Unit test: Valid link parsed correctly +- Unit test: Invalid link format throws error +- Unit test: Successful resolution returns content and status='alive' +- Unit test: Failed resolution returns status='dead' with error +- Unit test: Dead link logged as error +- Unit test: Service account header included in request +- Integration test: End-to-end link resolution diff --git a/.taskmaster/tasks/task_057.md b/.taskmaster/tasks/task_057.md new file mode 100644 index 0000000..1e28dec --- /dev/null +++ b/.taskmaster/tasks/task_057.md @@ -0,0 +1,50 @@ +# Task ID: 57 + +**Title:** Add linkStatus to prompt GET responses + +**Status:** done + +**Dependencies:** 56 ✓ + +**Priority:** medium + +**Description:** Modify the GET /api/v1/prompts endpoint to include linkStatus (alive/dead/unknown) for linked prompts by checking link health. + +**Details:** + +1. Update `/src/mcpd/src/routes/prompts.ts` GET endpoint: + ```typescript + fastify.get('/api/v1/prompts', async (request, reply) => { + const prompts = await promptService.findAll(filter); + + // Check link status for linked prompts + const promptsWithStatus = await Promise.all( + prompts.map(async (p) => { + if (!p.linkTarget) { + return { ...p, linkStatus: null }; + } + const status = await linkResolver.checkLinkHealth(p.linkTarget); + return { ...p, linkStatus: status }; + }) + ); + + return reply.send(promptsWithStatus); + }); + ``` + +2. Consider caching link health to avoid repeated checks + +3. Add `linkStatus` field to prompt response schema: + - `null` for non-linked prompts + - `'alive'` for working links + - `'dead'` for broken links + - `'unknown'` for unchecked links + +**Test Strategy:** + +- Unit test: Non-linked prompt has linkStatus=null +- Unit test: Linked prompt with working link has linkStatus='alive' +- Unit test: Linked prompt with broken link has linkStatus='dead' +- Unit test: Link health cached to avoid repeated checks +- Unit test: All prompts in response have linkStatus field +- Integration test: End-to-end GET with linked prompts diff --git a/.taskmaster/tasks/task_058.md b/.taskmaster/tasks/task_058.md new file mode 100644 index 0000000..0ec4462 --- /dev/null +++ b/.taskmaster/tasks/task_058.md @@ -0,0 +1,55 @@ +# Task ID: 58 + +**Title:** Add RBAC for prompt link creation + +**Status:** done + +**Dependencies:** 56 ✓ + +**Priority:** medium + +**Description:** Implement RBAC checks requiring edit permission on the target project to create prompt links, and auto-create service account permission on the source project. + +**Details:** + +1. Update prompt creation in `/src/mcpd/src/services/prompt.service.ts`: + ```typescript + async createPrompt(data: CreatePromptInput, userId: string): Promise { + if (data.linkTarget) { + // Verify user has edit permission on target project RBAC + const hasPermission = await this.rbacService.checkPermission( + userId, data.projectId, 'edit' + ); + if (!hasPermission) { + throw new ForbiddenError('Edit permission required to create prompt links'); + } + + // Parse link target + const { project: sourceProject, server, uri } = this.parseLink(data.linkTarget); + + // Create service account permission on source project + await this.rbacService.createServiceAccountPermission( + data.projectId, // target project + sourceProject, // source project + server, + uri, + 'read' + ); + } + + return this.promptRepo.create(data); + } + ``` + +2. Clean up service account permission when link is deleted + +3. Handle permission denied from source project + +**Test Strategy:** + +- Unit test: Link creation requires edit permission +- Unit test: Link creation without permission throws 403 +- Unit test: Service account permission created on source project +- Unit test: Service account permission deleted when link deleted +- Unit test: Non-link prompts skip RBAC checks +- Integration test: End-to-end link creation with RBAC diff --git a/.taskmaster/tasks/task_059.md b/.taskmaster/tasks/task_059.md new file mode 100644 index 0000000..9f60d05 --- /dev/null +++ b/.taskmaster/tasks/task_059.md @@ -0,0 +1,64 @@ +# Task ID: 59 + +**Title:** Update CLI create prompt command for priority and link + +**Status:** done + +**Dependencies:** 40 ✓ + +**Priority:** medium + +**Description:** Extend the mcpctl create prompt command to accept --priority (1-10) and --link (project/server:uri) flags. + +**Details:** + +1. Update `/src/cli/src/commands/create.ts` for prompt: + ```typescript + .command('prompt ') + .description('Create a new prompt') + .option('-p, --project ', 'Project to create prompt in') + .option('--priority ', 'Priority level (1-10, default: 5)', '5') + .option('--link ', 'Link to MCP resource (project/server:uri)') + .option('-f, --file ', 'Read content from file') + .action(async (name, options) => { + const priority = parseInt(options.priority, 10); + if (priority < 1 || priority > 10) { + console.error('Priority must be between 1 and 10'); + process.exit(1); + } + + let content = ''; + if (options.link) { + // Linked prompts don't need content (fetched from source) + content = `[Link: ${options.link}]`; + } else if (options.file) { + content = await fs.readFile(options.file, 'utf-8'); + } else { + content = await promptForContent(); + } + + const body = { + name, + content, + projectId: options.project, + priority, + linkTarget: options.link, + }; + + await api.post('/api/v1/prompts', body); + }); + ``` + +2. Validate link format: `project/server:resource-uri` + +3. Add shell completions for new flags + +**Test Strategy:** + +- Unit test: --priority flag sets prompt priority +- Unit test: --priority validation (1-10 range) +- Unit test: --link flag sets linkTarget +- Unit test: --link validation (format check) +- Unit test: Linked prompt skips content prompt +- Unit test: Default priority is 5 +- Integration test: End-to-end create with flags diff --git a/.taskmaster/tasks/task_060.md b/.taskmaster/tasks/task_060.md new file mode 100644 index 0000000..2395639 --- /dev/null +++ b/.taskmaster/tasks/task_060.md @@ -0,0 +1,54 @@ +# Task ID: 60 + +**Title:** Update CLI get prompt command for -A flag and link columns + +**Status:** done + +**Dependencies:** 57 ✓, 59 ✓ + +**Priority:** medium + +**Description:** Extend the mcpctl get prompt command with -A (all projects) flag and add link target and status columns to output. + +**Details:** + +1. Update `/src/cli/src/commands/get.ts` for prompt: + ```typescript + .command('prompt [name]') + .option('-A, --all-projects', 'Show prompts from all projects') + .option('-p, --project ', 'Filter by project') + .action(async (name, options) => { + let url = '/api/v1/prompts'; + if (options.allProjects) { + url += '?all=true'; + } else if (options.project) { + url += `?project=${options.project}`; + } + + const prompts = await api.get(url); + + // Format table with new columns + formatPromptsTable(prompts, { + columns: ['PROJECT', 'NAME', 'PRIORITY', 'LINK', 'STATUS'] + }); + }); + ``` + +2. Update table formatter to handle link columns: + ``` + PROJECT NAME PRIORITY LINK STATUS + homeautomation security-policies 8 - - + homeautomation architecture-adr 6 system-public/docmost-mcp:docmost://pages/a1 alive + ``` + +3. Add shell completions for -A flag + +**Test Strategy:** + +- Unit test: -A flag shows all projects +- Unit test: --project flag filters by project +- Unit test: PRIORITY column displayed +- Unit test: LINK column shows linkTarget or - +- Unit test: STATUS column shows linkStatus or - +- Unit test: Table formatted correctly +- Integration test: End-to-end get with flags diff --git a/.taskmaster/tasks/task_061.md b/.taskmaster/tasks/task_061.md new file mode 100644 index 0000000..1afdd68 --- /dev/null +++ b/.taskmaster/tasks/task_061.md @@ -0,0 +1,49 @@ +# Task ID: 61 + +**Title:** Update CLI describe project command for gated status + +**Status:** done + +**Dependencies:** 44 ✓, 54 ✓ + +**Priority:** medium + +**Description:** Extend mcpctl describe project to show gated status, session greeting, and prompt table with priority and link information. + +**Details:** + +1. Update `/src/cli/src/commands/get.ts` describe project: + ```typescript + async function describeProject(name: string) { + const project = await api.get(`/api/v1/projects/${name}`); + const prompts = await api.get(`/api/v1/projects/${name}/prompt-index`); + const greeting = await getSessionGreeting(name); + + console.log(`Name: ${project.name}`); + console.log(`Gated: ${project.gated}`); + console.log(`LLM Provider: ${project.llmProvider || '-'}`); + console.log(`...`); + console.log(); + console.log(`Session greeting:`); + console.log(` ${greeting}`); + console.log(); + console.log(`Prompts:`); + console.log(` NAME PRIORITY TYPE LINK`); + for (const p of prompts) { + const type = p.linkTarget ? 'link' : 'local'; + const link = p.linkTarget || '-'; + console.log(` ${p.name.padEnd(20)} ${p.priority.toString().padEnd(9)} ${type.padEnd(7)} ${link}`); + } + } + ``` + +2. Fetch session greeting from system prompts or project config + +**Test Strategy:** + +- Unit test: Gated status displayed +- Unit test: Session greeting displayed +- Unit test: Prompt table with PRIORITY, TYPE, LINK columns +- Unit test: TYPE shows 'local' or 'link' +- Unit test: LINK shows target or - +- Integration test: End-to-end describe project diff --git a/.taskmaster/tasks/task_062.md b/.taskmaster/tasks/task_062.md new file mode 100644 index 0000000..4fd4641 --- /dev/null +++ b/.taskmaster/tasks/task_062.md @@ -0,0 +1,51 @@ +# Task ID: 62 + +**Title:** Update CLI edit project command for gated field + +**Status:** done + +**Dependencies:** 39 ✓ + +**Priority:** medium + +**Description:** Extend mcpctl edit project to allow editing the gated boolean field. + +**Details:** + +1. Update `/src/cli/src/commands/edit.ts` for project: + ```typescript + async function editProject(name: string) { + const project = await api.get(`/api/v1/projects/${name}`); + + // Add gated to editable fields + const yaml = ` + name: ${project.name} + description: ${project.description} + gated: ${project.gated} + llmProvider: ${project.llmProvider || ''} + ...`; + + const edited = await openEditor(yaml); + const parsed = YAML.parse(edited); + + // Validate gated is boolean + if (typeof parsed.gated !== 'boolean') { + console.error('gated must be true or false'); + process.exit(1); + } + + await api.put(`/api/v1/projects/${name}`, parsed); + } + ``` + +2. Update project validation schema to accept gated + +3. Handle conversion from string 'true'/'false' to boolean + +**Test Strategy:** + +- Unit test: Gated field appears in editor YAML +- Unit test: Gated field saved on edit +- Unit test: Boolean validation (true/false only) +- Unit test: String 'true'/'false' converted to boolean +- Integration test: End-to-end edit project gated diff --git a/.taskmaster/tasks/task_063.md b/.taskmaster/tasks/task_063.md new file mode 100644 index 0000000..bd2d804 --- /dev/null +++ b/.taskmaster/tasks/task_063.md @@ -0,0 +1,68 @@ +# Task ID: 63 + +**Title:** Add unit tests for prompt priority and link CRUD + +**Status:** done + +**Dependencies:** 40 ✓, 41 ✓ + +**Priority:** high + +**Description:** Create comprehensive unit tests for all prompt CRUD operations with the new priority and linkTarget fields. + +**Details:** + +1. Add tests in `/src/mcpd/tests/services/prompt-service.test.ts`: + ```typescript + describe('Prompt Priority', () => { + it('creates prompt with explicit priority', async () => { + const prompt = await service.createPrompt({ ...data, priority: 8 }); + expect(prompt.priority).toBe(8); + }); + + it('uses default priority 5 when not specified', async () => { + const prompt = await service.createPrompt(data); + expect(prompt.priority).toBe(5); + }); + + it('validates priority range 1-10', async () => { + await expect(service.createPrompt({ ...data, priority: 11 })) + .rejects.toThrow(); + }); + + it('updates priority', async () => { + const updated = await service.updatePrompt(id, { priority: 3 }); + expect(updated.priority).toBe(3); + }); + }); + + describe('Prompt Links', () => { + it('creates linked prompt', async () => { + const prompt = await service.createPrompt({ + ...data, + linkTarget: 'project/server:uri' + }); + expect(prompt.linkTarget).toBe('project/server:uri'); + }); + + it('rejects invalid link format', async () => { + await expect(service.createPrompt({ + ...data, + linkTarget: 'invalid' + })).rejects.toThrow(); + }); + + it('linkTarget is immutable on update', async () => { + // linkTarget not accepted in update schema + }); + }); + ``` + +**Test Strategy:** + +This task IS the test implementation. Verify: +- All priority CRUD tests pass +- All link CRUD tests pass +- Validation tests cover edge cases +- Tests use proper mocking patterns +- Coverage meets project standards diff --git a/.taskmaster/tasks/task_064.md b/.taskmaster/tasks/task_064.md new file mode 100644 index 0000000..48c38a9 --- /dev/null +++ b/.taskmaster/tasks/task_064.md @@ -0,0 +1,58 @@ +# Task ID: 64 + +**Title:** Add unit tests for tag matching algorithm + +**Status:** done + +**Dependencies:** 45 ✓ + +**Priority:** high + +**Description:** Create comprehensive unit tests for the deterministic tag matching algorithm covering score calculation, byte budget, and priority handling. + +**Details:** + +1. Add tests in `/src/mcplocal/tests/services/tag-matcher.test.ts`: + ```typescript + describe('TagMatcherService', () => { + describe('score calculation', () => { + it('priority 10 prompts have infinite score', () => { + const score = matcher.computeScore(['any'], { priority: 10, ... }); + expect(score).toBe(Infinity); + }); + + it('score = matching_tags * priority', () => { + const score = matcher.computeScore( + ['tag1', 'tag2'], + { priority: 5, summary: 'tag1 tag2', chapters: [] } + ); + expect(score).toBe(10); // 2 tags * 5 priority + }); + }); + + describe('matching', () => { + it('matches case-insensitively', () => { + const matches = matcher.matchesPrompt('ZIGBEE', { summary: 'zigbee setup' }); + expect(matches).toBe(true); + }); + + it('matches substring in summary', () => { ... }); + it('matches substring in chapters', () => { ... }); + }); + + describe('byte budget', () => { + it('includes full content until budget exhausted', () => { ... }); + it('matched prompts beyond budget become index entries', () => { ... }); + it('non-matched prompts listed as names only', () => { ... }); + }); + }); + ``` + +**Test Strategy:** + +This task IS the test implementation. Verify: +- Score calculation tests pass +- Matching tests cover all cases +- Byte budget tests verify allocation +- Edge cases handled (empty tags, no prompts, etc.) +- Tests are deterministic diff --git a/.taskmaster/tasks/task_065.md b/.taskmaster/tasks/task_065.md new file mode 100644 index 0000000..b6cfc1c --- /dev/null +++ b/.taskmaster/tasks/task_065.md @@ -0,0 +1,63 @@ +# Task ID: 65 + +**Title:** Add unit tests for gating state machine + +**Status:** done + +**Dependencies:** 50 ✓, 52 ✓ + +**Priority:** high + +**Description:** Create comprehensive unit tests for the session gating state machine covering all transitions and edge cases. + +**Details:** + +1. Add tests in `/src/mcplocal/tests/router-gating.test.ts`: + ```typescript + describe('Gating State Machine', () => { + describe('initial state', () => { + it('starts gated for gated project', () => { + const router = createRouter({ gated: true }); + const state = router.getSessionState('session1'); + expect(state.gated).toBe(true); + }); + + it('starts ungated for non-gated project', () => { + const router = createRouter({ gated: false }); + const state = router.getSessionState('session1'); + expect(state.gated).toBe(false); + }); + }); + + describe('begin_session transition', () => { + it('ungates session on successful begin_session', async () => { + const router = createGatedRouter(); + await router.handleBeginSession('session1', { tags: ['test'] }); + expect(router.getSessionState('session1').gated).toBe(false); + }); + + it('returns matched prompts', async () => { ... }); + it('sends notifications/tools/list_changed', async () => { ... }); + }); + + describe('intercept transition', () => { + it('ungates session on tool call intercept', async () => { ... }); + it('extracts keywords from tool call', async () => { ... }); + it('injects briefing with tool result', async () => { ... }); + }); + + describe('tools/list behavior', () => { + it('returns only begin_session while gated', async () => { ... }); + it('returns all tools + read_prompts after ungating', async () => { ... }); + }); + }); + ``` + +**Test Strategy:** + +This task IS the test implementation. Verify: +- Initial state tests pass +- Transition tests cover happy paths +- Edge case tests (already ungated, etc.) +- Notification tests verify signals sent +- Tests use proper mocking diff --git a/.taskmaster/tasks/task_066.md b/.taskmaster/tasks/task_066.md new file mode 100644 index 0000000..cb846e9 --- /dev/null +++ b/.taskmaster/tasks/task_066.md @@ -0,0 +1,61 @@ +# Task ID: 66 + +**Title:** Add unit tests for LLM prompt selection + +**Status:** done + +**Dependencies:** 46 ✓ + +**Priority:** high + +**Description:** Create unit tests for the LLM-based prompt selection service covering LLM interactions, fallback behavior, and priority 10 handling. + +**Details:** + +1. Add tests in `/src/mcplocal/tests/services/llm-prompt-selector.test.ts`: + ```typescript + describe('LlmPromptSelectorService', () => { + describe('priority 10 handling', () => { + it('always includes priority 10 prompts', async () => { + const result = await selector.selectPrompts(['unrelated'], promptIndex); + expect(result.priority10).toContain(priority10Prompt); + }); + }); + + describe('LLM selection', () => { + it('sends tags and index to heavy LLM', async () => { + await selector.selectPrompts(['zigbee', 'mqtt'], promptIndex); + expect(mockLlm.complete).toHaveBeenCalledWith( + expect.stringContaining('zigbee') + ); + }); + + it('parses LLM response correctly', async () => { + mockLlm.complete.mockResolvedValue( + '[{"name": "prompt1", "reason": "relevant"}]' + ); + const result = await selector.selectPrompts(['test'], promptIndex); + expect(result.selected[0].name).toBe('prompt1'); + }); + }); + + describe('fallback behavior', () => { + it('falls back to tag matcher on LLM error', async () => { ... }); + it('falls back on LLM timeout', async () => { ... }); + it('falls back when no LLM available', async () => { ... }); + }); + + describe('summary generation', () => { + it('generates missing summaries with fast LLM', async () => { ... }); + }); + }); + ``` + +**Test Strategy:** + +This task IS the test implementation. Verify: +- Priority 10 tests pass +- LLM interaction tests use proper mocks +- Fallback tests cover all error scenarios +- Summary generation tests pass +- Response parsing handles edge cases diff --git a/.taskmaster/tasks/task_067.md b/.taskmaster/tasks/task_067.md new file mode 100644 index 0000000..89c09a7 --- /dev/null +++ b/.taskmaster/tasks/task_067.md @@ -0,0 +1,64 @@ +# Task ID: 67 + +**Title:** Add integration tests for gated session flow + +**Status:** done + +**Dependencies:** 65 ✓ + +**Priority:** high + +**Description:** Create end-to-end integration tests for the complete gated session flow including connect, begin_session, tool calls, and read_prompts. + +**Details:** + +1. Add tests in `/src/mcplocal/tests/integration/gated-flow.test.ts`: + ```typescript + describe('Gated Session Flow Integration', () => { + let app: FastifyInstance; + let mcpClient: McpClient; + + beforeAll(async () => { + app = await createTestApp(); + // Seed test project with gated=true and test prompts + }); + + describe('end-to-end gated flow', () => { + it('connect → begin_session with tags → tools available → correct prompts', async () => { + // 1. Connect to MCP endpoint + const session = await mcpClient.connect(app, 'test-project'); + + // 2. Verify only begin_session available + const toolsBefore = await session.listTools(); + expect(toolsBefore.map(t => t.name)).toEqual(['begin_session']); + + // 3. Call begin_session + const briefing = await session.callTool('begin_session', { + tags: ['test', 'integration'] + }); + expect(briefing).toContain('matched prompt content'); + + // 4. Verify all tools now available + const toolsAfter = await session.listTools(); + expect(toolsAfter.map(t => t.name)).toContain('read_prompts'); + }); + }); + + describe('end-to-end intercept flow', () => { + it('connect → skip begin_session → call tool → keywords extracted → briefing injected', async () => { ... }); + }); + + describe('end-to-end read_prompts', () => { + it('after ungating → request more context → additional prompts → no duplicates', async () => { ... }); + }); + }); + ``` + +**Test Strategy:** + +This task IS the test implementation. Verify: +- Happy path tests pass +- Intercept path tests pass +- read_prompts deduplication works +- Tests use realistic data +- Tests clean up properly diff --git a/.taskmaster/tasks/task_068.md b/.taskmaster/tasks/task_068.md new file mode 100644 index 0000000..90cce57 --- /dev/null +++ b/.taskmaster/tasks/task_068.md @@ -0,0 +1,58 @@ +# Task ID: 68 + +**Title:** Add integration tests for prompt links + +**Status:** done + +**Dependencies:** 57 ✓, 58 ✓ + +**Priority:** medium + +**Description:** Create end-to-end integration tests for prompt link creation, resolution, and dead link detection. + +**Details:** + +1. Add tests in `/src/mcplocal/tests/integration/prompt-links.test.ts`: + ```typescript + describe('Prompt Links Integration', () => { + describe('link creation', () => { + it('creates link with RBAC permission', async () => { + // Setup: user with edit permission on target project + const prompt = await api.post('/api/v1/prompts', { + name: 'linked-prompt', + content: '[Link]', + projectId: targetProject.id, + linkTarget: 'source-project/server:uri' + }); + expect(prompt.linkTarget).toBe('source-project/server:uri'); + }); + + it('rejects link creation without RBAC permission', async () => { ... }); + }); + + describe('link resolution', () => { + it('fetches content from source server', async () => { ... }); + it('uses service account for RBAC', async () => { ... }); + }); + + describe('dead link lifecycle', () => { + it('detects dead link when source unavailable', async () => { + // Kill source server + const prompts = await api.get('/api/v1/prompts'); + const linked = prompts.find(p => p.linkTarget); + expect(linked.linkStatus).toBe('dead'); + }); + + it('recovers when source restored', async () => { ... }); + }); + }); + ``` + +**Test Strategy:** + +This task IS the test implementation. Verify: +- RBAC tests cover permission scenarios +- Resolution tests verify content fetched +- Dead link tests cover full lifecycle +- Tests properly mock/control source servers +- Tests clean up resources diff --git a/.taskmaster/tasks/task_069.md b/.taskmaster/tasks/task_069.md new file mode 100644 index 0000000..2d405b1 --- /dev/null +++ b/.taskmaster/tasks/task_069.md @@ -0,0 +1,59 @@ +# Task ID: 69 + +**Title:** Add CLI unit tests for new prompt and project flags + +**Status:** done + +**Dependencies:** 59 ✓, 60 ✓, 61 ✓, 62 ✓ + +**Priority:** medium + +**Description:** Create unit tests for the new CLI flags: --priority, --link for prompts, -A for get, and gated field for projects. + +**Details:** + +1. Add tests in `/src/cli/tests/commands/prompt.test.ts`: + ```typescript + describe('create prompt command', () => { + it('--priority sets prompt priority', async () => { + await cli('create prompt test --priority 8'); + expect(mockApi.post).toHaveBeenCalledWith( + '/api/v1/prompts', + expect.objectContaining({ priority: 8 }) + ); + }); + + it('--priority validates range 1-10', async () => { + await expect(cli('create prompt test --priority 15')) + .rejects.toThrow('Priority must be between 1 and 10'); + }); + + it('--link sets linkTarget', async () => { + await cli('create prompt test --link proj/srv:uri'); + expect(mockApi.post).toHaveBeenCalledWith( + '/api/v1/prompts', + expect.objectContaining({ linkTarget: 'proj/srv:uri' }) + ); + }); + }); + + describe('get prompt command', () => { + it('-A shows all projects', async () => { + await cli('get prompt -A'); + expect(mockApi.get).toHaveBeenCalledWith('/api/v1/prompts?all=true'); + }); + }); + ``` + +2. Add tests for project gated field editing + +3. Add tests for describe project output + +**Test Strategy:** + +This task IS the test implementation. Verify: +- Flag parsing tests pass +- Validation tests cover edge cases +- API call tests verify correct parameters +- Output formatting tests verify columns +- Tests mock API properly diff --git a/.taskmaster/tasks/task_070.md b/.taskmaster/tasks/task_070.md new file mode 100644 index 0000000..9e2f994 --- /dev/null +++ b/.taskmaster/tasks/task_070.md @@ -0,0 +1,37 @@ +# Task ID: 70 + +**Title:** Add shell completions for new CLI flags + +**Status:** done + +**Dependencies:** 59 ✓, 60 ✓ + +**Priority:** low + +**Description:** Update shell completion scripts (bash, zsh, fish) to include completions for new flags: --priority, --link, -A, and gated values. + +**Details:** + +1. Update `/completions/mcpctl.fish`: + ```fish + # create prompt completions + complete -c mcpctl -n '__fish_seen_subcommand_from create; and __fish_seen_subcommand_from prompt' -l priority -d 'Priority level (1-10)' -a '(seq 1 10)' + complete -c mcpctl -n '__fish_seen_subcommand_from create; and __fish_seen_subcommand_from prompt' -l link -d 'Link to MCP resource (project/server:uri)' + + # get prompt completions + complete -c mcpctl -n '__fish_seen_subcommand_from get; and __fish_seen_subcommand_from prompt' -s A -l all-projects -d 'Show prompts from all projects' + ``` + +2. Update bash completions similarly + +3. Update zsh completions similarly + +4. Add dynamic completion for priority values (1-10) + +**Test Strategy:** + +- Manual test: Fish completions suggest --priority with values 1-10 +- Manual test: Fish completions suggest --link flag +- Manual test: Fish completions suggest -A/--all-projects +- Manual test: Bash completions work similarly +- Manual test: Zsh completions work similarly diff --git a/.taskmaster/tasks/task_071.md b/.taskmaster/tasks/task_071.md new file mode 100644 index 0000000..bc88435 --- /dev/null +++ b/.taskmaster/tasks/task_071.md @@ -0,0 +1,70 @@ +# Task ID: 71 + +**Title:** Define ProxyModel Public Type Contract + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Create the core TypeScript types for the ProxyModel framework that stages will import from `mcpctl/proxymodel`. This establishes the public API contract that stage authors write against. + +**Details:** + +Create `src/mcplocal/src/proxymodel/types.ts` with: + +```typescript +export interface StageHandler { + (content: string, ctx: StageContext): Promise; +} + +export interface StageContext { + contentType: 'prompt' | 'toolResult' | 'resource'; + sourceName: string; + projectName: string; + sessionId: string; + originalContent: string; + llm: LLMProvider; + cache: CacheProvider; + log: Logger; + config: Record; +} + +export interface StageResult { + content: string; + sections?: Section[]; + metadata?: Record; +} + +export interface Section { + id: string; + title: string; + content: string; +} + +export interface LLMProvider { + complete(prompt: string, options?: { system?: string; maxTokens?: number }): Promise; + available(): boolean; +} + +export interface CacheProvider { + getOrCompute(key: string, compute: () => Promise): Promise; + hash(content: string): string; + get(key: string): Promise; + set(key: string, value: string): Promise; +} + +export interface Logger { + debug(msg: string): void; + info(msg: string): void; + warn(msg: string): void; + error(msg: string): void; +} +``` + +Also create `src/mcplocal/src/proxymodel/index.ts` as the public entrypoint that re-exports these types. Update `package.json` exports to expose `mcpctl/proxymodel`. + +**Test Strategy:** + +Unit tests verifying type exports are accessible from the public entrypoint. Create a sample stage file that imports from `mcpctl/proxymodel` and verify it compiles without errors. diff --git a/.taskmaster/tasks/task_072.md b/.taskmaster/tasks/task_072.md new file mode 100644 index 0000000..780fc2e --- /dev/null +++ b/.taskmaster/tasks/task_072.md @@ -0,0 +1,49 @@ +# Task ID: 72 + +**Title:** Implement LLMProvider Adapter + +**Status:** done + +**Dependencies:** 71 ✓ + +**Priority:** high + +**Description:** Create an adapter that wraps the existing ProviderRegistry to implement the StageContext.llm interface, providing stages with a simplified LLM access API. + +**Details:** + +Create `src/mcplocal/src/proxymodel/llm-adapter.ts`: + +```typescript +import type { LLMProvider } from './types'; +import type { ProviderRegistry } from '../providers/registry'; + +export function createLLMAdapter(registry: ProviderRegistry, projectName: string): LLMProvider { + return { + async complete(prompt: string, options?: { system?: string; maxTokens?: number }): Promise { + const provider = registry.getProvider('heavy'); + if (!provider) throw new Error('No LLM provider configured'); + + const messages = options?.system + ? [{ role: 'system', content: options.system }, { role: 'user', content: prompt }] + : [{ role: 'user', content: prompt }]; + + const result = await provider.complete({ + messages, + maxTokens: options?.maxTokens ?? 1000, + }); + return result.content; + }, + + available(): boolean { + return registry.getProvider('heavy') !== null; + } + }; +} +``` + +This adapter uses the 'heavy' tier from the existing registry, preserving the project-level LLM configuration. + +**Test Strategy:** + +Unit test with mocked ProviderRegistry verifying complete() calls are delegated correctly. Test available() returns false when no provider is configured. Integration test with a real provider. diff --git a/.taskmaster/tasks/task_073.md b/.taskmaster/tasks/task_073.md new file mode 100644 index 0000000..17c3104 --- /dev/null +++ b/.taskmaster/tasks/task_073.md @@ -0,0 +1,67 @@ +# Task ID: 73 + +**Title:** Implement In-Memory CacheProvider + +**Status:** done + +**Dependencies:** 71 ✓ + +**Priority:** high + +**Description:** Create the CacheProvider implementation that stages use for caching expensive computations. Start with in-memory cache for Phase 1, with content-addressed keys. + +**Details:** + +Create `src/mcplocal/src/proxymodel/cache-provider.ts`: + +```typescript +import { createHash } from 'crypto'; +import type { CacheProvider } from './types'; + +export class InMemoryCacheProvider implements CacheProvider { + private cache = new Map(); + private maxSize: number; + private ttlMs: number; + + constructor(options: { maxSize?: number; ttlMs?: number } = {}) { + this.maxSize = options.maxSize ?? 1000; + this.ttlMs = options.ttlMs ?? 3600000; // 1 hour default + } + + hash(content: string): string { + return createHash('sha256').update(content).digest('hex').slice(0, 16); + } + + async get(key: string): Promise { + const entry = this.cache.get(key); + if (!entry) return null; + if (Date.now() - entry.timestamp > this.ttlMs) { + this.cache.delete(key); + return null; + } + return entry.value; + } + + async set(key: string, value: string): Promise { + if (this.cache.size >= this.maxSize) this.evictOldest(); + this.cache.set(key, { value, timestamp: Date.now() }); + } + + async getOrCompute(key: string, compute: () => Promise): Promise { + const cached = await this.get(key); + if (cached !== null) return cached; + const value = await compute(); + await this.set(key, value); + return value; + } + + private evictOldest(): void { + const oldest = [...this.cache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp)[0]; + if (oldest) this.cache.delete(oldest[0]); + } +} +``` + +**Test Strategy:** + +Unit tests for: hash() produces consistent output, get() returns null for missing keys, set()/get() round-trip works, TTL expiration works, LRU eviction triggers at maxSize, getOrCompute() caches and returns cached values. diff --git a/.taskmaster/tasks/task_074.md b/.taskmaster/tasks/task_074.md new file mode 100644 index 0000000..82d9faf --- /dev/null +++ b/.taskmaster/tasks/task_074.md @@ -0,0 +1,57 @@ +# Task ID: 74 + +**Title:** Implement Content Type Detection + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Create a utility that detects content type (JSON, YAML, XML, code, prose) for structural splitting in the section-split stage. + +**Details:** + +Create `src/mcplocal/src/proxymodel/content-detection.ts`: + +```typescript +export type ContentType = 'json' | 'yaml' | 'xml' | 'code' | 'prose'; + +export function detectContentType(content: string): ContentType { + const trimmed = content.trimStart(); + + // JSON detection + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + JSON.parse(content); + return 'json'; + } catch { /* not valid JSON, continue */ } + } + + // XML detection + if (trimmed.startsWith(']*>/.test(trimmed)) { + return 'xml'; + } + + // YAML detection (key: value at start of lines) + if (/^[a-zA-Z_][a-zA-Z0-9_]*:\s/m.test(trimmed) && !trimmed.includes('{')) { + return 'yaml'; + } + + // Code detection (common patterns) + const codePatterns = [ + /^(function |class |def |const |let |var |import |export |package |pub fn |fn |impl )/m, + /^#include\s+[<"]/m, + /^(public |private |protected )?(static )?(void |int |string |bool )/m, + ]; + if (codePatterns.some(p => p.test(trimmed))) { + return 'code'; + } + + return 'prose'; +} +``` + +**Test Strategy:** + +Unit tests with sample content for each type: valid JSON objects/arrays, XML documents, YAML configs, code snippets in multiple languages (JS, Python, Rust, Go, Java), and prose markdown. Edge cases: JSON-like strings that aren't valid JSON, mixed content. diff --git a/.taskmaster/tasks/task_075.md b/.taskmaster/tasks/task_075.md new file mode 100644 index 0000000..b228d52 --- /dev/null +++ b/.taskmaster/tasks/task_075.md @@ -0,0 +1,77 @@ +# Task ID: 75 + +**Title:** Implement section-split Stage + +**Status:** done + +**Dependencies:** 71 ✓, 74 ✓ + +**Priority:** high + +**Description:** Create the built-in section-split stage that splits content based on detected content type, using structural boundaries for JSON/YAML/XML and headers for prose. + +**Details:** + +Create `src/mcplocal/src/proxymodel/stages/section-split.ts`: + +```typescript +import type { StageHandler, Section } from '../types'; +import { detectContentType } from '../content-detection'; + +const handler: StageHandler = async (content, ctx) => { + const minSize = (ctx.config.minSectionSize as number) ?? 2000; + const maxSize = (ctx.config.maxSectionSize as number) ?? 15000; + const contentType = detectContentType(content); + + let sections: Section[]; + + switch (contentType) { + case 'json': + sections = splitJson(content, minSize, maxSize); + break; + case 'yaml': + sections = splitYaml(content, minSize, maxSize); + break; + case 'xml': + sections = splitXml(content, minSize, maxSize); + break; + case 'code': + sections = splitCode(content, minSize); + break; + default: + sections = splitProse(content, minSize); + } + + if (sections.length === 0) { + return { content, sections: [{ id: 'main', title: 'Content', content }] }; + } + + const toc = sections.map((s, i) => `[${s.id}] ${s.title}`).join('\n'); + return { + content: `${sections.length} sections (${contentType}):\n${toc}`, + sections, + }; +}; + +function splitJson(content: string, minSize: number, maxSize: number): Section[] { + const parsed = JSON.parse(content); + if (Array.isArray(parsed)) { + return parsed.map((item, i) => ({ + id: item.id ?? item.name ?? `item-${i}`, + title: item.label ?? item.title ?? item.name ?? `Item ${i}`, + content: JSON.stringify(item, null, 2), + })); + } + return Object.entries(parsed).map(([key, value]) => ({ + id: key, + title: key, + content: JSON.stringify(value, null, 2), + })); +} + +// Similar implementations for splitYaml, splitXml, splitCode, splitProse +``` + +**Test Strategy:** + +Unit tests for each content type: JSON arrays split by element, JSON objects split by key, YAML split by top-level keys, XML split by elements, prose split by markdown headers. Test minSize/maxSize thresholds. Test fallback when content can't be parsed. diff --git a/.taskmaster/tasks/task_076.md b/.taskmaster/tasks/task_076.md new file mode 100644 index 0000000..250d9e2 --- /dev/null +++ b/.taskmaster/tasks/task_076.md @@ -0,0 +1,84 @@ +# Task ID: 76 + +**Title:** Implement summarize-tree Stage + +**Status:** done + +**Dependencies:** 71 ✓, 72 ✓, 73 ✓, 74 ✓ + +**Priority:** high + +**Description:** Create the built-in summarize-tree stage that recursively summarizes sections, using structural summaries for programmatic content and LLM summaries for prose. + +**Details:** + +Create `src/mcplocal/src/proxymodel/stages/summarize-tree.ts`: + +```typescript +import type { StageHandler, Section, StageContext } from '../types'; +import { detectContentType } from '../content-detection'; + +const handler: StageHandler = async (content, ctx) => { + const maxTokens = (ctx.config.maxSummaryTokens as number) ?? 200; + const maxGroup = (ctx.config.maxGroupSize as number) ?? 5; + const maxDepth = (ctx.config.maxDepth as number) ?? 3; + + // Parse sections from previous stage or create single section + const inputSections = parseSectionsFromContent(content); + + const tree = await buildTree(inputSections, ctx, { maxTokens, maxGroup, maxDepth, depth: 0 }); + + const toc = tree.map(s => + `[${s.id}] ${s.title} — ${s.metadata?.summary ?? ''}` + + (s.sections?.length ? `\n → ${s.sections.length} sub-sections` : '') + ).join('\n'); + + return { + content: `${tree.length} sections:\n${toc}\n\nUse section parameter to read details.`, + sections: tree, + }; +}; + +async function buildTree( + sections: Section[], + ctx: StageContext, + opts: { maxTokens: number; maxGroup: number; maxDepth: number; depth: number } +): Promise { + for (const section of sections) { + const contentType = detectContentType(section.content); + + // Structural summary for programmatic content (no LLM needed) + if (contentType !== 'prose') { + section.metadata = { summary: generateStructuralSummary(section.content, contentType) }; + } else { + // LLM summary for prose (cached) + const cacheKey = `summary:${ctx.cache.hash(section.content)}:${opts.maxTokens}`; + const summary = await ctx.cache.getOrCompute(cacheKey, () => + ctx.llm.complete( + `Summarize in ${opts.maxTokens} tokens, preserve MUST/REQUIRED items:\n\n${section.content}` + ) + ); + section.metadata = { summary }; + } + + // Recurse if large and not at max depth + if (section.content.length > 5000 && opts.depth < opts.maxDepth) { + section.sections = await buildTree( + splitContent(section.content), + ctx, + { ...opts, depth: opts.depth + 1 } + ); + } + } + return sections; +} + +function generateStructuralSummary(content: string, type: string): string { + // Generate summary from structure: key names, array lengths, types + // No LLM needed for JSON/YAML/XML/code +} +``` + +**Test Strategy:** + +Unit tests: prose content gets LLM summary (mock LLM), JSON content gets structural summary without LLM call, recursive splitting triggers at 5000 chars, maxDepth is respected, cache is used for repeated content. Integration test with real LLM provider. diff --git a/.taskmaster/tasks/task_077.md b/.taskmaster/tasks/task_077.md new file mode 100644 index 0000000..4ea7301 --- /dev/null +++ b/.taskmaster/tasks/task_077.md @@ -0,0 +1,63 @@ +# Task ID: 77 + +**Title:** Implement passthrough and paginate Stages + +**Status:** done + +**Dependencies:** 71 ✓ + +**Priority:** high + +**Description:** Create the built-in passthrough (no-op) and paginate (large response splitting) stages that form the default proxymodel. + +**Details:** + +Create `src/mcplocal/src/proxymodel/stages/passthrough.ts`: + +```typescript +import type { StageHandler } from '../types'; + +const handler: StageHandler = async (content, ctx) => { + return { content }; +}; +export default handler; +``` + +Create `src/mcplocal/src/proxymodel/stages/paginate.ts`: + +```typescript +import type { StageHandler, Section } from '../types'; + +const handler: StageHandler = async (content, ctx) => { + const pageSize = (ctx.config.pageSize as number) ?? 8000; + + if (content.length <= pageSize) { + return { content }; + } + + const pages: Section[] = []; + let offset = 0; + let pageNum = 1; + + while (offset < content.length) { + const pageContent = content.slice(offset, offset + pageSize); + pages.push({ + id: `page-${pageNum}`, + title: `Page ${pageNum}`, + content: pageContent, + }); + offset += pageSize; + pageNum++; + } + + return { + content: `Content split into ${pages.length} pages (${content.length} chars total). Use section parameter to read specific pages.`, + sections: pages, + }; +}; +export default handler; +``` + +**Test Strategy:** + +passthrough: verify content returned unchanged. paginate: verify content under threshold returns unchanged, content over threshold splits correctly, page boundaries are correct, section IDs are sequential. diff --git a/.taskmaster/tasks/task_078.md b/.taskmaster/tasks/task_078.md new file mode 100644 index 0000000..2397e6b --- /dev/null +++ b/.taskmaster/tasks/task_078.md @@ -0,0 +1,78 @@ +# Task ID: 78 + +**Title:** Create ProxyModel YAML Schema and Loader + +**Status:** done + +**Dependencies:** 71 ✓ + +**Priority:** high + +**Description:** Define the YAML schema for proxymodel definitions and implement the loader that reads from ~/.mcpctl/proxymodels/ and merges with built-ins. + +**Details:** + +Create `src/mcplocal/src/proxymodel/schema.ts`: + +```typescript +import { z } from 'zod'; + +export const ProxyModelSchema = z.object({ + kind: z.literal('ProxyModel'), + metadata: z.object({ + name: z.string(), + }), + spec: z.object({ + controller: z.string().optional().default('gate'), + controllerConfig: z.record(z.unknown()).optional(), + stages: z.array(z.object({ + type: z.string(), + config: z.record(z.unknown()).optional(), + })), + appliesTo: z.array(z.enum(['prompts', 'toolResults', 'resource'])).optional(), + cacheable: z.boolean().optional().default(true), + }), +}); + +export type ProxyModelDefinition = z.infer; +``` + +Create `src/mcplocal/src/proxymodel/loader.ts`: + +```typescript +import { readdir, readFile } from 'fs/promises'; +import { join } from 'path'; +import { parse as parseYaml } from 'yaml'; +import { ProxyModelSchema, type ProxyModelDefinition } from './schema'; +import { getBuiltInProxyModels } from './built-in-models'; + +const PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels'); + +export async function loadProxyModels(): Promise> { + const models = new Map(); + + // Load built-ins first + for (const [name, model] of getBuiltInProxyModels()) { + models.set(name, model); + } + + // Load local (overrides built-ins) + try { + const files = await readdir(PROXYMODELS_DIR); + for (const file of files.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) { + const content = await readFile(join(PROXYMODELS_DIR, file), 'utf-8'); + const parsed = parseYaml(content); + const validated = ProxyModelSchema.parse(parsed); + models.set(validated.metadata.name, validated); + } + } catch (e) { + // Directory doesn't exist or can't be read - use built-ins only + } + + return models; +} +``` + +**Test Strategy:** + +Unit tests: valid YAML parses correctly, invalid YAML throws validation error, local models override built-ins with same name, missing directory doesn't throw. Create test fixtures for various YAML configurations. diff --git a/.taskmaster/tasks/task_079.md b/.taskmaster/tasks/task_079.md new file mode 100644 index 0000000..a0367d8 --- /dev/null +++ b/.taskmaster/tasks/task_079.md @@ -0,0 +1,68 @@ +# Task ID: 79 + +**Title:** Implement Stage Registry and Dynamic Loader + +**Status:** done + +**Dependencies:** 71 ✓, 75 ✓, 76 ✓, 77 ✓ + +**Priority:** high + +**Description:** Create the stage registry that resolves stage names to handlers, loading from ~/.mcpctl/stages/ for custom stages and falling back to built-ins. + +**Details:** + +Create `src/mcplocal/src/proxymodel/stage-registry.ts`: + +```typescript +import { readdir, stat } from 'fs/promises'; +import { join } from 'path'; +import type { StageHandler } from './types'; + +const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages'); + +const builtInStages: Map = new Map(); +const customStages: Map = new Map(); + +// Register built-ins at module load +import passthrough from './stages/passthrough'; +import paginate from './stages/paginate'; +import sectionSplit from './stages/section-split'; +import summarizeTree from './stages/summarize-tree'; + +builtInStages.set('passthrough', passthrough); +builtInStages.set('paginate', paginate); +builtInStages.set('section-split', sectionSplit); +builtInStages.set('summarize-tree', summarizeTree); + +export async function loadCustomStages(): Promise { + customStages.clear(); + try { + const files = await readdir(STAGES_DIR); + for (const file of files.filter(f => f.endsWith('.ts') || f.endsWith('.js'))) { + const name = file.replace(/\.(ts|js)$/, ''); + const module = await import(join(STAGES_DIR, file)); + customStages.set(name, module.default); + } + } catch { /* directory doesn't exist */ } +} + +export function getStage(name: string): StageHandler | null { + return customStages.get(name) ?? builtInStages.get(name) ?? null; +} + +export function listStages(): { name: string; source: 'built-in' | 'local' }[] { + const result: { name: string; source: 'built-in' | 'local' }[] = []; + for (const name of builtInStages.keys()) { + result.push({ name, source: customStages.has(name) ? 'local' : 'built-in' }); + } + for (const name of customStages.keys()) { + if (!builtInStages.has(name)) result.push({ name, source: 'local' }); + } + return result; +} +``` + +**Test Strategy:** + +Unit tests: built-in stages are registered, getStage() returns correct handler, custom stages override built-ins, listStages() shows correct sources, missing stages return null. Integration test with actual stage files in temp directory. diff --git a/.taskmaster/tasks/task_080.md b/.taskmaster/tasks/task_080.md new file mode 100644 index 0000000..5699745 --- /dev/null +++ b/.taskmaster/tasks/task_080.md @@ -0,0 +1,90 @@ +# Task ID: 80 + +**Title:** Implement Pipeline Executor + +**Status:** done + +**Dependencies:** 71 ✓, 72 ✓, 73 ✓, 79 ✓ + +**Priority:** high + +**Description:** Create the pipeline executor that runs content through a sequence of stages, managing context, caching, and error handling. + +**Details:** + +Create `src/mcplocal/src/proxymodel/executor.ts`: + +```typescript +import type { StageContext, StageResult, Section } from './types'; +import type { ProxyModelDefinition } from './schema'; +import { getStage } from './stage-registry'; +import { createLLMAdapter } from './llm-adapter'; +import { InMemoryCacheProvider } from './cache-provider'; +import type { ProviderRegistry } from '../providers/registry'; + +export interface ExecuteOptions { + content: string; + contentType: 'prompt' | 'toolResult' | 'resource'; + sourceName: string; + projectName: string; + sessionId: string; + proxyModel: ProxyModelDefinition; + providerRegistry: ProviderRegistry; + cache?: InMemoryCacheProvider; +} + +export async function executePipeline(opts: ExecuteOptions): Promise { + const { content, proxyModel, providerRegistry } = opts; + const cache = opts.cache ?? new InMemoryCacheProvider(); + const llm = createLLMAdapter(providerRegistry, opts.projectName); + + let currentContent = content; + let sections: Section[] | undefined; + let metadata: Record = {}; + + for (const stageConfig of proxyModel.spec.stages) { + const handler = getStage(stageConfig.type); + if (!handler) { + console.warn(`Stage '${stageConfig.type}' not found, skipping`); + continue; + } + + const ctx: StageContext = { + contentType: opts.contentType, + sourceName: opts.sourceName, + projectName: opts.projectName, + sessionId: opts.sessionId, + originalContent: content, + llm, + cache, + log: createLogger(stageConfig.type), + config: stageConfig.config ?? {}, + }; + + try { + const result = await handler(currentContent, ctx); + currentContent = result.content; + if (result.sections) sections = result.sections; + if (result.metadata) metadata = { ...metadata, ...result.metadata }; + } catch (err) { + console.error(`Stage '${stageConfig.type}' failed:`, err); + // Continue with previous content on error + } + } + + return { content: currentContent, sections, metadata }; +} + +function createLogger(stageName: string) { + return { + debug: (msg: string) => console.debug(`[${stageName}] ${msg}`), + info: (msg: string) => console.info(`[${stageName}] ${msg}`), + warn: (msg: string) => console.warn(`[${stageName}] ${msg}`), + error: (msg: string) => console.error(`[${stageName}] ${msg}`), + }; +} +``` + +**Test Strategy:** + +Unit tests: single stage executes correctly, multiple stages chain output to input, originalContent preserved across stages, missing stage logs warning and continues, stage error doesn't break pipeline, sections/metadata accumulate correctly. diff --git a/.taskmaster/tasks/task_081.md b/.taskmaster/tasks/task_081.md new file mode 100644 index 0000000..c90debd --- /dev/null +++ b/.taskmaster/tasks/task_081.md @@ -0,0 +1,59 @@ +# Task ID: 81 + +**Title:** Define Built-in ProxyModels (default, subindex) + +**Status:** done + +**Dependencies:** 78 ✓ + +**Priority:** high + +**Description:** Create the built-in proxymodel definitions for 'default' (current behavior) and 'subindex' (hierarchical navigation). + +**Details:** + +Create `src/mcplocal/src/proxymodel/built-in-models.ts`: + +```typescript +import type { ProxyModelDefinition } from './schema'; + +export function getBuiltInProxyModels(): Map { + const models = new Map(); + + models.set('default', { + kind: 'ProxyModel', + metadata: { name: 'default' }, + spec: { + controller: 'gate', + controllerConfig: { byteBudget: 8192 }, + stages: [ + { type: 'passthrough' }, + { type: 'paginate', config: { pageSize: 8000 } }, + ], + appliesTo: ['prompts', 'toolResults'], + cacheable: false, + }, + }); + + models.set('subindex', { + kind: 'ProxyModel', + metadata: { name: 'subindex' }, + spec: { + controller: 'gate', + controllerConfig: { byteBudget: 8192 }, + stages: [ + { type: 'section-split', config: { minSectionSize: 2000, maxSectionSize: 15000 } }, + { type: 'summarize-tree', config: { maxSummaryTokens: 200, maxGroupSize: 5, maxDepth: 3 } }, + ], + appliesTo: ['prompts', 'toolResults'], + cacheable: true, + }, + }); + + return models; +} +``` + +**Test Strategy:** + +Unit tests: both models are returned by getBuiltInProxyModels(), 'default' has passthrough+paginate stages, 'subindex' has section-split+summarize-tree stages, both schemas validate correctly. diff --git a/.taskmaster/tasks/task_082.md b/.taskmaster/tasks/task_082.md new file mode 100644 index 0000000..4dd3939 --- /dev/null +++ b/.taskmaster/tasks/task_082.md @@ -0,0 +1,66 @@ +# Task ID: 82 + +**Title:** Integrate Pipeline Executor into Router + +**Status:** done + +**Dependencies:** 80 ✓, 81 ✓ + +**Priority:** high + +**Description:** Modify the McpRouter to route content through the proxymodel pipeline, keeping the gating logic cleanly separated from content processing. + +**Details:** + +Modify `src/mcplocal/src/router.ts` to: + +1. Add proxyModel resolution during router creation: +```typescript +import { loadProxyModels } from './proxymodel/loader'; +import { executePipeline } from './proxymodel/executor'; + +interface RouterOptions { + proxyModelName?: string; + // ... existing options +} + +async function createRouter(opts: RouterOptions): Promise { + const proxyModels = await loadProxyModels(); + const proxyModel = proxyModels.get(opts.proxyModelName ?? 'default'); + // ... +} +``` + +2. Add content processing method: +```typescript +async processContent( + content: string, + type: 'prompt' | 'toolResult', + sourceName: string, + sessionId: string +): Promise { + if (!this.proxyModel) return { content }; + + const appliesTo = this.proxyModel.spec.appliesTo ?? ['prompts', 'toolResults']; + if (!appliesTo.includes(type === 'prompt' ? 'prompts' : 'toolResults')) { + return { content }; + } + + return executePipeline({ + content, + contentType: type, + sourceName, + projectName: this.projectName, + sessionId, + proxyModel: this.proxyModel, + providerRegistry: this.providerRegistry, + cache: this.cache, + }); +} +``` + +3. Call processContent at the appropriate points in the request flow (prompt serving, tool result handling) WITHOUT interweaving with gating logic. + +**Test Strategy:** + +Integration tests: default proxymodel passes content through unchanged, subindex proxymodel produces summaries, appliesTo filtering works correctly, gating still works as before with proxymodel processing happening at the right stage. diff --git a/.taskmaster/tasks/task_083.md b/.taskmaster/tasks/task_083.md new file mode 100644 index 0000000..748652a --- /dev/null +++ b/.taskmaster/tasks/task_083.md @@ -0,0 +1,49 @@ +# Task ID: 83 + +**Title:** Implement Section Drill-Down for Prompts + +**Status:** cancelled + +**Dependencies:** 82 ✓ + +**Priority:** high + +**Description:** Extend read_prompts to support section parameter for drilling into specific sections produced by proxymodel stages. + +**Details:** + +Modify the read_prompts handler in `src/mcplocal/src/router.ts`: + +```typescript +// In the read_prompts tool handler +if (args.section) { + // Look up section in the processed result + const sectionId = args.section; + const cachedResult = this.sectionCache.get(promptName); + if (cachedResult?.sections) { + const section = findSection(cachedResult.sections, sectionId); + if (section) { + return { content: [{ type: 'text', text: section.content }] }; + } + return { content: [{ type: 'text', text: `Section '${sectionId}' not found` }], isError: true }; + } +} + +// Helper to find section by ID (supports nested sections) +function findSection(sections: Section[], id: string): Section | null { + for (const s of sections) { + if (s.id === id) return s; + if (s.sections) { + const nested = findSection(s.sections, id); + if (nested) return nested; + } + } + return null; +} +``` + +Also add a sectionCache Map to store processed results with their sections for drill-down. + +**Test Strategy:** + +Integration tests: read_prompts with section parameter returns correct section content, nested section lookup works, missing section returns error, section cache populated after initial processing. diff --git a/.taskmaster/tasks/task_084.md b/.taskmaster/tasks/task_084.md new file mode 100644 index 0000000..e3993aa --- /dev/null +++ b/.taskmaster/tasks/task_084.md @@ -0,0 +1,47 @@ +# Task ID: 84 + +**Title:** Implement Section Drill-Down for Tool Results + +**Status:** done + +**Dependencies:** 82 ✓ + +**Priority:** medium + +**Description:** Extend tool result handling to support _section parameter for drilling into specific sections of large tool responses. + +**Details:** + +Modify tool call handling in `src/mcplocal/src/router.ts`: + +```typescript +// When processing tool calls +if (args._section) { + const sectionId = args._section; + delete args._section; // Don't pass to upstream + + // Check cache for previous full result + const cacheKey = `tool:${serverName}/${toolName}:${JSON.stringify(args)}`; + const cachedResult = this.toolResultCache.get(cacheKey); + + if (cachedResult?.sections) { + const section = findSection(cachedResult.sections, sectionId); + if (section) { + return { content: [{ type: 'text', text: section.content }] }; + } + } + // If no cache, make the full call and process, then serve section +} + +// After receiving tool result, process through pipeline +const processed = await this.processContent(result, 'toolResult', `${serverName}/${toolName}`, sessionId); +if (processed.sections) { + this.toolResultCache.set(cacheKey, processed); +} +``` + +Add a toolResultCache Map with appropriate TTL. + +**Test Strategy:** + +Integration tests: large tool result gets processed into sections, _section parameter returns specific section, _section removed before upstream call, cache hit serves from cache, cache miss processes and caches. diff --git a/.taskmaster/tasks/task_085.md b/.taskmaster/tasks/task_085.md new file mode 100644 index 0000000..b93f40f --- /dev/null +++ b/.taskmaster/tasks/task_085.md @@ -0,0 +1,65 @@ +# Task ID: 85 + +**Title:** Implement Hot-Reload for Stages + +**Status:** done + +**Dependencies:** 79 ✓ + +**Priority:** medium + +**Description:** Add file watching for ~/.mcpctl/stages/ to automatically reload custom stages when they change without restarting mcplocal. + +**Details:** + +Modify `src/mcplocal/src/proxymodel/stage-registry.ts`: + +```typescript +import { watch, FSWatcher } from 'fs'; +import { join, basename } from 'path'; + +let watcher: FSWatcher | null = null; +const stageFileHashes: Map = new Map(); + +export function startStageWatcher(): void { + if (watcher) return; + + try { + watcher = watch(STAGES_DIR, async (eventType, filename) => { + if (!filename || (!filename.endsWith('.ts') && !filename.endsWith('.js'))) return; + + const name = filename.replace(/\.(ts|js)$/, ''); + const fullPath = join(STAGES_DIR, filename); + + if (eventType === 'rename') { + // File added or removed + await loadCustomStages(); + console.info(`[proxymodel] Stages reloaded due to ${filename} change`); + } else if (eventType === 'change') { + // File modified - invalidate module cache and reload + delete require.cache[require.resolve(fullPath)]; + try { + const module = await import(fullPath + '?t=' + Date.now()); + customStages.set(name, module.default); + console.info(`[proxymodel] Stage '${name}' hot-reloaded`); + } catch (err) { + console.error(`[proxymodel] Failed to reload stage '${name}':`, err); + } + } + }); + } catch { + // Directory doesn't exist - no watching needed + } +} + +export function stopStageWatcher(): void { + watcher?.close(); + watcher = null; +} +``` + +Call startStageWatcher() during mcplocal initialization. + +**Test Strategy:** + +Integration tests: modify a stage file and verify the new version is loaded without restart, add a new stage file and verify it becomes available, remove a stage file and verify it's no longer available, syntax errors in stage file don't crash the watcher. diff --git a/.taskmaster/tasks/task_086.md b/.taskmaster/tasks/task_086.md new file mode 100644 index 0000000..19c8191 --- /dev/null +++ b/.taskmaster/tasks/task_086.md @@ -0,0 +1,57 @@ +# Task ID: 86 + +**Title:** Implement Hot-Reload for ProxyModels + +**Status:** done + +**Dependencies:** 78 ✓ + +**Priority:** medium + +**Description:** Add file watching for ~/.mcpctl/proxymodels/ to automatically reload proxymodel definitions when they change. + +**Details:** + +Create `src/mcplocal/src/proxymodel/model-watcher.ts`: + +```typescript +import { watch, FSWatcher } from 'fs'; +import { join } from 'path'; +import { readFile } from 'fs/promises'; +import { parse as parseYaml } from 'yaml'; +import { ProxyModelSchema } from './schema'; + +const PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels'); +let watcher: FSWatcher | null = null; +const modelUpdateCallbacks: Set<() => void> = new Set(); + +export function onModelUpdate(callback: () => void): () => void { + modelUpdateCallbacks.add(callback); + return () => modelUpdateCallbacks.delete(callback); +} + +export function startModelWatcher(): void { + if (watcher) return; + + try { + watcher = watch(PROXYMODELS_DIR, async (eventType, filename) => { + if (!filename || (!filename.endsWith('.yaml') && !filename.endsWith('.yml'))) return; + + console.info(`[proxymodel] Model file ${filename} changed, reloading...`); + + // Notify all subscribers to reload their models + for (const cb of modelUpdateCallbacks) { + try { cb(); } catch (err) { console.error('Model update callback failed:', err); } + } + }); + } catch { + // Directory doesn't exist + } +} +``` + +Integrate with router to reload proxymodels when files change. + +**Test Strategy:** + +Integration tests: modify a proxymodel YAML and verify changes take effect, add a new proxymodel and verify it becomes available, invalid YAML logs error but doesn't crash. diff --git a/.taskmaster/tasks/task_087.md b/.taskmaster/tasks/task_087.md new file mode 100644 index 0000000..0d7414b --- /dev/null +++ b/.taskmaster/tasks/task_087.md @@ -0,0 +1,48 @@ +# Task ID: 87 + +**Title:** Add proxyModel Field to Project Schema + +**Status:** cancelled + +**Dependencies:** None + +**Priority:** high + +**Description:** Extend the Project database schema and API to support proxyModel field and proxyModelOverrides for per-content-type configuration. + +**Details:** + +Update `src/db/prisma/schema.prisma`: + +```prisma +model Project { + // ... existing fields + proxyModel String? @default("default") + proxyModelOverrides Json? // { prompts: { "prompt-name": "model" }, toolResults: { "server/tool": "model" } } +} +``` + +Run `npx prisma migrate dev --name add_proxymodel_field`. + +Update `src/mcpd/src/routes/projects.ts` to include the new fields in CRUD operations. + +Update `src/cli/src/commands/get.ts` and `describe.ts` to display proxyModel. + +Update `src/cli/src/commands/patch.ts` to support `--set proxyModel=`. + +**Test Strategy:** + +Database migration test: verify migration applies cleanly. API tests: verify proxyModel field is returned in project GET, can be updated via PATCH. CLI tests: verify `mcpctl describe project ` shows proxyModel. + +## Subtasks + +### 87.1. Minimal placeholder subtask + +**Status:** pending +**Dependencies:** None + +This task requires complete rewrite before expansion. + +**Details:** + +Task 87 has been marked as DO NOT EXPAND and needs to be completely rewritten first. No subtasks should be generated until the task is properly redefined. diff --git a/.taskmaster/tasks/task_088.md b/.taskmaster/tasks/task_088.md new file mode 100644 index 0000000..68c68c1 --- /dev/null +++ b/.taskmaster/tasks/task_088.md @@ -0,0 +1,38 @@ +# Task ID: 88 + +**Title:** Rename proxyMode: filtered to proxyMode: proxy + +**Status:** cancelled + +**Dependencies:** 87 ✗ + +**Priority:** low + +**Description:** Rename the existing proxyMode value 'filtered' to 'proxy' for clarity, with backwards compatibility for existing configs. + +**Details:** + +Update `src/db/prisma/schema.prisma`: + +```prisma +enum ProxyMode { + direct + proxy // renamed from 'filtered' +} +``` + +Create migration that updates existing 'filtered' values to 'proxy': +```sql +UPDATE Project SET proxyMode = 'proxy' WHERE proxyMode = 'filtered'; +``` + +Update all code references from 'filtered' to 'proxy': +- `src/mcplocal/src/http/project-mcp-endpoint.ts` +- `src/cli/src/commands/create.ts` +- Documentation and help text + +For backwards compatibility in config files, add a normalization step that treats 'filtered' as 'proxy'. + +**Test Strategy:** + +Migration test: existing projects with proxyMode='filtered' are updated to 'proxy'. Config parsing test: both 'filtered' and 'proxy' values work. CLI test: help text shows 'proxy' not 'filtered'. diff --git a/.taskmaster/tasks/task_089.md b/.taskmaster/tasks/task_089.md new file mode 100644 index 0000000..d0e97d9 --- /dev/null +++ b/.taskmaster/tasks/task_089.md @@ -0,0 +1,53 @@ +# Task ID: 89 + +**Title:** Implement mcpctl get proxymodels Command + +**Status:** cancelled + +**Dependencies:** 78 ✓, 79 ✓ + +**Priority:** medium + +**Description:** Add CLI command to list all available proxymodels (built-in + local) with source, stages, and requirements. + +**Details:** + +Create `src/cli/src/commands/get-proxymodels.ts`: + +```typescript +import { Command } from 'commander'; +import { loadProxyModels } from 'mcplocal/proxymodel/loader'; +import { listStages } from 'mcplocal/proxymodel/stage-registry'; +import Table from 'cli-table3'; + +export function registerGetProxymodels(program: Command): void { + program + .command('get proxymodels') + .description('List all available proxymodels') + .action(async () => { + const models = await loadProxyModels(); + const stageInfo = new Map(listStages().map(s => [s.name, s])); + + const table = new Table({ + head: ['NAME', 'SOURCE', 'STAGES', 'REQUIRES-LLM', 'CACHEABLE'], + }); + + for (const [name, model] of models) { + const source = isBuiltIn(name) ? 'built-in' : 'local'; + const stages = model.spec.stages.map(s => s.type).join(','); + const requiresLlm = model.spec.stages.some(s => stageRequiresLlm(s.type)); + const cacheable = model.spec.cacheable ? 'yes' : 'no'; + + table.push([name, source, stages, requiresLlm ? 'yes' : 'no', cacheable]); + } + + console.log(table.toString()); + }); +} +``` + +Register in `src/cli/src/commands/get.ts` as a subcommand. + +**Test Strategy:** + +CLI test: `mcpctl get proxymodels` outputs table with expected columns. Test with only built-ins, test with local overrides, verify correct source detection. diff --git a/.taskmaster/tasks/task_090.md b/.taskmaster/tasks/task_090.md new file mode 100644 index 0000000..f102374 --- /dev/null +++ b/.taskmaster/tasks/task_090.md @@ -0,0 +1,47 @@ +# Task ID: 90 + +**Title:** Implement mcpctl get stages Command + +**Status:** cancelled + +**Dependencies:** 79 ✓ + +**Priority:** medium + +**Description:** Add CLI command to list all available stages (built-in + custom) with source and LLM requirements. + +**Details:** + +Create `src/cli/src/commands/get-stages.ts`: + +```typescript +import { Command } from 'commander'; +import { listStages } from 'mcplocal/proxymodel/stage-registry'; +import Table from 'cli-table3'; + +const LLM_REQUIRING_STAGES = ['summarize', 'summarize-tree', 'enhance', 'compress']; + +export function registerGetStages(program: Command): void { + program + .command('get stages') + .description('List all available stages') + .action(async () => { + const stages = listStages(); + + const table = new Table({ + head: ['NAME', 'SOURCE', 'REQUIRES-LLM'], + }); + + for (const stage of stages) { + const requiresLlm = LLM_REQUIRING_STAGES.includes(stage.name); + table.push([stage.name, stage.source, requiresLlm ? 'yes' : 'no']); + } + + console.log(table.toString()); + }); +} +``` + +**Test Strategy:** + +CLI test: `mcpctl get stages` outputs table with expected columns. Test with only built-ins, test with custom stages in ~/.mcpctl/stages/, verify custom overrides show 'local' source. diff --git a/.taskmaster/tasks/task_091.md b/.taskmaster/tasks/task_091.md new file mode 100644 index 0000000..4bb5d6c --- /dev/null +++ b/.taskmaster/tasks/task_091.md @@ -0,0 +1,57 @@ +# Task ID: 91 + +**Title:** Implement mcpctl describe proxymodel Command + +**Status:** cancelled + +**Dependencies:** 78 ✓ + +**Priority:** medium + +**Description:** Add CLI command to show detailed information about a specific proxymodel including full stage configuration. + +**Details:** + +Create `src/cli/src/commands/describe-proxymodel.ts`: + +```typescript +import { Command } from 'commander'; +import { loadProxyModels } from 'mcplocal/proxymodel/loader'; +import { stringify as yamlStringify } from 'yaml'; + +export function registerDescribeProxymodel(program: Command): void { + program + .command('describe proxymodel ') + .description('Show detailed information about a proxymodel') + .action(async (name: string) => { + const models = await loadProxyModels(); + const model = models.get(name); + + if (!model) { + console.error(`Proxymodel '${name}' not found`); + process.exit(1); + } + + console.log(`Name: ${model.metadata.name}`); + console.log(`Source: ${isBuiltIn(name) ? 'built-in' : 'local'}`); + console.log(`Controller: ${model.spec.controller ?? 'gate'}`); + console.log(`Cacheable: ${model.spec.cacheable ? 'yes' : 'no'}`); + console.log(`Applies to: ${(model.spec.appliesTo ?? ['prompts', 'toolResults']).join(', ')}`); + console.log(''); + console.log('Stages:'); + for (const stage of model.spec.stages) { + console.log(` - ${stage.type}`); + if (stage.config) { + console.log(` config:`); + for (const [k, v] of Object.entries(stage.config)) { + console.log(` ${k}: ${JSON.stringify(v)}`); + } + } + } + }); +} +``` + +**Test Strategy:** + +CLI test: `mcpctl describe proxymodel default` shows expected output. Test with proxymodel that has stage configs, verify all fields displayed correctly. diff --git a/.taskmaster/tasks/task_092.md b/.taskmaster/tasks/task_092.md new file mode 100644 index 0000000..35a486f --- /dev/null +++ b/.taskmaster/tasks/task_092.md @@ -0,0 +1,57 @@ +# Task ID: 92 + +**Title:** Implement mcpctl describe stage Command + +**Status:** cancelled + +**Dependencies:** 79 ✓ + +**Priority:** medium + +**Description:** Add CLI command to show detailed information about a specific stage including its source location. + +**Details:** + +Create `src/cli/src/commands/describe-stage.ts`: + +```typescript +import { Command } from 'commander'; +import { listStages, getStage } from 'mcplocal/proxymodel/stage-registry'; +import { join } from 'path'; + +const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages'); + +const STAGE_DESCRIPTIONS: Record = { + 'passthrough': 'Returns content unchanged. No processing.', + 'paginate': 'Splits large content into pages with navigation.', + 'section-split': 'Splits content on structural boundaries (headers, JSON keys, etc.).', + 'summarize-tree': 'Recursively summarizes sections with hierarchical navigation.', +}; + +export function registerDescribeStage(program: Command): void { + program + .command('describe stage ') + .description('Show detailed information about a stage') + .action(async (name: string) => { + const stages = listStages(); + const stageInfo = stages.find(s => s.name === name); + + if (!stageInfo) { + console.error(`Stage '${name}' not found`); + process.exit(1); + } + + console.log(`Name: ${name}`); + console.log(`Source: ${stageInfo.source}`); + if (stageInfo.source === 'local') { + console.log(`Path: ${join(STAGES_DIR, name + '.ts')}`); + } + console.log(`Description: ${STAGE_DESCRIPTIONS[name] ?? 'Custom stage'}`); + console.log(`Requires LLM: ${requiresLlm(name) ? 'yes' : 'no'}`); + }); +} +``` + +**Test Strategy:** + +CLI test: `mcpctl describe stage passthrough` shows expected output. Test with custom stage, verify path is shown correctly. diff --git a/.taskmaster/tasks/task_093.md b/.taskmaster/tasks/task_093.md new file mode 100644 index 0000000..d64c94d --- /dev/null +++ b/.taskmaster/tasks/task_093.md @@ -0,0 +1,73 @@ +# Task ID: 93 + +**Title:** Implement mcpctl create stage Command + +**Status:** cancelled + +**Dependencies:** 71 ✓ + +**Priority:** medium + +**Description:** Add CLI command to scaffold a new custom stage with boilerplate TypeScript code. + +**Details:** + +Create `src/cli/src/commands/create-stage.ts`: + +```typescript +import { Command } from 'commander'; +import { mkdir, writeFile, access } from 'fs/promises'; +import { join } from 'path'; + +const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages'); + +const STAGE_TEMPLATE = `import type { StageHandler } from 'mcpctl/proxymodel'; + +/** + * Custom stage: {{name}} + * + * Modify this handler to transform content as needed. + * Available in ctx: + * - ctx.llm.complete(prompt) - call the configured LLM + * - ctx.cache.getOrCompute(key, fn) - cache expensive computations + * - ctx.config - stage configuration from proxymodel YAML + * - ctx.originalContent - raw content before any stage processing + * - ctx.log - structured logging + */ +const handler: StageHandler = async (content, ctx) => { + // TODO: Implement your transformation + return { content }; +}; + +export default handler; +`; + +export function registerCreateStage(program: Command): void { + program + .command('create stage ') + .description('Create a new custom stage') + .action(async (name: string) => { + await mkdir(STAGES_DIR, { recursive: true }); + + const filePath = join(STAGES_DIR, `${name}.ts`); + + try { + await access(filePath); + console.error(`Stage '${name}' already exists at ${filePath}`); + process.exit(1); + } catch { + // File doesn't exist, good + } + + const code = STAGE_TEMPLATE.replace(/\{\{name\}\}/g, name); + await writeFile(filePath, code); + + console.log(`Created ${filePath}`); + console.log('Edit the file to implement your stage logic.'); + }); +} +``` + +**Test Strategy:** + +CLI test: `mcpctl create stage my-filter` creates file at expected path with correct template. Test error when stage already exists. Verify generated code compiles. diff --git a/.taskmaster/tasks/task_094.md b/.taskmaster/tasks/task_094.md new file mode 100644 index 0000000..717e9bd --- /dev/null +++ b/.taskmaster/tasks/task_094.md @@ -0,0 +1,66 @@ +# Task ID: 94 + +**Title:** Implement mcpctl create proxymodel Command + +**Status:** cancelled + +**Dependencies:** 78 ✓ + +**Priority:** medium + +**Description:** Add CLI command to scaffold a new proxymodel YAML file with specified stages. + +**Details:** + +Create `src/cli/src/commands/create-proxymodel.ts`: + +```typescript +import { Command } from 'commander'; +import { mkdir, writeFile, access } from 'fs/promises'; +import { join } from 'path'; +import { stringify as yamlStringify } from 'yaml'; + +const PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels'); + +export function registerCreateProxymodel(program: Command): void { + program + .command('create proxymodel ') + .description('Create a new proxymodel') + .option('--stages ', 'Comma-separated list of stage names', 'passthrough') + .option('--controller ', 'Session controller (gate or none)', 'gate') + .action(async (name: string, opts) => { + await mkdir(PROXYMODELS_DIR, { recursive: true }); + + const filePath = join(PROXYMODELS_DIR, `${name}.yaml`); + + try { + await access(filePath); + console.error(`Proxymodel '${name}' already exists at ${filePath}`); + process.exit(1); + } catch { + // File doesn't exist, good + } + + const stages = opts.stages.split(',').map((s: string) => ({ type: s.trim() })); + + const model = { + kind: 'ProxyModel', + metadata: { name }, + spec: { + controller: opts.controller, + stages, + appliesTo: ['prompts', 'toolResults'], + cacheable: true, + }, + }; + + await writeFile(filePath, yamlStringify(model)); + + console.log(`Created ${filePath}`); + }); +} +``` + +**Test Strategy:** + +CLI test: `mcpctl create proxymodel my-pipeline --stages summarize,compress` creates valid YAML. Test default values. Verify generated YAML validates against schema. diff --git a/.taskmaster/tasks/task_095.md b/.taskmaster/tasks/task_095.md new file mode 100644 index 0000000..1a51125 --- /dev/null +++ b/.taskmaster/tasks/task_095.md @@ -0,0 +1,70 @@ +# Task ID: 95 + +**Title:** Implement mcpctl proxymodel validate Command + +**Status:** cancelled + +**Dependencies:** 78 ✓, 79 ✓ + +**Priority:** medium + +**Description:** Add CLI command to validate a proxymodel definition, checking that all stages resolve and config is valid. + +**Details:** + +Create `src/cli/src/commands/proxymodel-validate.ts`: + +```typescript +import { Command } from 'commander'; +import { loadProxyModels } from 'mcplocal/proxymodel/loader'; +import { getStage, loadCustomStages } from 'mcplocal/proxymodel/stage-registry'; + +export function registerProxymodelValidate(program: Command): void { + program + .command('proxymodel validate ') + .description('Validate a proxymodel definition') + .action(async (name: string) => { + await loadCustomStages(); + const models = await loadProxyModels(); + const model = models.get(name); + + if (!model) { + console.error(`Proxymodel '${name}' not found`); + process.exit(1); + } + + let valid = true; + const errors: string[] = []; + + // Check all stages resolve + for (const stageConfig of model.spec.stages) { + const stage = getStage(stageConfig.type); + if (!stage) { + errors.push(`Stage '${stageConfig.type}' not found`); + valid = false; + } + } + + // Check controller is valid + const validControllers = ['gate', 'none']; + if (model.spec.controller && !validControllers.includes(model.spec.controller)) { + errors.push(`Unknown controller '${model.spec.controller}'`); + valid = false; + } + + if (valid) { + console.log(`✓ Proxymodel '${name}' is valid`); + } else { + console.error(`✗ Proxymodel '${name}' has errors:`); + for (const err of errors) { + console.error(` - ${err}`); + } + process.exit(1); + } + }); +} +``` + +**Test Strategy:** + +CLI test: valid proxymodel passes, proxymodel with unknown stage fails with clear error, proxymodel with unknown controller fails. Test with both built-in and custom stages. diff --git a/.taskmaster/tasks/task_096.md b/.taskmaster/tasks/task_096.md new file mode 100644 index 0000000..61c8d01 --- /dev/null +++ b/.taskmaster/tasks/task_096.md @@ -0,0 +1,53 @@ +# Task ID: 96 + +**Title:** Implement mcpctl delete stage Command + +**Status:** cancelled + +**Dependencies:** 79 ✓ + +**Priority:** low + +**Description:** Add CLI command to delete a custom stage file (cannot delete built-ins). + +**Details:** + +Create `src/cli/src/commands/delete-stage.ts`: + +```typescript +import { Command } from 'commander'; +import { unlink, access } from 'fs/promises'; +import { join } from 'path'; +import { listStages } from 'mcplocal/proxymodel/stage-registry'; + +const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages'); + +export function registerDeleteStage(program: Command): void { + program + .command('delete stage ') + .description('Delete a custom stage') + .action(async (name: string) => { + const stages = listStages(); + const stageInfo = stages.find(s => s.name === name); + + if (!stageInfo) { + console.error(`Stage '${name}' not found`); + process.exit(1); + } + + if (stageInfo.source === 'built-in') { + console.error(`Cannot delete built-in stage '${name}'`); + process.exit(1); + } + + const filePath = join(STAGES_DIR, `${name}.ts`); + await unlink(filePath); + + console.log(`Deleted ${filePath}`); + }); +} +``` + +**Test Strategy:** + +CLI test: can delete custom stage, cannot delete built-in stage (error message), deleting non-existent stage shows error. diff --git a/.taskmaster/tasks/task_097.md b/.taskmaster/tasks/task_097.md new file mode 100644 index 0000000..b66a0b6 --- /dev/null +++ b/.taskmaster/tasks/task_097.md @@ -0,0 +1,64 @@ +# Task ID: 97 + +**Title:** Implement mcpctl delete proxymodel Command + +**Status:** cancelled + +**Dependencies:** 78 ✓ + +**Priority:** low + +**Description:** Add CLI command to delete a local proxymodel YAML file (cannot delete built-ins). + +**Details:** + +Create `src/cli/src/commands/delete-proxymodel.ts`: + +```typescript +import { Command } from 'commander'; +import { unlink, access } from 'fs/promises'; +import { join } from 'path'; +import { loadProxyModels } from 'mcplocal/proxymodel/loader'; +import { getBuiltInProxyModels } from 'mcplocal/proxymodel/built-in-models'; + +const PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels'); + +export function registerDeleteProxymodel(program: Command): void { + program + .command('delete proxymodel ') + .description('Delete a local proxymodel') + .action(async (name: string) => { + const models = await loadProxyModels(); + const builtIns = getBuiltInProxyModels(); + + if (!models.has(name)) { + console.error(`Proxymodel '${name}' not found`); + process.exit(1); + } + + const filePath = join(PROXYMODELS_DIR, `${name}.yaml`); + + try { + await access(filePath); + } catch { + if (builtIns.has(name)) { + console.error(`Cannot delete built-in proxymodel '${name}'`); + } else { + console.error(`Proxymodel '${name}' file not found at ${filePath}`); + } + process.exit(1); + } + + await unlink(filePath); + console.log(`Deleted ${filePath}`); + + if (builtIns.has(name)) { + console.log(`Note: Built-in '${name}' will still be available`); + } + }); +} +``` + +**Test Strategy:** + +CLI test: can delete local proxymodel, cannot delete built-in (error message), deleting local override shows note about built-in fallback. diff --git a/.taskmaster/tasks/task_098.md b/.taskmaster/tasks/task_098.md new file mode 100644 index 0000000..4447f4e --- /dev/null +++ b/.taskmaster/tasks/task_098.md @@ -0,0 +1,79 @@ +# Task ID: 98 + +**Title:** Implement Persistent File Cache for Stages + +**Status:** done + +**Dependencies:** 73 ✓ + +**Priority:** high + +**Description:** Extend CacheProvider with file-based persistence in ~/.mcpctl/cache/proxymodel/ for cross-session caching. + +**Details:** + +Create `src/mcplocal/src/proxymodel/file-cache.ts`: + +```typescript +import { mkdir, readFile, writeFile, readdir, stat, unlink } from 'fs/promises'; +import { join } from 'path'; +import { createHash } from 'crypto'; +import type { CacheProvider } from './types'; + +const CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel'); + +export class FileCacheProvider implements CacheProvider { + private memCache = new Map(); + private maxSizeBytes: number; + + constructor(options: { maxSizeBytes?: number } = {}) { + this.maxSizeBytes = options.maxSizeBytes ?? 100 * 1024 * 1024; // 100MB default + } + + hash(content: string): string { + return createHash('sha256').update(content).digest('hex').slice(0, 16); + } + + private keyToPath(key: string): string { + const safeKey = key.replace(/[^a-zA-Z0-9-_]/g, '_'); + return join(CACHE_DIR, safeKey); + } + + async get(key: string): Promise { + // Check memory first + if (this.memCache.has(key)) return this.memCache.get(key)!; + + // Check file + try { + const content = await readFile(this.keyToPath(key), 'utf-8'); + this.memCache.set(key, content); // Warm memory cache + return content; + } catch { + return null; + } + } + + async set(key: string, value: string): Promise { + await mkdir(CACHE_DIR, { recursive: true }); + this.memCache.set(key, value); + await writeFile(this.keyToPath(key), value); + await this.enforceMaxSize(); + } + + async getOrCompute(key: string, compute: () => Promise): Promise { + const cached = await this.get(key); + if (cached !== null) return cached; + const value = await compute(); + await this.set(key, value); + return value; + } + + private async enforceMaxSize(): Promise { + // LRU eviction based on file mtime when cache exceeds maxSizeBytes + } +} +``` + +**Test Strategy:** + +Unit tests: file-based persistence survives process restart, memory cache is warmed on file read, LRU eviction works when size exceeded, concurrent access is safe. Integration test with real filesystem. diff --git a/.taskmaster/tasks/task_099.md b/.taskmaster/tasks/task_099.md new file mode 100644 index 0000000..ae28c7c --- /dev/null +++ b/.taskmaster/tasks/task_099.md @@ -0,0 +1,61 @@ +# Task ID: 99 + +**Title:** Add Cache Key with Stage File Hash + +**Status:** done + +**Dependencies:** 98 ✓ + +**Priority:** medium + +**Description:** Include the stage file hash in cache keys so cached artifacts are automatically invalidated when stage code changes. + +**Details:** + +Modify `src/mcplocal/src/proxymodel/executor.ts`: + +```typescript +import { readFile, stat } from 'fs/promises'; +import { createHash } from 'crypto'; +import { join } from 'path'; + +const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages'); +const stageFileHashes: Map = new Map(); + +async function getStageFileHash(stageName: string): Promise { + // Check if custom stage file exists + const filePath = join(STAGES_DIR, `${stageName}.ts`); + try { + const content = await readFile(filePath, 'utf-8'); + const hash = createHash('sha256').update(content).digest('hex').slice(0, 8); + stageFileHashes.set(stageName, hash); + return hash; + } catch { + // Built-in stage, use version-based hash or fixed value + return 'builtin-v1'; + } +} + +// In executePipeline, compute cache key: +const stageHash = await getStageFileHash(stageConfig.type); +const cacheKey = [ + 'stage', + stageConfig.type, + stageHash, + cache.hash(currentContent), + cache.hash(JSON.stringify(stageConfig.config ?? {})), +].join(':'); + +// Use pipeline-level cache wrapping: +if (proxyModel.spec.cacheable) { + const cached = await cache.get(cacheKey); + if (cached) { + currentContent = cached; + continue; // Skip stage execution + } +} +``` + +**Test Strategy:** + +Unit tests: changing stage file content changes the hash, built-in stages have stable hash, cache miss when stage file changes, cache hit when stage file unchanged. diff --git a/.taskmaster/tasks/task_100.md b/.taskmaster/tasks/task_100.md new file mode 100644 index 0000000..9c67924 --- /dev/null +++ b/.taskmaster/tasks/task_100.md @@ -0,0 +1,77 @@ +# Task ID: 100 + +**Title:** Implement mcpctl cache list Command + +**Status:** done + +**Dependencies:** 98 ✓ + +**Priority:** low + +**Description:** Add CLI command to list cached proxymodel artifacts with size and age information. + +**Details:** + +Create `src/cli/src/commands/cache-list.ts`: + +```typescript +import { Command } from 'commander'; +import { readdir, stat } from 'fs/promises'; +import { join } from 'path'; +import Table from 'cli-table3'; + +const CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel'); + +export function registerCacheList(program: Command): void { + program + .command('cache list') + .description('List cached proxymodel artifacts') + .option('--project ', 'Filter by project') + .action(async (opts) => { + try { + const files = await readdir(CACHE_DIR); + + const table = new Table({ + head: ['KEY', 'SIZE', 'AGE'], + }); + + let totalSize = 0; + + for (const file of files) { + const filePath = join(CACHE_DIR, file); + const stats = await stat(filePath); + const age = formatAge(Date.now() - stats.mtimeMs); + const size = formatSize(stats.size); + totalSize += stats.size; + + if (opts.project && !file.includes(opts.project)) continue; + + table.push([file, size, age]); + } + + console.log(table.toString()); + console.log(`Total: ${formatSize(totalSize)}`); + } catch { + console.log('No cache entries found'); + } + }); +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; +} + +function formatAge(ms: number): string { + const mins = Math.floor(ms / 60000); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} +``` + +**Test Strategy:** + +CLI test: list shows cache entries with correct format, --project filter works, empty cache shows appropriate message, size/age formatting is correct. diff --git a/.taskmaster/tasks/task_101.md b/.taskmaster/tasks/task_101.md new file mode 100644 index 0000000..44f8d02 --- /dev/null +++ b/.taskmaster/tasks/task_101.md @@ -0,0 +1,61 @@ +# Task ID: 101 + +**Title:** Implement mcpctl cache clear Command + +**Status:** done + +**Dependencies:** 98 ✓ + +**Priority:** low + +**Description:** Add CLI command to clear the proxymodel cache, optionally filtered by project. + +**Details:** + +Create `src/cli/src/commands/cache-clear.ts`: + +```typescript +import { Command } from 'commander'; +import { readdir, unlink, rmdir } from 'fs/promises'; +import { join } from 'path'; + +const CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel'); + +export function registerCacheClear(program: Command): void { + program + .command('cache clear') + .description('Clear the proxymodel cache') + .option('--project ', 'Clear only cache for a specific project') + .option('--force', 'Skip confirmation', false) + .action(async (opts) => { + try { + const files = await readdir(CACHE_DIR); + const toDelete = opts.project + ? files.filter(f => f.includes(opts.project)) + : files; + + if (toDelete.length === 0) { + console.log('No cache entries to clear'); + return; + } + + if (!opts.force) { + console.log(`This will delete ${toDelete.length} cache entries.`); + // Add confirmation prompt + } + + for (const file of toDelete) { + await unlink(join(CACHE_DIR, file)); + } + + console.log(`Cleared ${toDelete.length} cache entries`); + } catch { + console.log('Cache directory does not exist'); + } + }); +} +``` + +**Test Strategy:** + +CLI test: clears all entries without --project, clears filtered entries with --project, confirmation required without --force, --force skips confirmation. diff --git a/.taskmaster/tasks/task_102.md b/.taskmaster/tasks/task_102.md new file mode 100644 index 0000000..41405ef --- /dev/null +++ b/.taskmaster/tasks/task_102.md @@ -0,0 +1,60 @@ +# Task ID: 102 + +**Title:** Implement mcpctl cache stats Command + +**Status:** done + +**Dependencies:** 98 ✓ + +**Priority:** low + +**Description:** Add CLI command to show cache statistics including hit rates, total size, and entry counts. + +**Details:** + +Create `src/cli/src/commands/cache-stats.ts`: + +```typescript +import { Command } from 'commander'; +import { readdir, stat } from 'fs/promises'; +import { join } from 'path'; + +const CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel'); + +export function registerCacheStats(program: Command): void { + program + .command('cache stats') + .description('Show cache statistics') + .action(async () => { + try { + const files = await readdir(CACHE_DIR); + + let totalSize = 0; + let oldest = Date.now(); + let newest = 0; + + for (const file of files) { + const filePath = join(CACHE_DIR, file); + const stats = await stat(filePath); + totalSize += stats.size; + oldest = Math.min(oldest, stats.mtimeMs); + newest = Math.max(newest, stats.mtimeMs); + } + + console.log(`Entries: ${files.length}`); + console.log(`Total size: ${formatSize(totalSize)}`); + console.log(`Oldest entry: ${formatAge(Date.now() - oldest)} ago`); + console.log(`Newest entry: ${formatAge(Date.now() - newest)} ago`); + + // Note: hit rate tracking would require runtime instrumentation + console.log('\nNote: Hit rate statistics require runtime instrumentation.'); + } catch { + console.log('No cache data available'); + } + }); +} +``` + +**Test Strategy:** + +CLI test: shows correct stats for populated cache, handles empty cache gracefully, size formatting is correct. diff --git a/.taskmaster/tasks/task_103.md b/.taskmaster/tasks/task_103.md new file mode 100644 index 0000000..70c0924 --- /dev/null +++ b/.taskmaster/tasks/task_103.md @@ -0,0 +1,47 @@ +# Task ID: 103 + +**Title:** Add Shell Completions for ProxyModel Commands + +**Status:** cancelled + +**Dependencies:** 89 ✗, 90 ✗, 91 ✗, 92 ✗, 93 ✗, 94 ✗, 95 ✗, 96 ✗, 97 ✗, 100 ✓, 101 ✓, 102 ✓ + +**Priority:** low + +**Description:** Extend shell completions to include all new proxymodel-related commands, resources, and flags. + +**Details:** + +Update `src/cli/src/completions.ts` to add completions for: + +```typescript +// Resource types +const RESOURCE_TYPES = [...existing, 'proxymodels', 'stages']; + +// Command completions +const COMMANDS = { + 'get': ['proxymodels', 'stages', ...existing], + 'describe': ['proxymodel', 'stage', ...existing], + 'create': ['proxymodel', 'stage', ...existing], + 'delete': ['proxymodel', 'stage', ...existing], + 'proxymodel': ['validate'], + 'cache': ['list', 'clear', 'stats'], +}; + +// Dynamic completions for proxymodel/stage names +async function completeProxymodelName(partial: string): Promise { + const models = await loadProxyModels(); + return [...models.keys()].filter(n => n.startsWith(partial)); +} + +async function completeStageName(partial: string): Promise { + const stages = listStages(); + return stages.map(s => s.name).filter(n => n.startsWith(partial)); +} +``` + +Generate completion scripts for bash, zsh, and fish. + +**Test Strategy:** + +Manual test: completions work in bash/zsh/fish for all new commands. Test proxymodel name completion, stage name completion, subcommand completion. diff --git a/.taskmaster/tasks/task_104.md b/.taskmaster/tasks/task_104.md new file mode 100644 index 0000000..30d4267 --- /dev/null +++ b/.taskmaster/tasks/task_104.md @@ -0,0 +1,67 @@ +# Task ID: 104 + +**Title:** Extend Traffic Events for ProxyModel Processing + +**Status:** cancelled + +**Dependencies:** 80 ✓ + +**Priority:** medium + +**Description:** Add new traffic event types for proxymodel processing: content_original, content_transformed, stage timing, cache hits/misses. + +**Details:** + +Modify `src/mcplocal/src/http/traffic.ts`: + +```typescript +export type TrafficEventType = + | 'client_request' + | 'client_response' + | 'upstream_request' + | 'upstream_response' + | 'client_notification' + // New proxymodel events: + | 'content_original' + | 'content_transformed' + | 'stage_executed' + | 'stage_cache_hit' + | 'stage_cache_miss'; + +export interface ContentOriginalEvent { + eventType: 'content_original'; + sessionId: string; + contentType: 'prompt' | 'toolResult'; + sourceName: string; + content: string; + charCount: number; +} + +export interface ContentTransformedEvent { + eventType: 'content_transformed'; + sessionId: string; + contentType: 'prompt' | 'toolResult'; + sourceName: string; + content: string; + charCount: number; + proxyModel: string; + stages: string[]; + durationMs: number; +} + +export interface StageExecutedEvent { + eventType: 'stage_executed'; + sessionId: string; + stageName: string; + inputChars: number; + outputChars: number; + durationMs: number; + cacheHit: boolean; +} +``` + +Emit these events from the pipeline executor. + +**Test Strategy:** + +Unit tests: events emitted at correct points in pipeline execution, event payloads contain correct data, cache hit/miss events distinguish correctly. Integration test with inspector showing new events. diff --git a/.taskmaster/tasks/task_105.md b/.taskmaster/tasks/task_105.md new file mode 100644 index 0000000..7ccb0a8 --- /dev/null +++ b/.taskmaster/tasks/task_105.md @@ -0,0 +1,83 @@ +# Task ID: 105 + +**Title:** Implement Model Studio TUI Base + +**Status:** done + +**Dependencies:** 104 ✗ + +**Priority:** medium + +**Description:** Create the base TUI for mcpctl console --model-studio that extends --inspect with original vs transformed view. + +**Details:** + +Create `src/cli/src/commands/console/model-studio.tsx`: + +```typescript +import React, { useState, useEffect } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { TrafficEvent } from './types'; + +interface ModelStudioProps { + projectName: string; + events: TrafficEvent[]; +} + +export function ModelStudio({ projectName, events }: ModelStudioProps) { + const [selectedIdx, setSelectedIdx] = useState(0); + const [viewMode, setViewMode] = useState<'original' | 'transformed' | 'diff'>('transformed'); + const [pauseMode, setPauseMode] = useState(false); + + useInput((input, key) => { + if (input === 'j') setSelectedIdx(i => Math.min(i + 1, events.length - 1)); + if (input === 'k') setSelectedIdx(i => Math.max(i - 1, 0)); + if (input === 'o') setViewMode(m => m === 'original' ? 'transformed' : m === 'transformed' ? 'diff' : 'original'); + if (input === 'p') setPauseMode(p => !p); + if (input === 'G') setSelectedIdx(events.length - 1); + }); + + const selected = events[selectedIdx]; + const isContentEvent = selected?.eventType === 'content_original' || selected?.eventType === 'content_transformed'; + + return ( + + + Model Studio: {projectName} + | + View: {viewMode} + | + {pauseMode ? '⏸ PAUSED' : '▶ LIVE'} + + + + {/* Event list sidebar */} + + {events.map((e, i) => ( + + {formatEventLine(e)} + + ))} + + + {/* Content view */} + + {isContentEvent && ( + + )} + + + + + [o] toggle view [p] pause [j/k] navigate [G] latest [q] quit + + + ); +} +``` + +Add --model-studio flag to console command. + +**Test Strategy:** + +Manual test: TUI renders correctly, keyboard navigation works, original/transformed/diff views switch correctly, pause indicator shows correctly. diff --git a/.taskmaster/tasks/task_106.md b/.taskmaster/tasks/task_106.md new file mode 100644 index 0000000..d0a1f1c --- /dev/null +++ b/.taskmaster/tasks/task_106.md @@ -0,0 +1,97 @@ +# Task ID: 106 + +**Title:** Implement Pause Queue for Model Studio + +**Status:** done + +**Dependencies:** 105 ✓ + +**Priority:** medium + +**Description:** Add a pause queue in mcplocal that holds outgoing responses when model studio pause mode is active. + +**Details:** + +Create `src/mcplocal/src/proxymodel/pause-queue.ts`: + +```typescript +interface PausedResponse { + id: string; + sessionId: string; + contentType: 'prompt' | 'toolResult'; + sourceName: string; + original: string; + transformed: string; + resolve: (content: string) => void; + timestamp: number; +} + +class PauseQueue { + private paused = false; + private queue: PausedResponse[] = []; + private listeners = new Set<(items: PausedResponse[]) => void>(); + + setPaused(paused: boolean): void { + this.paused = paused; + if (!paused) { + // Release all paused items with their transformed content + for (const item of this.queue) { + item.resolve(item.transformed); + } + this.queue = []; + } + this.notifyListeners(); + } + + isPaused(): boolean { + return this.paused; + } + + async enqueue(item: Omit): Promise { + if (!this.paused) return item.transformed; + + return new Promise(resolve => { + this.queue.push({ + ...item, + id: crypto.randomUUID(), + timestamp: Date.now(), + resolve, + }); + this.notifyListeners(); + }); + } + + editAndRelease(id: string, editedContent: string): void { + const idx = this.queue.findIndex(q => q.id === id); + if (idx >= 0) { + const item = this.queue.splice(idx, 1)[0]; + item.resolve(editedContent); + this.notifyListeners(); + } + } + + releaseOne(id: string): void { + const idx = this.queue.findIndex(q => q.id === id); + if (idx >= 0) { + const item = this.queue.splice(idx, 1)[0]; + item.resolve(item.transformed); + this.notifyListeners(); + } + } + + dropOne(id: string): void { + const idx = this.queue.findIndex(q => q.id === id); + if (idx >= 0) { + const item = this.queue.splice(idx, 1)[0]; + item.resolve(''); // Empty response + this.notifyListeners(); + } + } +} + +export const pauseQueue = new PauseQueue(); +``` + +**Test Strategy:** + +Unit tests: enqueue returns immediately when not paused, enqueue blocks when paused, releaseOne/editAndRelease/dropOne work correctly, setPaused(false) releases all. diff --git a/.taskmaster/tasks/task_107.md b/.taskmaster/tasks/task_107.md new file mode 100644 index 0000000..ea6667b --- /dev/null +++ b/.taskmaster/tasks/task_107.md @@ -0,0 +1,70 @@ +# Task ID: 107 + +**Title:** Implement Edit Mode for Model Studio + +**Status:** done + +**Dependencies:** 106 ✓ + +**Priority:** medium + +**Description:** Add inline editing capability to model studio for modifying paused responses before release. + +**Details:** + +Extend `src/cli/src/commands/console/model-studio.tsx`: + +```typescript +import { spawn } from 'child_process'; +import { writeFileSync, readFileSync, unlinkSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +async function editContent(original: string): Promise { + const editor = process.env.EDITOR ?? 'vim'; + const tmpFile = join(tmpdir(), `mcpctl-edit-${Date.now()}.txt`); + + writeFileSync(tmpFile, original); + + return new Promise((resolve, reject) => { + const proc = spawn(editor, [tmpFile], { + stdio: 'inherit', + }); + + proc.on('close', (code) => { + if (code === 0) { + const edited = readFileSync(tmpFile, 'utf-8'); + unlinkSync(tmpFile); + resolve(edited); + } else { + unlinkSync(tmpFile); + reject(new Error(`Editor exited with code ${code}`)); + } + }); + }); +} + +// In the TUI component: +useInput(async (input, key) => { + if (input === 'e' && pauseMode && selectedPausedItem) { + const edited = await editContent(selectedPausedItem.transformed); + pauseQueue.editAndRelease(selectedPausedItem.id, edited); + + // Emit correction event + trafficCapture.emit({ + eventType: 'content_edited', + sessionId: selectedPausedItem.sessionId, + contentType: selectedPausedItem.contentType, + sourceName: selectedPausedItem.sourceName, + original: selectedPausedItem.original, + transformed: selectedPausedItem.transformed, + edited, + timestamp: Date.now(), + }); + } +}); +``` + +**Test Strategy:** + +Integration test: pressing 'e' opens editor with content, saving and closing applies edit, edit event is emitted with correct before/after content. diff --git a/.taskmaster/tasks/task_108.md b/.taskmaster/tasks/task_108.md new file mode 100644 index 0000000..ced395a --- /dev/null +++ b/.taskmaster/tasks/task_108.md @@ -0,0 +1,75 @@ +# Task ID: 108 + +**Title:** Implement Model Switch for Model Studio + +**Status:** done + +**Dependencies:** 105 ✓, 82 ✓ + +**Priority:** medium + +**Description:** Add ability to switch the active proxymodel for a project mid-session from model studio. + +**Details:** + +Extend `src/cli/src/commands/console/model-studio.tsx`: + +```typescript +function ModelPicker({ models, current, onSelect }: { + models: string[]; + current: string; + onSelect: (name: string) => void; +}) { + const [selectedIdx, setSelectedIdx] = useState(models.indexOf(current)); + + useInput((input, key) => { + if (key.upArrow) setSelectedIdx(i => Math.max(0, i - 1)); + if (key.downArrow) setSelectedIdx(i => Math.min(models.length - 1, i + 1)); + if (key.return) onSelect(models[selectedIdx]); + }); + + return ( + + Select ProxyModel: + {models.map((m, i) => ( + + {m === current ? '✓ ' : ' '}{m} + + ))} + + ); +} + +// Add to main component: +const [showModelPicker, setShowModelPicker] = useState(false); +const [activeModel, setActiveModel] = useState('default'); + +useInput((input) => { + if (input === 'm') setShowModelPicker(true); +}); + +async function switchModel(name: string) { + // Call mcplocal API to switch model + await fetch(`http://localhost:${port}/projects/${projectName}/proxymodel`, { + method: 'PUT', + body: JSON.stringify({ proxyModel: name }), + }); + setActiveModel(name); + setShowModelPicker(false); + + // Emit model_switched event + trafficCapture.emit({ + eventType: 'model_switched', + projectName, + previousModel: activeModel, + newModel: name, + timestamp: Date.now(), + }); +} +``` + +Add PUT endpoint to mcplocal for switching proxymodel. + +**Test Strategy:** + +Integration test: 'm' opens model picker, selecting a model updates the active model, subsequent content flows through new model, model_switched event is emitted. diff --git a/.taskmaster/tasks/task_109.md b/.taskmaster/tasks/task_109.md new file mode 100644 index 0000000..b207fa9 --- /dev/null +++ b/.taskmaster/tasks/task_109.md @@ -0,0 +1,94 @@ +# Task ID: 109 + +**Title:** Implement Studio MCP Server Tools + +**Status:** done + +**Dependencies:** 104 ✗, 106 ✓, 107 ✓, 108 ✓ + +**Priority:** medium + +**Description:** Create MCP tools for Claude Monitor to observe traffic, get corrections, switch models, and modify stages. + +**Details:** + +Extend `src/cli/src/commands/console/inspect-mcp.ts` with studio tools: + +```typescript +const studioTools: Tool[] = [ + { + name: 'get_content_diff', + description: 'Get original vs transformed vs edited content for a specific event', + inputSchema: { + type: 'object', + properties: { + eventId: { type: 'string', description: 'Event ID' }, + }, + required: ['eventId'], + }, + }, + { + name: 'get_corrections', + description: 'Get all user corrections (edits) in a session', + inputSchema: { + type: 'object', + properties: { + sessionId: { type: 'string', description: 'Optional session filter' }, + }, + }, + }, + { + name: 'get_active_model', + description: 'Get current proxymodel name and stage list for a project', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string' }, + }, + required: ['project'], + }, + }, + { + name: 'switch_model', + description: 'Hot-swap the active proxymodel on a project', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string' }, + model: { type: 'string' }, + }, + required: ['project', 'model'], + }, + }, + { + name: 'reload_stages', + description: 'Force reload all stages from ~/.mcpctl/stages/', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'get_stage_source', + description: 'Read the source code of a stage file', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + { + name: 'list_models', + description: 'List available proxymodels', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'list_stages', + description: 'List available stages', + inputSchema: { type: 'object', properties: {} }, + }, +]; +``` + +**Test Strategy:** + +Integration test with MCP client: each tool returns expected data format, switch_model actually changes the model, reload_stages picks up file changes, get_corrections returns user edits. diff --git a/.taskmaster/tasks/task_110.md b/.taskmaster/tasks/task_110.md new file mode 100644 index 0000000..2f8c570 --- /dev/null +++ b/.taskmaster/tasks/task_110.md @@ -0,0 +1,62 @@ +# Task ID: 110 + +**Title:** Implement RBAC for ProxyModels + +**Status:** deferred + +**Dependencies:** 87 ✗ + +**Priority:** low + +**Description:** Add 'run' permission on proxymodels resource controlling which proxymodels users can activate on projects. + +**Details:** + +Update RBAC schema and enforcement: + +1. Add to `src/db/prisma/schema.prisma`: +```prisma +// Extend existing RbacBinding or Permission model +enum RbacResource { + // ... existing + proxymodels +} + +enum RbacPermission { + // ... existing + run // permission to use a proxymodel + cache // permission to push to shared cache +} +``` + +2. Add enforcement in `src/mcplocal/src/router.ts`: +```typescript +async function resolveProxyModel( + requestedModel: string, + projectName: string, + userId: string +): Promise { + const models = await loadProxyModels(); + const model = models.get(requestedModel); + + if (!model) { + console.warn(`Proxymodel '${requestedModel}' not found, using default`); + return models.get('default')!; + } + + // Check RBAC permission + const hasPermission = await checkPermission(userId, 'run', 'proxymodels', requestedModel); + if (!hasPermission) { + console.warn(`User lacks 'run' permission for proxymodel '${requestedModel}', using default`); + return models.get('default')!; + } + + return model; +} +``` + +3. 'default' proxymodel requires no permission (always allowed). + +**Test Strategy:** + +Integration test: user with 'run' permission can use proxymodel, user without permission falls back to default, 'default' always works, permission check logs reason for fallback. diff --git a/.taskmaster/tasks/task_111.md b/.taskmaster/tasks/task_111.md new file mode 100644 index 0000000..c6e0536 --- /dev/null +++ b/.taskmaster/tasks/task_111.md @@ -0,0 +1,76 @@ +# Task ID: 111 + +**Title:** Write Integration Tests for subindex Model + +**Status:** done + +**Dependencies:** 75 ✓, 76 ✓, 82 ✓, 83 ✗ + +**Priority:** high + +**Description:** Create comprehensive integration tests for the subindex proxymodel processing real content through section-split and summarize-tree. + +**Details:** + +Create `src/mcplocal/tests/proxymodel/subindex.test.ts`: + +```typescript +import { describe, it, expect, beforeAll } from 'vitest'; +import { executePipeline } from '../../src/proxymodel/executor'; +import { loadProxyModels } from '../../src/proxymodel/loader'; +import { createMockProviderRegistry } from '../mocks/providers'; + +describe('subindex proxymodel', () => { + let proxyModel; + let mockRegistry; + + beforeAll(async () => { + const models = await loadProxyModels(); + proxyModel = models.get('subindex'); + mockRegistry = createMockProviderRegistry({ + complete: async (prompt) => 'Mock summary of the content', + }); + }); + + it('splits JSON array into sections', async () => { + const content = JSON.stringify([ + { id: 'flow1', label: 'Thermostat', nodes: [] }, + { id: 'flow2', label: 'Lighting', nodes: [] }, + ]); + + const result = await executePipeline({ + content, + contentType: 'toolResult', + sourceName: 'test/get_flows', + projectName: 'test', + sessionId: 'test-session', + proxyModel, + providerRegistry: mockRegistry, + }); + + expect(result.sections).toHaveLength(2); + expect(result.sections[0].id).toBe('flow1'); + expect(result.content).toContain('2 sections'); + }); + + it('provides drill-down to exact JSON content', async () => { + // Test that drilling into a section returns exact original JSON + }); + + it('uses structural summaries for JSON (no LLM call)', async () => { + // Verify LLM not called for JSON content + }); + + it('uses LLM summaries for prose content', async () => { + // Verify LLM called for markdown content + }); + + it('caches summaries across requests', async () => { + // Verify cache hit on second request with same content + }); +}); +``` + +**Test Strategy:** + +Run with vitest, verify all test cases pass, check LLM call counts are as expected (structural vs prose), verify cache behavior. diff --git a/.taskmaster/tasks/task_112.md b/.taskmaster/tasks/task_112.md new file mode 100644 index 0000000..fe9f3e2 --- /dev/null +++ b/.taskmaster/tasks/task_112.md @@ -0,0 +1,47 @@ +# Task ID: 112 + +**Title:** Write Documentation for ProxyModel Authoring + +**Status:** done + +**Dependencies:** 71 ✓, 78 ✓, 93 ✗, 94 ✗ + +**Priority:** low + +**Description:** Create comprehensive documentation for users wanting to create custom stages and proxymodels. + +**Details:** + +Create documentation covering: + +1. `docs/proxymodels/authoring-guide.md` - Complete guide from PRD's "Authoring Guide" section: + - Concepts: stages, proxymodels, framework + - File locations + - Step-by-step stage creation + - Step-by-step proxymodel creation + - Testing with mcpctl proxymodel validate + - Section drill-down + - Cache usage + - Error handling + - Full example + +2. `docs/proxymodels/built-in-stages.md` - Reference for all built-in stages: + - passthrough + - paginate + - section-split + - summarize-tree + - Config options for each + +3. `docs/proxymodels/api-reference.md` - Type reference: + - StageHandler + - StageContext + - StageResult + - Section + - LLMProvider + - CacheProvider + +4. Update main README with proxymodels overview. + +**Test Strategy:** + +Review documentation for completeness, verify all code examples compile, test example stage from documentation works end-to-end. diff --git a/.taskmaster/tasks/task_113.md b/.taskmaster/tasks/task_113.md new file mode 100644 index 0000000..b4e9e10 --- /dev/null +++ b/.taskmaster/tasks/task_113.md @@ -0,0 +1,31 @@ +# Task ID: 113 + +**Title:** Write Documentation for Model Studio + +**Status:** done + +**Dependencies:** 105 ✓, 106 ✓, 107 ✓, 108 ✓, 109 ✓ + +**Priority:** low + +**Description:** Create documentation for using model studio for live proxymodel development and debugging. + +**Details:** + +Create `docs/proxymodels/model-studio.md` covering: + +1. Overview: Three-window setup (Claude Client, Model Studio, Claude Monitor) +2. Starting Model Studio: `mcpctl console --model-studio ` +3. Keyboard shortcuts reference +4. Viewing original vs transformed content +5. Pause mode: when and why to use it +6. Editing paused responses +7. Switching proxymodels mid-session +8. Using Claude Monitor to observe and modify stages +9. The correction workflow: edit → observe → adjust stage → verify +10. MCP tools available to Claude Monitor +11. Troubleshooting common issues + +**Test Strategy:** + +Review documentation for completeness, verify all described features work as documented. diff --git a/.taskmaster/tasks/task_114.md b/.taskmaster/tasks/task_114.md new file mode 100644 index 0000000..83cb871 --- /dev/null +++ b/.taskmaster/tasks/task_114.md @@ -0,0 +1,74 @@ +# Task ID: 114 + +**Title:** ProxyModel v2: Code-based MCP middleware plugin system + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Redesign the ProxyModel framework from a YAML-configured content transformation pipeline into a full code-based MCP middleware plugin system. Proxy models become TypeScript files that can intercept any MCP request/response, create synthetic tools, maintain per-session state, and compose via multiple inheritance with compile-time conflict detection. The existing gate functionality (begin_session, tools/list filtering, prompt scoring, ungating) becomes the first proxy model implementation, proving the framework works by implementing gate entirely as a plugin with zero gate-specific code in router.ts. + +**Details:** + +## Vision + +A proxy model is a TypeScript code file (not YAML) that acts as full MCP middleware. It can: +- Intercept any MCP request (initialize, tools/list, tools/call, resources/*, prompts/*) +- Modify any response before it reaches the client +- Create synthetic tools (e.g. begin_session doesn't exist upstream) +- Maintain per-session state (gated/ungated, accumulated tags, etc.) +- Access project resources (prompts, servers, config) +- Transform content (what stages do today: paginate, section-split, etc.) + +## Key design decisions + +1. Code not YAML: Proxy models live as .ts files in a known directory (e.g. ~/.mcpctl/proxymodels/). File exists = model exists. No create/delete via CLI. +2. Stages deprecated: No separate stage resource. Content transformation is just code inside the proxy model. +3. Multiple inheritance: A model can extend [gate, subindex] to compose behaviors from multiple parents. Conflicts (two parents intercepting the same method incompatibly) detected at load/compile time, not runtime. +4. Gate is just a proxy model: The ~300 lines of gate logic in router.ts move into a gate.ts proxy model file. Router becomes thin plumbing (~100 lines). +5. gated:true replaced by proxyModel field: Projects get a proxyModel: gate field. If the assigned model implements gating, the project is gated. No separate boolean. +6. Discoverable as resources: mcpctl get proxymodels lists available models (discovered from files). mcpctl describe proxymodel gate shows details. But no create/delete commands. +7. Attached to projects: mcpctl edit project foo --proxyModel gate or via apply YAML. + +## Framework interface (sketch) + +export interface ProxyModelContext { + session: SessionState; + project: ProjectConfig; + upstream: UpstreamClient; + llm?: LLMProvider; + cache?: CacheProvider; +} + +export interface ProxyModel { + name: string; + extends?: string[]; + onInitialize?(ctx, request): Promise; + onToolsList?(ctx): Promise; + onToolCall?(ctx, name, args): Promise; + onResourceRead?(ctx, uri): Promise; + transformContent?(ctx, content, contentType): Promise; + createSessionState?(): Record; +} + +## Migration path + +1. Define the ProxyModel TypeScript interface +2. Implement the plugin loader (discover .ts files, compile, validate inheritance, detect conflicts) +3. Implement the router integration (router delegates to loaded proxy model) +4. Extract gate logic from router.ts into gate.ts proxy model +5. Extract content pipeline (passthrough, paginate, section-split) into proxy model code +6. Add proxyModel field to Project schema (replaces gated boolean) +7. Add CLI: get proxymodels, describe proxymodel, edit project --proxyModel +8. Add smoke tests: gate proxy model produces identical behavior to current hardcoded gate +9. Deprecate gated field (backward compat: gated:true maps to proxyModel:gate) + +## Supersedes + +This task supersedes deferred tasks 83, 85-97, 98-99, 103, 104, 110, 111-112 which assumed the old YAML/stage architecture. + +**Test Strategy:** + +1. Gate proxy model smoke test: identical behavior to current hardcoded gate (begin_session, tools/list filtering, ungating). 2. Composition test: model extending [gate, paginate] inherits both behaviors. 3. Conflict detection test: two parents intercepting same hook differently = compile-time error. 4. Discovery test: drop a .ts file in proxymodels dir, mcpctl get proxymodels shows it. 5. Existing smoke tests (proxy-pipeline.test.ts) pass unchanged after migration. diff --git a/.taskmaster/tasks/task_115.md b/.taskmaster/tasks/task_115.md new file mode 100644 index 0000000..da882aa --- /dev/null +++ b/.taskmaster/tasks/task_115.md @@ -0,0 +1,29 @@ +# Task ID: 115 + +**Title:** RBAC tasks namespace split and alias repoint + +**Status:** pending + +**Dependencies:** None + +**Priority:** high + +**Description:** Split the RBAC 'tasks' resource into 'tasks' (work items) and 'inference-tasks', update RBAC_RESOURCES and RESOURCE_ALIASES in rbac-definition.schema.ts + +**Details:** + +In src/mcpd/src/validation/rbac-definition.schema.ts: +1. Update RBAC_RESOURCES array to include: 'epics', 'epictasks', 'tasks', 'repos', 'sessions', 'inference-tasks' +2. Update RESOURCE_ALIASES: + - task → tasks (work items) + - epic → epics + - epictask → epictasks, et → epictasks + - repo → repos, repository → repos, repositories → repos + - session → sessions + - inference-task → inference-tasks +3. REMOVE the entry 'inference-tasks': 'tasks' +4. Export normalizeResource unchanged (it uses the alias map) + +**Test Strategy:** + +Unit tests in src/mcpd/tests/validation/: verify normalizeResource('task') returns 'tasks', normalizeResource('inference-task') returns 'inference-tasks', normalizeResource('et') returns 'epictasks'. Test that RBAC_RESOURCES includes all new entries. diff --git a/.taskmaster/tasks/task_116.md b/.taskmaster/tasks/task_116.md new file mode 100644 index 0000000..61e2316 --- /dev/null +++ b/.taskmaster/tasks/task_116.md @@ -0,0 +1,26 @@ +# Task ID: 116 + +**Title:** One-shot RBAC binding backfill migration + +**Status:** pending + +**Dependencies:** 115 + +**Priority:** high + +**Description:** Implement guarded startup migration to rewrite existing 'tasks' bindings to 'inference-tasks' so pre-existing grants continue authorizing inference endpoints + +**Details:** + +In src/mcpd/src/main.ts or a new bootstrap module: +1. Read system settings from SYSTEM_SETTINGS_SECRET (see src/mcpd/src/bootstrap/system-settings.ts pattern) +2. Check for boolean key 'rbacTasksBackfillComplete' +3. If false/missing: iterate rbacRepo.findAll(), for each RbacDefinition row, rewrite roleBindings where resource === 'tasks' to resource: 'inference-tasks' +4. Use rbacRepo.update() to persist changes +5. Set 'rbacTasksBackfillComplete': true in system settings after completion +6. Wildcard bindings (resource: '*') are untouched +7. Model on migrateAdminRole pattern in main.ts + +**Test Strategy:** + +Unit test: create RbacDefinition with resource:'tasks', run backfill, verify resource changed to 'inference-tasks'. Test idempotency: run twice, verify no duplicate rewrites. Test wildcard unchanged. diff --git a/.taskmaster/tasks/task_117.md b/.taskmaster/tasks/task_117.md new file mode 100644 index 0000000..19fa45f --- /dev/null +++ b/.taskmaster/tasks/task_117.md @@ -0,0 +1,25 @@ +# Task ID: 117 + +**Title:** Add Epic, EpicTask, EpicTaskEdge Prisma models + +**Status:** pending + +**Dependencies:** None + +**Priority:** high + +**Description:** Add Epic, EpicTask, EpicTaskEdge models with enums EpicStatus and EpicTaskStatus to schema.prisma following PRD specifications + +**Details:** + +In src/db/prisma/schema.prisma add: +1. enum EpicStatus { open, delivered, delivered_with_tradeoffs, cancelled } +2. enum EpicTaskStatus { todo, in_progress, done, cancelled } +3. model Epic with: id, name, description, content (@db.Text), doneCriteria (Json), acceptedTradeoffs (Json), projectId, status, semver, currentRevisionId, ownerId, version, createdAt, updatedAt. Relations: project, owner (User), epicTasks[]. @@unique([name, projectId]), @@index([projectId]), @@index([name]) +4. model EpicTask with: id, name, description, epicId, doneCriteria (Json), status, ownerId, version, createdAt, updatedAt. Relations: epic, owner, tasks[], dependsOn (EpicTaskEdge[]), blocks (EpicTaskEdge[]). @@unique([name, epicId]), @@index([epicId]) +5. model EpicTaskEdge with: id, fromId, toId, createdAt. Relations using @relation names. @@unique([fromId, toId]), @@index([fromId]), @@index([toId]) +6. Add reverse relations to User and Project models + +**Test Strategy:** + +Schema tests in src/db/tests/: create Epic, verify unique constraint on (name, projectId), test cascade delete from Project, test EpicTask creation with Epic reference, test EpicTaskEdge uniqueness. diff --git a/.taskmaster/tasks/task_118.md b/.taskmaster/tasks/task_118.md new file mode 100644 index 0000000..30b59bc --- /dev/null +++ b/.taskmaster/tasks/task_118.md @@ -0,0 +1,25 @@ +# Task ID: 118 + +**Title:** Add Task, TaskVerdict, TaskEvent Prisma models + +**Status:** pending + +**Dependencies:** 117 + +**Priority:** high + +**Description:** Add Task, TaskVerdict, TaskEvent models with enums TaskState and TaskRisk to schema.prisma for work item tracking + +**Details:** + +In src/db/prisma/schema.prisma add: +1. enum TaskState { todo, in_progress, building, self_testing, qa_review, security_review, sre_review, done, awaiting_review, merged, reopened, cancelled } +2. enum TaskRisk { trivial, normal, high } +3. model Task with: id, name, title, description (@db.Text), epicTaskId, repoId?, assigneeId?, state, risk, branch?, headCommit?, prUrl?, prNumber?, reviewNote? (@db.Text), ownerId, version, createdAt, updatedAt. Relations: epicTask, repo (CodeRepo?), assignee (Agent?), owner, verdicts[], sessions[], events[]. @@unique([name, epicTaskId]), indexes on epicTaskId, state, assigneeId +4. model TaskVerdict with: id, taskId, stage (TaskState), agentId?, commit, passed, summary (@db.Text), findings (Json), createdAt. Relations: task, agent. @@index([taskId]), @@index([taskId, stage]) +5. model TaskEvent with: id, taskId, fromState?, toState, actorType, actorId?, note? (@db.Text), createdAt. @@index([taskId, createdAt]) +6. Add reverse relations to Agent model (assignedTasks[], verdicts[]) + +**Test Strategy:** + +Schema tests: create Task linked to EpicTask, verify cascade on EpicTask delete, test TaskVerdict immutability pattern, test TaskEvent append-only by creating multiple events for same task. diff --git a/.taskmaster/tasks/task_119.md b/.taskmaster/tasks/task_119.md new file mode 100644 index 0000000..e708b58 --- /dev/null +++ b/.taskmaster/tasks/task_119.md @@ -0,0 +1,25 @@ +# Task ID: 119 + +**Title:** Add CodeRepo Prisma model + +**Status:** pending + +**Dependencies:** None + +**Priority:** high + +**Description:** Add CodeRepo model to schema.prisma for git repository management within projects + +**Details:** + +In src/db/prisma/schema.prisma add: +1. model CodeRepo with: id, name, projectId, url, defaultBranch (@default("main")), path (@default("")), provider (@default("gitea")), credSecretId?, credSecretKey?, ownerId, version, createdAt, updatedAt +2. Relations: project (Project), cred (Secret?), owner (User), tasks (Task[]) +3. @@unique([name, projectId]), @@index([projectId]) +4. Add repos CodeRepo[] relation to Project model +5. Add codeRepos CodeRepo[] relation to Secret model +6. Add ownedRepos CodeRepo[] relation to User model + +**Test Strategy:** + +Schema tests: create CodeRepo linked to Project, verify unique constraint on (name, projectId), test cascade delete from Project, test SetNull behavior when Secret is deleted. diff --git a/.taskmaster/tasks/task_120.md b/.taskmaster/tasks/task_120.md new file mode 100644 index 0000000..a56e72b --- /dev/null +++ b/.taskmaster/tasks/task_120.md @@ -0,0 +1,26 @@ +# Task ID: 120 + +**Title:** Add AgentProject junction model + +**Status:** pending + +**Dependencies:** None + +**Priority:** high + +**Description:** Add AgentProject many-to-many junction table linking Agents to Projects with role and optional LLM override + +**Details:** + +In src/db/prisma/schema.prisma add: +1. model AgentProject with: id, agentId, projectId, role (@default("developer")), llmId?, createdAt +2. Relations: agent (Agent), project (Project), llm (Llm?) +3. @@unique([agentId, projectId]), @@index([agentId]), @@index([projectId]) +4. Add projectMemberships AgentProject[] to Agent model +5. Add agentMemberships AgentProject[] to Project model +6. Add agentProjects AgentProject[] to Llm model +7. Keep Agent.projectId for backward compatibility (one release) + +**Test Strategy:** + +Schema tests: create AgentProject junction, verify unique constraint prevents duplicate membership, test cascade on both Agent and Project delete, test SetNull on Llm delete. diff --git a/.taskmaster/tasks/task_121.md b/.taskmaster/tasks/task_121.md new file mode 100644 index 0000000..8442be1 --- /dev/null +++ b/.taskmaster/tasks/task_121.md @@ -0,0 +1,24 @@ +# Task ID: 121 + +**Title:** Add AgentSession Prisma model + +**Status:** pending + +**Dependencies:** 118, 120 + +**Priority:** high + +**Description:** Add AgentSession model with SessionState enum for ephemeral per-session pod tracking + +**Details:** + +In src/db/prisma/schema.prisma add: +1. enum SessionState { pending, provisioning, running, ended, failed } +2. model AgentSession with: id, agentId, projectId, taskId?, containerId?, state, image, expiresAt, lastError? (@db.Text), ownerId, createdAt, updatedAt, endedAt? +3. Relations: agent (Agent), project (Project), task (Task?), owner (User) +4. @@index([state]), @@index([taskId]), @@index([agentId]) +5. Add sessions AgentSession[] to Agent, Project, Task, User models + +**Test Strategy:** + +Schema tests: create AgentSession with all relations, test cascade on Agent/Project delete, test SetNull on Task delete, verify state enum transitions. diff --git a/.taskmaster/tasks/task_122.md b/.taskmaster/tasks/task_122.md new file mode 100644 index 0000000..33c34cd --- /dev/null +++ b/.taskmaster/tasks/task_122.md @@ -0,0 +1,30 @@ +# Task ID: 122 + +**Title:** Update clearAllTables test helper + +**Status:** pending + +**Dependencies:** 117, 118, 119, 120, 121 + +**Priority:** high + +**Description:** Add deleteMany calls for all new models to clearAllTables in src/db/tests/helpers.ts + +**Details:** + +In src/db/tests/helpers.ts clearAllTables function: +1. Add deleteMany for TaskEvent (child first) +2. Add deleteMany for TaskVerdict +3. Add deleteMany for AgentSession +4. Add deleteMany for Task +5. Add deleteMany for EpicTaskEdge +6. Add deleteMany for EpicTask +7. Add deleteMany for Epic +8. Add deleteMany for CodeRepo +9. Add deleteMany for AgentProject +10. Order matters: delete children before parents to avoid FK violations +11. Follow existing pattern in the file + +**Test Strategy:** + +Run existing test suite to verify clearAllTables works correctly with new models. Add explicit test that creates rows in all new tables then calls clearAllTables and verifies tables are empty. diff --git a/.taskmaster/tasks/task_123.md b/.taskmaster/tasks/task_123.md new file mode 100644 index 0000000..30ac011 --- /dev/null +++ b/.taskmaster/tasks/task_123.md @@ -0,0 +1,25 @@ +# Task ID: 123 + +**Title:** Epic repository layer + +**Status:** pending + +**Dependencies:** 117 + +**Priority:** medium + +**Description:** Create EpicRepository in src/mcpd/src/repositories/ following existing repository patterns + +**Details:** + +Create src/mcpd/src/repositories/epic.repository.ts: +1. Export EpicRepository class following agent.repository.ts pattern +2. Methods: create, findById, findByName, findByProjectId, findAll, update, delete +3. update must increment version (optimistic concurrency) +4. Include relations in queries where needed (epicTasks, project, owner) +5. Add type exports for CreateEpicData, UpdateEpicData +6. Register in repository index file + +**Test Strategy:** + +Unit tests in src/mcpd/tests/repositories/: test CRUD operations, test optimistic concurrency version increment, test findByProjectId filtering, test cascade behavior. diff --git a/.taskmaster/tasks/task_124.md b/.taskmaster/tasks/task_124.md new file mode 100644 index 0000000..5a017fa --- /dev/null +++ b/.taskmaster/tasks/task_124.md @@ -0,0 +1,27 @@ +# Task ID: 124 + +**Title:** Epic service layer with revision integration + +**Status:** pending + +**Dependencies:** 123 + +**Priority:** medium + +**Description:** Create EpicService with CRUD operations, revision tracking via ResourceRevision, and done-criteria roll-up logic + +**Details:** + +Create src/mcpd/src/services/epic.service.ts: +1. Inject EpicRepository, ResourceRevisionRepository, ProjectRepository +2. create(): validate project exists, create Epic, create initial ResourceRevision with resourceType:'epic' +3. update(): bump semver, create ResourceRevision, update currentRevisionId +4. getById(), getByName(), listByProject() +5. addTradeoff(): append to acceptedTradeoffs JSON array +6. computeStatus(): roll up EpicTask completion to determine if Epic is delivered/delivered_with_tradeoffs +7. getRoadmap(): return epictask graph with per-node completeness percentages +8. Follow PromptService pattern for revision integration + +**Test Strategy:** + +Unit tests: test create writes revision, test update bumps semver and writes revision, test addTradeoff appends correctly, test computeStatus logic for all EpicStatus values. diff --git a/.taskmaster/tasks/task_125.md b/.taskmaster/tasks/task_125.md new file mode 100644 index 0000000..087922b --- /dev/null +++ b/.taskmaster/tasks/task_125.md @@ -0,0 +1,25 @@ +# Task ID: 125 + +**Title:** Epic zod validation schemas + +**Status:** pending + +**Dependencies:** 117 + +**Priority:** medium + +**Description:** Create Epic validation schemas in src/mcpd/src/validation/ for request/response validation + +**Details:** + +Create src/mcpd/src/validation/epic.schema.ts: +1. DoneCriterionSchema: z.object({ id: z.string(), text: z.string(), kind: z.enum(['outcome', 'quality']), met: z.boolean() }) +2. AcceptedTradeoffSchema: z.object({ what: z.string(), why: z.string(), who: z.string(), deScopedBy: z.string().optional(), at: z.string() }) +3. CreateEpicSchema: name, content, doneCriteria (array), projectId or project (name), description optional +4. UpdateEpicSchema: partial of create fields plus status +5. EpicResponseSchema for API responses +6. Follow prompt.schema.ts patterns + +**Test Strategy:** + +Unit tests: validate CreateEpicSchema accepts valid input, rejects missing required fields, validates doneCriteria array structure, validates status enum values. diff --git a/.taskmaster/tasks/task_126.md b/.taskmaster/tasks/task_126.md new file mode 100644 index 0000000..cc2b8de --- /dev/null +++ b/.taskmaster/tasks/task_126.md @@ -0,0 +1,28 @@ +# Task ID: 126 + +**Title:** Epic routes and API endpoints + +**Status:** pending + +**Dependencies:** 124, 125 + +**Priority:** medium + +**Description:** Create Epic routes in src/mcpd/src/routes/ with CRUD endpoints plus roadmap and tradeoffs + +**Details:** + +Create src/mcpd/src/routes/epic.routes.ts: +1. GET /api/v1/epics - list with ?project= filter +2. POST /api/v1/epics - create, resolve project name to id +3. GET /api/v1/epics/:id - get by id +4. PUT /api/v1/epics/:id - update +5. DELETE /api/v1/epics/:id - delete +6. GET /api/v1/epics/:id/roadmap - return epictask graph with completeness +7. POST /api/v1/epics/:id/tradeoffs - append accepted tradeoff +8. Register routes in main.ts +9. Follow agent-routes.ts pattern for auth and validation + +**Test Strategy:** + +Route tests in src/mcpd/tests/routes/: test CRUD endpoints, test project name resolution, test roadmap returns graph structure, test tradeoffs append correctly. diff --git a/.taskmaster/tasks/task_127.md b/.taskmaster/tasks/task_127.md new file mode 100644 index 0000000..5a8a978 --- /dev/null +++ b/.taskmaster/tasks/task_127.md @@ -0,0 +1,25 @@ +# Task ID: 127 + +**Title:** EpicTask repository and service layers + +**Status:** pending + +**Dependencies:** 117, 123 + +**Priority:** medium + +**Description:** Create EpicTaskRepository and EpicTaskService with edge graph management and cycle detection + +**Details:** + +Create src/mcpd/src/repositories/epic-task.repository.ts and src/mcpd/src/services/epic-task.service.ts: +1. Repository: CRUD for EpicTask, edge CRUD for EpicTaskEdge, findByEpicId +2. Service.addEdge(): before creating edge, walk toId's transitive dependencies (via dependsOn edges), reject if fromId is reachable (cycle detection) +3. Service.removeEdge(): simple delete +4. Service.getGraph(): return nodes with edges for visualization +5. Service.computeCompleteness(): calculate percentage of doneCriteria met per node +6. Follow existing repository/service patterns + +**Test Strategy:** + +Unit tests: test edge creation, test cycle detection rejects A→B→C→A, test completeness calculation with mixed met/unmet criteria, test transitive dependency walk. diff --git a/.taskmaster/tasks/task_128.md b/.taskmaster/tasks/task_128.md new file mode 100644 index 0000000..bb4da22 --- /dev/null +++ b/.taskmaster/tasks/task_128.md @@ -0,0 +1,27 @@ +# Task ID: 128 + +**Title:** EpicTask routes and API endpoints + +**Status:** pending + +**Dependencies:** 127 + +**Priority:** medium + +**Description:** Create EpicTask routes with CRUD and edge management endpoints + +**Details:** + +Create src/mcpd/src/routes/epic-task.routes.ts: +1. GET /api/v1/epictasks - list with ?epic=&project= filters +2. POST /api/v1/epictasks - create +3. GET /api/v1/epictasks/:id - get +4. PUT /api/v1/epictasks/:id - update +5. DELETE /api/v1/epictasks/:id - delete +6. POST /api/v1/epictasks/:id/edges - add dependency edge { toId } +7. DELETE /api/v1/epictasks/:id/edges - remove edge { toId } +8. Register in main.ts + +**Test Strategy:** + +Route tests: test CRUD, test edge creation returns error on cycle, test edge deletion, test filters work correctly. diff --git a/.taskmaster/tasks/task_129.md b/.taskmaster/tasks/task_129.md new file mode 100644 index 0000000..ed05816 --- /dev/null +++ b/.taskmaster/tasks/task_129.md @@ -0,0 +1,24 @@ +# Task ID: 129 + +**Title:** Task repository with compare-and-swap transitions + +**Status:** pending + +**Dependencies:** 118 + +**Priority:** medium + +**Description:** Create TaskRepository with optimistic locking state transitions using updateMany with where clause + +**Details:** + +Create src/mcpd/src/repositories/task.repository.ts: +1. Standard CRUD methods following existing patterns +2. transition(id, fromState, toState): use prisma.task.updateMany({ where: { id, state: fromState }, data: { state: toState, version: { increment: 1 } } }), return affected count +3. If affected count is 0, the transition failed (race or wrong state) +4. findByFilters(): support epicTaskId, state, assigneeId filters +5. Include verdict and event relations where appropriate + +**Test Strategy:** + +Unit tests: test transition succeeds when state matches, test transition returns 0 when state doesn't match (simulating race), test concurrent transitions only one wins. diff --git a/.taskmaster/tasks/task_130.md b/.taskmaster/tasks/task_130.md new file mode 100644 index 0000000..ebd2a90 --- /dev/null +++ b/.taskmaster/tasks/task_130.md @@ -0,0 +1,26 @@ +# Task ID: 130 + +**Title:** Task service with lifecycle state machine + +**Status:** pending + +**Dependencies:** 129 + +**Priority:** medium + +**Description:** Create TaskService implementing the full task lifecycle with transition guards and event logging + +**Details:** + +Create src/mcpd/src/services/task.service.ts: +1. Define VALID_TRANSITIONS map: { [fromState]: [allowedToStates] } based on PRD lifecycle diagram +2. transition(id, toState, actorType, actorId, note): check transition is valid, call repo.transition, if success create TaskEvent, return result +3. assign(id, agentId): set assigneeId +4. recordVerdict(id, stage, agentId, commit, passed, summary, findings): create TaskVerdict, if failed transition back to in_progress +5. accept(id, userId, note): transition to merged, trigger merge via GitProvider +6. reject(id, userId, note): transition to in_progress +7. reopenOnPostMergeFailure(id): transition merged→reopened + +**Test Strategy:** + +Unit tests: test all valid transitions succeed, test invalid transitions throw, test TaskEvent created on every transition, test verdict creation, test accept triggers merge. diff --git a/.taskmaster/tasks/task_131.md b/.taskmaster/tasks/task_131.md new file mode 100644 index 0000000..02074f1 --- /dev/null +++ b/.taskmaster/tasks/task_131.md @@ -0,0 +1,30 @@ +# Task ID: 131 + +**Title:** Task routes and API endpoints + +**Status:** pending + +**Dependencies:** 130 + +**Priority:** medium + +**Description:** Create Task routes with CRUD, transition, verdict, accept/reject, and events endpoints + +**Details:** + +Create src/mcpd/src/routes/task.routes.ts: +1. GET /api/v1/tasks - list with ?project=&epictask=&state=&assignee= filters +2. POST /api/v1/tasks - create +3. GET /api/v1/tasks/:id - get +4. PUT /api/v1/tasks/:id - update +5. DELETE /api/v1/tasks/:id - delete +6. POST /api/v1/tasks/:id/transition - { toState, note? } +7. POST /api/v1/tasks/:id/verdict - record QA/Security/SRE verdict +8. POST /api/v1/tasks/:id/accept - { note? } human gate → merge +9. POST /api/v1/tasks/:id/reject - { note } → back to in_progress +10. GET /api/v1/tasks/:id/events - list TaskEvents +11. Register in main.ts + +**Test Strategy:** + +Route tests: test CRUD, test transition endpoint, test verdict recording, test accept triggers merge, test reject changes state, test events returns ordered list. diff --git a/.taskmaster/tasks/task_132.md b/.taskmaster/tasks/task_132.md new file mode 100644 index 0000000..635669d --- /dev/null +++ b/.taskmaster/tasks/task_132.md @@ -0,0 +1,25 @@ +# Task ID: 132 + +**Title:** CodeRepo repository and service layers + +**Status:** pending + +**Dependencies:** 119 + +**Priority:** medium + +**Description:** Create CodeRepoRepository and CodeRepoService with credential resolution through SecretService + +**Details:** + +Create src/mcpd/src/repositories/code-repo.repository.ts and src/mcpd/src/services/code-repo.service.ts: +1. Repository: standard CRUD, findByProjectId +2. Service.create(): validate project exists, validate credSecretId/Key if provided +3. Service.resolveCredentials(repoId): fetch Secret via SecretService, extract the key, return credential (never store plaintext) +4. Service.update(): validate changes +5. Service.delete(): check no active sessions reference this repo +6. Follow existing service patterns for validation + +**Test Strategy:** + +Unit tests: test create with valid project, test credential resolution returns correct value, test create fails with invalid secret reference, test findByProjectId returns filtered list. diff --git a/.taskmaster/tasks/task_133.md b/.taskmaster/tasks/task_133.md new file mode 100644 index 0000000..ed89352 --- /dev/null +++ b/.taskmaster/tasks/task_133.md @@ -0,0 +1,26 @@ +# Task ID: 133 + +**Title:** CodeRepo routes and API endpoints + +**Status:** pending + +**Dependencies:** 132 + +**Priority:** medium + +**Description:** Create CodeRepo routes with CRUD endpoints + +**Details:** + +Create src/mcpd/src/routes/code-repo.routes.ts: +1. GET /api/v1/repos - list with ?project= filter +2. POST /api/v1/repos - create, resolve project name +3. GET /api/v1/repos/:id - get (credential fields return references, not values) +4. PUT /api/v1/repos/:id - update +5. DELETE /api/v1/repos/:id - delete +6. Register in main.ts +7. Ensure credentials are never exposed in responses + +**Test Strategy:** + +Route tests: test CRUD, test project filter, test response does not include credential values, test delete succeeds when no sessions reference repo. diff --git a/.taskmaster/tasks/task_134.md b/.taskmaster/tasks/task_134.md new file mode 100644 index 0000000..3b3f2dc --- /dev/null +++ b/.taskmaster/tasks/task_134.md @@ -0,0 +1,26 @@ +# Task ID: 134 + +**Title:** RBAC wiring for new resources + +**Status:** pending + +**Dependencies:** 115, 126, 128, 131, 133 + +**Priority:** medium + +**Description:** Update mapUrlToPermission and nameResolvers in main.ts for epics, epictasks, tasks, repos, sessions + +**Details:** + +In src/mcpd/src/main.ts: +1. Add to mapUrlToPermission cases: epics, epictasks, tasks, repos, sessions +2. Map /api/v1/inference-tasks to 'inference-tasks' resource +3. Add non-CRUD verb cases above the map: + - POST /tasks/:id/accept|reject|transition → run:tasks: + - POST /sessions/:id/exec, GET /sessions/:id/attach → run:sessions: +4. Add nameResolvers for epics, epictasks, tasks, repos, sessions (fetch by id, return name for scoped grants) +5. Follow existing patterns for permission mapping + +**Test Strategy:** + +Unit tests: verify mapUrlToPermission returns correct permission strings for all new routes, test nameResolvers resolve id→name correctly, test non-CRUD verbs get run: prefix. diff --git a/.taskmaster/tasks/task_135.md b/.taskmaster/tasks/task_135.md new file mode 100644 index 0000000..ce3c4ba --- /dev/null +++ b/.taskmaster/tasks/task_135.md @@ -0,0 +1,27 @@ +# Task ID: 135 + +**Title:** Backup and YAML kinds for new resources + +**Status:** pending + +**Dependencies:** 126, 128, 131, 133 + +**Priority:** medium + +**Description:** Add new resources to BACKUP_KINDS, APPLY_ORDER, kindMap, and export/import loops + +**Details:** + +Update backup/apply infrastructure: +1. Add 'Epic', 'EpicTask', 'Task', 'CodeRepo' to BACKUP_KINDS array +2. Update APPLY_ORDER for correct dependency ordering (Epic before EpicTask before Task) +3. Add to kindMap: { Epic: 'epics', EpicTask: 'epictasks', Task: 'tasks', CodeRepo: 'repos' } +4. Update serializeResource for each new kind +5. Update kindFromSegment in main.ts import handler +6. Update importResource for create/update logic per kind +7. Update deleteResource for each kind +8. Fix existing gap: add 'skill' to importResource/deleteResource if missing + +**Test Strategy:** + +Integration tests: test backup exports all new resource types, test apply imports Epic→EpicTask→Task in correct order, test round-trip export→import preserves data. diff --git a/.taskmaster/tasks/task_136.md b/.taskmaster/tasks/task_136.md new file mode 100644 index 0000000..d3021b5 --- /dev/null +++ b/.taskmaster/tasks/task_136.md @@ -0,0 +1,28 @@ +# Task ID: 136 + +**Title:** CLI get columns and describe formatters for new resources + +**Status:** pending + +**Dependencies:** 126, 128, 131, 133 + +**Priority:** medium + +**Description:** Add column definitions and describe formatters for epics, epictasks, tasks, repos, sessions in CLI + +**Details:** + +In src/cli/src/commands/get.ts: +1. Add row interfaces: EpicRow, EpicTaskRow, TaskRow, CodeRepoRow, AgentSessionRow +2. Add to getColumnsForResource for each: define column names, widths, formatters +3. Add to RESOURCE_KIND map: epics→Epic, epictasks→EpicTask, tasks→Task, repos→CodeRepo, sessions→AgentSession +4. Add to toApplyDocs strip blocks + +In src/cli/src/commands/describe.ts: +5. Add describe handlers with clean sections and tables (not key-value dumps) +6. For Epic: show roadmap section with epictask names and completeness +7. For Task: show lifecycle state, assignee, PR link if present + +**Test Strategy:** + +Unit tests: test getColumnsForResource returns correct columns for each resource, test describe output formatting matches expected structure. diff --git a/.taskmaster/tasks/task_137.md b/.taskmaster/tasks/task_137.md new file mode 100644 index 0000000..b1da932 --- /dev/null +++ b/.taskmaster/tasks/task_137.md @@ -0,0 +1,25 @@ +# Task ID: 137 + +**Title:** CLI create subcommands for new resources + +**Status:** pending + +**Dependencies:** 136 + +**Priority:** medium + +**Description:** Add create subcommands for epic, epictask, task, repo with all settable fields as flags + +**Details:** + +In src/cli/src/commands/create.ts: +1. Add 'epic' subcommand: --name, --project, --content, --done-criteria (JSON), --description +2. Add 'epictask' subcommand: --name, --epic, --project, --done-criteria, --description, --depends-on (comma-separated names) +3. Add 'task' subcommand: --name, --title, --epictask, --epic, --project, --description, --risk, --repo +4. Add 'repo' subcommand: --name, --project, --url, --default-branch, --path, --provider, --cred-secret, --cred-key +5. Every field settable in apply must have a corresponding flag +6. Follow existing create subcommand patterns + +**Test Strategy:** + +Unit tests: test each create subcommand parses all flags correctly, test required flags are enforced, test API calls are made with correct payload. diff --git a/.taskmaster/tasks/task_138.md b/.taskmaster/tasks/task_138.md new file mode 100644 index 0000000..8da2eea --- /dev/null +++ b/.taskmaster/tasks/task_138.md @@ -0,0 +1,27 @@ +# Task ID: 138 + +**Title:** CLI apply and edit support for new resources + +**Status:** pending + +**Dependencies:** 137 + +**Priority:** medium + +**Description:** Add new resources to apply -f spec schemas and edit validResources + +**Details:** + +In src/cli/src/commands/apply.ts: +1. Add spec schemas: EpicSpecSchema, EpicTaskSpecSchema, TaskSpecSchema, CodeRepoSpecSchema +2. Add to ApplyConfigSchema discriminated union +3. Add to KIND_TO_RESOURCE map +4. Update dry-run counts for new resources +5. Add apply loops handling create/update for each resource + +In src/cli/src/commands/edit.ts: +6. Add 'epic', 'epictask', 'task', 'repo' to validResources array + +**Test Strategy:** + +Unit tests: test apply parses valid YAML for each new kind, test get -o yaml → apply -f round-trip for each resource type. diff --git a/.taskmaster/tasks/task_139.md b/.taskmaster/tasks/task_139.md new file mode 100644 index 0000000..febf77b --- /dev/null +++ b/.taskmaster/tasks/task_139.md @@ -0,0 +1,26 @@ +# Task ID: 139 + +**Title:** CLI shell completions for new resources + +**Status:** pending + +**Dependencies:** 138 + +**Priority:** low + +**Description:** Update completion generation for new resource names and aliases + +**Details:** + +In scripts/generate-completions.ts: +1. Add to CANONICAL_RESOURCES: 'epics', 'epictasks', 'tasks', 'repos', 'sessions' +2. Add to ALIAS_ENTRIES: epic→epics, epictask→epictasks, et→epictasks, task→tasks, repo→repos, repository→repos, repositories→repos, session→sessions +3. Run pnpm completions:generate to regenerate completion scripts +4. Completions are generated, never hand-edited + +In src/cli/tests/completions.test.ts: +5. Test assertions already run generator with --check, will fail on drift automatically + +**Test Strategy:** + +Run completions.test.ts which validates generated completions match source of truth. Manual test: source completions in shell, verify tab-completion works for new resources. diff --git a/.taskmaster/tasks/task_140.md b/.taskmaster/tasks/task_140.md new file mode 100644 index 0000000..e56247c --- /dev/null +++ b/.taskmaster/tasks/task_140.md @@ -0,0 +1,25 @@ +# Task ID: 140 + +**Title:** Extend ContainerSpec for ephemeral pods + +**Status:** pending + +**Dependencies:** None + +**Priority:** high + +**Description:** Add restartPolicy, workdir, volumes, and initContainers to ContainerSpec interface + +**Details:** + +In src/mcpd/src/services/orchestrator.ts: +1. Add to ContainerSpec interface: + - restartPolicy?: 'Always' | 'Never' | 'OnFailure' + - workdir?: string + - volumes?: Array<{ name: string; mountPath: string; kind: 'emptyDir' }> + - initContainers?: Array<{ name: string; image: string; command: string[]; env?: Record; volumeMounts?: Array<{ name: string; mountPath: string }> }> +2. These enable ephemeral session pods with workspace volumes and repo-clone init containers + +**Test Strategy:** + +Type tests: verify ContainerSpec accepts all new optional fields, verify existing code still compiles without providing new fields. diff --git a/.taskmaster/tasks/task_141.md b/.taskmaster/tasks/task_141.md new file mode 100644 index 0000000..acc77fa --- /dev/null +++ b/.taskmaster/tasks/task_141.md @@ -0,0 +1,25 @@ +# Task ID: 141 + +**Title:** Kubernetes orchestrator ephemeral pod support + +**Status:** pending + +**Dependencies:** 140 + +**Priority:** high + +**Description:** Update KubernetesOrchestrator to handle new ContainerSpec fields in generatePodSpec + +**Details:** + +In src/mcpd/src/services/k8s/manifest-generator.ts and KubernetesOrchestrator: +1. Update generatePodSpec to use spec.restartPolicy if provided (default 'Always' unchanged) +2. Add workingDir to container spec if workdir provided +3. Add volumes array for emptyDir volumes +4. Add volumeMounts to main container +5. Add initContainers array with their own volumeMounts +6. Ensure init containers complete before main container starts (Kubernetes handles this) + +**Test Strategy:** + +Unit tests: test generatePodSpec output includes restartPolicy, volumes, initContainers when provided. Integration test against test cluster: create pod with init container, verify init completes before main starts. diff --git a/.taskmaster/tasks/task_142.md b/.taskmaster/tasks/task_142.md new file mode 100644 index 0000000..630c6b4 --- /dev/null +++ b/.taskmaster/tasks/task_142.md @@ -0,0 +1,24 @@ +# Task ID: 142 + +**Title:** Docker orchestrator ephemeral container support + +**Status:** pending + +**Dependencies:** 140 + +**Priority:** medium + +**Description:** Update DockerContainerManager to handle new ContainerSpec fields + +**Details:** + +In DockerContainerManager (src/mcpd/src/services/docker/): +1. Map restartPolicy to Docker restart policy +2. Set WorkingDir in container config +3. Create volumes as Docker volumes, add Binds/Mounts +4. For initContainers: run each sequentially before main container, fail if any init fails +5. Docker doesn't have native init container support, so implement as sequential container runs that populate shared volume + +**Test Strategy:** + +Unit tests: test container spec translation. Integration test: create container with init, verify volume populated before main container runs. diff --git a/.taskmaster/tasks/task_143.md b/.taskmaster/tasks/task_143.md new file mode 100644 index 0000000..e6e5301 --- /dev/null +++ b/.taskmaster/tasks/task_143.md @@ -0,0 +1,25 @@ +# Task ID: 143 + +**Title:** SessionService for pod provisioning and lifecycle + +**Status:** pending + +**Dependencies:** 121, 132, 141, 142 + +**Priority:** high + +**Description:** Create SessionService handling AgentSession creation, repo-clone init container, credential resolution, and GC sweep + +**Details:** + +Create src/mcpd/src/services/session.service.ts: +1. create(agentId, projectId, taskId?, image, expiresAt): create AgentSession row in pending state +2. provision(sessionId): resolve project's CodeRepo list, resolve credentials via SecretService (fail on resolution error like InstanceService.attemptStart), build ContainerSpec with init container that clones repos to workspace volume, create pod via orchestrator, transition to running +3. end(sessionId): stop container, mark ended +4. getById(), listByFilters() +5. GC sweep: find sessions where now() > expiresAt, stop containers, mark ended. Run in same style as inference-task sweep in main.ts +6. Note: assumes single mcpd replica (no leader election) + +**Test Strategy:** + +Unit tests: test provision builds correct init container command, test credential resolution failure aborts provision, test GC sweep marks expired sessions ended. diff --git a/.taskmaster/tasks/task_144.md b/.taskmaster/tasks/task_144.md new file mode 100644 index 0000000..349e29a --- /dev/null +++ b/.taskmaster/tasks/task_144.md @@ -0,0 +1,27 @@ +# Task ID: 144 + +**Title:** Session routes and exec/attach endpoints + +**Status:** pending + +**Dependencies:** 143 + +**Priority:** high + +**Description:** Create Session routes with CRUD, one-shot exec, and WebSocket attach endpoints + +**Details:** + +Create src/mcpd/src/routes/session.routes.ts: +1. GET /api/v1/sessions - list with ?project=&agent=&task= filters +2. POST /api/v1/sessions - create and provision +3. GET /api/v1/sessions/:id - get +4. DELETE /api/v1/sessions/:id - end session +5. POST /api/v1/sessions/:id/exec - one-shot exec, body: { command: string[] }, returns { exitCode, stdout, stderr } +6. GET /api/v1/sessions/:id/attach - WebSocket upgrade, bidirectional stdio with terminal resize and exit code propagation +7. Use existing execInContainer, attachInteractive from orchestrator +8. Register in main.ts + +**Test Strategy:** + +Route tests: test CRUD, test exec returns command output, test attach upgrades to WebSocket. Integration: exec 'echo hello' in session pod, verify output. diff --git a/.taskmaster/tasks/task_145.md b/.taskmaster/tasks/task_145.md new file mode 100644 index 0000000..53bb3d7 --- /dev/null +++ b/.taskmaster/tasks/task_145.md @@ -0,0 +1,26 @@ +# Task ID: 145 + +**Title:** CLI exec command + +**Status:** pending + +**Dependencies:** 144 + +**Priority:** medium + +**Description:** Add mcpctl exec --project NAME AGENT -- command for interactive session access + +**Details:** + +In src/cli/src/commands/ add exec.ts or extend existing: +1. Parse: mcpctl exec --project NAME AGENT -- command args +2. Find or create AgentSession for agent in project +3. If command provided: call POST /sessions/:id/exec, print output +4. If no command (or -- bash): attach via WebSocket to /sessions/:id/attach +5. Handle terminal resize (SIGWINCH), pass to WebSocket +6. Propagate exit code from session +7. Per design doc: exec drops human into working environment (cloned repos), NOT the agent's reasoning loop + +**Test Strategy:** + +Manual testing: mcpctl exec --project test myagent -- ls should list repo contents. mcpctl exec --project test myagent -- bash should give interactive shell. diff --git a/.taskmaster/tasks/task_146.md b/.taskmaster/tasks/task_146.md new file mode 100644 index 0000000..a32e321 --- /dev/null +++ b/.taskmaster/tasks/task_146.md @@ -0,0 +1,29 @@ +# Task ID: 146 + +**Title:** AgentRuntime interface definition + +**Status:** pending + +**Dependencies:** None + +**Priority:** medium + +**Description:** Define AgentRuntime adapter interface for swappable agent implementations + +**Details:** + +Create src/mcpd/src/services/agent-runtime.ts: +1. Define RuntimeSessionOpts: { agentId, projectId, taskId?, image, persona (prompt files) } +2. Define RuntimeStatus: 'starting' | 'running' | 'completed' | 'failed' +3. Define RuntimeResult: { branch: string, headCommit: string, summary: string, prUrl?: string } +4. Define AgentRuntime interface: + - startSession(opts: RuntimeSessionOpts): Promise<{ sessionId: string }> + - send(sessionId: string, message: string): Promise + - status(sessionId: string): Promise + - result(sessionId: string): Promise + - endSession(sessionId: string): Promise +5. This is an interface, not an implementation - allows swapping runtimes later + +**Test Strategy:** + +Type tests: verify interface can be implemented, verify all methods have correct signatures. diff --git a/.taskmaster/tasks/task_147.md b/.taskmaster/tasks/task_147.md new file mode 100644 index 0000000..da20a43 --- /dev/null +++ b/.taskmaster/tasks/task_147.md @@ -0,0 +1,27 @@ +# Task ID: 147 + +**Title:** Headless coding agent runtime adapter + +**Status:** pending + +**Dependencies:** 143, 146 + +**Priority:** medium + +**Description:** Implement AgentRuntime adapter that runs a headless coding agent inside the session pod + +**Details:** + +Create src/mcpd/src/services/agent-runtime/headless-adapter.ts: +1. Implement AgentRuntime interface +2. startSession: create AgentSession, provision pod with coding agent image, inject persona as prompt files in container +3. send: exec into container to send message to agent process (implementation depends on chosen agent's IPC) +4. status: check agent process state via exec or container status +5. result: read agent output (branch, commit, summary) from known output location +6. endSession: stop container, update session state +7. The coding agent handles git, branching, MCP tools, streaming internally +8. Persona delivered via mounted files the agent reads + +**Test Strategy:** + +Integration tests against test cluster: start session, send simple task, verify branch created and pushed. diff --git a/.taskmaster/tasks/task_148.md b/.taskmaster/tasks/task_148.md new file mode 100644 index 0000000..c6d61db --- /dev/null +++ b/.taskmaster/tasks/task_148.md @@ -0,0 +1,27 @@ +# Task ID: 148 + +**Title:** GitProvider interface and Gitea driver + +**Status:** pending + +**Dependencies:** 132 + +**Priority:** high + +**Description:** Create GitProvider interface and Gitea implementation for PR creation, status check, and merge + +**Details:** + +Create src/mcpd/src/services/git-provider.ts and src/mcpd/src/services/git-provider/gitea.ts: +1. GitProvider interface: createPullRequest(repo, branch, title, body), getPullRequest(repo, prNumber), getCombinedStatus(repo, ref), mergePullRequest(repo, prNumber, method?) +2. Gitea driver: implement using Gitea API v1 + - POST /api/v1/repos/:owner/:repo/pulls for createPullRequest + - GET /api/v1/repos/:owner/:repo/pulls/:id for getPullRequest + - GET /api/v1/repos/:owner/:repo/commits/:sha/status for getCombinedStatus + - POST /api/v1/repos/:owner/:repo/pulls/:id/merge for mergePullRequest +3. Credentials resolved from CodeRepo's Secret via SecretService +4. Reference pr.sh script for endpoint shapes + +**Test Strategy:** + +Unit tests with mocked HTTP: test each method sends correct request, handles success/error responses. Integration test against local Gitea instance if available. diff --git a/.taskmaster/tasks/task_149.md b/.taskmaster/tasks/task_149.md new file mode 100644 index 0000000..4b5ff99 --- /dev/null +++ b/.taskmaster/tasks/task_149.md @@ -0,0 +1,28 @@ +# Task ID: 149 + +**Title:** Task PR linkage and human accept gate + +**Status:** pending + +**Dependencies:** 130, 148 + +**Priority:** high + +**Description:** Integrate GitProvider into TaskService for PR creation on push and merge on accept + +**Details:** + +Update src/mcpd/src/services/task.service.ts: +1. linkPR(taskId, branch, headCommit): create PR via GitProvider, update Task with prUrl, prNumber +2. accept(taskId, userId, note): + - Verify task is in awaiting_review state + - Check PR combined status is success (optional gate based on project config) + - Call gitProvider.mergePullRequest + - Transition to merged state + - Create TaskEvent +3. If merge fails, keep in awaiting_review, surface error +4. reject: transition to in_progress, clear PR linkage or keep for reference + +**Test Strategy:** + +Unit tests: test linkPR updates task fields, test accept calls merge API, test accept fails if PR status is failing. diff --git a/.taskmaster/tasks/task_150.md b/.taskmaster/tasks/task_150.md new file mode 100644 index 0000000..cc724f0 --- /dev/null +++ b/.taskmaster/tasks/task_150.md @@ -0,0 +1,28 @@ +# Task ID: 150 + +**Title:** QA/Security/SRE stage verdicts + +**Status:** pending + +**Dependencies:** 130 + +**Priority:** medium + +**Description:** Implement risk-scaled verification stages that record commit-pinned verdicts + +**Details:** + +Update TaskService: +1. recordVerdict(taskId, stage, agentId, commit, passed, summary, findings): create TaskVerdict row +2. If !passed: transition task back to in_progress, create TaskEvent explaining why +3. Determine required stages based on Task.risk: + - trivial: may pass on light self-check (no mandatory QA/Security/SRE) + - normal: requires qa_review + - high: requires qa_review + security_review + sre_review +4. Verdicts are immutable - append only, pinned to commit SHA +5. Each stage persona decides its own depth (not enforced in code, just recorded) +6. Transition to done only when all required stages have passing verdicts for current headCommit + +**Test Strategy:** + +Unit tests: test verdict creation, test failed verdict transitions back to in_progress, test risk-based stage requirements. diff --git a/.taskmaster/tasks/task_151.md b/.taskmaster/tasks/task_151.md new file mode 100644 index 0000000..36b6692 --- /dev/null +++ b/.taskmaster/tasks/task_151.md @@ -0,0 +1,28 @@ +# Task ID: 151 + +**Title:** Git webhook receiver for post-merge failure + +**Status:** pending + +**Dependencies:** 130, 148 + +**Priority:** medium + +**Description:** Create webhook endpoint that reopens tasks when post-merge CI fails + +**Details:** + +Create src/mcpd/src/routes/webhook.routes.ts: +1. POST /api/v1/webhooks/git/:provider - receive webhook payloads +2. Validate webhook signature using provider-specific method (Gitea uses HMAC-SHA256 with secret) +3. Parse payload to identify: repo, commit SHA, status (success/failure/pending) +4. If status is failure for a merged commit: + - Find Task with that headCommit and state=merged + - Call taskService.reopenOnPostMergeFailure(taskId) + - This transitions merged→reopened and creates TaskEvent +5. Register route in main.ts +6. Webhook secret stored in CodeRepo's credential secret + +**Test Strategy:** + +Unit tests: test signature validation, test payload parsing, test reopen triggered on failure status. Integration: simulate Gitea webhook payload. diff --git a/.taskmaster/tasks/task_152.md b/.taskmaster/tasks/task_152.md new file mode 100644 index 0000000..5721aaf --- /dev/null +++ b/.taskmaster/tasks/task_152.md @@ -0,0 +1,25 @@ +# Task ID: 152 + +**Title:** Agent membership routes + +**Status:** pending + +**Dependencies:** 120 + +**Priority:** medium + +**Description:** Create API endpoints for managing agent-project memberships + +**Details:** + +Add to existing agent routes or create new file: +1. GET /api/v1/agents/:name/projects - list projects agent belongs to +2. POST /api/v1/agents/:name/projects/:project - add membership { role?, llm? } +3. DELETE /api/v1/agents/:name/projects/:project - remove membership +4. GET /api/v1/projects/:name/agents - list agents in project +5. Keep Agent.projectId in sync when membership added/removed (backward compat for one release) +6. Follow existing route patterns + +**Test Strategy:** + +Route tests: test add membership creates junction row, test remove deletes it, test list returns correct memberships. diff --git a/.taskmaster/tasks/task_153.md b/.taskmaster/tasks/task_153.md new file mode 100644 index 0000000..83910ce --- /dev/null +++ b/.taskmaster/tasks/task_153.md @@ -0,0 +1,24 @@ +# Task ID: 153 + +**Title:** CLI tasks accept and reject commands + +**Status:** pending + +**Dependencies:** 131, 149 + +**Priority:** medium + +**Description:** Add mcpctl tasks accept and reject commands for human approval gate + +**Details:** + +In src/cli/src/commands/ add or extend: +1. mcpctl tasks accept NAME [--note TEXT] - call POST /tasks/:id/accept, triggers merge +2. mcpctl tasks reject NAME --note TEXT - call POST /tasks/:id/reject, note required +3. Resolve task name to id via API +4. Print result: merge URL on success, error message on failure +5. Accept should not require going to git - the CLI is the approval surface + +**Test Strategy:** + +Unit tests: test accept sends correct API call, test reject requires note flag, test output formatting. diff --git a/.taskmaster/tasks/task_154.md b/.taskmaster/tasks/task_154.md new file mode 100644 index 0000000..b94349a --- /dev/null +++ b/.taskmaster/tasks/task_154.md @@ -0,0 +1,26 @@ +# Task ID: 154 + +**Title:** Documentation for new resources + +**Status:** pending + +**Dependencies:** 126, 128, 131, 133, 144, 152 + +**Priority:** low + +**Description:** Create docs for epics, tasks, repos, sessions and update README and CLAUDE.md + +**Details:** + +Create documentation files: +1. docs/epics.md - Epic/EpicTask concepts, CLI usage, API reference +2. docs/tasks.md - Task lifecycle, states, transitions, verdicts +3. docs/repos.md - CodeRepo configuration, credential setup +4. docs/sessions.md - AgentSession lifecycle, exec usage +5. docs/inference-tasks.md - document the rename from 'tasks' RBAC resource +6. Update README.md resources table with new resources +7. Update CLAUDE.md resource cheatsheet with new resources + +**Test Strategy:** + +Manual review: verify docs are accurate, CLI examples work, API endpoints match implementation. diff --git a/.taskmaster/tasks/task_155.md b/.taskmaster/tasks/task_155.md new file mode 100644 index 0000000..aff25f9 --- /dev/null +++ b/.taskmaster/tasks/task_155.md @@ -0,0 +1,25 @@ +# Task ID: 155 + +**Title:** Smoke tests for Epic/EpicTask/Task CRUD + +**Status:** pending + +**Dependencies:** 126, 128, 131 + +**Priority:** medium + +**Description:** Create smoke tests for vision layer resources against live mcpd + +**Details:** + +Create src/mcplocal/tests/smoke/epic.smoke.test.ts: +1. Follow personality.smoke.test.ts pattern: Date.now().toString(36) name suffix, healthz() preflight that skips when daemon unreachable, mcpctl --direct shelling, explicit afterAll cleanup +2. Test Epic create/list/describe/delete +3. Test EpicTask create with edge dependencies +4. Test Task create linked to EpicTask +5. Verify cascade: delete Epic removes EpicTasks and Tasks +6. When test fails, fix environment not assertion + +**Test Strategy:** + +Run against live mcpd instance. Tests self-document expected behavior. diff --git a/.taskmaster/tasks/task_156.md b/.taskmaster/tasks/task_156.md new file mode 100644 index 0000000..62a3696 --- /dev/null +++ b/.taskmaster/tasks/task_156.md @@ -0,0 +1,23 @@ +# Task ID: 156 + +**Title:** Smoke tests for CodeRepo and Session + +**Status:** pending + +**Dependencies:** 133, 144 + +**Priority:** medium + +**Description:** Create smoke tests for repo and session resources with credential resolution + +**Details:** + +Create src/mcplocal/tests/smoke/repo.smoke.test.ts and session.smoke.test.ts: +1. repo tests: create CodeRepo with credential reference, list, describe, delete +2. session tests: provision session pod, confirm repo cloned via exec, verify workspace mounted, verify pod reaped after expiry +3. Follow smoke test patterns: preflight skip, cleanup in afterAll +4. Session test requires real cluster access + +**Test Strategy:** + +Run against live mcpd with cluster access. Verify repo credentials resolve, session pod starts and has cloned repos. diff --git a/.taskmaster/tasks/task_157.md b/.taskmaster/tasks/task_157.md new file mode 100644 index 0000000..de17fa5 --- /dev/null +++ b/.taskmaster/tasks/task_157.md @@ -0,0 +1,26 @@ +# Task ID: 157 + +**Title:** Smoke test for task lifecycle transitions + +**Status:** pending + +**Dependencies:** 131, 149, 153 + +**Priority:** medium + +**Description:** Create smoke test driving a task through full lifecycle including rejected accept-gate bounce + +**Details:** + +Create src/mcplocal/tests/smoke/task-lifecycle.smoke.test.ts: +1. Create Epic → EpicTask → Task +2. Transition task: todo → in_progress → building → self_testing → qa_review → done → awaiting_review +3. Test reject: awaiting_review → in_progress +4. Advance again to awaiting_review +5. Test accept: verify merge called (may need mock git provider for smoke) +6. Verify TaskEvents recorded for all transitions +7. Follow smoke test patterns + +**Test Strategy:** + +Run against live mcpd. Verify each transition succeeds, events logged, reject bounces state correctly. diff --git a/.taskmaster/tasks/task_158.md b/.taskmaster/tasks/task_158.md new file mode 100644 index 0000000..daacb4f --- /dev/null +++ b/.taskmaster/tasks/task_158.md @@ -0,0 +1,25 @@ +# Task ID: 158 + +**Title:** Deferred: Tier-2 vision critic placeholder + +**Status:** pending + +**Dependencies:** 131, 149 + +**Priority:** low + +**Description:** Placeholder task for Tier-2 vision critic and Epic-comparison loop - blocked on design questions + +**Details:** + +DEFERRED - DO NOT IMPLEMENT YET + +Blocked on docs/agentic-teams.md §15 Q13: does this need render/screenshot infrastructure, or can a design-aware persona judge from a structured artifact? + +Concept: A review-only, non-executing design/product persona that judges the assembled result against the Epic in its native medium, producing reopen / new-task / exec outcomes. + +Expand this task when the blocking design question is answered. + +**Test Strategy:** + +N/A - placeholder only diff --git a/.taskmaster/tasks/task_159.md b/.taskmaster/tasks/task_159.md new file mode 100644 index 0000000..778e67d --- /dev/null +++ b/.taskmaster/tasks/task_159.md @@ -0,0 +1,27 @@ +# Task ID: 159 + +**Title:** Deferred: Collaboration modes placeholder + +**Status:** pending + +**Dependencies:** 144, 145 + +**Priority:** low + +**Description:** Placeholder task for async standup and exec joint sessions - blocked on design questions + +**Details:** + +DEFERRED - DO NOT IMPLEMENT YET + +Blocked on docs/agentic-teams.md §15 Q4 and Q5. + +Concepts: +- mcpctl standup --project NAME: on-demand async standup +- exec as a workflow rather than just a command + +Expand this task when the blocking design questions are answered. + +**Test Strategy:** + +N/A - placeholder only diff --git a/.taskmaster/tasks/task_160.md b/.taskmaster/tasks/task_160.md new file mode 100644 index 0000000..24bcd8c --- /dev/null +++ b/.taskmaster/tasks/task_160.md @@ -0,0 +1,29 @@ +# Task ID: 160 + +**Title:** Deferred: Confidence signal and escalation dashboard placeholder + +**Status:** pending + +**Dependencies:** 131, 150 + +**Priority:** low + +**Description:** Placeholder task for evidence-anchored confidence and human decision queue - blocked on design questions + +**Details:** + +DEFERRED - DO NOT IMPLEMENT YET + +Blocked on docs/agentic-teams.md §15 Q12: web / TUI / CLI? + +Concepts: +- Evidence-anchored confidence: retrieve K similar past tasks with outcomes, assess confidence +- Thresholds driving autonomy level +- Single cross-project human decision queue +- Retrieval quality is the crux - weak retrieval quietly poisons the model + +Expand this task when the blocking design question is answered. + +**Test Strategy:** + +N/A - placeholder only diff --git a/.taskmaster/tasks/task_161.md b/.taskmaster/tasks/task_161.md new file mode 100644 index 0000000..c54e0bd --- /dev/null +++ b/.taskmaster/tasks/task_161.md @@ -0,0 +1,27 @@ +# Task ID: 161 + +**Title:** Deferred: Per-agent memory and coverage ledger placeholder + +**Status:** pending + +**Dependencies:** 147, 150 + +**Priority:** low + +**Description:** Placeholder task for agent memory and commit-pinned coverage ledger - blocked on design questions + +**Details:** + +DEFERRED - DO NOT IMPLEMENT YET + +Concepts: +- Global per-agent memory plus per-project knowledge +- Commit-pinned coverage ledger enabling delta-verification +- Non-negotiable: invalidation when covered region changes (or ledger becomes false confidence) +- Trust boundary: ledger is platform-verified machine fact; agent prose notes are opinion, never the gate + +Expand this task when prerequisite infrastructure exists. + +**Test Strategy:** + +N/A - placeholder only diff --git a/.taskmaster/tasks/task_162.md b/.taskmaster/tasks/task_162.md new file mode 100644 index 0000000..1b12a81 --- /dev/null +++ b/.taskmaster/tasks/task_162.md @@ -0,0 +1,28 @@ +# Task ID: 162 + +**Title:** Deferred: Script artifacts and baseline scorecard placeholder + +**Status:** pending + +**Dependencies:** 147 + +**Priority:** low + +**Description:** Placeholder task for reusable script artifacts and baseline rules scorecard - blocked on design questions + +**Details:** + +DEFERRED - DO NOT IMPLEMENT YET + +Blocked on docs/agentic-teams.md §15 Q15 and Q16. + +Concepts: +- Reusable executable script artifacts at task, project, and agent scope +- Provisioned baseline-rules scorecard +- Watch incentive integrity risk: reward reused tools, not tool proliferation + +Expand this task when the blocking design questions are answered. + +**Test Strategy:** + +N/A - placeholder only diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 7363208..131a4b6 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -6,10 +6,10 @@ _mcpctl() { _init_completion || return local commands="status login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" - local project_commands="get describe delete logs create edit attach-server detach-server favourites" + local project_commands="get describe delete logs create edit attach-server detach-server attach-agent detach-agent favourites" local global_opts="-v --version --daemon-url --direct -p --project -h --help" - local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all" - local resource_aliases="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp" + local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all" + local resource_aliases="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp membership team" # Check if --project/-p was given local has_project=false @@ -374,6 +374,31 @@ _mcpctl() { fi COMPREPLY=($(compgen -W "$names" -- "$cur")) return ;; + attach-agent) + if [[ $((cword - subcmd_pos)) -ne 1 ]]; then + COMPREPLY=($(compgen -W "--role --llm -h --help" -- "$cur")) + return + fi + local proj names all_agents proj_agents + proj=$(_mcpctl_get_project_value) + if [[ -n "$proj" ]]; then + all_agents=$(mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + proj_agents=$(mcpctl --project "$proj" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + names=$(comm -23 <(echo "$all_agents" | sort) <(echo "$proj_agents" | sort)) + else + names=$(_mcpctl_resource_names "agents") + fi + COMPREPLY=($(compgen -W "$names" -- "$cur")) + return ;; + detach-agent) + if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi + local proj names + proj=$(_mcpctl_get_project_value) + if [[ -n "$proj" ]]; then + names=$(mcpctl --project "$proj" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + fi + COMPREPLY=($(compgen -W "$names" -- "$cur")) + return ;; approve) if [[ -z "$resource_type" ]]; then COMPREPLY=($(compgen -W "promptrequest -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 68e7356..9e5d169 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -5,7 +5,7 @@ complete -c mcpctl -e set -l commands status login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate -set -l project_commands get describe delete logs create edit attach-server detach-server favourites +set -l project_commands get describe delete logs create edit attach-server detach-server attach-agent detach-agent favourites # Disable file completions by default complete -c mcpctl -f @@ -31,10 +31,10 @@ function __mcpctl_has_project end # Resource type detection -set -l resources servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all +set -l resources servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all function __mcpctl_needs_resource_type - set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp + set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp membership team set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -78,13 +78,14 @@ function __mcpctl_resolve_resource case task tasks; echo tasks case repo repos repository repositories; echo repos case gitprovider gitproviders forge gp; echo gitproviders + case membership memberships team; echo memberships case all; echo all case '*'; echo $argv[1] end end function __mcpctl_get_resource_type - set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp + set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp membership team set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -156,6 +157,31 @@ function __mcpctl_available_servers end end +# Agents already on the project team (for detach-agent) +function __mcpctl_project_agents + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + return + end + mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null +end + +# Agents NOT yet on the project team (for attach-agent) +function __mcpctl_available_agents + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null + return + end + set -l all (mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + set -l joined (mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + for a in $all + if not contains -- $a $joined + echo $a + end + end +end + # Instance names for logs function __mcpctl_instance_names mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null @@ -202,6 +228,26 @@ function __mcpctl_needs_server_arg return 1 end +# Helper: check if attach-agent/detach-agent already has an agent argument +function __mcpctl_needs_agent_arg + set -l tokens (commandline -opc) + set -l found_cmd false + for tok in $tokens + if $found_cmd + if not string match -q -- '-*' $tok + return 1 # agent arg already present + end + end + if contains -- $tok attach-agent detach-agent + set found_cmd true + end + end + if $found_cmd + return 0 + end + return 1 +end + # Helper: check if a specific parent-child subcommand pair is active function __mcpctl_subcmd_active set -l parent $argv[1] @@ -263,6 +309,8 @@ complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to a project (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from a project (requires --project)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-agent -d 'Add an agent to a project team (requires --project)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-agent -d 'Remove an agent from a project team (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a favourites -d 'Inspect / derive a project\'s favourite-index tool shortlist (requires --project)' # Resource types — only when resource type not yet selected @@ -671,7 +719,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Path complete -c mcpctl -n "__fish_seen_subcommand_from apply" -l dry-run -d 'Validate and show changes without applying' # chat options -complete -c mcpctl -n "__fish_seen_subcommand_from chat" -s p -l project -d 'Chat scoped to a project: its prompts, tools, and (with --allow-secrets) secrets' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -s p -l project -d 'With : view that project (agent must be a member). Alone: chat with the project itself' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l allow-secrets -d 'Project chat: let the model read secret values via the get_secret tool (needs view:secrets)' complete -c mcpctl -n "__fish_seen_subcommand_from chat" -s m -l message -d 'One-shot: send a single message and exit (no REPL)' -x complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l thread -d 'Resume an existing thread' -x @@ -700,6 +748,10 @@ complete -c mcpctl -n "__fish_seen_subcommand_from chat-llm" -l async -d 'Enqueu # errors options complete -c mcpctl -n "__fish_seen_subcommand_from errors" -s n -l limit -d 'max entries to show (default 50)' -x +# attach-agent options +complete -c mcpctl -n "__fish_seen_subcommand_from attach-agent" -l role -d 'Persona on this team: developer|qa|security|sre' -x +complete -c mcpctl -n "__fish_seen_subcommand_from attach-agent" -l llm -d 'Per-membership brain override (wins over the agent\'s pinned Llm in this project)' -x + # console options complete -c mcpctl -n "__fish_seen_subcommand_from console" -l stdin-mcp -d 'Run inspector as MCP server over stdin/stdout (for Claude)' complete -c mcpctl -n "__fish_seen_subcommand_from console" -l audit -d 'Browse audit events from mcpd' @@ -716,6 +768,12 @@ complete -c mcpctl -n "__fish_seen_subcommand_from attach-server; and __mcpctl_n # detach-server: show servers IN the project (only if no server arg yet) complete -c mcpctl -n "__fish_seen_subcommand_from detach-server; and __mcpctl_needs_server_arg" -a '(__mcpctl_project_servers)' -d 'Server' +# attach-agent: show agents NOT yet on the team (only if no agent arg yet) +complete -c mcpctl -n "__fish_seen_subcommand_from attach-agent; and __mcpctl_needs_agent_arg" -a '(__mcpctl_available_agents)' -d 'Agent' + +# detach-agent: show agents ON the team (only if no agent arg yet) +complete -c mcpctl -n "__fish_seen_subcommand_from detach-agent; and __mcpctl_needs_agent_arg" -a '(__mcpctl_project_agents)' -d 'Agent' + # apply: allow file completions for positional argument complete -c mcpctl -n "__fish_seen_subcommand_from apply" -F diff --git a/docs/agents.md b/docs/agents.md index eb1a3c8..c29e742 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -80,7 +80,11 @@ POST /api/v1/agents/:name/chat chat — non-streaming or SSE (run:agents POST /api/v1/agents/:name/threads create thread (run:agents:) GET /api/v1/agents/:name/threads list threads (run:agents:) GET /api/v1/threads/:id/messages replay history (view:agents) -GET /api/v1/projects/:p/agents project-scoped list (view:projects:

) +GET /api/v1/projects/:p/agents project roster — members + legacy pins (view:projects:

) +GET /api/v1/agents/:name/projects memberships (view:agents:) +POST /api/v1/agents/:name/projects/:p join, body {role?, llm?} (edit:agents:) +DELETE /api/v1/agents/:name/projects/:p leave (edit:agents:) +GET /api/v1/projects/:p/memberships roster with roles (view:projects:

) ``` The chat endpoint reuses the SSE pattern from `llm-infer.ts` exactly: same @@ -93,6 +97,37 @@ framing, same `[DONE]` terminator. SSE chunk types: - `{type:'final', threadId, turnIndex}` — terminal turn - `{type:'error', message}` — fatal error in the loop +## Team membership (an agent joins many projects) + +An agent is a **person**, not a project's property: it joins zero, one, or many +projects (`docs/agentic-teams.md` §4). Membership lives in the `AgentProject` +junction and carries two per-project facts the `Agent` row cannot: + +- **`role`** — the persona worn on this team. v1: `developer` (default), `qa`, + `security`, `sre`. TEXT in the schema, validated in the service layer, so + adding a role later is not a migration. +- **`llm`** — an optional **brain override**. When set, that Llm answers for + this project instead of the agent's pinned one; the agent keeps its own brain + everywhere else. + +```bash +mcpctl attach-agent alice --project sre --role sre --llm qwen3-thinking +mcpctl attach-agent alice --project payments --role security +mcpctl get memberships alice # every team alice is on +mcpctl get memberships --project sre # the sre roster, with roles +mcpctl get agents --project sre # the same roster as plain agent rows +mcpctl detach-agent alice --project sre +``` + +Joining is idempotent — re-running `attach-agent` updates the role/brain +instead of failing, so repeated applies converge. + +**Transition:** the legacy single `Agent.projectId` pin still works this +release. `mcpctl chat --project

` accepts a membership *or* a legacy +pin naming that same project; anything else is an error rather than a silent +answer from the wrong project's context. `GET /projects/:p/agents` returns the +union of both, so nothing disappears from a project mid-migration. + ## Tool-use loop When the agent's project has MCP servers attached, mcpd's `ChatService` lists @@ -113,10 +148,15 @@ Recommended: - `view:agents` — list / describe - `create:agents` / `edit:agents` / `delete:agents` — CRUD - `run:agents:` — drive a chat turn or manage its threads +- `edit:agents:` — join/leave a project team (NOT `create:agents`, which + mints whole agents; this mirrors project↔server attach/detach being `edit`) Project-attached agents do not implicitly inherit project RBAC. If a project member should be able to chat with the project's agents, grant them -`run:agents:` (or wildcard `run:agents`) explicitly. +`run:agents:` (or wildcard `run:agents`) explicitly. The reverse also +holds: membership grants the *agent* project context, not the *caller* project +permissions — `run:agents:` is still the only check on +`mcpctl chat --project

`. ## YAML round-trip diff --git a/docs/chat.md b/docs/chat.md index 1ece80b..8280fb2 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -8,6 +8,7 @@ create one. ```bash mcpctl chat # interactive REPL, new thread +mcpctl chat --project

# same agent, viewing project p mcpctl chat --thread # interactive REPL, resume thread mcpctl chat -m "hi" # one-shot, prints reply, no REPL mcpctl chat -m "hi" --no-stream # one-shot, single JSON response (no SSE) @@ -17,6 +18,35 @@ Streaming is on by default. Text deltas land on stdout as they arrive; tool calls and tool results print to stderr in dim brackets so the chat output stays clean. +## Two lenses on the same agent + +`mcpctl chat alice` is the person **on their own** — their system prompt, their +own pinned brain, no project tools. + +`mcpctl chat alice --project sre` is the **same person viewing one of their +teams**: the project's Prompts join the system block, the project's MCP servers +become callable tools, and if that membership carries a brain override, that +Llm answers instead of alice's pinned one. + +```bash +mcpctl attach-agent alice --project sre --role sre # join first +mcpctl chat alice --project sre +``` + +The agent must actually be a member. Naming a project it has not joined is an +error naming the fix, not a silent fallback to some other project's context: + +``` +Agent 'alice' is not a member of project 'payments'. +Join it with: mcpctl attach-agent alice --project payments +``` + +(The legacy single-project pin on the agent still counts as membership in that +one project for this release — see [agents.md](agents.md#team-membership-an-agent-joins-many-projects).) + +Threads stay scoped to the **agent**, not to (agent, project) — switching +`--project` mid-conversation changes the context layer, not the history. + ## Project chat Chat directly with a **project** — no Agent needed. The session sees the diff --git a/scripts/generate-completions.ts b/scripts/generate-completions.ts index a61c7d3..21c268b 100644 --- a/scripts/generate-completions.ts +++ b/scripts/generate-completions.ts @@ -29,6 +29,8 @@ type CompletionHint = | 'instance_names' | 'available_servers' | 'project_servers' + | 'available_agents' + | 'project_agents' | 'file' | { static: string[] }; @@ -53,6 +55,8 @@ const COMPLETION_HINTS: Record = { 'logs.name': 'instance_names', 'attach-server.server-name': 'available_servers', 'detach-server.server-name': 'project_servers', + 'attach-agent.agent-name': 'available_agents', + 'detach-agent.agent-name': 'project_agents', 'apply.file': 'file', }; @@ -66,7 +70,9 @@ const FILE_OPTIONS = new Set([ ]); /** Commands shown ONLY when --project/-p is on the command line. */ -const PROJECT_ONLY_COMMANDS = new Set(['attach-server', 'detach-server', 'favourites']); +const PROJECT_ONLY_COMMANDS = new Set([ + 'attach-server', 'detach-server', 'attach-agent', 'detach-agent', 'favourites', +]); /** Commands that appear in BOTH project and non-project context. */ const PROJECT_SCOPED_COMMANDS = new Set([ @@ -188,7 +194,7 @@ const CANONICAL_RESOURCES = [ 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'serverattachments', 'proxymodels', 'inference-tasks', // Agentic teams - 'epics', 'epictasks', 'tasks', 'repos', 'gitproviders', 'all', + 'epics', 'epictasks', 'tasks', 'repos', 'gitproviders', 'memberships', 'all', ]; const ALIAS_ENTRIES: [string, string][] = [ @@ -220,6 +226,7 @@ const ALIAS_ENTRIES: [string, string][] = [ ['repo', 'repos'], ['repos', 'repos'], ['repository', 'repos'], ['repositories', 'repos'], ['gitprovider', 'gitproviders'], ['gitproviders', 'gitproviders'], ['forge', 'gitproviders'], ['gp', 'gitproviders'], + ['membership', 'memberships'], ['memberships', 'memberships'], ['team', 'memberships'], ['all', 'all'], ]; @@ -452,6 +459,14 @@ function generateFish(root: CmdInfo): string { emit("complete -c mcpctl -n \"__fish_seen_subcommand_from detach-server; and __mcpctl_needs_server_arg\" -a '(__mcpctl_project_servers)' -d 'Server'"); emit(''); + // attach-agent / detach-agent — the AgentProject team roster + emit("# attach-agent: show agents NOT yet on the team (only if no agent arg yet)"); + emit("complete -c mcpctl -n \"__fish_seen_subcommand_from attach-agent; and __mcpctl_needs_agent_arg\" -a '(__mcpctl_available_agents)' -d 'Agent'"); + emit(''); + emit("# detach-agent: show agents ON the team (only if no agent arg yet)"); + emit("complete -c mcpctl -n \"__fish_seen_subcommand_from detach-agent; and __mcpctl_needs_agent_arg\" -a '(__mcpctl_project_agents)' -d 'Agent'"); + emit(''); + // apply: allow file completions for positional argument emit("# apply: allow file completions for positional argument"); emit("complete -c mcpctl -n \"__fish_seen_subcommand_from apply\" -F"); @@ -603,6 +618,33 @@ function __mcpctl_available_servers end end end +`); + + emit(`# Agents already on the project team (for detach-agent) +function __mcpctl_project_agents + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + return + end + mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null +end +`); + + emit(`# Agents NOT yet on the project team (for attach-agent) +function __mcpctl_available_agents + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null + return + end + set -l all (mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + set -l joined (mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + for a in $all + if not contains -- $a $joined + echo $a + end + end +end `); emit(`# Instance names for logs @@ -652,6 +694,27 @@ function __mcpctl_needs_server_arg end return 1 end +`); + + emit(`# Helper: check if attach-agent/detach-agent already has an agent argument +function __mcpctl_needs_agent_arg + set -l tokens (commandline -opc) + set -l found_cmd false + for tok in $tokens + if $found_cmd + if not string match -q -- '-*' $tok + return 1 # agent arg already present + end + end + if contains -- $tok attach-agent detach-agent + set found_cmd true + end + end + if $found_cmd + return 0 + end + return 1 +end `); emit(`# Helper: check if a specific parent-child subcommand pair is active @@ -994,6 +1057,41 @@ function emitBashCase(emit: (s: string) => void, cmd: CmdInfo, root: CmdInfo): v return; } + // attach-agent — the team roster, minus whoever already joined + if (name === 'attach-agent') { + emit(' attach-agent)'); + emit(' if [[ $((cword - subcmd_pos)) -ne 1 ]]; then'); + emit(` COMPREPLY=($(compgen -W "${optFlags}" -- "$cur"))`); + emit(' return'); + emit(' fi'); + emit(' local proj names all_agents proj_agents'); + emit(' proj=$(_mcpctl_get_project_value)'); + emit(' if [[ -n "$proj" ]]; then'); + emit(" all_agents=$(mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)"); + emit(" proj_agents=$(mcpctl --project \"$proj\" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)"); + emit(' names=$(comm -23 <(echo "$all_agents" | sort) <(echo "$proj_agents" | sort))'); + emit(' else'); + emit(' names=$(_mcpctl_resource_names "agents")'); + emit(' fi'); + emit(' COMPREPLY=($(compgen -W "$names" -- "$cur"))'); + emit(' return ;;'); + return; + } + + // detach-agent — only agents already on the team + if (name === 'detach-agent') { + emit(' detach-agent)'); + emit(' if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi'); + emit(' local proj names'); + emit(' proj=$(_mcpctl_get_project_value)'); + emit(' if [[ -n "$proj" ]]; then'); + emit(" names=$(mcpctl --project \"$proj\" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)"); + emit(' fi'); + emit(' COMPREPLY=($(compgen -W "$names" -- "$cur"))'); + emit(' return ;;'); + return; + } + // apply: file completions if (name === 'apply') { emit(' apply)'); diff --git a/src/cli/src/commands/chat.ts b/src/cli/src/commands/chat.ts index 628642b..8a4307a 100644 --- a/src/cli/src/commands/chat.ts +++ b/src/cli/src/commands/chat.ts @@ -9,8 +9,10 @@ * * Modes: * mcpctl chat # REPL, new thread + * mcpctl chat --project

# same person, viewing project p * mcpctl chat --thread # REPL, resume thread * mcpctl chat -m "hi" # one-shot, prints reply, no REPL + * mcpctl chat --project

# the project itself, no agent * * Slash-commands inside the REPL: * /set KEY VALUE # adjust an override (temperature 0.2) @@ -40,7 +42,7 @@ export function createChatCommand(deps: ChatCommandDeps): Command { return new Command('chat') .description('Open an interactive chat session with an agent or a project (REPL or one-shot).') .argument('[agent]', 'Agent name (see `mcpctl get agents`). Omit and use --project to chat with a project.') - .option('-p, --project ', 'Chat scoped to a project: its prompts, tools, and (with --allow-secrets) secrets') + .option('-p, --project ', 'With : view that project (agent must be a member). Alone: chat with the project itself') .option('--allow-secrets', 'Project chat: let the model read secret values via the get_secret tool (needs view:secrets)') .option('-m, --message ', 'One-shot: send a single message and exit (no REPL)') .option('--thread ', 'Resume an existing thread') @@ -77,18 +79,30 @@ interface ChatSubject { path: string; /** Project chat only: opt into the gated get_secret tool. */ allowSecrets: boolean; + /** + * Agent chat only: the project this turn is viewed through. The same person, + * looking at one of the teams they joined — mcpd requires an AgentProject + * membership and 404s with a join hint when there isn't one. + */ + project?: string; } function resolveSubject(agent: string | undefined, opts: ChatOpts): ChatSubject { - if (opts.project !== undefined && agent !== undefined) { - throw new Error('Pass either an or --project , not both.'); + if (agent !== undefined) { + if (opts.allowSecrets === true) { + throw new Error('--allow-secrets is project-chat only (drop the argument).'); + } + return { + kind: 'agent', + name: agent, + path: `agents/${encodeURIComponent(agent)}`, + allowSecrets: false, + ...(opts.project !== undefined ? { project: opts.project } : {}), + }; } if (opts.project !== undefined) { return { kind: 'project', name: opts.project, path: `projects/${encodeURIComponent(opts.project)}`, allowSecrets: opts.allowSecrets === true }; } - if (agent !== undefined) { - return { kind: 'agent', name: agent, path: `agents/${encodeURIComponent(agent)}`, allowSecrets: false }; - } throw new Error('Specify an name or --project . See `mcpctl chat --help`.'); } @@ -102,6 +116,10 @@ function chatBody(subject: ChatSubject, message: string, threadId: string | unde if (subject.kind === 'project') { delete o.personality; if (subject.allowSecrets) o.allowSecrets = true; + } else if (subject.project !== undefined) { + // Agent chat viewed through one of the agent's projects. The endpoint is + // still the agent's — only the context layer changes. + o.project = subject.project; } const body: Record = { message, ...o }; if (threadId !== undefined) body.threadId = threadId; @@ -372,6 +390,10 @@ async function handleSlash( let projectName: string | null; if (subject.kind === 'project') { projectName = subject.name; + } else if (subject.project !== undefined) { + // --project is the lens for this session; it beats the agent's + // legacy pin, exactly as it does server-side. + projectName = subject.project; } else { const a = await deps.client.get<{ project: { name: string } | null }>( `/api/v1/agents/${encodeURIComponent(subject.name)}`, @@ -880,7 +902,10 @@ async function printChatHeader( out(sep); out(`Agent: ${info.name}${info.description !== '' ? ` — ${info.description}` : ''}`); - const tail = info.project !== null ? ` Project: ${info.project.name}` : ''; + // --project is the lens for this session; without it the agent's legacy + // `project` pin (if any) is what mcpd will use. + const viewing = subject.project ?? info.project?.name; + const tail = viewing !== undefined ? ` Project: ${viewing}` : ''; out(`LLM: ${info.llm.name}${tail}`); // Personality overlay: explicit --personality wins; otherwise agent's @@ -903,7 +928,7 @@ async function printChatHeader( out(`System append (--system-append):`); out(indent(overrides.systemAppend)); } - if (info.project !== null) { + if (viewing !== undefined) { out(`(project prompts auto-appended at chat time; /tools lists MCP servers)`); } diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 401bf92..6156b69 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -234,6 +234,25 @@ const agentColumns: Column[] = [ { header: 'ID', key: 'id' }, ]; +/** One AgentProject row — an agent's seat on a project team. */ +interface MembershipRow { + id: string; + agent: { id: string; name: string }; + project: { id: string; name: string }; + role: string; + llm: { id: string; name: string } | null; + createdAt: string; +} + +const membershipColumns: Column[] = [ + { header: 'AGENT', key: (r) => r.agent.name, width: 24 }, + { header: 'PROJECT', key: (r) => r.project.name, width: 20 }, + { header: 'ROLE', key: 'role', width: 12 }, + // '-' means "no override" — the agent's own pinned Llm answers here. + { header: 'BRAIN', key: (r) => r.llm?.name ?? '-', width: 24 }, + { header: 'JOINED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 }, +]; + interface ThreadRow { id: string; title: string; lastTurnAt: string; createdAt: string } const threadColumns: Column[] = [ { header: 'ID', key: 'id' }, @@ -520,6 +539,8 @@ function getColumnsForResource(resource: string): Column return agentColumns as unknown as Column>[]; case 'personalities': return personalityColumns as unknown as Column>[]; + case 'memberships': + return membershipColumns as unknown as Column>[]; case 'threads': return threadColumns as unknown as Column>[]; case 'inference-tasks': diff --git a/src/cli/src/commands/project-ops.ts b/src/cli/src/commands/project-ops.ts index 706fbcb..1edafa9 100644 --- a/src/cli/src/commands/project-ops.ts +++ b/src/cli/src/commands/project-ops.ts @@ -46,6 +46,53 @@ export function createDetachServerCommand(deps: ProjectOpsDeps): Command { }); } +/** + * v1 persona roles. Mirrors MEMBERSHIP_ROLES in + * src/mcpd/src/validation/agent-project.schema.ts — mcpd is the authority, this + * list only exists so `--help` names the options and a typo fails locally. + */ +const MEMBERSHIP_ROLES = ['developer', 'qa', 'security', 'sre'] as const; + +export function createAttachAgentCommand(deps: ProjectOpsDeps): Command { + const { client, log } = deps; + + return new Command('attach-agent') + .description('Add an agent to a project team (requires --project)') + .argument('', 'Agent name to add') + .option('--role ', `Persona on this team: ${MEMBERSHIP_ROLES.join('|')}`) + .option('--llm ', 'Per-membership brain override (wins over the agent\'s pinned Llm in this project)') + .action(async (agentName: string, opts: { role?: string; llm?: string }) => { + const projectName = requireProject(deps); + if (opts.role !== undefined && !MEMBERSHIP_ROLES.includes(opts.role as typeof MEMBERSHIP_ROLES[number])) { + throw new Error(`--role must be one of: ${MEMBERSHIP_ROLES.join(', ')}`); + } + const membership = await client.post<{ role: string; llm: { name: string } | null }>( + `/api/v1/agents/${encodeURIComponent(agentName)}/projects/${encodeURIComponent(projectName)}`, + { + ...(opts.role !== undefined ? { role: opts.role } : {}), + ...(opts.llm !== undefined ? { llm: opts.llm } : {}), + }, + ); + const brain = membership.llm !== null ? `, brain ${membership.llm.name}` : ''; + log(`agent '${agentName}' joined project '${projectName}' as ${membership.role}${brain}`); + }); +} + +export function createDetachAgentCommand(deps: ProjectOpsDeps): Command { + const { client, log } = deps; + + return new Command('detach-agent') + .description('Remove an agent from a project team (requires --project)') + .argument('', 'Agent name to remove') + .action(async (agentName: string) => { + const projectName = requireProject(deps); + await client.delete( + `/api/v1/agents/${encodeURIComponent(agentName)}/projects/${encodeURIComponent(projectName)}`, + ); + log(`agent '${agentName}' left project '${projectName}'`); + }); +} + export function createApproveCommand(deps: ProjectOpsDeps): Command { const { client, log } = deps; diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index 6ac08e7..081aba0 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -69,6 +69,11 @@ export const RESOURCE_ALIASES: Record = { agents: 'agents', personality: 'personalities', personalities: 'personalities', + // Agent ↔ Project team membership (the AgentProject junction). Read through + // one side or the other: `--project

` for the roster, or an agent name. + membership: 'memberships', + memberships: 'memberships', + team: 'memberships', thread: 'threads', threads: 'threads', // v5 shipped the durable inference queue under the short `task`/`tasks` diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index a5c3da7..70ae7f1 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -15,7 +15,13 @@ import { createCreateCommand } from './commands/create.js'; import { createEditCommand } from './commands/edit.js'; import { createBackupCommand } from './commands/backup.js'; import { createLoginCommand, createLogoutCommand } from './commands/auth.js'; -import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js'; +import { + createAttachServerCommand, + createDetachServerCommand, + createAttachAgentCommand, + createDetachAgentCommand, + createApproveCommand, +} from './commands/project-ops.js'; import { createFavouritesCommand } from './commands/favourites.js'; import { createMcpCommand } from './commands/mcp.js'; import { createPatchCommand } from './commands/patch.js'; @@ -135,6 +141,25 @@ export function createProgram(): Command { return client.get(`/api/v1/${resource}?project=${encodeURIComponent(projectName)}`); } + // `get agents --project X` — the team: members plus any legacy-pinned + // agents. Same shape as the unscoped list, so the columns still line up. + if (nameOrId === undefined && resource === 'agents' && projectName !== undefined && projectName !== '') { + return client.get(`/api/v1/projects/${encodeURIComponent(projectName)}/agents`); + } + + // Memberships are a junction, not a resource with its own name — they are + // always read through one side. `--project` gives the roster with roles; + // otherwise name the agent (`get memberships `). + if (resource === 'memberships') { + if (projectName !== undefined && projectName !== '') { + return client.get(`/api/v1/projects/${encodeURIComponent(projectName)}/memberships`); + } + if (nameOrId === undefined) { + throw new Error('memberships are scoped — pass --project or an agent name (`mcpctl get memberships `)'); + } + return client.get(`/api/v1/agents/${encodeURIComponent(nameOrId)}/projects`); + } + // --project scoping for mcptokens if (!nameOrId && resource === 'mcptokens' && projectName) { return client.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`); @@ -306,6 +331,9 @@ export function createProgram(): Command { }; program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true }); program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true }); + // Agentic teams: agents join projects the same way servers attach to them. + program.addCommand(createAttachAgentCommand(projectOpsDeps), { hidden: true }); + program.addCommand(createDetachAgentCommand(projectOpsDeps), { hidden: true }); program.addCommand(createApproveCommand(projectOpsDeps)); program.addCommand(createFavouritesCommand(projectOpsDeps)); // PR-4: reviewer queue for proposed prompts + skills. diff --git a/src/cli/tests/commands/agent-membership.test.ts b/src/cli/tests/commands/agent-membership.test.ts new file mode 100644 index 0000000..d6c45a7 --- /dev/null +++ b/src/cli/tests/commands/agent-membership.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { ApiClient } from '../../src/api-client.js'; +import { createGetCommand } from '../../src/commands/get.js'; +import { + createAttachAgentCommand, + createDetachAgentCommand, +} from '../../src/commands/project-ops.js'; + +function mockClient(overrides: Partial = {}): ApiClient { + return { + get: vi.fn(async () => []), + post: vi.fn(async () => ({ role: 'developer', llm: null })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + ...overrides, + } as unknown as ApiClient; +} + +describe('mcpctl attach-agent / detach-agent', () => { + let client: ApiClient; + let output: string[]; + const log = (...args: string[]) => output.push(args.join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + }); + + it('joins an agent to the --project team', async () => { + const cmd = createAttachAgentCommand({ client, log, getProject: () => 'sre' }); + await cmd.parseAsync(['alice'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/agents/alice/projects/sre', {}); + expect(output.join('\n')).toContain("agent 'alice' joined project 'sre' as developer"); + }); + + it('passes --role and --llm through', async () => { + client = mockClient({ + post: vi.fn(async () => ({ role: 'qa', llm: { id: 'llm-1', name: 'qwen3-thinking' } })) as ApiClient['post'], + }); + const cmd = createAttachAgentCommand({ client, log, getProject: () => 'sre' }); + await cmd.parseAsync(['alice', '--role', 'qa', '--llm', 'qwen3-thinking'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/agents/alice/projects/sre', { + role: 'qa', + llm: 'qwen3-thinking', + }); + expect(output.join('\n')).toContain('brain qwen3-thinking'); + }); + + it('rejects a role outside the v1 set before hitting the network', async () => { + const cmd = createAttachAgentCommand({ client, log, getProject: () => 'sre' }); + await expect( + cmd.parseAsync(['alice', '--role', 'chief-vibes-officer'], { from: 'user' }), + ).rejects.toThrow(/--role must be one of/); + expect(client.post).not.toHaveBeenCalled(); + }); + + it('requires --project', async () => { + const cmd = createAttachAgentCommand({ client, log, getProject: () => undefined }); + await expect(cmd.parseAsync(['alice'], { from: 'user' })).rejects.toThrow(/--project required/); + }); + + it('url-encodes names', async () => { + const cmd = createAttachAgentCommand({ client, log, getProject: () => 'my team' }); + await cmd.parseAsync(['a/b'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/agents/a%2Fb/projects/my%20team', {}); + }); + + it('detach-agent deletes the membership', async () => { + const cmd = createDetachAgentCommand({ client, log, getProject: () => 'sre' }); + await cmd.parseAsync(['alice'], { from: 'user' }); + + expect(client.delete).toHaveBeenCalledWith('/api/v1/agents/alice/projects/sre'); + expect(output.join('\n')).toContain("agent 'alice' left project 'sre'"); + }); +}); + +describe('mcpctl get memberships', () => { + it('renders the roster with role and brain columns', async () => { + const out: string[] = []; + const cmd = createGetCommand({ + fetchResource: vi.fn(async () => [ + { + id: 'ap-1', + agent: { id: 'agent-alice', name: 'alice' }, + project: { id: 'proj-sre', name: 'sre' }, + role: 'sre', + llm: { id: 'llm-1', name: 'qwen3-thinking' }, + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'ap-2', + agent: { id: 'agent-bob', name: 'bob' }, + project: { id: 'proj-sre', name: 'sre' }, + role: 'qa', + llm: null, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ]), + log: (...args: string[]) => out.push(args.join(' ')), + }); + await cmd.parseAsync(['node', 'test', 'memberships', '--project', 'sre']); + + const text = out.join('\n'); + expect(text).toContain('ROLE'); + expect(text).toContain('BRAIN'); + expect(text).toContain('qwen3-thinking'); + // No override reads as '-', not as an empty cell. + expect(text).toMatch(/bob\s+sre\s+qa\s+-/); + }); + + it('accepts the `team` alias', async () => { + const fetchResource = vi.fn(async () => []); + const out: string[] = []; + const cmd = createGetCommand({ fetchResource, log: (...a: string[]) => out.push(a.join(' ')) }); + await cmd.parseAsync(['node', 'test', 'team', '--project', 'sre']); + expect(fetchResource).toHaveBeenCalledWith('memberships', undefined, { project: 'sre' }); + }); +}); diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index dc0152f..a9aa8d7 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -36,8 +36,10 @@ import { LlmService } from './services/llm.service.js'; import { InferenceTaskRepository } from './repositories/inference-task.repository.js'; import { InferenceTaskService } from './services/inference-task.service.js'; import { AgentRepository } from './repositories/agent.repository.js'; +import { AgentProjectRepository } from './repositories/agent-project.repository.js'; import { ChatRepository } from './repositories/chat.repository.js'; import { AgentService } from './services/agent.service.js'; +import { AgentProjectService } from './services/agent-project.service.js'; import { ChatService } from './services/chat.service.js'; import { ChatToolDispatcherImpl } from './services/chat-tool-dispatcher.js'; import { LlmAdapterRegistry } from './services/llm/dispatcher.js'; @@ -47,6 +49,7 @@ import { registerVirtualLlmRoutes } from './routes/virtual-llms.js'; import { registerInferenceTaskRoutes } from './routes/inference-tasks.js'; import { VirtualLlmService } from './services/virtual-llm.service.js'; import { registerAgentRoutes } from './routes/agents.js'; +import { registerAgentProjectRoutes } from './routes/agent-projects.js'; import { registerAgentChatRoutes } from './routes/agent-chat.js'; import { registerProjectChatRoutes } from './routes/project-chat.js'; import { PromptRepository } from './repositories/prompt.repository.js'; @@ -408,7 +411,16 @@ async function main(): Promise { ); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); - const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); + // Agentic teams: an agent is a person who joins projects. The junction repo + // goes into AgentService so both the project roster and the chat path read + // memberships, not just the legacy `Agent.projectId` pin. + const agentProjectRepo = new AgentProjectRepository(prisma); + const agentService = new AgentService( + agentRepo, llmService, projectService, personalityRepo, agentProjectRepo, + ); + const agentProjectService = new AgentProjectService( + agentProjectRepo, agentService, projectService, llmService, + ); // v5: durable inference task queue. VirtualLlmService persists infer // tasks here; the result-route updates them; an in-process emitter // wakes blocked HTTP handlers when results land. @@ -547,6 +559,7 @@ async function main(): Promise { registerSecretMigrateRoutes(app, secretMigrateService); registerLlmRoutes(app, llmService); registerAgentRoutes(app, agentService); + registerAgentProjectRoutes(app, agentProjectService); registerPersonalityRoutes(app, personalityService); // ChatService needs an `app.log`-aware tool dispatcher. const chatToolDispatcher = new ChatToolDispatcherImpl({ diff --git a/src/mcpd/src/middleware/url-permission.ts b/src/mcpd/src/middleware/url-permission.ts index e7b07ae..dcd684e 100644 --- a/src/mcpd/src/middleware/url-permission.ts +++ b/src/mcpd/src/middleware/url-permission.ts @@ -109,6 +109,22 @@ export function mapUrlToPermission(method: string, url: string): PermissionCheck return { kind: 'resource', resource: 'epictasks', action: 'edit', resourceName: edgeMatch[1] }; } + // /api/v1/agents/:name/projects[/:project] — team membership. + // Reading the roster is `view:agents:`; joining/leaving is + // `edit:agents:`, mirroring project↔server attach/detach (which is + // `edit:projects`, not `create`). Without this the default mapping would + // grade a join as `create:agents` — the permission for minting a whole new + // agent, which is far too broad for "add this person to a team". + const membershipAgentName = url.match(/^\/api\/v1\/agents\/([^/?]+)\/projects/)?.[1]; + if (membershipAgentName !== undefined) { + return { + kind: 'resource', + resource: 'agents', + action: method === 'GET' || method === 'HEAD' ? 'view' : 'edit', + resourceName: membershipAgentName, + }; + } + // /api/v1/threads/:id/messages → `view:agents` (we don't carry the agent // name in the URL; the service-level owner check enforces fine-grained // access on top). diff --git a/src/mcpd/src/repositories/agent-project.repository.ts b/src/mcpd/src/repositories/agent-project.repository.ts new file mode 100644 index 0000000..db56a59 --- /dev/null +++ b/src/mcpd/src/repositories/agent-project.repository.ts @@ -0,0 +1,81 @@ +import type { PrismaClient, AgentProject } from '@prisma/client'; + +/** + * AgentProject repository — the junction that makes an agent a *person who + * joins projects* rather than a thing owned by one. + * + * The row carries the two things that vary per project: the persona `role` + * (TEXT, validated in the service — production runs `prisma db push`, so a + * CHECK constraint here would never exist in prod) and an optional `llmId` + * brain override. + */ + +export interface AgentProjectCreateInput { + agentId: string; + projectId: string; + role?: string; + llmId?: string | null; +} + +export interface AgentProjectUpdateInput { + role?: string; + llmId?: string | null; +} + +export interface IAgentProjectRepository { + findByAgent(agentId: string): Promise; + findByProject(projectId: string): Promise; + find(agentId: string, projectId: string): Promise; + create(data: AgentProjectCreateInput): Promise; + update(id: string, data: AgentProjectUpdateInput): Promise; + delete(id: string): Promise; +} + +export class AgentProjectRepository implements IAgentProjectRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findByAgent(agentId: string): Promise { + return this.prisma.agentProject.findMany({ + where: { agentId }, + orderBy: { createdAt: 'asc' }, + }); + } + + async findByProject(projectId: string): Promise { + return this.prisma.agentProject.findMany({ + where: { projectId }, + orderBy: { createdAt: 'asc' }, + }); + } + + async find(agentId: string, projectId: string): Promise { + return this.prisma.agentProject.findUnique({ + where: { agentId_projectId: { agentId, projectId } }, + }); + } + + async create(data: AgentProjectCreateInput): Promise { + return this.prisma.agentProject.create({ + data: { + agentId: data.agentId, + projectId: data.projectId, + ...(data.role !== undefined ? { role: data.role } : {}), + ...(data.llmId !== undefined ? { llmId: data.llmId } : {}), + }, + }); + } + + async update(id: string, data: AgentProjectUpdateInput): Promise { + return this.prisma.agentProject.update({ + where: { id }, + data: { + ...(data.role !== undefined ? { role: data.role } : {}), + ...(data.llmId !== undefined ? { llmId: data.llmId } : {}), + }, + }); + } + + async delete(id: string): Promise { + await this.prisma.agentProject.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/routes/agent-chat.ts b/src/mcpd/src/routes/agent-chat.ts index 7dde512..39cdbb3 100644 --- a/src/mcpd/src/routes/agent-chat.ts +++ b/src/mcpd/src/routes/agent-chat.ts @@ -37,7 +37,7 @@ export function registerAgentChatRoutes( } const { - threadId, message, messages: messagesOverride, stream, personality, + threadId, message, messages: messagesOverride, stream, personality, project, ...paramsRest } = parsed; @@ -58,6 +58,7 @@ export function registerAgentChatRoutes( ? { messagesOverride: messagesOverride.map((m) => ({ role: m.role, content: m.content, ...(m.tool_call_id !== undefined ? { tool_call_id: m.tool_call_id } : {}) })) } : {}), ...(personality !== undefined ? { personalityName: personality } : {}), + ...(project !== undefined ? { projectName: project } : {}), params: paramsRest, }; diff --git a/src/mcpd/src/routes/agent-projects.ts b/src/mcpd/src/routes/agent-projects.ts new file mode 100644 index 0000000..32af661 --- /dev/null +++ b/src/mcpd/src/routes/agent-projects.ts @@ -0,0 +1,95 @@ +/** + * Agent ↔ Project membership HTTP surface. + * + * GET /api/v1/agents/:name/projects — projects this agent joined + * POST /api/v1/agents/:name/projects/:project — join (body: { role?, llm? }) + * DELETE /api/v1/agents/:name/projects/:project — leave + * GET /api/v1/projects/:name/memberships — the roster, with roles + * + * The reverse *agent list* stays at `GET /api/v1/projects/:name/agents` + * (routes/agents.ts) because mcplocal's agents plugin consumes that shape; + * it now returns members as well as legacy-pinned agents. This route adds the + * membership detail (role + brain override) that an `Agent` row cannot carry. + * + * RBAC comes from the global hook in `main.ts:mapUrlToPermission`: reading the + * roster is `view:agents:`, joining/leaving is `edit:agents:` — + * mirroring project↔server attach/detach, which is `edit:projects`, not + * `create`. + */ +import type { FastifyInstance } from 'fastify'; + +import type { AgentProjectService } from '../services/agent-project.service.js'; +import { NotFoundError } from '../services/mcp-server.service.js'; + +export function registerAgentProjectRoutes( + app: FastifyInstance, + service: AgentProjectService, +): void { + app.get<{ Params: { name: string } }>( + '/api/v1/agents/:name/projects', + async (request, reply) => { + try { + return await service.listForAgent(request.params.name); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.post<{ Params: { name: string; project: string } }>( + '/api/v1/agents/:name/projects/:project', + async (request, reply) => { + try { + const membership = await service.join( + request.params.name, + request.params.project, + request.body ?? {}, + ); + reply.code(201); + return membership; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.delete<{ Params: { name: string; project: string } }>( + '/api/v1/agents/:name/projects/:project', + async (request, reply) => { + try { + await service.leave(request.params.name, request.params.project); + reply.code(204); + return null; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.get<{ Params: { name: string } }>( + '/api/v1/projects/:name/memberships', + async (request, reply) => { + try { + return await service.listForProject(request.params.name); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); +} diff --git a/src/mcpd/src/services/agent-project.service.ts b/src/mcpd/src/services/agent-project.service.ts new file mode 100644 index 0000000..e80fc22 --- /dev/null +++ b/src/mcpd/src/services/agent-project.service.ts @@ -0,0 +1,132 @@ +/** + * AgentProjectService — team membership, the thing that turns an Agent from + * "a persona owned by one project" into "a person who joins zero, one, or many + * projects" (docs/agentic-teams.md §4). + * + * The junction row is where the two per-project facts hang: + * - `role` — the persona this agent wears on this team (developer/qa/…). + * - `llmId` — an optional brain override, so the open brain-ownership + * question (§15 Q1) can be answered per membership instead of + * globally. + * + * Invariants are enforced here, not in the database: production provisions with + * `prisma db push`, so migration-only CHECK constraints never exist in prod. + */ +import type { AgentProject } from '@prisma/client'; + +import type { IAgentProjectRepository } from '../repositories/agent-project.repository.js'; +import { + DEFAULT_MEMBERSHIP_ROLE, + JoinProjectSchema, + MEMBERSHIP_ROLES, +} from '../validation/agent-project.schema.js'; + +import type { AgentService } from './agent.service.js'; +import type { LlmService } from './llm.service.js'; +import type { ProjectService } from './project.service.js'; +import { NotFoundError } from './mcp-server.service.js'; + +/** One membership, name-resolved so callers never handle raw CUIDs. */ +export interface MembershipView { + id: string; + agent: { id: string; name: string }; + project: { id: string; name: string }; + role: string; + /** Per-membership brain override; null means "use the agent's pinned Llm". */ + llm: { id: string; name: string } | null; + createdAt: Date; +} + +export class AgentProjectService { + constructor( + private readonly repo: IAgentProjectRepository, + private readonly agents: AgentService, + private readonly projects: ProjectService, + private readonly llms: LlmService, + ) {} + + /** Every project this agent has joined. */ + async listForAgent(agentName: string): Promise { + const agent = await this.agents.getByName(agentName); + const rows = await this.repo.findByAgent(agent.id); + return Promise.all(rows.map((r) => this.toView(r))); + } + + /** Every agent that has joined this project — the team roster. */ + async listForProject(projectName: string): Promise { + const project = await this.projects.resolveAndGet(projectName); + const rows = await this.repo.findByProject(project.id); + return Promise.all(rows.map((r) => this.toView(r))); + } + + /** + * Join a project. Idempotent by design: re-joining an existing membership + * updates the role/brain rather than 409-ing, so `apply`-style repeat calls + * converge instead of failing. + */ + async join(agentName: string, projectName: string, input: unknown): Promise { + const data = JoinProjectSchema.parse(input ?? {}); + const agent = await this.agents.getByName(agentName); + const project = await this.projects.resolveAndGet(projectName); + + // `llm: null` clears the override; an absent `llm` leaves it untouched. + let llmId: string | null | undefined; + if (data.llm !== undefined) { + llmId = data.llm === null ? null : (await this.llms.getByName(data.llm)).id; + } + + const existing = await this.repo.find(agent.id, project.id); + const row = existing !== null + ? await this.repo.update(existing.id, { + ...(data.role !== undefined ? { role: data.role } : {}), + ...(llmId !== undefined ? { llmId } : {}), + }) + : await this.repo.create({ + agentId: agent.id, + projectId: project.id, + role: data.role ?? DEFAULT_MEMBERSHIP_ROLE, + ...(llmId !== undefined ? { llmId } : {}), + }); + + return this.toView(row); + } + + /** Leave a project. 404s when the agent was never a member — a silent no-op + * here would hide a typo'd project name. */ + async leave(agentName: string, projectName: string): Promise { + const agent = await this.agents.getByName(agentName); + const project = await this.projects.resolveAndGet(projectName); + const existing = await this.repo.find(agent.id, project.id); + if (existing === null) { + throw new NotFoundError( + `Agent '${agent.name}' is not a member of project '${project.name}'`, + ); + } + await this.repo.delete(existing.id); + } + + /** The valid role set, for error messages and CLI help. */ + static roles(): readonly string[] { + return MEMBERSHIP_ROLES; + } + + private async toView(row: AgentProject): Promise { + const agent = await this.agents.getById(row.agentId); + const project = await this.projects.getById(row.projectId); + let llm: { id: string; name: string } | null = null; + if (row.llmId !== null) { + // A brain deleted out from under a membership sets llmId to NULL + // (onDelete: SetNull), so this only tolerates a torn read. + const found = await this.llms.getById(row.llmId).catch(() => null); + if (found !== null) llm = { id: found.id, name: found.name }; + } + return { + id: row.id, + agent: { id: agent.id, name: agent.name }, + project: { id: project.id, name: project.name }, + role: row.role, + llm, + createdAt: row.createdAt, + }; + } +} diff --git a/src/mcpd/src/services/agent.service.ts b/src/mcpd/src/services/agent.service.ts index ed824f4..8417723 100644 --- a/src/mcpd/src/services/agent.service.ts +++ b/src/mcpd/src/services/agent.service.ts @@ -9,6 +9,7 @@ * identifiers. */ import type { Agent } from '@prisma/client'; +import type { IAgentProjectRepository } from '../repositories/agent-project.repository.js'; import type { IAgentRepository } from '../repositories/agent.repository.js'; import type { IPersonalityRepository } from '../repositories/personality.repository.js'; import type { LlmService } from './llm.service.js'; @@ -88,12 +89,30 @@ export interface VirtualAgentInput { visibility?: 'public' | 'private'; } +/** + * One resolved AgentProject membership, in the shape the chat path needs: + * which project the agent is looking at, and which brain wins there. + */ +export interface ResolvedMembership { + projectId: string; + projectName: string; + role: string; + /** Per-membership brain override; null means "use the agent's pinned Llm". */ + llmName: string | null; +} + export class AgentService { constructor( private readonly repo: IAgentRepository, private readonly llms: LlmService, private readonly projects: ProjectService, private readonly personalities?: IPersonalityRepository, + /** + * AgentProject junction. Optional so the many existing test wirings still + * compile; without it an agent simply has no memberships and the legacy + * `Agent.projectId` pin is the only project link. + */ + private readonly memberships?: IAgentProjectRepository, ) {} async list(viewer: AgentViewer | null = null): Promise { @@ -102,13 +121,90 @@ export class AgentService { return Promise.all(visible.map((r) => this.toView(r))); } + /** + * Agents attached to a project — by AgentProject membership OR by the legacy + * `Agent.projectId` pin. The union is what keeps this correct during the + * transition release: a joined agent must show up in the project's roster + * (mcplocal's agents plugin publishes these as virtual MCP servers), and a + * pre-membership agent must not vanish from it. + */ async listByProject(projectName: string, viewer: AgentViewer | null = null): Promise { const project = await this.projects.resolveAndGet(projectName); const rows = await this.repo.findByProjectId(project.id); - const visible = rows.filter((r) => isAgentVisibleTo(r, viewer)); + + const byId = new Map(rows.map((r) => [r.id, r])); + if (this.memberships !== undefined) { + for (const m of await this.memberships.findByProject(project.id)) { + if (byId.has(m.agentId)) continue; + const agent = await this.repo.findById(m.agentId); + if (agent !== null) byId.set(agent.id, agent); + } + } + + const visible = [...byId.values()] + .filter((r) => isAgentVisibleTo(r, viewer)) + .sort((a, b) => a.name.localeCompare(b.name)); return Promise.all(visible.map((r) => this.toView(r))); } + /** + * Which project is this agent looking at, and with which brain? + * + * Two resolution modes, deliberately different: + * + * - `projectName` given (`mcpctl chat --project

`): the agent + * must actually be on that team. A membership wins; the legacy + * `Agent.projectId` pin naming the same project is accepted as the + * transition fallback (the schema comment promises the column keeps + * working this release). Anything else throws, because silently + * answering from a *different* project's context would be worse than an + * error. + * - `projectName` omitted (`mcpctl chat `): unchanged legacy + * behaviour — the pinned `Agent.projectId`, or no project at all. + * + * The brain override only applies on the membership path: a legacy pin has + * no membership row and therefore no override to read. + */ + async resolveMembership( + agentName: string, + projectName?: string, + ): Promise { + const agent = await this.getByName(agentName); + + if (projectName === undefined) { + if (agent.project === null) return null; + return { + projectId: agent.project.id, + projectName: agent.project.name, + role: '', + llmName: null, + }; + } + + const project = await this.projects.resolveAndGet(projectName); + const row = this.memberships !== undefined + ? await this.memberships.find(agent.id, project.id) + : null; + + if (row !== null) { + return { + projectId: project.id, + projectName: project.name, + role: row.role, + llmName: row.llmId !== null ? (await this.llms.getById(row.llmId)).name : null, + }; + } + + if (agent.project !== null && agent.project.id === project.id) { + return { projectId: project.id, projectName: project.name, role: '', llmName: null }; + } + + throw new NotFoundError( + `Agent '${agent.name}' is not a member of project '${project.name}'. ` + + `Join it with: mcpctl attach-agent ${agent.name} --project ${project.name}`, + ); + } + async getById(id: string, viewer: AgentViewer | null = null): Promise { const row = await this.repo.findById(id); if (row === null) throw new NotFoundError(`Agent not found: ${id}`); diff --git a/src/mcpd/src/services/chat.service.ts b/src/mcpd/src/services/chat.service.ts index cab46b0..45a7e4d 100644 --- a/src/mcpd/src/services/chat.service.ts +++ b/src/mcpd/src/services/chat.service.ts @@ -137,6 +137,13 @@ export interface PoolCandidate { export interface ChatRequestArgs { agentName: string; + /** + * Which project the agent is looking at for this turn — the same person, + * viewing one of the teams they joined (docs/agentic-teams.md §4). Requires + * an AgentProject membership; see AgentService.resolveMembership. Omitted = + * the agent on its own (falling back to the legacy `Agent.projectId` pin). + */ + projectName?: string; /** * The end user's bearer token. Only used by the `mcpctl` toolset, which acts * AS the caller — see services/mcpctl-tools.ts. Absent for every other agent. @@ -693,7 +700,15 @@ export class ChatService { private async prepareContext(args: ChatRequestArgs): Promise { const agent = await this.agents.getByName(args.agentName); - const pinned = await this.llms.getByName(agent.llm.name); + + // Membership resolution runs BEFORE the Llm lookup, because a + // per-membership brain override has to be able to *replace* the agent's + // pinned Llm (and therefore its whole failover pool), not patch the + // dispatch list afterwards. Throws when `--project` names a team this + // agent has not joined. + const membership = await this.agents.resolveMembership(args.agentName, args.projectName); + + const pinned = await this.llms.getByName(membership?.llmName ?? agent.llm.name); const poolCandidates = await this.resolveCandidatesWithFallbacks(pinned); if (poolCandidates.length === 0) { // Pool exists but every member is inactive (publishers offline, @@ -707,10 +722,10 @@ export class ChatService { } const threadId = await this.resolveThreadId(args, agent.id); - const projectId = agent.project?.id ?? null; + const projectId = membership?.projectId ?? null; // Project prompts (existing): only those whose projectId actually matches - // the agent's project — `findAll(projectId)` also returns globals which + // the project in view — `findAll(projectId)` also returns globals which // we exclude here so they don't double-count if a future change adds an // explicit "global" injection step. const projectPrompts = projectId !== null diff --git a/src/mcpd/src/validation/agent-project.schema.ts b/src/mcpd/src/validation/agent-project.schema.ts new file mode 100644 index 0000000..aaf81ce --- /dev/null +++ b/src/mcpd/src/validation/agent-project.schema.ts @@ -0,0 +1,32 @@ +/** + * AgentProject (team membership) validation. + * + * `role` is TEXT in the schema on purpose — adding a role later must not be a + * migration. The allowed set therefore lives here, in the service layer, which + * is also the only place it *can* live: production provisions with + * `prisma db push`, so a CHECK constraint written into a migration would never + * run there. + */ +import { z } from 'zod'; + +/** v1 persona roles. Extend this list, not the database. */ +export const MEMBERSHIP_ROLES = ['developer', 'qa', 'security', 'sre'] as const; + +export type MembershipRole = (typeof MEMBERSHIP_ROLES)[number]; + +export const DEFAULT_MEMBERSHIP_ROLE: MembershipRole = 'developer'; + +/** + * Body for `POST /api/v1/agents/:name/projects/:project`. + * + * `llm` names the per-membership brain override; `null` clears it so a + * re-join can drop back to the agent's own pinned Llm. + */ +export const JoinProjectSchema = z + .object({ + role: z.enum(MEMBERSHIP_ROLES).optional(), + llm: z.string().min(1).nullable().optional(), + }) + .strict(); + +export type JoinProjectInput = z.infer; diff --git a/src/mcpd/src/validation/agent.schema.ts b/src/mcpd/src/validation/agent.schema.ts index cdfb215..459c572 100644 --- a/src/mcpd/src/validation/agent.schema.ts +++ b/src/mcpd/src/validation/agent.schema.ts @@ -120,6 +120,12 @@ export const AgentChatRequestSchema = AgentChatParamsSchema.merge( * agent's `defaultPersonalityId` when omitted. */ personality: z.string().min(1).optional(), + /** + * Which of the agent's projects to view for this turn — the second lens in + * docs/agentic-teams.md §4. Requires an AgentProject membership; the chat + * path 404s when the agent has not joined it. + */ + project: z.string().min(1).optional(), }), ).strict().refine((v) => v.message !== undefined || (v.messages?.length ?? 0) > 0, { message: 'Either `message` or `messages` is required', diff --git a/src/mcpd/tests/agent-membership.test.ts b/src/mcpd/tests/agent-membership.test.ts new file mode 100644 index 0000000..a65164b --- /dev/null +++ b/src/mcpd/tests/agent-membership.test.ts @@ -0,0 +1,198 @@ +/** + * The membership half of AgentService: which project an agent is looking at, + * and which brain answers there. + * + * These tests exist because the AgentProject junction shipped inert — the table + * was there but nothing read it, so an agent still had exactly one project. The + * assertions below are what "an agent joins many projects" actually means in + * behaviour rather than in schema. + */ +import type { Agent, AgentProject } from '@prisma/client'; +import { describe, it, expect, vi } from 'vitest'; + +import type { IAgentProjectRepository } from '../src/repositories/agent-project.repository.js'; +import type { IAgentRepository } from '../src/repositories/agent.repository.js'; +import { AgentService } from '../src/services/agent.service.js'; +import type { LlmService } from '../src/services/llm.service.js'; +import type { ProjectService } from '../src/services/project.service.js'; + +const NOW = new Date('2026-01-01T00:00:00Z'); + +function makeAgent(overrides: Partial = {}): Agent { + return { + id: 'agent-alice', + name: 'alice', + description: '', + systemPrompt: '', + llmId: 'llm-home', + projectId: null, + defaultPersonalityId: null, + proxyModelName: null, + toolset: 'project', + defaultParams: {} as Agent['defaultParams'], + extras: {} as Agent['extras'], + visibility: 'public', + kind: 'public', + providerSessionId: null, + status: 'active', + lastHeartbeatAt: null, + inactiveSince: null, + ownerId: 'owner-1', + version: 1, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + } as Agent; +} + +function harness(agents: Agent[], memberships: Array> = []) { + const rows = new Map(agents.map((a) => [a.id, a])); + const links: AgentProject[] = memberships.map((m, i) => ({ + id: `ap-${String(i)}`, + agentId: m.agentId ?? 'agent-alice', + projectId: m.projectId ?? 'proj-sre', + role: m.role ?? 'developer', + llmId: m.llmId ?? null, + createdAt: NOW, + }) as AgentProject); + + const repo = { + findAll: vi.fn(async () => [...rows.values()]), + findById: vi.fn(async (id: string) => rows.get(id) ?? null), + findByName: vi.fn(async (name: string) => [...rows.values()].find((r) => r.name === name) ?? null), + findByProjectId: vi.fn(async (projectId: string) => + [...rows.values()].filter((r) => r.projectId === projectId)), + } as unknown as IAgentRepository; + + const junction = { + findByAgent: vi.fn(async (agentId: string) => links.filter((l) => l.agentId === agentId)), + findByProject: vi.fn(async (projectId: string) => links.filter((l) => l.projectId === projectId)), + find: vi.fn(async (agentId: string, projectId: string) => + links.find((l) => l.agentId === agentId && l.projectId === projectId) ?? null), + } as unknown as IAgentProjectRepository; + + const llms = { + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^llm-/, '') })), + getByName: vi.fn(async (name: string) => ({ id: `llm-${name}`, name })), + } as unknown as LlmService; + + const projects = { + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^proj-/, '') })), + resolveAndGet: vi.fn(async (name: string) => ({ id: `proj-${name}`, name })), + } as unknown as ProjectService; + + return { + withMemberships: new AgentService(repo, llms, projects, undefined, junction), + // The same service without the junction wired — proves the transition + // fallback still works for a deployment that has not migrated. + legacyOnly: new AgentService(repo, llms, projects), + }; +} + +describe('AgentService.resolveMembership — no --project (the person on their own)', () => { + it('returns nothing for an agent with no project at all', async () => { + const { withMemberships } = harness([makeAgent()]); + expect(await withMemberships.resolveMembership('alice')).toBeNull(); + }); + + it('falls back to the legacy Agent.projectId pin', async () => { + const { withMemberships } = harness([makeAgent({ projectId: 'proj-legacy' })]); + const m = await withMemberships.resolveMembership('alice'); + expect(m?.projectName).toBe('legacy'); + expect(m?.llmName).toBeNull(); + }); +}); + +describe('AgentService.resolveMembership — with --project (the person viewing a team)', () => { + it('resolves a joined project', async () => { + const { withMemberships } = harness([makeAgent()], [{ projectId: 'proj-sre', role: 'sre' }]); + const m = await withMemberships.resolveMembership('alice', 'sre'); + expect(m).toMatchObject({ projectId: 'proj-sre', projectName: 'sre', role: 'sre', llmName: null }); + }); + + it('picks the RIGHT membership when the agent is on several teams', async () => { + const { withMemberships } = harness( + [makeAgent()], + [ + { projectId: 'proj-sre', role: 'sre' }, + { projectId: 'proj-payments', role: 'security' }, + ], + ); + expect((await withMemberships.resolveMembership('alice', 'payments'))?.role).toBe('security'); + expect((await withMemberships.resolveMembership('alice', 'sre'))?.role).toBe('sre'); + }); + + it('throws with a join hint when the agent is not on that team', async () => { + const { withMemberships } = harness([makeAgent()], [{ projectId: 'proj-sre' }]); + await expect(withMemberships.resolveMembership('alice', 'payments')).rejects.toThrow( + /is not a member of project 'payments'.*mcpctl attach-agent alice --project payments/s, + ); + }); + + it('accepts the legacy pin as the transition fallback when it names the same project', async () => { + const { withMemberships } = harness([makeAgent({ projectId: 'proj-sre' })]); + const m = await withMemberships.resolveMembership('alice', 'sre'); + expect(m?.projectId).toBe('proj-sre'); + // No membership row means no override to read. + expect(m?.llmName).toBeNull(); + }); + + it('still refuses a DIFFERENT project than the legacy pin — silently answering from the wrong context would be worse than an error', async () => { + const { withMemberships } = harness([makeAgent({ projectId: 'proj-sre' })]); + await expect(withMemberships.resolveMembership('alice', 'payments')).rejects.toThrow(/not a member/); + }); + + it('works without the junction wired at all (pre-migration deployment)', async () => { + const { legacyOnly } = harness([makeAgent({ projectId: 'proj-sre' })]); + expect((await legacyOnly.resolveMembership('alice', 'sre'))?.projectId).toBe('proj-sre'); + await expect(legacyOnly.resolveMembership('alice', 'payments')).rejects.toThrow(/not a member/); + }); +}); + +describe('AgentService.resolveMembership — the per-membership brain override', () => { + it('reports the override so chat can dispatch to it', async () => { + const { withMemberships } = harness( + [makeAgent({ llmId: 'llm-home' })], + [{ projectId: 'proj-sre', llmId: 'llm-sre-brain' }], + ); + expect((await withMemberships.resolveMembership('alice', 'sre'))?.llmName).toBe('sre-brain'); + }); + + it('is per-membership: the same agent keeps its own brain on another team', async () => { + const { withMemberships } = harness( + [makeAgent()], + [ + { projectId: 'proj-sre', llmId: 'llm-sre-brain' }, + { projectId: 'proj-payments', llmId: null }, + ], + ); + expect((await withMemberships.resolveMembership('alice', 'sre'))?.llmName).toBe('sre-brain'); + expect((await withMemberships.resolveMembership('alice', 'payments'))?.llmName).toBeNull(); + }); +}); + +describe('AgentService.listByProject — the roster', () => { + it('unions members with legacy-pinned agents, without duplicates', async () => { + const { withMemberships } = harness( + [ + makeAgent({ id: 'agent-alice', name: 'alice', projectId: 'proj-sre' }), // legacy pin + makeAgent({ id: 'agent-bob', name: 'bob' }), // member only + makeAgent({ id: 'agent-carol', name: 'carol' }), // neither + ], + [ + { agentId: 'agent-bob', projectId: 'proj-sre' }, + { agentId: 'agent-alice', projectId: 'proj-sre' }, // both pinned AND joined + ], + ); + const roster = await withMemberships.listByProject('sre'); + expect(roster.map((a) => a.name)).toEqual(['alice', 'bob']); + }); + + it('returns only legacy-pinned agents when the junction is not wired', async () => { + const { legacyOnly } = harness( + [makeAgent({ id: 'agent-alice', name: 'alice', projectId: 'proj-sre' }), makeAgent({ id: 'agent-bob', name: 'bob' })], + [{ agentId: 'agent-bob', projectId: 'proj-sre' }], + ); + expect((await legacyOnly.listByProject('sre')).map((a) => a.name)).toEqual(['alice']); + }); +}); diff --git a/src/mcpd/tests/agent-project-routes.test.ts b/src/mcpd/tests/agent-project-routes.test.ts new file mode 100644 index 0000000..7906b59 --- /dev/null +++ b/src/mcpd/tests/agent-project-routes.test.ts @@ -0,0 +1,103 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { errorHandler } from '../src/middleware/error-handler.js'; +import { registerAgentProjectRoutes } from '../src/routes/agent-projects.js'; +import type { AgentProjectService, MembershipView } from '../src/services/agent-project.service.js'; +import { NotFoundError } from '../src/services/mcp-server.service.js'; + +const NOW = new Date('2026-01-01T00:00:00Z'); + +function view(overrides: Partial = {}): MembershipView { + return { + id: 'ap-1', + agent: { id: 'agent-alice', name: 'alice' }, + project: { id: 'proj-sre', name: 'sre' }, + role: 'sre', + llm: null, + createdAt: NOW, + ...overrides, + }; +} + +function build(service: Partial>): FastifyInstance { + const app = Fastify(); + app.setErrorHandler(errorHandler); + registerAgentProjectRoutes(app, service as unknown as AgentProjectService); + return app; +} + +let app: FastifyInstance | undefined; +afterEach(async () => { await app?.close(); app = undefined; }); + +describe('agent ↔ project membership routes', () => { + it('GET /agents/:name/projects lists memberships', async () => { + app = build({ listForAgent: vi.fn(async () => [view(), view({ id: 'ap-2', project: { id: 'proj-pay', name: 'payments' } })]) }); + const res = await app.inject({ method: 'GET', url: '/api/v1/agents/alice/projects' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveLength(2); + }); + + it('POST /agents/:name/projects/:project joins and returns 201', async () => { + const join = vi.fn(async () => view({ role: 'qa' })); + app = build({ join }); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/agents/alice/projects/sre', + payload: { role: 'qa' }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().role).toBe('qa'); + expect(join).toHaveBeenCalledWith('alice', 'sre', { role: 'qa' }); + }); + + it('POST with no body still joins — role and brain are both optional', async () => { + const join = vi.fn(async () => view()); + app = build({ join }); + const res = await app.inject({ method: 'POST', url: '/api/v1/agents/alice/projects/sre' }); + expect(res.statusCode).toBe(201); + expect(join).toHaveBeenCalledWith('alice', 'sre', {}); + }); + + it('DELETE /agents/:name/projects/:project leaves and returns 204', async () => { + const leave = vi.fn(async () => undefined); + app = build({ leave }); + const res = await app.inject({ method: 'DELETE', url: '/api/v1/agents/alice/projects/sre' }); + expect(res.statusCode).toBe(204); + expect(leave).toHaveBeenCalledWith('alice', 'sre'); + }); + + it('DELETE 404s when the agent was never a member', async () => { + app = build({ + leave: vi.fn(async () => { throw new NotFoundError("Agent 'alice' is not a member of project 'sre'"); }), + }); + const res = await app.inject({ method: 'DELETE', url: '/api/v1/agents/alice/projects/sre' }); + expect(res.statusCode).toBe(404); + expect(res.json().error).toMatch(/not a member/); + }); + + it('404s on an unknown agent', async () => { + app = build({ listForAgent: vi.fn(async () => { throw new NotFoundError('Agent not found: ghost'); }) }); + const res = await app.inject({ method: 'GET', url: '/api/v1/agents/ghost/projects' }); + expect(res.statusCode).toBe(404); + }); + + it('GET /projects/:name/memberships is the reverse view, with roles', async () => { + app = build({ + listForProject: vi.fn(async () => [ + view({ agent: { id: 'agent-alice', name: 'alice' }, role: 'sre' }), + view({ id: 'ap-2', agent: { id: 'agent-bob', name: 'bob' }, role: 'qa' }), + ]), + }); + const res = await app.inject({ method: 'GET', url: '/api/v1/projects/sre/memberships' }); + expect(res.statusCode).toBe(200); + expect(res.json().map((m: MembershipView) => `${m.agent.name}:${m.role}`)).toEqual(['alice:sre', 'bob:qa']); + }); + + it('url-decodes names before handing them to the service', async () => { + const join = vi.fn(async () => view()); + app = build({ join }); + await app.inject({ method: 'POST', url: '/api/v1/agents/alice/projects/my%2Dteam' }); + expect(join).toHaveBeenCalledWith('alice', 'my-team', {}); + }); +}); diff --git a/src/mcpd/tests/chat-service-membership.test.ts b/src/mcpd/tests/chat-service-membership.test.ts new file mode 100644 index 0000000..1ddeb38 --- /dev/null +++ b/src/mcpd/tests/chat-service-membership.test.ts @@ -0,0 +1,180 @@ +/** + * `mcpctl chat --project

` — the same person, viewing one of the + * teams they joined (docs/agentic-teams.md §4). + * + * The regression these lock down: the AgentProject junction existed for a whole + * release without the chat path reading it, so `--project` on an agent could + * only ever mean the one project pinned on the Agent row. + */ +import type { ChatMessage, ChatThread, Prompt } from '@prisma/client'; +import { describe, it, expect, vi } from 'vitest'; + +import type { IChatRepository } from '../src/repositories/chat.repository.js'; +import type { IPromptRepository } from '../src/repositories/prompt.repository.js'; +import type { AgentService, ResolvedMembership } from '../src/services/agent.service.js'; +import { ChatService, type ChatToolDispatcher } from '../src/services/chat.service.js'; +import type { LlmAdapterRegistry } from '../src/services/llm/dispatcher.js'; +import type { LlmAdapter, NonStreamingResult } from '../src/services/llm/types.js'; +import type { LlmService } from '../src/services/llm.service.js'; +import { NotFoundError } from '../src/services/mcp-server.service.js'; + +const NOW = new Date('2026-01-01T00:00:00Z'); + +function mockChatRepo(): IChatRepository { + const msgs: ChatMessage[] = []; + let n = 1; + return { + createThread: vi.fn(async ({ agentId, ownerId }) => ({ + id: `thread-${String(n++)}`, agentId, projectId: null, ownerId, + title: '', lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW, + } as ChatThread)), + findThread: vi.fn(async () => null), + listThreadsByAgent: vi.fn(async () => []), + listMessages: vi.fn(async () => []), + appendMessage: vi.fn(async (input: { threadId: string; role: string; content: string }) => { + const m = { + id: `msg-${String(n++)}`, threadId: input.threadId, turnIndex: msgs.length, + role: input.role, content: input.content, toolCalls: null, toolCallId: null, + status: 'complete', createdAt: NOW, + } as unknown as ChatMessage; + msgs.push(m); + return m; + }), + updateStatus: vi.fn(async () => ({}) as ChatMessage), + markPendingAsError: vi.fn(async () => 0), + touchThread: vi.fn(async () => undefined), + nextTurnIndex: vi.fn(async () => msgs.length), + } as unknown as IChatRepository; +} + +function mockPromptRepo(rows: Prompt[] = []): IPromptRepository { + return { + findAll: vi.fn(async () => rows), + findByAgent: vi.fn(async () => []), + } as unknown as IPromptRepository; +} + +/** + * The agent: pinned to `home-brain`, legacy-pinned to project `legacy`, and a + * member of `sre` (with its own brain) and `payments` (without one). + */ +const MEMBERSHIPS: Record = { + sre: { projectId: 'proj-sre', projectName: 'sre', role: 'sre', llmName: 'sre-brain' }, + payments: { projectId: 'proj-payments', projectName: 'payments', role: 'security', llmName: null }, +}; + +function mockAgents(): AgentService { + return { + getByName: vi.fn(async (name: string) => ({ + id: `agent-${name}`, name, description: '', systemPrompt: 'You are alice.', + llm: { id: 'llm-home-brain', name: 'home-brain' }, + project: { id: 'proj-legacy', name: 'legacy' }, + defaultPersonality: null, proxyModelName: null, toolset: 'project', + defaultParams: {}, extras: {}, ownerId: 'owner-1', version: 1, + createdAt: NOW, updatedAt: NOW, + })), + resolveMembership: vi.fn(async (_name: string, projectName?: string) => { + if (projectName === undefined) { + return { projectId: 'proj-legacy', projectName: 'legacy', role: '', llmName: null }; + } + const found = MEMBERSHIPS[projectName]; + if (found === undefined) { + throw new NotFoundError(`Agent 'alice' is not a member of project '${projectName}'.`); + } + return found; + }), + } as unknown as AgentService; +} + +function mockLlms(): LlmService { + const row = (name: string): Record => ({ + id: `llm-${name}`, name, type: 'openai', model: `${name}-model`, + url: '', tier: 'fast', description: '', + apiKeySecretId: null, apiKeySecretKey: null, extraConfig: {}, poolName: null, + kind: 'public', providerSessionId: null, status: 'active', + lastHeartbeatAt: null, inactiveSince: null, + version: 1, createdAt: NOW, updatedAt: NOW, + }); + return { + getByName: vi.fn(async (name: string) => ({ ...row(name), apiKeyRef: null })), + findByPoolName: vi.fn(async (poolName: string) => [row(poolName)]), + resolveApiKey: vi.fn(async () => 'fake-key'), + } as unknown as LlmService; +} + +function reply(content: string): NonStreamingResult { + return { + status: 200, + body: { id: 'c', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] }, + }; +} + +function build() { + const listTools = vi.fn(async () => []); + const tools = { listTools, callTool: vi.fn(async () => ({})) } as unknown as ChatToolDispatcher; + const llms = mockLlms(); + const promptRepo = mockPromptRepo(); + const adapter: LlmAdapter = { + kind: 'scripted', + infer: vi.fn(async () => reply('ok')), + stream: async function*() { yield { data: '[DONE]', done: true }; }, + } as unknown as LlmAdapter; + const service = new ChatService( + mockAgents(), llms, { get: () => adapter } as unknown as LlmAdapterRegistry, + mockChatRepo(), promptRepo, tools, + ); + return { service, llms, listTools, promptRepo, adapter }; +} + +describe('agent chat scoped to a joined project', () => { + it('uses the MEMBERSHIP project for tools, not the legacy pin', async () => { + const { service, listTools } = build(); + await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'payments' }); + expect(listTools).toHaveBeenCalledWith('proj-payments'); + }); + + it('falls back to the legacy pin when no project is named', async () => { + const { service, listTools } = build(); + await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi' }); + expect(listTools).toHaveBeenCalledWith('proj-legacy'); + }); + + it('the per-membership brain wins over the agent\'s pinned Llm', async () => { + const { service, llms } = build(); + const res = await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'sre' }); + expect(llms.getByName).toHaveBeenCalledWith('sre-brain'); + expect(llms.getByName).not.toHaveBeenCalledWith('home-brain'); + // Reported back to the caller, so `mcpctl chat` can show which brain answered. + expect(res.llm).toBe('sre-brain'); + }); + + it('keeps the agent\'s own brain on a membership with no override', async () => { + const { service, llms } = build(); + const res = await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'payments' }); + expect(llms.getByName).toHaveBeenCalledWith('home-brain'); + expect(res.llm).toBe('home-brain'); + }); + + it('and on bare chat, with no project at all', async () => { + const { service, llms } = build(); + await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi' }); + expect(llms.getByName).toHaveBeenCalledWith('home-brain'); + }); + + it('refuses a project the agent has not joined — before spending a token', async () => { + const { service, adapter } = build(); + await expect( + service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'finance' }), + ).rejects.toThrow(/not a member of project 'finance'/); + expect(adapter.infer).not.toHaveBeenCalled(); + }); + + it('scopes the streaming path the same way', async () => { + const { service, listTools } = build(); + const chunks = []; + for await (const c of service.chatStream({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'sre' })) { + chunks.push(c); + } + expect(listTools).toHaveBeenCalledWith('proj-sre'); + }); +}); diff --git a/src/mcpd/tests/chat-service-virtual-llm.test.ts b/src/mcpd/tests/chat-service-virtual-llm.test.ts index 5d86b3f..a36cd82 100644 --- a/src/mcpd/tests/chat-service-virtual-llm.test.ts +++ b/src/mcpd/tests/chat-service-virtual-llm.test.ts @@ -56,6 +56,7 @@ function mockChatRepo(): IChatRepository { function mockAgents(): AgentService { return { + resolveMembership: vi.fn(async () => null), getByName: vi.fn(async (name: string) => ({ id: `agent-${name}`, name, description: '', systemPrompt: 'You are a helpful agent.', diff --git a/src/mcpd/tests/chat-service.test.ts b/src/mcpd/tests/chat-service.test.ts index 42f4f9d..ef5f9aa 100644 --- a/src/mcpd/tests/chat-service.test.ts +++ b/src/mcpd/tests/chat-service.test.ts @@ -97,6 +97,15 @@ function mockTools(impl: Partial = {}): ChatToolDispatcher { function mockAgents(opts: { defaultPersonality?: { id: string; name: string } | null } = {}): AgentService { return { + // Membership resolution with no `--project`: the legacy `Agent.projectId` + // pin, exactly what the real service returns for the same input. + resolveMembership: vi.fn(async (name: string, projectName?: string) => { + if (projectName !== undefined) { + return { projectId: `proj-${projectName}`, projectName, role: 'developer', llmName: null }; + } + if (name === 'no-project') return null; + return { projectId: 'proj-1', projectName: 'mcpctl-dev', role: '', llmName: null }; + }), getByName: vi.fn(async (name: string) => ({ id: `agent-${name}`, name, @@ -801,6 +810,9 @@ describe('ChatService', () => { }); // Agent with maxIterations=2 — only 2 tool-call rounds allowed before bail. const agentsLowCap = { + resolveMembership: vi.fn(async () => ({ + projectId: 'proj-1', projectName: 'mcpctl-dev', role: '', llmName: null, + })), getByName: vi.fn(async () => ({ id: 'agent-low', name: 'low', description: '', systemPrompt: '', llm: { id: 'llm-1', name: 'qwen3-thinking' }, @@ -821,6 +833,9 @@ describe('ChatService', () => { // Hostile agent with maxIterations=1000000 — must clamp to 50, not iterate forever. const agentsHostile = { + resolveMembership: vi.fn(async () => ({ + projectId: 'proj-1', projectName: 'mcpctl-dev', role: '', llmName: null, + })), getByName: vi.fn(async () => ({ id: 'agent-bad', name: 'bad', description: '', systemPrompt: '', llm: { id: 'llm-1', name: 'qwen3-thinking' }, @@ -1106,6 +1121,8 @@ describe('ChatService — failover chain', () => { } function agentPinnedTo(llmName: string): AgentService { return { + // No project, no membership — the failover chain is about the Llm pool. + resolveMembership: vi.fn(async () => null), getByName: vi.fn(async (name: string) => ({ id: `agent-${name}`, name, description: '', systemPrompt: 'sys', llm: { id: 'x', name: llmName }, project: null, defaultPersonality: null, diff --git a/src/mcpd/tests/security.test.ts b/src/mcpd/tests/security.test.ts index 0a1492b..29033f4 100644 --- a/src/mcpd/tests/security.test.ts +++ b/src/mcpd/tests/security.test.ts @@ -19,71 +19,17 @@ import { AuditEventService } from '../src/services/audit-event.service.js'; import type { IAuditEventRepository } from '../src/repositories/interfaces.js'; import { errorHandler } from '../src/middleware/error-handler.js'; import { CreateMcpServerSchema } from '../src/validation/mcp-server.schema.js'; +import { mapUrlToPermission } from '../src/middleware/url-permission.js'; // ───────────────────────────────────────────────────────── // § 1 audit-events endpoint bypasses RBAC // ───────────────────────────────────────────────────────── -/** - * Reproduce mapUrlToPermission from main.ts to test which URLs - * get RBAC checks and which are skipped. - */ -type PermissionCheck = - | { kind: 'resource'; resource: string; action: string; resourceName?: string } - | { kind: 'operation'; operation: string } - | { kind: 'skip' }; - -function mapUrlToPermission(method: string, url: string): PermissionCheck { - const match = url.match(/^\/api\/v1\/([a-z-]+)/); - if (!match) return { kind: 'skip' }; - - const segment = match[1] as string; - - if (segment === 'backup') return { kind: 'operation', operation: 'backup' }; - if (segment === 'restore') return { kind: 'operation', operation: 'restore' }; - if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' }; - - const resourceMap: Record = { - 'servers': 'servers', - 'instances': 'instances', - 'secrets': 'secrets', - 'projects': 'projects', - 'templates': 'templates', - 'users': 'users', - 'groups': 'groups', - 'rbac': 'rbac', - 'audit-logs': 'rbac', - 'mcp': 'servers', - 'prompts': 'prompts', - 'promptrequests': 'promptrequests', - }; - - const resource = resourceMap[segment]; - if (resource === undefined) return { kind: 'skip' }; - - let action: string; - switch (method) { - case 'GET': - case 'HEAD': - action = 'view'; - break; - case 'POST': - action = 'create'; - break; - case 'DELETE': - action = 'delete'; - break; - default: - action = 'edit'; - break; - } - - const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/); - const resourceName = nameMatch?.[1]; - const check: PermissionCheck = { kind: 'resource', resource, action }; - if (resourceName !== undefined) (check as { resourceName: string }).resourceName = resourceName; - return check; -} +// Uses the REAL mapping, not a copy. This file previously reproduced +// mapUrlToPermission by hand because main.ts self-invokes main() and nothing +// in it was importable. A security test asserting against a hand-copied +// duplicate stops testing the system the moment the real one changes — and +// this copy had already drifted. It now lives in middleware/url-permission.ts. describe('Security: RBAC coverage gaps in mapUrlToPermission', () => { it('audit-events endpoint is NOT in resourceMap — bypasses RBAC', () => { diff --git a/src/mcpd/tests/services/agent-project-service.test.ts b/src/mcpd/tests/services/agent-project-service.test.ts new file mode 100644 index 0000000..c1d628a --- /dev/null +++ b/src/mcpd/tests/services/agent-project-service.test.ts @@ -0,0 +1,215 @@ +import type { AgentProject } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { IAgentProjectRepository } from '../../src/repositories/agent-project.repository.js'; +import { AgentProjectService } from '../../src/services/agent-project.service.js'; +import type { AgentService } from '../../src/services/agent.service.js'; +import type { LlmService } from '../../src/services/llm.service.js'; +import { NotFoundError } from '../../src/services/mcp-server.service.js'; +import type { ProjectService } from '../../src/services/project.service.js'; + +const NOW = new Date('2026-01-01T00:00:00Z'); + +/** + * In-memory AgentProject junction. The unique (agentId, projectId) constraint + * is modelled here because the service's idempotent re-join depends on it — + * without it the "join twice" test would pass for the wrong reason. + */ +function makeHarness(seed: Array> = []) { + let idCounter = 1; + let rows: AgentProject[] = seed.map((r) => ({ + id: r.id ?? `ap-${String(idCounter++)}`, + agentId: r.agentId ?? 'agent-alice', + projectId: r.projectId ?? 'proj-sre', + role: r.role ?? 'developer', + llmId: r.llmId ?? null, + createdAt: NOW, + }) as AgentProject); + + const repo = { + findByAgent: vi.fn(async (agentId: string) => rows.filter((r) => r.agentId === agentId)), + findByProject: vi.fn(async (projectId: string) => rows.filter((r) => r.projectId === projectId)), + find: vi.fn(async (agentId: string, projectId: string) => + rows.find((r) => r.agentId === agentId && r.projectId === projectId) ?? null, + ), + create: vi.fn(async (data: { agentId: string; projectId: string; role?: string; llmId?: string | null }) => { + const row = { + id: `ap-${String(idCounter++)}`, + agentId: data.agentId, + projectId: data.projectId, + role: data.role ?? 'developer', + llmId: data.llmId ?? null, + createdAt: NOW, + } as AgentProject; + rows = [...rows, row]; + return row; + }), + update: vi.fn(async (id: string, data: { role?: string; llmId?: string | null }) => { + const row = rows.find((r) => r.id === id); + if (row === undefined) throw new Error(`no such membership: ${id}`); + if (data.role !== undefined) row.role = data.role; + if (data.llmId !== undefined) row.llmId = data.llmId; + return row; + }), + delete: vi.fn(async (id: string) => { rows = rows.filter((r) => r.id !== id); }), + } as unknown as IAgentProjectRepository & Record>; + + const agents = { + getByName: vi.fn(async (name: string) => { + if (name === 'ghost') throw new NotFoundError(`Agent not found: ${name}`); + return { id: `agent-${name}`, name }; + }), + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^agent-/, '') })), + } as unknown as AgentService; + + const projects = { + resolveAndGet: vi.fn(async (name: string) => { + if (name === 'nowhere') throw new NotFoundError(`Project not found: ${name}`); + return { id: `proj-${name}`, name }; + }), + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^proj-/, '') })), + } as unknown as ProjectService; + + const llms = { + getByName: vi.fn(async (name: string) => { + if (name === 'missing-brain') throw new NotFoundError(`Llm not found: ${name}`); + return { id: `llm-${name}`, name }; + }), + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^llm-/, '') })), + } as unknown as LlmService; + + return { + repo, agents, projects, llms, + service: new AgentProjectService(repo, agents, projects, llms), + all: () => rows, + }; +} + +describe('AgentProjectService — joining and leaving', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('joins a project with the default role', async () => { + const { service } = makeHarness(); + const m = await service.join('alice', 'sre', {}); + expect(m.project.name).toBe('sre'); + expect(m.agent.name).toBe('alice'); + expect(m.role).toBe('developer'); + expect(m.llm).toBeNull(); + }); + + it('joins the SAME agent to several projects — the whole point of the junction', async () => { + const { service } = makeHarness(); + await service.join('alice', 'sre', {}); + await service.join('alice', 'payments', { role: 'security' }); + + const memberships = await service.listForAgent('alice'); + expect(memberships.map((m) => m.project.name).sort()).toEqual(['payments', 'sre']); + expect(memberships.find((m) => m.project.name === 'payments')?.role).toBe('security'); + }); + + it('re-joining updates the role instead of 409-ing — repeat applies converge', async () => { + const { service, repo, all } = makeHarness(); + await service.join('alice', 'sre', { role: 'developer' }); + const second = await service.join('alice', 'sre', { role: 'qa' }); + + expect(second.role).toBe('qa'); + expect(all()).toHaveLength(1); + expect(repo.create).toHaveBeenCalledTimes(1); + expect(repo.update).toHaveBeenCalledTimes(1); + }); + + it('rejects a role outside the v1 set', async () => { + const { service } = makeHarness(); + await expect(service.join('alice', 'sre', { role: 'chief-vibes-officer' })).rejects.toThrow(); + }); + + it('accepts every v1 role', async () => { + const { service } = makeHarness(); + for (const role of AgentProjectService.roles()) { + const m = await service.join('alice', 'sre', { role }); + expect(m.role).toBe(role); + } + }); + + it('rejects unknown body fields rather than silently dropping them', async () => { + const { service } = makeHarness(); + // A typo'd `llmName` would otherwise leave the membership with no brain + // override and no complaint — the exact failure the strict schema exists for. + await expect(service.join('alice', 'sre', { llmName: 'x' })).rejects.toThrow(); + }); + + it('404s on an unknown agent and on an unknown project', async () => { + const { service } = makeHarness(); + await expect(service.join('ghost', 'sre', {})).rejects.toThrow(/Agent not found/); + await expect(service.join('alice', 'nowhere', {})).rejects.toThrow(/Project not found/); + }); + + it('leaves a project', async () => { + const { service, all } = makeHarness([{ agentId: 'agent-alice', projectId: 'proj-sre' }]); + await service.leave('alice', 'sre'); + expect(all()).toHaveLength(0); + }); + + it('404s when leaving a project the agent never joined — a typo must not no-op', async () => { + const { service } = makeHarness(); + await expect(service.leave('alice', 'sre')).rejects.toThrow(/is not a member of project 'sre'/); + }); +}); + +describe('AgentProjectService — the per-membership brain override', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('records the named Llm', async () => { + const { service } = makeHarness(); + const m = await service.join('alice', 'sre', { llm: 'qwen3-thinking' }); + expect(m.llm).toEqual({ id: 'llm-qwen3-thinking', name: 'qwen3-thinking' }); + }); + + it('clears the override with an explicit null', async () => { + const { service } = makeHarness(); + await service.join('alice', 'sre', { llm: 'qwen3-thinking' }); + const cleared = await service.join('alice', 'sre', { llm: null }); + expect(cleared.llm).toBeNull(); + }); + + it('leaves an existing override untouched when `llm` is absent', async () => { + const { service } = makeHarness(); + await service.join('alice', 'sre', { llm: 'qwen3-thinking' }); + const roleOnly = await service.join('alice', 'sre', { role: 'qa' }); + expect(roleOnly.role).toBe('qa'); + expect(roleOnly.llm?.name).toBe('qwen3-thinking'); + }); + + it('404s on an Llm that does not exist', async () => { + const { service } = makeHarness(); + await expect(service.join('alice', 'sre', { llm: 'missing-brain' })).rejects.toThrow(/Llm not found/); + }); + + it('survives a brain that vanished under the membership', async () => { + // onDelete: SetNull should prevent this, but a torn read must not 500 the + // whole roster. + const { service, llms } = makeHarness([{ agentId: 'agent-alice', projectId: 'proj-sre', llmId: 'llm-gone' }]); + (llms.getById as ReturnType).mockRejectedValueOnce(new NotFoundError('Llm not found: llm-gone')); + const [m] = await service.listForAgent('alice'); + expect(m?.llm).toBeNull(); + }); +}); + +describe('AgentProjectService — the reverse view', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('lists the whole roster for a project, with roles', async () => { + const { service } = makeHarness([ + { agentId: 'agent-alice', projectId: 'proj-sre', role: 'sre' }, + { agentId: 'agent-bob', projectId: 'proj-sre', role: 'qa' }, + { agentId: 'agent-carol', projectId: 'proj-payments', role: 'security' }, + ]); + const roster = await service.listForProject('sre'); + expect(roster.map((m) => `${m.agent.name}:${m.role}`)).toEqual(['alice:sre', 'bob:qa']); + }); + + it('404s on an unknown project', async () => { + const { service } = makeHarness(); + await expect(service.listForProject('nowhere')).rejects.toThrow(/Project not found/); + }); +});