From 386029d0528047d12153cde3e6b34cd99ba9438d Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 21 Feb 2026 03:46:14 +0000 Subject: [PATCH] feat: implement MCP registry client with multi-source search Add registry client that queries Official, Glama, and Smithery MCP registries with caching, request deduplication, retry logic, and result ranking/dedup. Includes 53 tests covering all components. Also fix null priority values in cancelled tasks (19-21) that broke Task Master, and add new tasks 25-27 for registry completion and CLI discover/install commands. Co-Authored-By: Claude Opus 4.6 --- .taskmaster/tasks/tasks.json | 239 ++++++++++++------- package.json | 1 + pnpm-lock.yaml | 173 +++++++++----- src/cli/package.json | 7 +- src/cli/src/registry/client.ts | 105 +++++++++ src/cli/src/registry/index.ts | 17 ++ src/cli/src/registry/ranking.ts | 63 +++++ src/cli/src/registry/retry.ts | 16 ++ src/cli/src/registry/sources/glama.ts | 92 ++++++++ src/cli/src/registry/sources/official.ts | 106 +++++++++ src/cli/src/registry/sources/smithery.ts | 62 +++++ src/cli/src/registry/types.ts | 2 +- src/cli/tests/registry/cache.test.ts | 90 ++++++++ src/cli/tests/registry/client.test.ts | 282 +++++++++++++++++++++++ src/cli/tests/registry/dedup.test.ts | 105 +++++++++ src/cli/tests/registry/ranking.test.ts | 91 ++++++++ 16 files changed, 1304 insertions(+), 147 deletions(-) create mode 100644 src/cli/src/registry/client.ts create mode 100644 src/cli/src/registry/index.ts create mode 100644 src/cli/src/registry/ranking.ts create mode 100644 src/cli/src/registry/retry.ts create mode 100644 src/cli/src/registry/sources/glama.ts create mode 100644 src/cli/src/registry/sources/official.ts create mode 100644 src/cli/src/registry/sources/smithery.ts create mode 100644 src/cli/tests/registry/cache.test.ts create mode 100644 src/cli/tests/registry/client.test.ts create mode 100644 src/cli/tests/registry/dedup.test.ts create mode 100644 src/cli/tests/registry/ranking.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index b47d9b6..043856f 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2,7 +2,7 @@ "master": { "tasks": [ { - "id": 1, + "id": "1", "title": "Initialize Project Structure and Core Dependencies", "description": "Set up the monorepo structure for mcpctl with CLI client, mcpd server, and shared libraries. Configure TypeScript, ESLint, and build tooling.", "details": "Create a monorepo using pnpm workspaces or npm workspaces with the following structure:\n\n```\nmcpctl/\n├── src/\n│ ├── cli/ # mcpctl CLI tool\n│ ├── mcpd/ # Backend daemon server\n│ ├── shared/ # Shared types, utilities, constants\n│ └── local-proxy/ # Local LLM proxy component\n├── deploy/\n│ └── docker-compose.yml\n├── package.json\n├── tsconfig.base.json\n└── pnpm-workspace.yaml\n```\n\nDependencies to install:\n- TypeScript 5.x\n- Commander.js for CLI\n- Express/Fastify for mcpd HTTP server\n- Zod for schema validation\n- Winston/Pino for logging\n- Prisma or Drizzle for database ORM\n\nCreate base tsconfig.json with strict mode, ES2022 target, and module resolution settings. Set up shared ESLint config with TypeScript rules.", @@ -18,7 +18,8 @@ "dependencies": [], "details": "Create root package.json with pnpm workspaces configuration. Create pnpm-workspace.yaml defining all workspace packages. Initialize the following directory structure:\n\n```\nmcpctl/\n├── src/\n│ ├── cli/ # mcpctl CLI tool (Task 7-10)\n│ │ ├── src/\n│ │ ├── tests/\n│ │ └── package.json\n│ ├── mcpd/ # Backend daemon server (Task 3-6, 14, 16)\n│ │ ├── src/\n│ │ ├── tests/\n│ │ └── package.json\n│ ├── shared/ # Shared types, utils, constants, validation\n│ │ ├── src/\n│ │ │ ├── types/ # TypeScript interfaces/types\n│ │ │ ├── utils/ # Utility functions\n│ │ │ ├── constants/# Shared constants\n│ │ │ ├── validation/ # Zod schemas\n│ │ │ └── index.ts # Barrel export\n│ │ ├── tests/\n│ │ └── package.json\n│ ├── local-proxy/ # Local LLM proxy (Task 11-13)\n│ │ ├── src/\n│ │ ├── tests/\n│ │ └── package.json\n│ └── db/ # Database package (Task 2)\n│ ├── src/\n│ ├── prisma/ # Schema and migrations\n│ ├── seed/ # Seed data\n│ ├── tests/\n│ └── package.json\n├── deploy/\n│ └── docker-compose.yml # Local dev services (postgres)\n├── tests/\n│ ├── e2e/ # End-to-end tests (Task 18)\n│ └── integration/ # Integration tests\n├── docs/ # Documentation (Task 18)\n├── package.json # Root workspace config\n├── pnpm-workspace.yaml\n└── turbo.json # Optional: Turborepo for build orchestration\n```\n\nThe pnpm-workspace.yaml should contain: `packages: [\"src/*\"]`", "status": "done", - "testStrategy": "Write Vitest tests that verify: (1) All expected directories exist, (2) All package.json files are valid JSON with correct workspace protocol dependencies, (3) pnpm-workspace.yaml correctly includes all packages, (4) Running 'pnpm install' succeeds and creates correct node_modules symlinks between packages. Run 'pnpm ls' to verify workspace linking." + "testStrategy": "Write Vitest tests that verify: (1) All expected directories exist, (2) All package.json files are valid JSON with correct workspace protocol dependencies, (3) pnpm-workspace.yaml correctly includes all packages, (4) Running 'pnpm install' succeeds and creates correct node_modules symlinks between packages. Run 'pnpm ls' to verify workspace linking.", + "parentId": "undefined" }, { "id": 2, @@ -29,7 +30,8 @@ ], "details": "Create root tsconfig.base.json with shared compiler options. Create package-specific tsconfig.json in each package that extends the base and sets appropriate paths.", "status": "done", - "testStrategy": "Write Vitest tests that verify tsconfig.base.json exists and has strict: true, each package tsconfig.json extends base correctly." + "testStrategy": "Write Vitest tests that verify tsconfig.base.json exists and has strict: true, each package tsconfig.json extends base correctly.", + "parentId": "undefined" }, { "id": 3, @@ -40,7 +42,8 @@ ], "details": "Install Vitest and related packages at root level. Create root vitest.config.ts and vitest.workspace.ts for workspace-aware testing pointing to src/cli, src/mcpd, src/shared, src/local-proxy, src/db.", "status": "done", - "testStrategy": "Run 'pnpm test:run' and verify Vitest discovers and runs tests, coverage report is generated." + "testStrategy": "Run 'pnpm test:run' and verify Vitest discovers and runs tests, coverage report is generated.", + "parentId": "undefined" }, { "id": 4, @@ -51,7 +54,8 @@ ], "details": "Install ESLint and plugins at root. Create eslint.config.js (flat config, ESLint 9+). Create deploy/docker-compose.yml for local development with PostgreSQL service.", "status": "done", - "testStrategy": "Write Vitest tests that verify eslint.config.js exists and exports valid config, deploy/docker-compose.yml is valid YAML and defines postgres service." + "testStrategy": "Write Vitest tests that verify eslint.config.js exists and exports valid config, deploy/docker-compose.yml is valid YAML and defines postgres service.", + "parentId": "undefined" }, { "id": 5, @@ -64,12 +68,13 @@ ], "details": "Install dependencies per package in src/cli, src/mcpd, src/shared, src/db, src/local-proxy. Perform security and architecture review.", "status": "done", - "testStrategy": "Verify each package.json has required dependencies, run pnpm audit, verify .gitignore contains required patterns." + "testStrategy": "Verify each package.json has required dependencies, run pnpm audit, verify .gitignore contains required patterns.", + "parentId": "undefined" } ] }, { - "id": 2, + "id": "2", "title": "Design and Implement Database Schema", "description": "Create the database schema for storing MCP server configurations, projects, profiles, user sessions, and audit logs. Use PostgreSQL for production readiness.", "details": "Design PostgreSQL schema using Prisma ORM with models: User, McpServer, McpProfile, Project, ProjectMcpProfile, McpInstance, AuditLog, Session. Create migrations and seed data for common MCP servers (slack, jira, github, terraform).", @@ -87,7 +92,8 @@ "dependencies": [], "details": "Create src/db/prisma directory structure. Install Prisma dependencies. Configure deploy/docker-compose.yml with two PostgreSQL services: mcpctl-postgres (port 5432) for development and mcpctl-postgres-test (port 5433) for testing.", "status": "pending", - "testStrategy": "Write Vitest tests that verify docker-compose creates both postgres services, setupTestDb() successfully connects and pushes schema." + "testStrategy": "Write Vitest tests that verify docker-compose creates both postgres services, setupTestDb() successfully connects and pushes schema.", + "parentId": "undefined" }, { "id": 2, @@ -98,7 +104,8 @@ ], "details": "Create src/db/tests/models directory with separate test files for each model. Tests will initially fail (TDD red phase) until schema is implemented.", "status": "pending", - "testStrategy": "Tests are expected to fail initially (TDD red phase). After schema implementation, all tests should pass." + "testStrategy": "Tests are expected to fail initially (TDD red phase). After schema implementation, all tests should pass.", + "parentId": "undefined" }, { "id": 3, @@ -109,7 +116,8 @@ ], "details": "Implement src/db/prisma/schema.prisma with all models. Add version Int field and updatedAt DateTime for git-based backup support.", "status": "pending", - "testStrategy": "Run TDD tests from subtask 2 - all should now pass (TDD green phase). Verify npx prisma validate passes." + "testStrategy": "Run TDD tests from subtask 2 - all should now pass (TDD green phase). Verify npx prisma validate passes.", + "parentId": "undefined" }, { "id": 4, @@ -120,7 +128,8 @@ ], "details": "Create src/db/seed directory with server definitions and seeding functions for Slack, Jira, GitHub, Terraform MCP servers.", "status": "pending", - "testStrategy": "Write unit tests BEFORE implementing seed functions (TDD). Verify seedMcpServers() creates exactly 4 servers with idempotent behavior." + "testStrategy": "Write unit tests BEFORE implementing seed functions (TDD). Verify seedMcpServers() creates exactly 4 servers with idempotent behavior.", + "parentId": "undefined" }, { "id": 5, @@ -132,12 +141,13 @@ ], "details": "Run npx prisma migrate dev --name init. Create src/db/src/migration-helpers.ts. Document security and architecture findings.", "status": "pending", - "testStrategy": "Verify migration files exist, migration helper tests pass, SECURITY_REVIEW.md covers all security checkpoints." + "testStrategy": "Verify migration files exist, migration helper tests pass, SECURITY_REVIEW.md covers all security checkpoints.", + "parentId": "undefined" } ] }, { - "id": 3, + "id": "3", "title": "Implement mcpd Core Server Framework", "description": "Build the mcpd daemon server with Express/Fastify, including middleware for authentication, logging, and error handling. Design for horizontal scalability.", "details": "Create mcpd server in src/mcpd/src/ with Fastify, health check endpoint, auth middleware, and audit logging. Design for statelessness and scalability.", @@ -156,7 +166,8 @@ "dependencies": [], "details": "Create src/mcpd/src/ with routes/, controllers/, services/, repositories/, middleware/, config/, types/, utils/ directories.", "status": "pending", - "testStrategy": "Write initial Vitest tests that verify all required directories exist, package.json has required dependencies." + "testStrategy": "Write initial Vitest tests that verify all required directories exist, package.json has required dependencies.", + "parentId": "undefined" }, { "id": 2, @@ -167,7 +178,8 @@ ], "details": "Create src/mcpd/src/server.ts with Fastify instance factory function. Implement config validation with Zod and health endpoint.", "status": "pending", - "testStrategy": "TDD approach - write tests first for config validation, health endpoint returns correct structure." + "testStrategy": "TDD approach - write tests first for config validation, health endpoint returns correct structure.", + "parentId": "undefined" }, { "id": 3, @@ -178,7 +190,8 @@ ], "details": "Create src/mcpd/src/middleware/auth.ts with authMiddleware factory function using dependency injection.", "status": "pending", - "testStrategy": "TDD - write all tests before implementation for 401 responses, token validation, request decoration." + "testStrategy": "TDD - write all tests before implementation for 401 responses, token validation, request decoration.", + "parentId": "undefined" }, { "id": 4, @@ -189,7 +202,8 @@ ], "details": "Create src/mcpd/src/middleware/security.ts with registerSecurityPlugins function. Create sanitization and validation utilities.", "status": "pending", - "testStrategy": "TDD tests for CORS headers, Helmet security headers, rate limiting returns 429, input validation." + "testStrategy": "TDD tests for CORS headers, Helmet security headers, rate limiting returns 429, input validation.", + "parentId": "undefined" }, { "id": 5, @@ -202,12 +216,13 @@ ], "details": "Create error-handler.ts, audit.ts middleware, and shutdown.ts utilities in src/mcpd/src/.", "status": "pending", - "testStrategy": "TDD for all components: error handler HTTP codes, audit middleware creates records, graceful shutdown handles SIGTERM." + "testStrategy": "TDD for all components: error handler HTTP codes, audit middleware creates records, graceful shutdown handles SIGTERM.", + "parentId": "undefined" } ] }, { - "id": 4, + "id": "4", "title": "Implement MCP Server Registry and Profile Management", "description": "Create APIs for registering MCP servers, managing profiles with different permission levels, and storing configuration templates.", "details": "Create REST API endpoints in mcpd for MCP server and profile CRUD operations with seed data for common servers.", @@ -225,7 +240,8 @@ "dependencies": [], "details": "Create src/mcpd/src/validation/mcp-server.schema.ts with CreateMcpServerSchema, UpdateMcpServerSchema, CreateMcpProfileSchema.", "status": "pending", - "testStrategy": "TDD approach - write all tests first, then implement schemas. Tests verify valid inputs pass, invalid inputs fail." + "testStrategy": "TDD approach - write all tests first, then implement schemas. Tests verify valid inputs pass, invalid inputs fail.", + "parentId": "undefined" }, { "id": 2, @@ -236,7 +252,8 @@ ], "details": "Create src/mcpd/src/repositories/interfaces.ts with IMcpServerRepository and IMcpProfileRepository interfaces.", "status": "pending", - "testStrategy": "TDD - write tests before implementation with mocked PrismaClient. Verify all repository methods are covered." + "testStrategy": "TDD - write tests before implementation with mocked PrismaClient. Verify all repository methods are covered.", + "parentId": "undefined" }, { "id": 3, @@ -248,7 +265,8 @@ ], "details": "Create src/mcpd/src/services/mcp-server.service.ts and mcp-profile.service.ts with DI and authorization checks.", "status": "pending", - "testStrategy": "TDD - write tests first mocking repositories and authorization. Verify authorization checks are called for every method." + "testStrategy": "TDD - write tests first mocking repositories and authorization. Verify authorization checks are called for every method.", + "parentId": "undefined" }, { "id": 4, @@ -259,7 +277,8 @@ ], "details": "Create src/mcpd/src/routes/mcp-servers.ts and mcp-profiles.ts with all CRUD endpoints.", "status": "pending", - "testStrategy": "Write integration tests before implementation using Fastify.inject(). Test with docker-compose postgres." + "testStrategy": "Write integration tests before implementation using Fastify.inject(). Test with docker-compose postgres.", + "parentId": "undefined" }, { "id": 5, @@ -270,12 +289,13 @@ ], "details": "Create src/mcpd/src/seed/mcp-servers.seed.ts with seedMcpServers() function and SECURITY_REVIEW.md.", "status": "pending", - "testStrategy": "Write unit tests for seed functions. Security tests for injection prevention, authorization checks." + "testStrategy": "Write unit tests for seed functions. Security tests for injection prevention, authorization checks.", + "parentId": "undefined" } ] }, { - "id": 5, + "id": "5", "title": "Implement Project Management APIs", "description": "Create APIs for managing MCP projects that group multiple MCP profiles together for easy assignment to Claude sessions.", "details": "Create project management endpoints with generateMcpConfig function for .mcp.json format output.", @@ -293,7 +313,8 @@ "dependencies": [], "details": "Create tests for CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema, and generateMcpConfig with security tests.", "status": "pending", - "testStrategy": "TDD red phase - all tests should fail initially. Verify generateMcpConfig security tests check secret env vars are excluded." + "testStrategy": "TDD red phase - all tests should fail initially. Verify generateMcpConfig security tests check secret env vars are excluded.", + "parentId": "undefined" }, { "id": 2, @@ -304,7 +325,8 @@ ], "details": "Create src/mcpd/src/repositories/project.repository.ts and src/mcpd/src/services/mcp-config-generator.ts.", "status": "pending", - "testStrategy": "Run TDD tests from subtask 1. Verify output must NOT contain secret values." + "testStrategy": "Run TDD tests from subtask 1. Verify output must NOT contain secret values.", + "parentId": "undefined" }, { "id": 3, @@ -315,7 +337,8 @@ ], "details": "Create src/mcpd/src/services/project.service.ts with DI accepting IProjectRepository and IMcpProfileRepository.", "status": "pending", - "testStrategy": "TDD - write tests before implementation. Verify authorization and profile validation." + "testStrategy": "TDD - write tests before implementation. Verify authorization and profile validation.", + "parentId": "undefined" }, { "id": 4, @@ -326,7 +349,8 @@ ], "details": "Create src/mcpd/src/routes/projects.ts with all CRUD routes and mcp-config endpoint.", "status": "pending", - "testStrategy": "Integration tests using Fastify.inject(). Verify mcp-config returns valid structure WITHOUT secret env vars." + "testStrategy": "Integration tests using Fastify.inject(). Verify mcp-config returns valid structure WITHOUT secret env vars.", + "parentId": "undefined" }, { "id": 5, @@ -337,12 +361,13 @@ ], "details": "Create src/mcpd/tests/integration/projects.test.ts with end-to-end scenarios and SECURITY_REVIEW.md section.", "status": "pending", - "testStrategy": "Run full integration test suite. Verify coverage >85% for project-related files." + "testStrategy": "Run full integration test suite. Verify coverage >85% for project-related files.", + "parentId": "undefined" } ] }, { - "id": 6, + "id": "6", "title": "Implement Docker Container Management for MCP Servers", "description": "Create the container orchestration layer for running MCP servers as Docker containers, with support for docker-compose deployment.", "details": "Create Docker management module with ContainerManager class using dockerode. Create deploy/docker-compose.yml template.", @@ -361,7 +386,8 @@ "dependencies": [], "details": "Create src/mcpd/src/services/orchestrator.ts interface and TDD tests for ContainerManager methods.", "status": "pending", - "testStrategy": "Run tests to verify they exist and fail with expected errors. Coverage target: 100% of interface methods." + "testStrategy": "Run tests to verify they exist and fail with expected errors. Coverage target: 100% of interface methods.", + "parentId": "undefined" }, { "id": 2, @@ -372,7 +398,8 @@ ], "details": "Create src/mcpd/src/services/docker/container-manager.ts implementing McpOrchestrator interface.", "status": "pending", - "testStrategy": "Run unit tests from subtask 1. Verify TypeScript compilation and resource limits." + "testStrategy": "Run unit tests from subtask 1. Verify TypeScript compilation and resource limits.", + "parentId": "undefined" }, { "id": 3, @@ -381,7 +408,8 @@ "dependencies": [], "details": "Create deploy/docker-compose.yml with mcpd, postgres, and test-mcp-server services with proper networking.", "status": "pending", - "testStrategy": "Validate with docker-compose config. Run docker-compose up -d and verify all services start." + "testStrategy": "Validate with docker-compose config. Run docker-compose up -d and verify all services start.", + "parentId": "undefined" }, { "id": 4, @@ -393,7 +421,8 @@ ], "details": "Create src/mcpd/src/services/docker/__tests__/container-manager.integration.test.ts.", "status": "pending", - "testStrategy": "Run integration tests with pnpm --filter @mcpctl/mcpd test:integration. Verify containers are created/destroyed." + "testStrategy": "Run integration tests with pnpm --filter @mcpctl/mcpd test:integration. Verify containers are created/destroyed.", + "parentId": "undefined" }, { "id": 5, @@ -404,7 +433,8 @@ ], "details": "Create src/mcpd/src/services/docker/network-manager.ts with network isolation and resource management.", "status": "pending", - "testStrategy": "Unit tests for network creation. Integration test: verify container network isolation." + "testStrategy": "Unit tests for network creation. Integration test: verify container network isolation.", + "parentId": "undefined" }, { "id": 6, @@ -417,7 +447,8 @@ ], "details": "Create src/mcpd/docs/DOCKER_SECURITY_REVIEW.md documenting risks and mitigations.", "status": "pending", - "testStrategy": "Review DOCKER_SECURITY_REVIEW.md covers all 6 security areas. Run security unit tests." + "testStrategy": "Review DOCKER_SECURITY_REVIEW.md covers all 6 security areas. Run security unit tests.", + "parentId": "undefined" }, { "id": 7, @@ -428,12 +459,13 @@ ], "details": "Extend ContainerManager with getLogs, getHealthStatus, attachToContainer, and event subscriptions.", "status": "pending", - "testStrategy": "Unit tests for getLogs. Integration test: run container, tail logs, verify output." + "testStrategy": "Unit tests for getLogs. Integration test: run container, tail logs, verify output.", + "parentId": "undefined" } ] }, { - "id": 7, + "id": "7", "title": "Build mcpctl CLI Core Framework", "description": "Create the CLI tool foundation using Commander.js with kubectl-inspired command structure, configuration management, and server communication.", "details": "Create CLI in src/cli/src/ with Commander.js, configuration management at ~/.mcpctl/config.json, and API client for mcpd.", @@ -451,7 +483,8 @@ "dependencies": [], "details": "Create src/cli/src/ with commands/, config/, client/, formatters/, utils/, types/ directories and registry pattern.", "status": "pending", - "testStrategy": "TDD approach - write tests first. Tests verify CLI shows version, help, CommandRegistry works." + "testStrategy": "TDD approach - write tests first. Tests verify CLI shows version, help, CommandRegistry works.", + "parentId": "undefined" }, { "id": 2, @@ -462,12 +495,13 @@ ], "details": "Implement config management with proxy settings, custom CA certificates support, and Zod validation.", "status": "pending", - "testStrategy": "TDD tests for config loading, saving, validation, and credential encryption." + "testStrategy": "TDD tests for config loading, saving, validation, and credential encryption.", + "parentId": "undefined" } ] }, { - "id": 8, + "id": "8", "title": "Implement mcpctl get and describe Commands", "description": "Create kubectl-style get and describe commands for viewing MCP servers, profiles, projects, and instances.", "details": "Implement get command with table/json/yaml output formats and describe command for detailed views.", @@ -477,10 +511,10 @@ "7" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 9, + "id": "9", "title": "Implement mcpctl apply and setup Commands", "description": "Create apply command for declarative configuration and setup wizard for interactive MCP server configuration.", "details": "Implement apply command for YAML/JSON config files and interactive setup wizard with credential prompts.", @@ -491,10 +525,10 @@ "4" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 10, + "id": "10", "title": "Implement mcpctl claude and project Commands", "description": "Create commands for managing Claude MCP configuration and project assignments.", "details": "Implement claude command for managing .mcp.json files and project command for project management.", @@ -505,10 +539,10 @@ "5" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 11, + "id": "11", "title": "Design Local LLM Proxy Architecture", "description": "Design the architecture for the local LLM proxy that enables Claude to use MCP servers through a local intermediary.", "details": "Design proxy architecture in src/local-proxy/ with MCP protocol handling and request/response transformation.", @@ -518,10 +552,10 @@ "1" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 12, + "id": "12", "title": "Implement Local LLM Proxy Core", "description": "Build the core local proxy server that handles MCP protocol communication between Claude and MCP servers.", "details": "Implement proxy server in src/local-proxy/src/ with MCP SDK integration and request routing.", @@ -531,10 +565,10 @@ "11" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 13, + "id": "13", "title": "Implement LLM Provider Strategy Pattern", "description": "Create pluggable LLM provider support with strategy pattern for different providers (OpenAI, Anthropic, local models).", "details": "Implement provider strategy pattern in src/local-proxy/src/providers/ with adapters for different LLM APIs.", @@ -544,10 +578,10 @@ "12" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 14, + "id": "14", "title": "Implement Audit Logging and Compliance", "description": "Create comprehensive audit logging system for tracking all MCP operations for compliance and debugging.", "details": "Implement audit logging in src/mcpd/src/services/ with structured logging, retention policies, and query APIs.", @@ -557,10 +591,10 @@ "3" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 15, + "id": "15", "title": "Create MCP Profiles Library", "description": "Build a library of pre-configured MCP profiles for common use cases with best practices baked in.", "details": "Create profile library in src/shared/src/profiles/ with templates for common MCP server configurations.", @@ -570,10 +604,10 @@ "4" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 16, + "id": "16", "title": "Implement MCP Instance Lifecycle Management", "description": "Create APIs and CLI commands for managing the full lifecycle of MCP server instances.", "details": "Implement instance lifecycle management in src/mcpd/src/services/ with start, stop, restart, logs commands.", @@ -583,10 +617,10 @@ "6" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 17, + "id": "17", "title": "Add Kubernetes Deployment Support", "description": "Extend the orchestration layer to support Kubernetes deployments for production environments.", "details": "Implement KubernetesOrchestrator in src/mcpd/src/services/k8s/ implementing McpOrchestrator interface.", @@ -596,10 +630,10 @@ "6" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 18, + "id": "18", "title": "Documentation and Testing", "description": "Create comprehensive documentation and end-to-end test suite for the entire mcpctl system.", "details": "Create documentation in docs/ and e2e tests in tests/e2e/ covering all major workflows.", @@ -612,46 +646,46 @@ "10" ], "status": "pending", - "subtasks": null + "subtasks": [] }, { - "id": 19, + "id": "19", "title": "CANCELLED - Auth middleware", "description": "Merged into Task 3 subtasks", "details": null, "testStrategy": null, - "priority": null, + "priority": "low", "dependencies": [], "status": "cancelled", - "subtasks": null, + "subtasks": [], "updatedAt": "2026-02-21T02:21:03.958Z" }, { - "id": 20, + "id": "20", "title": "CANCELLED - Duplicate project management", "description": "Merged into Task 5", "details": null, "testStrategy": null, - "priority": null, + "priority": "low", "dependencies": [], "status": "cancelled", - "subtasks": null, + "subtasks": [], "updatedAt": "2026-02-21T02:21:03.966Z" }, { - "id": 21, + "id": "21", "title": "CANCELLED - Duplicate audit logging", "description": "Merged into Task 14", "details": null, "testStrategy": null, - "priority": null, + "priority": "low", "dependencies": [], "status": "cancelled", - "subtasks": null, + "subtasks": [], "updatedAt": "2026-02-21T02:21:03.972Z" }, { - "id": 22, + "id": "22", "title": "Implement Health Monitoring Dashboard", "description": "Create a monitoring dashboard for tracking MCP server health, resource usage, and system metrics.", "details": "Implement health monitoring endpoints in src/mcpd/src/routes/ and optional web dashboard.", @@ -661,11 +695,12 @@ "6", "14" ], - "status": "pending", - "subtasks": null + "status": "cancelled", + "subtasks": [], + "updatedAt": "2026-02-21T03:23:02.572Z" }, { - "id": 23, + "id": "23", "title": "Implement Backup and Restore", "description": "Create backup and restore functionality for mcpctl configuration and state.", "details": "Implement git-based backup in src/mcpd/src/services/backup/ with encrypted secrets and restore capability.", @@ -675,11 +710,12 @@ "2", "5" ], - "status": "pending", - "subtasks": null + "status": "cancelled", + "subtasks": [], + "updatedAt": "2026-02-21T03:23:02.575Z" }, { - "id": 24, + "id": "24", "title": "CI/CD Pipeline Setup", "description": "Set up continuous integration and deployment pipelines for the mcpctl project.", "details": "Create GitHub Actions workflows in .github/workflows/ for testing, building, and releasing.", @@ -688,13 +724,52 @@ "dependencies": [ "1" ], + "status": "cancelled", + "subtasks": [], + "updatedAt": "2026-02-21T03:23:02.583Z" + }, + { + "id": 25, + "title": "Complete MCP Registry Client with Proxy, Metrics Exposure, and HTTP/CA Support", + "description": "Finalize the registry client implementation by adding HTTP proxy support, custom CA certificates for enterprise environments, and exposing SRE metrics via a dedicated metrics interface. The core client with strategy pattern, caching, deduplication, and ranking is already implemented.", + "details": "The registry client foundation already exists in src/cli/src/registry/ with:\n- RegistryClient class with search(), caching, metrics tracking\n- OfficialRegistrySource, GlamaRegistrySource, SmitheryRegistrySource\n- Deduplication by npm package/repo URL\n- Ranking by relevance, popularity, verified status\n- Zod validation of API responses\n- sanitizeString() for XSS prevention\n\nRemaining implementation:\n\n1. **HTTP Proxy & Custom CA Support** (src/cli/src/registry/http-agent.ts):\n```typescript\nimport { Agent } from 'undici';\nimport { ProxyAgent } from 'undici';\nimport fs from 'node:fs';\n\nexport function createHttpAgent(config: {\n httpProxy?: string;\n httpsProxy?: string;\n caPath?: string;\n}): Agent | ProxyAgent | undefined {\n const proxy = config.httpsProxy ?? config.httpProxy;\n if (proxy) {\n const ca = config.caPath ? fs.readFileSync(config.caPath) : undefined;\n return new ProxyAgent({ uri: proxy, connect: { ca } });\n }\n if (config.caPath) {\n const ca = fs.readFileSync(config.caPath);\n return new Agent({ connect: { ca } });\n }\n return undefined;\n}\n```\n\n2. **Update fetch calls** in each source to accept dispatcher option:\n```typescript\n// In retry.ts or each source\nconst agent = createHttpAgent(config);\nconst response = await fetch(url, { dispatcher: agent });\n```\n\n3. **Metrics Exposure Interface** (src/cli/src/registry/metrics.ts):\n```typescript\nexport interface RegistryMetrics {\n queryLatencyMs: { source: string; latencies: number[] }[];\n cacheHitRatio: number;\n cacheHits: number;\n cacheMisses: number;\n errorCounts: { source: string; count: number }[];\n}\n\nexport function collectMetrics(client: RegistryClient): RegistryMetrics {\n const cacheMetrics = client.getCacheMetrics();\n return {\n queryLatencyMs: Array.from(client.getQueryLatencies().entries())\n .map(([source, latencies]) => ({ source, latencies })),\n cacheHitRatio: cacheMetrics.ratio,\n cacheHits: cacheMetrics.hits,\n cacheMisses: cacheMetrics.misses,\n errorCounts: Array.from(client.getErrorCounts().entries())\n .map(([source, count]) => ({ source, count })),\n };\n}\n```\n\n4. **Update RegistryClientConfig** to include caPath:\n```typescript\nexport interface RegistryClientConfig {\n registries?: RegistryName[];\n cacheTTLMs?: number;\n smitheryApiKey?: string;\n httpProxy?: string;\n httpsProxy?: string;\n caPath?: string; // ADD THIS\n}\n```\n\n5. **Add data platform category filter** - update SearchOptions:\n```typescript\ncategory?: 'devops' | 'data-platform' | 'analytics' | 'communication' | 'development' | string;\n```\n\nTDD approach:\n- Write tests for createHttpAgent() with proxy, CA, and combined configs\n- Write tests for metrics collection interface\n- Write tests for category filtering in search results\n- All tests should be written BEFORE implementation", + "testStrategy": "1. Unit tests for http-agent.ts: verify ProxyAgent created with correct proxy URI, verify custom CA loaded from file path, verify combined proxy+CA configuration\n2. Unit tests for metrics.ts: verify collectMetrics() returns correct structure, verify latency arrays are captured per-source\n3. Integration test: mock HTTP server with self-signed cert, verify client connects with custom CA\n4. Test category filtering returns only servers matching category\n5. Run existing test suite to ensure no regressions: pnpm --filter @mcpctl/cli test", + "priority": "high", + "dependencies": [], "status": "pending", - "subtasks": null + "subtasks": [] + }, + { + "id": 26, + "title": "Implement mcpctl discover Command with Interactive Mode", + "description": "Create the `mcpctl discover` CLI command that lets users search for MCP servers across all configured registries with rich filtering, table/JSON/YAML output, and an interactive browsing mode using inquirer.", + "details": "Create src/cli/src/commands/discover.ts with Commander.js:\n\n```typescript\nimport { Command } from 'commander';\nimport { RegistryClient, type SearchOptions, type RegistryServer } from '../registry/index.js';\nimport { getConfig } from '../config/index.js';\nimport inquirer from 'inquirer';\nimport chalk from 'chalk';\n\nexport function createDiscoverCommand(): Command {\n return new Command('discover')\n .description('Search for MCP servers across registries')\n .argument('', 'Search query (e.g., \"slack\", \"database\", \"terraform\")')\n .option('-c, --category ', 'Filter by category (devops, data-platform, analytics)')\n .option('-v, --verified', 'Only show verified servers')\n .option('-t, --transport ', 'Filter by transport (stdio, sse)', undefined)\n .option('-r, --registry ', 'Query specific registry (official, glama, smithery, all)', 'all')\n .option('-l, --limit ', 'Maximum results', '20')\n .option('-o, --output ', 'Output format (table, json, yaml)', 'table')\n .option('-i, --interactive', 'Interactive browsing mode')\n .action(async (query, options) => {\n const config = await getConfig();\n const client = new RegistryClient({\n smitheryApiKey: config.smitheryApiKey,\n httpProxy: config.httpProxy,\n httpsProxy: config.httpsProxy,\n caPath: config.caPath,\n });\n\n const searchOpts: SearchOptions = {\n query,\n limit: parseInt(options.limit, 10),\n verified: options.verified,\n transport: options.transport,\n category: options.category,\n registries: options.registry === 'all' \n ? undefined \n : [options.registry],\n };\n\n const results = await client.search(searchOpts);\n\n if (results.length === 0) {\n console.log('No servers found matching your query.');\n process.exitCode = 2;\n return;\n }\n\n if (options.interactive) {\n await runInteractiveMode(results);\n } else {\n outputResults(results, options.output);\n }\n });\n}\n\nfunction outputResults(results: RegistryServer[], format: string): void {\n switch (format) {\n case 'json':\n console.log(JSON.stringify(results, null, 2));\n break;\n case 'yaml':\n // Use yaml library\n import('yaml').then(yaml => console.log(yaml.stringify(results)));\n break;\n default:\n printTable(results);\n }\n}\n\nfunction printTable(results: RegistryServer[]): void {\n console.log('NAME'.padEnd(30) + 'DESCRIPTION'.padEnd(50) + 'PACKAGE'.padEnd(35) + 'TRANSPORT VERIFIED POPULARITY');\n console.log('-'.repeat(140));\n for (const s of results) {\n const pkg = s.packages.npm ?? s.packages.pypi ?? s.packages.docker ?? '-';\n const verified = s.verified ? chalk.green('✓') : '-';\n console.log(\n s.name.slice(0, 28).padEnd(30) +\n s.description.slice(0, 48).padEnd(50) +\n pkg.slice(0, 33).padEnd(35) +\n s.transport.padEnd(11) +\n verified.padEnd(10) +\n String(s.popularityScore)\n );\n }\n console.log(`\\nRun 'mcpctl install ' to set up a server.`);\n}\n\nasync function runInteractiveMode(results: RegistryServer[]): Promise {\n const { selected } = await inquirer.prompt([{\n type: 'list',\n name: 'selected',\n message: 'Select an MCP server to install:',\n choices: results.map(s => ({\n name: `${s.name} - ${s.description.slice(0, 60)}`,\n value: s,\n })),\n }]);\n\n const { action } = await inquirer.prompt([{\n type: 'list',\n name: 'action',\n message: `What would you like to do with ${selected.name}?`,\n choices: [\n { name: 'Install and configure', value: 'install' },\n { name: 'View details', value: 'details' },\n { name: 'Cancel', value: 'cancel' },\n ],\n }]);\n\n if (action === 'install') {\n // Invoke install command programmatically\n const { execSync } = await import('node:child_process');\n execSync(`mcpctl install ${selected.name}`, { stdio: 'inherit' });\n } else if (action === 'details') {\n console.log(JSON.stringify(selected, null, 2));\n }\n}\n```\n\nRegister command in src/cli/src/commands/index.ts.\n\nExit codes for scripting:\n- 0: Results found\n- 1: Error occurred\n- 2: No results found\n\nTDD: Write all tests BEFORE implementation:\n- Test command parsing with all options\n- Test table output formatting\n- Test JSON/YAML output\n- Test exit codes\n- Mock inquirer for interactive mode tests", + "testStrategy": "1. Unit tests (src/cli/tests/commands/discover.test.ts):\n - Test argument parsing: verify query is required, options have defaults\n - Test table output: mock RegistryClient, verify correct columns printed\n - Test JSON output: verify valid JSON with all fields\n - Test YAML output: verify valid YAML structure\n - Test --verified filter is passed to client\n - Test --registry parses correctly\n2. Integration tests:\n - Mock registry sources, run full discover command, verify output\n - Test exit code 2 when no results\n - Test exit code 1 on network error\n3. Interactive mode tests:\n - Mock inquirer responses, verify correct server selected\n - Verify install command invoked with correct name\n4. Run: pnpm --filter @mcpctl/cli test", + "priority": "high", + "dependencies": [ + 25 + ], + "status": "pending", + "subtasks": [] + }, + { + "id": 27, + "title": "Implement mcpctl install with LLM-Assisted Auto-Configuration", + "description": "Create the `mcpctl install ` command that uses a local LLM (Claude Code session, Ollama, or configured provider) to automatically analyze MCP server READMEs, generate envTemplate and setup guides, walk users through configuration, and register the server in mcpd.", + "details": "Create src/cli/src/commands/install.ts:\n\n```typescript\nimport { Command } from 'commander';\nimport { RegistryClient, type RegistryServer, type EnvVar } from '../registry/index.js';\nimport { getConfig } from '../config/index.js';\nimport { z } from 'zod';\nimport inquirer from 'inquirer';\n\n// Zod schema for validating LLM-generated envTemplate\nconst LLMEnvVarSchema = z.object({\n name: z.string().min(1),\n description: z.string(),\n isSecret: z.boolean(),\n setupUrl: z.string().url().optional(),\n defaultValue: z.string().optional(),\n});\n\nconst LLMConfigResponseSchema = z.object({\n envTemplate: z.array(LLMEnvVarSchema),\n setupGuide: z.array(z.string()),\n defaultProfiles: z.array(z.object({\n name: z.string(),\n permissions: z.array(z.string()),\n })).optional().default([]),\n});\n\nexport type LLMConfigResponse = z.infer;\n\nexport function createInstallCommand(): Command {\n return new Command('install')\n .description('Install and configure an MCP server')\n .argument('', 'Server name(s) from discover results')\n .option('--non-interactive', 'Use env vars for credentials (no prompts)')\n .option('--profile-name ', 'Name for the created profile')\n .option('--project ', 'Add to existing project after install')\n .option('--dry-run', 'Show configuration without applying')\n .option('--skip-llm', 'Skip LLM analysis, use registry metadata only')\n .action(async (servers, options) => {\n for (const serverName of servers) {\n await installServer(serverName, options);\n }\n });\n}\n\nasync function installServer(serverName: string, options: {\n nonInteractive?: boolean;\n profileName?: string;\n project?: string;\n dryRun?: boolean;\n skipLlm?: boolean;\n}): Promise {\n const config = await getConfig();\n const client = new RegistryClient(config);\n\n // Step 1: Fetch server metadata from registry\n console.log(`Searching for ${serverName}...`);\n const results = await client.search({ query: serverName, limit: 10 });\n const server = results.find(s => \n s.name.toLowerCase() === serverName.toLowerCase() ||\n s.packages.npm?.includes(serverName)\n );\n\n if (!server) {\n console.error(`Server \"${serverName}\" not found. Run 'mcpctl discover ${serverName}' to search.`);\n process.exitCode = 1;\n return;\n }\n\n console.log(`Found: ${server.name} (${server.packages.npm ?? server.packages.docker ?? 'N/A'})`);\n\n // Step 2: Determine envTemplate\n let envTemplate: EnvVar[] = server.envTemplate;\n let setupGuide: string[] = [];\n\n // Step 3: If envTemplate incomplete and LLM not skipped, use LLM\n if (envTemplate.length === 0 && !options.skipLlm && server.repositoryUrl) {\n console.log('Registry metadata incomplete. Analyzing README with LLM...');\n const llmConfig = await analyzeWithLLM(server.repositoryUrl, config);\n if (llmConfig) {\n envTemplate = llmConfig.envTemplate;\n setupGuide = llmConfig.setupGuide;\n }\n }\n\n // Step 4: Show setup guide if available\n if (setupGuide.length > 0) {\n console.log('\\n📋 Setup Guide:');\n setupGuide.forEach((step, i) => console.log(` ${i + 1}. ${step}`));\n console.log('');\n }\n\n if (options.dryRun) {\n console.log('Dry run - would configure:');\n console.log(JSON.stringify({ server, envTemplate }, null, 2));\n return;\n }\n\n // Step 5: Collect credentials\n const credentials: Record = {};\n if (!options.nonInteractive) {\n for (const env of envTemplate) {\n const { value } = await inquirer.prompt([{\n type: env.isSecret ? 'password' : 'input',\n name: 'value',\n message: `${env.name}${env.description ? ` (${env.description})` : ''}:`,\n default: env.defaultValue,\n }]);\n credentials[env.name] = value;\n }\n } else {\n // Use environment variables\n for (const env of envTemplate) {\n credentials[env.name] = process.env[env.name] ?? env.defaultValue ?? '';\n }\n }\n\n // Step 6: Register with mcpd (mock for now until mcpd integration)\n console.log(`\\nRegistering ${server.name} with mcpd...`);\n // TODO: POST to mcpd /api/mcp-servers when mcpd is implemented\n // For now, write to local config\n await saveServerConfig(server, credentials, options.profileName ?? server.name);\n\n // Step 7: Add to project if specified\n if (options.project) {\n console.log(`Adding to project: ${options.project}`);\n // TODO: Call mcpd project API\n }\n\n console.log(`\\n✅ ${server.name} installed successfully!`);\n console.log(`Run 'mcpctl get servers' to see installed servers.`);\n}\n\nasync function analyzeWithLLM(repoUrl: string, config: any): Promise {\n try {\n // Fetch README from GitHub\n const readmeUrl = convertToRawReadmeUrl(repoUrl);\n const response = await fetch(readmeUrl);\n if (!response.ok) {\n console.warn('Could not fetch README.');\n return null;\n }\n const readme = await response.text();\n\n // Sanitize README - prevent prompt injection\n const sanitizedReadme = sanitizeReadme(readme);\n\n // Use configured LLM provider (Ollama, OpenAI, etc. from Task 12)\n // For Claude Code integration, output prompt for user to paste\n const prompt = buildLLMPrompt(sanitizedReadme);\n \n // TODO: Integrate with actual LLM provider from Task 12\n // For now, attempt Ollama if configured\n const llmResponse = await callLLM(prompt, config);\n \n // Parse and validate response\n const parsed = JSON.parse(llmResponse);\n return LLMConfigResponseSchema.parse(parsed);\n } catch (error) {\n console.warn('LLM analysis failed, using registry metadata only.');\n return null;\n }\n}\n\nfunction buildLLMPrompt(readme: string): string {\n return `Analyze this MCP server README and extract configuration requirements.\n\nRETURN ONLY VALID JSON matching this schema:\n{\n \"envTemplate\": [{ \"name\": string, \"description\": string, \"isSecret\": boolean, \"setupUrl\"?: string }],\n \"setupGuide\": [\"Step 1...\", \"Step 2...\"],\n \"defaultProfiles\": [{ \"name\": string, \"permissions\": string[] }]\n}\n\nREADME content (trusted, from official repository):\n${readme.slice(0, 8000)}\n\nJSON output:`;\n}\n\nfunction sanitizeReadme(readme: string): string {\n // Remove potential prompt injection patterns\n return readme\n .replace(/ignore.*instructions/gi, '')\n .replace(/disregard.*above/gi, '')\n .replace(/system.*prompt/gi, '');\n}\n\nfunction convertToRawReadmeUrl(repoUrl: string): string {\n // Convert GitHub repo URL to raw README URL\n const match = repoUrl.match(/github\\.com\\/([^/]+)\\/([^/]+)/);\n if (match) {\n return `https://raw.githubusercontent.com/${match[1]}/${match[2]}/main/README.md`;\n }\n return repoUrl;\n}\n\nasync function callLLM(prompt: string, config: any): Promise {\n // Try Ollama first if available\n if (config.ollamaUrl) {\n const response = await fetch(`${config.ollamaUrl}/api/generate`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n model: config.ollamaModel ?? 'llama3',\n prompt,\n stream: false,\n }),\n });\n const data = await response.json();\n return data.response;\n }\n throw new Error('No LLM provider configured. Set OLLAMA_URL or use --skip-llm.');\n}\n\nasync function saveServerConfig(server: RegistryServer, credentials: Record, profileName: string): Promise {\n // Save to ~/.mcpctl/servers/.json\n const fs = await import('node:fs/promises');\n const path = await import('node:path');\n const os = await import('node:os');\n \n const configDir = path.join(os.homedir(), '.mcpctl', 'servers');\n await fs.mkdir(configDir, { recursive: true });\n \n await fs.writeFile(\n path.join(configDir, `${profileName}.json`),\n JSON.stringify({ server, credentials, createdAt: new Date().toISOString() }, null, 2)\n );\n}\n```\n\nSecurity considerations:\n- sanitizeReadme() removes prompt injection patterns\n- LLM responses validated against Zod schema before use\n- Never auto-execute commands suggested by LLM\n- Credentials stored in separate secure config (encrypted via Task 7.2)\n\nTDD: Write comprehensive tests BEFORE implementation.", + "testStrategy": "1. Unit tests (src/cli/tests/commands/install.test.ts):\n - Test server lookup from registry results\n - Test LLMConfigResponseSchema validates correct JSON\n - Test LLMConfigResponseSchema rejects invalid JSON\n - Test sanitizeReadme() removes injection patterns\n - Test buildLLMPrompt() generates valid prompt structure\n - Test convertToRawReadmeUrl() for various GitHub URL formats\n - Test --dry-run outputs config without saving\n - Test --non-interactive uses env vars\n - Test batch install: multiple servers processed sequentially\n2. Security tests:\n - Test sanitizeReadme blocks 'ignore all instructions'\n - Test LLM response with extra fields is safely parsed\n - Test credentials are not logged\n3. Integration tests:\n - Mock registry client and LLM endpoint\n - Full install flow with mocked dependencies\n - Verify server config file created with correct structure\n4. Run: pnpm --filter @mcpctl/cli test", + "priority": "high", + "dependencies": [ + 25, + 26 + ], + "status": "pending", + "subtasks": [] } ], "metadata": { - "created": "2026-02-21T02:23:17.813Z", - "updated": "2026-02-21T02:23:17.813Z", + "created": "2026-02-21T03:25:05.784Z", + "updated": "2026-02-21T03:25:05.784Z", "description": "Tasks for master context" } } diff --git a/package.json b/package.json index ae88e8f..7f17b6d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "packageManager": "pnpm@9.15.0", "devDependencies": { + "@types/node": "^25.3.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", "@vitest/coverage-v8": "^4.0.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c46283..253e672 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@types/node': + specifier: ^25.3.0 + version: 25.3.0 '@typescript-eslint/eslint-plugin': specifier: ^8.56.0 version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) @@ -16,7 +19,7 @@ importers: version: 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0)) + version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)) eslint: specifier: ^10.0.1 version: 10.0.1(jiti@2.6.1) @@ -34,7 +37,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(jiti@2.6.1)(tsx@4.21.0) + version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0) src/cli: dependencies: @@ -52,10 +55,13 @@ importers: version: 13.1.0 inquirer: specifier: ^12.0.0 - version: 12.11.1 + version: 12.11.1(@types/node@25.3.0) js-yaml: specifier: ^4.1.0 version: 4.1.1 + zod: + specifier: ^3.24.0 + version: 3.25.76 src/db: dependencies: @@ -701,6 +707,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.3.0': + resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@typescript-eslint/eslint-plugin@8.56.0': resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1795,6 +1804,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -2098,100 +2110,128 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2': + '@inquirer/checkbox@4.3.2(@types/node@25.3.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2 + '@inquirer/core': 10.3.2(@types/node@25.3.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10 + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/confirm@5.1.21': + '@inquirer/confirm@5.1.21(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/core@10.3.2': + '@inquirer/core@10.3.2(@types/node@25.3.0)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10 + '@inquirer/type': 3.0.10(@types/node@25.3.0) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/editor@4.2.23': + '@inquirer/editor@4.2.23(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 - '@inquirer/external-editor': 1.0.3 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/external-editor': 1.0.3(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/expand@4.0.23': + '@inquirer/expand@4.0.23(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/external-editor@1.0.3': + '@inquirer/external-editor@1.0.3(@types/node@25.3.0)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 25.3.0 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1': + '@inquirer/input@4.3.1(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/number@3.0.23': + '@inquirer/number@3.0.23(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/password@4.0.23': + '@inquirer/password@4.0.23(@types/node@25.3.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/prompts@7.10.1': + '@inquirer/prompts@7.10.1(@types/node@25.3.0)': dependencies: - '@inquirer/checkbox': 4.3.2 - '@inquirer/confirm': 5.1.21 - '@inquirer/editor': 4.2.23 - '@inquirer/expand': 4.0.23 - '@inquirer/input': 4.3.1 - '@inquirer/number': 3.0.23 - '@inquirer/password': 4.0.23 - '@inquirer/rawlist': 4.1.11 - '@inquirer/search': 3.2.2 - '@inquirer/select': 4.4.2 + '@inquirer/checkbox': 4.3.2(@types/node@25.3.0) + '@inquirer/confirm': 5.1.21(@types/node@25.3.0) + '@inquirer/editor': 4.2.23(@types/node@25.3.0) + '@inquirer/expand': 4.0.23(@types/node@25.3.0) + '@inquirer/input': 4.3.1(@types/node@25.3.0) + '@inquirer/number': 3.0.23(@types/node@25.3.0) + '@inquirer/password': 4.0.23(@types/node@25.3.0) + '@inquirer/rawlist': 4.1.11(@types/node@25.3.0) + '@inquirer/search': 3.2.2(@types/node@25.3.0) + '@inquirer/select': 4.4.2(@types/node@25.3.0) + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/rawlist@4.1.11': + '@inquirer/rawlist@4.1.11(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/search@3.2.2': + '@inquirer/search@3.2.2(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2 + '@inquirer/core': 10.3.2(@types/node@25.3.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10 + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/select@4.4.2': + '@inquirer/select@4.4.2(@types/node@25.3.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2 + '@inquirer/core': 10.3.2(@types/node@25.3.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10 + '@inquirer/type': 3.0.10(@types/node@25.3.0) yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.3.0 - '@inquirer/type@3.0.10': {} + '@inquirer/type@3.0.10(@types/node@25.3.0)': + optionalDependencies: + '@types/node': 25.3.0 '@jridgewell/resolve-uri@3.1.2': {} @@ -2353,6 +2393,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@25.3.0': + dependencies: + undici-types: 7.18.2 + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2444,7 +2488,7 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.1 - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -2456,7 +2500,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(jiti@2.6.1)(tsx@4.21.0) + vitest: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0) '@vitest/expect@4.0.18': dependencies: @@ -2467,13 +2511,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(jiti@2.6.1)(tsx@4.21.0))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(jiti@2.6.1)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0) '@vitest/pretty-format@4.0.18': dependencies: @@ -3033,15 +3077,17 @@ snapshots: inherits@2.0.4: {} - inquirer@12.11.1: + inquirer@12.11.1(@types/node@25.3.0): dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2 - '@inquirer/prompts': 7.10.1 - '@inquirer/type': 3.0.10 + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/prompts': 7.10.1(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) mute-stream: 2.0.0 run-async: 4.0.6 rxjs: 7.8.2 + optionalDependencies: + '@types/node': 25.3.0 ip-address@10.0.1: {} @@ -3532,6 +3578,8 @@ snapshots: typescript@5.9.3: {} + undici-types@7.18.2: {} + unpipe@1.0.0: {} uri-js@4.4.1: @@ -3540,7 +3588,7 @@ snapshots: vary@1.1.2: {} - vite@7.3.1(jiti@2.6.1)(tsx@4.21.0): + vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -3549,14 +3597,15 @@ snapshots: rollup: 4.58.0 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 25.3.0 fsevents: 2.3.3 jiti: 2.6.1 tsx: 4.21.0 - vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0): + vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(jiti@2.6.1)(tsx@4.21.0)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -3573,8 +3622,10 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(jiti@2.6.1)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.3.0 transitivePeerDependencies: - jiti - less diff --git a/src/cli/package.json b/src/cli/package.json index f860d28..0726d48 100644 --- a/src/cli/package.json +++ b/src/cli/package.json @@ -16,11 +16,12 @@ "test:run": "vitest run" }, "dependencies": { - "commander": "^13.0.0", + "@mcpctl/db": "workspace:*", + "@mcpctl/shared": "workspace:*", "chalk": "^5.4.0", + "commander": "^13.0.0", "inquirer": "^12.0.0", "js-yaml": "^4.1.0", - "@mcpctl/shared": "workspace:*", - "@mcpctl/db": "workspace:*" + "zod": "^3.24.0" } } diff --git a/src/cli/src/registry/client.ts b/src/cli/src/registry/client.ts new file mode 100644 index 0000000..32feb06 --- /dev/null +++ b/src/cli/src/registry/client.ts @@ -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; + private cache: RegistryCache; + private enabledRegistries: RegistryName[]; + private metrics = { + queryLatencies: new Map(), + errorCounts: new Map(), + }; + + constructor(config: RegistryClientConfig = {}) { + this.enabledRegistries = config.registries ?? ['official', 'glama', 'smithery']; + this.cache = new RegistryCache(config.cacheTTLMs); + + this.sources = new Map([ + ['official', new OfficialRegistrySource()], + ['glama', new GlamaRegistrySource()], + ['smithery', new SmitheryRegistrySource()], + ]); + } + + async search(options: SearchOptions): Promise { + // 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 { + return new Map(this.metrics.queryLatencies); + } + + getErrorCounts(): Map { + 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); + } +} diff --git a/src/cli/src/registry/index.ts b/src/cli/src/registry/index.ts new file mode 100644 index 0000000..ffaf929 --- /dev/null +++ b/src/cli/src/registry/index.ts @@ -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'; diff --git a/src/cli/src/registry/ranking.ts b/src/cli/src/registry/ranking.ts new file mode 100644 index 0000000..d05b461 --- /dev/null +++ b/src/cli/src/registry/ranking.ts @@ -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)); +} diff --git a/src/cli/src/registry/retry.ts b/src/cli/src/registry/retry.ts new file mode 100644 index 0000000..98c4386 --- /dev/null +++ b/src/cli/src/registry/retry.ts @@ -0,0 +1,16 @@ +export async function withRetry( + fn: () => Promise, + maxRetries = 3, + baseDelay = 1000, +): Promise { + 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'); +} diff --git a/src/cli/src/registry/sources/glama.ts b/src/cli/src/registry/sources/glama.ts new file mode 100644 index 0000000..f2d1796 --- /dev/null +++ b/src/cli/src/registry/sources/glama.ts @@ -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 { + 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; + } +} diff --git a/src/cli/src/registry/sources/official.ts b/src/cli/src/registry/sources/official.ts new file mode 100644 index 0000000..3771ea1 --- /dev/null +++ b/src/cli/src/registry/sources/official.ts @@ -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 { + 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> | 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; + } +} diff --git a/src/cli/src/registry/sources/smithery.ts b/src/cli/src/registry/sources/smithery.ts new file mode 100644 index 0000000..b5cf563 --- /dev/null +++ b/src/cli/src/registry/sources/smithery.ts @@ -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 { + 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; + } +} diff --git a/src/cli/src/registry/types.ts b/src/cli/src/registry/types.ts index 952d371..3155e12 100644 --- a/src/cli/src/registry/types.ts +++ b/src/cli/src/registry/types.ts @@ -173,7 +173,7 @@ export type SmitheryServerEntry = z.infer; // ── Security utilities ── -const ANSI_ESCAPE_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F]|\x1b\[[0-9;]*[a-zA-Z]|\033\[[0-9;]*[a-zA-Z]/g; +const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*[a-zA-Z]|[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F]|\x1b/g; export function sanitizeString(text: string): string { return text.replace(ANSI_ESCAPE_RE, ''); diff --git a/src/cli/tests/registry/cache.test.ts b/src/cli/tests/registry/cache.test.ts new file mode 100644 index 0000000..811b8db --- /dev/null +++ b/src/cli/tests/registry/cache.test.ts @@ -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); + }); +}); diff --git a/src/cli/tests/registry/client.test.ts b/src/cli/tests/registry/client.test.ts new file mode 100644 index 0000000..5fdbdcd --- /dev/null +++ b/src/cli/tests/registry/client.test.ts @@ -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(); + }); +}); diff --git a/src/cli/tests/registry/dedup.test.ts b/src/cli/tests/registry/dedup.test.ts new file mode 100644 index 0000000..1314879 --- /dev/null +++ b/src/cli/tests/registry/dedup.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/cli/tests/registry/ranking.test.ts b/src/cli/tests/registry/ranking.test.ts new file mode 100644 index 0000000..c0ab7d2 --- /dev/null +++ b/src/cli/tests/registry/ranking.test.ts @@ -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 { + 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.49.1