feat: implement database schema with Prisma ORM #2

Closed
michal wants to merge 5 commits from feat/database-schema into main
72 changed files with 4145 additions and 161 deletions

View File

@@ -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"
]
}
}
}

189
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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<ConfigLoaderDeps>;
log: (...args: string[]) => void;
}
const defaultDeps: ConfigCommandDeps = {
configDeps: {},
log: (...args) => console.log(...args),
};
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>): 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 <format>', '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('<key>', 'configuration key (e.g., daemonUrl, outputFormat)')
.argument('<value>', 'value to set')
.action((key: string, value: string) => {
const updates: Record<string, unknown> = {};
// 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<McpctlConfig>, 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;
}

View File

@@ -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<ConfigLoaderDeps>;
log: (...args: string[]) => void;
checkDaemon: (url: string) => Promise<boolean>;
}
function defaultCheckDaemon(url: string): Promise<boolean> {
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<StatusCommandDeps>): Command {
const { configDeps, log, checkDaemon } = { ...defaultDeps, ...deps };
return new Command('status')
.description('Show mcpctl status and connectivity')
.option('-o, --output <format>', '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}`);
}
});
}

View File

@@ -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';

View File

@@ -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<ConfigLoaderDeps>): 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<ConfigLoaderDeps>): 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<McpctlConfig>, deps?: Partial<ConfigLoaderDeps>): McpctlConfig {
const current = loadConfig(deps);
return McpctlConfigSchema.parse({ ...current, ...overrides });
}

View File

@@ -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<typeof McpctlConfigSchema>;
export const DEFAULT_CONFIG: McpctlConfig = McpctlConfigSchema.parse({});

View File

@@ -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';

View File

@@ -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();
}

View File

@@ -0,0 +1,44 @@
export interface Column<T> {
header: string;
key: keyof T | ((row: T) => string);
width?: number;
align?: 'left' | 'right';
}
export function formatTable<T>(rows: T[], columns: Column<T>[]): string {
if (rows.length === 0) {
return 'No results found.';
}
const getValue = (row: T, col: Column<T>): 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');
}

View File

@@ -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 <format>', 'output format (table, json, yaml)', 'table')
.option('--daemon-url <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);
}

View File

@@ -173,7 +173,7 @@ export type SmitheryServerEntry = z.infer<typeof SmitheryServerSchema>;
// ── 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, '');

38
src/cli/tests/cli.test.ts Normal file
View File

@@ -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();
});
});

View File

@@ -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<string, unknown>;
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);
});
});

View File

@@ -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<string, unknown>;
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');
});
});

View File

@@ -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');
});
});

View File

@@ -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({}));
});
});

View File

@@ -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<string, unknown>;
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);
});
});

View File

@@ -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<TestRow>[] = [
{ 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<TestRow>[] = [
{ 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<TestRow>[] = [
{ 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]+$/);
});
});

View File

@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"references": [

172
src/db/prisma/schema.prisma Normal file
View File

@@ -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])
}

View File

@@ -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';

131
src/db/src/seed/index.ts Normal file
View File

@@ -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<number> {
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));
});
}

58
src/db/tests/helpers.ts Normal file
View File

@@ -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<PrismaClient> {
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<void> {
if (prisma) {
await prisma.$disconnect();
prisma = undefined;
}
}
export async function clearAllTables(client: PrismaClient): Promise<void> {
// 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();
}

364
src/db/tests/models.test.ts Normal file
View File

@@ -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);
});
});

71
src/db/tests/seed.test.ts Normal file
View File

@@ -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');
});
});

View File

@@ -4,5 +4,7 @@ export default defineProject({
test: {
name: 'db',
include: ['tests/**/*.test.ts'],
// Test files share the same database — run sequentially
fileParallelism: false,
},
});

View File

@@ -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"
}
}

View File

@@ -0,0 +1,2 @@
export { McpdConfigSchema, loadConfigFromEnv } from './schema.js';
export type { McpdConfig } from './schema.js';

View File

@@ -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<typeof McpdConfigSchema>;
export function loadConfigFromEnv(env: Record<string, string | undefined> = 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,
});
}

View File

@@ -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';

View File

@@ -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<string, unknown>;
}) => Promise<void>;
}
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<typeof deps.createAuditLog>[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 };
}

View File

@@ -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<void> {
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;
};
}

View File

@@ -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);
}

View File

@@ -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';

View File

@@ -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<void> {
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,
});
}

View File

@@ -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';

View File

@@ -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<McpServer[]>;
findById(id: string): Promise<McpServer | null>;
findByName(name: string): Promise<McpServer | null>;
create(data: CreateMcpServerInput): Promise<McpServer>;
update(id: string, data: UpdateMcpServerInput): Promise<McpServer>;
delete(id: string): Promise<void>;
}
export interface IMcpProfileRepository {
findAll(serverId?: string): Promise<McpProfile[]>;
findById(id: string): Promise<McpProfile | null>;
findByServerAndName(serverId: string, name: string): Promise<McpProfile | null>;
create(data: CreateMcpProfileInput): Promise<McpProfile>;
update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile>;
delete(id: string): Promise<void>;
}

View File

@@ -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<McpProfile[]> {
const where = serverId !== undefined ? { serverId } : {};
return this.prisma.mcpProfile.findMany({ where, orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<McpProfile | null> {
return this.prisma.mcpProfile.findUnique({ where: { id } });
}
async findByServerAndName(serverId: string, name: string): Promise<McpProfile | null> {
return this.prisma.mcpProfile.findUnique({
where: { name_serverId: { name, serverId } },
});
}
async create(data: CreateMcpProfileInput): Promise<McpProfile> {
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<McpProfile> {
const updateData: Record<string, unknown> = {};
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<void> {
await this.prisma.mcpProfile.delete({ where: { id } });
}
}

View File

@@ -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<McpServer[]> {
return this.prisma.mcpServer.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<McpServer | null> {
return this.prisma.mcpServer.findUnique({ where: { id } });
}
async findByName(name: string): Promise<McpServer | null> {
return this.prisma.mcpServer.findUnique({ where: { name } });
}
async create(data: CreateMcpServerInput): Promise<McpServer> {
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<McpServer> {
const updateData: Record<string, unknown> = {};
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<void> {
await this.prisma.mcpServer.delete({ where: { id } });
}
}

View File

@@ -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<Project[]>;
findById(id: string): Promise<Project | null>;
findByName(name: string): Promise<Project | null>;
create(data: CreateProjectInput & { ownerId: string }): Promise<Project>;
update(id: string, data: UpdateProjectInput): Promise<Project>;
delete(id: string): Promise<void>;
setProfiles(projectId: string, profileIds: string[]): Promise<void>;
getProfileIds(projectId: string): Promise<string[]>;
}
export class ProjectRepository implements IProjectRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(ownerId?: string): Promise<Project[]> {
const where = ownerId !== undefined ? { ownerId } : {};
return this.prisma.project.findMany({ where, orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<Project | null> {
return this.prisma.project.findUnique({ where: { id } });
}
async findByName(name: string): Promise<Project | null> {
return this.prisma.project.findUnique({ where: { name } });
}
async create(data: CreateProjectInput & { ownerId: string }): Promise<Project> {
return this.prisma.project.create({
data: {
name: data.name,
description: data.description,
ownerId: data.ownerId,
},
});
}
async update(id: string, data: UpdateProjectInput): Promise<Project> {
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
return this.prisma.project.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.project.delete({ where: { id } });
}
async setProfiles(projectId: string, profileIds: string[]): Promise<void> {
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<string[]> {
const links = await this.prisma.projectMcpProfile.findMany({
where: { projectId },
select: { profileId: true },
});
return links.map((l) => l.profileId);
}
}

View File

@@ -0,0 +1,30 @@
import type { FastifyInstance } from 'fastify';
import { APP_VERSION } from '@mcpctl/shared';
export interface HealthDeps {
checkDb: () => Promise<boolean>;
}
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' });
});
}

View File

@@ -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';

View File

@@ -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);
});
}

View File

@@ -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);
});
}

View File

@@ -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);
});
}

34
src/mcpd/src/server.ts Normal file
View File

@@ -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<FastifyInstance> {
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;
}

View File

@@ -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';

View File

@@ -0,0 +1,59 @@
import type { McpServer, McpProfile } from '@prisma/client';
export interface McpConfigServer {
command: string;
args: string[];
env?: Record<string, string>;
}
export interface McpConfig {
mcpServers: Record<string, McpConfigServer>;
}
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<string, McpConfigServer> = {};
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<string, string>;
// Build env: only include non-secret env vars
const env: Record<string, string> = {};
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 };
}

View File

@@ -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<McpProfile[]> {
return this.profileRepo.findAll(serverId);
}
async getById(id: string): Promise<McpProfile> {
const profile = await this.profileRepo.findById(id);
if (profile === null) {
throw new NotFoundError(`Profile not found: ${id}`);
}
return profile;
}
async create(input: unknown): Promise<McpProfile> {
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<McpProfile> {
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<void> {
await this.getById(id);
await this.profileRepo.delete(id);
}
}

View File

@@ -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<McpServer[]> {
return this.repo.findAll();
}
async getById(id: string): Promise<McpServer> {
const server = await this.repo.findById(id);
if (server === null) {
throw new NotFoundError(`Server not found: ${id}`);
}
return server;
}
async getByName(name: string): Promise<McpServer> {
const server = await this.repo.findByName(name);
if (server === null) {
throw new NotFoundError(`Server not found: ${name}`);
}
return server;
}
async create(input: unknown): Promise<McpServer> {
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<McpServer> {
const data = UpdateMcpServerSchema.parse(input);
// Verify exists
await this.getById(id);
return this.repo.update(id, data);
}
async delete(id: string): Promise<void> {
// 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';
}
}

View File

@@ -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<Project[]> {
return this.projectRepo.findAll(ownerId);
}
async getById(id: string): Promise<Project> {
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<Project> {
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<Project> {
const data = UpdateProjectSchema.parse(input);
await this.getById(id);
return this.projectRepo.update(id, data);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.projectRepo.delete(id);
}
async setProfiles(projectId: string, input: unknown): Promise<string[]> {
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<string[]> {
await this.getById(projectId);
return this.projectRepo.getProfileIds(projectId);
}
async getMcpConfig(projectId: string): Promise<McpConfig> {
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);
}
}

View File

@@ -0,0 +1,2 @@
export { setupGracefulShutdown } from './shutdown.js';
export type { ShutdownDeps } from './shutdown.js';

View File

@@ -0,0 +1,33 @@
import type { FastifyInstance } from 'fastify';
export interface ShutdownDeps {
disconnectDb: () => Promise<void>;
}
export function setupGracefulShutdown(
app: FastifyInstance,
deps: ShutdownDeps,
processRef: NodeJS.Process = process,
): void {
let shuttingDown = false;
const shutdown = async (signal: string): Promise<void> => {
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'); });
}

View File

@@ -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';

View File

@@ -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<typeof CreateMcpProfileSchema>;
export type UpdateMcpProfileInput = z.infer<typeof UpdateMcpProfileSchema>;

View File

@@ -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<typeof CreateMcpServerSchema>;
export type UpdateMcpServerInput = z.infer<typeof UpdateMcpServerSchema>;

View File

@@ -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<typeof CreateProjectSchema>;
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
export type UpdateProjectProfilesInput = z.infer<typeof UpdateProjectProfilesSchema>;

View File

@@ -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',
}));
});
});

101
src/mcpd/tests/auth.test.ts Normal file
View File

@@ -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');
});
});

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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']> = {}): 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']> = {}): 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']);
});
});

View File

@@ -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<typeof mockProfileRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
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);
});
});
});

View File

@@ -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<Array<{ name: string }>>();
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);
});
});
});

View File

@@ -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<typeof mockRepo>;
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);
});
});
});

View File

@@ -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<typeof mockProjectRepo>;
let profileRepo: ReturnType<typeof mockProfileRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
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');
});
});
});

View File

@@ -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');
});
});

View File

@@ -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();
});
});

View File

@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"references": [