@evantahler/mcpx 0.16.2 → 0.16.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.
@@ -30,7 +30,7 @@ mcpx exec <server> <tool> '<json args>' # explicit server (required if too
30
30
  mcpx exec <server> <tool> -f params.json
31
31
  ```
32
32
 
33
- Output is JSON by default. Use `--json` to force JSON output in any context — prefer this when you need to parse results programmatically. Use `--format text` to extract just the text content (stripping the MCP protocol wrapper), or `--format markdown` for rich terminal rendering.
33
+ Output is JSON by default. Use `--json` to force JSON output in any context — prefer this when you need to parse results programmatically. Use `--format markdown` for rich terminal rendering with colors, headings, and bullet lists.
34
34
 
35
35
  ## Rules
36
36
 
@@ -39,7 +39,6 @@ Output is JSON by default. Use `--json` to force JSON output in any context —
39
39
  - Use `mcpx search -k` for exact name matching
40
40
  - Pipe results through `jq` when you need to extract specific fields
41
41
  - Use `--json` when parsing output programmatically (automatic when piped, but explicit is safer)
42
- - Use `--format text` to extract plain text from tool results (strips MCP protocol wrapper)
43
42
  - Use `--format markdown` for rich terminal-rendered output with colors and formatting
44
43
  - Use `-v` for verbose debugging (HTTP details + JSON-RPC protocol messages) if an exec fails unexpectedly
45
44
  - Use `-l debug` to see all server log messages, or `-l error` for errors only
@@ -159,7 +158,7 @@ mcpx deauth <server> # remove stored auth
159
158
  | Flag | Purpose |
160
159
  | --------------------------- | -------------------------------------------------------- |
161
160
  | `-j, --json` | Force JSON output (default when piped) |
162
- | `-F, --format <format>` | Output format: `json`, `text`, or `markdown` |
161
+ | `-F, --format <format>` | Output format: `json` or `markdown` |
163
162
  | `-v, --verbose` | Show HTTP details and JSON-RPC protocol messages |
164
163
  | `-d, --with-descriptions` | Include tool descriptions in list output |
165
164
  | `-c, --config <path>` | Specify config file location |
@@ -30,7 +30,7 @@ mcpx exec <server> <tool> '<json args>' # explicit server (required if too
30
30
  mcpx exec <server> <tool> -f params.json
31
31
  ```
32
32
 
33
- Output is JSON by default. Use `--json` to force JSON output in any context — prefer this when you need to parse results programmatically. Use `--format text` to extract just the text content (stripping the MCP protocol wrapper), or `--format markdown` for rich terminal rendering.
33
+ Output is JSON by default. Use `--json` to force JSON output in any context — prefer this when you need to parse results programmatically. Use `--format markdown` for rich terminal rendering with colors, headings, and bullet lists.
34
34
 
35
35
  ## Rules
36
36
 
@@ -39,7 +39,6 @@ Output is JSON by default. Use `--json` to force JSON output in any context —
39
39
  - Use `mcpx search -k` for exact name matching
40
40
  - Pipe results through `jq` when you need to extract specific fields
41
41
  - Use `--json` when parsing output programmatically (automatic when piped, but explicit is safer)
42
- - Use `--format text` to extract plain text from tool results (strips MCP protocol wrapper)
43
42
  - Use `--format markdown` for rich terminal-rendered output with colors and formatting
44
43
  - Use `-v` for verbose debugging (HTTP details + JSON-RPC protocol messages) if an exec fails unexpectedly
45
44
  - Use `-l debug` to see all server log messages, or `-l error` for errors only
@@ -159,7 +158,7 @@ mcpx deauth <server> # remove stored auth
159
158
  | Flag | Purpose |
160
159
  | --------------------------- | -------------------------------------------------------- |
161
160
  | `-j, --json` | Force JSON output (default when piped) |
162
- | `-F, --format <format>` | Output format: `json`, `text`, or `markdown` |
161
+ | `-F, --format <format>` | Output format: `json` or `markdown` |
163
162
  | `-v, --verbose` | Show HTTP details and JSON-RPC protocol messages |
164
163
  | `-d, --with-descriptions` | Include tool descriptions in list output |
165
164
  | `-c, --config <path>` | Specify config file location |
package/README.md CHANGED
@@ -12,14 +12,19 @@ Two audiences:
12
12
  ## Install
13
13
 
14
14
  ```bash
15
- # Via bun
15
+ # Via bun (all platforms)
16
16
  bun install -g @evantahler/mcpx
17
17
 
18
- # Via curl
18
+ # Via curl (macOS/Linux)
19
19
  curl -fsSL https://raw.githubusercontent.com/evantahler/mcpx/main/install.sh | bash
20
20
  ```
21
21
 
22
- The curl installer downloads a pre-built binary (macOS/Linux) — no runtime needed. The bun install method requires [Bun](https://bun.sh). Windows `.exe` binaries are available on the [GitHub Releases](https://github.com/evantahler/mcpx/releases) page.
22
+ ```powershell
23
+ # Via PowerShell (Windows)
24
+ irm https://raw.githubusercontent.com/evantahler/mcpx/main/install.ps1 | iex
25
+ ```
26
+
27
+ The curl/PowerShell installers download a pre-built binary — no runtime needed. The bun install method requires [Bun](https://bun.sh). Binaries for all platforms are also available on the [GitHub Releases](https://github.com/evantahler/mcpx/releases) page.
23
28
 
24
29
  ## Quick Start
25
30
 
@@ -111,7 +116,7 @@ mcpx search -n 5 "manage pull requests"
111
116
  | `-v, --verbose` | Show HTTP details and JSON-RPC protocol messages |
112
117
  | `-S, --show-secrets` | Show full auth tokens in verbose output (unmasked) |
113
118
  | `-j, --json` | Force JSON output (default when piped) |
114
- | `-F, --format <format>` | Output format: `json`, `text`, or `markdown` |
119
+ | `-F, --format <format>` | Output format: `json` or `markdown` |
115
120
  | `-N, --no-interactive` | Decline server elicitation requests (for scripted usage) |
116
121
  | `-l, --log-level <level>` | Minimum server log level to display (default: `warning`) |
117
122
 
@@ -514,18 +519,13 @@ Tool results (`exec`, `task result`) support three output formats via the global
514
519
  # Default JSON output — full MCP response with content array
515
520
  mcpx exec github search_repositories '{"query":"mcp"}'
516
521
 
517
- # Text — just the content, no protocol wrapper
518
- mcpx exec github search_repositories '{"query":"mcp"}' --format text
519
-
520
522
  # Markdown — rich terminal rendering with colors and formatting
521
523
  mcpx exec github search_repositories '{"query":"mcp"}' -F markdown
522
524
  ```
523
525
 
524
- The `text` format extracts text from MCP content blocks and strips the protocol wrapper. If the text contains JSON, it's pretty-printed. Non-text content (images, resources) gets descriptive placeholders.
525
-
526
- The `markdown` format extracts text the same way, then renders it through Bun's built-in markdown parser with ANSI styling — headings, bold/italic, code blocks with borders, colored links, and bullet lists.
526
+ The `markdown` format extracts text from MCP content blocks and renders it through Bun's built-in markdown parser with ANSI styling — headings, bold/italic, code blocks with borders, colored links, and bullet lists. JSON content is converted to a structured document with headings and bullet lists.
527
527
 
528
- For other commands (`list`, `info`, `search`), `--format json` forces JSON output and `--format text`/`--format markdown` use the existing human-friendly formatting.
528
+ For other commands (`list`, `info`, `search`), `--format json` forces JSON output and `--format markdown` uses the existing human-friendly formatting.
529
529
 
530
530
  ### Chaining tool results
531
531
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evantahler/mcpx",
3
- "version": "0.16.2",
3
+ "version": "0.16.4",
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
@@ -25,7 +25,7 @@ program
25
25
  .option("-c, --config <path>", "config directory path")
26
26
  .option("-d, --with-descriptions", "include tool descriptions in output")
27
27
  .option("-j, --json", "force JSON output")
28
- .option("-F, --format <format>", "output format (json, text, markdown)")
28
+ .option("-F, --format <format>", "output format (json, markdown)")
29
29
  .option("-v, --verbose", "show HTTP details and JSON-RPC protocol messages")
30
30
  .option("-S, --show-secrets", "show full auth tokens in verbose output")
31
31
  .option("-N, --no-interactive", "decline server elicitation requests")
@@ -5,7 +5,7 @@ import { isInteractive } from "./formatter.ts";
5
5
  * Format output with automatic JSON/interactive branching.
6
6
  * When --format is explicitly set, it takes precedence:
7
7
  * json → JSON.stringify of jsonData
8
- * text or markdown → interactiveFn() (already well-formatted for non-exec commands)
8
+ * markdown → interactiveFn() (already well-formatted for non-exec commands)
9
9
  * Otherwise falls back to the existing auto-detection:
10
10
  * non-interactive → JSON, interactive → formatted text.
11
11
  */
@@ -18,7 +18,7 @@ export function formatOutput(
18
18
  if (options.format === "json") {
19
19
  return JSON.stringify(jsonData, null, 2);
20
20
  }
21
- // text and markdown use the interactive formatter for non-exec commands
21
+ // markdown uses the interactive formatter for non-exec commands
22
22
  return interactiveFn();
23
23
  }
24
24
  if (!isInteractive(options)) {
@@ -6,7 +6,7 @@ import type { SearchResult } from "../search/index.ts";
6
6
  import { formatOutput } from "./format-output.ts";
7
7
  import { formatTable } from "./format-table.ts";
8
8
 
9
- export const VALID_FORMATS = ["json", "text", "markdown"] as const;
9
+ export const VALID_FORMATS = ["json", "markdown"] as const;
10
10
 
11
11
  export type OutputFormat = (typeof VALID_FORMATS)[number];
12
12
 
@@ -355,8 +355,6 @@ export function formatCallResult(result: unknown, options: FormatOptions): strin
355
355
  const format = options.format ?? "json";
356
356
 
357
357
  switch (format) {
358
- case "text":
359
- return formatCallResultAsText(result);
360
358
  case "markdown":
361
359
  return formatCallResultAsMarkdown(result);
362
360
  case "json":
@@ -365,58 +363,6 @@ export function formatCallResult(result: unknown, options: FormatOptions): strin
365
363
  }
366
364
  }
367
365
 
368
- /** Extract human-readable text from an MCP tool call result */
369
- function formatCallResultAsText(result: unknown): string {
370
- const r = result as {
371
- content?: Array<{
372
- type: string;
373
- text?: string;
374
- data?: string;
375
- mimeType?: string;
376
- uri?: string;
377
- }>;
378
- isError?: boolean;
379
- };
380
-
381
- if (!r.content || !Array.isArray(r.content) || r.content.length === 0) {
382
- return JSON.stringify(result, null, 2);
383
- }
384
-
385
- const parts: string[] = [];
386
-
387
- for (const block of r.content) {
388
- switch (block.type) {
389
- case "text":
390
- if (block.text !== undefined) {
391
- try {
392
- const parsed = JSON.parse(block.text);
393
- parts.push(JSON.stringify(parsed, null, 2));
394
- } catch {
395
- parts.push(block.text);
396
- }
397
- }
398
- break;
399
- case "image":
400
- parts.push(
401
- `[image: ${block.mimeType ?? "unknown type"}, ${block.data ? Math.ceil((block.data.length * 3) / 4) : 0} bytes]`,
402
- );
403
- break;
404
- case "resource":
405
- parts.push(`[resource: ${block.uri ?? "unknown"}]`);
406
- break;
407
- default:
408
- parts.push(`[${block.type}]`);
409
- break;
410
- }
411
- }
412
-
413
- let output = parts.join("\n");
414
- if (r.isError) {
415
- output = `error: ${output}`;
416
- }
417
- return output;
418
- }
419
-
420
366
  /** Render an MCP tool call result as styled markdown for terminal output */
421
367
  function formatCallResultAsMarkdown(result: unknown): string {
422
368
  const r = result as {
@@ -483,30 +429,162 @@ function isPrimitive(value: unknown): value is string | number | boolean | null
483
429
  return value === null || typeof value !== "object";
484
430
  }
485
431
 
432
+ /**
433
+ * URL placeholders: Bun.markdown.ansi() wraps and auto-links URLs, so we
434
+ * replace them with short tokens before rendering, then swap them back after.
435
+ */
436
+ let urlCounter = 0;
437
+ let urlMap = new Map<string, string>();
438
+
439
+ function resetUrlPlaceholders(): void {
440
+ urlCounter = 0;
441
+ urlMap = new Map();
442
+ }
443
+
444
+ function restoreUrlPlaceholders(ansiOutput: string): string {
445
+ for (const [token, url] of urlMap) {
446
+ ansiOutput = ansiOutput.replace(token, `\x1b[34m\x1b[4m${url}\x1b[24m\x1b[39m`);
447
+ }
448
+ return ansiOutput;
449
+ }
450
+
451
+ /** Format a primitive value, replacing URLs with placeholders to avoid mangling */
452
+ function formatPrimitive(value: string | number | boolean | null): string {
453
+ const str = String(value ?? "null");
454
+ if (typeof value === "string" && /^https?:\/\/\S+$/.test(value)) {
455
+ const token = `URLPLACEHOLDER${urlCounter++}`;
456
+ urlMap.set(token, str);
457
+ return token;
458
+ }
459
+ return str;
460
+ }
461
+
462
+ /** Normalize a key for label matching: lowercase, strip underscores/hyphens */
463
+ function normalizeKey(key: string): string {
464
+ return key.replace(/[_-]/g, "").toLowerCase();
465
+ }
466
+
467
+ /** Priority-ordered label keys (checked after normalization) */
468
+ const LABEL_KEYS = [
469
+ "name",
470
+ "displayname",
471
+ "fullname",
472
+ "username",
473
+ "screenname",
474
+ "title",
475
+ "subject",
476
+ "headline",
477
+ "heading",
478
+ "label",
479
+ "description",
480
+ "summary",
481
+ "email",
482
+ "url",
483
+ "slug",
484
+ "key",
485
+ "identifier",
486
+ ];
487
+
488
+ /** Find the best label field in an object, returning { originalKey, value } or null */
489
+ function findLabel(obj: Record<string, unknown>): { originalKey: string; value: string } | null {
490
+ const entries = Object.entries(obj);
491
+ for (const candidate of LABEL_KEYS) {
492
+ for (const [key, val] of entries) {
493
+ if (normalizeKey(key) === candidate && typeof val === "string" && val.length > 0) {
494
+ return { originalKey: key, value: val };
495
+ }
496
+ }
497
+ }
498
+ return null;
499
+ }
500
+
501
+ /** Render object entries as an indented bullet list */
502
+ function objectToBullets(entries: [string, unknown][], indent: number, skipKey?: string): string {
503
+ const prefix = " ".repeat(indent);
504
+ const lines: string[] = [];
505
+
506
+ for (const [key, val] of entries) {
507
+ if (key === skipKey) continue;
508
+ const heading = humanizeKey(key);
509
+
510
+ if (isPrimitive(val)) {
511
+ lines.push(`${prefix}- **${heading}:** ${formatPrimitive(val)}`);
512
+ } else if (Array.isArray(val) && val.every(isPrimitive)) {
513
+ lines.push(`${prefix}- **${heading}:**`);
514
+ for (const v of val) {
515
+ lines.push(`${prefix} - ${formatPrimitive(v)}`);
516
+ }
517
+ } else if (Array.isArray(val)) {
518
+ lines.push(`${prefix}- **${heading}:**`);
519
+ for (const item of val) {
520
+ if (isPrimitive(item)) {
521
+ lines.push(`${prefix} - ${formatPrimitive(item)}`);
522
+ } else {
523
+ const itemObj = item as Record<string, unknown>;
524
+ const label = findLabel(itemObj);
525
+ if (label) {
526
+ lines.push(`${prefix} - ${label.value}`);
527
+ lines.push(objectToBullets(Object.entries(itemObj), indent + 4, label.originalKey));
528
+ } else {
529
+ lines.push(`${prefix} -`);
530
+ lines.push(objectToBullets(Object.entries(itemObj), indent + 4));
531
+ }
532
+ }
533
+ }
534
+ } else {
535
+ lines.push(`${prefix}- **${heading}:**`);
536
+ lines.push(objectToBullets(Object.entries(val as Record<string, unknown>), indent + 2));
537
+ }
538
+ }
539
+
540
+ return lines.join("\n");
541
+ }
542
+
486
543
  /**
487
544
  * Convert a JSON value into a readable markdown document.
488
- * Object keys become headings at their nesting depth (depth 1 = #, depth 2 = ##, etc.).
489
- * Arrays of primitives become bullet lists. Arrays of objects get numbered sub-sections.
490
- * Headings are capped at depth 6 (######); deeper nesting uses **bold** labels instead.
545
+ * Depth 1–2 use headings; depth 3+ switch to compact bullet lists.
546
+ * Arrays of objects use a label field (name, title, etc.) in the heading when available.
491
547
  */
492
- export function jsonToMarkdown(value: unknown, depth: number = 1): string {
548
+ export function jsonToMarkdown(value: unknown, depth: number = 1, skipKey?: string): string {
493
549
  if (isPrimitive(value)) {
494
- return String(value ?? "null");
550
+ return formatPrimitive(value);
551
+ }
552
+
553
+ // At depth >= 3, switch to bullet-list rendering
554
+ if (depth >= 3) {
555
+ if (Array.isArray(value)) {
556
+ if (value.every(isPrimitive)) {
557
+ return value.map((v) => `- ${formatPrimitive(v)}`).join("\n");
558
+ }
559
+ return value
560
+ .map((item) => {
561
+ if (isPrimitive(item)) return `- ${formatPrimitive(item)}`;
562
+ const obj = item as Record<string, unknown>;
563
+ const label = findLabel(obj);
564
+ const header = label ? `- ${label.value}` : `-`;
565
+ return `${header}\n${objectToBullets(Object.entries(obj), 2, label?.originalKey)}`;
566
+ })
567
+ .join("\n");
568
+ }
569
+ return objectToBullets(Object.entries(value as Record<string, unknown>), 0, skipKey);
495
570
  }
496
571
 
497
572
  if (Array.isArray(value)) {
498
573
  // Array of all primitives → bullet list
499
574
  if (value.every(isPrimitive)) {
500
- return value.map((v) => `- ${String(v ?? "null")}`).join("\n");
575
+ return value.map((v) => `- ${formatPrimitive(v)}`).join("\n");
501
576
  }
502
- // Array of objects → numbered sub-sections
577
+ // Array of objects → numbered sub-sections with label
503
578
  return value
504
579
  .map((item, i) => {
505
580
  if (isPrimitive(item)) {
506
- return `- ${String(item ?? "null")}`;
581
+ return `- ${formatPrimitive(item)}`;
507
582
  }
508
- const label = depth <= 6 ? `${"#".repeat(depth)} ${i + 1}` : `**${i + 1}**`;
509
- return `${label}\n\n${jsonToMarkdown(item, depth + 1)}`;
583
+ const obj = item as Record<string, unknown>;
584
+ const labelInfo = findLabel(obj);
585
+ const numberLabel = labelInfo ? `${i + 1}. ${labelInfo.value}` : `${i + 1}`;
586
+ const heading = depth <= 6 ? `${"#".repeat(depth)} ${numberLabel}` : `**${numberLabel}**`;
587
+ return `${heading}\n\n${jsonToMarkdown(item, depth + 1, labelInfo?.originalKey)}`;
510
588
  })
511
589
  .join("\n\n");
512
590
  }
@@ -520,13 +598,13 @@ export function jsonToMarkdown(value: unknown, depth: number = 1): string {
520
598
 
521
599
  if (isPrimitive(val)) {
522
600
  if (depth <= 6) {
523
- lines.push(`${"#".repeat(depth)} ${heading}\n\n${String(val ?? "null")}`);
601
+ lines.push(`${"#".repeat(depth)} ${heading}\n\n${formatPrimitive(val)}`);
524
602
  } else {
525
- lines.push(`**${heading}:** ${String(val ?? "null")}`);
603
+ lines.push(`**${heading}:** ${formatPrimitive(val)}`);
526
604
  }
527
605
  } else if (Array.isArray(val) && val.every(isPrimitive)) {
528
606
  // Array of primitives: heading then bullet list
529
- const list = val.map((v) => `- ${String(v ?? "null")}`).join("\n");
607
+ const list = val.map((v) => `- ${formatPrimitive(v)}`).join("\n");
530
608
  if (depth <= 6) {
531
609
  lines.push(`${"#".repeat(depth)} ${heading}\n\n${list}`);
532
610
  } else {
@@ -544,7 +622,10 @@ export function jsonToMarkdown(value: unknown, depth: number = 1): string {
544
622
 
545
623
  /** Render a markdown string to ANSI-styled terminal output using Bun's built-in renderer */
546
624
  export function renderMarkdownToAnsi(input: string): string {
547
- return Bun.markdown.ansi(input);
625
+ const result = Bun.markdown.ansi(input);
626
+ const restored = restoreUrlPlaceholders(result);
627
+ resetUrlPlaceholders();
628
+ return restored;
548
629
  }
549
630
 
550
631
  /** Recursively parse JSON strings inside MCP content blocks */