Compare commits
5 Commits
feat/creat
...
feat/datab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da90f01dc1 | ||
|
|
ae7d79da6f | ||
|
|
d2a682a460 | ||
|
|
1b66e235fc | ||
|
|
981585a943 |
@@ -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
189
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
69
src/cli/src/commands/config.ts
Normal file
69
src/cli/src/commands/config.ts
Normal 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;
|
||||
}
|
||||
63
src/cli/src/commands/status.ts
Normal file
63
src/cli/src/commands/status.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
4
src/cli/src/config/index.ts
Normal file
4
src/cli/src/config/index.ts
Normal 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';
|
||||
45
src/cli/src/config/loader.ts
Normal file
45
src/cli/src/config/loader.ts
Normal 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 });
|
||||
}
|
||||
22
src/cli/src/config/schema.ts
Normal file
22
src/cli/src/config/schema.ts
Normal 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({});
|
||||
4
src/cli/src/formatters/index.ts
Normal file
4
src/cli/src/formatters/index.ts
Normal 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';
|
||||
11
src/cli/src/formatters/output.ts
Normal file
11
src/cli/src/formatters/output.ts
Normal 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();
|
||||
}
|
||||
44
src/cli/src/formatters/table.ts
Normal file
44
src/cli/src/formatters/table.ts
Normal 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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
38
src/cli/tests/cli.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
99
src/cli/tests/commands/config.test.ts
Normal file
99
src/cli/tests/commands/config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
94
src/cli/tests/commands/status.test.ts
Normal file
94
src/cli/tests/commands/status.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
83
src/cli/tests/config/loader.test.ts
Normal file
83
src/cli/tests/config/loader.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
52
src/cli/tests/config/schema.test.ts
Normal file
52
src/cli/tests/config/schema.test.ts
Normal 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({}));
|
||||
});
|
||||
});
|
||||
41
src/cli/tests/formatters/output.test.ts
Normal file
41
src/cli/tests/formatters/output.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
87
src/cli/tests/formatters/table.test.ts
Normal file
87
src/cli/tests/formatters/table.test.ts
Normal 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]+$/);
|
||||
});
|
||||
});
|
||||
@@ -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
172
src/db/prisma/schema.prisma
Normal 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])
|
||||
}
|
||||
@@ -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
131
src/db/src/seed/index.ts
Normal 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
58
src/db/tests/helpers.ts
Normal 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
364
src/db/tests/models.test.ts
Normal 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
71
src/db/tests/seed.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -4,5 +4,7 @@ export default defineProject({
|
||||
test: {
|
||||
name: 'db',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
// Test files share the same database — run sequentially
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
2
src/mcpd/src/config/index.ts
Normal file
2
src/mcpd/src/config/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { McpdConfigSchema, loadConfigFromEnv } from './schema.js';
|
||||
export type { McpdConfig } from './schema.js';
|
||||
25
src/mcpd/src/config/schema.ts
Normal file
25
src/mcpd/src/config/schema.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
59
src/mcpd/src/middleware/audit.ts
Normal file
59
src/mcpd/src/middleware/audit.ts
Normal 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 };
|
||||
}
|
||||
40
src/mcpd/src/middleware/auth.ts
Normal file
40
src/mcpd/src/middleware/auth.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
60
src/mcpd/src/middleware/error-handler.ts
Normal file
60
src/mcpd/src/middleware/error-handler.ts
Normal 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);
|
||||
}
|
||||
7
src/mcpd/src/middleware/index.ts
Normal file
7
src/mcpd/src/middleware/index.ts
Normal 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';
|
||||
24
src/mcpd/src/middleware/security.ts
Normal file
24
src/mcpd/src/middleware/security.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
5
src/mcpd/src/repositories/index.ts
Normal file
5
src/mcpd/src/repositories/index.ts
Normal 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';
|
||||
21
src/mcpd/src/repositories/interfaces.ts
Normal file
21
src/mcpd/src/repositories/interfaces.ts
Normal 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>;
|
||||
}
|
||||
46
src/mcpd/src/repositories/mcp-profile.repository.ts
Normal file
46
src/mcpd/src/repositories/mcp-profile.repository.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
49
src/mcpd/src/repositories/mcp-server.repository.ts
Normal file
49
src/mcpd/src/repositories/mcp-server.repository.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
69
src/mcpd/src/repositories/project.repository.ts
Normal file
69
src/mcpd/src/repositories/project.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/mcpd/src/routes/health.ts
Normal file
30
src/mcpd/src/routes/health.ts
Normal 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' });
|
||||
});
|
||||
}
|
||||
5
src/mcpd/src/routes/index.ts
Normal file
5
src/mcpd/src/routes/index.ts
Normal 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';
|
||||
27
src/mcpd/src/routes/mcp-profiles.ts
Normal file
27
src/mcpd/src/routes/mcp-profiles.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
27
src/mcpd/src/routes/mcp-servers.ts
Normal file
27
src/mcpd/src/routes/mcp-servers.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
43
src/mcpd/src/routes/projects.ts
Normal file
43
src/mcpd/src/routes/projects.ts
Normal 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
34
src/mcpd/src/server.ts
Normal 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;
|
||||
}
|
||||
5
src/mcpd/src/services/index.ts
Normal file
5
src/mcpd/src/services/index.ts
Normal 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';
|
||||
59
src/mcpd/src/services/mcp-config-generator.ts
Normal file
59
src/mcpd/src/services/mcp-config-generator.ts
Normal 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 };
|
||||
}
|
||||
62
src/mcpd/src/services/mcp-profile.service.ts
Normal file
62
src/mcpd/src/services/mcp-profile.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
69
src/mcpd/src/services/mcp-server.service.ts
Normal file
69
src/mcpd/src/services/mcp-server.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
86
src/mcpd/src/services/project.service.ts
Normal file
86
src/mcpd/src/services/project.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
2
src/mcpd/src/utils/index.ts
Normal file
2
src/mcpd/src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { setupGracefulShutdown } from './shutdown.js';
|
||||
export type { ShutdownDeps } from './shutdown.js';
|
||||
33
src/mcpd/src/utils/shutdown.ts
Normal file
33
src/mcpd/src/utils/shutdown.ts
Normal 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'); });
|
||||
}
|
||||
6
src/mcpd/src/validation/index.ts
Normal file
6
src/mcpd/src/validation/index.ts
Normal 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';
|
||||
17
src/mcpd/src/validation/mcp-profile.schema.ts
Normal file
17
src/mcpd/src/validation/mcp-profile.schema.ts
Normal 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>;
|
||||
30
src/mcpd/src/validation/mcp-server.schema.ts
Normal file
30
src/mcpd/src/validation/mcp-server.schema.ts
Normal 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>;
|
||||
18
src/mcpd/src/validation/project.schema.ts
Normal file
18
src/mcpd/src/validation/project.schema.ts
Normal 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>;
|
||||
102
src/mcpd/tests/audit.test.ts
Normal file
102
src/mcpd/tests/audit.test.ts
Normal 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
101
src/mcpd/tests/auth.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
81
src/mcpd/tests/config.test.ts
Normal file
81
src/mcpd/tests/config.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
72
src/mcpd/tests/error-handler.test.ts
Normal file
72
src/mcpd/tests/error-handler.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
71
src/mcpd/tests/health.test.ts
Normal file
71
src/mcpd/tests/health.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
109
src/mcpd/tests/mcp-config-generator.test.ts
Normal file
109
src/mcpd/tests/mcp-config-generator.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
128
src/mcpd/tests/mcp-profile-service.test.ts
Normal file
128
src/mcpd/tests/mcp-profile-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
168
src/mcpd/tests/mcp-server-routes.test.ts
Normal file
168
src/mcpd/tests/mcp-server-routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
110
src/mcpd/tests/mcp-server-service.test.ts
Normal file
110
src/mcpd/tests/mcp-server-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
145
src/mcpd/tests/project-service.test.ts
Normal file
145
src/mcpd/tests/project-service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
83
src/mcpd/tests/server.test.ts
Normal file
83
src/mcpd/tests/server.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
124
src/mcpd/tests/validation.test.ts
Normal file
124
src/mcpd/tests/validation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
|
||||
Reference in New Issue
Block a user