diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index b47d9b6..1c40700 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2,7 +2,7 @@ "master": { "tasks": [ { - "id": 1, + "id": "1", "title": "Initialize Project Structure and Core Dependencies", "description": "Set up the monorepo structure for mcpctl with CLI client, mcpd server, and shared libraries. Configure TypeScript, ESLint, and build tooling.", "details": "Create a monorepo using pnpm workspaces or npm workspaces with the following structure:\n\n```\nmcpctl/\n├── src/\n│ ├── cli/ # mcpctl CLI tool\n│ ├── mcpd/ # Backend daemon server\n│ ├── shared/ # Shared types, utilities, constants\n│ └── local-proxy/ # Local LLM proxy component\n├── deploy/\n│ └── docker-compose.yml\n├── package.json\n├── tsconfig.base.json\n└── pnpm-workspace.yaml\n```\n\nDependencies to install:\n- TypeScript 5.x\n- Commander.js for CLI\n- Express/Fastify for mcpd HTTP server\n- Zod for schema validation\n- Winston/Pino for logging\n- Prisma or Drizzle for database ORM\n\nCreate base tsconfig.json with strict mode, ES2022 target, and module resolution settings. Set up shared ESLint config with TypeScript rules.", @@ -18,7 +18,8 @@ "dependencies": [], "details": "Create root package.json with pnpm workspaces configuration. Create pnpm-workspace.yaml defining all workspace packages. Initialize the following directory structure:\n\n```\nmcpctl/\n├── src/\n│ ├── cli/ # mcpctl CLI tool (Task 7-10)\n│ │ ├── src/\n│ │ ├── tests/\n│ │ └── package.json\n│ ├── mcpd/ # Backend daemon server (Task 3-6, 14, 16)\n│ │ ├── src/\n│ │ ├── tests/\n│ │ └── package.json\n│ ├── shared/ # Shared types, utils, constants, validation\n│ │ ├── src/\n│ │ │ ├── types/ # TypeScript interfaces/types\n│ │ │ ├── utils/ # Utility functions\n│ │ │ ├── constants/# Shared constants\n│ │ │ ├── validation/ # Zod schemas\n│ │ │ └── index.ts # Barrel export\n│ │ ├── tests/\n│ │ └── package.json\n│ ├── local-proxy/ # Local LLM proxy (Task 11-13)\n│ │ ├── src/\n│ │ ├── tests/\n│ │ └── package.json\n│ └── db/ # Database package (Task 2)\n│ ├── src/\n│ ├── prisma/ # Schema and migrations\n│ ├── seed/ # Seed data\n│ ├── tests/\n│ └── package.json\n├── deploy/\n│ └── docker-compose.yml # Local dev services (postgres)\n├── tests/\n│ ├── e2e/ # End-to-end tests (Task 18)\n│ └── integration/ # Integration tests\n├── docs/ # Documentation (Task 18)\n├── package.json # Root workspace config\n├── pnpm-workspace.yaml\n└── turbo.json # Optional: Turborepo for build orchestration\n```\n\nThe pnpm-workspace.yaml should contain: `packages: [\"src/*\"]`", "status": "done", - "testStrategy": "Write Vitest tests that verify: (1) All expected directories exist, (2) All package.json files are valid JSON with correct workspace protocol dependencies, (3) pnpm-workspace.yaml correctly includes all packages, (4) Running 'pnpm install' succeeds and creates correct node_modules symlinks between packages. Run 'pnpm ls' to verify workspace linking." + "testStrategy": "Write Vitest tests that verify: (1) All expected directories exist, (2) All package.json files are valid JSON with correct workspace protocol dependencies, (3) pnpm-workspace.yaml correctly includes all packages, (4) Running 'pnpm install' succeeds and creates correct node_modules symlinks between packages. Run 'pnpm ls' to verify workspace linking.", + "parentId": "undefined" }, { "id": 2, @@ -29,7 +30,8 @@ ], "details": "Create root tsconfig.base.json with shared compiler options. Create package-specific tsconfig.json in each package that extends the base and sets appropriate paths.", "status": "done", - "testStrategy": "Write Vitest tests that verify tsconfig.base.json exists and has strict: true, each package tsconfig.json extends base correctly." + "testStrategy": "Write Vitest tests that verify tsconfig.base.json exists and has strict: true, each package tsconfig.json extends base correctly.", + "parentId": "undefined" }, { "id": 3, @@ -40,7 +42,8 @@ ], "details": "Install Vitest and related packages at root level. Create root vitest.config.ts and vitest.workspace.ts for workspace-aware testing pointing to src/cli, src/mcpd, src/shared, src/local-proxy, src/db.", "status": "done", - "testStrategy": "Run 'pnpm test:run' and verify Vitest discovers and runs tests, coverage report is generated." + "testStrategy": "Run 'pnpm test:run' and verify Vitest discovers and runs tests, coverage report is generated.", + "parentId": "undefined" }, { "id": 4, @@ -51,7 +54,8 @@ ], "details": "Install ESLint and plugins at root. Create eslint.config.js (flat config, ESLint 9+). Create deploy/docker-compose.yml for local development with PostgreSQL service.", "status": "done", - "testStrategy": "Write Vitest tests that verify eslint.config.js exists and exports valid config, deploy/docker-compose.yml is valid YAML and defines postgres service." + "testStrategy": "Write Vitest tests that verify eslint.config.js exists and exports valid config, deploy/docker-compose.yml is valid YAML and defines postgres service.", + "parentId": "undefined" }, { "id": 5, @@ -64,12 +68,13 @@ ], "details": "Install dependencies per package in src/cli, src/mcpd, src/shared, src/db, src/local-proxy. Perform security and architecture review.", "status": "done", - "testStrategy": "Verify each package.json has required dependencies, run pnpm audit, verify .gitignore contains required patterns." + "testStrategy": "Verify each package.json has required dependencies, run pnpm audit, verify .gitignore contains required patterns.", + "parentId": "undefined" } ] }, { - "id": 2, + "id": "2", "title": "Design and Implement Database Schema", "description": "Create the database schema for storing MCP server configurations, projects, profiles, user sessions, and audit logs. Use PostgreSQL for production readiness.", "details": "Design PostgreSQL schema using Prisma ORM with models: User, McpServer, McpProfile, Project, ProjectMcpProfile, McpInstance, AuditLog, Session. Create migrations and seed data for common MCP servers (slack, jira, github, terraform).", @@ -78,7 +83,7 @@ "dependencies": [ "1" ], - "status": "pending", + "status": "done", "subtasks": [ { "id": 1, @@ -87,7 +92,8 @@ "dependencies": [], "details": "Create src/db/prisma directory structure. Install Prisma dependencies. Configure deploy/docker-compose.yml with two PostgreSQL services: mcpctl-postgres (port 5432) for development and mcpctl-postgres-test (port 5433) for testing.", "status": "pending", - "testStrategy": "Write Vitest tests that verify docker-compose creates both postgres services, setupTestDb() successfully connects and pushes schema." + "testStrategy": "Write Vitest tests that verify docker-compose creates both postgres services, setupTestDb() successfully connects and pushes schema.", + "parentId": "undefined" }, { "id": 2, @@ -98,7 +104,8 @@ ], "details": "Create src/db/tests/models directory with separate test files for each model. Tests will initially fail (TDD red phase) until schema is implemented.", "status": "pending", - "testStrategy": "Tests are expected to fail initially (TDD red phase). After schema implementation, all tests should pass." + "testStrategy": "Tests are expected to fail initially (TDD red phase). After schema implementation, all tests should pass.", + "parentId": "undefined" }, { "id": 3, @@ -109,7 +116,8 @@ ], "details": "Implement src/db/prisma/schema.prisma with all models. Add version Int field and updatedAt DateTime for git-based backup support.", "status": "pending", - "testStrategy": "Run TDD tests from subtask 2 - all should now pass (TDD green phase). Verify npx prisma validate passes." + "testStrategy": "Run TDD tests from subtask 2 - all should now pass (TDD green phase). Verify npx prisma validate passes.", + "parentId": "undefined" }, { "id": 4, @@ -120,7 +128,8 @@ ], "details": "Create src/db/seed directory with server definitions and seeding functions for Slack, Jira, GitHub, Terraform MCP servers.", "status": "pending", - "testStrategy": "Write unit tests BEFORE implementing seed functions (TDD). Verify seedMcpServers() creates exactly 4 servers with idempotent behavior." + "testStrategy": "Write unit tests BEFORE implementing seed functions (TDD). Verify seedMcpServers() creates exactly 4 servers with idempotent behavior.", + "parentId": "undefined" }, { "id": 5, @@ -132,12 +141,14 @@ ], "details": "Run npx prisma migrate dev --name init. Create src/db/src/migration-helpers.ts. Document security and architecture findings.", "status": "pending", - "testStrategy": "Verify migration files exist, migration helper tests pass, SECURITY_REVIEW.md covers all security checkpoints." + "testStrategy": "Verify migration files exist, migration helper tests pass, SECURITY_REVIEW.md covers all security checkpoints.", + "parentId": "undefined" } - ] + ], + "updatedAt": "2026-02-21T04:10:25.433Z" }, { - "id": 3, + "id": "3", "title": "Implement mcpd Core Server Framework", "description": "Build the mcpd daemon server with Express/Fastify, including middleware for authentication, logging, and error handling. Design for horizontal scalability.", "details": "Create mcpd server in src/mcpd/src/ with Fastify, health check endpoint, auth middleware, and audit logging. Design for statelessness and scalability.", @@ -147,7 +158,7 @@ "1", "2" ], - "status": "pending", + "status": "done", "subtasks": [ { "id": 1, @@ -156,7 +167,8 @@ "dependencies": [], "details": "Create src/mcpd/src/ with routes/, controllers/, services/, repositories/, middleware/, config/, types/, utils/ directories.", "status": "pending", - "testStrategy": "Write initial Vitest tests that verify all required directories exist, package.json has required dependencies." + "testStrategy": "Write initial Vitest tests that verify all required directories exist, package.json has required dependencies.", + "parentId": "undefined" }, { "id": 2, @@ -167,7 +179,8 @@ ], "details": "Create src/mcpd/src/server.ts with Fastify instance factory function. Implement config validation with Zod and health endpoint.", "status": "pending", - "testStrategy": "TDD approach - write tests first for config validation, health endpoint returns correct structure." + "testStrategy": "TDD approach - write tests first for config validation, health endpoint returns correct structure.", + "parentId": "undefined" }, { "id": 3, @@ -178,7 +191,8 @@ ], "details": "Create src/mcpd/src/middleware/auth.ts with authMiddleware factory function using dependency injection.", "status": "pending", - "testStrategy": "TDD - write all tests before implementation for 401 responses, token validation, request decoration." + "testStrategy": "TDD - write all tests before implementation for 401 responses, token validation, request decoration.", + "parentId": "undefined" }, { "id": 4, @@ -189,7 +203,8 @@ ], "details": "Create src/mcpd/src/middleware/security.ts with registerSecurityPlugins function. Create sanitization and validation utilities.", "status": "pending", - "testStrategy": "TDD tests for CORS headers, Helmet security headers, rate limiting returns 429, input validation." + "testStrategy": "TDD tests for CORS headers, Helmet security headers, rate limiting returns 429, input validation.", + "parentId": "undefined" }, { "id": 5, @@ -202,12 +217,14 @@ ], "details": "Create error-handler.ts, audit.ts middleware, and shutdown.ts utilities in src/mcpd/src/.", "status": "pending", - "testStrategy": "TDD for all components: error handler HTTP codes, audit middleware creates records, graceful shutdown handles SIGTERM." + "testStrategy": "TDD for all components: error handler HTTP codes, audit middleware creates records, graceful shutdown handles SIGTERM.", + "parentId": "undefined" } - ] + ], + "updatedAt": "2026-02-21T04:21:50.389Z" }, { - "id": 4, + "id": "4", "title": "Implement MCP Server Registry and Profile Management", "description": "Create APIs for registering MCP servers, managing profiles with different permission levels, and storing configuration templates.", "details": "Create REST API endpoints in mcpd for MCP server and profile CRUD operations with seed data for common servers.", @@ -216,7 +233,7 @@ "dependencies": [ "3" ], - "status": "pending", + "status": "done", "subtasks": [ { "id": 1, @@ -225,7 +242,8 @@ "dependencies": [], "details": "Create src/mcpd/src/validation/mcp-server.schema.ts with CreateMcpServerSchema, UpdateMcpServerSchema, CreateMcpProfileSchema.", "status": "pending", - "testStrategy": "TDD approach - write all tests first, then implement schemas. Tests verify valid inputs pass, invalid inputs fail." + "testStrategy": "TDD approach - write all tests first, then implement schemas. Tests verify valid inputs pass, invalid inputs fail.", + "parentId": "undefined" }, { "id": 2, @@ -236,7 +254,8 @@ ], "details": "Create src/mcpd/src/repositories/interfaces.ts with IMcpServerRepository and IMcpProfileRepository interfaces.", "status": "pending", - "testStrategy": "TDD - write tests before implementation with mocked PrismaClient. Verify all repository methods are covered." + "testStrategy": "TDD - write tests before implementation with mocked PrismaClient. Verify all repository methods are covered.", + "parentId": "undefined" }, { "id": 3, @@ -248,7 +267,8 @@ ], "details": "Create src/mcpd/src/services/mcp-server.service.ts and mcp-profile.service.ts with DI and authorization checks.", "status": "pending", - "testStrategy": "TDD - write tests first mocking repositories and authorization. Verify authorization checks are called for every method." + "testStrategy": "TDD - write tests first mocking repositories and authorization. Verify authorization checks are called for every method.", + "parentId": "undefined" }, { "id": 4, @@ -259,7 +279,8 @@ ], "details": "Create src/mcpd/src/routes/mcp-servers.ts and mcp-profiles.ts with all CRUD endpoints.", "status": "pending", - "testStrategy": "Write integration tests before implementation using Fastify.inject(). Test with docker-compose postgres." + "testStrategy": "Write integration tests before implementation using Fastify.inject(). Test with docker-compose postgres.", + "parentId": "undefined" }, { "id": 5, @@ -270,12 +291,14 @@ ], "details": "Create src/mcpd/src/seed/mcp-servers.seed.ts with seedMcpServers() function and SECURITY_REVIEW.md.", "status": "pending", - "testStrategy": "Write unit tests for seed functions. Security tests for injection prevention, authorization checks." + "testStrategy": "Write unit tests for seed functions. Security tests for injection prevention, authorization checks.", + "parentId": "undefined" } - ] + ], + "updatedAt": "2026-02-21T04:26:06.239Z" }, { - "id": 5, + "id": "5", "title": "Implement Project Management APIs", "description": "Create APIs for managing MCP projects that group multiple MCP profiles together for easy assignment to Claude sessions.", "details": "Create project management endpoints with generateMcpConfig function for .mcp.json format output.", @@ -293,7 +316,8 @@ "dependencies": [], "details": "Create tests for CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema, and generateMcpConfig with security tests.", "status": "pending", - "testStrategy": "TDD red phase - all tests should fail initially. Verify generateMcpConfig security tests check secret env vars are excluded." + "testStrategy": "TDD red phase - all tests should fail initially. Verify generateMcpConfig security tests check secret env vars are excluded.", + "parentId": "undefined" }, { "id": 2, @@ -304,7 +328,8 @@ ], "details": "Create src/mcpd/src/repositories/project.repository.ts and src/mcpd/src/services/mcp-config-generator.ts.", "status": "pending", - "testStrategy": "Run TDD tests from subtask 1. Verify output must NOT contain secret values." + "testStrategy": "Run TDD tests from subtask 1. Verify output must NOT contain secret values.", + "parentId": "undefined" }, { "id": 3, @@ -315,7 +340,8 @@ ], "details": "Create src/mcpd/src/services/project.service.ts with DI accepting IProjectRepository and IMcpProfileRepository.", "status": "pending", - "testStrategy": "TDD - write tests before implementation. Verify authorization and profile validation." + "testStrategy": "TDD - write tests before implementation. Verify authorization and profile validation.", + "parentId": "undefined" }, { "id": 4, @@ -326,7 +352,8 @@ ], "details": "Create src/mcpd/src/routes/projects.ts with all CRUD routes and mcp-config endpoint.", "status": "pending", - "testStrategy": "Integration tests using Fastify.inject(). Verify mcp-config returns valid structure WITHOUT secret env vars." + "testStrategy": "Integration tests using Fastify.inject(). Verify mcp-config returns valid structure WITHOUT secret env vars.", + "parentId": "undefined" }, { "id": 5, @@ -337,12 +364,13 @@ ], "details": "Create src/mcpd/tests/integration/projects.test.ts with end-to-end scenarios and SECURITY_REVIEW.md section.", "status": "pending", - "testStrategy": "Run full integration test suite. Verify coverage >85% for project-related files." + "testStrategy": "Run full integration test suite. Verify coverage >85% for project-related files.", + "parentId": "undefined" } ] }, { - "id": 6, + "id": "6", "title": "Implement Docker Container Management for MCP Servers", "description": "Create the container orchestration layer for running MCP servers as Docker containers, with support for docker-compose deployment.", "details": "Create Docker management module with ContainerManager class using dockerode. Create deploy/docker-compose.yml template.", @@ -361,7 +389,8 @@ "dependencies": [], "details": "Create src/mcpd/src/services/orchestrator.ts interface and TDD tests for ContainerManager methods.", "status": "pending", - "testStrategy": "Run tests to verify they exist and fail with expected errors. Coverage target: 100% of interface methods." + "testStrategy": "Run tests to verify they exist and fail with expected errors. Coverage target: 100% of interface methods.", + "parentId": "undefined" }, { "id": 2, @@ -372,7 +401,8 @@ ], "details": "Create src/mcpd/src/services/docker/container-manager.ts implementing McpOrchestrator interface.", "status": "pending", - "testStrategy": "Run unit tests from subtask 1. Verify TypeScript compilation and resource limits." + "testStrategy": "Run unit tests from subtask 1. Verify TypeScript compilation and resource limits.", + "parentId": "undefined" }, { "id": 3, @@ -381,7 +411,8 @@ "dependencies": [], "details": "Create deploy/docker-compose.yml with mcpd, postgres, and test-mcp-server services with proper networking.", "status": "pending", - "testStrategy": "Validate with docker-compose config. Run docker-compose up -d and verify all services start." + "testStrategy": "Validate with docker-compose config. Run docker-compose up -d and verify all services start.", + "parentId": "undefined" }, { "id": 4, @@ -393,7 +424,8 @@ ], "details": "Create src/mcpd/src/services/docker/__tests__/container-manager.integration.test.ts.", "status": "pending", - "testStrategy": "Run integration tests with pnpm --filter @mcpctl/mcpd test:integration. Verify containers are created/destroyed." + "testStrategy": "Run integration tests with pnpm --filter @mcpctl/mcpd test:integration. Verify containers are created/destroyed.", + "parentId": "undefined" }, { "id": 5, @@ -404,7 +436,8 @@ ], "details": "Create src/mcpd/src/services/docker/network-manager.ts with network isolation and resource management.", "status": "pending", - "testStrategy": "Unit tests for network creation. Integration test: verify container network isolation." + "testStrategy": "Unit tests for network creation. Integration test: verify container network isolation.", + "parentId": "undefined" }, { "id": 6, @@ -417,7 +450,8 @@ ], "details": "Create src/mcpd/docs/DOCKER_SECURITY_REVIEW.md documenting risks and mitigations.", "status": "pending", - "testStrategy": "Review DOCKER_SECURITY_REVIEW.md covers all 6 security areas. Run security unit tests." + "testStrategy": "Review DOCKER_SECURITY_REVIEW.md covers all 6 security areas. Run security unit tests.", + "parentId": "undefined" }, { "id": 7, @@ -428,12 +462,13 @@ ], "details": "Extend ContainerManager with getLogs, getHealthStatus, attachToContainer, and event subscriptions.", "status": "pending", - "testStrategy": "Unit tests for getLogs. Integration test: run container, tail logs, verify output." + "testStrategy": "Unit tests for getLogs. Integration test: run container, tail logs, verify output.", + "parentId": "undefined" } ] }, { - "id": 7, + "id": "7", "title": "Build mcpctl CLI Core Framework", "description": "Create the CLI tool foundation using Commander.js with kubectl-inspired command structure, configuration management, and server communication.", "details": "Create CLI in src/cli/src/ with Commander.js, configuration management at ~/.mcpctl/config.json, and API client for mcpd.", @@ -442,7 +477,7 @@ "dependencies": [ "1" ], - "status": "pending", + "status": "done", "subtasks": [ { "id": 1, @@ -451,7 +486,8 @@ "dependencies": [], "details": "Create src/cli/src/ with commands/, config/, client/, formatters/, utils/, types/ directories and registry pattern.", "status": "pending", - "testStrategy": "TDD approach - write tests first. Tests verify CLI shows version, help, CommandRegistry works." + "testStrategy": "TDD approach - write tests first. Tests verify CLI shows version, help, CommandRegistry works.", + "parentId": "undefined" }, { "id": 2, @@ -462,12 +498,14 @@ ], "details": "Implement config management with proxy settings, custom CA certificates support, and Zod validation.", "status": "pending", - "testStrategy": "TDD tests for config loading, saving, validation, and credential encryption." + "testStrategy": "TDD tests for config loading, saving, validation, and credential encryption.", + "parentId": "undefined" } - ] + ], + "updatedAt": "2026-02-21T04:17:17.744Z" }, { - "id": 8, + "id": "8", "title": "Implement mcpctl get and describe Commands", "description": "Create kubectl-style get and describe commands for viewing MCP servers, profiles, projects, and instances.", "details": "Implement get command with table/json/yaml output formats and describe command for detailed views.", @@ -477,10 +515,10 @@ "7" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 9, + "id": "9", "title": "Implement mcpctl apply and setup Commands", "description": "Create apply command for declarative configuration and setup wizard for interactive MCP server configuration.", "details": "Implement apply command for YAML/JSON config files and interactive setup wizard with credential prompts.", @@ -491,10 +529,10 @@ "4" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 10, + "id": "10", "title": "Implement mcpctl claude and project Commands", "description": "Create commands for managing Claude MCP configuration and project assignments.", "details": "Implement claude command for managing .mcp.json files and project command for project management.", @@ -505,10 +543,10 @@ "5" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 11, + "id": "11", "title": "Design Local LLM Proxy Architecture", "description": "Design the architecture for the local LLM proxy that enables Claude to use MCP servers through a local intermediary.", "details": "Design proxy architecture in src/local-proxy/ with MCP protocol handling and request/response transformation.", @@ -518,10 +556,10 @@ "1" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 12, + "id": "12", "title": "Implement Local LLM Proxy Core", "description": "Build the core local proxy server that handles MCP protocol communication between Claude and MCP servers.", "details": "Implement proxy server in src/local-proxy/src/ with MCP SDK integration and request routing.", @@ -531,10 +569,10 @@ "11" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 13, + "id": "13", "title": "Implement LLM Provider Strategy Pattern", "description": "Create pluggable LLM provider support with strategy pattern for different providers (OpenAI, Anthropic, local models).", "details": "Implement provider strategy pattern in src/local-proxy/src/providers/ with adapters for different LLM APIs.", @@ -544,10 +582,10 @@ "12" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 14, + "id": "14", "title": "Implement Audit Logging and Compliance", "description": "Create comprehensive audit logging system for tracking all MCP operations for compliance and debugging.", "details": "Implement audit logging in src/mcpd/src/services/ with structured logging, retention policies, and query APIs.", @@ -557,10 +595,10 @@ "3" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 15, + "id": "15", "title": "Create MCP Profiles Library", "description": "Build a library of pre-configured MCP profiles for common use cases with best practices baked in.", "details": "Create profile library in src/shared/src/profiles/ with templates for common MCP server configurations.", @@ -570,10 +608,10 @@ "4" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 16, + "id": "16", "title": "Implement MCP Instance Lifecycle Management", "description": "Create APIs and CLI commands for managing the full lifecycle of MCP server instances.", "details": "Implement instance lifecycle management in src/mcpd/src/services/ with start, stop, restart, logs commands.", @@ -583,10 +621,10 @@ "6" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 17, + "id": "17", "title": "Add Kubernetes Deployment Support", "description": "Extend the orchestration layer to support Kubernetes deployments for production environments.", "details": "Implement KubernetesOrchestrator in src/mcpd/src/services/k8s/ implementing McpOrchestrator interface.", @@ -596,10 +634,10 @@ "6" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 18, + "id": "18", "title": "Documentation and Testing", "description": "Create comprehensive documentation and end-to-end test suite for the entire mcpctl system.", "details": "Create documentation in docs/ and e2e tests in tests/e2e/ covering all major workflows.", @@ -612,10 +650,10 @@ "10" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 19, + "id": "19", "title": "CANCELLED - Auth middleware", "description": "Merged into Task 3 subtasks", "details": null, @@ -623,11 +661,11 @@ "priority": null, "dependencies": [], "status": "cancelled", - "subtasks": null, + "subtasks": [], "updatedAt": "2026-02-21T02:21:03.958Z" }, { - "id": 20, + "id": "20", "title": "CANCELLED - Duplicate project management", "description": "Merged into Task 5", "details": null, @@ -635,11 +673,11 @@ "priority": null, "dependencies": [], "status": "cancelled", - "subtasks": null, + "subtasks": [], "updatedAt": "2026-02-21T02:21:03.966Z" }, { - "id": 21, + "id": "21", "title": "CANCELLED - Duplicate audit logging", "description": "Merged into Task 14", "details": null, @@ -647,11 +685,11 @@ "priority": null, "dependencies": [], "status": "cancelled", - "subtasks": null, + "subtasks": [], "updatedAt": "2026-02-21T02:21:03.972Z" }, { - "id": 22, + "id": "22", "title": "Implement Health Monitoring Dashboard", "description": "Create a monitoring dashboard for tracking MCP server health, resource usage, and system metrics.", "details": "Implement health monitoring endpoints in src/mcpd/src/routes/ and optional web dashboard.", @@ -662,10 +700,10 @@ "14" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 23, + "id": "23", "title": "Implement Backup and Restore", "description": "Create backup and restore functionality for mcpctl configuration and state.", "details": "Implement git-based backup in src/mcpd/src/services/backup/ with encrypted secrets and restore capability.", @@ -676,10 +714,10 @@ "5" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 24, + "id": "24", "title": "CI/CD Pipeline Setup", "description": "Set up continuous integration and deployment pipelines for the mcpctl project.", "details": "Create GitHub Actions workflows in .github/workflows/ for testing, building, and releasing.", @@ -689,13 +727,17 @@ "1" ], "status": "pending", - "subtasks": null + "subtasks": [] } ], "metadata": { - "created": "2026-02-21T02:23:17.813Z", - "updated": "2026-02-21T02:23:17.813Z", - "description": "Tasks for master context" + "version": "1.0.0", + "lastModified": "2026-02-21T04:26:06.239Z", + "taskCount": 24, + "completedCount": 5, + "tags": [ + "master" + ] } } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c46283..8fe86b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0)) + version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)) eslint: specifier: ^10.0.1 version: 10.0.1(jiti@2.6.1) @@ -34,7 +34,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(jiti@2.6.1)(tsx@4.21.0) + version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0) src/cli: dependencies: @@ -52,10 +52,20 @@ importers: version: 13.1.0 inquirer: specifier: ^12.0.0 - version: 12.11.1 + version: 12.11.1(@types/node@25.3.0) js-yaml: specifier: ^4.1.0 version: 4.1.1 + zod: + specifier: ^3.24.0 + version: 3.25.76 + devDependencies: + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^25.3.0 + version: 25.3.0 src/db: dependencies: @@ -96,12 +106,19 @@ importers: '@mcpctl/shared': specifier: workspace:* version: link:../shared + '@prisma/client': + specifier: ^6.0.0 + version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) fastify: specifier: ^5.0.0 version: 5.7.4 zod: specifier: ^3.24.0 version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^25.3.0 + version: 25.3.0 src/shared: dependencies: @@ -698,9 +715,15 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.3.0': + resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@typescript-eslint/eslint-plugin@8.56.0': resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1795,6 +1818,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -2098,100 +2124,128 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2': + '@inquirer/checkbox@4.3.2(@types/node@25.3.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2 + '@inquirer/core': 10.3.2(@types/node@25.3.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10 + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/confirm@5.1.21': + '@inquirer/confirm@5.1.21(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/core@10.3.2': + '@inquirer/core@10.3.2(@types/node@25.3.0)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10 + '@inquirer/type': 3.0.10(@types/node@25.3.0) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/editor@4.2.23': + '@inquirer/editor@4.2.23(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 - '@inquirer/external-editor': 1.0.3 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/external-editor': 1.0.3(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/expand@4.0.23': + '@inquirer/expand@4.0.23(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/external-editor@1.0.3': + '@inquirer/external-editor@1.0.3(@types/node@25.3.0)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 25.3.0 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1': + '@inquirer/input@4.3.1(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/number@3.0.23': + '@inquirer/number@3.0.23(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/password@4.0.23': + '@inquirer/password@4.0.23(@types/node@25.3.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/prompts@7.10.1': + '@inquirer/prompts@7.10.1(@types/node@25.3.0)': dependencies: - '@inquirer/checkbox': 4.3.2 - '@inquirer/confirm': 5.1.21 - '@inquirer/editor': 4.2.23 - '@inquirer/expand': 4.0.23 - '@inquirer/input': 4.3.1 - '@inquirer/number': 3.0.23 - '@inquirer/password': 4.0.23 - '@inquirer/rawlist': 4.1.11 - '@inquirer/search': 3.2.2 - '@inquirer/select': 4.4.2 + '@inquirer/checkbox': 4.3.2(@types/node@25.3.0) + '@inquirer/confirm': 5.1.21(@types/node@25.3.0) + '@inquirer/editor': 4.2.23(@types/node@25.3.0) + '@inquirer/expand': 4.0.23(@types/node@25.3.0) + '@inquirer/input': 4.3.1(@types/node@25.3.0) + '@inquirer/number': 3.0.23(@types/node@25.3.0) + '@inquirer/password': 4.0.23(@types/node@25.3.0) + '@inquirer/rawlist': 4.1.11(@types/node@25.3.0) + '@inquirer/search': 3.2.2(@types/node@25.3.0) + '@inquirer/select': 4.4.2(@types/node@25.3.0) + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/rawlist@4.1.11': + '@inquirer/rawlist@4.1.11(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/search@3.2.2': + '@inquirer/search@3.2.2(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 + '@inquirer/core': 10.3.2(@types/node@25.3.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10 + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/select@4.4.2': + '@inquirer/select@4.4.2(@types/node@25.3.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2 + '@inquirer/core': 10.3.2(@types/node@25.3.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10 + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/type@3.0.10': {} + '@inquirer/type@3.0.10(@types/node@25.3.0)': + optionalDependencies: + '@types/node': 25.3.0 '@jridgewell/resolve-uri@3.1.2': {} @@ -2351,8 +2405,14 @@ snapshots: '@types/estree@1.0.8': {} + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/node@25.3.0': + dependencies: + undici-types: 7.18.2 + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2444,7 +2504,7 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.1 - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -2456,7 +2516,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(jiti@2.6.1)(tsx@4.21.0) + vitest: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0) '@vitest/expect@4.0.18': dependencies: @@ -2467,13 +2527,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(jiti@2.6.1)(tsx@4.21.0))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(jiti@2.6.1)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0) '@vitest/pretty-format@4.0.18': dependencies: @@ -3033,15 +3093,17 @@ snapshots: inherits@2.0.4: {} - inquirer@12.11.1: + inquirer@12.11.1(@types/node@25.3.0): dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2 - '@inquirer/prompts': 7.10.1 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/prompts': 7.10.1(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) mute-stream: 2.0.0 run-async: 4.0.6 rxjs: 7.8.2 + optionalDependencies: + '@types/node': 25.3.0 ip-address@10.0.1: {} @@ -3532,6 +3594,8 @@ snapshots: typescript@5.9.3: {} + undici-types@7.18.2: {} + unpipe@1.0.0: {} uri-js@4.4.1: @@ -3540,7 +3604,7 @@ snapshots: vary@1.1.2: {} - vite@7.3.1(jiti@2.6.1)(tsx@4.21.0): + vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -3549,14 +3613,15 @@ snapshots: rollup: 4.58.0 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 25.3.0 fsevents: 2.3.3 jiti: 2.6.1 tsx: 4.21.0 - vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0): + vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(jiti@2.6.1)(tsx@4.21.0)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -3573,8 +3638,10 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(jiti@2.6.1)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.3.0 transitivePeerDependencies: - jiti - less diff --git a/src/cli/package.json b/src/cli/package.json index f860d28..c99cd2c 100644 --- a/src/cli/package.json +++ b/src/cli/package.json @@ -16,11 +16,16 @@ "test:run": "vitest run" }, "dependencies": { - "commander": "^13.0.0", + "@mcpctl/db": "workspace:*", + "@mcpctl/shared": "workspace:*", "chalk": "^5.4.0", + "commander": "^13.0.0", "inquirer": "^12.0.0", "js-yaml": "^4.1.0", - "@mcpctl/shared": "workspace:*", - "@mcpctl/db": "workspace:*" + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^25.3.0" } } diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts new file mode 100644 index 0000000..cd011f0 --- /dev/null +++ b/src/cli/src/commands/config.ts @@ -0,0 +1,69 @@ +import { Command } from 'commander'; +import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js'; +import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js'; +import { formatJson, formatYaml } from '../formatters/index.js'; + +export interface ConfigCommandDeps { + configDeps: Partial; + log: (...args: string[]) => void; +} + +const defaultDeps: ConfigCommandDeps = { + configDeps: {}, + log: (...args) => console.log(...args), +}; + +export function createConfigCommand(deps?: Partial): Command { + const { configDeps, log } = { ...defaultDeps, ...deps }; + + const config = new Command('config').description('Manage mcpctl configuration'); + + config + .command('view') + .description('Show current configuration') + .option('-o, --output ', 'output format (json, yaml)', 'json') + .action((opts: { output: string }) => { + const cfg = loadConfig(configDeps); + const out = opts.output === 'yaml' ? formatYaml(cfg) : formatJson(cfg); + log(out); + }); + + config + .command('set') + .description('Set a configuration value') + .argument('', 'configuration key (e.g., daemonUrl, outputFormat)') + .argument('', 'value to set') + .action((key: string, value: string) => { + const updates: Record = {}; + + // Handle typed conversions + if (key === 'cacheTTLMs') { + updates[key] = parseInt(value, 10); + } else if (key === 'registries') { + updates[key] = value.split(',').map((s) => s.trim()); + } else { + updates[key] = value; + } + + const updated = mergeConfig(updates as Partial, configDeps); + saveConfig(updated, configDeps); + log(`Set ${key} = ${value}`); + }); + + config + .command('path') + .description('Show configuration file path') + .action(() => { + log(getConfigPath(configDeps?.configDir)); + }); + + config + .command('reset') + .description('Reset configuration to defaults') + .action(() => { + saveConfig(DEFAULT_CONFIG, configDeps); + log('Configuration reset to defaults'); + }); + + return config; +} diff --git a/src/cli/src/commands/status.ts b/src/cli/src/commands/status.ts new file mode 100644 index 0000000..24324ec --- /dev/null +++ b/src/cli/src/commands/status.ts @@ -0,0 +1,63 @@ +import { Command } from 'commander'; +import http from 'node:http'; +import { loadConfig } from '../config/index.js'; +import type { ConfigLoaderDeps } from '../config/index.js'; +import { formatJson, formatYaml } from '../formatters/index.js'; +import { APP_VERSION } from '@mcpctl/shared'; + +export interface StatusCommandDeps { + configDeps: Partial; + log: (...args: string[]) => void; + checkDaemon: (url: string) => Promise; +} + +function defaultCheckDaemon(url: string): Promise { + return new Promise((resolve) => { + const req = http.get(`${url}/health`, { timeout: 3000 }, (res) => { + resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 400); + res.resume(); + }); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + }); +} + +const defaultDeps: StatusCommandDeps = { + configDeps: {}, + log: (...args) => console.log(...args), + checkDaemon: defaultCheckDaemon, +}; + +export function createStatusCommand(deps?: Partial): Command { + const { configDeps, log, checkDaemon } = { ...defaultDeps, ...deps }; + + return new Command('status') + .description('Show mcpctl status and connectivity') + .option('-o, --output ', 'output format (table, json, yaml)', 'table') + .action(async (opts: { output: string }) => { + const config = loadConfig(configDeps); + const daemonReachable = await checkDaemon(config.daemonUrl); + + const status = { + version: APP_VERSION, + daemonUrl: config.daemonUrl, + daemonReachable, + registries: config.registries, + outputFormat: config.outputFormat, + }; + + if (opts.output === 'json') { + log(formatJson(status)); + } else if (opts.output === 'yaml') { + log(formatYaml(status)); + } else { + log(`mcpctl v${status.version}`); + log(`Daemon: ${status.daemonUrl} (${daemonReachable ? 'connected' : 'unreachable'})`); + log(`Registries: ${status.registries.join(', ')}`); + log(`Output: ${status.outputFormat}`); + } + }); +} diff --git a/src/cli/src/config/index.ts b/src/cli/src/config/index.ts new file mode 100644 index 0000000..8765cf8 --- /dev/null +++ b/src/cli/src/config/index.ts @@ -0,0 +1,4 @@ +export { McpctlConfigSchema, DEFAULT_CONFIG } from './schema.js'; +export type { McpctlConfig } from './schema.js'; +export { loadConfig, saveConfig, mergeConfig, getConfigPath } from './loader.js'; +export type { ConfigLoaderDeps } from './loader.js'; diff --git a/src/cli/src/config/loader.ts b/src/cli/src/config/loader.ts new file mode 100644 index 0000000..fd79823 --- /dev/null +++ b/src/cli/src/config/loader.ts @@ -0,0 +1,45 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { McpctlConfigSchema, DEFAULT_CONFIG } from './schema.js'; +import type { McpctlConfig } from './schema.js'; + +export interface ConfigLoaderDeps { + configDir: string; +} + +function defaultConfigDir(): string { + return join(homedir(), '.mcpctl'); +} + +export function getConfigPath(configDir?: string): string { + return join(configDir ?? defaultConfigDir(), 'config.json'); +} + +export function loadConfig(deps?: Partial): McpctlConfig { + const configPath = getConfigPath(deps?.configDir); + + if (!existsSync(configPath)) { + return DEFAULT_CONFIG; + } + + const raw = readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + return McpctlConfigSchema.parse(parsed); +} + +export function saveConfig(config: McpctlConfig, deps?: Partial): void { + const dir = deps?.configDir ?? defaultConfigDir(); + const configPath = getConfigPath(dir); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); +} + +export function mergeConfig(overrides: Partial, deps?: Partial): McpctlConfig { + const current = loadConfig(deps); + return McpctlConfigSchema.parse({ ...current, ...overrides }); +} diff --git a/src/cli/src/config/schema.ts b/src/cli/src/config/schema.ts new file mode 100644 index 0000000..5f4d78d --- /dev/null +++ b/src/cli/src/config/schema.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const McpctlConfigSchema = z.object({ + /** mcpd daemon endpoint */ + daemonUrl: z.string().default('http://localhost:3000'), + /** Active registries for search */ + registries: z.array(z.enum(['official', 'glama', 'smithery'])).default(['official', 'glama', 'smithery']), + /** Cache TTL in milliseconds */ + cacheTTLMs: z.number().int().positive().default(3_600_000), + /** HTTP proxy URL */ + httpProxy: z.string().optional(), + /** HTTPS proxy URL */ + httpsProxy: z.string().optional(), + /** Default output format */ + outputFormat: z.enum(['table', 'json', 'yaml']).default('table'), + /** Smithery API key */ + smitheryApiKey: z.string().optional(), +}); + +export type McpctlConfig = z.infer; + +export const DEFAULT_CONFIG: McpctlConfig = McpctlConfigSchema.parse({}); diff --git a/src/cli/src/formatters/index.ts b/src/cli/src/formatters/index.ts new file mode 100644 index 0000000..47e6f4a --- /dev/null +++ b/src/cli/src/formatters/index.ts @@ -0,0 +1,4 @@ +export { formatTable } from './table.js'; +export type { Column } from './table.js'; +export { formatJson, formatYaml } from './output.js'; +export type { OutputFormat } from './output.js'; diff --git a/src/cli/src/formatters/output.ts b/src/cli/src/formatters/output.ts new file mode 100644 index 0000000..cc3e894 --- /dev/null +++ b/src/cli/src/formatters/output.ts @@ -0,0 +1,11 @@ +import yaml from 'js-yaml'; + +export type OutputFormat = 'table' | 'json' | 'yaml'; + +export function formatJson(data: unknown): string { + return JSON.stringify(data, null, 2); +} + +export function formatYaml(data: unknown): string { + return yaml.dump(data, { lineWidth: 120, noRefs: true }).trimEnd(); +} diff --git a/src/cli/src/formatters/table.ts b/src/cli/src/formatters/table.ts new file mode 100644 index 0000000..7a2b8d2 --- /dev/null +++ b/src/cli/src/formatters/table.ts @@ -0,0 +1,44 @@ +export interface Column { + header: string; + key: keyof T | ((row: T) => string); + width?: number; + align?: 'left' | 'right'; +} + +export function formatTable(rows: T[], columns: Column[]): string { + if (rows.length === 0) { + return 'No results found.'; + } + + const getValue = (row: T, col: Column): string => { + if (typeof col.key === 'function') { + return col.key(row); + } + const val = row[col.key]; + return val == null ? '' : String(val); + }; + + // Calculate column widths + const widths = columns.map((col) => { + if (col.width !== undefined) return col.width; + const headerLen = col.header.length; + const maxDataLen = rows.reduce((max, row) => { + const val = getValue(row, col); + return Math.max(max, val.length); + }, 0); + return Math.max(headerLen, maxDataLen); + }); + + const pad = (text: string, width: number, align: 'left' | 'right' = 'left'): string => { + const truncated = text.length > width ? text.slice(0, width - 1) + '\u2026' : text; + return align === 'right' ? truncated.padStart(width) : truncated.padEnd(width); + }; + + const headerLine = columns.map((col, i) => pad(col.header, widths[i] ?? 0, col.align ?? 'left')).join(' '); + const separator = widths.map((w) => '-'.repeat(w)).join(' '); + const dataLines = rows.map((row) => + columns.map((col, i) => pad(getValue(row, col), widths[i] ?? 0, col.align ?? 'left')).join(' '), + ); + + return [headerLine, separator, ...dataLines].join('\n'); +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 4c6ac3f..a8b2b4f 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -1,2 +1,29 @@ -// mcpctl CLI entry point -// Will be implemented in Task 7 +#!/usr/bin/env node +import { Command } from 'commander'; +import { APP_NAME, APP_VERSION } from '@mcpctl/shared'; +import { createConfigCommand } from './commands/config.js'; +import { createStatusCommand } from './commands/status.js'; + +export function createProgram(): Command { + const program = new Command() + .name(APP_NAME) + .description('Manage MCP servers like kubectl manages containers') + .version(APP_VERSION, '-v, --version') + .option('-o, --output ', 'output format (table, json, yaml)', 'table') + .option('--daemon-url ', 'mcpd daemon URL'); + + program.addCommand(createConfigCommand()); + program.addCommand(createStatusCommand()); + + return program; +} + +// Run when invoked directly +const isDirectRun = + typeof process !== 'undefined' && + process.argv[1] !== undefined && + import.meta.url === `file://${process.argv[1]}`; + +if (isDirectRun) { + createProgram().parseAsync(process.argv); +} diff --git a/src/cli/src/registry/types.ts b/src/cli/src/registry/types.ts index 952d371..613152b 100644 --- a/src/cli/src/registry/types.ts +++ b/src/cli/src/registry/types.ts @@ -173,7 +173,7 @@ export type SmitheryServerEntry = z.infer; // ── Security utilities ── -const ANSI_ESCAPE_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F]|\x1b\[[0-9;]*[a-zA-Z]|\033\[[0-9;]*[a-zA-Z]/g; +const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*[a-zA-Z]|[\x00-\x08\x0B\x0C\x0E-\x1F]/g; export function sanitizeString(text: string): string { return text.replace(ANSI_ESCAPE_RE, ''); diff --git a/src/cli/tests/cli.test.ts b/src/cli/tests/cli.test.ts new file mode 100644 index 0000000..2a82ff7 --- /dev/null +++ b/src/cli/tests/cli.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { createProgram } from '../src/index.js'; + +describe('createProgram', () => { + it('creates a Commander program', () => { + const program = createProgram(); + expect(program.name()).toBe('mcpctl'); + }); + + it('has version flag', () => { + const program = createProgram(); + expect(program.version()).toBe('0.1.0'); + }); + + it('has config subcommand', () => { + const program = createProgram(); + const config = program.commands.find((c) => c.name() === 'config'); + expect(config).toBeDefined(); + }); + + it('has status subcommand', () => { + const program = createProgram(); + const status = program.commands.find((c) => c.name() === 'status'); + expect(status).toBeDefined(); + }); + + it('has output option', () => { + const program = createProgram(); + const opt = program.options.find((o) => o.long === '--output'); + expect(opt).toBeDefined(); + }); + + it('has daemon-url option', () => { + const program = createProgram(); + const opt = program.options.find((o) => o.long === '--daemon-url'); + expect(opt).toBeDefined(); + }); +}); diff --git a/src/cli/tests/commands/config.test.ts b/src/cli/tests/commands/config.test.ts new file mode 100644 index 0000000..bc029c1 --- /dev/null +++ b/src/cli/tests/commands/config.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createConfigCommand } from '../../src/commands/config.js'; +import { loadConfig, saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js'; + +let tempDir: string; +let output: string[]; + +function log(...args: string[]) { + output.push(args.join(' ')); +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-test-')); + output = []; +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); +}); + +function makeCommand() { + return createConfigCommand({ + configDeps: { configDir: tempDir }, + log, + }); +} + +describe('config view', () => { + it('outputs default config as JSON', async () => { + const cmd = makeCommand(); + await cmd.parseAsync(['view'], { from: 'user' }); + expect(output).toHaveLength(1); + const parsed = JSON.parse(output[0]) as Record; + expect(parsed['daemonUrl']).toBe('http://localhost:3000'); + }); + + it('outputs config as YAML with --output yaml', async () => { + const cmd = makeCommand(); + await cmd.parseAsync(['view', '-o', 'yaml'], { from: 'user' }); + expect(output[0]).toContain('daemonUrl:'); + }); +}); + +describe('config set', () => { + it('sets a string value', async () => { + const cmd = makeCommand(); + await cmd.parseAsync(['set', 'daemonUrl', 'http://new:9000'], { from: 'user' }); + expect(output[0]).toContain('daemonUrl'); + const config = loadConfig({ configDir: tempDir }); + expect(config.daemonUrl).toBe('http://new:9000'); + }); + + it('sets cacheTTLMs as integer', async () => { + const cmd = makeCommand(); + await cmd.parseAsync(['set', 'cacheTTLMs', '60000'], { from: 'user' }); + const config = loadConfig({ configDir: tempDir }); + expect(config.cacheTTLMs).toBe(60000); + }); + + it('sets registries as comma-separated list', async () => { + const cmd = makeCommand(); + await cmd.parseAsync(['set', 'registries', 'official,glama'], { from: 'user' }); + const config = loadConfig({ configDir: tempDir }); + expect(config.registries).toEqual(['official', 'glama']); + }); + + it('sets outputFormat', async () => { + const cmd = makeCommand(); + await cmd.parseAsync(['set', 'outputFormat', 'json'], { from: 'user' }); + const config = loadConfig({ configDir: tempDir }); + expect(config.outputFormat).toBe('json'); + }); +}); + +describe('config path', () => { + it('shows config file path', async () => { + const cmd = makeCommand(); + await cmd.parseAsync(['path'], { from: 'user' }); + expect(output[0]).toContain(tempDir); + expect(output[0]).toContain('config.json'); + }); +}); + +describe('config reset', () => { + it('resets to defaults', async () => { + // First set a custom value + saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom' }, { configDir: tempDir }); + + const cmd = makeCommand(); + await cmd.parseAsync(['reset'], { from: 'user' }); + expect(output[0]).toContain('reset'); + + const config = loadConfig({ configDir: tempDir }); + expect(config.daemonUrl).toBe(DEFAULT_CONFIG.daemonUrl); + }); +}); diff --git a/src/cli/tests/commands/status.test.ts b/src/cli/tests/commands/status.test.ts new file mode 100644 index 0000000..fefc48d --- /dev/null +++ b/src/cli/tests/commands/status.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createStatusCommand } from '../../src/commands/status.js'; +import { saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js'; + +let tempDir: string; +let output: string[]; + +function log(...args: string[]) { + output.push(args.join(' ')); +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-status-test-')); + output = []; +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('status command', () => { + it('shows status in table format', async () => { + const cmd = createStatusCommand({ + configDeps: { configDir: tempDir }, + log, + checkDaemon: async () => true, + }); + await cmd.parseAsync([], { from: 'user' }); + expect(output.join('\n')).toContain('mcpctl v'); + expect(output.join('\n')).toContain('connected'); + }); + + it('shows unreachable when daemon is down', async () => { + const cmd = createStatusCommand({ + configDeps: { configDir: tempDir }, + log, + checkDaemon: async () => false, + }); + await cmd.parseAsync([], { from: 'user' }); + expect(output.join('\n')).toContain('unreachable'); + }); + + it('shows status in JSON format', async () => { + const cmd = createStatusCommand({ + configDeps: { configDir: tempDir }, + log, + checkDaemon: async () => true, + }); + await cmd.parseAsync(['-o', 'json'], { from: 'user' }); + const parsed = JSON.parse(output[0]) as Record; + expect(parsed['version']).toBe('0.1.0'); + expect(parsed['daemonReachable']).toBe(true); + }); + + it('shows status in YAML format', async () => { + const cmd = createStatusCommand({ + configDeps: { configDir: tempDir }, + log, + checkDaemon: async () => false, + }); + await cmd.parseAsync(['-o', 'yaml'], { from: 'user' }); + expect(output[0]).toContain('daemonReachable: false'); + }); + + it('uses custom daemon URL from config', async () => { + saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom:5555' }, { configDir: tempDir }); + let checkedUrl = ''; + const cmd = createStatusCommand({ + configDeps: { configDir: tempDir }, + log, + checkDaemon: async (url) => { + checkedUrl = url; + return false; + }, + }); + await cmd.parseAsync([], { from: 'user' }); + expect(checkedUrl).toBe('http://custom:5555'); + }); + + it('shows registries from config', async () => { + saveConfig({ ...DEFAULT_CONFIG, registries: ['official'] }, { configDir: tempDir }); + const cmd = createStatusCommand({ + configDeps: { configDir: tempDir }, + log, + checkDaemon: async () => true, + }); + await cmd.parseAsync([], { from: 'user' }); + expect(output.join('\n')).toContain('official'); + expect(output.join('\n')).not.toContain('glama'); + }); +}); diff --git a/src/cli/tests/config/loader.test.ts b/src/cli/tests/config/loader.test.ts new file mode 100644 index 0000000..ce091f5 --- /dev/null +++ b/src/cli/tests/config/loader.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../../src/config/index.js'; + +let tempDir: string; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-test-')); +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('getConfigPath', () => { + it('returns path within config dir', () => { + const path = getConfigPath('/tmp/mcpctl'); + expect(path).toBe('/tmp/mcpctl/config.json'); + }); +}); + +describe('loadConfig', () => { + it('returns defaults when no config file exists', () => { + const config = loadConfig({ configDir: tempDir }); + expect(config).toEqual(DEFAULT_CONFIG); + }); + + it('loads config from file', () => { + saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom:5000' }, { configDir: tempDir }); + const config = loadConfig({ configDir: tempDir }); + expect(config.daemonUrl).toBe('http://custom:5000'); + }); + + it('applies defaults for missing fields', () => { + const { writeFileSync } = require('node:fs') as typeof import('node:fs'); + writeFileSync(join(tempDir, 'config.json'), '{"daemonUrl":"http://x:1"}'); + const config = loadConfig({ configDir: tempDir }); + expect(config.daemonUrl).toBe('http://x:1'); + expect(config.registries).toEqual(['official', 'glama', 'smithery']); + }); +}); + +describe('saveConfig', () => { + it('creates config file', () => { + saveConfig(DEFAULT_CONFIG, { configDir: tempDir }); + expect(existsSync(join(tempDir, 'config.json'))).toBe(true); + }); + + it('creates config directory if missing', () => { + const nested = join(tempDir, 'nested', 'dir'); + saveConfig(DEFAULT_CONFIG, { configDir: nested }); + expect(existsSync(join(nested, 'config.json'))).toBe(true); + }); + + it('round-trips configuration', () => { + const custom = { + ...DEFAULT_CONFIG, + daemonUrl: 'http://custom:9000', + registries: ['official' as const], + outputFormat: 'json' as const, + }; + saveConfig(custom, { configDir: tempDir }); + const loaded = loadConfig({ configDir: tempDir }); + expect(loaded).toEqual(custom); + }); +}); + +describe('mergeConfig', () => { + it('merges overrides into existing config', () => { + saveConfig(DEFAULT_CONFIG, { configDir: tempDir }); + const merged = mergeConfig({ daemonUrl: 'http://new:1234' }, { configDir: tempDir }); + expect(merged.daemonUrl).toBe('http://new:1234'); + expect(merged.registries).toEqual(DEFAULT_CONFIG.registries); + }); + + it('works when no config file exists', () => { + const merged = mergeConfig({ outputFormat: 'yaml' }, { configDir: tempDir }); + expect(merged.outputFormat).toBe('yaml'); + expect(merged.daemonUrl).toBe('http://localhost:3000'); + }); +}); diff --git a/src/cli/tests/config/schema.test.ts b/src/cli/tests/config/schema.test.ts new file mode 100644 index 0000000..785f48d --- /dev/null +++ b/src/cli/tests/config/schema.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { McpctlConfigSchema, DEFAULT_CONFIG } from '../../src/config/schema.js'; + +describe('McpctlConfigSchema', () => { + it('provides sensible defaults from empty object', () => { + const config = McpctlConfigSchema.parse({}); + expect(config.daemonUrl).toBe('http://localhost:3000'); + expect(config.registries).toEqual(['official', 'glama', 'smithery']); + expect(config.cacheTTLMs).toBe(3_600_000); + expect(config.outputFormat).toBe('table'); + expect(config.httpProxy).toBeUndefined(); + expect(config.httpsProxy).toBeUndefined(); + expect(config.smitheryApiKey).toBeUndefined(); + }); + + it('validates a full config', () => { + const config = McpctlConfigSchema.parse({ + daemonUrl: 'http://custom:4000', + registries: ['official'], + cacheTTLMs: 60_000, + httpProxy: 'http://proxy:8080', + httpsProxy: 'http://proxy:8443', + outputFormat: 'json', + smitheryApiKey: 'sk-test', + }); + expect(config.daemonUrl).toBe('http://custom:4000'); + expect(config.registries).toEqual(['official']); + expect(config.outputFormat).toBe('json'); + }); + + it('rejects invalid registry names', () => { + expect(() => McpctlConfigSchema.parse({ registries: ['invalid'] })).toThrow(); + }); + + it('rejects invalid output format', () => { + expect(() => McpctlConfigSchema.parse({ outputFormat: 'xml' })).toThrow(); + }); + + it('rejects negative cacheTTLMs', () => { + expect(() => McpctlConfigSchema.parse({ cacheTTLMs: -1 })).toThrow(); + }); + + it('rejects non-integer cacheTTLMs', () => { + expect(() => McpctlConfigSchema.parse({ cacheTTLMs: 1.5 })).toThrow(); + }); +}); + +describe('DEFAULT_CONFIG', () => { + it('matches schema defaults', () => { + expect(DEFAULT_CONFIG).toEqual(McpctlConfigSchema.parse({})); + }); +}); diff --git a/src/cli/tests/formatters/output.test.ts b/src/cli/tests/formatters/output.test.ts new file mode 100644 index 0000000..b3b09a2 --- /dev/null +++ b/src/cli/tests/formatters/output.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { formatJson, formatYaml } from '../../src/formatters/output.js'; + +describe('formatJson', () => { + it('formats object as indented JSON', () => { + const result = formatJson({ key: 'value', num: 42 }); + expect(JSON.parse(result)).toEqual({ key: 'value', num: 42 }); + expect(result).toContain('\n'); // indented + }); + + it('formats arrays', () => { + const result = formatJson([1, 2, 3]); + expect(JSON.parse(result)).toEqual([1, 2, 3]); + }); + + it('handles null and undefined values', () => { + const result = formatJson({ a: null, b: undefined }); + const parsed = JSON.parse(result) as Record; + expect(parsed['a']).toBeNull(); + expect('b' in parsed).toBe(false); // undefined stripped by JSON + }); +}); + +describe('formatYaml', () => { + it('formats object as YAML', () => { + const result = formatYaml({ key: 'value', num: 42 }); + expect(result).toContain('key: value'); + expect(result).toContain('num: 42'); + }); + + it('formats arrays', () => { + const result = formatYaml(['a', 'b']); + expect(result).toContain('- a'); + expect(result).toContain('- b'); + }); + + it('does not end with trailing newline', () => { + const result = formatYaml({ x: 1 }); + expect(result.endsWith('\n')).toBe(false); + }); +}); diff --git a/src/cli/tests/formatters/table.test.ts b/src/cli/tests/formatters/table.test.ts new file mode 100644 index 0000000..67e0398 --- /dev/null +++ b/src/cli/tests/formatters/table.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { formatTable } from '../../src/formatters/table.js'; +import type { Column } from '../../src/formatters/table.js'; + +interface TestRow { + name: string; + age: number; + city: string; +} + +const columns: Column[] = [ + { header: 'NAME', key: 'name' }, + { header: 'AGE', key: 'age', align: 'right' }, + { header: 'CITY', key: 'city' }, +]; + +describe('formatTable', () => { + it('returns empty message for no rows', () => { + expect(formatTable([], columns)).toBe('No results found.'); + }); + + it('formats a single row', () => { + const rows = [{ name: 'Alice', age: 30, city: 'NYC' }]; + const result = formatTable(rows, columns); + const lines = result.split('\n'); + expect(lines).toHaveLength(3); // header, separator, data + expect(lines[0]).toContain('NAME'); + expect(lines[0]).toContain('AGE'); + expect(lines[0]).toContain('CITY'); + expect(lines[2]).toContain('Alice'); + expect(lines[2]).toContain('NYC'); + }); + + it('right-aligns numeric columns', () => { + const rows = [{ name: 'Bob', age: 5, city: 'LA' }]; + const result = formatTable(rows, columns); + const lines = result.split('\n'); + // AGE column should be right-aligned: " 5" or "5" padded + const ageLine = lines[2]; + // The age value should have leading space(s) for right alignment + expect(ageLine).toMatch(/\s+5/); + }); + + it('auto-sizes columns to content', () => { + const rows = [ + { name: 'A', age: 1, city: 'X' }, + { name: 'LongName', age: 100, city: 'LongCityName' }, + ]; + const result = formatTable(rows, columns); + const lines = result.split('\n'); + // Header should be at least as wide as longest data + expect(lines[0]).toContain('NAME'); + expect(lines[2]).toContain('A'); + expect(lines[3]).toContain('LongName'); + expect(lines[3]).toContain('LongCityName'); + }); + + it('truncates long values when width is fixed', () => { + const narrowCols: Column[] = [ + { header: 'NAME', key: 'name', width: 5 }, + ]; + const rows = [{ name: 'VeryLongName', age: 0, city: '' }]; + const result = formatTable(rows, narrowCols); + const lines = result.split('\n'); + // Should be truncated with ellipsis + expect(lines[2].trim().length).toBeLessThanOrEqual(5); + expect(lines[2]).toContain('\u2026'); + }); + + it('supports function-based column keys', () => { + const fnCols: Column[] = [ + { header: 'INFO', key: (row) => `${row.name} (${row.age})` }, + ]; + const rows = [{ name: 'Eve', age: 25, city: 'SF' }]; + const result = formatTable(rows, fnCols); + expect(result).toContain('Eve (25)'); + }); + + it('handles separator line matching column widths', () => { + const rows = [{ name: 'Test', age: 1, city: 'Here' }]; + const result = formatTable(rows, columns); + const lines = result.split('\n'); + const separator = lines[1]; + // Separator should consist of dashes and spaces + expect(separator).toMatch(/^[-\s]+$/); + }); +}); diff --git a/src/cli/tsconfig.json b/src/cli/tsconfig.json index 1d1421c..be275fe 100644 --- a/src/cli/tsconfig.json +++ b/src/cli/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "types": ["node"] }, "include": ["src/**/*.ts"], "references": [ diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma new file mode 100644 index 0000000..a445a89 --- /dev/null +++ b/src/db/prisma/schema.prisma @@ -0,0 +1,172 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ── Users ── + +model User { + id String @id @default(cuid()) + email String @unique + name String? + role Role @default(USER) + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + sessions Session[] + auditLogs AuditLog[] + projects Project[] + + @@index([email]) +} + +enum Role { + USER + ADMIN +} + +// ── Sessions ── + +model Session { + id String @id @default(cuid()) + token String @unique + userId String + expiresAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([token]) + @@index([userId]) + @@index([expiresAt]) +} + +// ── MCP Servers ── + +model McpServer { + id String @id @default(cuid()) + name String @unique + description String @default("") + packageName String? + dockerImage String? + transport Transport @default(STDIO) + repositoryUrl String? + envTemplate Json @default("[]") + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + profiles McpProfile[] + instances McpInstance[] + + @@index([name]) +} + +enum Transport { + STDIO + SSE + STREAMABLE_HTTP +} + +// ── MCP Profiles ── + +model McpProfile { + id String @id @default(cuid()) + name String + serverId String + permissions Json @default("[]") + envOverrides Json @default("{}") + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + projects ProjectMcpProfile[] + + @@unique([name, serverId]) + @@index([serverId]) +} + +// ── Projects ── + +model Project { + id String @id @default(cuid()) + name String @unique + description String @default("") + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + profiles ProjectMcpProfile[] + + @@index([name]) + @@index([ownerId]) +} + +// ── Project <-> Profile join table ── + +model ProjectMcpProfile { + id String @id @default(cuid()) + projectId String + profileId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + profile McpProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + + @@unique([projectId, profileId]) + @@index([projectId]) + @@index([profileId]) +} + +// ── MCP Instances (running containers) ── + +model McpInstance { + id String @id @default(cuid()) + serverId String + containerId String? + status InstanceStatus @default(STOPPED) + port Int? + metadata Json @default("{}") + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + + @@index([serverId]) + @@index([status]) +} + +enum InstanceStatus { + STARTING + RUNNING + STOPPING + STOPPED + ERROR +} + +// ── Audit Logs ── + +model AuditLog { + id String @id @default(cuid()) + userId String + action String + resource String + resourceId String? + details Json @default("{}") + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([action]) + @@index([resource]) + @@index([createdAt]) +} diff --git a/src/db/src/index.ts b/src/db/src/index.ts index 0140567..2312a36 100644 --- a/src/db/src/index.ts +++ b/src/db/src/index.ts @@ -1,2 +1,18 @@ // Database package - Prisma client and utilities -// Will be implemented in Task 2 +export { PrismaClient } from '@prisma/client'; +export type { + User, + Session, + McpServer, + McpProfile, + Project, + ProjectMcpProfile, + McpInstance, + AuditLog, + Role, + Transport, + InstanceStatus, +} from '@prisma/client'; + +export { seedMcpServers, defaultServers } from './seed/index.js'; +export type { SeedServer } from './seed/index.js'; diff --git a/src/db/src/seed/index.ts b/src/db/src/seed/index.ts new file mode 100644 index 0000000..8840535 --- /dev/null +++ b/src/db/src/seed/index.ts @@ -0,0 +1,131 @@ +import { PrismaClient } from '@prisma/client'; + +export interface SeedServer { + name: string; + description: string; + packageName: string; + transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP'; + repositoryUrl: string; + envTemplate: Array<{ + name: string; + description: string; + isSecret: boolean; + setupUrl?: string; + }>; +} + +export const defaultServers: SeedServer[] = [ + { + name: 'slack', + description: 'Slack MCP server for reading channels, messages, and user info', + packageName: '@anthropic/slack-mcp', + transport: 'STDIO', + repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack', + envTemplate: [ + { + name: 'SLACK_BOT_TOKEN', + description: 'Slack Bot User OAuth Token (xoxb-...)', + isSecret: true, + setupUrl: 'https://api.slack.com/apps', + }, + { + name: 'SLACK_TEAM_ID', + description: 'Slack Workspace Team ID', + isSecret: false, + }, + ], + }, + { + name: 'jira', + description: 'Jira MCP server for issues, projects, and boards', + packageName: '@anthropic/jira-mcp', + transport: 'STDIO', + repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/jira', + envTemplate: [ + { + name: 'JIRA_URL', + description: 'Jira instance URL (e.g., https://company.atlassian.net)', + isSecret: false, + }, + { + name: 'JIRA_EMAIL', + description: 'Jira account email', + isSecret: false, + }, + { + name: 'JIRA_API_TOKEN', + description: 'Jira API token', + isSecret: true, + setupUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens', + }, + ], + }, + { + name: 'github', + description: 'GitHub MCP server for repos, issues, PRs, and code search', + packageName: '@anthropic/github-mcp', + transport: 'STDIO', + repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github', + envTemplate: [ + { + name: 'GITHUB_TOKEN', + description: 'GitHub Personal Access Token', + isSecret: true, + setupUrl: 'https://github.com/settings/tokens', + }, + ], + }, + { + name: 'terraform', + description: 'Terraform MCP server for infrastructure documentation and state', + packageName: '@anthropic/terraform-mcp', + transport: 'STDIO', + repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/terraform', + envTemplate: [], + }, +]; + +export async function seedMcpServers( + prisma: PrismaClient, + servers: SeedServer[] = defaultServers, +): Promise { + let created = 0; + + for (const server of servers) { + await prisma.mcpServer.upsert({ + where: { name: server.name }, + update: { + description: server.description, + packageName: server.packageName, + transport: server.transport, + repositoryUrl: server.repositoryUrl, + envTemplate: server.envTemplate, + }, + create: { + name: server.name, + description: server.description, + packageName: server.packageName, + transport: server.transport, + repositoryUrl: server.repositoryUrl, + envTemplate: server.envTemplate, + }, + }); + created++; + } + + return created; +} + +// CLI entry point +if (import.meta.url === `file://${process.argv[1]}`) { + const prisma = new PrismaClient(); + seedMcpServers(prisma) + .then((count) => { + console.log(`Seeded ${count} MCP servers`); + return prisma.$disconnect(); + }) + .catch((e) => { + console.error(e); + return prisma.$disconnect().then(() => process.exit(1)); + }); +} diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts new file mode 100644 index 0000000..7ef2c06 --- /dev/null +++ b/src/db/tests/helpers.ts @@ -0,0 +1,58 @@ +import { PrismaClient } from '@prisma/client'; +import { execSync } from 'node:child_process'; + +const TEST_DATABASE_URL = process.env['DATABASE_URL'] ?? + 'postgresql://mcpctl:mcpctl_test@localhost:5433/mcpctl_test'; + +let prisma: PrismaClient | undefined; +let schemaReady = false; + +export function getTestClient(): PrismaClient { + if (!prisma) { + prisma = new PrismaClient({ + datasources: { db: { url: TEST_DATABASE_URL } }, + }); + } + return prisma; +} + +export async function setupTestDb(): Promise { + const client = getTestClient(); + + // Only push schema once per process (multiple test files share the worker) + if (!schemaReady) { + execSync('npx prisma db push --force-reset --skip-generate', { + cwd: new URL('..', import.meta.url).pathname, + env: { + ...process.env, + DATABASE_URL: TEST_DATABASE_URL, + // Consent required when Prisma detects AI agent context. + // This targets the ephemeral test database (tmpfs-backed, port 5433). + PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: 'yes', + }, + stdio: 'pipe', + }); + schemaReady = true; + } + + return client; +} + +export async function cleanupTestDb(): Promise { + if (prisma) { + await prisma.$disconnect(); + prisma = undefined; + } +} + +export async function clearAllTables(client: PrismaClient): Promise { + // Delete in order respecting foreign keys + await client.auditLog.deleteMany(); + await client.projectMcpProfile.deleteMany(); + await client.mcpInstance.deleteMany(); + await client.mcpProfile.deleteMany(); + await client.session.deleteMany(); + await client.project.deleteMany(); + await client.mcpServer.deleteMany(); + await client.user.deleteMany(); +} diff --git a/src/db/tests/models.test.ts b/src/db/tests/models.test.ts new file mode 100644 index 0000000..7e01abf --- /dev/null +++ b/src/db/tests/models.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { setupTestDb, cleanupTestDb, clearAllTables, getTestClient } from './helpers.js'; + +let prisma: PrismaClient; + +beforeAll(async () => { + prisma = await setupTestDb(); +}, 30_000); + +afterAll(async () => { + await cleanupTestDb(); +}); + +beforeEach(async () => { + await clearAllTables(prisma); +}); + +// ── Helper factories ── + +async function createUser(overrides: { email?: string; name?: string; role?: 'USER' | 'ADMIN' } = {}) { + return prisma.user.create({ + data: { + email: overrides.email ?? `test-${Date.now()}@example.com`, + name: overrides.name ?? 'Test User', + role: overrides.role ?? 'USER', + }, + }); +} + +async function createServer(overrides: { name?: string; transport?: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP' } = {}) { + return prisma.mcpServer.create({ + data: { + name: overrides.name ?? `server-${Date.now()}`, + description: 'Test server', + packageName: '@test/mcp-server', + transport: overrides.transport ?? 'STDIO', + }, + }); +} + +// ── User model ── + +describe('User', () => { + it('creates a user with defaults', async () => { + const user = await createUser(); + expect(user.id).toBeDefined(); + expect(user.role).toBe('USER'); + expect(user.version).toBe(1); + expect(user.createdAt).toBeInstanceOf(Date); + expect(user.updatedAt).toBeInstanceOf(Date); + }); + + it('enforces unique email', async () => { + await createUser({ email: 'dup@test.com' }); + await expect(createUser({ email: 'dup@test.com' })).rejects.toThrow(); + }); + + it('allows ADMIN role', async () => { + const admin = await createUser({ role: 'ADMIN' }); + expect(admin.role).toBe('ADMIN'); + }); + + it('updates updatedAt on change', async () => { + const user = await createUser(); + const original = user.updatedAt; + // Small delay to ensure different timestamp + await new Promise((r) => setTimeout(r, 50)); + const updated = await prisma.user.update({ + where: { id: user.id }, + data: { name: 'Updated' }, + }); + expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(original.getTime()); + }); +}); + +// ── Session model ── + +describe('Session', () => { + it('creates a session linked to user', async () => { + const user = await createUser(); + const session = await prisma.session.create({ + data: { + token: 'test-token-123', + userId: user.id, + expiresAt: new Date(Date.now() + 86400_000), + }, + }); + expect(session.token).toBe('test-token-123'); + expect(session.userId).toBe(user.id); + }); + + it('enforces unique token', async () => { + const user = await createUser(); + const data = { + token: 'unique-token', + userId: user.id, + expiresAt: new Date(Date.now() + 86400_000), + }; + await prisma.session.create({ data }); + await expect(prisma.session.create({ data })).rejects.toThrow(); + }); + + it('cascades delete when user is deleted', async () => { + const user = await createUser(); + await prisma.session.create({ + data: { + token: 'cascade-token', + userId: user.id, + expiresAt: new Date(Date.now() + 86400_000), + }, + }); + await prisma.user.delete({ where: { id: user.id } }); + const sessions = await prisma.session.findMany({ where: { userId: user.id } }); + expect(sessions).toHaveLength(0); + }); +}); + +// ── McpServer model ── + +describe('McpServer', () => { + it('creates a server with defaults', async () => { + const server = await createServer(); + expect(server.transport).toBe('STDIO'); + expect(server.version).toBe(1); + expect(server.envTemplate).toEqual([]); + }); + + it('enforces unique name', async () => { + await createServer({ name: 'slack' }); + await expect(createServer({ name: 'slack' })).rejects.toThrow(); + }); + + it('stores envTemplate as JSON', async () => { + const server = await prisma.mcpServer.create({ + data: { + name: 'with-env', + envTemplate: [ + { name: 'API_KEY', description: 'Key', isSecret: true }, + ], + }, + }); + const envTemplate = server.envTemplate as Array<{ name: string }>; + expect(envTemplate).toHaveLength(1); + expect(envTemplate[0].name).toBe('API_KEY'); + }); + + it('supports SSE transport', async () => { + const server = await createServer({ transport: 'SSE' }); + expect(server.transport).toBe('SSE'); + }); +}); + +// ── McpProfile model ── + +describe('McpProfile', () => { + it('creates a profile linked to server', async () => { + const server = await createServer(); + const profile = await prisma.mcpProfile.create({ + data: { + name: 'readonly', + serverId: server.id, + permissions: ['read'], + }, + }); + expect(profile.name).toBe('readonly'); + expect(profile.serverId).toBe(server.id); + }); + + it('enforces unique name per server', async () => { + const server = await createServer(); + const data = { name: 'default', serverId: server.id }; + await prisma.mcpProfile.create({ data }); + await expect(prisma.mcpProfile.create({ data })).rejects.toThrow(); + }); + + it('allows same profile name on different servers', async () => { + const server1 = await createServer({ name: 'server-1' }); + const server2 = await createServer({ name: 'server-2' }); + await prisma.mcpProfile.create({ data: { name: 'default', serverId: server1.id } }); + const profile2 = await prisma.mcpProfile.create({ data: { name: 'default', serverId: server2.id } }); + expect(profile2.name).toBe('default'); + }); + + it('cascades delete when server is deleted', async () => { + const server = await createServer(); + await prisma.mcpProfile.create({ data: { name: 'test', serverId: server.id } }); + await prisma.mcpServer.delete({ where: { id: server.id } }); + const profiles = await prisma.mcpProfile.findMany({ where: { serverId: server.id } }); + expect(profiles).toHaveLength(0); + }); +}); + +// ── Project model ── + +describe('Project', () => { + it('creates a project with owner', async () => { + const user = await createUser(); + const project = await prisma.project.create({ + data: { name: 'weekly-reports', ownerId: user.id }, + }); + expect(project.name).toBe('weekly-reports'); + expect(project.ownerId).toBe(user.id); + }); + + it('enforces unique project name', async () => { + const user = await createUser(); + await prisma.project.create({ data: { name: 'dup', ownerId: user.id } }); + await expect( + prisma.project.create({ data: { name: 'dup', ownerId: user.id } }), + ).rejects.toThrow(); + }); + + it('cascades delete when owner is deleted', async () => { + const user = await createUser(); + await prisma.project.create({ data: { name: 'orphan', ownerId: user.id } }); + await prisma.user.delete({ where: { id: user.id } }); + const projects = await prisma.project.findMany({ where: { ownerId: user.id } }); + expect(projects).toHaveLength(0); + }); +}); + +// ── ProjectMcpProfile (join table) ── + +describe('ProjectMcpProfile', () => { + it('links project to profile', async () => { + const user = await createUser(); + const server = await createServer(); + const profile = await prisma.mcpProfile.create({ + data: { name: 'default', serverId: server.id }, + }); + const project = await prisma.project.create({ + data: { name: 'test-project', ownerId: user.id }, + }); + + const link = await prisma.projectMcpProfile.create({ + data: { projectId: project.id, profileId: profile.id }, + }); + expect(link.projectId).toBe(project.id); + expect(link.profileId).toBe(profile.id); + }); + + it('enforces unique project+profile combination', async () => { + const user = await createUser(); + const server = await createServer(); + const profile = await prisma.mcpProfile.create({ + data: { name: 'default', serverId: server.id }, + }); + const project = await prisma.project.create({ + data: { name: 'test-project', ownerId: user.id }, + }); + + const data = { projectId: project.id, profileId: profile.id }; + await prisma.projectMcpProfile.create({ data }); + await expect(prisma.projectMcpProfile.create({ data })).rejects.toThrow(); + }); + + it('loads profiles through project include', async () => { + const user = await createUser(); + const server = await createServer(); + const profile = await prisma.mcpProfile.create({ + data: { name: 'slack-ro', serverId: server.id }, + }); + const project = await prisma.project.create({ + data: { name: 'reports', ownerId: user.id }, + }); + await prisma.projectMcpProfile.create({ + data: { projectId: project.id, profileId: profile.id }, + }); + + const loaded = await prisma.project.findUnique({ + where: { id: project.id }, + include: { profiles: { include: { profile: true } } }, + }); + expect(loaded!.profiles).toHaveLength(1); + expect(loaded!.profiles[0].profile.name).toBe('slack-ro'); + }); +}); + +// ── McpInstance model ── + +describe('McpInstance', () => { + it('creates an instance linked to server', async () => { + const server = await createServer(); + const instance = await prisma.mcpInstance.create({ + data: { serverId: server.id }, + }); + expect(instance.status).toBe('STOPPED'); + expect(instance.serverId).toBe(server.id); + }); + + it('tracks instance status transitions', async () => { + const server = await createServer(); + const instance = await prisma.mcpInstance.create({ + data: { serverId: server.id, status: 'STARTING' }, + }); + const running = await prisma.mcpInstance.update({ + where: { id: instance.id }, + data: { status: 'RUNNING', containerId: 'abc123', port: 8080 }, + }); + expect(running.status).toBe('RUNNING'); + expect(running.containerId).toBe('abc123'); + expect(running.port).toBe(8080); + }); + + it('cascades delete when server is deleted', async () => { + const server = await createServer(); + await prisma.mcpInstance.create({ data: { serverId: server.id } }); + await prisma.mcpServer.delete({ where: { id: server.id } }); + const instances = await prisma.mcpInstance.findMany({ where: { serverId: server.id } }); + expect(instances).toHaveLength(0); + }); +}); + +// ── AuditLog model ── + +describe('AuditLog', () => { + it('creates an audit log entry', async () => { + const user = await createUser(); + const log = await prisma.auditLog.create({ + data: { + userId: user.id, + action: 'CREATE', + resource: 'McpServer', + resourceId: 'server-123', + details: { name: 'slack' }, + }, + }); + expect(log.action).toBe('CREATE'); + expect(log.resource).toBe('McpServer'); + expect(log.createdAt).toBeInstanceOf(Date); + }); + + it('supports querying by action and resource', async () => { + const user = await createUser(); + await prisma.auditLog.createMany({ + data: [ + { userId: user.id, action: 'CREATE', resource: 'McpServer' }, + { userId: user.id, action: 'UPDATE', resource: 'McpServer' }, + { userId: user.id, action: 'CREATE', resource: 'Project' }, + ], + }); + + const creates = await prisma.auditLog.findMany({ + where: { action: 'CREATE' }, + }); + expect(creates).toHaveLength(2); + + const serverLogs = await prisma.auditLog.findMany({ + where: { resource: 'McpServer' }, + }); + expect(serverLogs).toHaveLength(2); + }); + + it('cascades delete when user is deleted', async () => { + const user = await createUser(); + await prisma.auditLog.create({ + data: { userId: user.id, action: 'TEST', resource: 'Test' }, + }); + await prisma.user.delete({ where: { id: user.id } }); + const logs = await prisma.auditLog.findMany({ where: { userId: user.id } }); + expect(logs).toHaveLength(0); + }); +}); diff --git a/src/db/tests/seed.test.ts b/src/db/tests/seed.test.ts new file mode 100644 index 0000000..41ddbd5 --- /dev/null +++ b/src/db/tests/seed.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js'; +import { seedMcpServers, defaultServers } from '../src/seed/index.js'; + +let prisma: PrismaClient; + +beforeAll(async () => { + prisma = await setupTestDb(); +}, 30_000); + +afterAll(async () => { + await cleanupTestDb(); +}); + +beforeEach(async () => { + await clearAllTables(prisma); +}); + +describe('seedMcpServers', () => { + it('seeds all default servers', async () => { + const count = await seedMcpServers(prisma); + expect(count).toBe(defaultServers.length); + + const servers = await prisma.mcpServer.findMany({ orderBy: { name: 'asc' } }); + expect(servers).toHaveLength(defaultServers.length); + + const names = servers.map((s) => s.name); + expect(names).toContain('slack'); + expect(names).toContain('github'); + expect(names).toContain('jira'); + expect(names).toContain('terraform'); + }); + + it('is idempotent (upsert)', async () => { + await seedMcpServers(prisma); + const count = await seedMcpServers(prisma); + expect(count).toBe(defaultServers.length); + + const servers = await prisma.mcpServer.findMany(); + expect(servers).toHaveLength(defaultServers.length); + }); + + it('seeds envTemplate correctly', async () => { + await seedMcpServers(prisma); + const slack = await prisma.mcpServer.findUnique({ where: { name: 'slack' } }); + const envTemplate = slack!.envTemplate as Array<{ name: string; isSecret: boolean }>; + expect(envTemplate).toHaveLength(2); + expect(envTemplate[0].name).toBe('SLACK_BOT_TOKEN'); + expect(envTemplate[0].isSecret).toBe(true); + }); + + it('accepts custom server list', async () => { + const custom = [ + { + name: 'custom-server', + description: 'Custom test server', + packageName: '@test/custom', + transport: 'STDIO' as const, + repositoryUrl: 'https://example.com', + envTemplate: [], + }, + ]; + const count = await seedMcpServers(prisma, custom); + expect(count).toBe(1); + + const servers = await prisma.mcpServer.findMany(); + expect(servers).toHaveLength(1); + expect(servers[0].name).toBe('custom-server'); + }); +}); diff --git a/src/db/vitest.config.ts b/src/db/vitest.config.ts index 8400038..9393b93 100644 --- a/src/db/vitest.config.ts +++ b/src/db/vitest.config.ts @@ -4,5 +4,7 @@ export default defineProject({ test: { name: 'db', include: ['tests/**/*.test.ts'], + // Test files share the same database — run sequentially + fileParallelism: false, }, }); diff --git a/src/mcpd/package.json b/src/mcpd/package.json index 18f0770..0635ea7 100644 --- a/src/mcpd/package.json +++ b/src/mcpd/package.json @@ -14,12 +14,16 @@ "test:run": "vitest run" }, "dependencies": { - "fastify": "^5.0.0", "@fastify/cors": "^10.0.0", "@fastify/helmet": "^12.0.0", "@fastify/rate-limit": "^10.0.0", - "zod": "^3.24.0", + "@mcpctl/db": "workspace:*", "@mcpctl/shared": "workspace:*", - "@mcpctl/db": "workspace:*" + "@prisma/client": "^6.0.0", + "fastify": "^5.0.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^25.3.0" } } diff --git a/src/mcpd/src/config/index.ts b/src/mcpd/src/config/index.ts new file mode 100644 index 0000000..2e7d21a --- /dev/null +++ b/src/mcpd/src/config/index.ts @@ -0,0 +1,2 @@ +export { McpdConfigSchema, loadConfigFromEnv } from './schema.js'; +export type { McpdConfig } from './schema.js'; diff --git a/src/mcpd/src/config/schema.ts b/src/mcpd/src/config/schema.ts new file mode 100644 index 0000000..ca7f43b --- /dev/null +++ b/src/mcpd/src/config/schema.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const McpdConfigSchema = z.object({ + port: z.number().int().positive().default(3000), + host: z.string().default('0.0.0.0'), + databaseUrl: z.string().min(1), + logLevel: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), + corsOrigins: z.array(z.string()).default(['*']), + rateLimitMax: z.number().int().positive().default(100), + rateLimitWindowMs: z.number().int().positive().default(60_000), +}); + +export type McpdConfig = z.infer; + +export function loadConfigFromEnv(env: Record = process.env): McpdConfig { + return McpdConfigSchema.parse({ + port: env['MCPD_PORT'] !== undefined ? parseInt(env['MCPD_PORT'], 10) : undefined, + host: env['MCPD_HOST'], + databaseUrl: env['DATABASE_URL'], + logLevel: env['MCPD_LOG_LEVEL'], + corsOrigins: env['MCPD_CORS_ORIGINS']?.split(',').map((s) => s.trim()), + rateLimitMax: env['MCPD_RATE_LIMIT_MAX'] !== undefined ? parseInt(env['MCPD_RATE_LIMIT_MAX'], 10) : undefined, + rateLimitWindowMs: env['MCPD_RATE_LIMIT_WINDOW_MS'] !== undefined ? parseInt(env['MCPD_RATE_LIMIT_WINDOW_MS'], 10) : undefined, + }); +} diff --git a/src/mcpd/src/index.ts b/src/mcpd/src/index.ts index 5dee52c..5606896 100644 --- a/src/mcpd/src/index.ts +++ b/src/mcpd/src/index.ts @@ -1,2 +1,15 @@ -// mcpd daemon server entry point -// Will be implemented in Task 3 +export { createServer } from './server.js'; +export type { ServerDeps } from './server.js'; +export { McpdConfigSchema, loadConfigFromEnv } from './config/index.js'; +export type { McpdConfig } from './config/index.js'; +export { + createAuthMiddleware, + registerSecurityPlugins, + errorHandler, + registerAuditHook, +} from './middleware/index.js'; +export type { AuthDeps, AuditDeps, ErrorResponse } from './middleware/index.js'; +export { registerHealthRoutes } from './routes/index.js'; +export type { HealthDeps } from './routes/index.js'; +export { setupGracefulShutdown } from './utils/index.js'; +export type { ShutdownDeps } from './utils/index.js'; diff --git a/src/mcpd/src/middleware/audit.ts b/src/mcpd/src/middleware/audit.ts new file mode 100644 index 0000000..9ea0437 --- /dev/null +++ b/src/mcpd/src/middleware/audit.ts @@ -0,0 +1,59 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +export interface AuditDeps { + createAuditLog: (entry: { + userId: string; + action: string; + resource: string; + resourceId?: string; + details?: Record; + }) => Promise; +} + +export function registerAuditHook(app: FastifyInstance, deps: AuditDeps): void { + app.addHook('onResponse', async (request: FastifyRequest, reply: FastifyReply) => { + // Only audit mutating methods on authenticated requests + if (request.userId === undefined) return; + if (request.method === 'GET' || request.method === 'HEAD' || request.method === 'OPTIONS') return; + + const action = methodToAction(request.method); + const { resource, resourceId } = parseRoute(request.url); + + const entry: Parameters[0] = { + userId: request.userId, + action, + resource, + details: { + method: request.method, + url: request.url, + statusCode: reply.statusCode, + }, + }; + if (resourceId !== undefined) { + entry.resourceId = resourceId; + } + await deps.createAuditLog(entry); + }); +} + +function methodToAction(method: string): string { + switch (method) { + case 'POST': return 'CREATE'; + case 'PUT': + case 'PATCH': return 'UPDATE'; + case 'DELETE': return 'DELETE'; + default: return method; + } +} + +function parseRoute(url: string): { resource: string; resourceId: string | undefined } { + const parts = url.split('?')[0]?.split('/').filter(Boolean) ?? []; + // Pattern: /api/v1/resource/:id + if (parts.length >= 3 && parts[0] === 'api') { + return { resource: parts[2] ?? 'unknown', resourceId: parts[3] }; + } + if (parts.length >= 1) { + return { resource: parts[0] ?? 'unknown', resourceId: parts[1] }; + } + return { resource: 'unknown', resourceId: undefined }; +} diff --git a/src/mcpd/src/middleware/auth.ts b/src/mcpd/src/middleware/auth.ts new file mode 100644 index 0000000..a9ebb83 --- /dev/null +++ b/src/mcpd/src/middleware/auth.ts @@ -0,0 +1,40 @@ +import type { FastifyRequest, FastifyReply } from 'fastify'; + +export interface AuthDeps { + findSession: (token: string) => Promise<{ userId: string; expiresAt: Date } | null>; +} + +declare module 'fastify' { + interface FastifyRequest { + userId?: string; + } +} + +export function createAuthMiddleware(deps: AuthDeps) { + return async function authMiddleware(request: FastifyRequest, reply: FastifyReply): Promise { + const header = request.headers.authorization; + if (header === undefined || !header.startsWith('Bearer ')) { + reply.code(401).send({ error: 'Missing or invalid Authorization header' }); + return; + } + + const token = header.slice(7); + if (token.length === 0) { + reply.code(401).send({ error: 'Empty token' }); + return; + } + + const session = await deps.findSession(token); + if (session === null) { + reply.code(401).send({ error: 'Invalid token' }); + return; + } + + if (session.expiresAt < new Date()) { + reply.code(401).send({ error: 'Token expired' }); + return; + } + + request.userId = session.userId; + }; +} diff --git a/src/mcpd/src/middleware/error-handler.ts b/src/mcpd/src/middleware/error-handler.ts new file mode 100644 index 0000000..7d06cd3 --- /dev/null +++ b/src/mcpd/src/middleware/error-handler.ts @@ -0,0 +1,60 @@ +import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'; +import { ZodError } from 'zod'; + +export interface ErrorResponse { + error: string; + statusCode: number; + details?: unknown; +} + +export function errorHandler( + error: FastifyError, + _request: FastifyRequest, + reply: FastifyReply, +): void { + // Zod validation errors + if (error instanceof ZodError) { + reply.code(400).send({ + error: 'Validation error', + statusCode: 400, + details: error.issues, + } satisfies ErrorResponse); + return; + } + + // Fastify validation errors (from schema validation) + if (error.validation !== undefined) { + reply.code(400).send({ + error: 'Validation error', + statusCode: 400, + details: error.validation, + } satisfies ErrorResponse); + return; + } + + // Rate limit exceeded + if (error.statusCode === 429) { + reply.code(429).send({ + error: 'Rate limit exceeded', + statusCode: 429, + } satisfies ErrorResponse); + return; + } + + // Known HTTP errors (includes service errors like NotFoundError, ConflictError) + const statusCode = error.statusCode ?? 500; + if (statusCode < 500) { + reply.code(statusCode).send({ + error: error.message, + statusCode, + } satisfies ErrorResponse); + return; + } + + // Internal server errors — don't leak details + reply.log.error(error); + reply.code(500).send({ + error: 'Internal server error', + statusCode: 500, + } satisfies ErrorResponse); +} diff --git a/src/mcpd/src/middleware/index.ts b/src/mcpd/src/middleware/index.ts new file mode 100644 index 0000000..61695ee --- /dev/null +++ b/src/mcpd/src/middleware/index.ts @@ -0,0 +1,7 @@ +export { createAuthMiddleware } from './auth.js'; +export type { AuthDeps } from './auth.js'; +export { registerSecurityPlugins } from './security.js'; +export { errorHandler } from './error-handler.js'; +export type { ErrorResponse } from './error-handler.js'; +export { registerAuditHook } from './audit.js'; +export type { AuditDeps } from './audit.js'; diff --git a/src/mcpd/src/middleware/security.ts b/src/mcpd/src/middleware/security.ts new file mode 100644 index 0000000..c1817c2 --- /dev/null +++ b/src/mcpd/src/middleware/security.ts @@ -0,0 +1,24 @@ +import type { FastifyInstance } from 'fastify'; +import cors from '@fastify/cors'; +import helmet from '@fastify/helmet'; +import rateLimit from '@fastify/rate-limit'; +import type { McpdConfig } from '../config/index.js'; + +export async function registerSecurityPlugins( + app: FastifyInstance, + config: McpdConfig, +): Promise { + await app.register(cors, { + origin: config.corsOrigins, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], + }); + + await app.register(helmet, { + contentSecurityPolicy: false, // API server, no HTML + }); + + await app.register(rateLimit, { + max: config.rateLimitMax, + timeWindow: config.rateLimitWindowMs, + }); +} diff --git a/src/mcpd/src/repositories/index.ts b/src/mcpd/src/repositories/index.ts new file mode 100644 index 0000000..497d881 --- /dev/null +++ b/src/mcpd/src/repositories/index.ts @@ -0,0 +1,5 @@ +export type { IMcpServerRepository, IMcpProfileRepository } from './interfaces.js'; +export { McpServerRepository } from './mcp-server.repository.js'; +export { McpProfileRepository } from './mcp-profile.repository.js'; +export type { IProjectRepository } from './project.repository.js'; +export { ProjectRepository } from './project.repository.js'; diff --git a/src/mcpd/src/repositories/interfaces.ts b/src/mcpd/src/repositories/interfaces.ts new file mode 100644 index 0000000..ff71f23 --- /dev/null +++ b/src/mcpd/src/repositories/interfaces.ts @@ -0,0 +1,21 @@ +import type { McpServer, McpProfile } from '@prisma/client'; +import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js'; +import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js'; + +export interface IMcpServerRepository { + findAll(): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + create(data: CreateMcpServerInput): Promise; + update(id: string, data: UpdateMcpServerInput): Promise; + delete(id: string): Promise; +} + +export interface IMcpProfileRepository { + findAll(serverId?: string): Promise; + findById(id: string): Promise; + findByServerAndName(serverId: string, name: string): Promise; + create(data: CreateMcpProfileInput): Promise; + update(id: string, data: UpdateMcpProfileInput): Promise; + delete(id: string): Promise; +} diff --git a/src/mcpd/src/repositories/mcp-profile.repository.ts b/src/mcpd/src/repositories/mcp-profile.repository.ts new file mode 100644 index 0000000..7128091 --- /dev/null +++ b/src/mcpd/src/repositories/mcp-profile.repository.ts @@ -0,0 +1,46 @@ +import type { PrismaClient, McpProfile } from '@prisma/client'; +import type { IMcpProfileRepository } from './interfaces.js'; +import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js'; + +export class McpProfileRepository implements IMcpProfileRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(serverId?: string): Promise { + const where = serverId !== undefined ? { serverId } : {}; + return this.prisma.mcpProfile.findMany({ where, orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.mcpProfile.findUnique({ where: { id } }); + } + + async findByServerAndName(serverId: string, name: string): Promise { + return this.prisma.mcpProfile.findUnique({ + where: { name_serverId: { name, serverId } }, + }); + } + + async create(data: CreateMcpProfileInput): Promise { + return this.prisma.mcpProfile.create({ + data: { + name: data.name, + serverId: data.serverId, + permissions: data.permissions, + envOverrides: data.envOverrides, + }, + }); + } + + async update(id: string, data: UpdateMcpProfileInput): Promise { + const updateData: Record = {}; + if (data.name !== undefined) updateData['name'] = data.name; + if (data.permissions !== undefined) updateData['permissions'] = data.permissions; + if (data.envOverrides !== undefined) updateData['envOverrides'] = data.envOverrides; + + return this.prisma.mcpProfile.update({ where: { id }, data: updateData }); + } + + async delete(id: string): Promise { + await this.prisma.mcpProfile.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/repositories/mcp-server.repository.ts b/src/mcpd/src/repositories/mcp-server.repository.ts new file mode 100644 index 0000000..92a031a --- /dev/null +++ b/src/mcpd/src/repositories/mcp-server.repository.ts @@ -0,0 +1,49 @@ +import type { PrismaClient, McpServer } from '@prisma/client'; +import type { IMcpServerRepository } from './interfaces.js'; +import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js'; + +export class McpServerRepository implements IMcpServerRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(): Promise { + return this.prisma.mcpServer.findMany({ orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.mcpServer.findUnique({ where: { id } }); + } + + async findByName(name: string): Promise { + return this.prisma.mcpServer.findUnique({ where: { name } }); + } + + async create(data: CreateMcpServerInput): Promise { + return this.prisma.mcpServer.create({ + data: { + name: data.name, + description: data.description, + packageName: data.packageName ?? null, + dockerImage: data.dockerImage ?? null, + transport: data.transport, + repositoryUrl: data.repositoryUrl ?? null, + envTemplate: data.envTemplate, + }, + }); + } + + async update(id: string, data: UpdateMcpServerInput): Promise { + const updateData: Record = {}; + if (data.description !== undefined) updateData['description'] = data.description; + if (data.packageName !== undefined) updateData['packageName'] = data.packageName; + if (data.dockerImage !== undefined) updateData['dockerImage'] = data.dockerImage; + if (data.transport !== undefined) updateData['transport'] = data.transport; + if (data.repositoryUrl !== undefined) updateData['repositoryUrl'] = data.repositoryUrl; + if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate; + + return this.prisma.mcpServer.update({ where: { id }, data: updateData }); + } + + async delete(id: string): Promise { + await this.prisma.mcpServer.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/repositories/project.repository.ts b/src/mcpd/src/repositories/project.repository.ts new file mode 100644 index 0000000..8980ddc --- /dev/null +++ b/src/mcpd/src/repositories/project.repository.ts @@ -0,0 +1,69 @@ +import type { PrismaClient, Project } from '@prisma/client'; +import type { CreateProjectInput, UpdateProjectInput } from '../validation/project.schema.js'; + +export interface IProjectRepository { + findAll(ownerId?: string): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + create(data: CreateProjectInput & { ownerId: string }): Promise; + update(id: string, data: UpdateProjectInput): Promise; + delete(id: string): Promise; + setProfiles(projectId: string, profileIds: string[]): Promise; + getProfileIds(projectId: string): Promise; +} + +export class ProjectRepository implements IProjectRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(ownerId?: string): Promise { + const where = ownerId !== undefined ? { ownerId } : {}; + return this.prisma.project.findMany({ where, orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.project.findUnique({ where: { id } }); + } + + async findByName(name: string): Promise { + return this.prisma.project.findUnique({ where: { name } }); + } + + async create(data: CreateProjectInput & { ownerId: string }): Promise { + return this.prisma.project.create({ + data: { + name: data.name, + description: data.description, + ownerId: data.ownerId, + }, + }); + } + + async update(id: string, data: UpdateProjectInput): Promise { + const updateData: Record = {}; + if (data.description !== undefined) updateData['description'] = data.description; + return this.prisma.project.update({ where: { id }, data: updateData }); + } + + async delete(id: string): Promise { + await this.prisma.project.delete({ where: { id } }); + } + + async setProfiles(projectId: string, profileIds: string[]): Promise { + await this.prisma.$transaction([ + this.prisma.projectMcpProfile.deleteMany({ where: { projectId } }), + ...profileIds.map((profileId) => + this.prisma.projectMcpProfile.create({ + data: { projectId, profileId }, + }), + ), + ]); + } + + async getProfileIds(projectId: string): Promise { + const links = await this.prisma.projectMcpProfile.findMany({ + where: { projectId }, + select: { profileId: true }, + }); + return links.map((l) => l.profileId); + } +} diff --git a/src/mcpd/src/routes/health.ts b/src/mcpd/src/routes/health.ts new file mode 100644 index 0000000..b669a0a --- /dev/null +++ b/src/mcpd/src/routes/health.ts @@ -0,0 +1,30 @@ +import type { FastifyInstance } from 'fastify'; +import { APP_VERSION } from '@mcpctl/shared'; + +export interface HealthDeps { + checkDb: () => Promise; +} + +export function registerHealthRoutes(app: FastifyInstance, deps: HealthDeps): void { + app.get('/health', async (_request, reply) => { + const dbOk = await deps.checkDb().catch(() => false); + + const status = dbOk ? 'healthy' : 'degraded'; + const statusCode = dbOk ? 200 : 503; + + reply.code(statusCode).send({ + status, + version: APP_VERSION, + uptime: process.uptime(), + timestamp: new Date().toISOString(), + checks: { + database: dbOk ? 'ok' : 'error', + }, + }); + }); + + // Simple liveness probe + app.get('/healthz', async (_request, reply) => { + reply.code(200).send({ status: 'ok' }); + }); +} diff --git a/src/mcpd/src/routes/index.ts b/src/mcpd/src/routes/index.ts new file mode 100644 index 0000000..0c0c777 --- /dev/null +++ b/src/mcpd/src/routes/index.ts @@ -0,0 +1,5 @@ +export { registerHealthRoutes } from './health.js'; +export type { HealthDeps } from './health.js'; +export { registerMcpServerRoutes } from './mcp-servers.js'; +export { registerMcpProfileRoutes } from './mcp-profiles.js'; +export { registerProjectRoutes } from './projects.js'; diff --git a/src/mcpd/src/routes/mcp-profiles.ts b/src/mcpd/src/routes/mcp-profiles.ts new file mode 100644 index 0000000..c710375 --- /dev/null +++ b/src/mcpd/src/routes/mcp-profiles.ts @@ -0,0 +1,27 @@ +import type { FastifyInstance } from 'fastify'; +import type { McpProfileService } from '../services/mcp-profile.service.js'; + +export function registerMcpProfileRoutes(app: FastifyInstance, service: McpProfileService): void { + app.get<{ Querystring: { serverId?: string } }>('/api/v1/profiles', async (request) => { + return service.list(request.query.serverId); + }); + + app.get<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request) => { + return service.getById(request.params.id); + }); + + app.post('/api/v1/profiles', async (request, reply) => { + const profile = await service.create(request.body); + reply.code(201); + return profile; + }); + + app.put<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request) => { + return service.update(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request, reply) => { + await service.delete(request.params.id); + reply.code(204); + }); +} diff --git a/src/mcpd/src/routes/mcp-servers.ts b/src/mcpd/src/routes/mcp-servers.ts new file mode 100644 index 0000000..fe6273c --- /dev/null +++ b/src/mcpd/src/routes/mcp-servers.ts @@ -0,0 +1,27 @@ +import type { FastifyInstance } from 'fastify'; +import type { McpServerService } from '../services/mcp-server.service.js'; + +export function registerMcpServerRoutes(app: FastifyInstance, service: McpServerService): void { + app.get('/api/v1/servers', async () => { + return service.list(); + }); + + app.get<{ Params: { id: string } }>('/api/v1/servers/:id', async (request) => { + return service.getById(request.params.id); + }); + + app.post('/api/v1/servers', async (request, reply) => { + const server = await service.create(request.body); + reply.code(201); + return server; + }); + + app.put<{ Params: { id: string } }>('/api/v1/servers/:id', async (request) => { + return service.update(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/servers/:id', async (request, reply) => { + await service.delete(request.params.id); + reply.code(204); + }); +} diff --git a/src/mcpd/src/routes/projects.ts b/src/mcpd/src/routes/projects.ts new file mode 100644 index 0000000..73bc54b --- /dev/null +++ b/src/mcpd/src/routes/projects.ts @@ -0,0 +1,43 @@ +import type { FastifyInstance } from 'fastify'; +import type { ProjectService } from '../services/project.service.js'; + +export function registerProjectRoutes(app: FastifyInstance, service: ProjectService): void { + app.get('/api/v1/projects', async (request) => { + // If authenticated, filter by owner; otherwise list all + return service.list(request.userId); + }); + + app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => { + return service.getById(request.params.id); + }); + + app.post('/api/v1/projects', async (request, reply) => { + const ownerId = request.userId ?? 'anonymous'; + const project = await service.create(request.body, ownerId); + reply.code(201); + return project; + }); + + app.put<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => { + return service.update(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/projects/:id', async (request, reply) => { + await service.delete(request.params.id); + reply.code(204); + }); + + // Profile associations + app.get<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => { + return service.getProfiles(request.params.id); + }); + + app.put<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => { + return service.setProfiles(request.params.id, request.body); + }); + + // MCP config generation + app.get<{ Params: { id: string } }>('/api/v1/projects/:id/mcp-config', async (request) => { + return service.getMcpConfig(request.params.id); + }); +} diff --git a/src/mcpd/src/server.ts b/src/mcpd/src/server.ts new file mode 100644 index 0000000..e731488 --- /dev/null +++ b/src/mcpd/src/server.ts @@ -0,0 +1,34 @@ +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import type { McpdConfig } from './config/index.js'; +import { registerSecurityPlugins } from './middleware/security.js'; +import { errorHandler } from './middleware/error-handler.js'; +import { registerHealthRoutes } from './routes/health.js'; +import type { HealthDeps } from './routes/health.js'; +import type { AuthDeps } from './middleware/auth.js'; +import type { AuditDeps } from './middleware/audit.js'; + +export interface ServerDeps { + health: HealthDeps; + auth?: AuthDeps; + audit?: AuditDeps; +} + +export async function createServer(config: McpdConfig, deps: ServerDeps): Promise { + const app = Fastify({ + logger: { + level: config.logLevel, + }, + }); + + // Error handler + app.setErrorHandler(errorHandler); + + // Security plugins + await registerSecurityPlugins(app, config); + + // Health routes (no auth required) + registerHealthRoutes(app, deps.health); + + return app; +} diff --git a/src/mcpd/src/services/index.ts b/src/mcpd/src/services/index.ts new file mode 100644 index 0000000..f199f69 --- /dev/null +++ b/src/mcpd/src/services/index.ts @@ -0,0 +1,5 @@ +export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js'; +export { McpProfileService } from './mcp-profile.service.js'; +export { ProjectService } from './project.service.js'; +export { generateMcpConfig } from './mcp-config-generator.js'; +export type { McpConfig, McpConfigServer, ProfileWithServer } from './mcp-config-generator.js'; diff --git a/src/mcpd/src/services/mcp-config-generator.ts b/src/mcpd/src/services/mcp-config-generator.ts new file mode 100644 index 0000000..8be1164 --- /dev/null +++ b/src/mcpd/src/services/mcp-config-generator.ts @@ -0,0 +1,59 @@ +import type { McpServer, McpProfile } from '@prisma/client'; + +export interface McpConfigServer { + command: string; + args: string[]; + env?: Record; +} + +export interface McpConfig { + mcpServers: Record; +} + +export interface ProfileWithServer { + profile: McpProfile; + server: McpServer; +} + +/** + * Generate .mcp.json config from a project's profiles. + * Secret env vars are excluded from the output — they must be injected at runtime. + */ +export function generateMcpConfig(profiles: ProfileWithServer[]): McpConfig { + const mcpServers: Record = {}; + + for (const { profile, server } of profiles) { + const key = `${server.name}--${profile.name}`; + const envTemplate = server.envTemplate as Array<{ + name: string; + isSecret: boolean; + defaultValue?: string; + }>; + const envOverrides = profile.envOverrides as Record; + + // Build env: only include non-secret env vars + const env: Record = {}; + for (const entry of envTemplate) { + if (entry.isSecret) continue; // Never include secrets in config output + const override = envOverrides[entry.name]; + if (override !== undefined) { + env[entry.name] = override; + } else if (entry.defaultValue !== undefined) { + env[entry.name] = entry.defaultValue; + } + } + + const config: McpConfigServer = { + command: 'npx', + args: ['-y', server.packageName ?? server.name], + }; + + if (Object.keys(env).length > 0) { + config.env = env; + } + + mcpServers[key] = config; + } + + return { mcpServers }; +} diff --git a/src/mcpd/src/services/mcp-profile.service.ts b/src/mcpd/src/services/mcp-profile.service.ts new file mode 100644 index 0000000..c2a33df --- /dev/null +++ b/src/mcpd/src/services/mcp-profile.service.ts @@ -0,0 +1,62 @@ +import type { McpProfile } from '@prisma/client'; +import type { IMcpProfileRepository, IMcpServerRepository } from '../repositories/interfaces.js'; +import { CreateMcpProfileSchema, UpdateMcpProfileSchema } from '../validation/mcp-profile.schema.js'; +import { NotFoundError, ConflictError } from './mcp-server.service.js'; + +export class McpProfileService { + constructor( + private readonly profileRepo: IMcpProfileRepository, + private readonly serverRepo: IMcpServerRepository, + ) {} + + async list(serverId?: string): Promise { + return this.profileRepo.findAll(serverId); + } + + async getById(id: string): Promise { + const profile = await this.profileRepo.findById(id); + if (profile === null) { + throw new NotFoundError(`Profile not found: ${id}`); + } + return profile; + } + + async create(input: unknown): Promise { + const data = CreateMcpProfileSchema.parse(input); + + // Verify server exists + const server = await this.serverRepo.findById(data.serverId); + if (server === null) { + throw new NotFoundError(`Server not found: ${data.serverId}`); + } + + // Check unique name per server + const existing = await this.profileRepo.findByServerAndName(data.serverId, data.name); + if (existing !== null) { + throw new ConflictError(`Profile "${data.name}" already exists for server "${server.name}"`); + } + + return this.profileRepo.create(data); + } + + async update(id: string, input: unknown): Promise { + const data = UpdateMcpProfileSchema.parse(input); + + const profile = await this.getById(id); + + // If renaming, check uniqueness + if (data.name !== undefined && data.name !== profile.name) { + const existing = await this.profileRepo.findByServerAndName(profile.serverId, data.name); + if (existing !== null) { + throw new ConflictError(`Profile "${data.name}" already exists for this server`); + } + } + + return this.profileRepo.update(id, data); + } + + async delete(id: string): Promise { + await this.getById(id); + await this.profileRepo.delete(id); + } +} diff --git a/src/mcpd/src/services/mcp-server.service.ts b/src/mcpd/src/services/mcp-server.service.ts new file mode 100644 index 0000000..4640dda --- /dev/null +++ b/src/mcpd/src/services/mcp-server.service.ts @@ -0,0 +1,69 @@ +import type { McpServer } from '@prisma/client'; +import type { IMcpServerRepository } from '../repositories/interfaces.js'; +import { CreateMcpServerSchema, UpdateMcpServerSchema } from '../validation/mcp-server.schema.js'; + +export class McpServerService { + constructor(private readonly repo: IMcpServerRepository) {} + + async list(): Promise { + return this.repo.findAll(); + } + + async getById(id: string): Promise { + const server = await this.repo.findById(id); + if (server === null) { + throw new NotFoundError(`Server not found: ${id}`); + } + return server; + } + + async getByName(name: string): Promise { + const server = await this.repo.findByName(name); + if (server === null) { + throw new NotFoundError(`Server not found: ${name}`); + } + return server; + } + + async create(input: unknown): Promise { + const data = CreateMcpServerSchema.parse(input); + + const existing = await this.repo.findByName(data.name); + if (existing !== null) { + throw new ConflictError(`Server already exists: ${data.name}`); + } + + return this.repo.create(data); + } + + async update(id: string, input: unknown): Promise { + const data = UpdateMcpServerSchema.parse(input); + + // Verify exists + await this.getById(id); + + return this.repo.update(id, data); + } + + async delete(id: string): Promise { + // Verify exists + await this.getById(id); + await this.repo.delete(id); + } +} + +export class NotFoundError extends Error { + readonly statusCode = 404; + constructor(message: string) { + super(message); + this.name = 'NotFoundError'; + } +} + +export class ConflictError extends Error { + readonly statusCode = 409; + constructor(message: string) { + super(message); + this.name = 'ConflictError'; + } +} diff --git a/src/mcpd/src/services/project.service.ts b/src/mcpd/src/services/project.service.ts new file mode 100644 index 0000000..ef763cc --- /dev/null +++ b/src/mcpd/src/services/project.service.ts @@ -0,0 +1,86 @@ +import type { Project } from '@prisma/client'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { IMcpProfileRepository, IMcpServerRepository } from '../repositories/interfaces.js'; +import { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from '../validation/project.schema.js'; +import { NotFoundError, ConflictError } from './mcp-server.service.js'; +import { generateMcpConfig } from './mcp-config-generator.js'; +import type { McpConfig, ProfileWithServer } from './mcp-config-generator.js'; + +export class ProjectService { + constructor( + private readonly projectRepo: IProjectRepository, + private readonly profileRepo: IMcpProfileRepository, + private readonly serverRepo: IMcpServerRepository, + ) {} + + async list(ownerId?: string): Promise { + return this.projectRepo.findAll(ownerId); + } + + async getById(id: string): Promise { + const project = await this.projectRepo.findById(id); + if (project === null) { + throw new NotFoundError(`Project not found: ${id}`); + } + return project; + } + + async create(input: unknown, ownerId: string): Promise { + const data = CreateProjectSchema.parse(input); + + const existing = await this.projectRepo.findByName(data.name); + if (existing !== null) { + throw new ConflictError(`Project already exists: ${data.name}`); + } + + return this.projectRepo.create({ ...data, ownerId }); + } + + async update(id: string, input: unknown): Promise { + const data = UpdateProjectSchema.parse(input); + await this.getById(id); + return this.projectRepo.update(id, data); + } + + async delete(id: string): Promise { + await this.getById(id); + await this.projectRepo.delete(id); + } + + async setProfiles(projectId: string, input: unknown): Promise { + const { profileIds } = UpdateProjectProfilesSchema.parse(input); + await this.getById(projectId); + + // Verify all profiles exist + for (const profileId of profileIds) { + const profile = await this.profileRepo.findById(profileId); + if (profile === null) { + throw new NotFoundError(`Profile not found: ${profileId}`); + } + } + + await this.projectRepo.setProfiles(projectId, profileIds); + return profileIds; + } + + async getProfiles(projectId: string): Promise { + await this.getById(projectId); + return this.projectRepo.getProfileIds(projectId); + } + + async getMcpConfig(projectId: string): Promise { + await this.getById(projectId); + const profileIds = await this.projectRepo.getProfileIds(projectId); + + const profilesWithServers: ProfileWithServer[] = []; + for (const profileId of profileIds) { + const profile = await this.profileRepo.findById(profileId); + if (profile === null) continue; + const server = await this.serverRepo.findById(profile.serverId); + if (server === null) continue; + profilesWithServers.push({ profile, server }); + } + + return generateMcpConfig(profilesWithServers); + } +} diff --git a/src/mcpd/src/utils/index.ts b/src/mcpd/src/utils/index.ts new file mode 100644 index 0000000..9ffcaad --- /dev/null +++ b/src/mcpd/src/utils/index.ts @@ -0,0 +1,2 @@ +export { setupGracefulShutdown } from './shutdown.js'; +export type { ShutdownDeps } from './shutdown.js'; diff --git a/src/mcpd/src/utils/shutdown.ts b/src/mcpd/src/utils/shutdown.ts new file mode 100644 index 0000000..256dc9d --- /dev/null +++ b/src/mcpd/src/utils/shutdown.ts @@ -0,0 +1,33 @@ +import type { FastifyInstance } from 'fastify'; + +export interface ShutdownDeps { + disconnectDb: () => Promise; +} + +export function setupGracefulShutdown( + app: FastifyInstance, + deps: ShutdownDeps, + processRef: NodeJS.Process = process, +): void { + let shuttingDown = false; + + const shutdown = async (signal: string): Promise => { + if (shuttingDown) return; + shuttingDown = true; + + app.log.info(`Received ${signal}, shutting down gracefully...`); + + try { + await app.close(); + await deps.disconnectDb(); + app.log.info('Server shut down successfully'); + } catch (err) { + app.log.error(err, 'Error during shutdown'); + } + + processRef.exit(0); + }; + + processRef.on('SIGTERM', () => { void shutdown('SIGTERM'); }); + processRef.on('SIGINT', () => { void shutdown('SIGINT'); }); +} diff --git a/src/mcpd/src/validation/index.ts b/src/mcpd/src/validation/index.ts new file mode 100644 index 0000000..a879147 --- /dev/null +++ b/src/mcpd/src/validation/index.ts @@ -0,0 +1,6 @@ +export { CreateMcpServerSchema, UpdateMcpServerSchema } from './mcp-server.schema.js'; +export type { CreateMcpServerInput, UpdateMcpServerInput } from './mcp-server.schema.js'; +export { CreateMcpProfileSchema, UpdateMcpProfileSchema } from './mcp-profile.schema.js'; +export type { CreateMcpProfileInput, UpdateMcpProfileInput } from './mcp-profile.schema.js'; +export { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from './project.schema.js'; +export type { CreateProjectInput, UpdateProjectInput, UpdateProjectProfilesInput } from './project.schema.js'; diff --git a/src/mcpd/src/validation/mcp-profile.schema.ts b/src/mcpd/src/validation/mcp-profile.schema.ts new file mode 100644 index 0000000..7ea4ce4 --- /dev/null +++ b/src/mcpd/src/validation/mcp-profile.schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const CreateMcpProfileSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), + serverId: z.string().min(1), + permissions: z.array(z.string()).default([]), + envOverrides: z.record(z.string()).default({}), +}); + +export const UpdateMcpProfileSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(), + permissions: z.array(z.string()).optional(), + envOverrides: z.record(z.string()).optional(), +}); + +export type CreateMcpProfileInput = z.infer; +export type UpdateMcpProfileInput = z.infer; diff --git a/src/mcpd/src/validation/mcp-server.schema.ts b/src/mcpd/src/validation/mcp-server.schema.ts new file mode 100644 index 0000000..1a2e217 --- /dev/null +++ b/src/mcpd/src/validation/mcp-server.schema.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +const EnvTemplateEntrySchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().max(500).default(''), + isSecret: z.boolean().default(false), + setupUrl: z.string().url().optional(), +}); + +export const CreateMcpServerSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), + description: z.string().max(1000).default(''), + packageName: z.string().max(200).optional(), + dockerImage: z.string().max(200).optional(), + transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'), + repositoryUrl: z.string().url().optional(), + envTemplate: z.array(EnvTemplateEntrySchema).default([]), +}); + +export const UpdateMcpServerSchema = z.object({ + description: z.string().max(1000).optional(), + packageName: z.string().max(200).nullable().optional(), + dockerImage: z.string().max(200).nullable().optional(), + transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).optional(), + repositoryUrl: z.string().url().nullable().optional(), + envTemplate: z.array(EnvTemplateEntrySchema).optional(), +}); + +export type CreateMcpServerInput = z.infer; +export type UpdateMcpServerInput = z.infer; diff --git a/src/mcpd/src/validation/project.schema.ts b/src/mcpd/src/validation/project.schema.ts new file mode 100644 index 0000000..95ec6a9 --- /dev/null +++ b/src/mcpd/src/validation/project.schema.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const CreateProjectSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), + description: z.string().max(1000).default(''), +}); + +export const UpdateProjectSchema = z.object({ + description: z.string().max(1000).optional(), +}); + +export const UpdateProjectProfilesSchema = z.object({ + profileIds: z.array(z.string().min(1)).min(0), +}); + +export type CreateProjectInput = z.infer; +export type UpdateProjectInput = z.infer; +export type UpdateProjectProfilesInput = z.infer; diff --git a/src/mcpd/tests/audit.test.ts b/src/mcpd/tests/audit.test.ts new file mode 100644 index 0000000..584a5ef --- /dev/null +++ b/src/mcpd/tests/audit.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { registerAuditHook } from '../src/middleware/audit.js'; + +let app: FastifyInstance; + +afterEach(async () => { + if (app) await app.close(); +}); + +describe('audit middleware', () => { + it('logs mutating requests from authenticated users', async () => { + const createAuditLog = vi.fn(async () => {}); + app = Fastify({ logger: false }); + + // Simulate authenticated request + app.addHook('preHandler', async (request) => { + request.userId = 'user-1'; + }); + + registerAuditHook(app, { createAuditLog }); + + app.post('/api/v1/servers', async () => ({ ok: true })); + await app.ready(); + + await app.inject({ method: 'POST', url: '/api/v1/servers', payload: {} }); + + expect(createAuditLog).toHaveBeenCalledOnce(); + expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'user-1', + action: 'CREATE', + resource: 'servers', + })); + }); + + it('does not log GET requests', async () => { + const createAuditLog = vi.fn(async () => {}); + app = Fastify({ logger: false }); + + app.addHook('preHandler', async (request) => { + request.userId = 'user-1'; + }); + + registerAuditHook(app, { createAuditLog }); + app.get('/api/v1/servers', async () => []); + await app.ready(); + + await app.inject({ method: 'GET', url: '/api/v1/servers' }); + expect(createAuditLog).not.toHaveBeenCalled(); + }); + + it('does not log unauthenticated requests', async () => { + const createAuditLog = vi.fn(async () => {}); + app = Fastify({ logger: false }); + + registerAuditHook(app, { createAuditLog }); + app.post('/api/v1/servers', async () => ({ ok: true })); + await app.ready(); + + await app.inject({ method: 'POST', url: '/api/v1/servers', payload: {} }); + expect(createAuditLog).not.toHaveBeenCalled(); + }); + + it('maps DELETE method to DELETE action', async () => { + const createAuditLog = vi.fn(async () => {}); + app = Fastify({ logger: false }); + + app.addHook('preHandler', async (request) => { + request.userId = 'user-1'; + }); + + registerAuditHook(app, { createAuditLog }); + app.delete('/api/v1/servers/:id', async () => ({ ok: true })); + await app.ready(); + + await app.inject({ method: 'DELETE', url: '/api/v1/servers/srv-123' }); + expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'DELETE', + resource: 'servers', + resourceId: 'srv-123', + })); + }); + + it('maps PUT/PATCH to UPDATE action', async () => { + const createAuditLog = vi.fn(async () => {}); + app = Fastify({ logger: false }); + + app.addHook('preHandler', async (request) => { + request.userId = 'user-1'; + }); + + registerAuditHook(app, { createAuditLog }); + app.put('/api/v1/servers/:id', async () => ({ ok: true })); + await app.ready(); + + await app.inject({ method: 'PUT', url: '/api/v1/servers/srv-1', payload: {} }); + expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'UPDATE', + })); + }); +}); diff --git a/src/mcpd/tests/auth.test.ts b/src/mcpd/tests/auth.test.ts new file mode 100644 index 0000000..938b8a2 --- /dev/null +++ b/src/mcpd/tests/auth.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { createAuthMiddleware } from '../src/middleware/auth.js'; + +let app: FastifyInstance; + +afterEach(async () => { + if (app) await app.close(); +}); + +function setupApp(findSession: (token: string) => Promise<{ userId: string; expiresAt: Date } | null>) { + app = Fastify({ logger: false }); + const authMiddleware = createAuthMiddleware({ findSession }); + + app.addHook('preHandler', authMiddleware); + app.get('/protected', async (request) => { + return { userId: request.userId }; + }); + + return app.ready(); +} + +describe('auth middleware', () => { + it('returns 401 when no Authorization header', async () => { + await setupApp(async () => null); + const res = await app.inject({ method: 'GET', url: '/protected' }); + expect(res.statusCode).toBe(401); + expect(res.json<{ error: string }>().error).toContain('Authorization'); + }); + + it('returns 401 when header is not Bearer', async () => { + await setupApp(async () => null); + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: { authorization: 'Basic abc123' }, + }); + expect(res.statusCode).toBe(401); + }); + + it('returns 401 when token is empty', async () => { + await setupApp(async () => null); + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: { authorization: 'Bearer ' }, + }); + expect(res.statusCode).toBe(401); + expect(res.json<{ error: string }>().error).toContain('Empty'); + }); + + it('returns 401 when token not found', async () => { + await setupApp(async () => null); + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: { authorization: 'Bearer invalid-token' }, + }); + expect(res.statusCode).toBe(401); + expect(res.json<{ error: string }>().error).toContain('Invalid'); + }); + + it('returns 401 when token is expired', async () => { + const pastDate = new Date(Date.now() - 86400_000); + await setupApp(async () => ({ userId: 'user-1', expiresAt: pastDate })); + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: { authorization: 'Bearer expired-token' }, + }); + expect(res.statusCode).toBe(401); + expect(res.json<{ error: string }>().error).toContain('expired'); + }); + + it('passes valid token and sets userId', async () => { + const futureDate = new Date(Date.now() + 86400_000); + await setupApp(async () => ({ userId: 'user-42', expiresAt: futureDate })); + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: { authorization: 'Bearer valid-token' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json<{ userId: string }>().userId).toBe('user-42'); + }); + + it('calls findSession with the token', async () => { + const findSession = vi.fn(async () => ({ + userId: 'user-1', + expiresAt: new Date(Date.now() + 86400_000), + })); + await setupApp(findSession); + await app.inject({ + method: 'GET', + url: '/protected', + headers: { authorization: 'Bearer my-token' }, + }); + expect(findSession).toHaveBeenCalledWith('my-token'); + }); +}); diff --git a/src/mcpd/tests/config.test.ts b/src/mcpd/tests/config.test.ts new file mode 100644 index 0000000..dcbb6c0 --- /dev/null +++ b/src/mcpd/tests/config.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { McpdConfigSchema, loadConfigFromEnv } from '../src/config/index.js'; + +describe('McpdConfigSchema', () => { + it('requires databaseUrl', () => { + expect(() => McpdConfigSchema.parse({})).toThrow(); + }); + + it('provides defaults with minimal input', () => { + const config = McpdConfigSchema.parse({ databaseUrl: 'postgresql://localhost/test' }); + expect(config.port).toBe(3000); + expect(config.host).toBe('0.0.0.0'); + expect(config.logLevel).toBe('info'); + expect(config.corsOrigins).toEqual(['*']); + expect(config.rateLimitMax).toBe(100); + expect(config.rateLimitWindowMs).toBe(60_000); + }); + + it('validates full config', () => { + const config = McpdConfigSchema.parse({ + port: 4000, + host: '127.0.0.1', + databaseUrl: 'postgresql://localhost/test', + logLevel: 'debug', + corsOrigins: ['http://localhost:3000'], + rateLimitMax: 50, + rateLimitWindowMs: 30_000, + }); + expect(config.port).toBe(4000); + expect(config.logLevel).toBe('debug'); + }); + + it('rejects invalid log level', () => { + expect(() => McpdConfigSchema.parse({ + databaseUrl: 'postgresql://localhost/test', + logLevel: 'verbose', + })).toThrow(); + }); + + it('rejects zero port', () => { + expect(() => McpdConfigSchema.parse({ + databaseUrl: 'postgresql://localhost/test', + port: 0, + })).toThrow(); + }); +}); + +describe('loadConfigFromEnv', () => { + it('loads config from environment variables', () => { + const config = loadConfigFromEnv({ + DATABASE_URL: 'postgresql://localhost/test', + MCPD_PORT: '4000', + MCPD_HOST: '127.0.0.1', + MCPD_LOG_LEVEL: 'debug', + }); + expect(config.port).toBe(4000); + expect(config.host).toBe('127.0.0.1'); + expect(config.databaseUrl).toBe('postgresql://localhost/test'); + expect(config.logLevel).toBe('debug'); + }); + + it('uses defaults for missing env vars', () => { + const config = loadConfigFromEnv({ + DATABASE_URL: 'postgresql://localhost/test', + }); + expect(config.port).toBe(3000); + expect(config.host).toBe('0.0.0.0'); + }); + + it('parses CORS origins from comma-separated string', () => { + const config = loadConfigFromEnv({ + DATABASE_URL: 'postgresql://localhost/test', + MCPD_CORS_ORIGINS: 'http://a.com, http://b.com', + }); + expect(config.corsOrigins).toEqual(['http://a.com', 'http://b.com']); + }); + + it('throws when DATABASE_URL is missing', () => { + expect(() => loadConfigFromEnv({})).toThrow(); + }); +}); diff --git a/src/mcpd/tests/error-handler.test.ts b/src/mcpd/tests/error-handler.test.ts new file mode 100644 index 0000000..eeb2cd1 --- /dev/null +++ b/src/mcpd/tests/error-handler.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { ZodError, z } from 'zod'; +import { errorHandler } from '../src/middleware/error-handler.js'; + +let app: FastifyInstance; + +afterEach(async () => { + if (app) await app.close(); +}); + +function setupApp() { + app = Fastify({ logger: false }); + app.setErrorHandler(errorHandler); + return app; +} + +describe('errorHandler', () => { + it('returns 400 for ZodError', async () => { + const a = setupApp(); + a.get('/test', async () => { + z.object({ name: z.string() }).parse({}); + }); + await a.ready(); + + const res = await a.inject({ method: 'GET', url: '/test' }); + expect(res.statusCode).toBe(400); + const body = res.json<{ error: string; details: unknown[] }>(); + expect(body.error).toBe('Validation error'); + expect(body.details).toBeDefined(); + }); + + it('returns 500 for unknown errors and hides details', async () => { + const a = setupApp(); + a.get('/test', async () => { + throw new Error('secret database password leaked'); + }); + await a.ready(); + + const res = await a.inject({ method: 'GET', url: '/test' }); + expect(res.statusCode).toBe(500); + const body = res.json<{ error: string }>(); + expect(body.error).toBe('Internal server error'); + expect(JSON.stringify(body)).not.toContain('secret'); + }); + + it('returns correct status for HTTP errors', async () => { + const a = setupApp(); + a.get('/test', async (_req, reply) => { + reply.code(404).send({ error: 'Not found', statusCode: 404 }); + }); + await a.ready(); + + const res = await a.inject({ method: 'GET', url: '/test' }); + expect(res.statusCode).toBe(404); + }); + + it('returns 429 for rate limit errors', async () => { + const a = setupApp(); + a.get('/test', async () => { + const err = new Error('Rate limit') as Error & { statusCode: number }; + err.statusCode = 429; + throw err; + }); + await a.ready(); + + const res = await a.inject({ method: 'GET', url: '/test' }); + expect(res.statusCode).toBe(429); + expect(res.json<{ error: string }>().error).toBe('Rate limit exceeded'); + }); +}); diff --git a/src/mcpd/tests/health.test.ts b/src/mcpd/tests/health.test.ts new file mode 100644 index 0000000..6a85150 --- /dev/null +++ b/src/mcpd/tests/health.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { registerHealthRoutes } from '../src/routes/health.js'; + +let app: FastifyInstance; + +afterEach(async () => { + if (app) await app.close(); +}); + +describe('GET /health', () => { + it('returns healthy when DB is up', async () => { + app = Fastify({ logger: false }); + registerHealthRoutes(app, { checkDb: async () => true }); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.statusCode).toBe(200); + const body = res.json<{ status: string; version: string; checks: { database: string } }>(); + expect(body.status).toBe('healthy'); + expect(body.version).toBeDefined(); + expect(body.checks.database).toBe('ok'); + }); + + it('returns degraded when DB is down', async () => { + app = Fastify({ logger: false }); + registerHealthRoutes(app, { checkDb: async () => false }); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.statusCode).toBe(503); + const body = res.json<{ status: string; checks: { database: string } }>(); + expect(body.status).toBe('degraded'); + expect(body.checks.database).toBe('error'); + }); + + it('returns degraded when DB check throws', async () => { + app = Fastify({ logger: false }); + registerHealthRoutes(app, { + checkDb: async () => { throw new Error('connection refused'); }, + }); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.statusCode).toBe(503); + }); + + it('includes uptime and timestamp', async () => { + app = Fastify({ logger: false }); + registerHealthRoutes(app, { checkDb: async () => true }); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/health' }); + const body = res.json<{ uptime: number; timestamp: string }>(); + expect(body.uptime).toBeGreaterThan(0); + expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); +}); + +describe('GET /healthz', () => { + it('returns ok (liveness probe)', async () => { + app = Fastify({ logger: false }); + registerHealthRoutes(app, { checkDb: async () => true }); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/healthz' }); + expect(res.statusCode).toBe(200); + expect(res.json<{ status: string }>().status).toBe('ok'); + }); +}); diff --git a/src/mcpd/tests/mcp-config-generator.test.ts b/src/mcpd/tests/mcp-config-generator.test.ts new file mode 100644 index 0000000..a4817e0 --- /dev/null +++ b/src/mcpd/tests/mcp-config-generator.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { generateMcpConfig } from '../src/services/mcp-config-generator.js'; +import type { ProfileWithServer } from '../src/services/mcp-config-generator.js'; + +function makeProfile(overrides: Partial = {}): ProfileWithServer['profile'] { + return { + id: 'p1', + name: 'default', + serverId: 's1', + permissions: [], + envOverrides: {}, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makeServer(overrides: Partial = {}): ProfileWithServer['server'] { + return { + id: 's1', + name: 'slack', + description: 'Slack MCP', + packageName: '@anthropic/slack-mcp', + dockerImage: null, + transport: 'STDIO', + repositoryUrl: null, + envTemplate: [], + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('generateMcpConfig', () => { + it('returns empty mcpServers for empty profiles', () => { + const result = generateMcpConfig([]); + expect(result).toEqual({ mcpServers: {} }); + }); + + it('generates config for a single profile', () => { + const result = generateMcpConfig([ + { profile: makeProfile(), server: makeServer() }, + ]); + expect(result.mcpServers['slack--default']).toBeDefined(); + expect(result.mcpServers['slack--default']?.command).toBe('npx'); + expect(result.mcpServers['slack--default']?.args).toEqual(['-y', '@anthropic/slack-mcp']); + }); + + it('excludes secret env vars from output', () => { + const server = makeServer({ + envTemplate: [ + { name: 'SLACK_BOT_TOKEN', description: 'Token', isSecret: true }, + { name: 'SLACK_TEAM_ID', description: 'Team', isSecret: false, defaultValue: 'T123' }, + ] as never, + }); + const result = generateMcpConfig([ + { profile: makeProfile(), server }, + ]); + const config = result.mcpServers['slack--default']; + expect(config?.env).toBeDefined(); + expect(config?.env?.['SLACK_TEAM_ID']).toBe('T123'); + expect(config?.env?.['SLACK_BOT_TOKEN']).toBeUndefined(); + }); + + it('applies env overrides from profile (non-secret only)', () => { + const server = makeServer({ + envTemplate: [ + { name: 'API_URL', description: 'URL', isSecret: false }, + ] as never, + }); + const profile = makeProfile({ + envOverrides: { API_URL: 'https://staging.example.com' } as never, + }); + const result = generateMcpConfig([{ profile, server }]); + expect(result.mcpServers['slack--default']?.env?.['API_URL']).toBe('https://staging.example.com'); + }); + + it('generates multiple server configs', () => { + const result = generateMcpConfig([ + { profile: makeProfile({ name: 'readonly' }), server: makeServer({ name: 'slack' }) }, + { profile: makeProfile({ name: 'default', id: 'p2' }), server: makeServer({ name: 'github', id: 's2', packageName: '@anthropic/github-mcp' }) }, + ]); + expect(Object.keys(result.mcpServers)).toHaveLength(2); + expect(result.mcpServers['slack--readonly']).toBeDefined(); + expect(result.mcpServers['github--default']).toBeDefined(); + }); + + it('omits env when no non-secret vars have values', () => { + const server = makeServer({ + envTemplate: [ + { name: 'TOKEN', description: 'Secret', isSecret: true }, + ] as never, + }); + const result = generateMcpConfig([ + { profile: makeProfile(), server }, + ]); + expect(result.mcpServers['slack--default']?.env).toBeUndefined(); + }); + + it('uses server name as fallback when packageName is null', () => { + const server = makeServer({ packageName: null }); + const result = generateMcpConfig([ + { profile: makeProfile(), server }, + ]); + expect(result.mcpServers['slack--default']?.args).toEqual(['-y', 'slack']); + }); +}); diff --git a/src/mcpd/tests/mcp-profile-service.test.ts b/src/mcpd/tests/mcp-profile-service.test.ts new file mode 100644 index 0000000..ef9a6c5 --- /dev/null +++ b/src/mcpd/tests/mcp-profile-service.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { McpProfileService } from '../src/services/mcp-profile.service.js'; +import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; +import type { IMcpProfileRepository, IMcpServerRepository } from '../src/repositories/interfaces.js'; + +function mockProfileRepo(): IMcpProfileRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByServerAndName: vi.fn(async () => null), + create: vi.fn(async (data) => ({ + id: 'new-id', + name: data.name, + serverId: data.serverId, + permissions: data.permissions ?? [], + envOverrides: data.envOverrides ?? {}, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + })), + update: vi.fn(async (id, data) => ({ + id, + name: data.name ?? 'test', + serverId: 'srv-1', + permissions: data.permissions ?? [], + envOverrides: data.envOverrides ?? {}, + version: 2, + createdAt: new Date(), + updatedAt: new Date(), + })), + delete: vi.fn(async () => {}), + }; +} + +function mockServerRepo(): IMcpServerRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async () => ({} as never)), + update: vi.fn(async () => ({} as never)), + delete: vi.fn(async () => {}), + }; +} + +describe('McpProfileService', () => { + let profileRepo: ReturnType; + let serverRepo: ReturnType; + let service: McpProfileService; + + beforeEach(() => { + profileRepo = mockProfileRepo(); + serverRepo = mockServerRepo(); + service = new McpProfileService(profileRepo, serverRepo); + }); + + describe('list', () => { + it('returns all profiles', async () => { + await service.list(); + expect(profileRepo.findAll).toHaveBeenCalledWith(undefined); + }); + + it('filters by serverId', async () => { + await service.list('srv-1'); + expect(profileRepo.findAll).toHaveBeenCalledWith('srv-1'); + }); + }); + + describe('getById', () => { + it('returns profile when found', async () => { + vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'test' } as never); + const result = await service.getById('1'); + expect(result.id).toBe('1'); + }); + + it('throws NotFoundError when not found', async () => { + await expect(service.getById('missing')).rejects.toThrow(NotFoundError); + }); + }); + + describe('create', () => { + it('creates a profile when server exists', async () => { + vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'test' } as never); + const result = await service.create({ name: 'readonly', serverId: 'srv-1' }); + expect(result.name).toBe('readonly'); + }); + + it('throws NotFoundError when server does not exist', async () => { + await expect(service.create({ name: 'test', serverId: 'missing' })).rejects.toThrow(NotFoundError); + }); + + it('throws ConflictError when profile name exists for server', async () => { + vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'test' } as never); + vi.mocked(profileRepo.findByServerAndName).mockResolvedValue({ id: '1' } as never); + await expect(service.create({ name: 'dup', serverId: 'srv-1' })).rejects.toThrow(ConflictError); + }); + }); + + describe('update', () => { + it('updates an existing profile', async () => { + vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'old', serverId: 'srv-1' } as never); + await service.update('1', { permissions: ['read'] }); + expect(profileRepo.update).toHaveBeenCalled(); + }); + + it('checks uniqueness when renaming', async () => { + vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'old', serverId: 'srv-1' } as never); + vi.mocked(profileRepo.findByServerAndName).mockResolvedValue({ id: '2' } as never); + await expect(service.update('1', { name: 'taken' })).rejects.toThrow(ConflictError); + }); + + it('throws NotFoundError when profile does not exist', async () => { + await expect(service.update('missing', {})).rejects.toThrow(NotFoundError); + }); + }); + + describe('delete', () => { + it('deletes an existing profile', async () => { + vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1' } as never); + await service.delete('1'); + expect(profileRepo.delete).toHaveBeenCalledWith('1'); + }); + + it('throws NotFoundError when profile does not exist', async () => { + await expect(service.delete('missing')).rejects.toThrow(NotFoundError); + }); + }); +}); diff --git a/src/mcpd/tests/mcp-server-routes.test.ts b/src/mcpd/tests/mcp-server-routes.test.ts new file mode 100644 index 0000000..95f7cb8 --- /dev/null +++ b/src/mcpd/tests/mcp-server-routes.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js'; +import { McpServerService, NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; +import { errorHandler } from '../src/middleware/error-handler.js'; +import type { IMcpServerRepository } from '../src/repositories/interfaces.js'; + +let app: FastifyInstance; + +function mockRepo(): IMcpServerRepository { + return { + findAll: vi.fn(async () => [ + { id: '1', name: 'slack', description: 'Slack server', transport: 'STDIO' }, + ]), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async (data) => ({ + id: 'new-id', + name: data.name, + description: data.description ?? '', + packageName: data.packageName ?? null, + dockerImage: null, + transport: data.transport ?? 'STDIO', + repositoryUrl: null, + envTemplate: [], + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + })), + update: vi.fn(async (id, data) => ({ + id, + name: 'slack', + description: (data.description as string) ?? 'Slack server', + packageName: null, + dockerImage: null, + transport: 'STDIO', + repositoryUrl: null, + envTemplate: [], + version: 2, + createdAt: new Date(), + updatedAt: new Date(), + })), + delete: vi.fn(async () => {}), + }; +} + +afterEach(async () => { + if (app) await app.close(); +}); + +function createApp(repo: IMcpServerRepository) { + app = Fastify({ logger: false }); + app.setErrorHandler(errorHandler); + const service = new McpServerService(repo); + registerMcpServerRoutes(app, service); + return app.ready(); +} + +describe('MCP Server Routes', () => { + describe('GET /api/v1/servers', () => { + it('returns server list', async () => { + const repo = mockRepo(); + await createApp(repo); + const res = await app.inject({ method: 'GET', url: '/api/v1/servers' }); + expect(res.statusCode).toBe(200); + const body = res.json>(); + expect(body).toHaveLength(1); + expect(body[0]?.name).toBe('slack'); + }); + }); + + describe('GET /api/v1/servers/:id', () => { + it('returns 404 when not found', async () => { + const repo = mockRepo(); + await createApp(repo); + const res = await app.inject({ method: 'GET', url: '/api/v1/servers/missing' }); + expect(res.statusCode).toBe(404); + }); + + it('returns server when found', async () => { + const repo = mockRepo(); + vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'slack' } as never); + await createApp(repo); + const res = await app.inject({ method: 'GET', url: '/api/v1/servers/1' }); + expect(res.statusCode).toBe(200); + }); + }); + + describe('POST /api/v1/servers', () => { + it('creates a server and returns 201', async () => { + const repo = mockRepo(); + await createApp(repo); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/servers', + payload: { name: 'new-server' }, + }); + expect(res.statusCode).toBe(201); + expect(res.json<{ name: string }>().name).toBe('new-server'); + }); + + it('returns 400 for invalid input', async () => { + const repo = mockRepo(); + await createApp(repo); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/servers', + payload: { name: '' }, + }); + expect(res.statusCode).toBe(400); + }); + + it('returns 409 when name already exists', async () => { + const repo = mockRepo(); + vi.mocked(repo.findByName).mockResolvedValue({ id: '1' } as never); + await createApp(repo); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/servers', + payload: { name: 'existing' }, + }); + expect(res.statusCode).toBe(409); + }); + }); + + describe('PUT /api/v1/servers/:id', () => { + it('updates a server', async () => { + const repo = mockRepo(); + vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'slack' } as never); + await createApp(repo); + const res = await app.inject({ + method: 'PUT', + url: '/api/v1/servers/1', + payload: { description: 'Updated' }, + }); + expect(res.statusCode).toBe(200); + }); + + it('returns 404 when not found', async () => { + const repo = mockRepo(); + await createApp(repo); + const res = await app.inject({ + method: 'PUT', + url: '/api/v1/servers/missing', + payload: { description: 'x' }, + }); + expect(res.statusCode).toBe(404); + }); + }); + + describe('DELETE /api/v1/servers/:id', () => { + it('deletes a server and returns 204', async () => { + const repo = mockRepo(); + vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'slack' } as never); + await createApp(repo); + const res = await app.inject({ method: 'DELETE', url: '/api/v1/servers/1' }); + expect(res.statusCode).toBe(204); + }); + + it('returns 404 when not found', async () => { + const repo = mockRepo(); + await createApp(repo); + const res = await app.inject({ method: 'DELETE', url: '/api/v1/servers/missing' }); + expect(res.statusCode).toBe(404); + }); + }); +}); diff --git a/src/mcpd/tests/mcp-server-service.test.ts b/src/mcpd/tests/mcp-server-service.test.ts new file mode 100644 index 0000000..5e52328 --- /dev/null +++ b/src/mcpd/tests/mcp-server-service.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { McpServerService, NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; +import type { IMcpServerRepository } from '../src/repositories/interfaces.js'; + +function mockRepo(): IMcpServerRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async (data) => ({ + id: 'new-id', + name: data.name, + description: data.description ?? '', + packageName: data.packageName ?? null, + dockerImage: null, + transport: data.transport ?? 'STDIO', + repositoryUrl: data.repositoryUrl ?? null, + envTemplate: data.envTemplate ?? [], + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + })), + update: vi.fn(async (id, data) => ({ + id, + name: 'test', + description: (data.description as string) ?? '', + packageName: null, + dockerImage: null, + transport: 'STDIO' as const, + repositoryUrl: null, + envTemplate: [], + version: 2, + createdAt: new Date(), + updatedAt: new Date(), + })), + delete: vi.fn(async () => {}), + }; +} + +describe('McpServerService', () => { + let repo: ReturnType; + let service: McpServerService; + + beforeEach(() => { + repo = mockRepo(); + service = new McpServerService(repo); + }); + + describe('list', () => { + it('returns all servers', async () => { + const servers = await service.list(); + expect(repo.findAll).toHaveBeenCalled(); + expect(servers).toEqual([]); + }); + }); + + describe('getById', () => { + it('returns server when found', async () => { + const server = { id: '1', name: 'test' }; + vi.mocked(repo.findById).mockResolvedValue(server as never); + const result = await service.getById('1'); + expect(result.id).toBe('1'); + }); + + it('throws NotFoundError when not found', async () => { + await expect(service.getById('missing')).rejects.toThrow(NotFoundError); + }); + }); + + describe('create', () => { + it('creates a server with valid input', async () => { + const result = await service.create({ name: 'my-server' }); + expect(result.name).toBe('my-server'); + expect(repo.create).toHaveBeenCalled(); + }); + + it('throws ConflictError when name exists', async () => { + vi.mocked(repo.findByName).mockResolvedValue({ id: '1', name: 'existing' } as never); + await expect(service.create({ name: 'existing' })).rejects.toThrow(ConflictError); + }); + + it('throws on invalid input', async () => { + await expect(service.create({ name: '' })).rejects.toThrow(); + }); + }); + + describe('update', () => { + it('updates an existing server', async () => { + vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'test' } as never); + await service.update('1', { description: 'updated' }); + expect(repo.update).toHaveBeenCalledWith('1', { description: 'updated' }); + }); + + it('throws NotFoundError when server does not exist', async () => { + await expect(service.update('missing', {})).rejects.toThrow(NotFoundError); + }); + }); + + describe('delete', () => { + it('deletes an existing server', async () => { + vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'test' } as never); + await service.delete('1'); + expect(repo.delete).toHaveBeenCalledWith('1'); + }); + + it('throws NotFoundError when server does not exist', async () => { + await expect(service.delete('missing')).rejects.toThrow(NotFoundError); + }); + }); +}); diff --git a/src/mcpd/tests/project-service.test.ts b/src/mcpd/tests/project-service.test.ts new file mode 100644 index 0000000..fc75433 --- /dev/null +++ b/src/mcpd/tests/project-service.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ProjectService } from '../src/services/project.service.js'; +import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; +import type { IProjectRepository } from '../src/repositories/project.repository.js'; +import type { IMcpProfileRepository, IMcpServerRepository } from '../src/repositories/interfaces.js'; + +function mockProjectRepo(): IProjectRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async (data) => ({ + id: 'proj-1', + name: data.name, + description: data.description ?? '', + ownerId: data.ownerId, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + })), + update: vi.fn(async (id) => ({ + id, name: 'test', description: '', ownerId: 'u1', version: 2, + createdAt: new Date(), updatedAt: new Date(), + })), + delete: vi.fn(async () => {}), + setProfiles: vi.fn(async () => {}), + getProfileIds: vi.fn(async () => []), + }; +} + +function mockProfileRepo(): IMcpProfileRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByServerAndName: vi.fn(async () => null), + create: vi.fn(async () => ({} as never)), + update: vi.fn(async () => ({} as never)), + delete: vi.fn(async () => {}), + }; +} + +function mockServerRepo(): IMcpServerRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async () => ({} as never)), + update: vi.fn(async () => ({} as never)), + delete: vi.fn(async () => {}), + }; +} + +describe('ProjectService', () => { + let projectRepo: ReturnType; + let profileRepo: ReturnType; + let serverRepo: ReturnType; + let service: ProjectService; + + beforeEach(() => { + projectRepo = mockProjectRepo(); + profileRepo = mockProfileRepo(); + serverRepo = mockServerRepo(); + service = new ProjectService(projectRepo, profileRepo, serverRepo); + }); + + describe('create', () => { + it('creates a project', async () => { + const result = await service.create({ name: 'my-project' }, 'user-1'); + expect(result.name).toBe('my-project'); + expect(result.ownerId).toBe('user-1'); + }); + + it('throws ConflictError when name exists', async () => { + vi.mocked(projectRepo.findByName).mockResolvedValue({ id: '1' } as never); + await expect(service.create({ name: 'taken' }, 'u1')).rejects.toThrow(ConflictError); + }); + + it('validates input', async () => { + await expect(service.create({ name: '' }, 'u1')).rejects.toThrow(); + }); + }); + + describe('getById', () => { + it('throws NotFoundError when not found', async () => { + await expect(service.getById('missing')).rejects.toThrow(NotFoundError); + }); + }); + + describe('setProfiles', () => { + it('sets profile associations', async () => { + vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never); + vi.mocked(profileRepo.findById).mockResolvedValue({ id: 'prof-1' } as never); + const result = await service.setProfiles('p1', { profileIds: ['prof-1'] }); + expect(result).toEqual(['prof-1']); + expect(projectRepo.setProfiles).toHaveBeenCalledWith('p1', ['prof-1']); + }); + + it('throws NotFoundError for missing profile', async () => { + vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never); + await expect(service.setProfiles('p1', { profileIds: ['missing'] })).rejects.toThrow(NotFoundError); + }); + + it('throws NotFoundError for missing project', async () => { + await expect(service.setProfiles('missing', { profileIds: [] })).rejects.toThrow(NotFoundError); + }); + }); + + describe('getMcpConfig', () => { + it('returns empty config for project with no profiles', async () => { + vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never); + const result = await service.getMcpConfig('p1'); + expect(result).toEqual({ mcpServers: {} }); + }); + + it('generates config from profiles', async () => { + vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never); + vi.mocked(projectRepo.getProfileIds).mockResolvedValue(['prof-1']); + vi.mocked(profileRepo.findById).mockResolvedValue({ + id: 'prof-1', name: 'default', serverId: 's1', + permissions: [], envOverrides: {}, + version: 1, createdAt: new Date(), updatedAt: new Date(), + }); + vi.mocked(serverRepo.findById).mockResolvedValue({ + id: 's1', name: 'slack', description: '', packageName: '@anthropic/slack-mcp', + dockerImage: null, transport: 'STDIO', repositoryUrl: null, envTemplate: [], + version: 1, createdAt: new Date(), updatedAt: new Date(), + }); + + const result = await service.getMcpConfig('p1'); + expect(result.mcpServers['slack--default']).toBeDefined(); + }); + + it('throws NotFoundError for missing project', async () => { + await expect(service.getMcpConfig('missing')).rejects.toThrow(NotFoundError); + }); + }); + + describe('delete', () => { + it('deletes project', async () => { + vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never); + await service.delete('p1'); + expect(projectRepo.delete).toHaveBeenCalledWith('p1'); + }); + }); +}); diff --git a/src/mcpd/tests/server.test.ts b/src/mcpd/tests/server.test.ts new file mode 100644 index 0000000..11e5b50 --- /dev/null +++ b/src/mcpd/tests/server.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { createServer } from '../src/server.js'; +import type { McpdConfig } from '../src/config/index.js'; + +let app: FastifyInstance; + +afterEach(async () => { + if (app) await app.close(); +}); + +const testConfig: McpdConfig = { + port: 3000, + host: '0.0.0.0', + databaseUrl: 'postgresql://localhost/test', + logLevel: 'fatal', // suppress logs in tests + corsOrigins: ['*'], + rateLimitMax: 100, + rateLimitWindowMs: 60_000, +}; + +describe('createServer', () => { + it('creates a Fastify instance', async () => { + app = await createServer(testConfig, { + health: { checkDb: async () => true }, + }); + expect(app).toBeDefined(); + }); + + it('registers health endpoint', async () => { + app = await createServer(testConfig, { + health: { checkDb: async () => true }, + }); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.statusCode).toBe(200); + }); + + it('registers healthz endpoint', async () => { + app = await createServer(testConfig, { + health: { checkDb: async () => true }, + }); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/healthz' }); + expect(res.statusCode).toBe(200); + }); + + it('returns 404 for unknown routes', async () => { + app = await createServer(testConfig, { + health: { checkDb: async () => true }, + }); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/nonexistent' }); + expect(res.statusCode).toBe(404); + }); + + it('includes CORS headers', async () => { + app = await createServer(testConfig, { + health: { checkDb: async () => true }, + }); + await app.ready(); + + const res = await app.inject({ + method: 'OPTIONS', + url: '/health', + headers: { origin: 'http://localhost:3000' }, + }); + expect(res.headers['access-control-allow-origin']).toBeDefined(); + }); + + it('includes security headers from Helmet', async () => { + app = await createServer(testConfig, { + health: { checkDb: async () => true }, + }); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + }); +}); diff --git a/src/mcpd/tests/validation.test.ts b/src/mcpd/tests/validation.test.ts new file mode 100644 index 0000000..1529ebc --- /dev/null +++ b/src/mcpd/tests/validation.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { + CreateMcpServerSchema, + UpdateMcpServerSchema, + CreateMcpProfileSchema, + UpdateMcpProfileSchema, +} from '../src/validation/index.js'; + +describe('CreateMcpServerSchema', () => { + it('validates valid input', () => { + const result = CreateMcpServerSchema.parse({ + name: 'my-server', + description: 'A test server', + transport: 'STDIO', + }); + expect(result.name).toBe('my-server'); + expect(result.envTemplate).toEqual([]); + }); + + it('rejects empty name', () => { + expect(() => CreateMcpServerSchema.parse({ name: '' })).toThrow(); + }); + + it('rejects name with spaces', () => { + expect(() => CreateMcpServerSchema.parse({ name: 'my server' })).toThrow(); + }); + + it('rejects uppercase name', () => { + expect(() => CreateMcpServerSchema.parse({ name: 'MyServer' })).toThrow(); + }); + + it('allows hyphens in name', () => { + const result = CreateMcpServerSchema.parse({ name: 'my-mcp-server' }); + expect(result.name).toBe('my-mcp-server'); + }); + + it('defaults transport to STDIO', () => { + const result = CreateMcpServerSchema.parse({ name: 'test' }); + expect(result.transport).toBe('STDIO'); + }); + + it('validates envTemplate entries', () => { + const result = CreateMcpServerSchema.parse({ + name: 'test', + envTemplate: [ + { name: 'API_KEY', description: 'The key', isSecret: true }, + ], + }); + expect(result.envTemplate).toHaveLength(1); + expect(result.envTemplate[0]?.isSecret).toBe(true); + }); + + it('rejects invalid transport', () => { + expect(() => CreateMcpServerSchema.parse({ name: 'test', transport: 'HTTP' })).toThrow(); + }); + + it('rejects invalid repository URL', () => { + expect(() => CreateMcpServerSchema.parse({ name: 'test', repositoryUrl: 'not-a-url' })).toThrow(); + }); +}); + +describe('UpdateMcpServerSchema', () => { + it('allows partial updates', () => { + const result = UpdateMcpServerSchema.parse({ description: 'updated' }); + expect(result.description).toBe('updated'); + expect(result.transport).toBeUndefined(); + }); + + it('allows empty object', () => { + const result = UpdateMcpServerSchema.parse({}); + expect(Object.keys(result)).toHaveLength(0); + }); + + it('allows nullable fields', () => { + const result = UpdateMcpServerSchema.parse({ packageName: null, dockerImage: null }); + expect(result.packageName).toBeNull(); + expect(result.dockerImage).toBeNull(); + }); +}); + +describe('CreateMcpProfileSchema', () => { + it('validates valid input', () => { + const result = CreateMcpProfileSchema.parse({ + name: 'readonly', + serverId: 'server-123', + }); + expect(result.name).toBe('readonly'); + expect(result.permissions).toEqual([]); + expect(result.envOverrides).toEqual({}); + }); + + it('rejects empty name', () => { + expect(() => CreateMcpProfileSchema.parse({ name: '', serverId: 'x' })).toThrow(); + }); + + it('accepts permissions array', () => { + const result = CreateMcpProfileSchema.parse({ + name: 'admin', + serverId: 'x', + permissions: ['read', 'write', 'delete'], + }); + expect(result.permissions).toHaveLength(3); + }); + + it('accepts envOverrides', () => { + const result = CreateMcpProfileSchema.parse({ + name: 'staging', + serverId: 'x', + envOverrides: { API_URL: 'https://staging.example.com' }, + }); + expect(result.envOverrides['API_URL']).toBe('https://staging.example.com'); + }); +}); + +describe('UpdateMcpProfileSchema', () => { + it('allows partial updates', () => { + const result = UpdateMcpProfileSchema.parse({ permissions: ['read'] }); + expect(result.permissions).toEqual(['read']); + }); + + it('allows empty object', () => { + expect(UpdateMcpProfileSchema.parse({})).toBeDefined(); + }); +}); diff --git a/src/mcpd/tsconfig.json b/src/mcpd/tsconfig.json index 1d1421c..be275fe 100644 --- a/src/mcpd/tsconfig.json +++ b/src/mcpd/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "types": ["node"] }, "include": ["src/**/*.ts"], "references": [