@evantahler/mcpcli 0.2.1 → 0.2.4

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
@@ -80,7 +80,6 @@ mcpcli search -q "manage pull requests"
80
80
  | `-v, --verbose` | Show HTTP request/response headers and timing |
81
81
  | `-S, --show-secrets` | Show full auth tokens in verbose output (unmasked) |
82
82
  | `-j, --json` | Force JSON output (default when piped) |
83
- | `--no-daemon` | Disable connection pooling |
84
83
 
85
84
  ## Managing Servers
86
85
 
@@ -236,22 +235,14 @@ Scenarios and keywords are extracted heuristically from tool names and descripti
236
235
 
237
236
  ## Environment Variables
238
237
 
239
- | Variable | Purpose | Default |
240
- | -------------------- | --------------------------------- | ------------------- |
241
- | `MCP_CONFIG_PATH` | Config directory path | `~/.config/mcpcli/` |
242
- | `MCP_DEBUG` | Enable debug output | `false` |
243
- | `MCP_TIMEOUT` | Request timeout (seconds) | `1800` |
244
- | `MCP_CONCURRENCY` | Parallel server connections | `5` |
245
- | `MCP_MAX_RETRIES` | Retry attempts | `3` |
246
- | `MCP_STRICT_ENV` | Error on missing `${VAR}` | `true` |
247
- | `MCP_NO_DAEMON` | Disable connection pooling | `false` |
248
- | `MCP_DAEMON_TIMEOUT` | Idle connection timeout (seconds) | `60` |
249
-
250
- ## Connection Pooling
251
-
252
- mcpcli runs a lightweight daemon that keeps MCP server connections warm. Stdio processes stay alive and HTTP connections are reused across invocations. The daemon exits after `MCP_DAEMON_TIMEOUT` seconds of inactivity (default 60s).
253
-
254
- Disable with `--no-daemon` or `MCP_NO_DAEMON=true` for one-shot usage.
238
+ | Variable | Purpose | Default |
239
+ | ----------------- | --------------------------- | ------------------- |
240
+ | `MCP_CONFIG_PATH` | Config directory path | `~/.config/mcpcli/` |
241
+ | `MCP_DEBUG` | Enable debug output | `false` |
242
+ | `MCP_TIMEOUT` | Request timeout (seconds) | `1800` |
243
+ | `MCP_CONCURRENCY` | Parallel server connections | `5` |
244
+ | `MCP_MAX_RETRIES` | Retry attempts | `3` |
245
+ | `MCP_STRICT_ENV` | Error on missing `${VAR}` | `true` |
255
246
 
256
247
  ## OAuth Flow
257
248
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evantahler/mcpcli",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "description": "A command-line interface for MCP servers. curl for MCP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import type { ServerConfig } from "../config/schemas.ts";
3
3
  import { loadRawServers, saveServers } from "../config/loader.ts";
4
+ import { runIndex } from "./index.ts";
4
5
 
5
6
  export function registerAddCommand(program: Command) {
6
7
  program
@@ -15,6 +16,7 @@ export function registerAddCommand(program: Command) {
15
16
  .option("--allowed-tools <tools>", "comma-separated list of allowed tools")
16
17
  .option("--disabled-tools <tools>", "comma-separated list of disabled tools")
17
18
  .option("-f, --force", "overwrite if server already exists")
19
+ .option("--no-index", "skip rebuilding the search index after adding")
18
20
  .action(
19
21
  async (
20
22
  name: string,
@@ -28,6 +30,7 @@ export function registerAddCommand(program: Command) {
28
30
  allowedTools?: string;
29
31
  disabledTools?: string;
30
32
  force?: boolean;
33
+ index?: boolean;
31
34
  },
32
35
  ) => {
33
36
  const hasCommand = !!options.command;
@@ -69,6 +72,11 @@ export function registerAddCommand(program: Command) {
69
72
  servers.mcpServers[name] = config;
70
73
  await saveServers(configDir, servers);
71
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
+ }
72
80
  },
73
81
  );
74
82
  }
@@ -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
  }
@@ -1,5 +1,12 @@
1
1
  import type { Command } from "commander";
2
- import { loadRawServers, loadRawAuth, saveServers, saveAuth } from "../config/loader.ts";
2
+ import {
3
+ loadRawServers,
4
+ loadRawAuth,
5
+ loadSearchIndex,
6
+ saveServers,
7
+ saveAuth,
8
+ saveSearchIndex,
9
+ } from "../config/loader.ts";
3
10
 
4
11
  export function registerRemoveCommand(program: Command) {
5
12
  program
@@ -24,6 +31,13 @@ export function registerRemoveCommand(program: Command) {
24
31
  console.log(`Would remove auth for "${name}" from ${configDir}/auth.json`);
25
32
  }
26
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
+ }
27
41
  return;
28
42
  }
29
43
 
@@ -39,5 +53,15 @@ export function registerRemoveCommand(program: Command) {
39
53
  console.log(`Removed auth for "${name}" from ${configDir}/auth.json`);
40
54
  }
41
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
+ }
42
66
  });
43
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,6 +112,12 @@ 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");
@@ -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
+ }