@evantahler/mcpcli 0.1.4 → 0.2.3

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 CHANGED
@@ -65,6 +65,9 @@ mcpcli search -q "manage pull requests"
65
65
  | `mcpcli auth <server> -s` | Check auth status and token TTL |
66
66
  | `mcpcli auth <server> -r` | Force token refresh |
67
67
  | `mcpcli deauth <server>` | Remove stored authentication for a server |
68
+ | `mcpcli add <name> --command <cmd>` | Add a stdio MCP server to your config |
69
+ | `mcpcli add <name> --url <url>` | Add an HTTP MCP server to your config |
70
+ | `mcpcli remove <name>` | Remove an MCP server from your config |
68
71
 
69
72
  ## Options
70
73
 
@@ -79,6 +82,57 @@ mcpcli search -q "manage pull requests"
79
82
  | `-j, --json` | Force JSON output (default when piped) |
80
83
  | `--no-daemon` | Disable connection pooling |
81
84
 
85
+ ## Managing Servers
86
+
87
+ Add and remove servers from the CLI — no manual JSON editing required.
88
+
89
+ ```bash
90
+ # Add a stdio server
91
+ mcpcli add filesystem --command npx --args "-y,@modelcontextprotocol/server-filesystem,/tmp"
92
+
93
+ # Add an HTTP server with headers
94
+ mcpcli add my-api --url https://api.example.com/mcp --header "Authorization:Bearer tok123"
95
+
96
+ # Add with tool filtering
97
+ mcpcli add github --url https://mcp.github.com --allowed-tools "search_*,get_*"
98
+
99
+ # Add with environment variables
100
+ mcpcli add my-server --command node --args "server.js" --env "API_KEY=sk-123,DEBUG=true"
101
+
102
+ # Overwrite an existing server
103
+ mcpcli add filesystem --command echo --force
104
+
105
+ # Remove a server (also cleans up auth.json)
106
+ mcpcli remove filesystem
107
+
108
+ # Remove but keep stored auth credentials
109
+ mcpcli remove my-api --keep-auth
110
+
111
+ # Preview what would be removed
112
+ mcpcli remove my-api --dry-run
113
+ ```
114
+
115
+ **`add` options:**
116
+
117
+ | Flag | Purpose |
118
+ | -------------------------- | -------------------------------------- |
119
+ | `--command <cmd>` | Command to run (stdio server) |
120
+ | `--args <a1,a2,...>` | Comma-separated arguments |
121
+ | `--env <KEY=VAL,...>` | Comma-separated environment variables |
122
+ | `--cwd <dir>` | Working directory for the command |
123
+ | `--url <url>` | Server URL (HTTP server) |
124
+ | `--header <Key:Value>` | HTTP header (repeatable) |
125
+ | `--allowed-tools <t1,t2>` | Comma-separated allowed tool patterns |
126
+ | `--disabled-tools <t1,t2>` | Comma-separated disabled tool patterns |
127
+ | `-f, --force` | Overwrite if server already exists |
128
+
129
+ **`remove` options:**
130
+
131
+ | Flag | Purpose |
132
+ | ------------- | ------------------------------------------------- |
133
+ | `--keep-auth` | Don't remove stored auth credentials |
134
+ | `--dry-run` | Show what would be removed without changing files |
135
+
82
136
  ## Configuration
83
137
 
84
138
  Config lives in `~/.config/mcpcli/` (or the current directory). Three files:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evantahler/mcpcli",
3
- "version": "0.1.4",
3
+ "version": "0.2.3",
4
4
  "description": "A command-line interface for MCP servers. curl for MCP.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -7,6 +7,8 @@ import { registerSearchCommand } from "./commands/search.ts";
7
7
  import { registerCallCommand } from "./commands/call.ts";
8
8
  import { registerAuthCommand, registerDeauthCommand } from "./commands/auth.ts";
9
9
  import { registerIndexCommand } from "./commands/index.ts";
10
+ import { registerAddCommand } from "./commands/add.ts";
11
+ import { registerRemoveCommand } from "./commands/remove.ts";
10
12
 
11
13
  declare const BUILD_VERSION: string | undefined;
12
14
 
@@ -20,8 +22,7 @@ program
20
22
  .option("-d, --with-descriptions", "include tool descriptions in output")
21
23
  .option("-j, --json", "force JSON output")
22
24
  .option("-v, --verbose", "show HTTP request/response details")
23
- .option("-S, --show-secrets", "show full auth tokens in verbose output")
24
- .option("--no-daemon", "disable connection pooling");
25
+ .option("-S, --show-secrets", "show full auth tokens in verbose output");
25
26
 
26
27
  registerListCommand(program);
27
28
  registerInfoCommand(program);
@@ -30,5 +31,7 @@ registerCallCommand(program);
30
31
  registerAuthCommand(program);
31
32
  registerDeauthCommand(program);
32
33
  registerIndexCommand(program);
34
+ registerAddCommand(program);
35
+ registerRemoveCommand(program);
33
36
 
34
37
  program.parse();
@@ -17,6 +17,17 @@ export interface ServerError {
17
17
  message: string;
18
18
  }
19
19
 
20
+ export interface ServerManagerOptions {
21
+ servers: ServersFile;
22
+ configDir: string;
23
+ auth: AuthFile;
24
+ concurrency?: number;
25
+ verbose?: boolean;
26
+ showSecrets?: boolean;
27
+ timeout?: number; // ms, default 1_800_000 (30 min)
28
+ maxRetries?: number; // default 3
29
+ }
30
+
20
31
  export class ServerManager {
21
32
  private clients = new Map<string, Client>();
22
33
  private transports = new Map<string, Transport>();
@@ -27,21 +38,18 @@ export class ServerManager {
27
38
  private concurrency: number;
28
39
  private verbose: boolean;
29
40
  private showSecrets: boolean;
41
+ private timeout: number;
42
+ private maxRetries: number;
30
43
 
31
- constructor(
32
- servers: ServersFile,
33
- configDir: string,
34
- auth: AuthFile,
35
- concurrency = 5,
36
- verbose = false,
37
- showSecrets = false,
38
- ) {
39
- this.servers = servers;
40
- this.configDir = configDir;
41
- this.auth = auth;
42
- this.concurrency = concurrency;
43
- this.verbose = verbose;
44
- this.showSecrets = showSecrets;
44
+ constructor(opts: ServerManagerOptions) {
45
+ this.servers = opts.servers;
46
+ this.configDir = opts.configDir;
47
+ this.auth = opts.auth;
48
+ this.concurrency = opts.concurrency ?? 5;
49
+ this.verbose = opts.verbose ?? false;
50
+ this.showSecrets = opts.showSecrets ?? false;
51
+ this.timeout = opts.timeout ?? 1_800_000;
52
+ this.maxRetries = opts.maxRetries ?? 3;
45
53
  }
46
54
 
47
55
  /** Get or create a connected client for a server */
@@ -71,7 +79,7 @@ export class ServerManager {
71
79
  this.transports.set(serverName, transport);
72
80
 
73
81
  const client = new Client({ name: "mcpcli", version: "0.1.0" });
74
- await client.connect(transport);
82
+ await this.withTimeout(client.connect(transport), `connect(${serverName})`);
75
83
  this.clients.set(serverName, client);
76
84
 
77
85
  return client;
@@ -110,12 +118,57 @@ export class ServerManager {
110
118
  throw new Error("Invalid server config");
111
119
  }
112
120
 
121
+ /** Race a promise against a timeout */
122
+ private withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
123
+ if (this.timeout <= 0) return promise;
124
+ let timer: ReturnType<typeof setTimeout>;
125
+ return Promise.race([
126
+ promise.finally(() => clearTimeout(timer)),
127
+ new Promise<never>((_, reject) => {
128
+ timer = setTimeout(
129
+ () => reject(new Error(`${label}: timed out after ${this.timeout / 1000}s`)),
130
+ this.timeout,
131
+ );
132
+ timer.unref();
133
+ }),
134
+ ]);
135
+ }
136
+
137
+ /** Retry a function up to maxRetries times, clearing cached client between attempts */
138
+ private async withRetry<T>(fn: () => Promise<T>, label: string, serverName?: string): Promise<T> {
139
+ let lastError: Error | undefined;
140
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
141
+ try {
142
+ return await fn();
143
+ } catch (err) {
144
+ lastError = err instanceof Error ? err : new Error(String(err));
145
+ if (attempt < this.maxRetries && serverName) {
146
+ // Clear cached client so next attempt reconnects fresh
147
+ try {
148
+ await this.clients.get(serverName)?.close();
149
+ } catch {
150
+ // ignore close errors
151
+ }
152
+ this.clients.delete(serverName);
153
+ this.transports.delete(serverName);
154
+ }
155
+ }
156
+ }
157
+ throw lastError;
158
+ }
159
+
113
160
  /** List tools for a single server, applying allowedTools/disabledTools filters */
114
161
  async listTools(serverName: string): Promise<Tool[]> {
115
- const client = await this.getClient(serverName);
116
- const result = await client.listTools();
117
- const config = this.servers.mcpServers[serverName]!;
118
- return filterTools(result.tools, config.allowedTools, config.disabledTools);
162
+ return this.withRetry(
163
+ async () => {
164
+ const client = await this.getClient(serverName);
165
+ const result = await this.withTimeout(client.listTools(), `listTools(${serverName})`);
166
+ const config = this.servers.mcpServers[serverName]!;
167
+ return filterTools(result.tools, config.allowedTools, config.disabledTools);
168
+ },
169
+ `listTools(${serverName})`,
170
+ serverName,
171
+ );
119
172
  }
120
173
 
121
174
  /** List tools across all configured servers */
@@ -156,8 +209,17 @@ export class ServerManager {
156
209
  toolName: string,
157
210
  args: Record<string, unknown> = {},
158
211
  ): Promise<unknown> {
159
- const client = await this.getClient(serverName);
160
- return client.callTool({ name: toolName, arguments: args });
212
+ return this.withRetry(
213
+ async () => {
214
+ const client = await this.getClient(serverName);
215
+ return this.withTimeout(
216
+ client.callTool({ name: toolName, arguments: args }),
217
+ `callTool(${serverName}/${toolName})`,
218
+ );
219
+ },
220
+ `callTool(${serverName}/${toolName})`,
221
+ serverName,
222
+ );
161
223
  }
162
224
 
163
225
  /** Get the schema for a specific tool */
@@ -0,0 +1,137 @@
1
+ import type { Command } from "commander";
2
+ import type { ServerConfig } from "../config/schemas.ts";
3
+ import { loadRawServers, saveServers } from "../config/loader.ts";
4
+ import { runIndex } from "./index.ts";
5
+
6
+ export function registerAddCommand(program: Command) {
7
+ program
8
+ .command("add <name>")
9
+ .description("add an MCP server to your config")
10
+ .option("--command <cmd>", "command to run (stdio server)")
11
+ .option("--args <args>", "comma-separated arguments for the command")
12
+ .option("--env <vars>", "comma-separated KEY=VAL environment variables")
13
+ .option("--cwd <dir>", "working directory for the command")
14
+ .option("--url <url>", "server URL (HTTP server)")
15
+ .option("--header <h>", "header in Key:Value format (repeatable)", collect, [])
16
+ .option("--allowed-tools <tools>", "comma-separated list of allowed tools")
17
+ .option("--disabled-tools <tools>", "comma-separated list of disabled tools")
18
+ .option("-f, --force", "overwrite if server already exists")
19
+ .option("--no-index", "skip rebuilding the search index after adding")
20
+ .action(
21
+ async (
22
+ name: string,
23
+ options: {
24
+ command?: string;
25
+ args?: string;
26
+ env?: string;
27
+ cwd?: string;
28
+ url?: string;
29
+ header?: string[];
30
+ allowedTools?: string;
31
+ disabledTools?: string;
32
+ force?: boolean;
33
+ index?: boolean;
34
+ },
35
+ ) => {
36
+ const hasCommand = !!options.command;
37
+ const hasUrl = !!options.url;
38
+
39
+ if (!hasCommand && !hasUrl) {
40
+ console.error("Must specify --command (stdio) or --url (http)");
41
+ process.exit(1);
42
+ }
43
+ if (hasCommand && hasUrl) {
44
+ console.error("Cannot specify both --command and --url");
45
+ process.exit(1);
46
+ }
47
+
48
+ const configFlag = program.opts().config;
49
+ const { configDir, servers } = await loadRawServers(configFlag);
50
+
51
+ if (servers.mcpServers[name] && !options.force) {
52
+ console.error(`Server "${name}" already exists (use --force to overwrite)`);
53
+ process.exit(1);
54
+ }
55
+
56
+ let config: ServerConfig;
57
+
58
+ if (hasCommand) {
59
+ config = buildStdioConfig(options);
60
+ } else {
61
+ config = buildHttpConfig(options);
62
+ }
63
+
64
+ // Common options
65
+ if (options.allowedTools) {
66
+ config.allowedTools = options.allowedTools.split(",").map((t) => t.trim());
67
+ }
68
+ if (options.disabledTools) {
69
+ config.disabledTools = options.disabledTools.split(",").map((t) => t.trim());
70
+ }
71
+
72
+ servers.mcpServers[name] = config;
73
+ await saveServers(configDir, servers);
74
+ console.log(`Added server "${name}" to ${configDir}/servers.json`);
75
+
76
+ // Commander treats --no-index as index=false (default true)
77
+ if (options.index !== false) {
78
+ await runIndex(program);
79
+ }
80
+ },
81
+ );
82
+ }
83
+
84
+ function collect(value: string, previous: string[]): string[] {
85
+ return previous.concat([value]);
86
+ }
87
+
88
+ function buildStdioConfig(options: {
89
+ command?: string;
90
+ args?: string;
91
+ env?: string;
92
+ cwd?: string;
93
+ }): ServerConfig {
94
+ const config: Record<string, unknown> = { command: options.command! };
95
+
96
+ if (options.args) {
97
+ config.args = options.args.split(",").map((a) => a.trim());
98
+ }
99
+
100
+ if (options.env) {
101
+ const env: Record<string, string> = {};
102
+ for (const pair of options.env.split(",")) {
103
+ const eqIdx = pair.indexOf("=");
104
+ if (eqIdx === -1) {
105
+ console.error(`Invalid env format "${pair}", expected KEY=VAL`);
106
+ process.exit(1);
107
+ }
108
+ env[pair.slice(0, eqIdx).trim()] = pair.slice(eqIdx + 1).trim();
109
+ }
110
+ config.env = env;
111
+ }
112
+
113
+ if (options.cwd) {
114
+ config.cwd = options.cwd;
115
+ }
116
+
117
+ return config as ServerConfig;
118
+ }
119
+
120
+ function buildHttpConfig(options: { url?: string; header?: string[] }): ServerConfig {
121
+ const config: Record<string, unknown> = { url: options.url! };
122
+
123
+ if (options.header && options.header.length > 0) {
124
+ const headers: Record<string, string> = {};
125
+ for (const h of options.header) {
126
+ const colonIdx = h.indexOf(":");
127
+ if (colonIdx === -1) {
128
+ console.error(`Invalid header format "${h}", expected Key:Value`);
129
+ process.exit(1);
130
+ }
131
+ headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim();
132
+ }
133
+ config.headers = headers;
134
+ }
135
+
136
+ return config as ServerConfig;
137
+ }
@@ -4,6 +4,7 @@ import { isHttpServer } from "../config/schemas.ts";
4
4
  import { saveAuth } from "../config/loader.ts";
5
5
  import { McpOAuthProvider, runOAuthFlow } from "../client/oauth.ts";
6
6
  import { startSpinner } from "../output/spinner.ts";
7
+ import { runIndex } from "./index.ts";
7
8
 
8
9
  export function registerAuthCommand(program: Command) {
9
10
  program
@@ -11,54 +12,61 @@ export function registerAuthCommand(program: Command) {
11
12
  .description("authenticate with an HTTP MCP server")
12
13
  .option("-s, --status", "check auth status and token TTL")
13
14
  .option("-r, --refresh", "force token refresh")
14
- .action(async (server: string, options: { status?: boolean; refresh?: boolean }) => {
15
- const { config, formatOptions } = await getContext(program);
15
+ .option("--no-index", "skip rebuilding the search index after auth")
16
+ .action(
17
+ async (server: string, options: { status?: boolean; refresh?: boolean; index?: boolean }) => {
18
+ const { config, formatOptions } = await getContext(program);
16
19
 
17
- const serverConfig = config.servers.mcpServers[server];
18
- if (!serverConfig) {
19
- console.error(`Unknown server: "${server}"`);
20
- process.exit(1);
21
- }
22
- if (!isHttpServer(serverConfig)) {
23
- console.error(
24
- `Server "${server}" is not an HTTP server — OAuth only applies to HTTP servers`,
25
- );
26
- process.exit(1);
27
- }
20
+ const serverConfig = config.servers.mcpServers[server];
21
+ if (!serverConfig) {
22
+ console.error(`Unknown server: "${server}"`);
23
+ process.exit(1);
24
+ }
25
+ if (!isHttpServer(serverConfig)) {
26
+ console.error(
27
+ `Server "${server}" is not an HTTP server — OAuth only applies to HTTP servers`,
28
+ );
29
+ process.exit(1);
30
+ }
28
31
 
29
- const provider = new McpOAuthProvider({
30
- serverName: server,
31
- configDir: config.configDir,
32
- auth: config.auth,
33
- });
32
+ const provider = new McpOAuthProvider({
33
+ serverName: server,
34
+ configDir: config.configDir,
35
+ auth: config.auth,
36
+ });
34
37
 
35
- if (options.status) {
36
- showStatus(server, provider);
37
- return;
38
- }
38
+ if (options.status) {
39
+ showStatus(server, provider);
40
+ return;
41
+ }
42
+
43
+ if (options.refresh) {
44
+ const spinner = startSpinner(`Refreshing token for "${server}"…`, formatOptions);
45
+ try {
46
+ await provider.refreshIfNeeded(serverConfig.url);
47
+ spinner.success(`Token refreshed for "${server}"`);
48
+ } catch (err) {
49
+ spinner.error(`Refresh failed: ${err instanceof Error ? err.message : err}`);
50
+ process.exit(1);
51
+ }
52
+ return;
53
+ }
39
54
 
40
- if (options.refresh) {
41
- const spinner = startSpinner(`Refreshing token for "${server}"…`, formatOptions);
55
+ // Default: full OAuth flow
56
+ const spinner = startSpinner(`Authenticating with "${server}"…`, formatOptions);
42
57
  try {
43
- await provider.refreshIfNeeded(serverConfig.url);
44
- spinner.success(`Token refreshed for "${server}"`);
58
+ await runOAuthFlow(serverConfig.url, provider);
59
+ spinner.success(`Authenticated with "${server}"`);
45
60
  } catch (err) {
46
- spinner.error(`Refresh failed: ${err instanceof Error ? err.message : err}`);
61
+ spinner.error(`Authentication failed: ${err instanceof Error ? err.message : err}`);
47
62
  process.exit(1);
48
63
  }
49
- return;
50
- }
51
64
 
52
- // Default: full OAuth flow
53
- const spinner = startSpinner(`Authenticating with "${server}"…`, formatOptions);
54
- try {
55
- await runOAuthFlow(serverConfig.url, provider);
56
- spinner.success(`Authenticated with "${server}"`);
57
- } catch (err) {
58
- spinner.error(`Authentication failed: ${err instanceof Error ? err.message : err}`);
59
- process.exit(1);
60
- }
61
- });
65
+ if (options.index !== false) {
66
+ await runIndex(program);
67
+ }
68
+ },
69
+ );
62
70
  }
63
71
 
64
72
  export function registerDeauthCommand(program: Command) {
@@ -1,20 +1,47 @@
1
1
  import type { Command } from "commander";
2
- import { dim } from "ansis";
2
+ import { dim, yellow } from "ansis";
3
3
  import { getContext } from "../context.ts";
4
4
  import { buildSearchIndex } from "../search/indexer.ts";
5
+ import { getStaleServers } from "../search/staleness.ts";
5
6
  import { saveSearchIndex } from "../config/loader.ts";
6
7
  import { formatError } from "../output/formatter.ts";
7
8
  import { startSpinner } from "../output/spinner.ts";
8
9
 
10
+ /** Run the search index build. Reusable from other commands (e.g. add). */
11
+ export async function runIndex(program: Command): Promise<void> {
12
+ const { config, manager, formatOptions } = await getContext(program);
13
+ const spinner = startSpinner("Connecting to servers...", formatOptions);
14
+
15
+ try {
16
+ const start = performance.now();
17
+ const index = await buildSearchIndex(manager, (progress) => {
18
+ spinner.update(`Indexing ${progress.current}/${progress.total}: ${progress.tool}`);
19
+ });
20
+ const elapsed = ((performance.now() - start) / 1000).toFixed(1);
21
+
22
+ await saveSearchIndex(config.configDir, index);
23
+ spinner.success(`Indexed ${index.tools.length} tools in ${elapsed}s`);
24
+
25
+ if (process.stderr.isTTY) {
26
+ process.stderr.write(dim(`Saved to ${config.configDir}/search.json\n`));
27
+ }
28
+ } catch (err) {
29
+ spinner.error("Indexing failed");
30
+ console.error(formatError(String(err), formatOptions));
31
+ process.exit(1);
32
+ } finally {
33
+ await manager.close();
34
+ }
35
+ }
36
+
9
37
  export function registerIndexCommand(program: Command) {
10
38
  program
11
39
  .command("index")
12
40
  .description("build the search index from all configured servers")
13
41
  .option("-i, --status", "show index status")
14
42
  .action(async (options: { status?: boolean }) => {
15
- const { config, manager, formatOptions } = await getContext(program);
16
-
17
43
  if (options.status) {
44
+ const { config, manager } = await getContext(program);
18
45
  const idx = config.searchIndex;
19
46
  if (idx.tools.length === 0) {
20
47
  console.log("No search index. Run: mcpcli index");
@@ -22,32 +49,16 @@ export function registerIndexCommand(program: Command) {
22
49
  console.log(`Tools: ${idx.tools.length}`);
23
50
  console.log(`Model: ${idx.embedding_model}`);
24
51
  console.log(`Indexed: ${idx.indexed_at}`);
52
+
53
+ const stale = getStaleServers(idx, config.servers);
54
+ if (stale.length > 0) {
55
+ console.log(yellow(`Stale: ${stale.join(", ")} (run mcpcli index to refresh)`));
56
+ }
25
57
  }
26
58
  await manager.close();
27
59
  return;
28
60
  }
29
61
 
30
- const spinner = startSpinner("Connecting to servers...", formatOptions);
31
-
32
- try {
33
- const start = performance.now();
34
- const index = await buildSearchIndex(manager, (progress) => {
35
- spinner.update(`Indexing ${progress.current}/${progress.total}: ${progress.tool}`);
36
- });
37
- const elapsed = ((performance.now() - start) / 1000).toFixed(1);
38
-
39
- await saveSearchIndex(config.configDir, index);
40
- spinner.success(`Indexed ${index.tools.length} tools in ${elapsed}s`);
41
-
42
- if (process.stderr.isTTY) {
43
- process.stderr.write(dim(`Saved to ${config.configDir}/search.json\n`));
44
- }
45
- } catch (err) {
46
- spinner.error("Indexing failed");
47
- console.error(formatError(String(err), formatOptions));
48
- process.exit(1);
49
- } finally {
50
- await manager.close();
51
- }
62
+ await runIndex(program);
52
63
  });
53
64
  }
@@ -0,0 +1,67 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ loadRawServers,
4
+ loadRawAuth,
5
+ loadSearchIndex,
6
+ saveServers,
7
+ saveAuth,
8
+ saveSearchIndex,
9
+ } from "../config/loader.ts";
10
+
11
+ export function registerRemoveCommand(program: Command) {
12
+ program
13
+ .command("remove <name>")
14
+ .description("remove an MCP server from your config")
15
+ .option("--keep-auth", "keep stored authentication credentials")
16
+ .option("--dry-run", "show what would be removed without changing files")
17
+ .action(async (name: string, options: { keepAuth?: boolean; dryRun?: boolean }) => {
18
+ const configFlag = program.opts().config;
19
+ const { configDir, servers } = await loadRawServers(configFlag);
20
+
21
+ if (!servers.mcpServers[name]) {
22
+ console.error(`Unknown server: "${name}"`);
23
+ process.exit(1);
24
+ }
25
+
26
+ if (options.dryRun) {
27
+ console.log(`Would remove server "${name}" from ${configDir}/servers.json`);
28
+ if (!options.keepAuth) {
29
+ const auth = await loadRawAuth(configDir);
30
+ if (auth[name]) {
31
+ console.log(`Would remove auth for "${name}" from ${configDir}/auth.json`);
32
+ }
33
+ }
34
+ const searchIndex = await loadSearchIndex(configDir);
35
+ const indexedCount = searchIndex.tools.filter((t) => t.server === name).length;
36
+ if (indexedCount > 0) {
37
+ console.log(
38
+ `Would remove ${indexedCount} tool(s) for "${name}" from ${configDir}/search.json`,
39
+ );
40
+ }
41
+ return;
42
+ }
43
+
44
+ delete servers.mcpServers[name];
45
+ await saveServers(configDir, servers);
46
+ console.log(`Removed server "${name}" from ${configDir}/servers.json`);
47
+
48
+ if (!options.keepAuth) {
49
+ const auth = await loadRawAuth(configDir);
50
+ if (auth[name]) {
51
+ delete auth[name];
52
+ await saveAuth(configDir, auth);
53
+ console.log(`Removed auth for "${name}" from ${configDir}/auth.json`);
54
+ }
55
+ }
56
+
57
+ // Remove tools for this server from the search index
58
+ const searchIndex = await loadSearchIndex(configDir);
59
+ const before = searchIndex.tools.length;
60
+ searchIndex.tools = searchIndex.tools.filter((t) => t.server !== name);
61
+ const removed = before - searchIndex.tools.length;
62
+ if (removed > 0) {
63
+ await saveSearchIndex(configDir, searchIndex);
64
+ console.log(`Removed ${removed} tool(s) for "${name}" from ${configDir}/search.json`);
65
+ }
66
+ });
67
+ }
@@ -1,6 +1,8 @@
1
1
  import type { Command } from "commander";
2
+ import { yellow } from "ansis";
2
3
  import { getContext } from "../context.ts";
3
4
  import { search } from "../search/index.ts";
5
+ import { getStaleServers } from "../search/staleness.ts";
4
6
  import { formatError, formatSearchResults } from "../output/formatter.ts";
5
7
  import { startSpinner } from "../output/spinner.ts";
6
8
 
@@ -19,6 +21,15 @@ export function registerSearchCommand(program: Command) {
19
21
  process.exit(1);
20
22
  }
21
23
 
24
+ const stale = getStaleServers(config.searchIndex, config.servers);
25
+ if (stale.length > 0) {
26
+ process.stderr.write(
27
+ yellow(
28
+ `Warning: index has tools for removed servers: ${stale.join(", ")}. Run: mcpcli index\n`,
29
+ ),
30
+ );
31
+ }
32
+
22
33
  const spinner = startSpinner("Searching...", formatOptions);
23
34
 
24
35
  try {
@@ -112,7 +112,45 @@ export async function saveAuth(configDir: string, auth: AuthFile): Promise<void>
112
112
  await Bun.write(join(configDir, "auth.json"), JSON.stringify(auth, null, 2) + "\n");
113
113
  }
114
114
 
115
+ /** Load search.json from the config directory */
116
+ export async function loadSearchIndex(configDir: string): Promise<SearchIndex> {
117
+ const raw = await readJsonFile(join(configDir, "search.json"));
118
+ return raw !== undefined ? validateSearchIndex(raw) : { ...EMPTY_SEARCH_INDEX };
119
+ }
120
+
115
121
  /** Save search.json to the config directory */
116
122
  export async function saveSearchIndex(configDir: string, index: SearchIndex): Promise<void> {
117
123
  await Bun.write(join(configDir, "search.json"), JSON.stringify(index, null, 2) + "\n");
118
124
  }
125
+
126
+ /** Save servers.json to the config directory */
127
+ export async function saveServers(configDir: string, servers: ServersFile): Promise<void> {
128
+ await Bun.write(join(configDir, "servers.json"), JSON.stringify(servers, null, 2) + "\n");
129
+ }
130
+
131
+ /** Load servers.json without env interpolation (preserves ${VAR} placeholders) */
132
+ export async function loadRawServers(
133
+ configFlag?: string,
134
+ ): Promise<{ configDir: string; servers: ServersFile }> {
135
+ let configDir = resolveConfigDir(configFlag);
136
+
137
+ if (!(await hasServersFile(configDir))) {
138
+ const cwd = process.cwd();
139
+ if (await hasServersFile(cwd)) {
140
+ configDir = cwd;
141
+ }
142
+ }
143
+
144
+ const serversPath = join(configDir, "servers.json");
145
+ const raw = await readJsonFile(serversPath);
146
+ const servers = raw !== undefined ? validateServersFile(raw) : EMPTY_SERVERS;
147
+
148
+ return { configDir, servers };
149
+ }
150
+
151
+ /** Load auth.json without loading the full config */
152
+ export async function loadRawAuth(configDir: string): Promise<AuthFile> {
153
+ const authPath = join(configDir, "auth.json");
154
+ const raw = await readJsonFile(authPath);
155
+ return raw !== undefined ? validateAuthFile(raw) : EMPTY_AUTH;
156
+ }
package/src/context.ts CHANGED
@@ -18,17 +18,26 @@ export async function getContext(program: Command): Promise<AppContext> {
18
18
  configFlag: opts.config as string | undefined,
19
19
  });
20
20
 
21
- const verbose = !!(opts.verbose as boolean | undefined);
21
+ const verbose = !!(
22
+ (opts.verbose as boolean | undefined) ||
23
+ process.env.MCP_DEBUG === "1" ||
24
+ process.env.MCP_DEBUG === "true"
25
+ );
22
26
  const showSecrets = !!(opts.showSecrets as boolean | undefined);
23
27
  const concurrency = Number(process.env.MCP_CONCURRENCY ?? 5);
24
- const manager = new ServerManager(
25
- config.servers,
26
- config.configDir,
27
- config.auth,
28
+ const timeout = Number(process.env.MCP_TIMEOUT ?? 1800) * 1000;
29
+ const maxRetries = Number(process.env.MCP_MAX_RETRIES ?? 3);
30
+
31
+ const manager = new ServerManager({
32
+ servers: config.servers,
33
+ configDir: config.configDir,
34
+ auth: config.auth,
28
35
  concurrency,
29
36
  verbose,
30
37
  showSecrets,
31
- );
38
+ timeout,
39
+ maxRetries,
40
+ });
32
41
 
33
42
  const formatOptions: FormatOptions = {
34
43
  json: opts.json as boolean | undefined,
@@ -0,0 +1,8 @@
1
+ import type { SearchIndex, ServersFile } from "../config/schemas.ts";
2
+
3
+ /** Return server names that appear in the index but not in the current config */
4
+ export function getStaleServers(index: SearchIndex, servers: ServersFile): string[] {
5
+ const configured = new Set(Object.keys(servers.mcpServers));
6
+ const indexed = new Set(index.tools.map((t) => t.server));
7
+ return [...indexed].filter((s) => !configured.has(s));
8
+ }