@evantahler/mcpcli 0.1.4 → 0.2.1

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.1",
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,129 @@
1
+ import type { Command } from "commander";
2
+ import type { ServerConfig } from "../config/schemas.ts";
3
+ import { loadRawServers, saveServers } from "../config/loader.ts";
4
+
5
+ export function registerAddCommand(program: Command) {
6
+ program
7
+ .command("add <name>")
8
+ .description("add an MCP server to your config")
9
+ .option("--command <cmd>", "command to run (stdio server)")
10
+ .option("--args <args>", "comma-separated arguments for the command")
11
+ .option("--env <vars>", "comma-separated KEY=VAL environment variables")
12
+ .option("--cwd <dir>", "working directory for the command")
13
+ .option("--url <url>", "server URL (HTTP server)")
14
+ .option("--header <h>", "header in Key:Value format (repeatable)", collect, [])
15
+ .option("--allowed-tools <tools>", "comma-separated list of allowed tools")
16
+ .option("--disabled-tools <tools>", "comma-separated list of disabled tools")
17
+ .option("-f, --force", "overwrite if server already exists")
18
+ .action(
19
+ async (
20
+ name: string,
21
+ options: {
22
+ command?: string;
23
+ args?: string;
24
+ env?: string;
25
+ cwd?: string;
26
+ url?: string;
27
+ header?: string[];
28
+ allowedTools?: string;
29
+ disabledTools?: string;
30
+ force?: boolean;
31
+ },
32
+ ) => {
33
+ const hasCommand = !!options.command;
34
+ const hasUrl = !!options.url;
35
+
36
+ if (!hasCommand && !hasUrl) {
37
+ console.error("Must specify --command (stdio) or --url (http)");
38
+ process.exit(1);
39
+ }
40
+ if (hasCommand && hasUrl) {
41
+ console.error("Cannot specify both --command and --url");
42
+ process.exit(1);
43
+ }
44
+
45
+ const configFlag = program.opts().config;
46
+ const { configDir, servers } = await loadRawServers(configFlag);
47
+
48
+ if (servers.mcpServers[name] && !options.force) {
49
+ console.error(`Server "${name}" already exists (use --force to overwrite)`);
50
+ process.exit(1);
51
+ }
52
+
53
+ let config: ServerConfig;
54
+
55
+ if (hasCommand) {
56
+ config = buildStdioConfig(options);
57
+ } else {
58
+ config = buildHttpConfig(options);
59
+ }
60
+
61
+ // Common options
62
+ if (options.allowedTools) {
63
+ config.allowedTools = options.allowedTools.split(",").map((t) => t.trim());
64
+ }
65
+ if (options.disabledTools) {
66
+ config.disabledTools = options.disabledTools.split(",").map((t) => t.trim());
67
+ }
68
+
69
+ servers.mcpServers[name] = config;
70
+ await saveServers(configDir, servers);
71
+ console.log(`Added server "${name}" to ${configDir}/servers.json`);
72
+ },
73
+ );
74
+ }
75
+
76
+ function collect(value: string, previous: string[]): string[] {
77
+ return previous.concat([value]);
78
+ }
79
+
80
+ function buildStdioConfig(options: {
81
+ command?: string;
82
+ args?: string;
83
+ env?: string;
84
+ cwd?: string;
85
+ }): ServerConfig {
86
+ const config: Record<string, unknown> = { command: options.command! };
87
+
88
+ if (options.args) {
89
+ config.args = options.args.split(",").map((a) => a.trim());
90
+ }
91
+
92
+ if (options.env) {
93
+ const env: Record<string, string> = {};
94
+ for (const pair of options.env.split(",")) {
95
+ const eqIdx = pair.indexOf("=");
96
+ if (eqIdx === -1) {
97
+ console.error(`Invalid env format "${pair}", expected KEY=VAL`);
98
+ process.exit(1);
99
+ }
100
+ env[pair.slice(0, eqIdx).trim()] = pair.slice(eqIdx + 1).trim();
101
+ }
102
+ config.env = env;
103
+ }
104
+
105
+ if (options.cwd) {
106
+ config.cwd = options.cwd;
107
+ }
108
+
109
+ return config as ServerConfig;
110
+ }
111
+
112
+ function buildHttpConfig(options: { url?: string; header?: string[] }): ServerConfig {
113
+ const config: Record<string, unknown> = { url: options.url! };
114
+
115
+ if (options.header && options.header.length > 0) {
116
+ const headers: Record<string, string> = {};
117
+ for (const h of options.header) {
118
+ const colonIdx = h.indexOf(":");
119
+ if (colonIdx === -1) {
120
+ console.error(`Invalid header format "${h}", expected Key:Value`);
121
+ process.exit(1);
122
+ }
123
+ headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim();
124
+ }
125
+ config.headers = headers;
126
+ }
127
+
128
+ return config as ServerConfig;
129
+ }
@@ -0,0 +1,43 @@
1
+ import type { Command } from "commander";
2
+ import { loadRawServers, loadRawAuth, saveServers, saveAuth } from "../config/loader.ts";
3
+
4
+ export function registerRemoveCommand(program: Command) {
5
+ program
6
+ .command("remove <name>")
7
+ .description("remove an MCP server from your config")
8
+ .option("--keep-auth", "keep stored authentication credentials")
9
+ .option("--dry-run", "show what would be removed without changing files")
10
+ .action(async (name: string, options: { keepAuth?: boolean; dryRun?: boolean }) => {
11
+ const configFlag = program.opts().config;
12
+ const { configDir, servers } = await loadRawServers(configFlag);
13
+
14
+ if (!servers.mcpServers[name]) {
15
+ console.error(`Unknown server: "${name}"`);
16
+ process.exit(1);
17
+ }
18
+
19
+ if (options.dryRun) {
20
+ console.log(`Would remove server "${name}" from ${configDir}/servers.json`);
21
+ if (!options.keepAuth) {
22
+ const auth = await loadRawAuth(configDir);
23
+ if (auth[name]) {
24
+ console.log(`Would remove auth for "${name}" from ${configDir}/auth.json`);
25
+ }
26
+ }
27
+ return;
28
+ }
29
+
30
+ delete servers.mcpServers[name];
31
+ await saveServers(configDir, servers);
32
+ console.log(`Removed server "${name}" from ${configDir}/servers.json`);
33
+
34
+ if (!options.keepAuth) {
35
+ const auth = await loadRawAuth(configDir);
36
+ if (auth[name]) {
37
+ delete auth[name];
38
+ await saveAuth(configDir, auth);
39
+ console.log(`Removed auth for "${name}" from ${configDir}/auth.json`);
40
+ }
41
+ }
42
+ });
43
+ }
@@ -116,3 +116,35 @@ export async function saveAuth(configDir: string, auth: AuthFile): Promise<void>
116
116
  export async function saveSearchIndex(configDir: string, index: SearchIndex): Promise<void> {
117
117
  await Bun.write(join(configDir, "search.json"), JSON.stringify(index, null, 2) + "\n");
118
118
  }
119
+
120
+ /** Save servers.json to the config directory */
121
+ export async function saveServers(configDir: string, servers: ServersFile): Promise<void> {
122
+ await Bun.write(join(configDir, "servers.json"), JSON.stringify(servers, null, 2) + "\n");
123
+ }
124
+
125
+ /** Load servers.json without env interpolation (preserves ${VAR} placeholders) */
126
+ export async function loadRawServers(
127
+ configFlag?: string,
128
+ ): Promise<{ configDir: string; servers: ServersFile }> {
129
+ let configDir = resolveConfigDir(configFlag);
130
+
131
+ if (!(await hasServersFile(configDir))) {
132
+ const cwd = process.cwd();
133
+ if (await hasServersFile(cwd)) {
134
+ configDir = cwd;
135
+ }
136
+ }
137
+
138
+ const serversPath = join(configDir, "servers.json");
139
+ const raw = await readJsonFile(serversPath);
140
+ const servers = raw !== undefined ? validateServersFile(raw) : EMPTY_SERVERS;
141
+
142
+ return { configDir, servers };
143
+ }
144
+
145
+ /** Load auth.json without loading the full config */
146
+ export async function loadRawAuth(configDir: string): Promise<AuthFile> {
147
+ const authPath = join(configDir, "auth.json");
148
+ const raw = await readJsonFile(authPath);
149
+ return raw !== undefined ? validateAuthFile(raw) : EMPTY_AUTH;
150
+ }
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,