@mcpinv/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +36 -0
  2. package/dist/commands/install.d.ts +2 -0
  3. package/dist/commands/install.js +52 -0
  4. package/dist/commands/logs.d.ts +2 -0
  5. package/dist/commands/logs.js +23 -0
  6. package/dist/commands/migrate.d.ts +2 -0
  7. package/dist/commands/migrate.js +32 -0
  8. package/dist/commands/remove.d.ts +2 -0
  9. package/dist/commands/remove.js +25 -0
  10. package/dist/commands/search.d.ts +2 -0
  11. package/dist/commands/search.js +31 -0
  12. package/dist/commands/status.d.ts +2 -0
  13. package/dist/commands/status.js +19 -0
  14. package/dist/commands/update.d.ts +2 -0
  15. package/dist/commands/update.js +28 -0
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.js +21 -0
  18. package/dist/services/config-manager.d.ts +16 -0
  19. package/dist/services/config-manager.js +98 -0
  20. package/dist/services/keychain.d.ts +4 -0
  21. package/dist/services/keychain.js +20 -0
  22. package/dist/services/smithery.d.ts +3 -0
  23. package/dist/services/smithery.js +30 -0
  24. package/dist/types/index.d.ts +28 -0
  25. package/dist/types/index.js +1 -0
  26. package/package.json +32 -0
  27. package/src/commands/install.ts +54 -0
  28. package/src/commands/logs.ts +24 -0
  29. package/src/commands/migrate.ts +36 -0
  30. package/src/commands/remove.ts +26 -0
  31. package/src/commands/search.ts +31 -0
  32. package/src/commands/status.ts +20 -0
  33. package/src/commands/update.ts +28 -0
  34. package/src/index.ts +24 -0
  35. package/src/services/config-manager.ts +110 -0
  36. package/src/services/keychain.ts +26 -0
  37. package/src/services/smithery.ts +35 -0
  38. package/src/types/index.ts +31 -0
  39. package/tests/commands/install.test.ts +50 -0
  40. package/tests/services/config-manager.test.ts +51 -0
  41. package/tests/services/keychain.test.ts +39 -0
  42. package/tests/services/smithery.test.ts +47 -0
  43. package/tsconfig.json +15 -0
  44. package/vitest.config.ts +8 -0
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # mcpinv — invoke anything
2
+
3
+ Install, run and host MCP servers in seconds.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g mcpinv
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ inv search github # find MCP servers
15
+ inv install github-mcp-server # install + inject into Claude/Cursor (secrets in Keychain)
16
+ inv status # list installed servers
17
+ inv logs github-mcp-server # tail logs
18
+ inv remove github-mcp-server # uninstall + remove secrets
19
+ inv migrate # move existing plaintext tokens to OS Keychain
20
+ inv update # check for updates
21
+ ```
22
+
23
+ ## How it works
24
+
25
+ - Discovers MCP servers via [Smithery](https://smithery.ai)
26
+ - Secrets stored in your OS Keychain — never in config files
27
+ - Auto-injects into Claude Desktop, Cursor, and Cline
28
+ - Works on Windows, macOS, Linux
29
+
30
+ ## Security
31
+
32
+ `inv migrate` scans your existing MCP configs for plaintext tokens and moves them into the OS Keychain automatically.
33
+
34
+ ## License
35
+
36
+ MIT
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function installCommand(): Command;
@@ -0,0 +1,52 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import inquirer from 'inquirer';
5
+ import { fetchManifest } from '../services/smithery.js';
6
+ import { setSecret, getSecret } from '../services/keychain.js';
7
+ import { addServer } from '../services/config-manager.js';
8
+ export function installCommand() {
9
+ return new Command('install')
10
+ .description('Install an MCP server')
11
+ .argument('<server-id>', 'Server ID from inv search')
12
+ .action(async (serverId) => {
13
+ const spinner = ora(`Fetching manifest for ${serverId}...`).start();
14
+ let manifest;
15
+ try {
16
+ manifest = await fetchManifest(serverId);
17
+ spinner.succeed(`Found: ${manifest.name} v${manifest.version}`);
18
+ }
19
+ catch {
20
+ spinner.fail(`Server "${serverId}" not found`);
21
+ process.exit(1);
22
+ }
23
+ const env = {};
24
+ for (const secret of manifest.secrets) {
25
+ const existing = await getSecret(serverId, secret.key);
26
+ if (existing) {
27
+ console.log(chalk.dim(` ${secret.key}: using stored value`));
28
+ env[secret.key] = `keychain://mcpinv/${serverId}:${secret.key}`;
29
+ continue;
30
+ }
31
+ if (!secret.required)
32
+ continue;
33
+ const answer = await inquirer.prompt([{
34
+ name: secret.key,
35
+ type: 'password',
36
+ message: `${secret.description || secret.key}:`,
37
+ mask: '*'
38
+ }]);
39
+ await setSecret(serverId, secret.key, answer[secret.key]);
40
+ env[secret.key] = `keychain://mcpinv/${serverId}:${secret.key}`;
41
+ }
42
+ const injectSpinner = ora('Updating client configs...').start();
43
+ await addServer(serverId, {
44
+ command: manifest.installCommand,
45
+ args: manifest.args,
46
+ env
47
+ });
48
+ injectSpinner.succeed('Done!');
49
+ console.log(chalk.green(`\n✓ ${manifest.name} installed`));
50
+ console.log(chalk.dim(' Restart Claude Desktop / Cursor to activate\n'));
51
+ });
52
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function logsCommand(): Command;
@@ -0,0 +1,23 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import fs from 'fs';
6
+ export function logsCommand() {
7
+ return new Command('logs')
8
+ .description('Show logs for an installed MCP server')
9
+ .argument('<server-id>', 'Server ID')
10
+ .option('-n, --lines <number>', 'Number of lines', '50')
11
+ .action(async (serverId, opts) => {
12
+ const logFile = path.join(os.homedir(), '.mcpinv', 'logs', `${serverId}.log`);
13
+ if (!fs.existsSync(logFile)) {
14
+ console.log(chalk.yellow(`No logs found for ${serverId}`));
15
+ console.log(chalk.dim(`Log file expected at: ${logFile}`));
16
+ return;
17
+ }
18
+ const lines = parseInt(opts.lines, 10);
19
+ const content = fs.readFileSync(logFile, 'utf-8').split('\n').slice(-lines).join('\n');
20
+ console.log(chalk.dim(`--- last ${lines} lines: ${logFile} ---\n`));
21
+ console.log(content);
22
+ });
23
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function migrateCommand(): Command;
@@ -0,0 +1,32 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import inquirer from 'inquirer';
4
+ import { hasPlaintextSecrets } from '../services/config-manager.js';
5
+ import { setSecret } from '../services/keychain.js';
6
+ export function migrateCommand() {
7
+ return new Command('migrate')
8
+ .description('Move plaintext secrets from config files into the OS Keychain')
9
+ .action(async () => {
10
+ const found = await hasPlaintextSecrets();
11
+ if (found.length === 0) {
12
+ console.log(chalk.green('✓ No plaintext secrets found — you are already secure!'));
13
+ return;
14
+ }
15
+ console.log(chalk.yellow(`\nFound ${found.length} plaintext secret(s) in your config:\n`));
16
+ for (const s of found) {
17
+ console.log(` ${chalk.cyan(s.serverId)} → ${s.key}: ${chalk.red(s.value.slice(0, 8) + '...')}`);
18
+ }
19
+ const { confirm } = await inquirer.prompt([{
20
+ name: 'confirm', type: 'confirm',
21
+ message: 'Move all to OS Keychain and remove from config?',
22
+ default: true
23
+ }]);
24
+ if (!confirm)
25
+ return;
26
+ for (const s of found) {
27
+ await setSecret(s.serverId, s.key, s.value);
28
+ console.log(chalk.green(` ✓ ${s.serverId}:${s.key} → Keychain`));
29
+ }
30
+ console.log(chalk.green('\n✓ Migration complete. Restart your AI clients to apply.\n'));
31
+ });
32
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function removeCommand(): Command;
@@ -0,0 +1,25 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import inquirer from 'inquirer';
4
+ import { removeServer } from '../services/config-manager.js';
5
+ import { listSecrets, deleteSecret } from '../services/keychain.js';
6
+ export function removeCommand() {
7
+ return new Command('remove')
8
+ .description('Uninstall an MCP server')
9
+ .argument('<server-id>', 'Server to remove')
10
+ .action(async (serverId) => {
11
+ const { confirm } = await inquirer.prompt([{
12
+ name: 'confirm', type: 'confirm',
13
+ message: `Remove ${serverId} and its stored secrets?`,
14
+ default: false
15
+ }]);
16
+ if (!confirm)
17
+ return;
18
+ await removeServer(serverId);
19
+ const secrets = await listSecrets(serverId);
20
+ for (const key of secrets)
21
+ await deleteSecret(serverId, key);
22
+ console.log(chalk.green(`✓ ${serverId} removed`));
23
+ console.log(chalk.dim(' Restart Claude Desktop / Cursor to deactivate\n'));
24
+ });
25
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function searchCommand(): Command;
@@ -0,0 +1,31 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { searchServers } from '../services/smithery.js';
5
+ export function searchCommand() {
6
+ return new Command('search')
7
+ .description('Search for MCP servers')
8
+ .argument('<query>', 'Search term')
9
+ .action(async (query) => {
10
+ const spinner = ora(`Searching for "${query}"...`).start();
11
+ try {
12
+ const results = await searchServers(query);
13
+ spinner.stop();
14
+ if (results.length === 0) {
15
+ console.log(chalk.yellow('No servers found.'));
16
+ return;
17
+ }
18
+ console.log(chalk.bold(`\n${results.length} server(s) found:\n`));
19
+ for (const s of results) {
20
+ console.log(` ${chalk.cyan(s.id)}`);
21
+ console.log(` ${chalk.dim(s.description)}`);
22
+ console.log(` ${chalk.green(`inv install ${s.id}`)}\n`);
23
+ }
24
+ }
25
+ catch (err) {
26
+ spinner.fail('Search failed');
27
+ console.error(chalk.red(String(err)));
28
+ process.exit(1);
29
+ }
30
+ });
31
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function statusCommand(): Command;
@@ -0,0 +1,19 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { listInstalled } from '../services/config-manager.js';
4
+ export function statusCommand() {
5
+ return new Command('status')
6
+ .description('Show installed MCP servers')
7
+ .action(async () => {
8
+ const servers = await listInstalled();
9
+ if (servers.length === 0) {
10
+ console.log(chalk.yellow('No servers installed. Run: inv search <query>'));
11
+ return;
12
+ }
13
+ console.log(chalk.bold(`\nInstalled servers (${servers.length}):\n`));
14
+ for (const id of servers) {
15
+ console.log(` ${chalk.cyan('●')} ${id}`);
16
+ }
17
+ console.log();
18
+ });
19
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function updateCommand(): Command;
@@ -0,0 +1,28 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { listInstalled } from '../services/config-manager.js';
5
+ import { fetchManifest } from '../services/smithery.js';
6
+ export function updateCommand() {
7
+ return new Command('update')
8
+ .description('Check for updates to installed MCP servers')
9
+ .action(async () => {
10
+ const servers = await listInstalled();
11
+ if (servers.length === 0) {
12
+ console.log(chalk.yellow('No servers installed.'));
13
+ return;
14
+ }
15
+ console.log(chalk.bold('\nChecking for updates...\n'));
16
+ for (const id of servers) {
17
+ const spinner = ora(id).start();
18
+ try {
19
+ const manifest = await fetchManifest(id);
20
+ spinner.succeed(`${id} — latest: v${manifest.version}`);
21
+ }
22
+ catch {
23
+ spinner.warn(`${id} — could not fetch latest version`);
24
+ }
25
+ }
26
+ console.log();
27
+ });
28
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import { searchCommand } from './commands/search.js';
4
+ import { installCommand } from './commands/install.js';
5
+ import { removeCommand } from './commands/remove.js';
6
+ import { statusCommand } from './commands/status.js';
7
+ import { logsCommand } from './commands/logs.js';
8
+ import { updateCommand } from './commands/update.js';
9
+ import { migrateCommand } from './commands/migrate.js';
10
+ program
11
+ .name('inv')
12
+ .description('Install, run and host MCP servers')
13
+ .version('0.1.0');
14
+ program.addCommand(searchCommand());
15
+ program.addCommand(installCommand());
16
+ program.addCommand(removeCommand());
17
+ program.addCommand(statusCommand());
18
+ program.addCommand(logsCommand());
19
+ program.addCommand(updateCommand());
20
+ program.addCommand(migrateCommand());
21
+ program.parse();
@@ -0,0 +1,16 @@
1
+ export interface ServerEntry {
2
+ command?: string;
3
+ args?: string[];
4
+ env?: Record<string, string>;
5
+ url?: string;
6
+ }
7
+ export interface PlaintextSecret {
8
+ serverId: string;
9
+ key: string;
10
+ value: string;
11
+ }
12
+ export declare function detectClients(): Promise<string[]>;
13
+ export declare function addServer(serverId: string, entry: ServerEntry): Promise<void>;
14
+ export declare function removeServer(serverId: string): Promise<void>;
15
+ export declare function listInstalled(): Promise<string[]>;
16
+ export declare function hasPlaintextSecrets(): Promise<PlaintextSecret[]>;
@@ -0,0 +1,98 @@
1
+ import * as fs from 'fs/promises';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ function claudeConfigPath() {
5
+ const home = os.homedir();
6
+ const platform = os.platform();
7
+ if (platform === 'win32') {
8
+ return path.join(process.env.APPDATA ?? home, 'Claude', 'claude_desktop_config.json');
9
+ }
10
+ if (platform === 'darwin') {
11
+ return path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
12
+ }
13
+ return path.join(home, '.config', 'claude', 'claude_desktop_config.json');
14
+ }
15
+ function cursorConfigPath() {
16
+ return path.join(os.homedir(), '.cursor', 'mcp.json');
17
+ }
18
+ async function readJson(filePath) {
19
+ try {
20
+ const raw = await fs.readFile(filePath, 'utf-8');
21
+ return JSON.parse(raw);
22
+ }
23
+ catch {
24
+ return { mcpServers: {} };
25
+ }
26
+ }
27
+ async function writeJson(filePath, data) {
28
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
29
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
30
+ }
31
+ export async function detectClients() {
32
+ const clients = [];
33
+ for (const p of [claudeConfigPath(), cursorConfigPath()]) {
34
+ try {
35
+ await fs.access(p);
36
+ clients.push(p);
37
+ }
38
+ catch (err) {
39
+ if (err?.code !== 'ENOENT')
40
+ throw err;
41
+ }
42
+ }
43
+ return clients;
44
+ }
45
+ export async function addServer(serverId, entry) {
46
+ const claudePath = claudeConfigPath();
47
+ const config = await readJson(claudePath);
48
+ config.mcpServers = config.mcpServers ?? {};
49
+ config.mcpServers[serverId] = entry;
50
+ await writeJson(claudePath, config);
51
+ const cursorPath = cursorConfigPath();
52
+ try {
53
+ await fs.access(cursorPath);
54
+ const cursorConfig = await readJson(cursorPath);
55
+ cursorConfig.mcpServers = cursorConfig.mcpServers ?? {};
56
+ cursorConfig.mcpServers[serverId] = entry;
57
+ await writeJson(cursorPath, cursorConfig);
58
+ }
59
+ catch (err) {
60
+ // Skip clients whose config directory doesn't exist yet
61
+ if (err?.code !== 'ENOENT')
62
+ throw err;
63
+ }
64
+ }
65
+ export async function removeServer(serverId) {
66
+ const claudePath = claudeConfigPath();
67
+ const config = await readJson(claudePath);
68
+ delete config.mcpServers?.[serverId];
69
+ await writeJson(claudePath, config);
70
+ const cursorPath = cursorConfigPath();
71
+ try {
72
+ await fs.access(cursorPath);
73
+ const cursorConfig = await readJson(cursorPath);
74
+ delete cursorConfig.mcpServers?.[serverId];
75
+ await writeJson(cursorPath, cursorConfig);
76
+ }
77
+ catch (err) {
78
+ // Skip clients whose config directory doesn't exist yet
79
+ if (err?.code !== 'ENOENT')
80
+ throw err;
81
+ }
82
+ }
83
+ export async function listInstalled() {
84
+ const config = await readJson(claudeConfigPath());
85
+ return Object.keys(config.mcpServers ?? {});
86
+ }
87
+ export async function hasPlaintextSecrets() {
88
+ const config = await readJson(claudeConfigPath());
89
+ const found = [];
90
+ for (const [serverId, entry] of Object.entries(config.mcpServers ?? {})) {
91
+ for (const [key, value] of Object.entries(entry.env ?? {})) {
92
+ if (typeof value === 'string' && !value.startsWith('keychain://')) {
93
+ found.push({ serverId, key, value });
94
+ }
95
+ }
96
+ }
97
+ return found;
98
+ }
@@ -0,0 +1,4 @@
1
+ export declare function setSecret(serverId: string, key: string, value: string): Promise<void>;
2
+ export declare function getSecret(serverId: string, key: string): Promise<string | null>;
3
+ export declare function deleteSecret(serverId: string, key: string): Promise<void>;
4
+ export declare function listSecrets(serverId: string): Promise<string[]>;
@@ -0,0 +1,20 @@
1
+ import keytar from 'keytar';
2
+ const SERVICE = 'mcpinv';
3
+ function accountKey(serverId, secretKey) {
4
+ return `${serverId}:${secretKey}`;
5
+ }
6
+ export async function setSecret(serverId, key, value) {
7
+ await keytar.setPassword(SERVICE, accountKey(serverId, key), value);
8
+ }
9
+ export async function getSecret(serverId, key) {
10
+ return keytar.getPassword(SERVICE, accountKey(serverId, key));
11
+ }
12
+ export async function deleteSecret(serverId, key) {
13
+ await keytar.deletePassword(SERVICE, accountKey(serverId, key));
14
+ }
15
+ export async function listSecrets(serverId) {
16
+ const creds = await keytar.findCredentials(SERVICE);
17
+ return creds
18
+ .filter(c => c.account.startsWith(`${serverId}:`))
19
+ .map(c => c.account.split(':')[1]);
20
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '../types/index.js';
2
+ export declare function searchServers(query: string): Promise<McpServer[]>;
3
+ export declare function fetchManifest(id: string): Promise<McpServer>;
@@ -0,0 +1,30 @@
1
+ import axios from 'axios';
2
+ const BASE = 'https://registry.smithery.ai';
3
+ export async function searchServers(query) {
4
+ const { data } = await axios.get(`${BASE}/servers`, {
5
+ params: { q: query, pageSize: 20 }
6
+ });
7
+ return (data.items ?? []).map(mapToServer);
8
+ }
9
+ export async function fetchManifest(id) {
10
+ const { data } = await axios.get(`${BASE}/servers/${id}`);
11
+ return mapToServer(data);
12
+ }
13
+ function mapToServer(raw) {
14
+ const conn = raw.connections?.[0] ?? {};
15
+ return {
16
+ id: raw.qualifiedName,
17
+ name: raw.displayName ?? raw.qualifiedName,
18
+ description: raw.description ?? '',
19
+ version: raw.version ?? 'latest',
20
+ runtime: 'node',
21
+ secrets: (raw.environmentVariables ?? []).map((e) => ({
22
+ key: e.name,
23
+ description: e.description ?? '',
24
+ required: e.required ?? false
25
+ })),
26
+ installCommand: conn.command ?? 'npx',
27
+ args: conn.args ?? [],
28
+ source: 'smithery'
29
+ };
30
+ }
@@ -0,0 +1,28 @@
1
+ export interface McpServer {
2
+ id: string;
3
+ name: string;
4
+ description: string;
5
+ version: string;
6
+ runtime: 'node' | 'python' | 'binary';
7
+ secrets: SecretSpec[];
8
+ installCommand: string;
9
+ args: string[];
10
+ source: 'smithery';
11
+ }
12
+ export interface SecretSpec {
13
+ key: string;
14
+ description: string;
15
+ required: boolean;
16
+ }
17
+ export interface InstalledServer {
18
+ id: string;
19
+ name: string;
20
+ version: string;
21
+ installedAt: string;
22
+ remoteUrl?: string;
23
+ }
24
+ export interface ClientConfig {
25
+ claude: string | null;
26
+ cursor: string | null;
27
+ cline: string | null;
28
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@mcpinv/cli",
3
+ "version": "0.1.0",
4
+ "description": "Install, run and host MCP servers — invoke anything",
5
+ "type": "module",
6
+ "bin": {
7
+ "inv": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsc --watch",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest"
14
+ },
15
+ "dependencies": {
16
+ "axios": "^1.7.2",
17
+ "chalk": "^5.3.0",
18
+ "commander": "^12.1.0",
19
+ "inquirer": "^10.1.8",
20
+ "keytar": "^7.9.0",
21
+ "ora": "^8.0.1"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^20.14.0",
25
+ "memfs": "^4.57.8",
26
+ "typescript": "^5.5.0",
27
+ "vitest": "^1.6.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=20.0.0"
31
+ }
32
+ }
@@ -0,0 +1,54 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import ora from 'ora'
4
+ import inquirer from 'inquirer'
5
+ import { fetchManifest } from '../services/smithery.js'
6
+ import { setSecret, getSecret } from '../services/keychain.js'
7
+ import { addServer } from '../services/config-manager.js'
8
+
9
+ export function installCommand(): Command {
10
+ return new Command('install')
11
+ .description('Install an MCP server')
12
+ .argument('<server-id>', 'Server ID from inv search')
13
+ .action(async (serverId: string) => {
14
+ const spinner = ora(`Fetching manifest for ${serverId}...`).start()
15
+ let manifest
16
+ try {
17
+ manifest = await fetchManifest(serverId)
18
+ spinner.succeed(`Found: ${manifest.name} v${manifest.version}`)
19
+ } catch {
20
+ spinner.fail(`Server "${serverId}" not found`)
21
+ process.exit(1)
22
+ }
23
+
24
+ const env: Record<string, string> = {}
25
+ for (const secret of manifest.secrets) {
26
+ const existing = await getSecret(serverId, secret.key)
27
+ if (existing) {
28
+ console.log(chalk.dim(` ${secret.key}: using stored value`))
29
+ env[secret.key] = `keychain://mcpinv/${serverId}:${secret.key}`
30
+ continue
31
+ }
32
+ if (!secret.required) continue
33
+ const answer = await inquirer.prompt([{
34
+ name: secret.key,
35
+ type: 'password',
36
+ message: `${secret.description || secret.key}:`,
37
+ mask: '*'
38
+ }])
39
+ await setSecret(serverId, secret.key, answer[secret.key])
40
+ env[secret.key] = `keychain://mcpinv/${serverId}:${secret.key}`
41
+ }
42
+
43
+ const injectSpinner = ora('Updating client configs...').start()
44
+ await addServer(serverId, {
45
+ command: manifest.installCommand,
46
+ args: manifest.args,
47
+ env
48
+ })
49
+ injectSpinner.succeed('Done!')
50
+
51
+ console.log(chalk.green(`\n✓ ${manifest.name} installed`))
52
+ console.log(chalk.dim(' Restart Claude Desktop / Cursor to activate\n'))
53
+ })
54
+ }
@@ -0,0 +1,24 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import os from 'os'
4
+ import path from 'path'
5
+ import fs from 'fs'
6
+
7
+ export function logsCommand(): Command {
8
+ return new Command('logs')
9
+ .description('Show logs for an installed MCP server')
10
+ .argument('<server-id>', 'Server ID')
11
+ .option('-n, --lines <number>', 'Number of lines', '50')
12
+ .action(async (serverId: string, opts: { lines: string }) => {
13
+ const logFile = path.join(os.homedir(), '.mcpinv', 'logs', `${serverId}.log`)
14
+ if (!fs.existsSync(logFile)) {
15
+ console.log(chalk.yellow(`No logs found for ${serverId}`))
16
+ console.log(chalk.dim(`Log file expected at: ${logFile}`))
17
+ return
18
+ }
19
+ const lines = parseInt(opts.lines, 10)
20
+ const content = fs.readFileSync(logFile, 'utf-8').split('\n').slice(-lines).join('\n')
21
+ console.log(chalk.dim(`--- last ${lines} lines: ${logFile} ---\n`))
22
+ console.log(content)
23
+ })
24
+ }
@@ -0,0 +1,36 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import inquirer from 'inquirer'
4
+ import { hasPlaintextSecrets } from '../services/config-manager.js'
5
+ import { setSecret } from '../services/keychain.js'
6
+
7
+ export function migrateCommand(): Command {
8
+ return new Command('migrate')
9
+ .description('Move plaintext secrets from config files into the OS Keychain')
10
+ .action(async () => {
11
+ const found = await hasPlaintextSecrets()
12
+ if (found.length === 0) {
13
+ console.log(chalk.green('✓ No plaintext secrets found — you are already secure!'))
14
+ return
15
+ }
16
+
17
+ console.log(chalk.yellow(`\nFound ${found.length} plaintext secret(s) in your config:\n`))
18
+ for (const s of found) {
19
+ console.log(` ${chalk.cyan(s.serverId)} → ${s.key}: ${chalk.red(s.value.slice(0, 8) + '...')}`)
20
+ }
21
+
22
+ const { confirm } = await inquirer.prompt([{
23
+ name: 'confirm', type: 'confirm',
24
+ message: 'Move all to OS Keychain and remove from config?',
25
+ default: true
26
+ }])
27
+ if (!confirm) return
28
+
29
+ for (const s of found) {
30
+ await setSecret(s.serverId, s.key, s.value)
31
+ console.log(chalk.green(` ✓ ${s.serverId}:${s.key} → Keychain`))
32
+ }
33
+
34
+ console.log(chalk.green('\n✓ Migration complete. Restart your AI clients to apply.\n'))
35
+ })
36
+ }
@@ -0,0 +1,26 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import inquirer from 'inquirer'
4
+ import { removeServer } from '../services/config-manager.js'
5
+ import { listSecrets, deleteSecret } from '../services/keychain.js'
6
+
7
+ export function removeCommand(): Command {
8
+ return new Command('remove')
9
+ .description('Uninstall an MCP server')
10
+ .argument('<server-id>', 'Server to remove')
11
+ .action(async (serverId: string) => {
12
+ const { confirm } = await inquirer.prompt([{
13
+ name: 'confirm', type: 'confirm',
14
+ message: `Remove ${serverId} and its stored secrets?`,
15
+ default: false
16
+ }])
17
+ if (!confirm) return
18
+
19
+ await removeServer(serverId)
20
+ const secrets = await listSecrets(serverId)
21
+ for (const key of secrets) await deleteSecret(serverId, key)
22
+
23
+ console.log(chalk.green(`✓ ${serverId} removed`))
24
+ console.log(chalk.dim(' Restart Claude Desktop / Cursor to deactivate\n'))
25
+ })
26
+ }
@@ -0,0 +1,31 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import ora from 'ora'
4
+ import { searchServers } from '../services/smithery.js'
5
+
6
+ export function searchCommand(): Command {
7
+ return new Command('search')
8
+ .description('Search for MCP servers')
9
+ .argument('<query>', 'Search term')
10
+ .action(async (query: string) => {
11
+ const spinner = ora(`Searching for "${query}"...`).start()
12
+ try {
13
+ const results = await searchServers(query)
14
+ spinner.stop()
15
+ if (results.length === 0) {
16
+ console.log(chalk.yellow('No servers found.'))
17
+ return
18
+ }
19
+ console.log(chalk.bold(`\n${results.length} server(s) found:\n`))
20
+ for (const s of results) {
21
+ console.log(` ${chalk.cyan(s.id)}`)
22
+ console.log(` ${chalk.dim(s.description)}`)
23
+ console.log(` ${chalk.green(`inv install ${s.id}`)}\n`)
24
+ }
25
+ } catch (err) {
26
+ spinner.fail('Search failed')
27
+ console.error(chalk.red(String(err)))
28
+ process.exit(1)
29
+ }
30
+ })
31
+ }
@@ -0,0 +1,20 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { listInstalled } from '../services/config-manager.js'
4
+
5
+ export function statusCommand(): Command {
6
+ return new Command('status')
7
+ .description('Show installed MCP servers')
8
+ .action(async () => {
9
+ const servers = await listInstalled()
10
+ if (servers.length === 0) {
11
+ console.log(chalk.yellow('No servers installed. Run: inv search <query>'))
12
+ return
13
+ }
14
+ console.log(chalk.bold(`\nInstalled servers (${servers.length}):\n`))
15
+ for (const id of servers) {
16
+ console.log(` ${chalk.cyan('●')} ${id}`)
17
+ }
18
+ console.log()
19
+ })
20
+ }
@@ -0,0 +1,28 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import ora from 'ora'
4
+ import { listInstalled } from '../services/config-manager.js'
5
+ import { fetchManifest } from '../services/smithery.js'
6
+
7
+ export function updateCommand(): Command {
8
+ return new Command('update')
9
+ .description('Check for updates to installed MCP servers')
10
+ .action(async () => {
11
+ const servers = await listInstalled()
12
+ if (servers.length === 0) {
13
+ console.log(chalk.yellow('No servers installed.'))
14
+ return
15
+ }
16
+ console.log(chalk.bold('\nChecking for updates...\n'))
17
+ for (const id of servers) {
18
+ const spinner = ora(id).start()
19
+ try {
20
+ const manifest = await fetchManifest(id)
21
+ spinner.succeed(`${id} — latest: v${manifest.version}`)
22
+ } catch {
23
+ spinner.warn(`${id} — could not fetch latest version`)
24
+ }
25
+ }
26
+ console.log()
27
+ })
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander'
3
+ import { searchCommand } from './commands/search.js'
4
+ import { installCommand } from './commands/install.js'
5
+ import { removeCommand } from './commands/remove.js'
6
+ import { statusCommand } from './commands/status.js'
7
+ import { logsCommand } from './commands/logs.js'
8
+ import { updateCommand } from './commands/update.js'
9
+ import { migrateCommand } from './commands/migrate.js'
10
+
11
+ program
12
+ .name('inv')
13
+ .description('Install, run and host MCP servers')
14
+ .version('0.1.0')
15
+
16
+ program.addCommand(searchCommand())
17
+ program.addCommand(installCommand())
18
+ program.addCommand(removeCommand())
19
+ program.addCommand(statusCommand())
20
+ program.addCommand(logsCommand())
21
+ program.addCommand(updateCommand())
22
+ program.addCommand(migrateCommand())
23
+
24
+ program.parse()
@@ -0,0 +1,110 @@
1
+ import * as fs from 'fs/promises'
2
+ import os from 'os'
3
+ import path from 'path'
4
+
5
+ export interface ServerEntry {
6
+ command?: string
7
+ args?: string[]
8
+ env?: Record<string, string>
9
+ url?: string
10
+ }
11
+
12
+ export interface PlaintextSecret {
13
+ serverId: string
14
+ key: string
15
+ value: string
16
+ }
17
+
18
+ function claudeConfigPath(): string {
19
+ const home = os.homedir()
20
+ const platform = os.platform()
21
+ if (platform === 'win32') {
22
+ return path.join(process.env.APPDATA ?? home, 'Claude', 'claude_desktop_config.json')
23
+ }
24
+ if (platform === 'darwin') {
25
+ return path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
26
+ }
27
+ return path.join(home, '.config', 'claude', 'claude_desktop_config.json')
28
+ }
29
+
30
+ function cursorConfigPath(): string {
31
+ return path.join(os.homedir(), '.cursor', 'mcp.json')
32
+ }
33
+
34
+ async function readJson(filePath: string): Promise<any> {
35
+ try {
36
+ const raw = await fs.readFile(filePath, 'utf-8')
37
+ return JSON.parse(raw)
38
+ } catch {
39
+ return { mcpServers: {} }
40
+ }
41
+ }
42
+
43
+ async function writeJson(filePath: string, data: any): Promise<void> {
44
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
45
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
46
+ }
47
+
48
+ export async function detectClients(): Promise<string[]> {
49
+ const clients: string[] = []
50
+ for (const p of [claudeConfigPath(), cursorConfigPath()]) {
51
+ try { await fs.access(p); clients.push(p) } catch (err: any) { if (err?.code !== 'ENOENT') throw err }
52
+ }
53
+ return clients
54
+ }
55
+
56
+ export async function addServer(serverId: string, entry: ServerEntry): Promise<void> {
57
+ const claudePath = claudeConfigPath()
58
+ const config = await readJson(claudePath)
59
+ config.mcpServers = config.mcpServers ?? {}
60
+ config.mcpServers[serverId] = entry
61
+ await writeJson(claudePath, config)
62
+
63
+ const cursorPath = cursorConfigPath()
64
+ try {
65
+ await fs.access(cursorPath)
66
+ const cursorConfig = await readJson(cursorPath)
67
+ cursorConfig.mcpServers = cursorConfig.mcpServers ?? {}
68
+ cursorConfig.mcpServers[serverId] = entry
69
+ await writeJson(cursorPath, cursorConfig)
70
+ } catch (err: any) {
71
+ // Skip clients whose config directory doesn't exist yet
72
+ if (err?.code !== 'ENOENT') throw err
73
+ }
74
+ }
75
+
76
+ export async function removeServer(serverId: string): Promise<void> {
77
+ const claudePath = claudeConfigPath()
78
+ const config = await readJson(claudePath)
79
+ delete config.mcpServers?.[serverId]
80
+ await writeJson(claudePath, config)
81
+
82
+ const cursorPath = cursorConfigPath()
83
+ try {
84
+ await fs.access(cursorPath)
85
+ const cursorConfig = await readJson(cursorPath)
86
+ delete cursorConfig.mcpServers?.[serverId]
87
+ await writeJson(cursorPath, cursorConfig)
88
+ } catch (err: any) {
89
+ // Skip clients whose config directory doesn't exist yet
90
+ if (err?.code !== 'ENOENT') throw err
91
+ }
92
+ }
93
+
94
+ export async function listInstalled(): Promise<string[]> {
95
+ const config = await readJson(claudeConfigPath())
96
+ return Object.keys(config.mcpServers ?? {})
97
+ }
98
+
99
+ export async function hasPlaintextSecrets(): Promise<PlaintextSecret[]> {
100
+ const config = await readJson(claudeConfigPath())
101
+ const found: PlaintextSecret[] = []
102
+ for (const [serverId, entry] of Object.entries<any>(config.mcpServers ?? {})) {
103
+ for (const [key, value] of Object.entries<any>(entry.env ?? {})) {
104
+ if (typeof value === 'string' && !value.startsWith('keychain://')) {
105
+ found.push({ serverId, key, value })
106
+ }
107
+ }
108
+ }
109
+ return found
110
+ }
@@ -0,0 +1,26 @@
1
+ import keytar from 'keytar'
2
+
3
+ const SERVICE = 'mcpinv'
4
+
5
+ function accountKey(serverId: string, secretKey: string): string {
6
+ return `${serverId}:${secretKey}`
7
+ }
8
+
9
+ export async function setSecret(serverId: string, key: string, value: string): Promise<void> {
10
+ await keytar.setPassword(SERVICE, accountKey(serverId, key), value)
11
+ }
12
+
13
+ export async function getSecret(serverId: string, key: string): Promise<string | null> {
14
+ return keytar.getPassword(SERVICE, accountKey(serverId, key))
15
+ }
16
+
17
+ export async function deleteSecret(serverId: string, key: string): Promise<void> {
18
+ await keytar.deletePassword(SERVICE, accountKey(serverId, key))
19
+ }
20
+
21
+ export async function listSecrets(serverId: string): Promise<string[]> {
22
+ const creds = await keytar.findCredentials(SERVICE)
23
+ return creds
24
+ .filter(c => c.account.startsWith(`${serverId}:`))
25
+ .map(c => c.account.split(':')[1])
26
+ }
@@ -0,0 +1,35 @@
1
+ import axios from 'axios'
2
+ import type { McpServer } from '../types/index.js'
3
+
4
+ const BASE = 'https://registry.smithery.ai'
5
+
6
+ export async function searchServers(query: string): Promise<McpServer[]> {
7
+ const { data } = await axios.get(`${BASE}/servers`, {
8
+ params: { q: query, pageSize: 20 }
9
+ })
10
+ return (data.items ?? []).map(mapToServer)
11
+ }
12
+
13
+ export async function fetchManifest(id: string): Promise<McpServer> {
14
+ const { data } = await axios.get(`${BASE}/servers/${id}`)
15
+ return mapToServer(data)
16
+ }
17
+
18
+ function mapToServer(raw: any): McpServer {
19
+ const conn = raw.connections?.[0] ?? {}
20
+ return {
21
+ id: raw.qualifiedName,
22
+ name: raw.displayName ?? raw.qualifiedName,
23
+ description: raw.description ?? '',
24
+ version: raw.version ?? 'latest',
25
+ runtime: 'node',
26
+ secrets: (raw.environmentVariables ?? []).map((e: any) => ({
27
+ key: e.name,
28
+ description: e.description ?? '',
29
+ required: e.required ?? false
30
+ })),
31
+ installCommand: conn.command ?? 'npx',
32
+ args: conn.args ?? [],
33
+ source: 'smithery'
34
+ }
35
+ }
@@ -0,0 +1,31 @@
1
+ export interface McpServer {
2
+ id: string
3
+ name: string
4
+ description: string
5
+ version: string
6
+ runtime: 'node' | 'python' | 'binary'
7
+ secrets: SecretSpec[]
8
+ installCommand: string
9
+ args: string[]
10
+ source: 'smithery'
11
+ }
12
+
13
+ export interface SecretSpec {
14
+ key: string
15
+ description: string
16
+ required: boolean
17
+ }
18
+
19
+ export interface InstalledServer {
20
+ id: string
21
+ name: string
22
+ version: string
23
+ installedAt: string
24
+ remoteUrl?: string
25
+ }
26
+
27
+ export interface ClientConfig {
28
+ claude: string | null
29
+ cursor: string | null
30
+ cline: string | null
31
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+
3
+ vi.mock('../../src/services/smithery.js', () => ({
4
+ fetchManifest: vi.fn().mockResolvedValue({
5
+ id: 'github-mcp-server',
6
+ name: 'GitHub',
7
+ description: 'GitHub tools',
8
+ version: '1.0.0',
9
+ runtime: 'node',
10
+ secrets: [{ key: 'GITHUB_TOKEN', description: 'GitHub token', required: true }],
11
+ installCommand: 'npx',
12
+ args: ['-y', '@modelcontextprotocol/server-github'],
13
+ source: 'smithery'
14
+ })
15
+ }))
16
+ vi.mock('../../src/services/keychain.js', () => ({
17
+ setSecret: vi.fn().mockResolvedValue(undefined),
18
+ getSecret: vi.fn().mockResolvedValue(null)
19
+ }))
20
+ vi.mock('../../src/services/config-manager.js', () => ({
21
+ addServer: vi.fn().mockResolvedValue(undefined)
22
+ }))
23
+ vi.mock('inquirer', () => ({
24
+ default: { prompt: vi.fn().mockResolvedValue({ GITHUB_TOKEN: 'ghp_test' }) }
25
+ }))
26
+
27
+ describe('install command logic', () => {
28
+ it('fetches manifest, stores secret, injects config', async () => {
29
+ const { fetchManifest } = await import('../../src/services/smithery.js')
30
+ const { setSecret } = await import('../../src/services/keychain.js')
31
+ const { addServer } = await import('../../src/services/config-manager.js')
32
+ const inquirer = (await import('inquirer')).default
33
+
34
+ const manifest = await fetchManifest('github-mcp-server')
35
+ const answers = await inquirer.prompt([{ name: 'GITHUB_TOKEN', type: 'password', message: 'GitHub token:' }])
36
+ await setSecret(manifest.id, 'GITHUB_TOKEN', answers.GITHUB_TOKEN)
37
+ await addServer(manifest.id, {
38
+ command: manifest.installCommand,
39
+ args: manifest.args,
40
+ env: { GITHUB_TOKEN: `keychain://mcpinv/${manifest.id}:GITHUB_TOKEN` }
41
+ })
42
+
43
+ expect(fetchManifest).toHaveBeenCalledWith('github-mcp-server')
44
+ expect(setSecret).toHaveBeenCalledWith('github-mcp-server', 'GITHUB_TOKEN', 'ghp_test')
45
+ expect(addServer).toHaveBeenCalledWith('github-mcp-server', expect.objectContaining({
46
+ command: 'npx',
47
+ env: { GITHUB_TOKEN: 'keychain://mcpinv/github-mcp-server:GITHUB_TOKEN' }
48
+ }))
49
+ })
50
+ })
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { vol } from 'memfs'
3
+ import { detectClients, addServer, removeServer, listInstalled, hasPlaintextSecrets } from '../../src/services/config-manager.js'
4
+
5
+ vi.mock('fs/promises', () => import('memfs').then(m => m.fs.promises))
6
+ vi.mock('os', () => ({ default: { homedir: () => '/home/test', platform: () => 'linux' } }))
7
+
8
+ describe('config-manager', () => {
9
+ beforeEach(() => vol.reset())
10
+
11
+ it('addServer writes entry into Claude config', async () => {
12
+ vol.fromJSON({
13
+ '/home/test/.config/claude/claude_desktop_config.json': JSON.stringify({ mcpServers: {} })
14
+ })
15
+ await addServer('github-mcp-server', {
16
+ command: 'npx',
17
+ args: ['-y', '@modelcontextprotocol/server-github'],
18
+ env: { GITHUB_TOKEN: 'keychain://mcpinv/github-mcp-server:GITHUB_TOKEN' }
19
+ })
20
+ const raw = vol.readFileSync('/home/test/.config/claude/claude_desktop_config.json', 'utf8') as string
21
+ const config = JSON.parse(raw)
22
+ expect(config.mcpServers['github-mcp-server']).toBeDefined()
23
+ expect(config.mcpServers['github-mcp-server'].command).toBe('npx')
24
+ })
25
+
26
+ it('removeServer deletes entry from Claude config', async () => {
27
+ vol.fromJSON({
28
+ '/home/test/.config/claude/claude_desktop_config.json': JSON.stringify({
29
+ mcpServers: { 'github-mcp-server': { command: 'npx', args: [] } }
30
+ })
31
+ })
32
+ await removeServer('github-mcp-server')
33
+ const raw = vol.readFileSync('/home/test/.config/claude/claude_desktop_config.json', 'utf8') as string
34
+ const config = JSON.parse(raw)
35
+ expect(config.mcpServers['github-mcp-server']).toBeUndefined()
36
+ })
37
+
38
+ it('hasPlaintextSecrets detects tokens in config', async () => {
39
+ vol.fromJSON({
40
+ '/home/test/.config/claude/claude_desktop_config.json': JSON.stringify({
41
+ mcpServers: {
42
+ github: { command: 'npx', args: [], env: { GITHUB_TOKEN: 'ghp_realtoken123' } }
43
+ }
44
+ })
45
+ })
46
+ const found = await hasPlaintextSecrets()
47
+ expect(found).toHaveLength(1)
48
+ expect(found[0].key).toBe('GITHUB_TOKEN')
49
+ expect(found[0].serverId).toBe('github')
50
+ })
51
+ })
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { setSecret, getSecret, deleteSecret, listSecrets } from '../../src/services/keychain.js'
3
+
4
+ vi.mock('keytar', () => ({
5
+ default: {
6
+ setPassword: vi.fn().mockResolvedValue(undefined),
7
+ getPassword: vi.fn().mockResolvedValue('my-token'),
8
+ deletePassword: vi.fn().mockResolvedValue(true),
9
+ findCredentials: vi.fn().mockResolvedValue([
10
+ { account: 'github-mcp-server:GITHUB_TOKEN', password: 'tok' }
11
+ ])
12
+ }
13
+ }))
14
+
15
+ describe('keychain service', () => {
16
+ it('setSecret stores with service prefix', async () => {
17
+ const keytar = (await import('keytar')).default
18
+ await setSecret('github-mcp-server', 'GITHUB_TOKEN', 'ghp_abc')
19
+ expect(keytar.setPassword).toHaveBeenCalledWith(
20
+ 'mcpinv', 'github-mcp-server:GITHUB_TOKEN', 'ghp_abc'
21
+ )
22
+ })
23
+
24
+ it('getSecret retrieves stored secret', async () => {
25
+ const value = await getSecret('github-mcp-server', 'GITHUB_TOKEN')
26
+ expect(value).toBe('my-token')
27
+ })
28
+
29
+ it('deleteSecret removes entry', async () => {
30
+ await deleteSecret('github-mcp-server', 'GITHUB_TOKEN')
31
+ const keytar = (await import('keytar')).default
32
+ expect(keytar.deletePassword).toHaveBeenCalledWith('mcpinv', 'github-mcp-server:GITHUB_TOKEN')
33
+ })
34
+
35
+ it('listSecrets returns keys for server', async () => {
36
+ const keys = await listSecrets('github-mcp-server')
37
+ expect(keys).toContain('GITHUB_TOKEN')
38
+ })
39
+ })
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import axios from 'axios'
3
+ import { searchServers, fetchManifest } from '../../src/services/smithery.js'
4
+
5
+ vi.mock('axios')
6
+ const mockedAxios = vi.mocked(axios)
7
+
8
+ describe('smithery service', () => {
9
+ beforeEach(() => vi.clearAllMocks())
10
+
11
+ it('searchServers returns list for query', async () => {
12
+ mockedAxios.get = vi.fn().mockResolvedValue({
13
+ data: {
14
+ items: [
15
+ { qualifiedName: 'github-mcp-server', displayName: 'GitHub', description: 'GitHub tools', version: '1.0.0' }
16
+ ]
17
+ }
18
+ })
19
+ const results = await searchServers('github')
20
+ expect(results).toHaveLength(1)
21
+ expect(results[0].id).toBe('github-mcp-server')
22
+ })
23
+
24
+ it('fetchManifest returns server details', async () => {
25
+ mockedAxios.get = vi.fn().mockResolvedValue({
26
+ data: {
27
+ qualifiedName: 'github-mcp-server',
28
+ displayName: 'GitHub',
29
+ description: 'GitHub MCP tools',
30
+ version: '1.2.0',
31
+ runtime: 'node',
32
+ connections: [{ type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] }],
33
+ environmentVariables: [{ name: 'GITHUB_TOKEN', description: 'GitHub personal access token', required: true }]
34
+ }
35
+ })
36
+ const manifest = await fetchManifest('github-mcp-server')
37
+ expect(manifest.id).toBe('github-mcp-server')
38
+ expect(manifest.secrets).toHaveLength(1)
39
+ expect(manifest.secrets[0].key).toBe('GITHUB_TOKEN')
40
+ })
41
+
42
+ it('searchServers returns empty array when no results', async () => {
43
+ mockedAxios.get = vi.fn().mockResolvedValue({ data: { items: [] } })
44
+ const results = await searchServers('nonexistent-xyz-123')
45
+ expect(results).toEqual([])
46
+ })
47
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src/**/*"],
14
+ "exclude": ["node_modules", "dist", "tests"]
15
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ coverage: { reporter: ['text'] }
7
+ }
8
+ })