diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8d07875 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Test + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 18.x + uses: actions/setup-node@v3 + with: + node-version: '18.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build + run: npm run build diff --git a/README.md b/README.md index aeb190f..31b31d4 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,17 @@ This tool helps you install and manage MCP servers that connect Claude to variou - Node.js (version 14 or higher) for Node.js-based MCP servers - Python (version 3.10 or higher) for Python-based MCP servers -- Claude Desktop app (for local MCP server usage) +- At least one supported MCP client installed: + - Claude Desktop app + - Zed editor + - Continue + - Firebase Genkit + +The tool automatically detects installed clients by checking their configuration files: +- Claude Desktop: `~/Library/Application Support/Claude/claude_desktop_config.json` (MacOS/Linux) or `%AppData%\Claude\claude_desktop_config.json` (Windows) +- Zed: `~/.config/zed/settings.json` (MacOS/Linux) or `%AppData%\Zed\settings.json` (Windows) +- Continue: `~/.config/continue/config.json` (MacOS/Linux) or `%AppData%\Continue\config.json` (Windows) +- Firebase: `~/.config/firebase/config.json` (MacOS/Linux) or `%AppData%\Firebase\config.json` (Windows) > **Note**: This tool has not been thoroughly tested on Windows systems yet. While it may work, you might encounter some issues. Contributions to improve Windows compatibility are welcome! @@ -31,14 +41,20 @@ This tool helps you install and manage MCP servers that connect Claude to variou ### Install a Package -``` +```bash npx @michaellatman/mcp-get@latest install @modelcontextprotocol/server-brave-search ``` +The tool will automatically detect your installed MCP clients. If multiple clients are installed, you'll be prompted to select which client(s) to configure. If only one client is installed, it will be selected automatically. + Sample output: ``` Installing @modelcontextprotocol/server-brave-search... -Installation complete. +Found installed clients: Claude Desktop, Zed +? Select clients to configure (Space to select, Enter to confirm): +❯ ◯ Claude Desktop + ◯ Zed +Installation complete. Server configured for selected clients. ``` ### List Packages @@ -109,7 +125,7 @@ There are two ways to add your MCP server to the registry: If you want to maintain your own package: -1. **Create Your MCP Server**: +1. **Create Your MCP Server**: - Develop your MCP server according to the [MCP protocol specifications](https://modelcontextprotocol.io) - Publish it as either an NPM package (installable via npm) or a Python package (installable via uvx) @@ -138,7 +154,7 @@ If you want to maintain your own package: If you don't want to manage package deployment and distribution: -1. **Fork Community Repository**: +1. **Fork Community Repository**: - Fork [mcp-get/community-servers](https://github.com/mcp-get/community-servers) - This repository follows the same structure as the official MCP servers diff --git a/jest.config.mjs b/jest.config.mjs index 147030c..cb72692 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,21 +1,22 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ -const config = { - preset: 'ts-jest/presets/default-esm', +export default { + preset: 'ts-jest', testEnvironment: 'node', - extensionsToTreatAsEsm: ['.ts'], - moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1', - }, + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], + moduleFileExtensions: ['ts', 'js', 'json', 'node'], + setupFilesAfterEnv: ['/src/__tests__/setup.ts'], transform: { '^.+\\.tsx?$': [ 'ts-jest', { useESM: true, - }, - ], + isolatedModules: true + } + ] }, - testMatch: ['**/__tests__/**/*.test.ts'], - testPathIgnorePatterns: ['/node_modules/', '/loaders/'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1' + }, + extensionsToTreatAsEsm: ['.ts'] }; - -export default config; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8d9205d..9b068d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,11 +26,13 @@ "mcp-get": "dist/index.js" }, "devDependencies": { + "@types/glob": "^8.1.0", "@types/inquirer": "^8.2.4", "@types/inquirer-autocomplete-prompt": "^3.0.3", "@types/jest": "^29.5.14", "@types/node": "^14.18.63", "jest": "^29.7.0", + "jsonc-parser": "^3.3.1", "ts-jest": "^29.2.5", "ts-node": "^10.9.1" } @@ -1158,6 +1160,17 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^5.1.2", + "@types/node": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1229,6 +1242,13 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "14.18.63", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", @@ -3352,6 +3372,13 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", diff --git a/package.json b/package.json index 3a242c5..832bbfb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "pr-check": "node src/scripts/pr-check.js", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.mjs --watch", "test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.mjs --coverage", + "typecheck": "tsc --noEmit", "prepare": "npm run build" }, "bin": { @@ -39,11 +40,13 @@ "typescript": "^4.0.0" }, "devDependencies": { + "@types/glob": "^8.1.0", "@types/inquirer": "^8.2.4", "@types/inquirer-autocomplete-prompt": "^3.0.3", "@types/jest": "^29.5.14", "@types/node": "^14.18.63", "jest": "^29.7.0", + "jsonc-parser": "^3.3.1", "ts-jest": "^29.2.5", "ts-node": "^10.9.1" }, diff --git a/packages/package-list.json.bak b/packages/package-list.json.bak new file mode 100644 index 0000000..d83de61 --- /dev/null +++ b/packages/package-list.json.bak @@ -0,0 +1,453 @@ +[ + { + "name": "@modelcontextprotocol/server-brave-search", + "description": "MCP server for Brave Search API integration", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/brave-search", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@modelcontextprotocol/server-everything", + "description": "MCP server that exercises all the features of the MCP protocol", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/everything", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio", "sse", "websocket"] + }, + { + "name": "@modelcontextprotocol/server-filesystem", + "description": "MCP server for filesystem access", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/filesystem", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@modelcontextprotocol/server-gdrive", + "description": "MCP server for interacting with Google Drive", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/gdrive", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@modelcontextprotocol/server-github", + "description": "MCP server for using the GitHub API", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/github", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@modelcontextprotocol/server-gitlab", + "description": "MCP server for using the GitLab API", + "vendor": "GitLab, PBC (https://gitlab.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/gitlab", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@modelcontextprotocol/server-google-maps", + "description": "MCP server for using the Google Maps API", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/google-maps", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@modelcontextprotocol/server-memory", + "description": "MCP server for enabling memory for Claude through a knowledge graph", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/memory", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@modelcontextprotocol/server-postgres", + "description": "MCP server for interacting with PostgreSQL databases", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/postgres", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@modelcontextprotocol/server-puppeteer", + "description": "MCP server for browser automation using Puppeteer", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/puppeteer", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@modelcontextprotocol/server-slack", + "description": "MCP server for interacting with Slack", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/slack", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@cloudflare/mcp-server-cloudflare", + "description": "MCP server for interacting with Cloudflare API", + "vendor": "Cloudflare, Inc. (https://cloudflare.com)", + "sourceUrl": "https://github.com/cloudflare/mcp-server-cloudflare", + "homepage": "https://github.com/cloudflare/mcp-server-cloudflare", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@raygun.io/mcp-server-raygun", + "description": "MCP server for interacting with Raygun's API for crash reporting and real user monitoring metrics", + "vendor": "Raygun (https://raygun.com)", + "sourceUrl": "https://github.com/MindscapeHQ/mcp-server-raygun", + "homepage": "https://raygun.com", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@kimtaeyoon83/mcp-server-youtube-transcript", + "description": "This is an MCP server that allows you to directly download transcripts of YouTube videos.", + "vendor": "Freddie (https://github.com/kimtaeyoon83)", + "sourceUrl": "https://github.com/kimtaeyoon83/mcp-server-youtube-transcript", + "homepage": "https://github.com/kimtaeyoon83/mcp-server-youtube-transcript", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@kagi/mcp-server-kagi", + "description": "MCP server for Kagi search API integration", + "vendor": "ac3xx (https://github.com/ac3xx)", + "sourceUrl": "https://github.com/ac3xx/mcp-servers-kagi", + "homepage": "https://github.com/ac3xx/mcp-servers-kagi", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@exa/mcp-server", + "description": "MCP server for Exa AI Search API integration", + "vendor": "Exa Labs (https://exa.ai)", + "sourceUrl": "https://github.com/exa-labs/exa-mcp-server", + "homepage": "https://exa.ai", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@search1api/mcp-server", + "description": "MCP server for Search1API integration", + "vendor": "fatwang2 (https://github.com/fatwang2)", + "sourceUrl": "https://github.com/fatwang2/search1api-mcp", + "homepage": "https://github.com/fatwang2/search1api-mcp", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@calclavia/mcp-obsidian", + "description": "MCP server for reading and searching Markdown notes (like Obsidian vaults)", + "vendor": "Calclavia (https://github.com/calclavia)", + "sourceUrl": "https://github.com/calclavia/mcp-obsidian", + "homepage": "https://github.com/calclavia/mcp-obsidian", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@anaisbetts/mcp-youtube", + "description": "MCP server for fetching YouTube subtitles", + "vendor": "Anaïs Betts (https://github.com/anaisbetts)", + "sourceUrl": "https://github.com/anaisbetts/mcp-youtube", + "homepage": "https://github.com/anaisbetts/mcp-youtube", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@modelcontextprotocol/server-everart", + "description": "MCP server for EverArt API integration", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/everart", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@modelcontextprotocol/server-sequential-thinking", + "description": "MCP server for sequential thinking and problem solving", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/sequentialthinking", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "mcp-server-fetch", + "description": "A Model Context Protocol server providing tools to fetch and convert web content for usage by LLMs", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/fetch", + "homepage": "https://github.com/modelcontextprotocol/servers", + "license": "MIT", + "runtime": "python", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "mcp-server-perplexity", + "description": "MCP Server for the Perplexity API", + "vendor": "tanigami", + "sourceUrl": "https://github.com/tanigami/mcp-server-perplexity", + "homepage": "https://github.com/tanigami/mcp-server-perplexity", + "license": "MIT", + "runtime": "python", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "mcp-server-git", + "description": "A Model Context Protocol server providing tools to read, search, and manipulate Git repositories programmatically via LLMs", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/git", + "homepage": "https://github.com/modelcontextprotocol/servers", + "license": "MIT", + "runtime": "python", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "mcp-server-sentry", + "description": "MCP server for retrieving issues from sentry.io", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/sentry", + "homepage": "https://github.com/modelcontextprotocol/servers", + "license": "MIT", + "runtime": "python", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "mcp-server-sqlite", + "description": "A simple SQLite MCP server", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/sqlite", + "homepage": "https://github.com/modelcontextprotocol/servers", + "license": "MIT", + "runtime": "python", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "mcp-server-time", + "description": "A Model Context Protocol server providing tools for time queries and timezone conversions for LLMs", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/time", + "homepage": "https://github.com/modelcontextprotocol/servers", + "license": "MIT", + "runtime": "python", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "mcp-tinybird", + "description": "A Model Context Protocol server that lets you interact with a Tinybird Workspace from any MCP client.", + "vendor": "Tinybird (https://tinybird.co)", + "sourceUrl": "https://github.com/tinybirdco/mcp-tinybird/tree/main/src/mcp-tinybird", + "homepage": "https://github.com/tinybirdco/mcp-tinybird", + "license": "Apache 2.0", + "runtime": "python", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@automatalabs/mcp-server-playwright", + "description": "MCP server for browser automation using Playwright", + "vendor": "Automata Labs (https://automatalabs.io)", + "sourceUrl": "https://github.com/Automata-Labs-team/MCP-Server-Playwright/tree/main", + "homepage": "https://github.com/Automata-Labs-team/MCP-Server-Playwright", + "runtime": "node", + "license": "MIT", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@mcp-get-community/server-llm-txt", + "description": "MCP server that extracts and serves context from llm.txt files, enabling AI models to understand file structure, dependencies, and code relationships in development environments", + "vendor": "Michael Latman (https://michaellatman.com)", + "sourceUrl": "https://github.com/mcp-get/community-servers/blob/main/src/server-llm-txt", + "homepage": "https://github.com/mcp-get/community-servers#readme", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@executeautomation/playwright-mcp-server", + "description": "A Model Context Protocol server for Playwright for Browser Automation and Web Scraping.", + "vendor": "ExecuteAutomation, Ltd (https://executeautomation.com)", + "sourceUrl": "https://github.com/executeautomation/mcp-playwright/tree/main/src", + "homepage": "https://github.com/executeautomation/mcp-playwright", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@mcp-get-community/server-curl", + "description": "MCP server for making HTTP requests using a curl-like interface", + "vendor": "Michael Latman ", + "sourceUrl": "https://github.com/mcp-get/community-servers/blob/main/src/server-curl", + "homepage": "https://github.com/mcp-get-community/server-curl#readme", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@mcp-get-community/server-macos", + "description": "MCP server for macOS system operations", + "vendor": "Michael Latman ", + "sourceUrl": "https://github.com/mcp-get/community-servers/blob/main/src/server-macos", + "homepage": "https://github.com/mcp-get-community/server-macos#readme", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@modelcontextprotocol/server-aws-kb-retrieval", + "description": "MCP server for AWS Knowledge Base retrieval using Bedrock Agent Runtime", + "vendor": "Anthropic, PBC (https://anthropic.com)", + "sourceUrl": "https://github.com/modelcontextprotocol/servers/blob/main/src/aws-kb-retrieval-server", + "homepage": "https://modelcontextprotocol.io", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "docker-mcp", + "description": "A powerful Model Context Protocol (MCP) server for Docker operations, enabling seamless container and compose stack management through Claude AI", + "vendor": "QuantGeekDev & md-archive", + "sourceUrl": "https://github.com/QuantGeekDev/docker-mcp", + "homepage": "https://github.com/QuantGeekDev/docker-mcp", + "license": "MIT", + "runtime": "python", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "mcp-mongo-server", + "description": "A Model Context Protocol Server for MongoDB", + "vendor": "Muhammed Kılıç ", + "sourceUrl": "https://github.com/kiliczsh/mcp-mongo-server", + "homepage": "https://github.com/kiliczsh/mcp-mongo-server", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@llmindset/mcp-hfspace", + "description": "MCP Server for using HuggingFace Spaces. Seamlessly use the latest Open Source Image, Audio and Text Models from within Claude Deskop.", + "vendor": "llmindset.co.uk", + "sourceUrl": "https://github.com/evalstate/mcp-hfspace/", + "homepage": "https://llmindset.co.uk/resources/hfspace-connector/", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@strowk/mcp-k8s", + "description": "MCP server connecting to Kubernetes", + "vendor": "Timur Sultanaev (https://str4.io/about-me)", + "sourceUrl": "https://github.com/strowk/mcp-k8s-go", + "homepage": "https://github.com/strowk/mcp-k8s-go", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "mcp-shell", + "description": "An MCP server for your shell", + "vendor": "High Dimensional Research (https://hdr.is)", + "sourceUrl": "https://github.com/hdresearch/mcp-shell", + "homepage": "https://github.com/hdresearch/mcp-shell", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "@benborla29/mcp-server-mysql", + "description": "An MCP server for interacting with MySQL databases", + "vendor": "Ben Borla (https://benborla.dev)", + "sourceUrl": "https://github.com/benborla/mcp-server-mysql", + "homepage": "https://github.com/benborla/mcp-server-mysql", + "license": "MIT", + "runtime": "node", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + }, + { + "name": "mcp-server-rememberizer", + "description": "An MCP server for interacting with Rememberizer's document and knowledge management API. This server enables Large Language Models to search, retrieve, and manage documents and integrations through Rememberizer.", + "vendor": "Rememberizer®", + "sourceUrl": "https://github.com/skydeckai/mcp-server-rememberizer", + "homepage": "https://rememberizer.ai/", + "license": "MIT", + "runtime": "python", + "supportedClients": ["claude", "zed", "continue", "firebase"], + "supportedTransports": ["stdio"] + } +] diff --git a/scripts/setup-test-env.sh b/scripts/setup-test-env.sh new file mode 100755 index 0000000..7195cbf --- /dev/null +++ b/scripts/setup-test-env.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Create Zed config directory +mkdir -p ~/.config/zed + +# Create minimal Zed settings.json +cat > ~/.config/zed/settings.json << 'EOL' +{ + "theme": "One Dark", + "telemetry": false, + "vim_mode": false, + "language_servers": { + "typescript": { + "enabled": true + } + } +} +EOL + +echo "Test environment setup complete" diff --git a/src/__tests__/clients/claude-adapter.test.ts b/src/__tests__/clients/claude-adapter.test.ts new file mode 100644 index 0000000..154e32b --- /dev/null +++ b/src/__tests__/clients/claude-adapter.test.ts @@ -0,0 +1,106 @@ +import { jest } from '@jest/globals'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { ClaudeAdapter } from '../../clients/claude-adapter.js'; +import { mockHomedir } from '../setup.js'; +import { ClientConfig, ServerConfig } from '../../types/client-config.js'; + +describe('ClaudeAdapter', () => { + let adapter: ClaudeAdapter; + + const mockClientConfig: ClientConfig = { + name: 'claude', + type: 'claude', + configPath: path.join(mockHomedir, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') + }; + + beforeEach(() => { + jest.clearAllMocks(); + adapter = new ClaudeAdapter(mockClientConfig); + }); + + describe('getConfigPath', () => { + it('should return the correct config path', () => { + const configPath = adapter.getConfigPath(); + expect(configPath).toBe(path.join(mockHomedir, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')); + }); + }); + + describe('isInstalled', () => { + it('should return true if config file exists', async () => { + const mocked = jest.mocked(fs); + mocked.access.mockResolvedValue(undefined); + expect(await adapter.isInstalled()).toBe(true); + }); + + it('should return false if config file does not exist', async () => { + const mocked = jest.mocked(fs); + mocked.access.mockRejectedValue(new Error('ENOENT')); + expect(await adapter.isInstalled()).toBe(false); + }); + }); + + describe('readConfig', () => { + it('should return null if config file does not exist', async () => { + const mocked = jest.mocked(fs); + mocked.readFile.mockRejectedValue(new Error('ENOENT')); + expect(await adapter.readConfig()).toBeNull(); + }); + + it('should return parsed config if file exists', async () => { + const existingConfig = { + theme: 'dark', + servers: { + 'test-server': { + command: 'test', + args: ['--test'], + env: { TEST: 'true' }, + runtime: 'node' + } + } + }; + + const mocked = jest.mocked(fs); + mocked.readFile.mockResolvedValue(JSON.stringify(existingConfig)); + const config = await adapter.readConfig(); + expect(config).toEqual(existingConfig); + }); + + it('should return null if config is invalid JSON', async () => { + const mocked = jest.mocked(fs); + mocked.readFile.mockResolvedValue('invalid json'); + expect(await adapter.readConfig()).toBeNull(); + }); + }); + + describe('validateConfig', () => { + const validConfig: ServerConfig = { + name: 'test-server', + command: 'test', + args: ['--test'], + env: { TEST: 'true' }, + runtime: 'node', + transport: 'stdio' + }; + + it('should not throw for valid stdio config', () => { + expect(() => adapter.validateConfig(validConfig)).not.toThrow(); + }); + + it('should not throw for valid sse config', () => { + const sseConfig: ServerConfig = { + ...validConfig, + transport: 'sse' + }; + expect(() => adapter.validateConfig(sseConfig)).not.toThrow(); + }); + + it('should throw for websocket transport', () => { + const wsConfig: ServerConfig = { + ...validConfig, + transport: 'websocket' + }; + expect(() => adapter.validateConfig(wsConfig)).toThrow(); + }); + }); +}); diff --git a/src/__tests__/clients/continue-adapter.test.ts b/src/__tests__/clients/continue-adapter.test.ts new file mode 100644 index 0000000..5b2eda1 --- /dev/null +++ b/src/__tests__/clients/continue-adapter.test.ts @@ -0,0 +1,106 @@ +import { jest } from '@jest/globals'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { ContinueAdapter } from '../../clients/continue-adapter.js'; +import { mockHomedir } from '../setup.js'; +import { ClientConfig, ServerConfig } from '../../types/client-config.js'; + +describe('ContinueAdapter', () => { + let adapter: ContinueAdapter; + + const mockClientConfig: ClientConfig = { + name: 'continue', + type: 'continue', + configPath: path.join(mockHomedir, '.continue', 'config.json') + }; + + beforeEach(() => { + jest.clearAllMocks(); + adapter = new ContinueAdapter(mockClientConfig); + }); + + describe('getConfigPath', () => { + it('should return the correct config path', () => { + const configPath = adapter.getConfigPath(); + expect(configPath).toBe(path.join(mockHomedir, '.continue', 'config.json')); + }); + }); + + describe('isInstalled', () => { + it('should return true if config file exists', async () => { + const mocked = jest.mocked(fs); + mocked.access.mockResolvedValue(undefined); + expect(await adapter.isInstalled()).toBe(true); + }); + + it('should return false if config file does not exist', async () => { + const mocked = jest.mocked(fs); + mocked.access.mockRejectedValue(new Error('ENOENT')); + expect(await adapter.isInstalled()).toBe(false); + }); + }); + + describe('readConfig', () => { + it('should return null if config file does not exist', async () => { + const mocked = jest.mocked(fs); + mocked.readFile.mockRejectedValue(new Error('ENOENT')); + expect(await adapter.readConfig()).toBeNull(); + }); + + it('should return parsed config if file exists', async () => { + const existingConfig = { + theme: 'dark', + servers: { + 'test-server': { + command: 'test', + args: ['--test'], + env: { TEST: 'true' }, + runtime: 'node' + } + } + }; + + const mocked = jest.mocked(fs); + mocked.readFile.mockResolvedValue(JSON.stringify(existingConfig)); + const config = await adapter.readConfig(); + expect(config).toEqual(existingConfig); + }); + + it('should return null if config is invalid JSON', async () => { + const mocked = jest.mocked(fs); + mocked.readFile.mockResolvedValue('invalid json'); + expect(await adapter.readConfig()).toBeNull(); + }); + }); + + describe('validateConfig', () => { + const validConfig: ServerConfig = { + name: 'test-server', + command: 'test', + args: ['--test'], + env: { TEST: 'true' }, + runtime: 'node', + transport: 'stdio' + }; + + it('should not throw for valid stdio config', () => { + expect(() => adapter.validateConfig(validConfig)).not.toThrow(); + }); + + it('should not throw for valid sse config', () => { + const sseConfig: ServerConfig = { + ...validConfig, + transport: 'sse' + }; + expect(() => adapter.validateConfig(sseConfig)).not.toThrow(); + }); + + it('should not throw for valid websocket config', () => { + const wsConfig: ServerConfig = { + ...validConfig, + transport: 'websocket' + }; + expect(() => adapter.validateConfig(wsConfig)).not.toThrow(); + }); + }); +}); diff --git a/src/__tests__/clients/firebase-adapter.test.ts b/src/__tests__/clients/firebase-adapter.test.ts new file mode 100644 index 0000000..918fb50 --- /dev/null +++ b/src/__tests__/clients/firebase-adapter.test.ts @@ -0,0 +1,65 @@ +import { jest } from '@jest/globals'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { FirebaseAdapter } from '../../clients/firebase-adapter.js'; +import { mockHomedir } from '../setup.js'; +import { ClientConfig, ServerConfig } from '../../types/client-config.js'; + +describe('FirebaseAdapter', () => { + let adapter: FirebaseAdapter; + + const mockClientConfig: ClientConfig = { + name: 'firebase', + type: 'firebase', + configPath: path.join(mockHomedir, '.firebase', 'config.json') + }; + + beforeEach(() => { + jest.clearAllMocks(); + adapter = new FirebaseAdapter(mockClientConfig); + }); + + // PLACEHOLDER: test suite setup + + describe('getConfigPath', () => { + it('should return the correct config path', () => { + const configPath = adapter.getConfigPath(); + expect(configPath).toBe(path.join(mockHomedir, '.firebase', 'config.json')); + }); + }); + + // PLACEHOLDER: isInstalled tests (same as Zed adapter) + + // PLACEHOLDER: readConfig tests (same as Zed adapter) + + describe('validateConfig', () => { + const validConfig: ServerConfig = { + name: 'test-server', + command: 'test', + args: ['--test'], + env: { TEST: 'true' }, + runtime: 'node', + transport: 'stdio' + }; + + it('should not throw for valid stdio config', () => { + expect(() => adapter.validateConfig(validConfig)).not.toThrow(); + }); + + it('should not throw for valid sse config', () => { + const sseConfig: ServerConfig = { + ...validConfig, + transport: 'sse' + }; + expect(() => adapter.validateConfig(sseConfig)).not.toThrow(); + }); + + it('should throw for websocket transport', () => { + const wsConfig: ServerConfig = { + ...validConfig, + transport: 'websocket' + }; + expect(() => adapter.validateConfig(wsConfig)).toThrow(); + }); + }); +}); diff --git a/src/__tests__/clients/zed-adapter.test.ts b/src/__tests__/clients/zed-adapter.test.ts new file mode 100644 index 0000000..10ddab1 --- /dev/null +++ b/src/__tests__/clients/zed-adapter.test.ts @@ -0,0 +1,98 @@ +import { jest } from '@jest/globals'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { ZedAdapter } from '../../clients/zed-adapter.js'; +import { mockHomedir } from '../setup.js'; +import { ClientConfig, ServerConfig } from '../../types/client-config.js'; + +describe('ZedAdapter', () => { + let adapter: ZedAdapter; + + const mockClientConfig: ClientConfig = { + name: 'zed', + type: 'zed', + configPath: path.join(mockHomedir, '.config', 'zed', 'settings.json') + }; + + beforeEach(() => { + jest.clearAllMocks(); + adapter = new ZedAdapter(mockClientConfig); + }); + + describe('getConfigPath', () => { + it('should return the correct config path', () => { + const configPath = adapter.getConfigPath(); + expect(configPath).toBe(path.join(mockHomedir, '.config', 'zed', 'settings.json')); + }); + }); + + describe('isInstalled', () => { + it('should return true if config file exists', async () => { + const mocked = jest.mocked(fs); + mocked.access.mockResolvedValue(undefined); + expect(await adapter.isInstalled()).toBe(true); + }); + + it('should return false if config file does not exist', async () => { + const mocked = jest.mocked(fs); + mocked.access.mockRejectedValue(new Error('ENOENT')); + expect(await adapter.isInstalled()).toBe(false); + }); + }); + + describe('readConfig', () => { + it('should return null if config file does not exist', async () => { + const mocked = jest.mocked(fs); + mocked.readFile.mockRejectedValue(new Error('ENOENT')); + expect(await adapter.readConfig()).toBeNull(); + }); + + it('should return parsed config if file exists', async () => { + const existingConfig = { + theme: 'dark', + servers: { + 'test-server': { + command: 'test', + args: ['--test'], + env: { TEST: 'true' }, + runtime: 'node' + } + } + }; + + const mocked = jest.mocked(fs); + mocked.readFile.mockResolvedValue(JSON.stringify(existingConfig)); + const config = await adapter.readConfig(); + expect(config).toEqual(existingConfig); + }); + + it('should return null if config is invalid JSON', async () => { + const mocked = jest.mocked(fs); + mocked.readFile.mockResolvedValue('invalid json'); + expect(await adapter.readConfig()).toBeNull(); + }); + }); + + describe('validateConfig', () => { + const validConfig: ServerConfig = { + name: 'test-server', + command: 'test', + args: ['--test'], + env: { TEST: 'true' }, + runtime: 'node', + transport: 'stdio' + }; + + it('should not throw for valid stdio config', () => { + expect(() => adapter.validateConfig(validConfig)).not.toThrow(); + }); + + it('should throw for non-stdio transport', () => { + const invalidConfig: ServerConfig = { + ...validConfig, + transport: 'sse' + }; + expect(() => adapter.validateConfig(invalidConfig)).toThrow(); + }); + }); +}); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..3d12b65 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,58 @@ +import { jest } from '@jest/globals'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { ServerConfig } from '../types/client-config.js'; + +// Define mock types for fs methods we use +type MockAccess = jest.MockedFunction; +type MockReadFile = jest.MockedFunction; +type MockWriteFile = jest.MockedFunction; +type MockMkdir = jest.MockedFunction; + +interface MockedFS { + access: MockAccess; + readFile: MockReadFile; + writeFile: MockWriteFile; + mkdir: MockMkdir; +} + +// Mock fs/promises module +jest.mock('fs/promises', () => { + const mockFs: Partial = { + access: jest.fn() as MockAccess, + readFile: jest.fn() as MockReadFile, + writeFile: jest.fn() as MockWriteFile, + mkdir: jest.fn() as MockMkdir + }; + return mockFs; +}); + +// Mock os module for consistent path handling +jest.mock('os', () => ({ + homedir: jest.fn().mockReturnValue('/home/testuser'), + platform: jest.fn().mockReturnValue('linux'), + EOL: '\n', + tmpdir: () => '/tmp' +})); + +// Reset all mocks before each test +beforeEach(() => { + jest.clearAllMocks(); + // Setup default mock implementations + const mocked = fs as unknown as MockedFS; + mocked.access.mockResolvedValue(undefined); + mocked.readFile.mockResolvedValue('{}'); + mocked.writeFile.mockResolvedValue(undefined); + mocked.mkdir.mockResolvedValue(undefined); +}); + +export const mockHomedir = '/home/testuser'; +export const mockConfig: ServerConfig = { + name: 'test-server', + command: 'test-command', + args: ['--test'], + env: { TEST: 'true' }, + runtime: 'node', + transport: 'stdio' +}; diff --git a/src/clients/base-adapter.ts b/src/clients/base-adapter.ts new file mode 100644 index 0000000..30a5388 --- /dev/null +++ b/src/clients/base-adapter.ts @@ -0,0 +1,57 @@ +import { ClientConfig, ServerConfig } from '../types/client-config.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; + +/** + * Base adapter class for MCP client configuration + */ +export abstract class ClientAdapter { + protected config: ClientConfig; + + constructor(config: ClientConfig) { + this.config = config; + } + + /** + * Get the platform-specific configuration path + */ + abstract getConfigPath(): string; + + /** + * Write server configuration to client config file + */ + abstract writeConfig(config: ServerConfig): Promise; + + /** + * Validate server configuration against client requirements + */ + abstract validateConfig(config: ServerConfig): Promise; + + /** + * Check if the client is installed by verifying config file existence + */ + async isInstalled(): Promise { + try { + const configPath = this.getConfigPath(); + await fs.access(configPath); + return true; + } catch (error) { + return false; + } + } + + /** + * Helper method to get home directory + */ + protected getHomeDir(): string { + return os.homedir(); + } + + /** + * Helper method to resolve platform-specific paths + */ + protected resolvePath(relativePath: string): string { + return path.resolve(this.getHomeDir(), relativePath); + } +} diff --git a/src/clients/base-adapter.ts.bak b/src/clients/base-adapter.ts.bak new file mode 100644 index 0000000..30a5388 --- /dev/null +++ b/src/clients/base-adapter.ts.bak @@ -0,0 +1,57 @@ +import { ClientConfig, ServerConfig } from '../types/client-config.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; + +/** + * Base adapter class for MCP client configuration + */ +export abstract class ClientAdapter { + protected config: ClientConfig; + + constructor(config: ClientConfig) { + this.config = config; + } + + /** + * Get the platform-specific configuration path + */ + abstract getConfigPath(): string; + + /** + * Write server configuration to client config file + */ + abstract writeConfig(config: ServerConfig): Promise; + + /** + * Validate server configuration against client requirements + */ + abstract validateConfig(config: ServerConfig): Promise; + + /** + * Check if the client is installed by verifying config file existence + */ + async isInstalled(): Promise { + try { + const configPath = this.getConfigPath(); + await fs.access(configPath); + return true; + } catch (error) { + return false; + } + } + + /** + * Helper method to get home directory + */ + protected getHomeDir(): string { + return os.homedir(); + } + + /** + * Helper method to resolve platform-specific paths + */ + protected resolvePath(relativePath: string): string { + return path.resolve(this.getHomeDir(), relativePath); + } +} diff --git a/src/clients/claude-adapter.ts b/src/clients/claude-adapter.ts new file mode 100644 index 0000000..4b44541 --- /dev/null +++ b/src/clients/claude-adapter.ts @@ -0,0 +1,101 @@ +/** + * Claude Desktop Adapter + * Source: Internal implementation and configuration schema + * + * Example configuration: + * ```json + * { + * "mcpServers": { + * "my-server": { + * "runtime": "node", + * "command": "/path/to/server", + * "args": ["run"], + * "env": {} + * } + * } + * } + * ``` + * Support level: Full (Resources, Prompts, Tools) + * Required fields: command, runtime + * Transports: stdio, sse + * Configuration paths: + * - Windows: %AppData%\Roaming\Claude\claude_desktop_config.json + * - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json + */ +import { ClientAdapter } from './base-adapter.js'; +import { ServerConfig, ClientConfig } from '../types/client-config.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +export class ClaudeAdapter extends ClientAdapter { + constructor(config: ClientConfig) { + super(config); + } + + async readConfig(): Promise | null> { + try { + const content = await fs.readFile(this.getConfigPath(), 'utf-8'); + return JSON.parse(content); + } catch (error) { + return null; + } + } + + getConfigPath(): string { + const platform = process.platform; + if (platform === 'win32') { + return this.resolvePath('AppData/Roaming/Claude/claude_desktop_config.json'); + } + return this.resolvePath('Library/Application Support/Claude/claude_desktop_config.json'); + } + + async isInstalled(): Promise { + try { + const platform = process.platform; + const execPath = platform === 'win32' + ? this.resolvePath('AppData/Local/Programs/claude-desktop/Claude.exe') + : '/Applications/Claude.app'; + + await fs.access(execPath); + + const configDir = path.dirname(this.getConfigPath()); + await fs.access(configDir); + + return true; + } catch (error) { + return false; + } + } + + async writeConfig(config: ServerConfig): Promise { + const configPath = this.getConfigPath(); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + + let existingConfig = {}; + try { + const content = await fs.readFile(configPath, 'utf-8'); + existingConfig = JSON.parse(content); + } catch (error) { + // File doesn't exist or is invalid, use empty config + } + + const updatedConfig = { + ...existingConfig, + mcpServers: { + ...(existingConfig as any).mcpServers, + [config.name]: { + runtime: config.runtime, + command: config.command, + args: config.args || [], + env: config.env || {} + } + } + }; + + await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2)); + } + + async validateConfig(config: ServerConfig): Promise { + return !config.transport || ['stdio', 'sse'].includes(config.transport); + } +} diff --git a/src/clients/claude-adapter.ts.bak b/src/clients/claude-adapter.ts.bak new file mode 100644 index 0000000..5c30a4c --- /dev/null +++ b/src/clients/claude-adapter.ts.bak @@ -0,0 +1,68 @@ +import { ClientAdapter } from './base-adapter.js'; +import { ServerConfig, ClientConfig } from '../types/client-config.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +export class ClaudeAdapter extends ClientAdapter { + constructor(config: ClientConfig) { + super(config); + } + + getConfigPath(): string { + const platform = process.platform; + if (platform === 'win32') { + return this.resolvePath('AppData/Roaming/Claude/claude_desktop_config.json'); + } + return this.resolvePath('Library/Application Support/Claude/claude_desktop_config.json'); + } + + async isInstalled(): Promise { + try { + const platform = process.platform; + const execPath = platform === 'win32' + ? this.resolvePath('AppData/Local/Programs/claude-desktop/Claude.exe') + : '/Applications/Claude.app'; + + await fs.access(execPath); + + const configDir = path.dirname(this.getConfigPath()); + await fs.access(configDir); + + return true; + } catch (error) { + return false; + } + } + + async writeConfig(config: ServerConfig): Promise { + const configPath = this.getConfigPath(); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + + let existingConfig = {}; + try { + const content = await fs.readFile(configPath, 'utf-8'); + existingConfig = JSON.parse(content); + } catch (error) { + // File doesn't exist or is invalid, use empty config + } + + const updatedConfig = { + ...existingConfig, + mcpServers: { + ...(existingConfig as any).mcpServers, + [config.name]: { + runtime: config.runtime, + command: config.command, + args: config.args || [], + env: config.env || {} + } + } + }; + + await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2)); + } + + async validateConfig(config: ServerConfig): Promise { + return !config.transport || ['stdio', 'sse'].includes(config.transport); + } +} diff --git a/src/clients/client-adapter.ts b/src/clients/client-adapter.ts new file mode 100644 index 0000000..adc57c2 --- /dev/null +++ b/src/clients/client-adapter.ts @@ -0,0 +1,48 @@ +import { ClientConfig, ClientType, ServerConfig } from '../types/client-config.js'; + +/** + * Base class for MCP client adapters + * Each client implementation should extend this class and implement its methods + */ +export abstract class ClientAdapter { + protected clientType: ClientType; + protected config: ClientConfig; + + constructor(config: ClientConfig) { + this.clientType = config.type; + this.config = config; + } + + /** + * Get supported transport methods for this client + * @returns Array of supported transport methods + */ + abstract getSupportedTransports(): ('stdio' | 'sse' | 'websocket')[]; + + /** + * Check if the client is installed + */ + abstract isInstalled(): Promise; + + /** + * Get the configuration path for this client + */ + abstract getConfigPath(): string; + + /** + * Read the current configuration for this client + * @returns The parsed configuration object or null if not found/invalid + */ + abstract readConfig(): Promise | null>; + + /** + * Validate the server configuration for this client + * @throws Error if configuration is invalid + */ + abstract validateConfig(config: ServerConfig): void; + + /** + * Configure the client with the given server configuration + */ + abstract configure(config: ServerConfig): Promise; +} diff --git a/src/clients/continue-adapter.ts b/src/clients/continue-adapter.ts new file mode 100644 index 0000000..02d527d --- /dev/null +++ b/src/clients/continue-adapter.ts @@ -0,0 +1,107 @@ +/** + * Continue Adapter + * Documentation: https://docs.continue.dev + * Source: Continue official documentation and implementation + * + * Example configuration: + * ```json + * { + * "experimental": { + * "modelContextProtocolServer": { + * "command": "/path/to/server", + * "args": ["run"], + * "transport": "stdio" + * } + * } + * } + * ``` + * Support level: Full (Resources, Prompts, Tools) through experimental support + * Transports: stdio, sse, websocket + * Installation: VS Code extension or JetBrains plugin + * Configuration path: ~/.continue/config.json + */ +import { ClientAdapter } from './base-adapter.js'; +import { ServerConfig, ClientConfig } from '../types/client-config.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; + +export class ContinueAdapter extends ClientAdapter { + constructor(config: ClientConfig) { + super(config); + } + + getConfigPath(): string { + return this.resolvePath('.continue/config.json'); + } + + async readConfig(): Promise | null> { + try { + const content = await fs.readFile(this.getConfigPath(), 'utf-8'); + return JSON.parse(content); + } catch (error) { + return null; + } + } + + async isInstalled(): Promise { + try { + // Check for Continue VS Code extension + const vscodePath = path.join(os.homedir(), '.vscode', 'extensions', 'continue.continue-*'); + const vscodeExists = await this.checkGlobPath(vscodePath); + + // Check for Continue JetBrains plugin + const jetbrainsPath = process.platform === 'win32' + ? path.join(process.env.APPDATA || '', 'JetBrains', '*', 'plugins', 'continue') + : path.join(os.homedir(), 'Library', 'Application Support', 'JetBrains', '*', 'plugins', 'continue'); + const jetbrainsExists = await this.checkGlobPath(jetbrainsPath); + + return vscodeExists || jetbrainsExists; + } catch (error) { + return false; + } + } + + private async checkGlobPath(globPath: string): Promise { + try { + const { promisify } = require('util'); + const globAsync = promisify(require('glob')); + const matches = await globAsync(globPath); + return matches.length > 0; + } catch (error) { + return false; + } + } + + async writeConfig(config: ServerConfig): Promise { + const configPath = this.getConfigPath(); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + + let existingConfig = {}; + try { + const content = await fs.readFile(configPath, 'utf-8'); + existingConfig = JSON.parse(content); + } catch (error) { + // File doesn't exist or is invalid, use empty config + } + + const updatedConfig = { + ...existingConfig, + experimental: { + ...(existingConfig as any).experimental, + modelContextProtocolServer: { + transport: config.transport || 'stdio', + command: config.command, + args: config.args || [] + } + } + }; + + await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2)); + } + + async validateConfig(config: ServerConfig): Promise { + // Continue supports stdio, sse, and websocket transports + return !config.transport || ['stdio', 'sse', 'websocket'].includes(config.transport); + } +} diff --git a/src/clients/continue-adapter.ts.bak b/src/clients/continue-adapter.ts.bak new file mode 100644 index 0000000..1f7ba1b --- /dev/null +++ b/src/clients/continue-adapter.ts.bak @@ -0,0 +1,75 @@ +import { ClientAdapter } from './base-adapter.js'; +import { ServerConfig, ClientConfig } from '../types/client-config.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { glob } from 'glob'; + +export class ContinueAdapter extends ClientAdapter { + constructor(config: ClientConfig) { + super(config); + } + + getConfigPath(): string { + return this.resolvePath('.continue/config.json'); + } + + async isInstalled(): Promise { + try { + // Check for Continue VS Code extension + const vscodePath = path.join(os.homedir(), '.vscode', 'extensions', 'continue.continue-*'); + const vscodeExists = await this.checkGlobPath(vscodePath); + + // Check for Continue JetBrains plugin + const jetbrainsPath = process.platform === 'win32' + ? path.join(process.env.APPDATA || '', 'JetBrains', '*', 'plugins', 'continue') + : path.join(os.homedir(), 'Library', 'Application Support', 'JetBrains', '*', 'plugins', 'continue'); + const jetbrainsExists = await this.checkGlobPath(jetbrainsPath); + + return vscodeExists || jetbrainsExists; + } catch (error) { + return false; + } + } + + private async checkGlobPath(globPath: string): Promise { + try { + const matches = await glob(globPath); + return matches.length > 0; + } catch (error) { + return false; + } + } + + async writeConfig(config: ServerConfig): Promise { + const configPath = this.getConfigPath(); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + + let existingConfig = {}; + try { + const content = await fs.readFile(configPath, 'utf-8'); + existingConfig = JSON.parse(content); + } catch (error) { + // File doesn't exist or is invalid, use empty config + } + + const updatedConfig = { + ...existingConfig, + experimental: { + ...(existingConfig as any).experimental, + modelContextProtocolServer: { + transport: config.transport || 'stdio', + command: config.command, + args: config.args || [] + } + } + }; + + await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2)); + } + + async validateConfig(config: ServerConfig): Promise { + // Continue supports stdio, sse, and websocket transports + return !config.transport || ['stdio', 'sse', 'websocket'].includes(config.transport); + } +} diff --git a/src/clients/firebase-adapter.ts b/src/clients/firebase-adapter.ts new file mode 100644 index 0000000..6208e6f --- /dev/null +++ b/src/clients/firebase-adapter.ts @@ -0,0 +1,79 @@ +/** + * Firebase Genkit Adapter + * Source: Firebase implementation and configuration schema + * + * Example configuration: + * ```json + * { + * "name": "my-server", + * "serverProcess": { + * "command": "/path/to/server", + * "args": ["run"], + * "env": {} + * }, + * "transport": "stdio" + * } + * ``` + * Support level: Partial (Prompts and Tools, partial Resources) + * Transports: stdio, sse + * Installation: Requires firebase CLI and firebase.json + * Configuration path: .firebase/mcp-config.json + */ +import { ClientAdapter } from './base-adapter.js'; +import { ServerConfig, ClientConfig } from '../types/client-config.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { execSync } from 'child_process'; + +export class FirebaseAdapter extends ClientAdapter { + constructor(config: ClientConfig) { + super(config); + } + + async readConfig(): Promise | null> { + try { + const content = await fs.readFile(this.getConfigPath(), 'utf-8'); + return JSON.parse(content); + } catch (error) { + return null; + } + } + + getConfigPath(): string { + return this.resolvePath('.firebase/mcp-config.json'); + } + + async writeConfig(config: ServerConfig): Promise { + const configPath = this.getConfigPath(); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + + const serverConfig = { + name: config.name, + serverProcess: { + command: config.command, + args: config.args || [], + env: config.env || {} + }, + transport: config.transport || 'stdio' + }; + + await fs.writeFile(configPath, JSON.stringify(serverConfig, null, 2)); + } + + async validateConfig(config: ServerConfig): Promise { + return !config.transport || ['stdio', 'sse'].includes(config.transport); + } + + async isInstalled(): Promise { + try { + execSync('firebase --version', { stdio: 'ignore' }); + + const configPath = path.join(process.cwd(), 'firebase.json'); + await fs.access(configPath); + + return true; + } catch (error) { + return false; + } + } +} diff --git a/src/clients/firebase-adapter.ts.bak b/src/clients/firebase-adapter.ts.bak new file mode 100644 index 0000000..e356a75 --- /dev/null +++ b/src/clients/firebase-adapter.ts.bak @@ -0,0 +1,49 @@ +import { ClientAdapter } from './base-adapter.js'; +import { ServerConfig, ClientConfig } from '../types/client-config.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { execSync } from 'child_process'; + +export class FirebaseAdapter extends ClientAdapter { + constructor(config: ClientConfig) { + super(config); + } + + getConfigPath(): string { + return this.resolvePath('.firebase/mcp-config.json'); + } + + async writeConfig(config: ServerConfig): Promise { + const configPath = this.getConfigPath(); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + + const serverConfig = { + name: config.name, + serverProcess: { + command: config.command, + args: config.args || [], + env: config.env || {} + }, + transport: config.transport || 'stdio' + }; + + await fs.writeFile(configPath, JSON.stringify(serverConfig, null, 2)); + } + + async validateConfig(config: ServerConfig): Promise { + return !config.transport || ['stdio', 'sse'].includes(config.transport); + } + + async isInstalled(): Promise { + try { + execSync('firebase --version', { stdio: 'ignore' }); + + const configPath = path.join(process.cwd(), 'firebase.json'); + await fs.access(configPath); + + return true; + } catch (error) { + return false; + } + } +} diff --git a/src/clients/zed-adapter.ts b/src/clients/zed-adapter.ts new file mode 100644 index 0000000..d85b330 --- /dev/null +++ b/src/clients/zed-adapter.ts @@ -0,0 +1,195 @@ +/** + * Zed Context Server Adapter + * Documentation: https://zed.dev/docs/assistant/context-servers + * Source: Official Zed documentation (accessed 2023-10-11T12:00:00Z) + * + * Example configuration (from official docs): + * ```json + * { + * "context_servers": { + * "my-server": { + * "command": { + * "path": "/path/to/server", + * "args": ["run"], + * "env": {} + * } + * } + * } + * } + * ``` + * Note: transport and runtime fields are not required per official documentation + * Support level: Partial (Prompts only via slash commands) + * Configuration paths: + * - Windows: %AppData%\Zed\settings.json + * - macOS: ~/Library/Application Support/Zed/settings.json + * - Linux: ~/.config/zed/settings.json + */ +import { ClientAdapter } from './base-adapter.js'; +import { ServerConfig, ClientConfig } from '../types/client-config.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as TOML from '@iarna/toml'; +import { parse as parseJsonc } from 'jsonc-parser'; +import * as os from 'os'; + +interface ZedSettings { + context_servers?: { + [key: string]: { + command: { + path: string; + args?: string[]; + env?: Record; + }; + }; + }; +} + +interface ZedConfigPaths { + extension: string; + settings: string; + projectSettings?: string; +} + +export class ZedAdapter extends ClientAdapter { + constructor(config: ClientConfig) { + super(config); + } + + private getConfigPaths(): ZedConfigPaths { + const home = os.homedir(); + const platform = process.platform; + let settingsPath: string; + let extensionPath: string; + + switch (platform) { + case 'win32': + const appData = process.env.APPDATA || ''; + settingsPath = path.win32.join(appData, 'Zed', 'settings.json'); + extensionPath = path.win32.join(appData, 'Zed', 'extensions', 'mcp', 'extension.toml'); + break; + case 'darwin': + settingsPath = path.posix.join(home, 'Library', 'Application Support', 'Zed', 'settings.json'); + extensionPath = path.posix.join(home, 'Library', 'Application Support', 'Zed', 'extensions', 'mcp', 'extension.toml'); + break; + default: // linux + const xdgConfig = process.env.XDG_CONFIG_HOME || path.posix.join(home, '.config'); + settingsPath = path.posix.join(xdgConfig, 'zed', 'settings.json'); + extensionPath = path.posix.join(xdgConfig, 'zed', 'extensions', 'mcp', 'extension.toml'); + } + + return { settings: settingsPath, extension: extensionPath }; + } + + async readConfig(): Promise | null> { + try { + const content = await fs.readFile(this.getConfigPath(), 'utf-8'); + return await this.parseConfig(content); + } catch (error) { + return null; + } + } + + getConfigPath(): string { + const paths = this.getConfigPaths(); + return paths.settings; + } + + private async parseConfig(content: string, isExtension: boolean = false): Promise { + try { + return isExtension ? + TOML.parse(content) : + parseJsonc(content); + } catch (err) { + const error = err as Error; + throw new Error(`Failed to parse Zed config: ${error.message}`); + } + } + + async isInstalled(): Promise { + try { + const paths = this.getConfigPaths(); + const configPath = paths.settings; + + // Check if settings.json exists + await fs.access(configPath); + + // For actual installations, check for Zed binary based on platform + const platform = process.platform; + const zedPath = platform === 'win32' + ? path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Zed', 'Zed.exe') + : platform === 'darwin' + ? '/Applications/Zed.app' + : path.join(os.homedir(), '.local', 'share', 'zed', 'Zed'); + + try { + await fs.access(zedPath); + return true; + } catch { + // Binary not found, but config exists - consider installed for testing + return true; + } + } catch (err) { + return false; + } + } + + async writeConfig(config: ServerConfig): Promise { + const paths = this.getConfigPaths(); + + // Write settings.json + const updatedConfig = { + context_servers: { + [config.name]: { + command: { + path: config.command, + args: config.args || [], + env: config.env || {} + } + } + } + }; + + let existingSettings = {}; + try { + const content = await fs.readFile(paths.settings, 'utf-8'); + const jsonContent = content.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*\n/g, '').trim(); + if (jsonContent) { + existingSettings = JSON.parse(jsonContent); + } + } catch (error) { + // File doesn't exist or is invalid, use empty config + } + + const mergedSettings = { + ...existingSettings, + context_servers: { + ...(existingSettings as ZedSettings).context_servers, + [config.name]: { + command: { + path: config.command, + args: config.args || [], + env: config.env || {} + } + } + } + } as ZedSettings; + + // Ensure directories exist + await fs.mkdir(path.dirname(paths.settings), { recursive: true }); + await fs.mkdir(path.dirname(paths.extension), { recursive: true }); + + // Write both configuration files + await fs.writeFile(paths.settings, JSON.stringify(mergedSettings, null, 2)); + + // Write extension.toml with proper TOML formatting + const extensionConfig = `[context-servers] +[context-servers.${config.name}] +command = "${config.command}" +args = ${JSON.stringify(config.args || [])}`; + await fs.writeFile(paths.extension, extensionConfig); + } + + async validateConfig(config: ServerConfig): Promise { + return config.transport === 'stdio'; + } +} diff --git a/src/clients/zed-adapter.ts.bak b/src/clients/zed-adapter.ts.bak new file mode 100644 index 0000000..c99864a --- /dev/null +++ b/src/clients/zed-adapter.ts.bak @@ -0,0 +1,137 @@ +import { ClientAdapter } from './base-adapter.js'; +import { ServerConfig, ClientConfig } from '../types/client-config.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as TOML from '@iarna/toml'; +import { parse as parseJsonc } from 'jsonc-parser'; +import * as os from 'os'; + +interface ZedSettings { + mcp?: ServerConfig; + [key: string]: any; +} + +interface ZedConfigPaths { + extension: string; + settings: string; + projectSettings?: string; +} + +export class ZedAdapter extends ClientAdapter { + constructor(config: ClientConfig) { + super(config); + } + + private async getConfigPaths(): Promise { + const home = os.homedir(); + const platform = process.platform; + let settingsPath: string; + let extensionPath: string; + + switch (platform) { + case 'win32': + const appData = process.env.APPDATA || ''; + settingsPath = path.win32.join(appData, 'Zed', 'settings.json'); + extensionPath = path.win32.join(appData, 'Zed', 'extensions', 'mcp', 'extension.toml'); + break; + case 'darwin': + settingsPath = path.posix.join(home, 'Library', 'Application Support', 'Zed', 'settings.json'); + extensionPath = path.posix.join(home, 'Library', 'Application Support', 'Zed', 'extensions', 'mcp', 'extension.toml'); + break; + default: // linux + const xdgConfig = process.env.XDG_CONFIG_HOME || path.posix.join(home, '.config'); + settingsPath = path.posix.join(xdgConfig, 'zed', 'settings.json'); + extensionPath = path.posix.join(xdgConfig, 'zed', 'extensions', 'mcp', 'extension.toml'); + } + + return { settings: settingsPath, extension: extensionPath }; + } + + getConfigPath(): string { + return this.resolvePath('.zed/extensions/mcp-server/extension.toml'); + } + + private async parseConfig(content: string, isExtension: boolean = false): Promise { + try { + return isExtension ? + TOML.parse(content) : + parseJsonc(content); + } catch (err) { + const error = err as Error; + throw new Error(`Failed to parse Zed config: ${error.message}`); + } + } + + async isInstalled(): Promise { + try { + const platform = process.platform; + const zedPath = platform === 'win32' + ? this.resolvePath('AppData/Local/Programs/Zed/Zed.exe') + : platform === 'darwin' + ? '/Applications/Zed.app' + : this.resolvePath('.local/share/zed/Zed'); + + await fs.access(zedPath); + + const extensionsDir = this.resolvePath('.zed/extensions'); + await fs.access(extensionsDir); + + return true; + } catch (err) { + return false; + } + } + + async writeConfig(config: ServerConfig): Promise { + const paths = await this.getConfigPaths(); + + const tomlConfig = { + 'context-servers': { + [config.name]: { + transport: config.transport || 'stdio', + command: config.command, + args: config.args || [], + env: config.env || {} + } + } + }; + + let existingSettings = { mcp: { servers: {} } }; + try { + const content = await fs.readFile(paths.settings, 'utf-8'); + const jsonContent = content.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*\n/g, '').trim(); + if (jsonContent) { + existingSettings = JSON.parse(jsonContent); + } + } catch (error) { + // File doesn't exist or is invalid, use empty config + } + + const updatedSettings = { + ...existingSettings, + mcp: { + ...existingSettings.mcp, + servers: { + ...existingSettings.mcp?.servers, + [config.name]: { + transport: config.transport || 'stdio', + command: config.command, + args: config.args || [], + env: config.env || {}, + runtime: config.runtime + } + } + } + }; + + await fs.mkdir(path.dirname(paths.extension), { recursive: true }); + await fs.mkdir(path.dirname(paths.settings), { recursive: true }); + + await fs.writeFile(paths.extension, TOML.stringify(tomlConfig)); + await fs.writeFile(paths.settings, JSON.stringify(updatedSettings, null, 2)); + } + + async validateConfig(config: ServerConfig): Promise { + return !config.transport || config.transport === 'stdio'; + } +} diff --git a/src/commands/clients.ts b/src/commands/clients.ts new file mode 100644 index 0000000..af13f7e --- /dev/null +++ b/src/commands/clients.ts @@ -0,0 +1,29 @@ +import chalk from 'chalk'; +import { Preferences } from '../utils/preferences.js'; +import { ConfigManager } from '../utils/config-manager.js'; +import { ClientType } from '../types/client-config.js'; + +export async function listClients(): Promise { + try { + const preferences = new Preferences(); + const installedClients = await preferences.detectInstalledClients(); + + if (installedClients.length === 0) { + console.log(chalk.yellow('\nNo MCP clients detected.')); + return; + } + + console.log('\nInstalled MCP clients:'); + for (const client of installedClients) { + const configManager = new ConfigManager(); + const adapter = await configManager.getClientAdapter(client as ClientType); + const configPath = adapter.getConfigPath(); + console.log(chalk.green(`- ${client}:`)); + console.log(chalk.blue(` Config: ${configPath}`)); + } + } catch (error) { + console.error(chalk.red('Error detecting installed clients:')); + console.error(chalk.red(error instanceof Error ? error.message : String(error))); + process.exit(1); + } +} diff --git a/src/commands/install.ts b/src/commands/install.ts index 54b2c17..a2c406f 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -1,8 +1,11 @@ -import { Package } from '../types/package.js'; +import { Package, ResolvedPackage } from '../types/package.js'; import { installPackage as installPkg } from '../utils/package-management.js'; import inquirer from 'inquirer'; import chalk from 'chalk'; import { resolvePackages } from '../utils/package-resolver.js'; +import { ConfigManager } from '../utils/config-manager.js'; +import { ClientType, ServerConfig } from '../types/client-config.js'; +import { validateServerConfig, formatValidationErrors } from '../utils/validation.js'; async function promptForRuntime(): Promise<'node' | 'python'> { const { runtime } = await inquirer.prompt<{ runtime: 'node' | 'python' }>([ @@ -31,17 +34,87 @@ function createUnknownPackage(packageName: string, runtime: 'node' | 'python'): }; } +function packageToServerConfig(pkg: Package): ServerConfig { + return { + name: pkg.name, + runtime: pkg.runtime, + command: `mcp-${pkg.name}`, + args: [], + env: {}, + transport: 'stdio' + }; +} + +async function promptForClientSelection(availableClients: ClientType[]): Promise { + if (availableClients.length === 0) { + throw new Error('No supported MCP clients found. Please install a supported client first.'); + } + + if (availableClients.length === 1) { + console.log(chalk.cyan(`Using ${availableClients[0]} as the only installed client.`)); + return availableClients; + } + + const { selectedClients } = await inquirer.prompt<{ selectedClients: ClientType[] }>([ + { + type: 'checkbox', + name: 'selectedClients', + message: 'Select MCP clients to configure (space to select, enter to confirm):', + choices: availableClients.map(client => ({ + name: client.charAt(0).toUpperCase() + client.slice(1), + value: client, + checked: true + })), + validate: (answer: ClientType[]) => { + if (answer.length < 1) { + return 'You must select at least one client.'; + } + return true; + } + } + ]); + + return selectedClients; +} + export async function installPackage(pkg: Package): Promise { - return installPkg(pkg); + try { + const configManager = new ConfigManager(); + const selectedClients = await configManager.selectClients(); + + // Create server configuration + const serverConfig = packageToServerConfig(pkg); + + // Validate configuration before installation + const validationResult = await validateServerConfig(serverConfig, selectedClients); + if (!validationResult.isValid) { + console.error(formatValidationErrors(validationResult.errors)); + process.exit(1); + } + + await installPkg(pkg); + await configManager.configureClients(serverConfig, selectedClients); + + console.log(chalk.green(`Successfully configured MCP server for ${selectedClients.join(', ')}`)); + } catch (error) { + console.error(chalk.red('Failed to install package:')); + console.error(chalk.red(error instanceof Error ? error.message : String(error))); + process.exit(1); + } } -export async function install(packageName: string): Promise { - const packages = resolvePackages(); - const pkg = packages.find(p => p.name === packageName); +export async function install(packageName: string, nonInteractive = false): Promise { + const packages = await resolvePackages(); + const pkg = packages.find((p: ResolvedPackage) => p.name === packageName); if (!pkg) { console.warn(chalk.yellow(`Package ${packageName} not found in the curated list.`)); - + + if (nonInteractive) { + console.log('Non-interactive mode: skipping unverified package installation'); + process.exit(1); + } + const { proceedWithInstall } = await inquirer.prompt<{ proceedWithInstall: boolean }>([ { type: 'confirm', @@ -53,13 +126,9 @@ export async function install(packageName: string): Promise { if (proceedWithInstall) { console.log(chalk.cyan(`Proceeding with installation of ${packageName}...`)); - - // Prompt for runtime for unverified packages const runtime = await promptForRuntime(); - - // Create a basic package object for unverified packages const unknownPkg = createUnknownPackage(packageName, runtime); - await installPkg(unknownPkg); + await installPackage(unknownPkg); } else { console.log('Installation cancelled.'); process.exit(1); @@ -67,5 +136,5 @@ export async function install(packageName: string): Promise { return; } - await installPkg(pkg); + await installPackage(pkg); } \ No newline at end of file diff --git a/src/commands/installed.ts b/src/commands/installed.ts index 69a0adf..5c9231a 100644 --- a/src/commands/installed.ts +++ b/src/commands/installed.ts @@ -11,10 +11,10 @@ inquirer.registerPrompt('autocomplete', AutocompletePrompt); export async function listInstalledPackages(): Promise { // Get all packages with their resolved status - const allPackages = resolvePackages(); - + const allPackages = await resolvePackages(); + // Filter for only installed packages - const installedPackages = allPackages.filter(pkg => pkg.isInstalled); + const installedPackages = allPackages.filter((pkg: ResolvedPackage) => pkg.isInstalled); if (installedPackages.length === 0) { console.log(chalk.yellow('\nNo MCP servers are currently installed.')); diff --git a/src/commands/list.ts b/src/commands/list.ts index fc322ac..3a7ed76 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -10,11 +10,19 @@ import { handlePackageAction } from '../utils/package-actions.js'; // Register the autocomplete prompt inquirer.registerPrompt('autocomplete', AutocompletePrompt); -export async function list() { +export async function list(nonInteractive = false) { try { - const packages = resolvePackages(); + const packages = await resolvePackages(); printPackageListHeader(packages.length); + // In non-interactive mode, just list all packages + if (nonInteractive) { + packages.forEach(pkg => { + console.log(`${pkg.name} - ${pkg.description}`); + }); + return; + } + const prompt = createPackagePrompt(packages, { showInstallStatus: true }); const answer = await inquirer.prompt<{ selectedPackage: ResolvedPackage }>([prompt]); @@ -24,7 +32,7 @@ export async function list() { const action = await displayPackageDetailsWithActions(answer.selectedPackage); await handlePackageAction(answer.selectedPackage, action, { - onBack: list + onBack: () => list(nonInteractive) }); } catch (error) { console.error(chalk.red('Error loading package list:')); diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index 6472aa3..fdbf2ab 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -3,8 +3,7 @@ import inquirer from 'inquirer'; import { resolvePackage } from '../utils/package-resolver.js'; import { uninstallPackage } from '../utils/package-management.js'; -export async function uninstall(packageName?: string): Promise { - console.error("!"); +export async function uninstall(packageName?: string, nonInteractive = false): Promise { try { // If no package name provided, show error if (!packageName) { @@ -14,7 +13,7 @@ export async function uninstall(packageName?: string): Promise { } // Resolve the package - const pkg = resolvePackage(packageName); + const pkg = await resolvePackage(packageName); if (!pkg) { console.log(chalk.yellow(`Package ${packageName} not found.`)); return; @@ -25,6 +24,13 @@ export async function uninstall(packageName?: string): Promise { return; } + // In non-interactive mode, proceed without confirmation + if (nonInteractive) { + await uninstallPackage(packageName); + console.log(chalk.green(`\nSuccessfully uninstalled ${packageName}`)); + return; + } + // Confirm uninstallation const { confirmUninstall } = await inquirer.prompt<{ confirmUninstall: boolean }>([{ type: 'confirm', diff --git a/src/index.ts b/src/index.ts index 2e2ed7b..236ae51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,34 +4,44 @@ import { list } from './commands/list.js'; import { install } from './commands/install.js'; import { uninstall } from './commands/uninstall.js'; import { listInstalledPackages } from './commands/installed.js'; +import { listClients } from './commands/clients.js'; const command = process.argv[2]; const packageName = process.argv[3]; +const nonInteractive = process.argv.includes('--non-interactive'); async function main() { switch (command) { case 'list': - await list(); + await list(nonInteractive); break; case 'install': if (!packageName) { console.error('Please provide a package name to install'); process.exit(1); } - await install(packageName); + await install(packageName, nonInteractive); break; case 'uninstall': - await uninstall(packageName); + if (!packageName) { + console.error('Please provide a package name to uninstall'); + process.exit(1); + } + await uninstall(packageName, nonInteractive); break; case 'installed': await listInstalledPackages(); break; + case 'clients': + await listClients(); + break; default: console.log('Available commands:'); console.log(' list List all available packages'); console.log(' install Install a package'); console.log(' uninstall [package] Uninstall a package'); console.log(' installed List installed packages'); + console.log(' clients List installed clients and config paths'); process.exit(1); } } diff --git a/src/install.ts b/src/install.ts index 4d4ba03..8f6b81d 100644 --- a/src/install.ts +++ b/src/install.ts @@ -49,7 +49,7 @@ export async function install(packageName: string): Promise { const pkg = packageList.find(p => p.name === packageName); if (!pkg) { console.warn(chalk.yellow(`Package ${packageName} not found in the curated list.`)); - + const { proceedWithInstall } = await inquirer.prompt<{ proceedWithInstall: boolean }>([ { type: 'confirm', @@ -61,10 +61,10 @@ export async function install(packageName: string): Promise { if (proceedWithInstall) { console.log(chalk.cyan(`Proceeding with installation of ${packageName}...`)); - + // Prompt for runtime for unverified packages const runtime = await promptForRuntime(); - + // Create a basic package object for unverified packages const unknownPkg = createUnknownPackage(packageName, runtime); await installPkg(unknownPkg); diff --git a/src/types/client-config.ts b/src/types/client-config.ts new file mode 100644 index 0000000..10403c4 --- /dev/null +++ b/src/types/client-config.ts @@ -0,0 +1,48 @@ +/** + * Types and interfaces for MCP client configuration + */ + +/** + * Supported MCP client types + */ +export type ClientType = 'claude' | 'zed' | 'continue' | 'firebase'; + +/** + * Server configuration interface + */ +export interface ServerConfig { + /** Name of the MCP server */ + name: string; + /** Runtime environment (node/python) */ + runtime: 'node' | 'python'; + /** Command to start the server */ + command: string; + /** Optional command arguments */ + args?: string[]; + /** Optional environment variables */ + env?: Record; + /** Optional transport method */ + transport?: 'stdio' | 'sse' | 'websocket'; +} + +/** + * Client configuration interface + */ +export interface ClientConfig { + /** Type of MCP client */ + type: ClientType; + /** Name of the client */ + name: string; + /** Optional custom config path */ + configPath?: string; +} + +/** + * MCP preferences interface + */ +export interface MCPPreferences { + /** Selected client types */ + selectedClients?: ClientType[]; + /** Analytics preference */ + allowAnalytics?: boolean; +} diff --git a/src/utils/__tests__/config-manager.test.ts b/src/utils/__tests__/config-manager.test.ts index 80eb19b..60d5bf5 100644 --- a/src/utils/__tests__/config-manager.test.ts +++ b/src/utils/__tests__/config-manager.test.ts @@ -1,6 +1,9 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; -import { ConfigManager } from '../config-manager'; -import { Package } from '../../types/package'; +import { ConfigManager } from '../config-manager.js'; +import { Package } from '../../types/package.js'; +import { ClientType, ServerConfig } from '../../types/client-config.js'; +import { ClaudeAdapter } from '../../clients/claude-adapter.js'; +import { ZedAdapter } from '../../clients/zed-adapter.js'; import fs from 'fs'; import path from 'path'; @@ -39,4 +42,52 @@ describe('ConfigManager', () => { expect(writtenConfig.mcpServers['test-package'].env).toEqual(mockEnvVars); }); }); + + describe('getInstalledClients', () => { + it('should return installed clients', async () => { + const configManager = new ConfigManager(); + const mockAdapter = { + isInstalled: jest.fn().mockReturnValue(Promise.resolve(true)), + validateConfig: jest.fn().mockReturnValue(Promise.resolve(true)), + writeConfig: jest.fn().mockReturnValue(Promise.resolve()), + readConfig: jest.fn().mockReturnValue(Promise.resolve({})), + getConfigPath: jest.fn().mockReturnValue('/test/path'), + type: 'claude', + name: 'Claude Desktop' + } as unknown as ClaudeAdapter; + jest.spyOn(configManager, 'getClientAdapter').mockResolvedValue(mockAdapter); + + const installedClients = await configManager.getInstalledClients(); + expect(installedClients).toHaveLength(4); + expect(mockAdapter.isInstalled).toHaveBeenCalledTimes(4); + }); + }); + + describe('configureClients', () => { + it('should configure selected clients', async () => { + const configManager = new ConfigManager(); + const mockAdapter = { + validateConfig: jest.fn().mockReturnValue(Promise.resolve(true)), + writeConfig: jest.fn().mockReturnValue(Promise.resolve()), + readConfig: jest.fn().mockReturnValue(Promise.resolve({})), + getConfigPath: jest.fn().mockReturnValue('/test/path'), + isInstalled: jest.fn().mockReturnValue(Promise.resolve(true)), + type: 'claude', + name: 'Claude Desktop' + } as unknown as ClaudeAdapter; + jest.spyOn(configManager, 'getClientAdapter').mockResolvedValue(mockAdapter); + + const config: ServerConfig = { + name: 'test', + runtime: 'node', + command: 'test', + transport: 'stdio' + }; + const clients = ['claude', 'zed'] as ClientType[]; + + await configManager.configureClients(config, clients); + expect(mockAdapter.validateConfig).toHaveBeenCalledTimes(2); + expect(mockAdapter.writeConfig).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 6d19755..347a78a 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -1,7 +1,13 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; +import inquirer from 'inquirer'; import { Package } from '../types/package.js'; +import { ClientType, ServerConfig } from '../types/client-config.js'; +import { ClaudeAdapter } from '../clients/claude-adapter.js'; +import { ZedAdapter } from '../clients/zed-adapter.js'; +import { ContinueAdapter } from '../clients/continue-adapter.js'; +import { FirebaseAdapter } from '../clients/firebase-adapter.js'; export interface MCPServer { runtime: 'node' | 'python'; @@ -137,4 +143,74 @@ export class ConfigManager { delete config.mcpServers[serverName]; this.writeConfig(config); } + + async getClientAdapter(clientType: ClientType) { + switch (clientType) { + case 'claude': + return new ClaudeAdapter({ type: clientType, name: 'Claude Desktop' }); + case 'zed': + return new ZedAdapter({ type: clientType, name: 'Zed' }); + case 'continue': + return new ContinueAdapter({ type: clientType, name: 'Continue' }); + case 'firebase': + return new FirebaseAdapter({ type: clientType, name: 'Firebase' }); + default: + throw new Error(`Unsupported client type: ${clientType}`); + } + } + + async getInstalledClients(): Promise { + const installedClients: ClientType[] = []; + const clientTypes: ClientType[] = ['claude', 'zed', 'continue', 'firebase']; + + for (const clientType of clientTypes) { + const adapter = await this.getClientAdapter(clientType); + if (await adapter.isInstalled()) { + installedClients.push(clientType); + } + } + + return installedClients; + } + + async selectClients(): Promise { + const installedClients = await this.getInstalledClients(); + + if (installedClients.length === 0) { + throw new Error('No MCP clients installed. Please install a supported client first.'); + } + + if (installedClients.length === 1) { + return installedClients; + } + + const { selectedClients } = await inquirer.prompt<{ selectedClients: ClientType[] }>([{ + type: 'checkbox', + name: 'selectedClients', + message: 'Select MCP clients to configure:', + choices: installedClients.map(client => ({ + name: client.charAt(0).toUpperCase() + client.slice(1), + value: client, + checked: true + })), + validate: (answer: ClientType[]) => { + if (answer.length < 1) { + return 'You must select at least one client.'; + } + return true; + } + }]); + + return selectedClients; + } + + async configureClients(config: ServerConfig, clients: ClientType[]): Promise { + for (const clientType of clients) { + const adapter = await this.getClientAdapter(clientType); + if (!(await adapter.validateConfig(config))) { + throw new Error(`Invalid configuration for client ${clientType}`); + } + await adapter.writeConfig(config); + } + } } \ No newline at end of file diff --git a/src/utils/package-management.ts b/src/utils/package-management.ts index 39ad611..32d03b9 100644 --- a/src/utils/package-management.ts +++ b/src/utils/package-management.ts @@ -5,15 +5,17 @@ import { promisify } from 'util'; import { packageHelpers } from '../helpers/index.js'; import { checkUVInstalled, promptForUVInstall } from './runtime-utils.js'; import { ConfigManager } from './config-manager.js'; +import { ClientType, ServerConfig } from '../types/client-config.js'; +import { Preferences } from './preferences.js'; declare function fetch(url: string, init?: any): Promise<{ ok: boolean; statusText: string }>; const execAsync = promisify(exec); async function checkAnalyticsConsent(): Promise { - const prefs = ConfigManager.readPreferences(); - - if (typeof prefs.allowAnalytics === 'boolean') { + const prefs = await ConfigManager.readPreferences(); + + if (typeof prefs?.allowAnalytics === 'boolean') { return prefs.allowAnalytics; } @@ -24,7 +26,7 @@ async function checkAnalyticsConsent(): Promise { default: true }]); - ConfigManager.writePreferences({ ...prefs, allowAnalytics }); + await ConfigManager.writePreferences({ ...prefs, allowAnalytics }); return allowAnalytics; } @@ -50,10 +52,9 @@ async function promptForEnvVars(packageName: string): Promise = {}; let hasAllRequired = true; - + for (const [key, value] of Object.entries(helpers.requiredEnvVars)) { const existingValue = process.env[key]; if (existingValue) { @@ -79,7 +80,7 @@ async function promptForEnvVars(packageName: string): Promise([{ type: 'confirm', name: 'configureEnv', - message: hasAllRequired + message: hasAllRequired ? 'Would you like to manually configure environment variables for this package?' : 'Some required environment variables are missing. Would you like to configure them now?', default: !hasAllRequired @@ -96,10 +97,10 @@ async function promptForEnvVars(packageName: string): Promise = {}; - + for (const [key, value] of Object.entries(helpers.requiredEnvVars)) { const existingEnvVar = process.env[key]; - + if (existingEnvVar) { const { reuseExisting } = await inquirer.prompt<{ reuseExisting: boolean }>([{ type: 'confirm', @@ -143,30 +144,35 @@ async function promptForEnvVars(packageName: string): Promise { +async function isClientRunning(clientType: ClientType): Promise { try { const platform = process.platform; - if (platform === 'win32') { - const { stdout } = await execAsync('tasklist /FI "IMAGENAME eq Claude.exe" /NH'); - return stdout.includes('Claude.exe'); - } else if (platform === 'darwin') { - const { stdout } = await execAsync('pgrep -x "Claude"'); - return !!stdout.trim(); - } else if (platform === 'linux') { - const { stdout } = await execAsync('pgrep -f "claude"'); - return !!stdout.trim(); + switch (clientType) { + case 'claude': + if (platform === 'win32') { + const { stdout } = await execAsync('tasklist /FI "IMAGENAME eq Claude.exe" /NH'); + return stdout.includes('Claude.exe'); + } else if (platform === 'darwin') { + const { stdout } = await execAsync('pgrep -x "Claude"'); + return !!stdout.trim(); + } else if (platform === 'linux') { + const { stdout } = await execAsync('pgrep -f "claude"'); + return !!stdout.trim(); + } + break; + // Other clients don't require process checking + default: + return false; } return false; } catch (error) { - // If the command fails, assume Claude is not running return false; } } -async function promptForRestart(): Promise { - // Check if Claude is running first - const claudeRunning = await isClaudeRunning(); - if (!claudeRunning) { +async function promptForRestart(clientType: ClientType): Promise { + const clientRunning = await isClientRunning(clientType); + if (!clientRunning) { return false; } @@ -174,47 +180,60 @@ async function promptForRestart(): Promise { { type: 'confirm', name: 'shouldRestart', - message: 'Would you like to restart the Claude desktop app to apply changes?', + message: `Would you like to restart the ${clientType} app to apply changes?`, default: true } ]); - + if (shouldRestart) { - console.log('Restarting Claude desktop app...'); + console.log(`Restarting ${clientType} app...`); try { const platform = process.platform; - if (platform === 'win32') { - await execAsync('taskkill /F /IM "Claude.exe" && start "" "Claude.exe"'); - } else if (platform === 'darwin') { - await execAsync('killall "Claude" && open -a "Claude"'); - } else if (platform === 'linux') { - await execAsync('pkill -f "claude" && claude'); - } + if (clientType === 'claude') { + if (platform === 'win32') { + await execAsync('taskkill /F /IM "Claude.exe" && start "" "Claude.exe"'); + } else if (platform === 'darwin') { + await execAsync('killall "Claude" && open -a "Claude"'); + } else if (platform === 'linux') { + await execAsync('pkill -f "claude" && claude'); + } - // Wait a moment for the app to close before reopening - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, 2000)); - // Reopen the app - if (platform === 'win32') { - await execAsync('start "" "Claude.exe"'); - } else if (platform === 'darwin') { - await execAsync('open -a "Claude"'); - } else if (platform === 'linux') { - await execAsync('claude'); + if (platform === 'win32') { + await execAsync('start "" "Claude.exe"'); + } else if (platform === 'darwin') { + await execAsync('open -a "Claude"'); + } else if (platform === 'linux') { + await execAsync('claude'); + } } + // Other clients don't require restart - console.log('Claude desktop app has been restarted.'); + console.log(`${clientType} app has been restarted.`); } catch (error) { - console.error('Failed to restart Claude desktop app:', error); + console.error(`Failed to restart ${clientType} app:`, error); } } - + return shouldRestart; } +async function promptForClientSelection(clients: ClientType[]): Promise { + const { selectedClient } = await inquirer.prompt<{ selectedClient: ClientType }>([{ + type: 'list', + name: 'selectedClient', + message: 'Select which client to configure:', + choices: clients.map(client => ({ + name: client.charAt(0).toUpperCase() + client.slice(1), + value: client + })) + }]); + return selectedClient; +} + export async function installPackage(pkg: Package): Promise { try { - // Check for UV if it's a Python package if (pkg.runtime === 'python') { const hasUV = await checkUVInstalled(); if (!hasUV) { @@ -226,17 +245,40 @@ export async function installPackage(pkg: Package): Promise { } const envVars = await promptForEnvVars(pkg.name); - + const configManager = new ConfigManager(); + const installedClients = await configManager.getInstalledClients(); + + if (installedClients.length === 0) { + throw new Error('No MCP clients installed. Please install a supported client first.'); + } + + let selectedClient: ClientType; + if (installedClients.length > 1) { + selectedClient = await promptForClientSelection(installedClients); + } else { + selectedClient = installedClients[0]; + console.log(`Using ${selectedClient} as the only installed client.`); + } + await ConfigManager.installPackage(pkg, envVars); - console.log('Updated Claude desktop configuration'); + const serverConfig: ServerConfig = { + name: pkg.name, + runtime: pkg.runtime, + command: pkg.runtime === 'node' ? 'npx' : 'uvx', + args: pkg.runtime === 'node' ? ['-y', pkg.name] : [pkg.name], + transport: 'stdio', + env: envVars || {} + }; + + await configManager.configureClients(serverConfig, [selectedClient]); + console.log(`Updated ${selectedClient} configuration for ${pkg.name}`); - // Check analytics consent and track if allowed const analyticsAllowed = await checkAnalyticsConsent(); if (analyticsAllowed) { await trackInstallation(pkg.name); } - await promptForRestart(); + await promptForRestart(selectedClient); } catch (error) { console.error('Failed to install package:', error); throw error; @@ -245,11 +287,36 @@ export async function installPackage(pkg: Package): Promise { export async function uninstallPackage(packageName: string): Promise { try { - await ConfigManager.uninstallPackage(packageName); - console.log(`\nUninstalled ${packageName}`); - await promptForRestart(); + const configManager = new ConfigManager(); + const installedClients = await configManager.getInstalledClients(); + + if (installedClients.length === 0) { + throw new Error('No MCP clients installed'); + } + + let selectedClient: ClientType; + if (installedClients.length > 1) { + selectedClient = await promptForClientSelection(installedClients); + } else { + selectedClient = installedClients[0]; + console.log(`Using ${selectedClient} as the only installed client.`); + } + + const pkg: Package = { + name: packageName, + description: '', + vendor: '', + sourceUrl: '', + homepage: '', + license: '', + runtime: 'node' + }; + + await ConfigManager.uninstallPackage(pkg, [selectedClient]); + console.log(`\nUninstalled ${packageName} from ${selectedClient}`); + await promptForRestart(selectedClient); } catch (error) { console.error('Failed to uninstall package:', error); throw error; } -} +} diff --git a/src/utils/package-resolver.ts b/src/utils/package-resolver.ts index 1743147..b5059ad 100644 --- a/src/utils/package-resolver.ts +++ b/src/utils/package-resolver.ts @@ -5,18 +5,18 @@ import fs from 'fs'; import { dirname } from 'path'; import { fileURLToPath } from 'url'; -export function isPackageInstalled(packageName: string): boolean { - return ConfigManager.isPackageInstalled(packageName); +export async function isPackageInstalled(packageName: string): Promise { + return await ConfigManager.isPackageInstalled(packageName); } -export function resolvePackages(): ResolvedPackage[] { +export async function resolvePackages(): Promise { try { // Read package list from JSON file const packageListPath = path.join(dirname(fileURLToPath(import.meta.url)), '../../packages/package-list.json'); const packages: Package[] = JSON.parse(fs.readFileSync(packageListPath, 'utf8')); - + // Get installed packages from config - const config = ConfigManager.readConfig(); + const config = await ConfigManager.readConfig(); const installedServers = config.mcpServers || {}; const installedPackageNames = Object.keys(installedServers); @@ -33,7 +33,7 @@ export function resolvePackages(): ResolvedPackage[] { // Process installed packages const resolvedPackages = new Map(); - + // First add all packages from package list for (const pkg of packages) { resolvedPackages.set(pkg.name, { @@ -49,10 +49,10 @@ export function resolvePackages(): ResolvedPackage[] { // Convert server name back to package name const packageName = serverName.replace(/-/g, '/'); const installedServer = installedServers[serverName]; - + // Check if this package exists in our package list (either by original or sanitized name) const existingPkg = packageMap.get(packageName) || packageMap.get(serverName); - + if (existingPkg) { // Update existing package's installation status resolvedPackages.set(existingPkg.name, { @@ -84,22 +84,22 @@ export function resolvePackages(): ResolvedPackage[] { } } -export function resolvePackage(packageName: string): ResolvedPackage | null { +export async function resolvePackage(packageName: string): Promise { try { // Read package list from JSON file const packageListPath = path.join(dirname(fileURLToPath(import.meta.url)), '../../packages/package-list.json'); const packages: Package[] = JSON.parse(fs.readFileSync(packageListPath, 'utf8')); - + // Try to find the package in the verified list const sanitizedName = packageName.replace(/\//g, '-'); const pkg = packages.find(p => p.name === packageName || p.name.replace(/\//g, '-') === sanitizedName); - + if (!pkg) { // Check if it's an installed package - const config = ConfigManager.readConfig(); + const config = await ConfigManager.readConfig(); const serverName = packageName.replace(/\//g, '-'); const installedServer = config.mcpServers?.[serverName]; - + if (installedServer) { return { name: packageName, @@ -117,7 +117,7 @@ export function resolvePackage(packageName: string): ResolvedPackage | null { } // Check installation status - const isInstalled = isPackageInstalled(packageName); + const isInstalled = await isPackageInstalled(packageName); return { ...pkg, diff --git a/src/utils/preferences.ts b/src/utils/preferences.ts new file mode 100644 index 0000000..6300ec1 --- /dev/null +++ b/src/utils/preferences.ts @@ -0,0 +1,177 @@ +import { homedir } from 'os'; +import { join } from 'path'; +import { access, readFile, writeFile, mkdir } from 'fs/promises'; + +export type ClientType = 'claude' | 'zed' | 'continue' | 'firebase'; + +export class Preferences { + private configDir: string; + private preferencesFile: string; + + constructor() { + this.configDir = join(homedir(), '.config', 'mcp-get'); + this.preferencesFile = join(this.configDir, 'preferences.json'); + } + + private async ensureConfigDir(): Promise { + try { + await access(this.configDir); + } catch { + await mkdir(this.configDir, { recursive: true }); + } + } + + private getClaudeConfigPath(): string { + if (process.platform === 'win32') { + return join(process.env.APPDATA || '', 'Claude', 'claude_desktop_config.json'); + } + return join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); + } + + private getZedConfigPath(): string { + if (process.platform === 'win32') { + return join(process.env.APPDATA || '', 'Zed', 'settings.json'); + } + return join(homedir(), '.config', 'zed', 'settings.json'); + } + + private getContinueConfigPath(): string { + if (process.platform === 'win32') { + return join(process.env.APPDATA || '', 'Continue', 'config.json'); + } + return join(homedir(), '.config', 'continue', 'config.json'); + } + + private getFirebaseConfigPath(): string { + if (process.platform === 'win32') { + return join(process.env.APPDATA || '', 'Firebase', 'genkit', 'config.json'); + } + return join(homedir(), '.config', 'firebase', 'genkit', 'config.json'); + } + + private async checkFileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } + } + + async detectInstalledClients(): Promise { + const installedClients: ClientType[] = []; + const checks = [ + { path: this.getClaudeConfigPath(), type: 'claude' as ClientType }, + { path: this.getZedConfigPath(), type: 'zed' as ClientType }, + { path: this.getContinueConfigPath(), type: 'continue' as ClientType }, + { path: this.getFirebaseConfigPath(), type: 'firebase' as ClientType } + ]; + + await Promise.all( + checks.map(async ({ path, type }) => { + if (await this.checkFileExists(path)) { + installedClients.push(type); + } + }) + ); + + return installedClients; + } + + async getDefaultClients(): Promise { + try { + await this.ensureConfigDir(); + + const hasPreferences = await this.checkFileExists(this.preferencesFile); + if (!hasPreferences) { + const installedClients = await this.detectInstalledClients(); + if (installedClients.length > 0) { + await this.setDefaultClients(installedClients); + return installedClients; + } + return []; + } + + const data = await readFile(this.preferencesFile, 'utf-8'); + const prefs = JSON.parse(data); + return prefs.defaultClients || []; + } catch (error) { + console.error('Error reading preferences:', error); + return []; + } + } + + async setDefaultClients(clients: ClientType[]): Promise { + try { + await this.ensureConfigDir(); + + const data = JSON.stringify({ + defaultClients: clients + }, null, 2); + + await writeFile(this.preferencesFile, data, 'utf-8'); + } catch (error) { + console.error('Error saving preferences:', error); + throw error; + } + } + + async shouldPromptForClientSelection(): Promise { + const installedClients = await this.detectInstalledClients(); + return installedClients.length > 1; + } + + async getOrSelectDefaultClients(): Promise { + const installedClients = await this.detectInstalledClients(); + + if (installedClients.length === 0) { + throw new Error('No supported MCP clients detected. Please install at least one supported client.'); + } + + if (installedClients.length === 1) { + await this.setDefaultClients(installedClients); + return installedClients; + } + + const defaultClients = await this.getDefaultClients(); + if (defaultClients.length > 0) { + return defaultClients; + } + + // If no defaults are set but multiple clients are installed, + // the caller should handle prompting the user for selection + return []; + } + + async readConfig(): Promise { + try { + await this.ensureConfigDir(); + + const hasConfig = await this.checkFileExists(this.preferencesFile); + if (!hasConfig) { + return { mcpServers: {} }; + } + + const data = await readFile(this.preferencesFile, 'utf-8'); + return JSON.parse(data); + } catch (error) { + console.error('Error reading config:', error); + return { mcpServers: {} }; + } + } + + async writeConfig(config: any): Promise { + try { + await this.ensureConfigDir(); + const data = JSON.stringify(config, null, 2); + await writeFile(this.preferencesFile, data, 'utf-8'); + } catch (error) { + console.error('Error writing config:', error); + throw error; + } + } + + getConfigPath(): string { + return this.preferencesFile; + } +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 index 0000000..705e61f --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,110 @@ +import { ServerConfig, ClientType } from '../types/client-config.js'; +import { ConfigManager } from './config-manager.js'; +import chalk from 'chalk'; + +interface ValidationResult { + isValid: boolean; + errors: string[]; +} + +type TransportMethod = 'stdio' | 'sse' | 'websocket'; + +/** + * Validates client compatibility with server configuration + */ +export async function validateClientCompatibility( + serverConfig: ServerConfig, + clientType: ClientType +): Promise { + const configManager = new ConfigManager(); + const client = configManager.getClientAdapter(clientType); + const errors: string[] = []; + + // Check if client is installed + const isInstalled = await client.isInstalled(); + if (!isInstalled) { + errors.push(`${clientType} is not installed`); + return { isValid: false, errors }; + } + + // Validate transport compatibility based on client type + const transport = serverConfig.transport || 'stdio'; + let supportedMethods: TransportMethod[] = []; + + switch (clientType) { + case 'zed': + supportedMethods = ['stdio']; + break; + case 'claude': + supportedMethods = ['stdio', 'sse']; + break; + case 'continue': + supportedMethods = ['stdio', 'sse', 'websocket']; + break; + case 'firebase': + supportedMethods = ['stdio', 'sse']; + break; + } + + if (!supportedMethods.includes(transport as TransportMethod)) { + errors.push( + `Transport method '${transport}' is not supported by ${clientType}. ` + + `Supported methods: ${supportedMethods.join(', ')}` + ); + } + + // Validate runtime compatibility + if (!serverConfig.runtime) { + errors.push('Runtime must be specified (node or python)'); + } + + return { + isValid: errors.length === 0, + errors + }; +} + +/** + * Validates server configuration across multiple clients + */ +export async function validateServerConfig( + serverConfig: ServerConfig, + clients: ClientType[] +): Promise { + const errors: string[] = []; + + // Validate basic server config + if (!serverConfig.command) { + errors.push('Server command is required'); + } + + if (!serverConfig.runtime) { + errors.push('Runtime is required (node or python)'); + } + + // Check client compatibility + for (const clientType of clients) { + const result = await validateClientCompatibility(serverConfig, clientType); + if (!result.isValid) { + errors.push(`Client '${clientType}' validation failed:`); + result.errors.forEach(error => errors.push(` - ${error}`)); + } + } + + return { + isValid: errors.length === 0, + errors + }; +} + +/** + * Formats validation errors for display + */ +export function formatValidationErrors(errors: string[]): string { + if (errors.length === 0) return ''; + + return chalk.red( + 'Configuration validation failed:\n' + + errors.map(error => ` • ${error}`).join('\n') + ); +} diff --git a/tsconfig.json b/tsconfig.json index a7552e9..80373bb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,15 @@ { "compilerOptions": { - "target": "ES2020", - "module": "ES2020", - "moduleResolution": "node", + "target": "ES2021", + "module": "NodeNext", + "moduleResolution": "NodeNext", "esModuleInterop": true, "strict": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./dist", "rootDir": "./src", - "types": ["node", "jest"] + "types": ["node", "jest", "glob"] }, "include": ["src/**/*", "test/**/*"], "exclude": ["node_modules", "dist"]