@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.
- package/README.md +36 -0
- package/dist/commands/install.d.ts +2 -0
- package/dist/commands/install.js +52 -0
- package/dist/commands/logs.d.ts +2 -0
- package/dist/commands/logs.js +23 -0
- package/dist/commands/migrate.d.ts +2 -0
- package/dist/commands/migrate.js +32 -0
- package/dist/commands/remove.d.ts +2 -0
- package/dist/commands/remove.js +25 -0
- package/dist/commands/search.d.ts +2 -0
- package/dist/commands/search.js +31 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +19 -0
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +28 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +21 -0
- package/dist/services/config-manager.d.ts +16 -0
- package/dist/services/config-manager.js +98 -0
- package/dist/services/keychain.d.ts +4 -0
- package/dist/services/keychain.js +20 -0
- package/dist/services/smithery.d.ts +3 -0
- package/dist/services/smithery.js +30 -0
- package/dist/types/index.d.ts +28 -0
- package/dist/types/index.js +1 -0
- package/package.json +32 -0
- package/src/commands/install.ts +54 -0
- package/src/commands/logs.ts +24 -0
- package/src/commands/migrate.ts +36 -0
- package/src/commands/remove.ts +26 -0
- package/src/commands/search.ts +31 -0
- package/src/commands/status.ts +20 -0
- package/src/commands/update.ts +28 -0
- package/src/index.ts +24 -0
- package/src/services/config-manager.ts +110 -0
- package/src/services/keychain.ts +26 -0
- package/src/services/smithery.ts +35 -0
- package/src/types/index.ts +31 -0
- package/tests/commands/install.test.ts +50 -0
- package/tests/services/config-manager.test.ts +51 -0
- package/tests/services/keychain.test.ts +39 -0
- package/tests/services/smithery.test.ts +47 -0
- package/tsconfig.json +15 -0
- 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,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,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,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,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,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,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,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
|
+
}
|
package/dist/index.d.ts
ADDED
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,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
|
+
}
|