@evantahler/mcpx 0.18.5 → 0.18.7

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.
@@ -199,20 +199,20 @@ mcpx deauth <server> # remove stored auth
199
199
 
200
200
  ## `add` options
201
201
 
202
- | Flag | Purpose |
203
- | -------------------------- | -------------------------------------- |
204
- | `--command <cmd>` | Command to run (stdio server) |
205
- | `--args <a1,a2,...>` | Comma-separated arguments |
206
- | `--env <KEY=VAL,...>` | Comma-separated environment variables |
207
- | `--cwd <dir>` | Working directory for the command |
208
- | `--url <url>` | Server URL (HTTP server) |
209
- | `--header <Key:Value>` | HTTP header (repeatable) |
210
- | `--transport <type>` | Transport: `sse` or `streamable-http` |
211
- | `--allowed-tools <t1,t2>` | Comma-separated allowed tool patterns |
212
- | `--disabled-tools <t1,t2>` | Comma-separated disabled tool patterns |
213
- | `-f, --force` | Overwrite if server already exists |
214
- | `--no-auth` | Skip automatic OAuth after adding |
215
- | `--no-index` | Skip rebuilding the search index |
202
+ | Flag | Purpose |
203
+ | ------------------------ | ----------------------------------------------------------------------------------------------------------- |
204
+ | `--command <cmd>` | Command to run (stdio server) |
205
+ | `--args <arg>` | Argument for the command. Repeatable, or comma-separated. Tokens after `--` are also appended (stdio only). |
206
+ | `--env <KEY=VAL>` | Environment variable. Repeatable, or comma-separated. |
207
+ | `--cwd <dir>` | Working directory for the command |
208
+ | `--url <url>` | Server URL (HTTP server) |
209
+ | `--header <Key:Value>` | HTTP header. Repeatable. |
210
+ | `--transport <type>` | Transport: `sse` or `streamable-http` |
211
+ | `--allowed-tools <pat>` | Allowed tool pattern. Repeatable, or comma-separated. |
212
+ | `--disabled-tools <pat>` | Disabled tool pattern. Repeatable, or comma-separated. |
213
+ | `-f, --force` | Overwrite if server already exists |
214
+ | `--no-auth` | Skip automatic OAuth after adding |
215
+ | `--no-index` | Skip rebuilding the search index |
216
216
 
217
217
  ## `remove` options
218
218
 
@@ -193,20 +193,20 @@ mcpx deauth <server> # remove stored auth
193
193
 
194
194
  ## `add` options
195
195
 
196
- | Flag | Purpose |
197
- | -------------------------- | -------------------------------------- |
198
- | `--command <cmd>` | Command to run (stdio server) |
199
- | `--args <a1,a2,...>` | Comma-separated arguments |
200
- | `--env <KEY=VAL,...>` | Comma-separated environment variables |
201
- | `--cwd <dir>` | Working directory for the command |
202
- | `--url <url>` | Server URL (HTTP server) |
203
- | `--header <Key:Value>` | HTTP header (repeatable) |
204
- | `--transport <type>` | Transport: `sse` or `streamable-http` |
205
- | `--allowed-tools <t1,t2>` | Comma-separated allowed tool patterns |
206
- | `--disabled-tools <t1,t2>` | Comma-separated disabled tool patterns |
207
- | `-f, --force` | Overwrite if server already exists |
208
- | `--no-auth` | Skip automatic OAuth after adding |
209
- | `--no-index` | Skip rebuilding the search index |
196
+ | Flag | Purpose |
197
+ | ------------------------ | ----------------------------------------------------------------------------------------------------------- |
198
+ | `--command <cmd>` | Command to run (stdio server) |
199
+ | `--args <arg>` | Argument for the command. Repeatable, or comma-separated. Tokens after `--` are also appended (stdio only). |
200
+ | `--env <KEY=VAL>` | Environment variable. Repeatable, or comma-separated. |
201
+ | `--cwd <dir>` | Working directory for the command |
202
+ | `--url <url>` | Server URL (HTTP server) |
203
+ | `--header <Key:Value>` | HTTP header. Repeatable. |
204
+ | `--transport <type>` | Transport: `sse` or `streamable-http` |
205
+ | `--allowed-tools <pat>` | Allowed tool pattern. Repeatable, or comma-separated. |
206
+ | `--disabled-tools <pat>` | Disabled tool pattern. Repeatable, or comma-separated. |
207
+ | `-f, --force` | Overwrite if server already exists |
208
+ | `--no-auth` | Skip automatic OAuth after adding |
209
+ | `--no-index` | Skip rebuilding the search index |
210
210
 
211
211
  ## `remove` options
212
212
 
package/README.md CHANGED
@@ -138,20 +138,24 @@ Server log messages (`notifications/message`) are displayed on stderr with level
138
138
  Add and remove servers from the CLI — no manual JSON editing required.
139
139
 
140
140
  ```bash
141
- # Add a stdio server
141
+ # Add a stdio server (anything after `--` is passed to the command verbatim)
142
+ mcpx add filesystem --command npx -- -y @modelcontextprotocol/server-filesystem /tmp
143
+
144
+ # Equivalent forms: repeatable --args, or a single comma-separated --args
145
+ mcpx add filesystem --command npx --args -y --args @modelcontextprotocol/server-filesystem --args /tmp
142
146
  mcpx add filesystem --command npx --args "-y,@modelcontextprotocol/server-filesystem,/tmp"
143
147
 
144
148
  # Add an HTTP server with headers
145
149
  mcpx add my-api --url https://api.example.com/mcp --header "Authorization:Bearer tok123"
146
150
 
147
- # Add with tool filtering
148
- mcpx add github --url https://mcp.github.com --allowed-tools "search_*,get_*"
151
+ # Add with tool filtering (repeatable, or comma-separated)
152
+ mcpx add github --url https://mcp.github.com --allowed-tools "search_*" --allowed-tools "get_*"
149
153
 
150
154
  # Add a legacy SSE server (explicit transport)
151
155
  mcpx add legacy-api --url https://api.example.com/sse --transport sse
152
156
 
153
- # Add with environment variables
154
- mcpx add my-server --command node --args "server.js" --env "API_KEY=sk-123,DEBUG=true"
157
+ # Add with environment variables (repeatable, or comma-separated)
158
+ mcpx add my-server --command node --args server.js --env API_KEY=sk-123 --env DEBUG=true
155
159
 
156
160
  # Overwrite an existing server
157
161
  mcpx add filesystem --command echo --force
@@ -168,20 +172,20 @@ mcpx remove my-api --dry-run
168
172
 
169
173
  **`add` options:**
170
174
 
171
- | Flag | Purpose |
172
- | -------------------------- | -------------------------------------- |
173
- | `--command <cmd>` | Command to run (stdio server) |
174
- | `--args <a1,a2,...>` | Comma-separated arguments |
175
- | `--env <KEY=VAL,...>` | Comma-separated environment variables |
176
- | `--cwd <dir>` | Working directory for the command |
177
- | `--url <url>` | Server URL (HTTP server) |
178
- | `--header <Key:Value>` | HTTP header (repeatable) |
179
- | `--transport <type>` | Transport: `sse` or `streamable-http` |
180
- | `--allowed-tools <t1,t2>` | Comma-separated allowed tool patterns |
181
- | `--disabled-tools <t1,t2>` | Comma-separated disabled tool patterns |
182
- | `-f, --force` | Overwrite if server already exists |
183
- | `--no-auth` | Skip automatic OAuth after adding |
184
- | `--no-index` | Skip rebuilding the search index |
175
+ | Flag | Purpose |
176
+ | -------------------------- | ---------------------------------------------------------------------- |
177
+ | `--command <cmd>` | Command to run (stdio server) |
178
+ | `--args <arg>` | Argument for the command. Repeatable, or comma-separated. Tokens after `--` are also appended (stdio only). |
179
+ | `--env <KEY=VAL>` | Environment variable. Repeatable, or comma-separated. |
180
+ | `--cwd <dir>` | Working directory for the command |
181
+ | `--url <url>` | Server URL (HTTP server) |
182
+ | `--header <Key:Value>` | HTTP header. Repeatable. |
183
+ | `--transport <type>` | Transport: `sse` or `streamable-http` |
184
+ | `--allowed-tools <pat>` | Allowed tool pattern. Repeatable, or comma-separated. |
185
+ | `--disabled-tools <pat>` | Disabled tool pattern. Repeatable, or comma-separated. |
186
+ | `-f, --force` | Overwrite if server already exists |
187
+ | `--no-auth` | Skip automatic OAuth after adding |
188
+ | `--no-index` | Skip rebuilding the search index |
185
189
 
186
190
  **`remove` options:**
187
191
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evantahler/mcpx",
3
- "version": "0.18.5",
3
+ "version": "0.18.7",
4
4
  "description": "A command-line interface for MCP servers. curl for MCP.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,20 +1,41 @@
1
- import { exec } from "node:child_process";
1
+ import { execFile } from "node:child_process";
2
2
 
3
3
  /**
4
4
  * Open a URL in the default browser (macOS/Windows/Linux).
5
5
  * Falls back to printing the URL to stderr if no browser is available
6
6
  * (e.g., headless servers, Docker containers).
7
+ *
8
+ * Uses execFile (not exec) to avoid shell injection via malicious URLs.
7
9
  */
8
10
  export function openBrowser(url: string): Promise<void> {
9
- const cmd =
10
- process.platform === "darwin"
11
- ? `open "${url}"`
12
- : process.platform === "win32"
13
- ? `start "${url}"`
14
- : `xdg-open "${url}"`;
11
+ // Validate URL scheme to prevent non-HTTP protocols
12
+ try {
13
+ const parsed = new URL(url);
14
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
15
+ process.stderr.write(`Refusing to open non-HTTP URL: ${url}\n`);
16
+ return Promise.resolve();
17
+ }
18
+ } catch {
19
+ process.stderr.write(`Invalid URL: ${url}\n`);
20
+ return Promise.resolve();
21
+ }
22
+
23
+ let cmd: string;
24
+ let args: string[];
25
+
26
+ if (process.platform === "darwin") {
27
+ cmd = "open";
28
+ args = [url];
29
+ } else if (process.platform === "win32") {
30
+ cmd = "cmd";
31
+ args = ["/c", "start", "", url];
32
+ } else {
33
+ cmd = "xdg-open";
34
+ args = [url];
35
+ }
15
36
 
16
37
  return new Promise((resolve) => {
17
- exec(cmd, (err) => {
38
+ execFile(cmd, args, (err) => {
18
39
  if (err) {
19
40
  process.stderr.write(`Could not open browser. Please visit:\n ${url}\n`);
20
41
  }
@@ -68,11 +68,22 @@ function logBody(body: string, fmt: (s: string) => string) {
68
68
  }
69
69
  }
70
70
 
71
+ const SENSITIVE_HEADERS = new Set([
72
+ "authorization",
73
+ "cookie",
74
+ "set-cookie",
75
+ "proxy-authorization",
76
+ "x-api-key",
77
+ "api-key",
78
+ "x-auth-token",
79
+ "x-token",
80
+ "token",
81
+ ]);
82
+
71
83
  export function maskSensitive(key: string, value: string): string {
72
- const lower = key.toLowerCase();
73
- if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
74
- if (value.length <= 12) return value;
75
- return `${value.slice(0, 12)}...`;
84
+ if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
85
+ if (value.length <= 6) return "***";
86
+ return `${value.slice(0, 4)}...`;
76
87
  }
77
88
  return value;
78
89
  }
@@ -6,33 +6,34 @@ import { runIndex } from "./index.ts";
6
6
 
7
7
  export function registerAddCommand(program: Command) {
8
8
  program
9
- .command("add <name>")
9
+ .command("add <name> [passthroughArgs...]")
10
10
  .description("add an MCP server to your config")
11
11
  .option("--command <cmd>", "command to run (stdio server)")
12
- .option("--args <args>", "comma-separated arguments for the command")
13
- .option("--env <vars>", "comma-separated KEY=VAL environment variables")
12
+ .option("--args <arg>", "argument for the command (repeatable, comma-separated, or pass after --)", collect, [])
13
+ .option("--env <KEY=VAL>", "environment variable (repeatable or comma-separated)", collect, [])
14
14
  .option("--cwd <dir>", "working directory for the command")
15
15
  .option("--url <url>", "server URL (HTTP server)")
16
16
  .option("--header <h>", "header in Key:Value format (repeatable)", collect, [])
17
17
  .option("--transport <type>", 'transport for HTTP servers: "sse" or "streamable-http"')
18
- .option("--allowed-tools <tools>", "comma-separated list of allowed tools")
19
- .option("--disabled-tools <tools>", "comma-separated list of disabled tools")
18
+ .option("--allowed-tools <pattern>", "allowed tool pattern (repeatable or comma-separated)", collect, [])
19
+ .option("--disabled-tools <pattern>", "disabled tool pattern (repeatable or comma-separated)", collect, [])
20
20
  .option("-f, --force", "overwrite if server already exists")
21
21
  .option("--no-auth", "skip automatic OAuth authentication after adding an HTTP server")
22
22
  .option("--no-index", "skip rebuilding the search index after adding")
23
23
  .action(
24
24
  async (
25
25
  name: string,
26
+ passthroughArgs: string[],
26
27
  options: {
27
28
  command?: string;
28
- args?: string;
29
- env?: string;
29
+ args: string[];
30
+ env: string[];
30
31
  cwd?: string;
31
32
  url?: string;
32
- header?: string[];
33
+ header: string[];
33
34
  transport?: string;
34
- allowedTools?: string;
35
- disabledTools?: string;
35
+ allowedTools: string[];
36
+ disabledTools: string[];
36
37
  force?: boolean;
37
38
  auth?: boolean;
38
39
  index?: boolean;
@@ -49,6 +50,10 @@ export function registerAddCommand(program: Command) {
49
50
  console.error("Cannot specify both --command and --url");
50
51
  process.exit(1);
51
52
  }
53
+ if (!hasCommand && passthroughArgs.length > 0) {
54
+ console.error("Positional arguments after -- only apply to stdio servers (--command)");
55
+ process.exit(1);
56
+ }
52
57
 
53
58
  const configFlag = program.opts().config;
54
59
  const { configDir, servers } = await loadRawServers(configFlag);
@@ -61,7 +66,7 @@ export function registerAddCommand(program: Command) {
61
66
  let config: ServerConfig;
62
67
 
63
68
  if (hasCommand) {
64
- config = buildStdioConfig(options);
69
+ config = buildStdioConfig(options, passthroughArgs);
65
70
  } else {
66
71
  config = buildHttpConfig(options);
67
72
  }
@@ -74,12 +79,13 @@ export function registerAddCommand(program: Command) {
74
79
  (config as { transport: string }).transport = options.transport;
75
80
  }
76
81
 
77
- // Common options
78
- if (options.allowedTools) {
79
- config.allowedTools = options.allowedTools.split(",").map((t) => t.trim());
82
+ const allowedTools = splitCommaList(options.allowedTools);
83
+ if (allowedTools.length > 0) {
84
+ config.allowedTools = allowedTools;
80
85
  }
81
- if (options.disabledTools) {
82
- config.disabledTools = options.disabledTools.split(",").map((t) => t.trim());
86
+ const disabledTools = splitCommaList(options.disabledTools);
87
+ if (disabledTools.length > 0) {
88
+ config.disabledTools = disabledTools;
83
89
  }
84
90
 
85
91
  // For HTTP servers, resolve the canonical resource URL before saving.
@@ -127,16 +133,26 @@ function collect(value: string, previous: string[]): string[] {
127
133
  return previous.concat([value]);
128
134
  }
129
135
 
130
- function buildStdioConfig(options: { command?: string; args?: string; env?: string; cwd?: string }): ServerConfig {
136
+ // Flatten a list of repeated CLI values, splitting each on commas and trimming.
137
+ // Supports both `--flag a --flag b` and `--flag "a,b"` forms.
138
+ function splitCommaList(values: string[]): string[] {
139
+ return values.flatMap((v) => v.split(",").map((s) => s.trim())).filter((s) => s.length > 0);
140
+ }
141
+
142
+ function buildStdioConfig(
143
+ options: { command?: string; args: string[]; env: string[]; cwd?: string },
144
+ passthroughArgs: string[],
145
+ ): ServerConfig {
131
146
  const config: Record<string, unknown> = { command: options.command! };
132
147
 
133
- if (options.args) {
134
- config.args = options.args.split(",").map((a) => a.trim());
148
+ const args = [...splitCommaList(options.args), ...passthroughArgs];
149
+ if (args.length > 0) {
150
+ config.args = args;
135
151
  }
136
152
 
137
- if (options.env) {
153
+ if (options.env.length > 0) {
138
154
  const env: Record<string, string> = {};
139
- for (const pair of options.env.split(",")) {
155
+ for (const pair of splitCommaList(options.env)) {
140
156
  const eqIdx = pair.indexOf("=");
141
157
  if (eqIdx === -1) {
142
158
  console.error(`Invalid env format "${pair}", expected KEY=VAL`);
@@ -154,10 +170,10 @@ function buildStdioConfig(options: { command?: string; args?: string; env?: stri
154
170
  return config as unknown as ServerConfig;
155
171
  }
156
172
 
157
- function buildHttpConfig(options: { url?: string; header?: string[] }): ServerConfig {
173
+ function buildHttpConfig(options: { url?: string; header: string[] }): ServerConfig {
158
174
  const config: Record<string, unknown> = { url: options.url! };
159
175
 
160
- if (options.header && options.header.length > 0) {
176
+ if (options.header.length > 0) {
161
177
  const headers: Record<string, string> = {};
162
178
  for (const h of options.header) {
163
179
  const colonIdx = h.indexOf(":");
@@ -1,3 +1,4 @@
1
+ import { chmod } from "node:fs/promises";
1
2
  import { join, resolve } from "node:path";
2
3
  import { DEFAULT_CONFIG_DIR, ENV } from "../constants.ts";
3
4
  import { interpolateEnv } from "./env.ts";
@@ -63,6 +64,7 @@ export async function loadConfig(options: LoadConfigOptions = {}): Promise<Confi
63
64
  const cwd = process.cwd();
64
65
  if (await hasServersFile(cwd)) {
65
66
  configDir = cwd;
67
+ process.stderr.write(`Note: using servers.json from current directory (${cwd})\n`);
66
68
  }
67
69
  }
68
70
 
@@ -109,9 +111,10 @@ async function saveJsonFile(configDir: string, filename: string, data: unknown):
109
111
  await Bun.write(join(configDir, filename), `${JSON.stringify(data, null, 2)}\n`);
110
112
  }
111
113
 
112
- /** Save auth.json to the config directory */
114
+ /** Save auth.json to the config directory with restrictive permissions */
113
115
  export async function saveAuth(configDir: string, auth: AuthFile): Promise<void> {
114
- return saveJsonFile(configDir, "auth.json", auth);
116
+ await saveJsonFile(configDir, "auth.json", auth);
117
+ await chmod(join(configDir, "auth.json"), 0o600).catch(() => {});
115
118
  }
116
119
 
117
120
  /** Load search.json from the config directory */
@@ -619,20 +619,23 @@ export function renderMarkdownToAnsi(input: string): string {
619
619
  return restored;
620
620
  }
621
621
 
622
+ const MAX_NESTED_JSON_DEPTH = 10;
623
+
622
624
  /** Recursively parse JSON strings inside MCP content blocks */
623
- function parseNestedJson(value: unknown): unknown {
625
+ function parseNestedJson(value: unknown, depth = 0): unknown {
626
+ if (depth > MAX_NESTED_JSON_DEPTH) return value;
624
627
  if (typeof value === "string") {
625
628
  try {
626
- return parseNestedJson(JSON.parse(value));
629
+ return parseNestedJson(JSON.parse(value), depth + 1);
627
630
  } catch {
628
631
  return value;
629
632
  }
630
633
  }
631
634
  if (Array.isArray(value)) {
632
- return value.map(parseNestedJson);
635
+ return value.map((v) => parseNestedJson(v, depth + 1));
633
636
  }
634
637
  if (typeof value === "object" && value !== null) {
635
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, parseNestedJson(v)]));
638
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, parseNestedJson(v, depth + 1)]));
636
639
  }
637
640
  return value;
638
641
  }
@@ -28,8 +28,9 @@ function validateWithSchema(
28
28
  try {
29
29
  validate = ajv.compile(schema);
30
30
  validatorCache.set(cacheKey, validate);
31
- } catch {
32
- return { valid: true, errors: [] };
31
+ } catch (err) {
32
+ const msg = err instanceof Error ? err.message : "unknown error";
33
+ return { valid: false, errors: [{ path: "(schema)", message: `schema compilation failed: ${msg}` }] };
33
34
  }
34
35
  }
35
36