@evantahler/mcpx 0.18.5 → 0.18.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evantahler/mcpx",
3
- "version": "0.18.5",
3
+ "version": "0.18.6",
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
  }
@@ -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