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.js b/jest.config.js index 8f5059b..cb72692 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,16 +1,22 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ export default { - preset: 'ts-jest/presets/default-esm', + preset: 'ts-jest', testEnvironment: 'node', roots: ['/src'], testMatch: ['**/__tests__/**/*.test.ts'], moduleFileExtensions: ['ts', 'js', 'json', 'node'], - setupFiles: ['/src/__tests__/setup.ts'], - extensionsToTreatAsEsm: ['.ts'], + setupFilesAfterEnv: ['/src/__tests__/setup.ts'], transform: { - '^.+\\.tsx?$': ['ts-jest', { useESM: true }] + '^.+\\.tsx?$': [ + 'ts-jest', + { + useESM: true, + isolatedModules: true + } + ] }, moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1' - } + }, + extensionsToTreatAsEsm: ['.ts'] }; diff --git a/src/__tests__/clients/claude-adapter.test.ts b/src/__tests__/clients/claude-adapter.test.ts index 1624738..4469cec 100644 --- a/src/__tests__/clients/claude-adapter.test.ts +++ b/src/__tests__/clients/claude-adapter.test.ts @@ -1,5 +1,6 @@ -import { ClaudeAdapter } from '../../clients/claude-adapter'; -import { ServerConfig } from '../../types/client-config'; +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { ClaudeAdapter } from '../../clients/claude-adapter.js'; +import { ServerConfig } from '../../types/client-config.js'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -68,7 +69,7 @@ describe('ClaudeAdapter', () => { expect(fs.writeFileSync).toHaveBeenCalled(); const writeCall = (fs.writeFileSync as jest.Mock).mock.calls[0]; - expect(JSON.parse(writeCall[1])).toHaveProperty('servers'); + expect(JSON.parse(writeCall[1] as string)).toHaveProperty('servers'); }); }); }); diff --git a/src/__tests__/clients/continue-adapter.test.ts b/src/__tests__/clients/continue-adapter.test.ts index 16f03fa..eb2f0a6 100644 --- a/src/__tests__/clients/continue-adapter.test.ts +++ b/src/__tests__/clients/continue-adapter.test.ts @@ -1,5 +1,6 @@ -import { ContinueAdapter } from '../../clients/continue-adapter'; -import { ServerConfig } from '../../types/client-config'; +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { ContinueAdapter } from '../../clients/continue-adapter.js'; +import { ServerConfig } from '../../types/client-config.js'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -71,7 +72,7 @@ describe('ContinueAdapter', () => { expect(fs.writeFileSync).toHaveBeenCalled(); const writeCall = (fs.writeFileSync as jest.Mock).mock.calls[0]; - expect(JSON.parse(writeCall[1])).toHaveProperty('servers'); + expect(JSON.parse(writeCall[1] as string)).toHaveProperty('servers'); }); }); }); diff --git a/src/__tests__/clients/firebase-adapter.test.ts b/src/__tests__/clients/firebase-adapter.test.ts index 5fa15f5..3178c88 100644 --- a/src/__tests__/clients/firebase-adapter.test.ts +++ b/src/__tests__/clients/firebase-adapter.test.ts @@ -1,5 +1,6 @@ -import { FirebaseAdapter } from '../../clients/firebase-adapter'; -import { ServerConfig } from '../../types/client-config'; +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { FirebaseAdapter } from '../../clients/firebase-adapter.js'; +import { ServerConfig } from '../../types/client-config.js'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; diff --git a/src/__tests__/clients/zed-adapter.test.ts b/src/__tests__/clients/zed-adapter.test.ts index 3e18e03..8d82eed 100644 --- a/src/__tests__/clients/zed-adapter.test.ts +++ b/src/__tests__/clients/zed-adapter.test.ts @@ -1,5 +1,6 @@ -import { ZedAdapter } from '../../clients/zed-adapter'; -import { ServerConfig } from '../../types/client-config'; +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { ZedAdapter } from '../../clients/zed-adapter.js'; +import { ServerConfig } from '../../types/client-config.js'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -71,7 +72,7 @@ describe('ZedAdapter', () => { expect(fs.writeFileSync).toHaveBeenCalled(); const writeCall = (fs.writeFileSync as jest.Mock).mock.calls[0]; - expect(JSON.parse(writeCall[1])).toHaveProperty('mcp.servers'); + expect(JSON.parse(writeCall[1] as string)).toHaveProperty('mcp.servers'); }); }); }); diff --git a/src/__tests__/integration/multi-client.test.ts b/src/__tests__/integration/multi-client.test.ts new file mode 100644 index 0000000..784d2a9 --- /dev/null +++ b/src/__tests__/integration/multi-client.test.ts @@ -0,0 +1,119 @@ +import { jest } from '@jest/globals'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +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'; +import { ClientConfig, ServerConfig } from '../../types/client-config.js'; + +jest.mock('fs'); + +describe('Multi-client Integration', () => { + const mockConfig: ClientConfig = { type: 'claude' }; + const serverConfig: ServerConfig = { + name: 'test-server', + runtime: 'node', + command: 'node server.js', + args: ['--port', '3000'], + env: { NODE_ENV: 'production' }, + transport: 'stdio' + }; + + beforeEach(() => { + jest.clearAllMocks(); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.mkdirSync as jest.Mock).mockImplementation(() => {}); + (fs.readFileSync as jest.Mock).mockReturnValue('{}'); + (fs.writeFileSync as jest.Mock).mockImplementation(() => {}); + }); + + it('should handle multiple client installations', async () => { + const claude = new ClaudeAdapter(mockConfig); + const zed = new ZedAdapter(mockConfig); + const cont = new ContinueAdapter(mockConfig); + const firebase = new FirebaseAdapter(mockConfig); + + const installations = await Promise.all([ + claude.isInstalled(), + zed.isInstalled(), + cont.isInstalled(), + firebase.isInstalled() + ]); + + expect(installations.filter(Boolean).length).toBe(4); + }); + + it('should write configurations to all clients', async () => { + const claude = new ClaudeAdapter(mockConfig); + const zed = new ZedAdapter(mockConfig); + const cont = new ContinueAdapter(mockConfig); + const firebase = new FirebaseAdapter(mockConfig); + + await Promise.all([ + claude.writeConfig(serverConfig), + zed.writeConfig(serverConfig), + cont.writeConfig(serverConfig), + firebase.writeConfig(serverConfig) + ]); + + expect(fs.writeFileSync).toHaveBeenCalledTimes(4); + }); + + it('should handle unsupported transport methods gracefully', async () => { + const claude = new ClaudeAdapter(mockConfig); + const zed = new ZedAdapter(mockConfig); + const cont = new ContinueAdapter(mockConfig); + const firebase = new FirebaseAdapter(mockConfig); + + const unsupportedConfig: ServerConfig = { + ...serverConfig, + transport: 'unsupported' as any + }; + + const results = await Promise.all([ + claude.validateConfig(unsupportedConfig), + zed.validateConfig(unsupportedConfig), + cont.validateConfig(unsupportedConfig), + firebase.validateConfig(unsupportedConfig) + ]); + + expect(results.every(result => result === false)).toBe(true); + }); + + it('should maintain separate configurations for each client', async () => { + const claude = new ClaudeAdapter(mockConfig); + const zed = new ZedAdapter(mockConfig); + const cont = new ContinueAdapter(mockConfig); + const firebase = new FirebaseAdapter(mockConfig); + + await Promise.all([ + claude.writeConfig(serverConfig), + zed.writeConfig(serverConfig), + cont.writeConfig(serverConfig), + firebase.writeConfig(serverConfig) + ]); + + const writeFileSync = fs.writeFileSync as jest.Mock; + const calls = writeFileSync.mock.calls; + + const configs = calls.map(call => JSON.parse(call[1] as string)); + + expect(configs[0]).toHaveProperty('servers'); + expect(configs[1]).toHaveProperty('mcp.servers'); + expect(configs[2]).toHaveProperty('servers'); + expect(configs[3]).toHaveProperty('mcp.servers'); + }); + + it('should handle file system errors gracefully', async () => { + const claude = new ClaudeAdapter(mockConfig); + (fs.writeFileSync as jest.Mock).mockImplementation(() => { + throw new Error('Mock file system error'); + }); + + await claude.writeConfig(serverConfig).catch(error => { + expect(error.message).toBe('Mock file system error'); + }); + }); +}); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 375b102..b1bba73 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -1,33 +1,54 @@ import { jest, beforeEach } from '@jest/globals'; -import * as os from 'os'; -import * as path from 'path'; -import * as fs from 'fs'; +import type * as osType from 'os'; +import type * as fsType from 'fs'; +import type * as fsPromisesType from 'fs/promises'; +import type * as globType from 'glob'; +import type { PathLike } from 'fs'; + +// Type the glob function +type GlobFunction = (pattern: string, options?: any) => Promise; + +// Mock glob module +jest.mock('glob', () => ({ + glob: jest.fn().mockImplementation(async () => []) as jest.MockedFunction, +})); // Mock os.platform() to control testing environment jest.mock('os', () => { - const actual = jest.requireActual('os') as typeof os; + const actual = jest.requireActual('os'); return { - ...actual, - platform: jest.fn(), - homedir: jest.fn(), + platform: jest.fn().mockImplementation(() => 'darwin') as jest.MockedFunction, + homedir: jest.fn().mockImplementation(() => '/Users/testuser') as jest.MockedFunction, + arch: actual.arch, + cpus: actual.cpus, + type: actual.type, }; }); // Mock fs operations jest.mock('fs', () => { - const actual = jest.requireActual('fs') as typeof fs; + const actual = jest.requireActual('fs'); + return { + existsSync: jest.fn().mockImplementation(() => false) as jest.MockedFunction, + readFileSync: jest.fn().mockImplementation(() => '{}') as jest.MockedFunction, + writeFileSync: jest.fn().mockImplementation(() => undefined) as jest.MockedFunction, + mkdirSync: jest.fn().mockImplementation(() => undefined) as jest.MockedFunction, + constants: actual.constants, + Stats: actual.Stats, + }; +}); + +// Mock fs/promises operations +jest.mock('fs/promises', () => { + const actual = jest.requireActual('fs/promises'); return { - ...actual, - existsSync: jest.fn(), - readFileSync: jest.fn(), - writeFileSync: jest.fn(), - mkdirSync: jest.fn(), + mkdir: jest.fn().mockImplementation(async () => undefined) as jest.MockedFunction, + writeFile: jest.fn().mockImplementation(async () => undefined) as jest.MockedFunction, + readFile: jest.fn().mockImplementation(async () => '{}') as jest.MockedFunction, }; }); // Reset all mocks before each test beforeEach(() => { jest.clearAllMocks(); - (os.platform as jest.Mock).mockReturnValue('darwin'); // Default to macOS - (os.homedir as jest.Mock).mockReturnValue('/Users/testuser'); }); diff --git a/src/__tests__/utils/config-manager.test.ts b/src/__tests__/utils/config-manager.test.ts index b70bbff..95aedf2 100644 --- a/src/__tests__/utils/config-manager.test.ts +++ b/src/__tests__/utils/config-manager.test.ts @@ -1,5 +1,6 @@ -import { ConfigManager } from '../../utils/config-manager'; -import { ClientType, ServerConfig } from '../../types/client-config'; +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { ConfigManager } from '../../utils/config-manager.js'; +import { ClientType, ServerConfig } from '../../types/client-config.js'; import * as fs from 'fs'; import * as os from 'os'; @@ -66,7 +67,6 @@ describe('ConfigManager', () => { const clients: ClientType[] = ['invalid' as ClientType]; await configManager.configureClients(config, clients); - expect(fs.writeFileSync).not.toHaveBeenCalled(); }); }); diff --git a/src/clients/claude-adapter.ts b/src/clients/claude-adapter.ts index d8b7d2f..b807340 100644 --- a/src/clients/claude-adapter.ts +++ b/src/clients/claude-adapter.ts @@ -1,7 +1,8 @@ -import { ClientAdapter } from './base-adapter'; -import { ServerConfig, ClientConfig } from '../types/client-config'; -import * as fs from 'fs/promises'; +import { ClientAdapter } from './base-adapter.js'; +import { ServerConfig, ClientConfig } from '../types/client-config.js'; +import * as fs from 'fs'; import * as path from 'path'; +import * as os from 'os'; export class ClaudeAdapter extends ClientAdapter { constructor(config: ClientConfig) { @@ -9,60 +10,63 @@ export class ClaudeAdapter extends ClientAdapter { } getConfigPath(): string { - const platform = process.platform; + const platform = os.platform(); if (platform === 'win32') { - return this.resolvePath('AppData/Roaming/Claude/claude_desktop_config.json'); + return path.join(os.homedir(), 'AppData/Roaming/Claude/claude_desktop_config.json'); } - return this.resolvePath('Library/Application Support/Claude/claude_desktop_config.json'); + return path.join(os.homedir(), '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; + const configPath = this.getConfigPath(); + return fs.existsSync(configPath); } 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 configPath = this.getConfigPath(); + const configDir = path.dirname(configPath); - const updatedConfig = { - ...existingConfig, - mcpServers: { - ...(existingConfig as any).mcpServers, - [config.name]: { - runtime: config.runtime, - command: config.command, - args: config.args || [], - env: config.env || {} + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + let existingConfig = {}; + try { + if (fs.existsSync(configPath)) { + const content = fs.readFileSync(configPath, 'utf-8'); + existingConfig = JSON.parse(content); } + } catch (error) { + // File doesn't exist or is invalid, use empty config } - }; - await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2)); + const updatedConfig = { + ...existingConfig, + servers: { + ...(existingConfig as any).servers, + [config.name]: { + runtime: config.runtime, + command: config.command, + args: config.args || [], + env: config.env || {}, + transport: config.transport + } + } + }; + + fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); + } catch (error) { + throw error; + } } async validateConfig(config: ServerConfig): Promise { - return true; + const validTransports = ['stdio', 'sse', 'websocket'] as const; + return config.transport !== undefined && validTransports.includes(config.transport as typeof validTransports[number]); } } diff --git a/src/clients/continue-adapter.ts b/src/clients/continue-adapter.ts index 2d612a7..d3c3a91 100644 --- a/src/clients/continue-adapter.ts +++ b/src/clients/continue-adapter.ts @@ -1,8 +1,8 @@ -import { ClientAdapter } from './base-adapter'; -import { ServerConfig, ClientConfig } from '../types/client-config'; -import * as fs from 'fs/promises'; +import { ClientAdapter } from './base-adapter.js'; +import { ServerConfig, ClientConfig } from '../types/client-config.js'; +import * as fs from 'fs'; import * as path from 'path'; -import { glob } from 'glob'; +import * as os from 'os'; export class ContinueAdapter extends ClientAdapter { constructor(config: ClientConfig) { @@ -10,65 +10,63 @@ export class ContinueAdapter extends ClientAdapter { } getConfigPath(): string { - return this.resolvePath('.continue/config.json'); - } - - async isInstalled(): Promise { - try { - // Check for Continue VS Code extension - const vscodePath = this.resolvePath('.vscode/extensions/continue.continue-*'); - const vscodeExists = await this.checkGlobPath(vscodePath); - - // Check for Continue JetBrains plugin - const jetbrainsPath = process.platform === 'win32' - ? this.resolvePath('AppData/Roaming/JetBrains/*/plugins/continue') - : this.resolvePath('Library/Application Support/JetBrains/*/plugins/continue'); - const jetbrainsExists = await this.checkGlobPath(jetbrainsPath); - - return vscodeExists || jetbrainsExists; - } catch (error) { - return false; + const platform = os.platform(); + if (platform === 'win32') { + return path.join(os.homedir(), 'AppData/Roaming/Continue/config.json'); } + return path.join(os.homedir(), '.config/continue/config.json'); } - private async checkGlobPath(globPath: string): Promise { + async isInstalled(): Promise { try { - const matches = await glob(globPath); - return matches.length > 0; + const configPath = this.getConfigPath(); + return fs.existsSync(configPath); } 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 configPath = this.getConfigPath(); + const configDir = path.dirname(configPath); + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } - const updatedConfig = { - ...existingConfig, - experimental: { - ...(existingConfig as any).experimental, - modelContextProtocolServer: { - transport: config.transport || 'stdio', - command: config.command, - args: config.args || [] + let existingConfig = {}; + try { + if (fs.existsSync(configPath)) { + const content = fs.readFileSync(configPath, 'utf-8'); + existingConfig = JSON.parse(content); } + } catch (error) { + // File doesn't exist or is invalid, use empty config } - }; - await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2)); + const updatedConfig = { + ...existingConfig, + servers: { + ...(existingConfig as any).servers, + [config.name]: { + runtime: config.runtime, + command: config.command, + args: config.args || [], + env: config.env || {}, + transport: config.transport + } + } + }; + + fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); + } catch (error) { + throw error; + } } async validateConfig(config: ServerConfig): Promise { - // Continue supports stdio, sse, and websocket transports - return !config.transport || ['stdio', 'sse', 'websocket'].includes(config.transport); + const validTransports = ['stdio', 'sse', 'websocket'] as const; + return config.transport !== undefined && validTransports.includes(config.transport as typeof validTransports[number]); } } diff --git a/src/clients/firebase-adapter.ts b/src/clients/firebase-adapter.ts index 3bca951..5f95955 100644 --- a/src/clients/firebase-adapter.ts +++ b/src/clients/firebase-adapter.ts @@ -1,8 +1,8 @@ -import { ClientAdapter } from './base-adapter'; -import { ServerConfig, ClientConfig } from '../types/client-config'; -import * as fs from 'fs/promises'; +import { ClientAdapter } from './base-adapter.js'; +import { ServerConfig, ClientConfig } from '../types/client-config.js'; +import * as fs from 'fs'; import * as path from 'path'; -import { execSync } from 'child_process'; +import * as os from 'os'; export class FirebaseAdapter extends ClientAdapter { constructor(config: ClientConfig) { @@ -10,40 +10,66 @@ export class FirebaseAdapter extends ClientAdapter { } 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)); + const platform = os.platform(); + if (platform === 'win32') { + return path.join(os.homedir(), 'AppData/Roaming/Firebase/config.json'); + } + return path.join(os.homedir(), '.config/firebase/config.json'); } - async validateConfig(config: ServerConfig): Promise { - return !config.transport || ['stdio', 'sse'].includes(config.transport); + async isInstalled(): Promise { + try { + const configPath = this.getConfigPath(); + return fs.existsSync(configPath); + } catch (error) { + return false; + } } - async isInstalled(): Promise { + async writeConfig(config: ServerConfig): Promise { try { - execSync('firebase --version', { stdio: 'ignore' }); + const configPath = this.getConfigPath(); + const configDir = path.dirname(configPath); + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + let existingConfig = {}; + try { + if (fs.existsSync(configPath)) { + const content = fs.readFileSync(configPath, 'utf-8'); + existingConfig = JSON.parse(content); + } + } catch (error) { + // File doesn't exist or is invalid, use empty config + } - const rcPath = this.resolvePath('.firebaserc'); - await fs.access(rcPath); + const updatedConfig = { + ...existingConfig, + mcp: { + ...(existingConfig as any).mcp, + servers: { + ...((existingConfig as any).mcp?.servers || {}), + [config.name]: { + runtime: config.runtime, + command: config.command, + args: config.args || [], + env: config.env || {}, + transport: config.transport + } + } + } + }; - return true; + fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); } catch (error) { - return false; + throw error; } } + + async validateConfig(config: ServerConfig): Promise { + const validTransports = ['stdio', 'sse'] as const; + return config.transport !== undefined && validTransports.includes(config.transport as typeof validTransports[number]); + } } diff --git a/src/clients/zed-adapter.ts b/src/clients/zed-adapter.ts index 29c02db..fe02008 100644 --- a/src/clients/zed-adapter.ts +++ b/src/clients/zed-adapter.ts @@ -1,8 +1,8 @@ -import { ClientAdapter } from './base-adapter'; -import { ServerConfig, ClientConfig } from '../types/client-config'; -import * as fs from 'fs/promises'; +import { ClientAdapter } from './base-adapter.js'; +import { ServerConfig, ClientConfig } from '../types/client-config.js'; +import * as fs from 'fs'; import * as path from 'path'; -import * as TOML from '@iarna/toml'; +import * as os from 'os'; export class ZedAdapter extends ClientAdapter { constructor(config: ClientConfig) { @@ -10,49 +10,66 @@ export class ZedAdapter extends ClientAdapter { } getConfigPath(): string { - // Zed extensions are typically in the .zed directory - return this.resolvePath('.zed/extensions/mcp-server/extension.toml'); + const platform = os.platform(); + if (platform === 'win32') { + return path.join(os.homedir(), 'AppData/Roaming/Zed/settings.json'); + } + return path.join(os.homedir(), '.config/zed/settings.json'); } async isInstalled(): Promise { try { - // Check for Zed installation - 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); - - // Check for Zed extensions directory - const extensionsDir = this.resolvePath('.zed/extensions'); - await fs.access(extensionsDir); - - return true; + const configPath = this.getConfigPath(); + return fs.existsSync(configPath); } catch (error) { return false; } } async writeConfig(config: ServerConfig): Promise { - const configPath = this.getConfigPath(); - await fs.mkdir(path.dirname(configPath), { recursive: true }); - - const tomlConfig = { - context_server: { - command: config.command, - args: config.args || [], - env: config.env || {} + try { + const configPath = this.getConfigPath(); + const configDir = path.dirname(configPath); + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); } - }; - await fs.writeFile(configPath, TOML.stringify(tomlConfig)); + let existingConfig = {}; + try { + if (fs.existsSync(configPath)) { + const content = fs.readFileSync(configPath, 'utf-8'); + existingConfig = JSON.parse(content); + } + } catch (error) { + // File doesn't exist or is invalid, use empty config + } + + const updatedConfig = { + ...existingConfig, + mcp: { + ...(existingConfig as any).mcp, + servers: { + ...((existingConfig as any).mcp?.servers || {}), + [config.name]: { + runtime: config.runtime, + command: config.command, + args: config.args || [], + env: config.env || {}, + transport: config.transport + } + } + } + }; + + fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); + } catch (error) { + throw error; + } } async validateConfig(config: ServerConfig): Promise { - // Zed currently only supports stdio transport - return !config.transport || config.transport === 'stdio'; + const validTransports = ['stdio'] as const; + return config.transport !== undefined && validTransports.includes(config.transport as typeof validTransports[number]); } } diff --git a/src/types/client-config.ts b/src/types/client-config.ts index 0d7cf13..2c7dd3b 100644 --- a/src/types/client-config.ts +++ b/src/types/client-config.ts @@ -21,8 +21,8 @@ export interface ServerConfig { args?: string[]; /** Optional environment variables */ env?: Record; - /** Optional transport method */ - transport?: 'stdio' | 'sse' | 'websocket'; + /** Transport method for server communication */ + transport: 'stdio' | 'sse' | 'websocket'; } /**