Skip to content

Commit

Permalink
feat: add multi-client support to mcp-get
Browse files Browse the repository at this point in the history
- Add client configuration abstractions and adapters
- Implement automatic client detection and selection
- Add support for multiple client configuration
- Update documentation with multi-client support details
- Add comprehensive test suite for client adapters
- Improve transport validation and error handling

Co-Authored-By: Michael Latman <[email protected]>
  • Loading branch information
devin-ai-integration[bot] and michaellatman committed Dec 11, 2024
1 parent 9287434 commit 8ebec03
Show file tree
Hide file tree
Showing 14 changed files with 399 additions and 188 deletions.
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,38 @@ 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!
## Usage Examples

### 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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down
16 changes: 11 additions & 5 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: 'ts-jest/presets/default-esm',
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts'],
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
setupFiles: ['<rootDir>/src/__tests__/setup.ts'],
extensionsToTreatAsEsm: ['.ts'],
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
transform: {
'^.+\\.tsx?$': ['ts-jest', { useESM: true }]
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
isolatedModules: true
}
]
},
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1'
}
},
extensionsToTreatAsEsm: ['.ts']
};
7 changes: 4 additions & 3 deletions src/__tests__/clients/claude-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
});
});
});
7 changes: 4 additions & 3 deletions src/__tests__/clients/continue-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
});
});
});
5 changes: 3 additions & 2 deletions src/__tests__/clients/firebase-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
7 changes: 4 additions & 3 deletions src/__tests__/clients/zed-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
});
});
});
119 changes: 119 additions & 0 deletions src/__tests__/integration/multi-client.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
51 changes: 36 additions & 15 deletions src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>;

// Mock glob module
jest.mock('glob', () => ({
glob: jest.fn().mockImplementation(async () => []) as jest.MockedFunction<GlobFunction>,
}));

// Mock os.platform() to control testing environment
jest.mock('os', () => {
const actual = jest.requireActual('os') as typeof os;
const actual = jest.requireActual<typeof osType>('os');
return {
...actual,
platform: jest.fn(),
homedir: jest.fn(),
platform: jest.fn().mockImplementation(() => 'darwin') as jest.MockedFunction<typeof actual.platform>,
homedir: jest.fn().mockImplementation(() => '/Users/testuser') as jest.MockedFunction<typeof actual.homedir>,
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<typeof fsType>('fs');
return {
existsSync: jest.fn().mockImplementation(() => false) as jest.MockedFunction<typeof actual.existsSync>,
readFileSync: jest.fn().mockImplementation(() => '{}') as jest.MockedFunction<typeof actual.readFileSync>,
writeFileSync: jest.fn().mockImplementation(() => undefined) as jest.MockedFunction<typeof actual.writeFileSync>,
mkdirSync: jest.fn().mockImplementation(() => undefined) as jest.MockedFunction<typeof actual.mkdirSync>,
constants: actual.constants,
Stats: actual.Stats,
};
});

// Mock fs/promises operations
jest.mock('fs/promises', () => {
const actual = jest.requireActual<typeof fsPromisesType>('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<typeof actual.mkdir>,
writeFile: jest.fn().mockImplementation(async () => undefined) as jest.MockedFunction<typeof actual.writeFile>,
readFile: jest.fn().mockImplementation(async () => '{}') as jest.MockedFunction<typeof actual.readFile>,
};
});

// 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');
});
6 changes: 3 additions & 3 deletions src/__tests__/utils/config-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -66,7 +67,6 @@ describe('ConfigManager', () => {
const clients: ClientType[] = ['invalid' as ClientType];
await configManager.configureClients(config, clients);


expect(fs.writeFileSync).not.toHaveBeenCalled();
});
});
Expand Down
Loading

0 comments on commit 8ebec03

Please sign in to comment.