Compare commits
8 Commits
feat/datab
...
feat/docke
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1390313a3 | ||
|
|
0ff5c85cf6 | ||
|
|
3fa2bc5ffa | ||
|
|
47f10f62c7 | ||
|
|
247b4967e5 | ||
|
|
dc45f5981b | ||
| f5fae2936a | |||
|
|
386029d052 |
@@ -2,7 +2,7 @@
|
|||||||
"master": {
|
"master": {
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": "1",
|
||||||
"title": "Initialize Project Structure and Core Dependencies",
|
"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.",
|
"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.",
|
"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": [],
|
"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/*\"]`",
|
"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",
|
"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,
|
"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.",
|
"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",
|
"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,
|
"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.",
|
"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",
|
"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,
|
"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.",
|
"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",
|
"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,
|
"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.",
|
"details": "Install dependencies per package in src/cli, src/mcpd, src/shared, src/db, src/local-proxy. Perform security and architecture review.",
|
||||||
"status": "done",
|
"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",
|
"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.",
|
"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).",
|
"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": [
|
"dependencies": [
|
||||||
"1"
|
"1"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -87,7 +92,8 @@
|
|||||||
"dependencies": [],
|
"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.",
|
"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",
|
"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,
|
"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.",
|
"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",
|
"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,
|
"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.",
|
"details": "Implement src/db/prisma/schema.prisma with all models. Add version Int field and updatedAt DateTime for git-based backup support.",
|
||||||
"status": "pending",
|
"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,
|
"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.",
|
"details": "Create src/db/seed directory with server definitions and seeding functions for Slack, Jira, GitHub, Terraform MCP servers.",
|
||||||
"status": "pending",
|
"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,
|
"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.",
|
"details": "Run npx prisma migrate dev --name init. Create src/db/src/migration-helpers.ts. Document security and architecture findings.",
|
||||||
"status": "pending",
|
"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",
|
"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.",
|
"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.",
|
"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",
|
"1",
|
||||||
"2"
|
"2"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -156,7 +167,8 @@
|
|||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"details": "Create src/mcpd/src/ with routes/, controllers/, services/, repositories/, middleware/, config/, types/, utils/ directories.",
|
"details": "Create src/mcpd/src/ with routes/, controllers/, services/, repositories/, middleware/, config/, types/, utils/ directories.",
|
||||||
"status": "pending",
|
"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,
|
"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.",
|
"details": "Create src/mcpd/src/server.ts with Fastify instance factory function. Implement config validation with Zod and health endpoint.",
|
||||||
"status": "pending",
|
"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,
|
"id": 3,
|
||||||
@@ -178,7 +191,8 @@
|
|||||||
],
|
],
|
||||||
"details": "Create src/mcpd/src/middleware/auth.ts with authMiddleware factory function using dependency injection.",
|
"details": "Create src/mcpd/src/middleware/auth.ts with authMiddleware factory function using dependency injection.",
|
||||||
"status": "pending",
|
"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,
|
"id": 4,
|
||||||
@@ -189,7 +203,8 @@
|
|||||||
],
|
],
|
||||||
"details": "Create src/mcpd/src/middleware/security.ts with registerSecurityPlugins function. Create sanitization and validation utilities.",
|
"details": "Create src/mcpd/src/middleware/security.ts with registerSecurityPlugins function. Create sanitization and validation utilities.",
|
||||||
"status": "pending",
|
"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,
|
"id": 5,
|
||||||
@@ -202,12 +217,14 @@
|
|||||||
],
|
],
|
||||||
"details": "Create error-handler.ts, audit.ts middleware, and shutdown.ts utilities in src/mcpd/src/.",
|
"details": "Create error-handler.ts, audit.ts middleware, and shutdown.ts utilities in src/mcpd/src/.",
|
||||||
"status": "pending",
|
"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",
|
"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.",
|
"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.",
|
"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": [
|
"dependencies": [
|
||||||
"3"
|
"3"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -225,7 +242,8 @@
|
|||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"details": "Create src/mcpd/src/validation/mcp-server.schema.ts with CreateMcpServerSchema, UpdateMcpServerSchema, CreateMcpProfileSchema.",
|
"details": "Create src/mcpd/src/validation/mcp-server.schema.ts with CreateMcpServerSchema, UpdateMcpServerSchema, CreateMcpProfileSchema.",
|
||||||
"status": "pending",
|
"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,
|
"id": 2,
|
||||||
@@ -236,7 +254,8 @@
|
|||||||
],
|
],
|
||||||
"details": "Create src/mcpd/src/repositories/interfaces.ts with IMcpServerRepository and IMcpProfileRepository interfaces.",
|
"details": "Create src/mcpd/src/repositories/interfaces.ts with IMcpServerRepository and IMcpProfileRepository interfaces.",
|
||||||
"status": "pending",
|
"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,
|
"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.",
|
"details": "Create src/mcpd/src/services/mcp-server.service.ts and mcp-profile.service.ts with DI and authorization checks.",
|
||||||
"status": "pending",
|
"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,
|
"id": 4,
|
||||||
@@ -259,7 +279,8 @@
|
|||||||
],
|
],
|
||||||
"details": "Create src/mcpd/src/routes/mcp-servers.ts and mcp-profiles.ts with all CRUD endpoints.",
|
"details": "Create src/mcpd/src/routes/mcp-servers.ts and mcp-profiles.ts with all CRUD endpoints.",
|
||||||
"status": "pending",
|
"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,
|
"id": 5,
|
||||||
@@ -270,12 +291,14 @@
|
|||||||
],
|
],
|
||||||
"details": "Create src/mcpd/src/seed/mcp-servers.seed.ts with seedMcpServers() function and SECURITY_REVIEW.md.",
|
"details": "Create src/mcpd/src/seed/mcp-servers.seed.ts with seedMcpServers() function and SECURITY_REVIEW.md.",
|
||||||
"status": "pending",
|
"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",
|
"title": "Implement Project Management APIs",
|
||||||
"description": "Create APIs for managing MCP projects that group multiple MCP profiles together for easy assignment to Claude sessions.",
|
"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.",
|
"details": "Create project management endpoints with generateMcpConfig function for .mcp.json format output.",
|
||||||
@@ -293,7 +316,8 @@
|
|||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"details": "Create tests for CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema, and generateMcpConfig with security tests.",
|
"details": "Create tests for CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema, and generateMcpConfig with security tests.",
|
||||||
"status": "pending",
|
"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,
|
"id": 2,
|
||||||
@@ -304,7 +328,8 @@
|
|||||||
],
|
],
|
||||||
"details": "Create src/mcpd/src/repositories/project.repository.ts and src/mcpd/src/services/mcp-config-generator.ts.",
|
"details": "Create src/mcpd/src/repositories/project.repository.ts and src/mcpd/src/services/mcp-config-generator.ts.",
|
||||||
"status": "pending",
|
"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,
|
"id": 3,
|
||||||
@@ -315,7 +340,8 @@
|
|||||||
],
|
],
|
||||||
"details": "Create src/mcpd/src/services/project.service.ts with DI accepting IProjectRepository and IMcpProfileRepository.",
|
"details": "Create src/mcpd/src/services/project.service.ts with DI accepting IProjectRepository and IMcpProfileRepository.",
|
||||||
"status": "pending",
|
"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,
|
"id": 4,
|
||||||
@@ -326,7 +352,8 @@
|
|||||||
],
|
],
|
||||||
"details": "Create src/mcpd/src/routes/projects.ts with all CRUD routes and mcp-config endpoint.",
|
"details": "Create src/mcpd/src/routes/projects.ts with all CRUD routes and mcp-config endpoint.",
|
||||||
"status": "pending",
|
"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,
|
"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.",
|
"details": "Create src/mcpd/tests/integration/projects.test.ts with end-to-end scenarios and SECURITY_REVIEW.md section.",
|
||||||
"status": "pending",
|
"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",
|
"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.",
|
"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.",
|
"details": "Create Docker management module with ContainerManager class using dockerode. Create deploy/docker-compose.yml template.",
|
||||||
@@ -361,7 +389,8 @@
|
|||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"details": "Create src/mcpd/src/services/orchestrator.ts interface and TDD tests for ContainerManager methods.",
|
"details": "Create src/mcpd/src/services/orchestrator.ts interface and TDD tests for ContainerManager methods.",
|
||||||
"status": "pending",
|
"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,
|
"id": 2,
|
||||||
@@ -372,7 +401,8 @@
|
|||||||
],
|
],
|
||||||
"details": "Create src/mcpd/src/services/docker/container-manager.ts implementing McpOrchestrator interface.",
|
"details": "Create src/mcpd/src/services/docker/container-manager.ts implementing McpOrchestrator interface.",
|
||||||
"status": "pending",
|
"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,
|
"id": 3,
|
||||||
@@ -381,7 +411,8 @@
|
|||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"details": "Create deploy/docker-compose.yml with mcpd, postgres, and test-mcp-server services with proper networking.",
|
"details": "Create deploy/docker-compose.yml with mcpd, postgres, and test-mcp-server services with proper networking.",
|
||||||
"status": "pending",
|
"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,
|
"id": 4,
|
||||||
@@ -393,7 +424,8 @@
|
|||||||
],
|
],
|
||||||
"details": "Create src/mcpd/src/services/docker/__tests__/container-manager.integration.test.ts.",
|
"details": "Create src/mcpd/src/services/docker/__tests__/container-manager.integration.test.ts.",
|
||||||
"status": "pending",
|
"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,
|
"id": 5,
|
||||||
@@ -404,7 +436,8 @@
|
|||||||
],
|
],
|
||||||
"details": "Create src/mcpd/src/services/docker/network-manager.ts with network isolation and resource management.",
|
"details": "Create src/mcpd/src/services/docker/network-manager.ts with network isolation and resource management.",
|
||||||
"status": "pending",
|
"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,
|
"id": 6,
|
||||||
@@ -417,7 +450,8 @@
|
|||||||
],
|
],
|
||||||
"details": "Create src/mcpd/docs/DOCKER_SECURITY_REVIEW.md documenting risks and mitigations.",
|
"details": "Create src/mcpd/docs/DOCKER_SECURITY_REVIEW.md documenting risks and mitigations.",
|
||||||
"status": "pending",
|
"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,
|
"id": 7,
|
||||||
@@ -428,12 +462,13 @@
|
|||||||
],
|
],
|
||||||
"details": "Extend ContainerManager with getLogs, getHealthStatus, attachToContainer, and event subscriptions.",
|
"details": "Extend ContainerManager with getLogs, getHealthStatus, attachToContainer, and event subscriptions.",
|
||||||
"status": "pending",
|
"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",
|
"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.",
|
"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.",
|
"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": [
|
"dependencies": [
|
||||||
"1"
|
"1"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -451,7 +486,8 @@
|
|||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"details": "Create src/cli/src/ with commands/, config/, client/, formatters/, utils/, types/ directories and registry pattern.",
|
"details": "Create src/cli/src/ with commands/, config/, client/, formatters/, utils/, types/ directories and registry pattern.",
|
||||||
"status": "pending",
|
"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,
|
"id": 2,
|
||||||
@@ -462,12 +498,14 @@
|
|||||||
],
|
],
|
||||||
"details": "Implement config management with proxy settings, custom CA certificates support, and Zod validation.",
|
"details": "Implement config management with proxy settings, custom CA certificates support, and Zod validation.",
|
||||||
"status": "pending",
|
"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",
|
"title": "Implement mcpctl get and describe Commands",
|
||||||
"description": "Create kubectl-style get and describe commands for viewing MCP servers, profiles, projects, and instances.",
|
"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.",
|
"details": "Implement get command with table/json/yaml output formats and describe command for detailed views.",
|
||||||
@@ -477,10 +515,10 @@
|
|||||||
"7"
|
"7"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 9,
|
"id": "9",
|
||||||
"title": "Implement mcpctl apply and setup Commands",
|
"title": "Implement mcpctl apply and setup Commands",
|
||||||
"description": "Create apply command for declarative configuration and setup wizard for interactive MCP server configuration.",
|
"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.",
|
"details": "Implement apply command for YAML/JSON config files and interactive setup wizard with credential prompts.",
|
||||||
@@ -491,10 +529,10 @@
|
|||||||
"4"
|
"4"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 10,
|
"id": "10",
|
||||||
"title": "Implement mcpctl claude and project Commands",
|
"title": "Implement mcpctl claude and project Commands",
|
||||||
"description": "Create commands for managing Claude MCP configuration and project assignments.",
|
"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.",
|
"details": "Implement claude command for managing .mcp.json files and project command for project management.",
|
||||||
@@ -505,10 +543,10 @@
|
|||||||
"5"
|
"5"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 11,
|
"id": "11",
|
||||||
"title": "Design Local LLM Proxy Architecture",
|
"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.",
|
"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.",
|
"details": "Design proxy architecture in src/local-proxy/ with MCP protocol handling and request/response transformation.",
|
||||||
@@ -518,10 +556,10 @@
|
|||||||
"1"
|
"1"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 12,
|
"id": "12",
|
||||||
"title": "Implement Local LLM Proxy Core",
|
"title": "Implement Local LLM Proxy Core",
|
||||||
"description": "Build the core local proxy server that handles MCP protocol communication between Claude and MCP servers.",
|
"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.",
|
"details": "Implement proxy server in src/local-proxy/src/ with MCP SDK integration and request routing.",
|
||||||
@@ -531,10 +569,10 @@
|
|||||||
"11"
|
"11"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 13,
|
"id": "13",
|
||||||
"title": "Implement LLM Provider Strategy Pattern",
|
"title": "Implement LLM Provider Strategy Pattern",
|
||||||
"description": "Create pluggable LLM provider support with strategy pattern for different providers (OpenAI, Anthropic, local models).",
|
"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.",
|
"details": "Implement provider strategy pattern in src/local-proxy/src/providers/ with adapters for different LLM APIs.",
|
||||||
@@ -544,10 +582,10 @@
|
|||||||
"12"
|
"12"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 14,
|
"id": "14",
|
||||||
"title": "Implement Audit Logging and Compliance",
|
"title": "Implement Audit Logging and Compliance",
|
||||||
"description": "Create comprehensive audit logging system for tracking all MCP operations for compliance and debugging.",
|
"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.",
|
"details": "Implement audit logging in src/mcpd/src/services/ with structured logging, retention policies, and query APIs.",
|
||||||
@@ -557,10 +595,10 @@
|
|||||||
"3"
|
"3"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 15,
|
"id": "15",
|
||||||
"title": "Create MCP Profiles Library",
|
"title": "Create MCP Profiles Library",
|
||||||
"description": "Build a library of pre-configured MCP profiles for common use cases with best practices baked in.",
|
"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.",
|
"details": "Create profile library in src/shared/src/profiles/ with templates for common MCP server configurations.",
|
||||||
@@ -570,10 +608,10 @@
|
|||||||
"4"
|
"4"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 16,
|
"id": "16",
|
||||||
"title": "Implement MCP Instance Lifecycle Management",
|
"title": "Implement MCP Instance Lifecycle Management",
|
||||||
"description": "Create APIs and CLI commands for managing the full lifecycle of MCP server instances.",
|
"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.",
|
"details": "Implement instance lifecycle management in src/mcpd/src/services/ with start, stop, restart, logs commands.",
|
||||||
@@ -583,10 +621,10 @@
|
|||||||
"6"
|
"6"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 17,
|
"id": "17",
|
||||||
"title": "Add Kubernetes Deployment Support",
|
"title": "Add Kubernetes Deployment Support",
|
||||||
"description": "Extend the orchestration layer to support Kubernetes deployments for production environments.",
|
"description": "Extend the orchestration layer to support Kubernetes deployments for production environments.",
|
||||||
"details": "Implement KubernetesOrchestrator in src/mcpd/src/services/k8s/ implementing McpOrchestrator interface.",
|
"details": "Implement KubernetesOrchestrator in src/mcpd/src/services/k8s/ implementing McpOrchestrator interface.",
|
||||||
@@ -596,10 +634,10 @@
|
|||||||
"6"
|
"6"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 18,
|
"id": "18",
|
||||||
"title": "Documentation and Testing",
|
"title": "Documentation and Testing",
|
||||||
"description": "Create comprehensive documentation and end-to-end test suite for the entire mcpctl system.",
|
"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.",
|
"details": "Create documentation in docs/ and e2e tests in tests/e2e/ covering all major workflows.",
|
||||||
@@ -612,10 +650,10 @@
|
|||||||
"10"
|
"10"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 19,
|
"id": "19",
|
||||||
"title": "CANCELLED - Auth middleware",
|
"title": "CANCELLED - Auth middleware",
|
||||||
"description": "Merged into Task 3 subtasks",
|
"description": "Merged into Task 3 subtasks",
|
||||||
"details": null,
|
"details": null,
|
||||||
@@ -623,11 +661,11 @@
|
|||||||
"priority": null,
|
"priority": null,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"status": "cancelled",
|
"status": "cancelled",
|
||||||
"subtasks": null,
|
"subtasks": [],
|
||||||
"updatedAt": "2026-02-21T02:21:03.958Z"
|
"updatedAt": "2026-02-21T02:21:03.958Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 20,
|
"id": "20",
|
||||||
"title": "CANCELLED - Duplicate project management",
|
"title": "CANCELLED - Duplicate project management",
|
||||||
"description": "Merged into Task 5",
|
"description": "Merged into Task 5",
|
||||||
"details": null,
|
"details": null,
|
||||||
@@ -635,11 +673,11 @@
|
|||||||
"priority": null,
|
"priority": null,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"status": "cancelled",
|
"status": "cancelled",
|
||||||
"subtasks": null,
|
"subtasks": [],
|
||||||
"updatedAt": "2026-02-21T02:21:03.966Z"
|
"updatedAt": "2026-02-21T02:21:03.966Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 21,
|
"id": "21",
|
||||||
"title": "CANCELLED - Duplicate audit logging",
|
"title": "CANCELLED - Duplicate audit logging",
|
||||||
"description": "Merged into Task 14",
|
"description": "Merged into Task 14",
|
||||||
"details": null,
|
"details": null,
|
||||||
@@ -647,11 +685,11 @@
|
|||||||
"priority": null,
|
"priority": null,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"status": "cancelled",
|
"status": "cancelled",
|
||||||
"subtasks": null,
|
"subtasks": [],
|
||||||
"updatedAt": "2026-02-21T02:21:03.972Z"
|
"updatedAt": "2026-02-21T02:21:03.972Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 22,
|
"id": "22",
|
||||||
"title": "Implement Health Monitoring Dashboard",
|
"title": "Implement Health Monitoring Dashboard",
|
||||||
"description": "Create a monitoring dashboard for tracking MCP server health, resource usage, and system metrics.",
|
"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.",
|
"details": "Implement health monitoring endpoints in src/mcpd/src/routes/ and optional web dashboard.",
|
||||||
@@ -662,10 +700,10 @@
|
|||||||
"14"
|
"14"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 23,
|
"id": "23",
|
||||||
"title": "Implement Backup and Restore",
|
"title": "Implement Backup and Restore",
|
||||||
"description": "Create backup and restore functionality for mcpctl configuration and state.",
|
"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.",
|
"details": "Implement git-based backup in src/mcpd/src/services/backup/ with encrypted secrets and restore capability.",
|
||||||
@@ -676,10 +714,10 @@
|
|||||||
"5"
|
"5"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 24,
|
"id": "24",
|
||||||
"title": "CI/CD Pipeline Setup",
|
"title": "CI/CD Pipeline Setup",
|
||||||
"description": "Set up continuous integration and deployment pipelines for the mcpctl project.",
|
"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.",
|
"details": "Create GitHub Actions workflows in .github/workflows/ for testing, building, and releasing.",
|
||||||
@@ -689,13 +727,17 @@
|
|||||||
"1"
|
"1"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": null
|
"subtasks": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"created": "2026-02-21T02:23:17.813Z",
|
"version": "1.0.0",
|
||||||
"updated": "2026-02-21T02:23:17.813Z",
|
"lastModified": "2026-02-21T04:26:06.239Z",
|
||||||
"description": "Tasks for master context"
|
"taskCount": 24,
|
||||||
|
"completedCount": 5,
|
||||||
|
"tags": [
|
||||||
|
"master"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,35 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- mcpctl
|
||||||
|
|
||||||
|
mcpd:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile.mcpd
|
||||||
|
container_name: mcpctl-mcpd
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://mcpctl:mcpctl_dev@postgres:5432/mcpctl
|
||||||
|
PORT: "3100"
|
||||||
|
HOST: "0.0.0.0"
|
||||||
|
LOG_LEVEL: info
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
networks:
|
||||||
|
- mcpctl
|
||||||
|
- mcp-servers
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q --spider http://localhost:3100/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
postgres-test:
|
postgres-test:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -32,6 +61,15 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
profiles:
|
||||||
|
- test
|
||||||
|
|
||||||
|
networks:
|
||||||
|
mcpctl:
|
||||||
|
driver: bridge
|
||||||
|
mcp-servers:
|
||||||
|
driver: bridge
|
||||||
|
internal: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mcpctl-pgdata:
|
mcpctl-pgdata:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.0",
|
"packageManager": "pnpm@9.15.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
||||||
"@typescript-eslint/parser": "^8.56.0",
|
"@typescript-eslint/parser": "^8.56.0",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
|||||||
623
pnpm-lock.yaml
generated
623
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -16,11 +16,16 @@
|
|||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^13.0.0",
|
"@mcpctl/db": "workspace:*",
|
||||||
|
"@mcpctl/shared": "workspace:*",
|
||||||
"chalk": "^5.4.0",
|
"chalk": "^5.4.0",
|
||||||
|
"commander": "^13.0.0",
|
||||||
"inquirer": "^12.0.0",
|
"inquirer": "^12.0.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"@mcpctl/shared": "workspace:*",
|
"zod": "^3.24.0"
|
||||||
"@mcpctl/db": "workspace:*"
|
},
|
||||||
|
"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
|
#!/usr/bin/env node
|
||||||
// Will be implemented in Task 7
|
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);
|
||||||
|
}
|
||||||
|
|||||||
105
src/cli/src/registry/client.ts
Normal file
105
src/cli/src/registry/client.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { RegistryServer, SearchOptions, RegistryClientConfig, RegistryName } from './types.js';
|
||||||
|
import { RegistrySource } from './base.js';
|
||||||
|
import { OfficialRegistrySource } from './sources/official.js';
|
||||||
|
import { GlamaRegistrySource } from './sources/glama.js';
|
||||||
|
import { SmitheryRegistrySource } from './sources/smithery.js';
|
||||||
|
import { RegistryCache } from './cache.js';
|
||||||
|
import { deduplicateResults } from './dedup.js';
|
||||||
|
import { rankResults } from './ranking.js';
|
||||||
|
|
||||||
|
export class RegistryClient {
|
||||||
|
private sources: Map<RegistryName, RegistrySource>;
|
||||||
|
private cache: RegistryCache;
|
||||||
|
private enabledRegistries: RegistryName[];
|
||||||
|
private metrics = {
|
||||||
|
queryLatencies: new Map<string, number[]>(),
|
||||||
|
errorCounts: new Map<string, number>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(config: RegistryClientConfig = {}) {
|
||||||
|
this.enabledRegistries = config.registries ?? ['official', 'glama', 'smithery'];
|
||||||
|
this.cache = new RegistryCache(config.cacheTTLMs);
|
||||||
|
|
||||||
|
this.sources = new Map<RegistryName, RegistrySource>([
|
||||||
|
['official', new OfficialRegistrySource()],
|
||||||
|
['glama', new GlamaRegistrySource()],
|
||||||
|
['smithery', new SmitheryRegistrySource()],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(options: SearchOptions): Promise<RegistryServer[]> {
|
||||||
|
// Check cache
|
||||||
|
const cached = this.cache.get(options.query, options);
|
||||||
|
if (cached !== null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registries = options.registries ?? this.enabledRegistries;
|
||||||
|
const limit = options.limit ?? 20;
|
||||||
|
|
||||||
|
// Query all enabled registries in parallel
|
||||||
|
const promises = registries
|
||||||
|
.map((name) => this.sources.get(name))
|
||||||
|
.filter((source): source is RegistrySource => source !== undefined)
|
||||||
|
.map(async (source) => {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const results = await source.search(options.query, limit);
|
||||||
|
this.recordLatency(source.name, Date.now() - start);
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
this.recordError(source.name);
|
||||||
|
// Graceful degradation: log and continue
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const settled = await Promise.all(promises);
|
||||||
|
let combined = settled.flat();
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if (options.verified === true) {
|
||||||
|
combined = combined.filter((s) => s.verified);
|
||||||
|
}
|
||||||
|
if (options.transport !== undefined) {
|
||||||
|
combined = combined.filter((s) => s.transport === options.transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate, rank, and limit
|
||||||
|
const deduped = deduplicateResults(combined);
|
||||||
|
const ranked = rankResults(deduped, options.query);
|
||||||
|
const results = ranked.slice(0, limit);
|
||||||
|
|
||||||
|
// Cache results
|
||||||
|
this.cache.set(options.query, options, results);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheMetrics(): { hits: number; misses: number; ratio: number } {
|
||||||
|
return this.cache.getHitRatio();
|
||||||
|
}
|
||||||
|
|
||||||
|
getQueryLatencies(): Map<string, number[]> {
|
||||||
|
return new Map(this.metrics.queryLatencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
getErrorCounts(): Map<string, number> {
|
||||||
|
return new Map(this.metrics.errorCounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordLatency(source: string, ms: number): void {
|
||||||
|
const existing = this.metrics.queryLatencies.get(source) ?? [];
|
||||||
|
existing.push(ms);
|
||||||
|
this.metrics.queryLatencies.set(source, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordError(source: string): void {
|
||||||
|
const count = this.metrics.errorCounts.get(source) ?? 0;
|
||||||
|
this.metrics.errorCounts.set(source, count + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/cli/src/registry/index.ts
Normal file
17
src/cli/src/registry/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export { RegistryClient } from './client.js';
|
||||||
|
export { RegistryCache } from './cache.js';
|
||||||
|
export { RegistrySource } from './base.js';
|
||||||
|
export { deduplicateResults } from './dedup.js';
|
||||||
|
export { rankResults } from './ranking.js';
|
||||||
|
export { withRetry } from './retry.js';
|
||||||
|
export { OfficialRegistrySource } from './sources/official.js';
|
||||||
|
export { GlamaRegistrySource } from './sources/glama.js';
|
||||||
|
export { SmitheryRegistrySource } from './sources/smithery.js';
|
||||||
|
export type {
|
||||||
|
RegistryServer,
|
||||||
|
SearchOptions,
|
||||||
|
RegistryClientConfig,
|
||||||
|
RegistryName,
|
||||||
|
EnvVar,
|
||||||
|
} from './types.js';
|
||||||
|
export { sanitizeString } from './types.js';
|
||||||
63
src/cli/src/registry/ranking.ts
Normal file
63
src/cli/src/registry/ranking.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { RegistryServer } from './types.js';
|
||||||
|
|
||||||
|
const WEIGHT_RELEVANCE = 0.4;
|
||||||
|
const WEIGHT_POPULARITY = 0.3;
|
||||||
|
const WEIGHT_VERIFIED = 0.2;
|
||||||
|
const WEIGHT_RECENCY = 0.1;
|
||||||
|
|
||||||
|
function textRelevance(server: RegistryServer, query: string): number {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const name = server.name.toLowerCase();
|
||||||
|
const desc = server.description.toLowerCase();
|
||||||
|
|
||||||
|
// Exact name match
|
||||||
|
if (name === q) return 1.0;
|
||||||
|
// Name starts with query
|
||||||
|
if (name.startsWith(q)) return 0.9;
|
||||||
|
// Name contains query
|
||||||
|
if (name.includes(q)) return 0.7;
|
||||||
|
// Description contains query
|
||||||
|
if (desc.includes(q)) return 0.4;
|
||||||
|
|
||||||
|
// Word-level matching
|
||||||
|
const queryWords = q.split(/\s+/);
|
||||||
|
const matchCount = queryWords.filter(
|
||||||
|
(w) => name.includes(w) || desc.includes(w),
|
||||||
|
).length;
|
||||||
|
return queryWords.length > 0 ? (matchCount / queryWords.length) * 0.3 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function popularityScore(server: RegistryServer): number {
|
||||||
|
// Normalize to 0-1 range; use log scale since popularity can vary hugely
|
||||||
|
if (server.popularityScore <= 0) return 0;
|
||||||
|
// Log scale: log10(1) = 0, log10(10000) ≈ 4 → normalize to 0-1 with cap at 100k
|
||||||
|
return Math.min(Math.log10(server.popularityScore + 1) / 5, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifiedScore(server: RegistryServer): number {
|
||||||
|
return server.verified ? 1.0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recencyScore(server: RegistryServer): number {
|
||||||
|
if (server.lastUpdated === undefined) return 0.5; // Unknown = middle score
|
||||||
|
const ageMs = Date.now() - server.lastUpdated.getTime();
|
||||||
|
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
||||||
|
// Less than 30 days = 1.0, decays to 0 at 365 days
|
||||||
|
return Math.max(0, 1 - ageDays / 365);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeScore(server: RegistryServer, query: string): number {
|
||||||
|
return (
|
||||||
|
WEIGHT_RELEVANCE * textRelevance(server, query) +
|
||||||
|
WEIGHT_POPULARITY * popularityScore(server) +
|
||||||
|
WEIGHT_VERIFIED * verifiedScore(server) +
|
||||||
|
WEIGHT_RECENCY * recencyScore(server)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rankResults(
|
||||||
|
results: RegistryServer[],
|
||||||
|
query: string,
|
||||||
|
): RegistryServer[] {
|
||||||
|
return [...results].sort((a, b) => computeScore(b, query) - computeScore(a, query));
|
||||||
|
}
|
||||||
16
src/cli/src/registry/retry.ts
Normal file
16
src/cli/src/registry/retry.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export async function withRetry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
maxRetries = 3,
|
||||||
|
baseDelay = 1000,
|
||||||
|
): Promise<T> {
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === maxRetries - 1) throw error;
|
||||||
|
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Unreachable');
|
||||||
|
}
|
||||||
92
src/cli/src/registry/sources/glama.ts
Normal file
92
src/cli/src/registry/sources/glama.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { RegistrySource } from '../base.js';
|
||||||
|
import {
|
||||||
|
GlamaRegistryResponseSchema,
|
||||||
|
sanitizeString,
|
||||||
|
type GlamaServerEntry,
|
||||||
|
type RegistryServer,
|
||||||
|
} from '../types.js';
|
||||||
|
import { withRetry } from '../retry.js';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://glama.ai/api/mcp/v1/servers';
|
||||||
|
|
||||||
|
export class GlamaRegistrySource extends RegistrySource {
|
||||||
|
readonly name = 'glama' as const;
|
||||||
|
|
||||||
|
async search(query: string, limit: number): Promise<RegistryServer[]> {
|
||||||
|
const results: RegistryServer[] = [];
|
||||||
|
let cursor: string | null | undefined;
|
||||||
|
|
||||||
|
while (results.length < limit) {
|
||||||
|
const url = new URL(BASE_URL);
|
||||||
|
url.searchParams.set('query', query);
|
||||||
|
if (cursor !== undefined && cursor !== null) {
|
||||||
|
url.searchParams.set('after', cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await withRetry(() => fetch(url.toString()));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Glama registry returned ${String(response.status)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw: unknown = await response.json();
|
||||||
|
const parsed = GlamaRegistryResponseSchema.parse(raw);
|
||||||
|
|
||||||
|
for (const entry of parsed.servers) {
|
||||||
|
results.push(this.normalizeResult(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.pageInfo.hasNextPage || parsed.servers.length === 0) break;
|
||||||
|
cursor = parsed.pageInfo.endCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected normalizeResult(raw: unknown): RegistryServer {
|
||||||
|
const entry = raw as GlamaServerEntry;
|
||||||
|
|
||||||
|
// Extract env vars from JSON Schema
|
||||||
|
const props = entry.environmentVariablesJsonSchema?.properties ?? {};
|
||||||
|
const envTemplate = Object.entries(props).map(([name, schemaProp]) => {
|
||||||
|
const envVar: import('../types.js').EnvVar = {
|
||||||
|
name,
|
||||||
|
description: sanitizeString(schemaProp.description ?? ''),
|
||||||
|
isSecret: name.toLowerCase().includes('token') ||
|
||||||
|
name.toLowerCase().includes('secret') ||
|
||||||
|
name.toLowerCase().includes('password') ||
|
||||||
|
name.toLowerCase().includes('key'),
|
||||||
|
};
|
||||||
|
if (schemaProp.default !== undefined) {
|
||||||
|
envVar.defaultValue = schemaProp.default;
|
||||||
|
}
|
||||||
|
return envVar;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine transport from attributes
|
||||||
|
const attrs = entry.attributes;
|
||||||
|
let transport: RegistryServer['transport'] = 'stdio';
|
||||||
|
if (attrs.includes('hosting:remote-capable') || attrs.includes('hosting:hybrid')) {
|
||||||
|
transport = 'sse';
|
||||||
|
}
|
||||||
|
|
||||||
|
const packages: RegistryServer['packages'] = {};
|
||||||
|
if (entry.slug !== '') {
|
||||||
|
packages.npm = entry.slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: RegistryServer = {
|
||||||
|
name: sanitizeString(entry.name),
|
||||||
|
description: sanitizeString(entry.description),
|
||||||
|
packages,
|
||||||
|
envTemplate,
|
||||||
|
transport,
|
||||||
|
popularityScore: 0, // Glama has no popularity metrics in list
|
||||||
|
verified: attrs.includes('author:official'),
|
||||||
|
sourceRegistry: 'glama',
|
||||||
|
};
|
||||||
|
if (entry.repository?.url !== undefined) {
|
||||||
|
result.repositoryUrl = entry.repository.url;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/cli/src/registry/sources/official.ts
Normal file
106
src/cli/src/registry/sources/official.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { RegistrySource } from '../base.js';
|
||||||
|
import {
|
||||||
|
OfficialRegistryResponseSchema,
|
||||||
|
sanitizeString,
|
||||||
|
type OfficialServerEntry,
|
||||||
|
type RegistryServer,
|
||||||
|
} from '../types.js';
|
||||||
|
import { withRetry } from '../retry.js';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://registry.modelcontextprotocol.io/v0/servers';
|
||||||
|
|
||||||
|
export class OfficialRegistrySource extends RegistrySource {
|
||||||
|
readonly name = 'official' as const;
|
||||||
|
|
||||||
|
async search(query: string, limit: number): Promise<RegistryServer[]> {
|
||||||
|
const results: RegistryServer[] = [];
|
||||||
|
let cursor: string | null | undefined;
|
||||||
|
|
||||||
|
while (results.length < limit) {
|
||||||
|
const url = new URL(BASE_URL);
|
||||||
|
url.searchParams.set('search', query);
|
||||||
|
url.searchParams.set('limit', String(Math.min(limit - results.length, 100)));
|
||||||
|
if (cursor !== undefined && cursor !== null) {
|
||||||
|
url.searchParams.set('cursor', cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await withRetry(() => fetch(url.toString()));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Official registry returned ${String(response.status)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw: unknown = await response.json();
|
||||||
|
const parsed = OfficialRegistryResponseSchema.parse(raw);
|
||||||
|
|
||||||
|
for (const entry of parsed.servers) {
|
||||||
|
results.push(this.normalizeResult(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = parsed.metadata?.nextCursor;
|
||||||
|
if (cursor === null || cursor === undefined || parsed.servers.length === 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected normalizeResult(raw: unknown): RegistryServer {
|
||||||
|
const entry = raw as OfficialServerEntry;
|
||||||
|
const server = entry.server;
|
||||||
|
|
||||||
|
// Extract env vars from packages
|
||||||
|
const envTemplate = server.packages.flatMap((pkg: { environmentVariables: Array<{ name: string; description?: string; isSecret?: boolean }> }) =>
|
||||||
|
pkg.environmentVariables.map((ev: { name: string; description?: string; isSecret?: boolean }) => ({
|
||||||
|
name: ev.name,
|
||||||
|
description: sanitizeString(ev.description ?? ''),
|
||||||
|
isSecret: ev.isSecret ?? false,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine transport from packages or remotes
|
||||||
|
let transport: RegistryServer['transport'] = 'stdio';
|
||||||
|
if (server.packages.length > 0) {
|
||||||
|
const pkgTransport = server.packages[0]?.transport?.type;
|
||||||
|
if (pkgTransport === 'stdio') transport = 'stdio';
|
||||||
|
}
|
||||||
|
if (server.remotes.length > 0) {
|
||||||
|
const remoteType = server.remotes[0]?.type;
|
||||||
|
if (remoteType === 'sse') transport = 'sse';
|
||||||
|
else if (remoteType === 'streamable-http') transport = 'streamable-http';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract npm package identifier
|
||||||
|
const npmPkg = server.packages.find((p: { registryType: string }) => p.registryType === 'npm');
|
||||||
|
const dockerPkg = server.packages.find((p: { registryType: string }) => p.registryType === 'oci');
|
||||||
|
|
||||||
|
// Extract dates from _meta
|
||||||
|
const meta = entry._meta as Record<string, Record<string, unknown>> | undefined;
|
||||||
|
const officialMeta = meta?.['io.modelcontextprotocol.registry/official'];
|
||||||
|
const updatedAt = officialMeta?.['updatedAt'];
|
||||||
|
|
||||||
|
const packages: RegistryServer['packages'] = {};
|
||||||
|
if (npmPkg !== undefined) {
|
||||||
|
packages.npm = npmPkg.identifier;
|
||||||
|
}
|
||||||
|
if (dockerPkg !== undefined) {
|
||||||
|
packages.docker = dockerPkg.identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: RegistryServer = {
|
||||||
|
name: sanitizeString(server.title ?? server.name),
|
||||||
|
description: sanitizeString(server.description),
|
||||||
|
packages,
|
||||||
|
envTemplate,
|
||||||
|
transport,
|
||||||
|
popularityScore: 0, // Official registry has no popularity data
|
||||||
|
verified: false, // Official registry has no verified badges
|
||||||
|
sourceRegistry: 'official',
|
||||||
|
};
|
||||||
|
if (server.repository?.url !== undefined) {
|
||||||
|
result.repositoryUrl = server.repository.url;
|
||||||
|
}
|
||||||
|
if (typeof updatedAt === 'string') {
|
||||||
|
result.lastUpdated = new Date(updatedAt);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/cli/src/registry/sources/smithery.ts
Normal file
62
src/cli/src/registry/sources/smithery.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { RegistrySource } from '../base.js';
|
||||||
|
import {
|
||||||
|
SmitheryRegistryResponseSchema,
|
||||||
|
sanitizeString,
|
||||||
|
type SmitheryServerEntry,
|
||||||
|
type RegistryServer,
|
||||||
|
} from '../types.js';
|
||||||
|
import { withRetry } from '../retry.js';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://registry.smithery.ai/servers';
|
||||||
|
|
||||||
|
export class SmitheryRegistrySource extends RegistrySource {
|
||||||
|
readonly name = 'smithery' as const;
|
||||||
|
|
||||||
|
async search(query: string, limit: number): Promise<RegistryServer[]> {
|
||||||
|
const results: RegistryServer[] = [];
|
||||||
|
let page = 1;
|
||||||
|
|
||||||
|
while (results.length < limit) {
|
||||||
|
const url = new URL(BASE_URL);
|
||||||
|
url.searchParams.set('q', query);
|
||||||
|
url.searchParams.set('pageSize', String(Math.min(limit - results.length, 50)));
|
||||||
|
url.searchParams.set('page', String(page));
|
||||||
|
|
||||||
|
const response = await withRetry(() => fetch(url.toString()));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Smithery registry returned ${String(response.status)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw: unknown = await response.json();
|
||||||
|
const parsed = SmitheryRegistryResponseSchema.parse(raw);
|
||||||
|
|
||||||
|
for (const entry of parsed.servers) {
|
||||||
|
results.push(this.normalizeResult(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page >= parsed.pagination.totalPages || parsed.servers.length === 0) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected normalizeResult(raw: unknown): RegistryServer {
|
||||||
|
const entry = raw as SmitheryServerEntry;
|
||||||
|
|
||||||
|
const result: RegistryServer = {
|
||||||
|
name: sanitizeString(entry.displayName !== '' ? entry.displayName : entry.qualifiedName),
|
||||||
|
description: sanitizeString(entry.description),
|
||||||
|
packages: {},
|
||||||
|
envTemplate: [], // Smithery doesn't include env vars in list view
|
||||||
|
transport: entry.remote ? 'sse' : 'stdio',
|
||||||
|
popularityScore: entry.useCount,
|
||||||
|
verified: entry.verified,
|
||||||
|
sourceRegistry: 'smithery',
|
||||||
|
};
|
||||||
|
if (entry.createdAt !== undefined) {
|
||||||
|
result.lastUpdated = new Date(entry.createdAt);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,7 +173,7 @@ export type SmitheryServerEntry = z.infer<typeof SmitheryServerSchema>;
|
|||||||
|
|
||||||
// ── Security utilities ──
|
// ── 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-\x1A\x1C-\x1F]|\x1b/g;
|
||||||
|
|
||||||
export function sanitizeString(text: string): string {
|
export function sanitizeString(text: string): string {
|
||||||
return text.replace(ANSI_ESCAPE_RE, '');
|
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]+$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
src/cli/tests/registry/cache.test.ts
Normal file
90
src/cli/tests/registry/cache.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { RegistryCache } from '../../src/registry/cache.js';
|
||||||
|
import type { RegistryServer, SearchOptions } from '../../src/registry/types.js';
|
||||||
|
|
||||||
|
function makeServer(name: string): RegistryServer {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description: `${name} server`,
|
||||||
|
packages: {},
|
||||||
|
envTemplate: [],
|
||||||
|
transport: 'stdio',
|
||||||
|
popularityScore: 0,
|
||||||
|
verified: false,
|
||||||
|
sourceRegistry: 'official',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: SearchOptions = { query: 'test' };
|
||||||
|
|
||||||
|
describe('RegistryCache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for cache miss', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
expect(cache.get('unknown', defaultOptions)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns data for cache hit within TTL', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
const data = [makeServer('test')];
|
||||||
|
cache.set('test', defaultOptions, data);
|
||||||
|
expect(cache.get('test', defaultOptions)).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null after TTL expires', () => {
|
||||||
|
const cache = new RegistryCache(1000); // 1 second TTL
|
||||||
|
cache.set('test', defaultOptions, [makeServer('test')]);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1001);
|
||||||
|
expect(cache.get('test', defaultOptions)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates deterministic cache keys', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
const data = [makeServer('test')];
|
||||||
|
cache.set('query', { query: 'query', limit: 10 }, data);
|
||||||
|
expect(cache.get('query', { query: 'query', limit: 10 })).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates different keys for different queries', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
cache.set('a', { query: 'a' }, [makeServer('a')]);
|
||||||
|
expect(cache.get('b', { query: 'b' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks hits and misses correctly', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
cache.set('test', defaultOptions, [makeServer('test')]);
|
||||||
|
|
||||||
|
cache.get('test', defaultOptions); // hit
|
||||||
|
cache.get('test', defaultOptions); // hit
|
||||||
|
cache.get('miss', { query: 'miss' }); // miss
|
||||||
|
|
||||||
|
const ratio = cache.getHitRatio();
|
||||||
|
expect(ratio.hits).toBe(2);
|
||||||
|
expect(ratio.misses).toBe(1);
|
||||||
|
expect(ratio.ratio).toBeCloseTo(2 / 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 ratio when no accesses', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
expect(cache.getHitRatio().ratio).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all entries and resets metrics', () => {
|
||||||
|
const cache = new RegistryCache();
|
||||||
|
cache.set('a', { query: 'a' }, [makeServer('a')]);
|
||||||
|
cache.get('a', { query: 'a' }); // hit
|
||||||
|
cache.clear();
|
||||||
|
|
||||||
|
expect(cache.get('a', { query: 'a' })).toBeNull();
|
||||||
|
expect(cache.size).toBe(0);
|
||||||
|
expect(cache.getHitRatio().hits).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
282
src/cli/tests/registry/client.test.ts
Normal file
282
src/cli/tests/registry/client.test.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { RegistryClient } from '../../src/registry/client.js';
|
||||||
|
import type { RegistryServer } from '../../src/registry/types.js';
|
||||||
|
|
||||||
|
function makeServer(name: string, source: 'official' | 'glama' | 'smithery'): RegistryServer {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description: `${name} description`,
|
||||||
|
packages: { npm: `@test/${name}` },
|
||||||
|
envTemplate: [],
|
||||||
|
transport: 'stdio',
|
||||||
|
popularityScore: 50,
|
||||||
|
verified: source === 'smithery',
|
||||||
|
sourceRegistry: source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockRegistryResponse(source: string, servers: RegistryServer[]): void {
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('registry.modelcontextprotocol.io')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: servers
|
||||||
|
.filter((s) => s.sourceRegistry === 'official')
|
||||||
|
.map((s) => ({
|
||||||
|
server: {
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
packages: s.packages.npm !== undefined ? [{
|
||||||
|
registryType: 'npm',
|
||||||
|
identifier: s.packages.npm,
|
||||||
|
transport: { type: 'stdio' },
|
||||||
|
environmentVariables: [],
|
||||||
|
}] : [],
|
||||||
|
remotes: [],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
metadata: { nextCursor: null, count: 1 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('glama.ai')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: servers
|
||||||
|
.filter((s) => s.sourceRegistry === 'glama')
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.name,
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
attributes: [],
|
||||||
|
slug: s.packages.npm ?? '',
|
||||||
|
})),
|
||||||
|
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('registry.smithery.ai')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: servers
|
||||||
|
.filter((s) => s.sourceRegistry === 'smithery')
|
||||||
|
.map((s) => ({
|
||||||
|
qualifiedName: s.name,
|
||||||
|
displayName: s.name,
|
||||||
|
description: s.description,
|
||||||
|
verified: s.verified,
|
||||||
|
useCount: s.popularityScore,
|
||||||
|
remote: false,
|
||||||
|
})),
|
||||||
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 1 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RegistryClient', () => {
|
||||||
|
it('queries all enabled registries', async () => {
|
||||||
|
const testServers = [
|
||||||
|
makeServer('slack-official', 'official'),
|
||||||
|
makeServer('slack-glama', 'glama'),
|
||||||
|
makeServer('slack-smithery', 'smithery'),
|
||||||
|
];
|
||||||
|
mockRegistryResponse('all', testServers);
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
const results = await client.search({ query: 'slack' });
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses cached results on second call', async () => {
|
||||||
|
mockRegistryResponse('all', [makeServer('slack', 'official')]);
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
await client.search({ query: 'slack' });
|
||||||
|
mockFetch.mockClear();
|
||||||
|
await client.search({ query: 'slack' });
|
||||||
|
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by registry when specified', async () => {
|
||||||
|
mockRegistryResponse('all', [makeServer('test', 'official')]);
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
await client.search({ query: 'test', registries: ['official'] });
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
const calledUrl = mockFetch.mock.calls[0]?.[0] as string;
|
||||||
|
expect(calledUrl).toContain('modelcontextprotocol.io');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles partial failures gracefully', async () => {
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('glama.ai')) {
|
||||||
|
return Promise.reject(new Error('Network error'));
|
||||||
|
}
|
||||||
|
if (url.includes('registry.smithery.ai')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: [{
|
||||||
|
qualifiedName: 'slack',
|
||||||
|
displayName: 'Slack',
|
||||||
|
description: 'Slack',
|
||||||
|
verified: true,
|
||||||
|
useCount: 100,
|
||||||
|
remote: false,
|
||||||
|
}],
|
||||||
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 1 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: [],
|
||||||
|
metadata: { nextCursor: null },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
const results = await client.search({ query: 'slack' });
|
||||||
|
|
||||||
|
// Should still return results from successful sources
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records error counts on failures', async () => {
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('glama.ai')) {
|
||||||
|
return Promise.reject(new Error('fail'));
|
||||||
|
}
|
||||||
|
// Return empty for others
|
||||||
|
if (url.includes('modelcontextprotocol')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ servers: [], metadata: { nextCursor: null } }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: [],
|
||||||
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 0 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
await client.search({ query: 'test' });
|
||||||
|
|
||||||
|
const errors = client.getErrorCounts();
|
||||||
|
expect(errors.get('glama')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by verified when specified', async () => {
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('registry.smithery.ai')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: [
|
||||||
|
{ qualifiedName: 'verified', displayName: 'Verified', description: '', verified: true, useCount: 100, remote: false },
|
||||||
|
{ qualifiedName: 'unverified', displayName: 'Unverified', description: '', verified: false, useCount: 50, remote: false },
|
||||||
|
],
|
||||||
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 2 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ servers: [], metadata: { nextCursor: null } }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock glama too
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('registry.smithery.ai')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
servers: [
|
||||||
|
{ qualifiedName: 'verified', displayName: 'Verified', description: '', verified: true, useCount: 100, remote: false },
|
||||||
|
{ qualifiedName: 'unverified', displayName: 'Unverified', description: '', verified: false, useCount: 50, remote: false },
|
||||||
|
],
|
||||||
|
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 2 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('glama.ai')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ servers: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ servers: [], metadata: { nextCursor: null } }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
const results = await client.search({ query: 'test', verified: true });
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
expect(r.verified).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects limit option', async () => {
|
||||||
|
mockRegistryResponse('all', [
|
||||||
|
makeServer('a', 'official'),
|
||||||
|
makeServer('b', 'glama'),
|
||||||
|
makeServer('c', 'smithery'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
const results = await client.search({ query: 'test', limit: 1 });
|
||||||
|
expect(results.length).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records latency metrics', async () => {
|
||||||
|
mockRegistryResponse('all', [makeServer('test', 'official')]);
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
await client.search({ query: 'test' });
|
||||||
|
|
||||||
|
const latencies = client.getQueryLatencies();
|
||||||
|
expect(latencies.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearCache empties cache', async () => {
|
||||||
|
mockRegistryResponse('all', [makeServer('test', 'official')]);
|
||||||
|
|
||||||
|
const client = new RegistryClient();
|
||||||
|
await client.search({ query: 'test' });
|
||||||
|
client.clearCache();
|
||||||
|
mockFetch.mockClear();
|
||||||
|
mockRegistryResponse('all', [makeServer('test', 'official')]);
|
||||||
|
await client.search({ query: 'test' });
|
||||||
|
|
||||||
|
// Should have fetched again after cache clear
|
||||||
|
expect(mockFetch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
src/cli/tests/registry/dedup.test.ts
Normal file
105
src/cli/tests/registry/dedup.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { deduplicateResults } from '../../src/registry/dedup.js';
|
||||||
|
import type { RegistryServer } from '../../src/registry/types.js';
|
||||||
|
|
||||||
|
function makeServer(overrides: Partial<RegistryServer> = {}): RegistryServer {
|
||||||
|
return {
|
||||||
|
name: 'test-server',
|
||||||
|
description: 'A test server',
|
||||||
|
packages: {},
|
||||||
|
envTemplate: [],
|
||||||
|
transport: 'stdio',
|
||||||
|
popularityScore: 0,
|
||||||
|
verified: false,
|
||||||
|
sourceRegistry: 'official',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('deduplicateResults', () => {
|
||||||
|
it('keeps unique servers', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'server-a', packages: { npm: 'pkg-a' } }),
|
||||||
|
makeServer({ name: 'server-b', packages: { npm: 'pkg-b' } }),
|
||||||
|
];
|
||||||
|
expect(deduplicateResults(servers)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates by npm package name, keeps higher popularity', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'low', packages: { npm: '@test/slack' }, popularityScore: 10, sourceRegistry: 'official' }),
|
||||||
|
makeServer({ name: 'high', packages: { npm: '@test/slack' }, popularityScore: 100, sourceRegistry: 'smithery' }),
|
||||||
|
];
|
||||||
|
const result = deduplicateResults(servers);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.name).toBe('high');
|
||||||
|
expect(result[0]?.popularityScore).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates by GitHub URL with different formats', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'a', repositoryUrl: 'https://github.com/org/repo', popularityScore: 5 }),
|
||||||
|
makeServer({ name: 'b', repositoryUrl: 'git@github.com:org/repo.git', popularityScore: 50 }),
|
||||||
|
];
|
||||||
|
const result = deduplicateResults(servers);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.name).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges envTemplate from both sources', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({
|
||||||
|
name: 'a',
|
||||||
|
packages: { npm: 'pkg' },
|
||||||
|
envTemplate: [{ name: 'TOKEN', description: 'API token', isSecret: true }],
|
||||||
|
popularityScore: 10,
|
||||||
|
}),
|
||||||
|
makeServer({
|
||||||
|
name: 'b',
|
||||||
|
packages: { npm: 'pkg' },
|
||||||
|
envTemplate: [{ name: 'URL', description: 'Base URL', isSecret: false }],
|
||||||
|
popularityScore: 5,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const result = deduplicateResults(servers);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.envTemplate).toHaveLength(2);
|
||||||
|
expect(result[0]?.envTemplate.map((e) => e.name)).toContain('TOKEN');
|
||||||
|
expect(result[0]?.envTemplate.map((e) => e.name)).toContain('URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates envTemplate by var name', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({
|
||||||
|
packages: { npm: 'pkg' },
|
||||||
|
envTemplate: [{ name: 'TOKEN', description: 'from a', isSecret: true }],
|
||||||
|
popularityScore: 10,
|
||||||
|
}),
|
||||||
|
makeServer({
|
||||||
|
packages: { npm: 'pkg' },
|
||||||
|
envTemplate: [{ name: 'TOKEN', description: 'from b', isSecret: true }],
|
||||||
|
popularityScore: 5,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const result = deduplicateResults(servers);
|
||||||
|
expect(result[0]?.envTemplate).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges verified status (OR)', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ packages: { npm: 'pkg' }, verified: true, popularityScore: 10 }),
|
||||||
|
makeServer({ packages: { npm: 'pkg' }, verified: false, popularityScore: 5 }),
|
||||||
|
];
|
||||||
|
const result = deduplicateResults(servers);
|
||||||
|
expect(result[0]?.verified).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles servers with no npm or repo', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'a' }),
|
||||||
|
makeServer({ name: 'b' }),
|
||||||
|
];
|
||||||
|
// No matching key → no dedup
|
||||||
|
expect(deduplicateResults(servers)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
src/cli/tests/registry/ranking.test.ts
Normal file
91
src/cli/tests/registry/ranking.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { rankResults } from '../../src/registry/ranking.js';
|
||||||
|
import type { RegistryServer } from '../../src/registry/types.js';
|
||||||
|
|
||||||
|
function makeServer(overrides: Partial<RegistryServer> = {}): RegistryServer {
|
||||||
|
return {
|
||||||
|
name: 'test-server',
|
||||||
|
description: 'A test server',
|
||||||
|
packages: {},
|
||||||
|
envTemplate: [],
|
||||||
|
transport: 'stdio',
|
||||||
|
popularityScore: 0,
|
||||||
|
verified: false,
|
||||||
|
sourceRegistry: 'official',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rankResults', () => {
|
||||||
|
it('puts exact name match first', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'slack-extended-tools' }),
|
||||||
|
makeServer({ name: 'slack' }),
|
||||||
|
makeServer({ name: 'my-slack-bot' }),
|
||||||
|
];
|
||||||
|
const ranked = rankResults(servers, 'slack');
|
||||||
|
expect(ranked[0]?.name).toBe('slack');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ranks verified servers higher than unverified', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'server-a', verified: false }),
|
||||||
|
makeServer({ name: 'server-b', verified: true }),
|
||||||
|
];
|
||||||
|
const ranked = rankResults(servers, 'server');
|
||||||
|
expect(ranked[0]?.name).toBe('server-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ranks popular servers higher', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'unpopular', popularityScore: 1 }),
|
||||||
|
makeServer({ name: 'popular', popularityScore: 10000 }),
|
||||||
|
];
|
||||||
|
const ranked = rankResults(servers, 'test');
|
||||||
|
expect(ranked[0]?.name).toBe('popular');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('considers recency', () => {
|
||||||
|
const recent = new Date();
|
||||||
|
const old = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'old-server', lastUpdated: old }),
|
||||||
|
makeServer({ name: 'new-server', lastUpdated: recent }),
|
||||||
|
];
|
||||||
|
const ranked = rankResults(servers, 'test');
|
||||||
|
expect(ranked[0]?.name).toBe('new-server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing lastUpdated gracefully', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'no-date' }),
|
||||||
|
makeServer({ name: 'has-date', lastUpdated: new Date() }),
|
||||||
|
];
|
||||||
|
// Should not throw
|
||||||
|
const ranked = rankResults(servers, 'test');
|
||||||
|
expect(ranked).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces stable ordering for identical scores', () => {
|
||||||
|
const servers = Array.from({ length: 10 }, (_, i) =>
|
||||||
|
makeServer({ name: `server-${String(i)}` }),
|
||||||
|
);
|
||||||
|
const ranked1 = rankResults(servers, 'test');
|
||||||
|
const ranked2 = rankResults(servers, 'test');
|
||||||
|
expect(ranked1.map((s) => s.name)).toEqual(ranked2.map((s) => s.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(rankResults([], 'test')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate original array', () => {
|
||||||
|
const servers = [
|
||||||
|
makeServer({ name: 'b' }),
|
||||||
|
makeServer({ name: 'a' }),
|
||||||
|
];
|
||||||
|
const original = [...servers];
|
||||||
|
rankResults(servers, 'test');
|
||||||
|
expect(servers.map((s) => s.name)).toEqual(original.map((s) => s.name));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist"
|
"outDir": "dist",
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"references": [
|
"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
|
// 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: {
|
test: {
|
||||||
name: 'db',
|
name: 'db',
|
||||||
include: ['tests/**/*.test.ts'],
|
include: ['tests/**/*.test.ts'],
|
||||||
|
// Test files share the same database — run sequentially
|
||||||
|
fileParallelism: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,12 +14,18 @@
|
|||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fastify": "^5.0.0",
|
|
||||||
"@fastify/cors": "^10.0.0",
|
"@fastify/cors": "^10.0.0",
|
||||||
"@fastify/helmet": "^12.0.0",
|
"@fastify/helmet": "^12.0.0",
|
||||||
"@fastify/rate-limit": "^10.0.0",
|
"@fastify/rate-limit": "^10.0.0",
|
||||||
"zod": "^3.24.0",
|
"@mcpctl/db": "workspace:*",
|
||||||
"@mcpctl/shared": "workspace:*",
|
"@mcpctl/shared": "workspace:*",
|
||||||
"@mcpctl/db": "workspace:*"
|
"@prisma/client": "^6.0.0",
|
||||||
|
"dockerode": "^4.0.9",
|
||||||
|
"fastify": "^5.0.0",
|
||||||
|
"zod": "^3.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/dockerode": "^4.0.1",
|
||||||
|
"@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
|
export { createServer } from './server.js';
|
||||||
// Will be implemented in Task 3
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
6
src/mcpd/src/repositories/index.ts
Normal file
6
src/mcpd/src/repositories/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type { IMcpServerRepository, IMcpProfileRepository, IMcpInstanceRepository } 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';
|
||||||
|
export { McpInstanceRepository } from './mcp-instance.repository.js';
|
||||||
30
src/mcpd/src/repositories/interfaces.ts
Normal file
30
src/mcpd/src/repositories/interfaces.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { McpServer, McpProfile, McpInstance, InstanceStatus } 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 IMcpInstanceRepository {
|
||||||
|
findAll(serverId?: string): Promise<McpInstance[]>;
|
||||||
|
findById(id: string): Promise<McpInstance | null>;
|
||||||
|
findByContainerId(containerId: string): Promise<McpInstance | null>;
|
||||||
|
create(data: { serverId: string; containerId?: string; status?: InstanceStatus; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
|
||||||
|
updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
|
||||||
|
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>;
|
||||||
|
}
|
||||||
71
src/mcpd/src/repositories/mcp-instance.repository.ts
Normal file
71
src/mcpd/src/repositories/mcp-instance.repository.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { PrismaClient, McpInstance, InstanceStatus, Prisma } from '@prisma/client';
|
||||||
|
import type { IMcpInstanceRepository } from './interfaces.js';
|
||||||
|
|
||||||
|
export class McpInstanceRepository implements IMcpInstanceRepository {
|
||||||
|
constructor(private prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async findAll(serverId?: string): Promise<McpInstance[]> {
|
||||||
|
const where: Prisma.McpInstanceWhereInput = {};
|
||||||
|
if (serverId) {
|
||||||
|
where.serverId = serverId;
|
||||||
|
}
|
||||||
|
return this.prisma.mcpInstance.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<McpInstance | null> {
|
||||||
|
return this.prisma.mcpInstance.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByContainerId(containerId: string): Promise<McpInstance | null> {
|
||||||
|
return this.prisma.mcpInstance.findFirst({ where: { containerId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: {
|
||||||
|
serverId: string;
|
||||||
|
containerId?: string;
|
||||||
|
status?: InstanceStatus;
|
||||||
|
port?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): Promise<McpInstance> {
|
||||||
|
return this.prisma.mcpInstance.create({
|
||||||
|
data: {
|
||||||
|
serverId: data.serverId,
|
||||||
|
containerId: data.containerId ?? null,
|
||||||
|
status: data.status ?? 'STOPPED',
|
||||||
|
port: data.port ?? null,
|
||||||
|
metadata: (data.metadata ?? {}) as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
status: InstanceStatus,
|
||||||
|
fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> },
|
||||||
|
): Promise<McpInstance> {
|
||||||
|
const updateData: Prisma.McpInstanceUpdateInput = {
|
||||||
|
status,
|
||||||
|
version: { increment: 1 },
|
||||||
|
};
|
||||||
|
if (fields?.containerId !== undefined) {
|
||||||
|
updateData.containerId = fields.containerId;
|
||||||
|
}
|
||||||
|
if (fields?.port !== undefined) {
|
||||||
|
updateData.port = fields.port;
|
||||||
|
}
|
||||||
|
if (fields?.metadata !== undefined) {
|
||||||
|
updateData.metadata = fields.metadata as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
return this.prisma.mcpInstance.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.prisma.mcpInstance.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
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' });
|
||||||
|
});
|
||||||
|
}
|
||||||
6
src/mcpd/src/routes/index.ts
Normal file
6
src/mcpd/src/routes/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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';
|
||||||
|
export { registerInstanceRoutes } from './instances.js';
|
||||||
49
src/mcpd/src/routes/instances.ts
Normal file
49
src/mcpd/src/routes/instances.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { InstanceService } from '../services/instance.service.js';
|
||||||
|
|
||||||
|
export function registerInstanceRoutes(app: FastifyInstance, service: InstanceService): void {
|
||||||
|
app.get<{ Querystring: { serverId?: string } }>('/api/v1/instances', async (request) => {
|
||||||
|
return service.list(request.query.serverId);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/v1/instances/:id', async (request) => {
|
||||||
|
return service.getById(request.params.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: { serverId: string; env?: Record<string, string>; hostPort?: number } }>(
|
||||||
|
'/api/v1/instances',
|
||||||
|
async (request, reply) => {
|
||||||
|
const { serverId } = request.body;
|
||||||
|
const opts: { env?: Record<string, string>; hostPort?: number } = {};
|
||||||
|
if (request.body.env) {
|
||||||
|
opts.env = request.body.env;
|
||||||
|
}
|
||||||
|
if (request.body.hostPort !== undefined) {
|
||||||
|
opts.hostPort = request.body.hostPort;
|
||||||
|
}
|
||||||
|
const instance = await service.start(serverId, opts);
|
||||||
|
reply.code(201);
|
||||||
|
return instance;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>('/api/v1/instances/:id/stop', async (request) => {
|
||||||
|
return service.stop(request.params.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => {
|
||||||
|
await service.remove(request.params.id);
|
||||||
|
reply.code(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string }; Querystring: { tail?: string } }>(
|
||||||
|
'/api/v1/instances/:id/logs',
|
||||||
|
async (request) => {
|
||||||
|
const opts: { tail?: number } = {};
|
||||||
|
if (request.query.tail) {
|
||||||
|
opts.tail = parseInt(request.query.tail, 10);
|
||||||
|
}
|
||||||
|
return service.getLogs(request.params.id, opts);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
156
src/mcpd/src/services/docker/container-manager.ts
Normal file
156
src/mcpd/src/services/docker/container-manager.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import Docker from 'dockerode';
|
||||||
|
import type {
|
||||||
|
McpOrchestrator,
|
||||||
|
ContainerSpec,
|
||||||
|
ContainerInfo,
|
||||||
|
ContainerLogs,
|
||||||
|
} from '../orchestrator.js';
|
||||||
|
import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from '../orchestrator.js';
|
||||||
|
|
||||||
|
const MCPCTL_LABEL = 'mcpctl.managed';
|
||||||
|
|
||||||
|
function mapState(state: string | undefined): ContainerInfo['state'] {
|
||||||
|
switch (state?.toLowerCase()) {
|
||||||
|
case 'running':
|
||||||
|
return 'running';
|
||||||
|
case 'created':
|
||||||
|
case 'restarting':
|
||||||
|
return 'starting';
|
||||||
|
case 'exited':
|
||||||
|
case 'dead':
|
||||||
|
case 'paused':
|
||||||
|
return 'stopped';
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DockerContainerManager implements McpOrchestrator {
|
||||||
|
private docker: Docker;
|
||||||
|
|
||||||
|
constructor(opts?: Docker.DockerOptions) {
|
||||||
|
this.docker = new Docker(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ping(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.docker.ping();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pullImage(image: string): Promise<void> {
|
||||||
|
const stream = await this.docker.pull(image);
|
||||||
|
// Wait for pull to complete
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.docker.modem.followProgress(stream, (err: Error | null) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContainer(spec: ContainerSpec): Promise<ContainerInfo> {
|
||||||
|
const memoryLimit = spec.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
|
||||||
|
const nanoCpus = spec.nanoCpus ?? DEFAULT_NANO_CPUS;
|
||||||
|
|
||||||
|
const portBindings: Record<string, Array<{ HostPort: string }>> = {};
|
||||||
|
const exposedPorts: Record<string, Record<string, never>> = {};
|
||||||
|
|
||||||
|
if (spec.containerPort) {
|
||||||
|
const key = `${spec.containerPort}/tcp`;
|
||||||
|
exposedPorts[key] = {};
|
||||||
|
portBindings[key] = [{ HostPort: spec.hostPort ? String(spec.hostPort) : '0' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
[MCPCTL_LABEL]: 'true',
|
||||||
|
...spec.labels,
|
||||||
|
};
|
||||||
|
|
||||||
|
const envArr = spec.env
|
||||||
|
? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const container = await this.docker.createContainer({
|
||||||
|
Image: spec.image,
|
||||||
|
name: spec.name,
|
||||||
|
Env: envArr,
|
||||||
|
ExposedPorts: exposedPorts,
|
||||||
|
Labels: labels,
|
||||||
|
HostConfig: {
|
||||||
|
PortBindings: portBindings,
|
||||||
|
Memory: memoryLimit,
|
||||||
|
NanoCpus: nanoCpus,
|
||||||
|
NetworkMode: spec.network ?? 'bridge',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await container.start();
|
||||||
|
|
||||||
|
return this.inspectContainer(container.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopContainer(containerId: string, timeoutSeconds = 10): Promise<void> {
|
||||||
|
const container = this.docker.getContainer(containerId);
|
||||||
|
await container.stop({ t: timeoutSeconds });
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeContainer(containerId: string, force = false): Promise<void> {
|
||||||
|
const container = this.docker.getContainer(containerId);
|
||||||
|
await container.remove({ force, v: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async inspectContainer(containerId: string): Promise<ContainerInfo> {
|
||||||
|
const container = this.docker.getContainer(containerId);
|
||||||
|
const info = await container.inspect();
|
||||||
|
|
||||||
|
let port: number | undefined;
|
||||||
|
const ports = info.NetworkSettings?.Ports;
|
||||||
|
if (ports) {
|
||||||
|
for (const bindings of Object.values(ports)) {
|
||||||
|
const arr = bindings as Array<{ HostIp: string; HostPort: string }> | undefined;
|
||||||
|
if (arr && arr.length > 0 && arr[0]) {
|
||||||
|
port = parseInt(arr[0].HostPort, 10);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ContainerInfo = {
|
||||||
|
containerId: info.Id,
|
||||||
|
name: info.Name.replace(/^\//, ''),
|
||||||
|
state: mapState(info.State?.Status),
|
||||||
|
createdAt: new Date(info.Created),
|
||||||
|
};
|
||||||
|
if (port !== undefined) {
|
||||||
|
result.port = port;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContainerLogs(
|
||||||
|
containerId: string,
|
||||||
|
opts?: { tail?: number; since?: number },
|
||||||
|
): Promise<ContainerLogs> {
|
||||||
|
const container = this.docker.getContainer(containerId);
|
||||||
|
const logOpts: Record<string, unknown> = {
|
||||||
|
stdout: true,
|
||||||
|
stderr: true,
|
||||||
|
follow: false,
|
||||||
|
tail: opts?.tail ?? 100,
|
||||||
|
};
|
||||||
|
if (opts?.since) {
|
||||||
|
logOpts['since'] = opts.since;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await container.logs(logOpts) as unknown as Buffer;
|
||||||
|
const raw = buffer.toString('utf-8');
|
||||||
|
|
||||||
|
// Docker multiplexes stdout/stderr with 8-byte headers.
|
||||||
|
// For simplicity we return everything as stdout.
|
||||||
|
return { stdout: raw, stderr: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/mcpd/src/services/index.ts
Normal file
9
src/mcpd/src/services/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||||
|
export { McpProfileService } from './mcp-profile.service.js';
|
||||||
|
export { ProjectService } from './project.service.js';
|
||||||
|
export { InstanceService } from './instance.service.js';
|
||||||
|
export { generateMcpConfig } from './mcp-config-generator.js';
|
||||||
|
export type { McpConfig, McpConfigServer, ProfileWithServer } from './mcp-config-generator.js';
|
||||||
|
export type { McpOrchestrator, ContainerSpec, ContainerInfo, ContainerLogs } from './orchestrator.js';
|
||||||
|
export { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from './orchestrator.js';
|
||||||
|
export { DockerContainerManager } from './docker/container-manager.js';
|
||||||
112
src/mcpd/src/services/instance.service.ts
Normal file
112
src/mcpd/src/services/instance.service.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import type { McpInstance } from '@prisma/client';
|
||||||
|
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
|
||||||
|
import type { McpOrchestrator, ContainerSpec } from './orchestrator.js';
|
||||||
|
import { NotFoundError } from './mcp-server.service.js';
|
||||||
|
|
||||||
|
export class InstanceService {
|
||||||
|
constructor(
|
||||||
|
private instanceRepo: IMcpInstanceRepository,
|
||||||
|
private serverRepo: IMcpServerRepository,
|
||||||
|
private orchestrator: McpOrchestrator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(serverId?: string): Promise<McpInstance[]> {
|
||||||
|
return this.instanceRepo.findAll(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<McpInstance> {
|
||||||
|
const instance = await this.instanceRepo.findById(id);
|
||||||
|
if (!instance) throw new NotFoundError(`Instance '${id}' not found`);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(serverId: string, opts?: { env?: Record<string, string>; hostPort?: number }): Promise<McpInstance> {
|
||||||
|
const server = await this.serverRepo.findById(serverId);
|
||||||
|
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
|
||||||
|
|
||||||
|
const image = server.dockerImage ?? server.packageName ?? server.name;
|
||||||
|
|
||||||
|
// Create DB record first in STARTING state
|
||||||
|
let instance = await this.instanceRepo.create({
|
||||||
|
serverId,
|
||||||
|
status: 'STARTING',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const spec: ContainerSpec = {
|
||||||
|
image,
|
||||||
|
name: `mcpctl-${server.name}-${instance.id}`,
|
||||||
|
hostPort: opts?.hostPort ?? null,
|
||||||
|
labels: {
|
||||||
|
'mcpctl.server-id': serverId,
|
||||||
|
'mcpctl.instance-id': instance.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
|
||||||
|
spec.containerPort = 3000;
|
||||||
|
}
|
||||||
|
if (opts?.env) {
|
||||||
|
spec.env = opts.env;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerInfo = await this.orchestrator.createContainer(spec);
|
||||||
|
|
||||||
|
const updateFields: { containerId: string; port?: number } = {
|
||||||
|
containerId: containerInfo.containerId,
|
||||||
|
};
|
||||||
|
if (containerInfo.port !== undefined) {
|
||||||
|
updateFields.port = containerInfo.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
instance = await this.instanceRepo.updateStatus(instance.id, 'RUNNING', updateFields);
|
||||||
|
} catch (err) {
|
||||||
|
// Mark as ERROR if container creation fails
|
||||||
|
instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', {
|
||||||
|
metadata: { error: err instanceof Error ? err.message : String(err) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(id: string): Promise<McpInstance> {
|
||||||
|
const instance = await this.getById(id);
|
||||||
|
if (!instance.containerId) {
|
||||||
|
return this.instanceRepo.updateStatus(id, 'STOPPED');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.instanceRepo.updateStatus(id, 'STOPPING');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.orchestrator.stopContainer(instance.containerId);
|
||||||
|
return await this.instanceRepo.updateStatus(id, 'STOPPED');
|
||||||
|
} catch (err) {
|
||||||
|
return await this.instanceRepo.updateStatus(id, 'ERROR', {
|
||||||
|
metadata: { error: err instanceof Error ? err.message : String(err) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
const instance = await this.getById(id);
|
||||||
|
|
||||||
|
if (instance.containerId) {
|
||||||
|
try {
|
||||||
|
await this.orchestrator.removeContainer(instance.containerId, true);
|
||||||
|
} catch {
|
||||||
|
// Container may already be gone, proceed with DB cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.instanceRepo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(id: string, opts?: { tail?: number }): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
const instance = await this.getById(id);
|
||||||
|
if (!instance.containerId) {
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.orchestrator.getContainerLogs(instance.containerId, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/mcpd/src/services/orchestrator.ts
Normal file
64
src/mcpd/src/services/orchestrator.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Container orchestrator abstraction. Implementations can back onto Docker, Podman, or Kubernetes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ContainerSpec {
|
||||||
|
/** Docker/OCI image reference */
|
||||||
|
image: string;
|
||||||
|
/** Human-readable name (used as container name prefix) */
|
||||||
|
name: string;
|
||||||
|
/** Environment variables */
|
||||||
|
env?: Record<string, string>;
|
||||||
|
/** Host port to bind (null = auto-assign) */
|
||||||
|
hostPort?: number | null;
|
||||||
|
/** Container port to expose */
|
||||||
|
containerPort?: number;
|
||||||
|
/** Memory limit in bytes (default: 512 MB) */
|
||||||
|
memoryLimit?: number;
|
||||||
|
/** CPU period quota (nanoCPUs, default: 0.5 CPU) */
|
||||||
|
nanoCpus?: number;
|
||||||
|
/** Labels for identification / filtering */
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
/** Network name to attach to */
|
||||||
|
network?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerInfo {
|
||||||
|
containerId: string;
|
||||||
|
name: string;
|
||||||
|
state: 'running' | 'stopped' | 'starting' | 'error' | 'unknown';
|
||||||
|
port?: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerLogs {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpOrchestrator {
|
||||||
|
/** Pull an image if not present locally */
|
||||||
|
pullImage(image: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Create and start a container */
|
||||||
|
createContainer(spec: ContainerSpec): Promise<ContainerInfo>;
|
||||||
|
|
||||||
|
/** Stop a running container */
|
||||||
|
stopContainer(containerId: string, timeoutSeconds?: number): Promise<void>;
|
||||||
|
|
||||||
|
/** Remove a stopped container */
|
||||||
|
removeContainer(containerId: string, force?: boolean): Promise<void>;
|
||||||
|
|
||||||
|
/** Get container info */
|
||||||
|
inspectContainer(containerId: string): Promise<ContainerInfo>;
|
||||||
|
|
||||||
|
/** Get container logs */
|
||||||
|
getContainerLogs(containerId: string, opts?: { tail?: number; since?: number }): Promise<ContainerLogs>;
|
||||||
|
|
||||||
|
/** Check if the orchestrator runtime is available */
|
||||||
|
ping(): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default resource limits */
|
||||||
|
export const DEFAULT_MEMORY_LIMIT = 512 * 1024 * 1024; // 512 MB
|
||||||
|
export const DEFAULT_NANO_CPUS = 500_000_000; // 0.5 CPU
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
125
src/mcpd/tests/container-manager.test.ts
Normal file
125
src/mcpd/tests/container-manager.test.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { DockerContainerManager } from '../src/services/docker/container-manager.js';
|
||||||
|
import type { ContainerSpec } from '../src/services/orchestrator.js';
|
||||||
|
|
||||||
|
// Mock dockerode
|
||||||
|
vi.mock('dockerode', () => {
|
||||||
|
const mockContainer = {
|
||||||
|
id: 'ctr-abc123',
|
||||||
|
start: vi.fn(async () => {}),
|
||||||
|
stop: vi.fn(async () => {}),
|
||||||
|
remove: vi.fn(async () => {}),
|
||||||
|
inspect: vi.fn(async () => ({
|
||||||
|
Id: 'ctr-abc123',
|
||||||
|
Name: '/mcpctl-test',
|
||||||
|
State: { Status: 'running' },
|
||||||
|
Created: '2024-01-01T00:00:00Z',
|
||||||
|
NetworkSettings: {
|
||||||
|
Ports: {
|
||||||
|
'3000/tcp': [{ HostIp: '0.0.0.0', HostPort: '32768' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
logs: vi.fn(async () => Buffer.from('test log output')),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockModem = {
|
||||||
|
followProgress: vi.fn((_stream: unknown, onFinished: (err: Error | null) => void) => {
|
||||||
|
onFinished(null);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
class MockDocker {
|
||||||
|
modem = mockModem;
|
||||||
|
ping = vi.fn(async () => 'OK');
|
||||||
|
pull = vi.fn(async () => ({}));
|
||||||
|
createContainer = vi.fn(async () => mockContainer);
|
||||||
|
getContainer = vi.fn(() => mockContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { default: MockDocker };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DockerContainerManager', () => {
|
||||||
|
let manager: DockerContainerManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
manager = new DockerContainerManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ping', () => {
|
||||||
|
it('returns true when Docker is available', async () => {
|
||||||
|
expect(await manager.ping()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pullImage', () => {
|
||||||
|
it('pulls an image', async () => {
|
||||||
|
await manager.pullImage('node:20-alpine');
|
||||||
|
// No error = success
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createContainer', () => {
|
||||||
|
it('creates and starts a container', async () => {
|
||||||
|
const spec: ContainerSpec = {
|
||||||
|
image: 'ghcr.io/slack-mcp:latest',
|
||||||
|
name: 'mcpctl-slack-inst1',
|
||||||
|
env: { SLACK_TOKEN: 'xoxb-test' },
|
||||||
|
containerPort: 3000,
|
||||||
|
hostPort: null,
|
||||||
|
labels: { 'mcpctl.server-id': 'srv-1' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await manager.createContainer(spec);
|
||||||
|
|
||||||
|
expect(result.containerId).toBe('ctr-abc123');
|
||||||
|
expect(result.name).toBe('mcpctl-test');
|
||||||
|
expect(result.state).toBe('running');
|
||||||
|
expect(result.port).toBe(32768);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies resource limits', async () => {
|
||||||
|
const spec: ContainerSpec = {
|
||||||
|
image: 'test:latest',
|
||||||
|
name: 'test-container',
|
||||||
|
memoryLimit: 256 * 1024 * 1024,
|
||||||
|
nanoCpus: 250_000_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.createContainer(spec);
|
||||||
|
// The mock captures the call - we verify it doesn't throw
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stopContainer', () => {
|
||||||
|
it('stops a container', async () => {
|
||||||
|
await manager.stopContainer('ctr-abc123');
|
||||||
|
// No error = success
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeContainer', () => {
|
||||||
|
it('removes a container', async () => {
|
||||||
|
await manager.removeContainer('ctr-abc123', true);
|
||||||
|
// No error = success
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inspectContainer', () => {
|
||||||
|
it('returns container info with mapped state', async () => {
|
||||||
|
const info = await manager.inspectContainer('ctr-abc123');
|
||||||
|
expect(info.containerId).toBe('ctr-abc123');
|
||||||
|
expect(info.state).toBe('running');
|
||||||
|
expect(info.port).toBe(32768);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getContainerLogs', () => {
|
||||||
|
it('returns container logs', async () => {
|
||||||
|
const logs = await manager.getContainerLogs('ctr-abc123', { tail: 50 });
|
||||||
|
expect(logs.stdout).toBe('test log output');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
253
src/mcpd/tests/instance-service.test.ts
Normal file
253
src/mcpd/tests/instance-service.test.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { InstanceService } from '../src/services/instance.service.js';
|
||||||
|
import { NotFoundError } from '../src/services/mcp-server.service.js';
|
||||||
|
import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
|
||||||
|
import type { McpOrchestrator } from '../src/services/orchestrator.js';
|
||||||
|
|
||||||
|
function mockInstanceRepo(): IMcpInstanceRepository {
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => []),
|
||||||
|
findById: vi.fn(async () => null),
|
||||||
|
findByContainerId: vi.fn(async () => null),
|
||||||
|
create: vi.fn(async (data) => ({
|
||||||
|
id: 'inst-1',
|
||||||
|
serverId: data.serverId,
|
||||||
|
containerId: data.containerId ?? null,
|
||||||
|
status: data.status ?? 'STOPPED',
|
||||||
|
port: data.port ?? null,
|
||||||
|
metadata: data.metadata ?? {},
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})),
|
||||||
|
updateStatus: vi.fn(async (id, status, fields) => ({
|
||||||
|
id,
|
||||||
|
serverId: 'srv-1',
|
||||||
|
containerId: fields?.containerId ?? 'ctr-abc',
|
||||||
|
status,
|
||||||
|
port: fields?.port ?? null,
|
||||||
|
metadata: fields?.metadata ?? {},
|
||||||
|
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 () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockOrchestrator(): McpOrchestrator {
|
||||||
|
return {
|
||||||
|
ping: vi.fn(async () => true),
|
||||||
|
pullImage: vi.fn(async () => {}),
|
||||||
|
createContainer: vi.fn(async (spec) => ({
|
||||||
|
containerId: 'ctr-abc123',
|
||||||
|
name: spec.name,
|
||||||
|
state: 'running' as const,
|
||||||
|
port: 3000,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})),
|
||||||
|
stopContainer: vi.fn(async () => {}),
|
||||||
|
removeContainer: vi.fn(async () => {}),
|
||||||
|
inspectContainer: vi.fn(async () => ({
|
||||||
|
containerId: 'ctr-abc123',
|
||||||
|
name: 'test',
|
||||||
|
state: 'running' as const,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})),
|
||||||
|
getContainerLogs: vi.fn(async () => ({ stdout: 'log output', stderr: '' })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InstanceService', () => {
|
||||||
|
let instanceRepo: ReturnType<typeof mockInstanceRepo>;
|
||||||
|
let serverRepo: ReturnType<typeof mockServerRepo>;
|
||||||
|
let orchestrator: ReturnType<typeof mockOrchestrator>;
|
||||||
|
let service: InstanceService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
instanceRepo = mockInstanceRepo();
|
||||||
|
serverRepo = mockServerRepo();
|
||||||
|
orchestrator = mockOrchestrator();
|
||||||
|
service = new InstanceService(instanceRepo, serverRepo, orchestrator);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('lists all instances', async () => {
|
||||||
|
const result = await service.list();
|
||||||
|
expect(instanceRepo.findAll).toHaveBeenCalledWith(undefined);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by serverId', async () => {
|
||||||
|
await service.list('srv-1');
|
||||||
|
expect(instanceRepo.findAll).toHaveBeenCalledWith('srv-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getById', () => {
|
||||||
|
it('throws NotFoundError when not found', async () => {
|
||||||
|
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns instance when found', async () => {
|
||||||
|
vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1' } as never);
|
||||||
|
const result = await service.getById('inst-1');
|
||||||
|
expect(result.id).toBe('inst-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('start', () => {
|
||||||
|
it('throws NotFoundError for unknown server', async () => {
|
||||||
|
await expect(service.start('missing')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates instance and starts container', async () => {
|
||||||
|
vi.mocked(serverRepo.findById).mockResolvedValue({
|
||||||
|
id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest',
|
||||||
|
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
|
||||||
|
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.start('srv-1');
|
||||||
|
|
||||||
|
expect(instanceRepo.create).toHaveBeenCalledWith({
|
||||||
|
serverId: 'srv-1',
|
||||||
|
status: 'STARTING',
|
||||||
|
});
|
||||||
|
expect(orchestrator.createContainer).toHaveBeenCalled();
|
||||||
|
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
|
||||||
|
'inst-1', 'RUNNING',
|
||||||
|
expect.objectContaining({ containerId: 'ctr-abc123' }),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe('RUNNING');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks instance as ERROR on container failure', async () => {
|
||||||
|
vi.mocked(serverRepo.findById).mockResolvedValue({
|
||||||
|
id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest',
|
||||||
|
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
|
||||||
|
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
vi.mocked(orchestrator.createContainer).mockRejectedValue(new Error('Docker unavailable'));
|
||||||
|
|
||||||
|
const result = await service.start('srv-1');
|
||||||
|
|
||||||
|
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
|
||||||
|
'inst-1', 'ERROR',
|
||||||
|
expect.objectContaining({ metadata: { error: 'Docker unavailable' } }),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe('ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses dockerImage for container spec', async () => {
|
||||||
|
vi.mocked(serverRepo.findById).mockResolvedValue({
|
||||||
|
id: 'srv-1', name: 'slack', dockerImage: 'myregistry.com/slack:v1',
|
||||||
|
packageName: '@slack/mcp', transport: 'SSE', description: '', repositoryUrl: null,
|
||||||
|
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.start('srv-1');
|
||||||
|
|
||||||
|
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]?.[0];
|
||||||
|
expect(spec?.image).toBe('myregistry.com/slack:v1');
|
||||||
|
expect(spec?.containerPort).toBe(3000); // SSE transport
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stop', () => {
|
||||||
|
it('throws NotFoundError for missing instance', async () => {
|
||||||
|
await expect(service.stop('missing')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops a running container', async () => {
|
||||||
|
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||||
|
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
|
||||||
|
serverId: 'srv-1', port: 3000, metadata: {},
|
||||||
|
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.stop('inst-1');
|
||||||
|
|
||||||
|
expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
|
||||||
|
expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles stop without containerId', async () => {
|
||||||
|
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||||
|
id: 'inst-1', containerId: null, status: 'ERROR',
|
||||||
|
serverId: 'srv-1', port: null, metadata: {},
|
||||||
|
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.stop('inst-1');
|
||||||
|
|
||||||
|
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
|
||||||
|
expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remove', () => {
|
||||||
|
it('removes container and DB record', async () => {
|
||||||
|
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||||
|
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
|
||||||
|
serverId: 'srv-1', port: null, metadata: {},
|
||||||
|
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.remove('inst-1');
|
||||||
|
|
||||||
|
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
|
||||||
|
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes DB record even if container is already gone', async () => {
|
||||||
|
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||||
|
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
|
||||||
|
serverId: 'srv-1', port: null, metadata: {},
|
||||||
|
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container'));
|
||||||
|
|
||||||
|
await service.remove('inst-1');
|
||||||
|
|
||||||
|
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLogs', () => {
|
||||||
|
it('returns empty logs for instance without container', async () => {
|
||||||
|
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||||
|
id: 'inst-1', containerId: null, status: 'ERROR',
|
||||||
|
serverId: 'srv-1', port: null, metadata: {},
|
||||||
|
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getLogs('inst-1');
|
||||||
|
expect(result).toEqual({ stdout: '', stderr: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns container logs', async () => {
|
||||||
|
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||||
|
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
|
||||||
|
serverId: 'srv-1', port: 3000, metadata: {},
|
||||||
|
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getLogs('inst-1', { tail: 50 });
|
||||||
|
|
||||||
|
expect(orchestrator.getContainerLogs).toHaveBeenCalledWith('ctr-abc', { tail: 50 });
|
||||||
|
expect(result.stdout).toBe('log output');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist"
|
"outDir": "dist",
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"references": [
|
"references": [
|
||||||
|
|||||||
Reference in New Issue
Block a user