mcp2cli 3.0.3__tar.gz → 3.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp2cli
3
- Version: 3.0.3
3
+ Version: 3.2.0
4
4
  Summary: Turn any MCP server or OpenAPI spec into a CLI
5
5
  Author: Stephan Fitzpatrick
6
6
  Author-email: Stephan Fitzpatrick <stephan@knowsuchagency.com>
@@ -236,6 +236,36 @@ mcp2cli @myapi --list --sort alpha
236
236
 
237
237
  When usage data exists for a source, `--list` defaults to sorting by call frequency. Otherwise insertion order is preserved. Usage data is stored in `~/.cache/mcp2cli/usage.json`.
238
238
 
239
+ ### JSON output
240
+
241
+ `--json` forces **valid JSON on stdout for every command**, in every mode. It is the
242
+ machine-readable counterpart to the human-formatted default output, designed for LLM
243
+ agents and scripts that need to parse results reliably.
244
+
245
+ ```bash
246
+ # --list emits a JSON array of command objects (name, description, parameters, ...)
247
+ mcp2cli --mcp https://mcp.example.com/sse --list --json
248
+ mcp2cli --spec ./openapi.json --list --json
249
+ mcp2cli --graphql https://api.example.com/graphql --list --json
250
+
251
+ # --list --json --compact emits a JSON array of names only
252
+ mcp2cli --mcp https://mcp.example.com/sse --list --json --compact
253
+
254
+ # MCP tool calls emit the FULL CallToolResult envelope — including
255
+ # structuredContent and isError, not just the flattened text. This surfaces the
256
+ # machine-readable result that modern MCP tools put in structuredContent.
257
+ mcp2cli --mcp https://mcp.example.com/sse --json search --query "test"
258
+ # { "content": [...], "structuredContent": {...}, "isError": false }
259
+
260
+ # OpenAPI / GraphQL calls emit the response as JSON (non-JSON bodies become a JSON string)
261
+ mcp2cli --spec ./openapi.json --json list-pets
262
+ mcp2cli --graphql https://api.example.com/graphql --json users
263
+ ```
264
+
265
+ `--json` takes precedence over `--raw` and `--toon` (both of which can produce
266
+ non-JSON), so it always wins — that is what makes it a reliable "force JSON" switch.
267
+ Indentation follows the usual rule: pretty on a TTY or with `--pretty`, compact when piped.
268
+
239
269
  ### Output control
240
270
 
241
271
  ```bash
@@ -308,6 +338,9 @@ Options:
308
338
  --fields FIELDS Override GraphQL selection set (e.g. "id name email")
309
339
  --pretty Pretty-print JSON output
310
340
  --raw Print raw response body
341
+ --json Force valid JSON output for every command (--list and tool
342
+ calls). MCP calls emit the full result envelope including
343
+ structuredContent. Takes precedence over --raw and --toon.
311
344
  --toon Encode output as TOON (token-efficient for LLMs)
312
345
  --head N Limit output to first N records (arrays)
313
346
  --version Show version
@@ -217,6 +217,36 @@ mcp2cli @myapi --list --sort alpha
217
217
 
218
218
  When usage data exists for a source, `--list` defaults to sorting by call frequency. Otherwise insertion order is preserved. Usage data is stored in `~/.cache/mcp2cli/usage.json`.
219
219
 
220
+ ### JSON output
221
+
222
+ `--json` forces **valid JSON on stdout for every command**, in every mode. It is the
223
+ machine-readable counterpart to the human-formatted default output, designed for LLM
224
+ agents and scripts that need to parse results reliably.
225
+
226
+ ```bash
227
+ # --list emits a JSON array of command objects (name, description, parameters, ...)
228
+ mcp2cli --mcp https://mcp.example.com/sse --list --json
229
+ mcp2cli --spec ./openapi.json --list --json
230
+ mcp2cli --graphql https://api.example.com/graphql --list --json
231
+
232
+ # --list --json --compact emits a JSON array of names only
233
+ mcp2cli --mcp https://mcp.example.com/sse --list --json --compact
234
+
235
+ # MCP tool calls emit the FULL CallToolResult envelope — including
236
+ # structuredContent and isError, not just the flattened text. This surfaces the
237
+ # machine-readable result that modern MCP tools put in structuredContent.
238
+ mcp2cli --mcp https://mcp.example.com/sse --json search --query "test"
239
+ # { "content": [...], "structuredContent": {...}, "isError": false }
240
+
241
+ # OpenAPI / GraphQL calls emit the response as JSON (non-JSON bodies become a JSON string)
242
+ mcp2cli --spec ./openapi.json --json list-pets
243
+ mcp2cli --graphql https://api.example.com/graphql --json users
244
+ ```
245
+
246
+ `--json` takes precedence over `--raw` and `--toon` (both of which can produce
247
+ non-JSON), so it always wins — that is what makes it a reliable "force JSON" switch.
248
+ Indentation follows the usual rule: pretty on a TTY or with `--pretty`, compact when piped.
249
+
220
250
  ### Output control
221
251
 
222
252
  ```bash
@@ -289,6 +319,9 @@ Options:
289
319
  --fields FIELDS Override GraphQL selection set (e.g. "id name email")
290
320
  --pretty Pretty-print JSON output
291
321
  --raw Print raw response body
322
+ --json Force valid JSON output for every command (--list and tool
323
+ calls). MCP calls emit the full result envelope including
324
+ structuredContent. Takes precedence over --raw and --toon.
292
325
  --toon Encode output as TOON (token-efficient for LLMs)
293
326
  --head N Limit output to first N records (arrays)
294
327
  --version Show version
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp2cli"
3
- version = "3.0.3"
3
+ version = "3.2.0"
4
4
  description = "Turn any MCP server or OpenAPI spec into a CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -277,6 +277,14 @@ def _apply_head(data, n: int):
277
277
  return data
278
278
 
279
279
 
280
+ def _emit_json(data, pretty: bool = False) -> None:
281
+ """Print *data* as JSON. Indented when *pretty* or stdout is a TTY, else compact."""
282
+ if pretty or sys.stdout.isatty():
283
+ print(json.dumps(data, indent=2))
284
+ else:
285
+ print(json.dumps(data))
286
+
287
+
280
288
  def output_result(
281
289
  data,
282
290
  *,
@@ -284,7 +292,22 @@ def output_result(
284
292
  raw: bool = False,
285
293
  toon: bool = False,
286
294
  head: int | None = None,
295
+ json_output: bool = False,
287
296
  ):
297
+ # --json forces valid JSON for every command, overriding --raw and --toon
298
+ # (both of which can produce non-JSON). Strings that contain JSON are
299
+ # unwrapped; everything else is emitted as a JSON value (e.g. a string
300
+ # literal for plain prose), guaranteeing parseable stdout.
301
+ if json_output:
302
+ if isinstance(data, str):
303
+ try:
304
+ data = json.loads(data)
305
+ except (json.JSONDecodeError, TypeError, ValueError):
306
+ pass
307
+ if head is not None:
308
+ data = _apply_head(data, head)
309
+ _emit_json(data, pretty)
310
+ return
288
311
  if raw:
289
312
  if isinstance(data, str):
290
313
  print(data)
@@ -310,10 +333,53 @@ def output_result(
310
333
  file=sys.stderr,
311
334
  )
312
335
  # Fall through to normal output
313
- if pretty or sys.stdout.isatty():
314
- print(json.dumps(data, indent=2))
336
+ _emit_json(data, pretty)
337
+
338
+
339
+ def _python_type_name(t: type | None) -> str:
340
+ """Human/JSON-friendly type label for a ParamDef.python_type (None = boolean flag)."""
341
+ if t is None:
342
+ return "boolean"
343
+ return getattr(t, "__name__", str(t))
344
+
345
+
346
+ def _param_to_dict(p: "ParamDef") -> dict:
347
+ d = {
348
+ "name": p.name,
349
+ "type": _python_type_name(p.python_type),
350
+ "required": p.required,
351
+ "description": p.description,
352
+ "location": p.location,
353
+ }
354
+ if p.choices:
355
+ d["choices"] = p.choices
356
+ return d
357
+
358
+
359
+ def command_to_dict(cmd: "CommandDef") -> dict:
360
+ """Serialize a CommandDef to a JSON-friendly dict for `--list --json`."""
361
+ d: dict = {"name": cmd.name, "description": cmd.description or ""}
362
+ if cmd.method:
363
+ d["method"] = cmd.method.upper()
364
+ if cmd.path:
365
+ d["path"] = cmd.path
366
+ if cmd.tool_name:
367
+ d["toolName"] = cmd.tool_name
368
+ if cmd.graphql_operation_type:
369
+ d["operationType"] = cmd.graphql_operation_type
370
+ d["parameters"] = [_param_to_dict(p) for p in cmd.params]
371
+ return d
372
+
373
+
374
+ def print_commands_json(
375
+ commands: "list[CommandDef]", compact: bool = False, pretty: bool = False
376
+ ) -> None:
377
+ """Emit a command list as JSON (array of objects, or names when *compact*)."""
378
+ if compact:
379
+ payload = [cmd.name for cmd in commands]
315
380
  else:
316
- print(json.dumps(data))
381
+ payload = [command_to_dict(cmd) for cmd in commands]
382
+ _emit_json(payload, pretty)
317
383
 
318
384
 
319
385
  def _build_http_headers(auth_headers: list[tuple[str, str]], multipart: bool = False) -> dict[str, str]:
@@ -469,13 +535,21 @@ OAUTH_DIR = CACHE_DIR / "oauth"
469
535
 
470
536
 
471
537
  class FileTokenStorage:
472
- """File-based token storage for OAuth tokens and client info."""
538
+ """File-based token storage for OAuth tokens and client info.
539
+
540
+ Persists an absolute ``expires_at`` timestamp alongside ``tokens.json``
541
+ (in ``tokens_meta.json``) so that on a fresh process the SDK can tell an
542
+ access token has expired and trigger a refresh, rather than blindly
543
+ re-sending the stale Bearer and falling through to a full re-auth that
544
+ reuses a possibly-forgotten DCR client_id (issue #50).
545
+ """
473
546
 
474
547
  def __init__(self, server_url: str):
475
548
  key = hashlib.sha256(server_url.encode()).hexdigest()[:16]
476
549
  self._dir = OAUTH_DIR / key
477
550
  self._dir.mkdir(parents=True, exist_ok=True)
478
551
  self._tokens_path = self._dir / "tokens.json"
552
+ self._tokens_meta_path = self._dir / "tokens_meta.json"
479
553
  self._client_path = self._dir / "client.json"
480
554
 
481
555
  async def get_tokens(self):
@@ -491,6 +565,50 @@ class FileTokenStorage:
491
565
 
492
566
  async def set_tokens(self, tokens) -> None:
493
567
  self._tokens_path.write_text(tokens.model_dump_json())
568
+ # Persist an absolute expiry timestamp so we can detect on a
569
+ # later process start that the access token has expired.
570
+ if tokens.expires_in is not None:
571
+ try:
572
+ meta = {"expires_at": time.time() + float(tokens.expires_in)}
573
+ self._tokens_meta_path.write_text(json.dumps(meta))
574
+ except Exception:
575
+ pass
576
+ else:
577
+ # No expiry info; drop any stale meta sidecar.
578
+ try:
579
+ self._tokens_meta_path.unlink()
580
+ except FileNotFoundError:
581
+ pass
582
+
583
+ def get_expires_at(self) -> float | None:
584
+ """Return the absolute expiry timestamp persisted alongside tokens.
585
+
586
+ Returns None when no sidecar exists (older caches or tokens without
587
+ an ``expires_in`` value).
588
+ """
589
+ if not self._tokens_meta_path.exists():
590
+ return None
591
+ try:
592
+ data = json.loads(self._tokens_meta_path.read_text())
593
+ value = data.get("expires_at")
594
+ return float(value) if value is not None else None
595
+ except Exception:
596
+ return None
597
+
598
+ def clear_tokens(self) -> None:
599
+ """Remove persisted tokens and their expiry sidecar."""
600
+ for path in (self._tokens_path, self._tokens_meta_path):
601
+ try:
602
+ path.unlink()
603
+ except FileNotFoundError:
604
+ pass
605
+
606
+ def clear_client_info(self) -> None:
607
+ """Remove persisted DCR client info so a fresh registration runs."""
608
+ try:
609
+ self._client_path.unlink()
610
+ except FileNotFoundError:
611
+ pass
494
612
 
495
613
  async def get_client_info(self):
496
614
  from mcp.shared.auth import OAuthClientInformationFull
@@ -601,6 +719,53 @@ def build_oauth_provider(
601
719
  from mcp.client.auth.oauth2 import OAuthClientProvider
602
720
  from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
603
721
 
722
+ class _RobustOAuthClientProvider(OAuthClientProvider):
723
+ """OAuthClientProvider with cross-process expiry + stale-DCR recovery.
724
+
725
+ Fixes for issue #50 — Atlassian "Internal Server Error" on next-day
726
+ OAuth calls. The upstream SDK has two gaps when state is reloaded
727
+ from disk in a new process:
728
+
729
+ 1. ``_initialize`` reloads ``current_tokens`` from storage but never
730
+ recomputes ``token_expiry_time`` from the bare ``OAuthToken``
731
+ dump (which only carries ``expires_in``). An expired access
732
+ token therefore looks valid, is sent, and only the 401 fallback
733
+ runs — skipping the refresh-token grant entirely.
734
+
735
+ 2. The 401 fallback re-uses the cached DCR ``client_id`` because
736
+ ``client_info`` was reloaded from disk; if the auth server has
737
+ since forgotten that registration, ``/authorize`` returns an
738
+ opaque 500 page rather than a clean ``invalid_client`` redirect
739
+ and the CLI hangs on the callback.
740
+
741
+ We patch both by restoring ``token_expiry_time`` from a sidecar
742
+ we persist in :class:`FileTokenStorage`, and by wiping the cached
743
+ ``client_info`` from disk and memory whenever a refresh fails so
744
+ the subsequent re-auth performs fresh Dynamic Client Registration.
745
+ """
746
+
747
+ async def _initialize(self) -> None:
748
+ await super()._initialize()
749
+ storage = self.context.storage
750
+ if isinstance(storage, FileTokenStorage) and self.context.current_tokens:
751
+ expires_at = storage.get_expires_at()
752
+ if expires_at is not None:
753
+ self.context.token_expiry_time = expires_at
754
+
755
+ async def _handle_refresh_response(self, response) -> bool:
756
+ ok = await super()._handle_refresh_response(response)
757
+ if not ok:
758
+ # Refresh failed. The cached DCR client_id may have been
759
+ # forgotten by the auth server too; clear it so the
760
+ # subsequent 401 fallback performs a fresh registration
761
+ # instead of /authorize?client_id=<stale> → opaque 500.
762
+ self.context.client_info = None
763
+ storage = self.context.storage
764
+ if isinstance(storage, FileTokenStorage):
765
+ storage.clear_client_info()
766
+ storage.clear_tokens()
767
+ return ok
768
+
604
769
  _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
605
770
 
606
771
  if redirect_uri is not None:
@@ -700,7 +865,7 @@ def build_oauth_provider(
700
865
  raise RuntimeError("No authorization code received")
701
866
  return (_CallbackHandler.auth_code, _CallbackHandler.state)
702
867
 
703
- return OAuthClientProvider(
868
+ return _RobustOAuthClientProvider(
704
869
  server_url=server_url,
705
870
  client_metadata=client_metadata,
706
871
  storage=storage,
@@ -1308,10 +1473,16 @@ def list_graphql_commands(
1308
1473
  source_hash: str = "",
1309
1474
  sort_mode: str | None = None,
1310
1475
  top: int | None = None,
1476
+ json_output: bool = False,
1477
+ pretty: bool = False,
1311
1478
  ):
1312
1479
  """Group commands by operation type and print."""
1313
1480
  commands = _apply_list_options(commands, source_hash, sort_mode, top)
1314
1481
 
1482
+ if json_output:
1483
+ print_commands_json(commands, compact=compact, pretty=pretty)
1484
+ return
1485
+
1315
1486
  if compact:
1316
1487
  print(" ".join(cmd.name for cmd in commands))
1317
1488
  return
@@ -1403,6 +1574,7 @@ def execute_graphql(
1403
1574
  fields_override: str | None = None,
1404
1575
  oauth_provider: "httpx.Auth | None" = None,
1405
1576
  head: int | None = None,
1577
+ json_output: bool = False,
1406
1578
  ):
1407
1579
  """Build and execute a GraphQL query/mutation."""
1408
1580
  document, variables, field_name = _build_graphql_document(
@@ -1426,13 +1598,13 @@ def execute_graphql(
1426
1598
  print(f"GraphQL error: {msgs}", file=sys.stderr)
1427
1599
  sys.exit(1)
1428
1600
  # Partial errors — include them in output
1429
- output_result(result, pretty=pretty, raw=raw, toon=toon, head=head)
1601
+ output_result(result, pretty=pretty, raw=raw, toon=toon, head=head, json_output=json_output)
1430
1602
  return
1431
1603
 
1432
1604
  data = result.get("data", {})
1433
1605
  # Extract the specific field's data
1434
1606
  field_data = data.get(field_name, data)
1435
- output_result(field_data, pretty=pretty, raw=raw, toon=toon, head=head)
1607
+ output_result(field_data, pretty=pretty, raw=raw, toon=toon, head=head, json_output=json_output)
1436
1608
 
1437
1609
 
1438
1610
  def handle_graphql(
@@ -1453,6 +1625,7 @@ def handle_graphql(
1453
1625
  sort_mode: str | None = None,
1454
1626
  top: int | None = None,
1455
1627
  compact: bool = False,
1628
+ json_output: bool = False,
1456
1629
  ):
1457
1630
  """Top-level handler for --graphql mode."""
1458
1631
  src_hash = _source_hash_for(url)
@@ -1462,6 +1635,7 @@ def handle_graphql(
1462
1635
  list_kwargs = dict(
1463
1636
  verbose=verbose, compact=compact,
1464
1637
  source_hash=src_hash, sort_mode=sort_mode, top=top,
1638
+ json_output=json_output, pretty=pretty,
1465
1639
  )
1466
1640
 
1467
1641
  if list_mode:
@@ -1469,10 +1643,10 @@ def handle_graphql(
1469
1643
  return
1470
1644
 
1471
1645
  if not remaining:
1472
- if not compact:
1646
+ if not compact and not json_output:
1473
1647
  print("Available operations:")
1474
1648
  list_graphql_commands(commands, **list_kwargs)
1475
- if not compact:
1649
+ if not compact and not json_output:
1476
1650
  print("\nUse --list for the same output, or provide a subcommand.")
1477
1651
  return
1478
1652
 
@@ -1488,6 +1662,7 @@ def handle_graphql(
1488
1662
  execute_graphql(
1489
1663
  args, cmd, url, schema, auth_headers, pretty, raw, toon=toon,
1490
1664
  fields_override=fields_override, oauth_provider=oauth_provider,
1665
+ json_output=json_output,
1491
1666
  )
1492
1667
 
1493
1668
  # Record usage after successful execution
@@ -1941,9 +2116,15 @@ def list_openapi_commands(
1941
2116
  source_hash: str = "",
1942
2117
  sort_mode: str | None = None,
1943
2118
  top: int | None = None,
2119
+ json_output: bool = False,
2120
+ pretty: bool = False,
1944
2121
  ):
1945
2122
  commands = _apply_list_options(commands, source_hash, sort_mode, top)
1946
2123
 
2124
+ if json_output:
2125
+ print_commands_json(commands, compact=compact, pretty=pretty)
2126
+ return
2127
+
1947
2128
  if compact:
1948
2129
  print(" ".join(cmd.name for cmd in commands))
1949
2130
  return
@@ -1974,9 +2155,15 @@ def list_mcp_commands(
1974
2155
  source_hash: str = "",
1975
2156
  sort_mode: str | None = None,
1976
2157
  top: int | None = None,
2158
+ json_output: bool = False,
2159
+ pretty: bool = False,
1977
2160
  ):
1978
2161
  commands = _apply_list_options(commands, source_hash, sort_mode, top)
1979
2162
 
2163
+ if json_output:
2164
+ print_commands_json(commands, compact=compact, pretty=pretty)
2165
+ return
2166
+
1980
2167
  if compact:
1981
2168
  print(" ".join(cmd.name for cmd in commands))
1982
2169
  return
@@ -2087,6 +2274,7 @@ def execute_openapi(
2087
2274
  toon: bool = False,
2088
2275
  oauth_provider: "httpx.Auth | None" = None,
2089
2276
  head: int | None = None,
2277
+ json_output: bool = False,
2090
2278
  ):
2091
2279
  path, query_params, extra_headers, body, files = _collect_openapi_params(cmd, args)
2092
2280
  url = base_url.rstrip("/") + path
@@ -2128,6 +2316,14 @@ def execute_openapi(
2128
2316
  for _, file_tuple in files.items():
2129
2317
  file_tuple[1].close()
2130
2318
 
2319
+ if json_output:
2320
+ try:
2321
+ data = resp.json()
2322
+ except Exception:
2323
+ data = resp.text
2324
+ output_result(data, pretty=pretty, head=head, json_output=True)
2325
+ return
2326
+
2131
2327
  if raw:
2132
2328
  sys.stdout.buffer.write(resp.content)
2133
2329
  return
@@ -2172,6 +2368,7 @@ def run_mcp_http(
2172
2368
  top: int | None = None,
2173
2369
  compact: bool = False,
2174
2370
  source_hash: str = "",
2371
+ json_output: bool = False,
2175
2372
  ):
2176
2373
  extra = dict(
2177
2374
  resource_action=resource_action,
@@ -2186,6 +2383,7 @@ def run_mcp_http(
2186
2383
  top=top,
2187
2384
  compact=compact,
2188
2385
  source_hash=source_hash,
2386
+ json_output=json_output,
2189
2387
  )
2190
2388
 
2191
2389
  async def _run():
@@ -2275,6 +2473,7 @@ def run_mcp_stdio(
2275
2473
  top: int | None = None,
2276
2474
  compact: bool = False,
2277
2475
  source_hash: str = "",
2476
+ json_output: bool = False,
2278
2477
  ):
2279
2478
  extra = dict(
2280
2479
  resource_action=resource_action,
@@ -2289,6 +2488,7 @@ def run_mcp_stdio(
2289
2488
  top=top,
2290
2489
  compact=compact,
2291
2490
  source_hash=source_hash,
2491
+ json_output=json_output,
2292
2492
  )
2293
2493
 
2294
2494
  import anyio
@@ -2344,11 +2544,13 @@ async def _mcp_session(
2344
2544
  top: int | None = None,
2345
2545
  compact: bool = False,
2346
2546
  source_hash: str = "",
2547
+ json_output: bool = False,
2347
2548
  ):
2348
2549
  # Handle resource operations
2349
2550
  if resource_action:
2350
2551
  await _handle_resources(
2351
2552
  session, resource_action, resource_uri, pretty, raw, toon, head=head,
2553
+ json_output=json_output,
2352
2554
  )
2353
2555
  return
2354
2556
 
@@ -2356,13 +2558,14 @@ async def _mcp_session(
2356
2558
  if prompt_action:
2357
2559
  await _handle_prompts(
2358
2560
  session, prompt_action, prompt_name, prompt_arguments,
2359
- pretty, raw, toon, head=head,
2561
+ pretty, raw, toon, head=head, json_output=json_output,
2360
2562
  )
2361
2563
  return
2362
2564
 
2363
2565
  list_kwargs = dict(
2364
2566
  verbose=verbose, compact=compact,
2365
2567
  source_hash=source_hash, sort_mode=sort_mode, top=top,
2568
+ json_output=json_output, pretty=pretty,
2366
2569
  )
2367
2570
 
2368
2571
  if list_mode:
@@ -2379,12 +2582,15 @@ async def _mcp_session(
2379
2582
  if search_pattern:
2380
2583
  commands = _filter_commands(commands, search_pattern)
2381
2584
  if not commands:
2382
- print(f"\nNo tools matching '{search_pattern}'.")
2585
+ if json_output:
2586
+ list_mcp_commands(commands, **list_kwargs)
2587
+ else:
2588
+ print(f"\nNo tools matching '{search_pattern}'.")
2383
2589
  return
2384
- if not compact:
2590
+ if not compact and not json_output:
2385
2591
  print(f"\nTools matching '{search_pattern}':")
2386
2592
  else:
2387
- if not compact:
2593
+ if not compact and not json_output:
2388
2594
  print("\nAvailable tools:")
2389
2595
  list_mcp_commands(commands, **list_kwargs)
2390
2596
  return
@@ -2398,6 +2604,12 @@ async def _mcp_session(
2398
2604
 
2399
2605
  result = await session.call_tool(tool_name, arguments or {})
2400
2606
 
2607
+ if json_output:
2608
+ # Emit the full MCP CallToolResult envelope (content, structuredContent,
2609
+ # isError) using the SDK's own serializer — 100% MCP-compatible.
2610
+ output_result(result.model_dump(mode="json"), pretty=pretty, head=head, json_output=True)
2611
+ return
2612
+
2401
2613
  text = _extract_content_parts(result.content)
2402
2614
  output_result(text, pretty=pretty, raw=raw, toon=toon, head=head)
2403
2615
 
@@ -2415,8 +2627,9 @@ async def _handle_resources(
2415
2627
  raw: bool,
2416
2628
  toon: bool,
2417
2629
  head: int | None = None,
2630
+ json_output: bool = False,
2418
2631
  ):
2419
- _out = dict(pretty=pretty, raw=raw, toon=toon, head=head)
2632
+ _out = dict(pretty=pretty, raw=raw, toon=toon, head=head, json_output=json_output)
2420
2633
  if action == "list":
2421
2634
  result = await session.list_resources()
2422
2635
  data = [
@@ -2469,8 +2682,9 @@ async def _handle_prompts(
2469
2682
  raw: bool,
2470
2683
  toon: bool,
2471
2684
  head: int | None = None,
2685
+ json_output: bool = False,
2472
2686
  ):
2473
- _out = dict(pretty=pretty, raw=raw, toon=toon, head=head)
2687
+ _out = dict(pretty=pretty, raw=raw, toon=toon, head=head, json_output=json_output)
2474
2688
  if action == "list":
2475
2689
  result = await session.list_prompts()
2476
2690
  data = [
@@ -3011,6 +3225,7 @@ def handle_mcp(
3011
3225
  sort_mode: str | None = None,
3012
3226
  top: int | None = None,
3013
3227
  compact: bool = False,
3228
+ json_output: bool = False,
3014
3229
  ):
3015
3230
  # Build a config dict for cache key generation (future-proof)
3016
3231
  config_for_cache = {
@@ -3033,6 +3248,7 @@ def handle_mcp(
3033
3248
  prompt_name=prompt_name,
3034
3249
  prompt_arguments=prompt_arguments,
3035
3250
  head=head,
3251
+ json_output=json_output,
3036
3252
  )
3037
3253
  _dispatch_mcp_call(
3038
3254
  source, is_stdio, auth_headers, env_vars,
@@ -3045,6 +3261,7 @@ def handle_mcp(
3045
3261
  list_kwargs = dict(
3046
3262
  verbose=verbose, compact=compact,
3047
3263
  source_hash=src_hash, sort_mode=sort_mode, top=top,
3264
+ json_output=json_output, pretty=pretty,
3048
3265
  )
3049
3266
 
3050
3267
  if list_mode:
@@ -3058,7 +3275,7 @@ def handle_mcp(
3058
3275
  commands = filter_commands(
3059
3276
  commands, bake_config.include, bake_config.exclude, bake_config.methods,
3060
3277
  )
3061
- if not compact:
3278
+ if not compact and not json_output:
3062
3279
  print("\nAvailable tools:")
3063
3280
  list_mcp_commands(commands, **list_kwargs)
3064
3281
  return
@@ -3070,6 +3287,7 @@ def handle_mcp(
3070
3287
  verbose=verbose,
3071
3288
  sort_mode=sort_mode, top=top, compact=compact,
3072
3289
  source_hash=src_hash,
3290
+ json_output=json_output,
3073
3291
  )
3074
3292
  return
3075
3293
 
@@ -3086,10 +3304,10 @@ def handle_mcp(
3086
3304
  )
3087
3305
 
3088
3306
  if not remaining:
3089
- if not compact:
3307
+ if not compact and not json_output:
3090
3308
  print("Available tools:")
3091
3309
  list_mcp_commands(commands, **list_kwargs)
3092
- if not compact:
3310
+ if not compact and not json_output:
3093
3311
  print("\nUse --list for the same output, or provide a subcommand.")
3094
3312
  return
3095
3313
 
@@ -3116,6 +3334,7 @@ def handle_mcp(
3116
3334
  source, is_stdio, auth_headers, env_vars,
3117
3335
  cmd.tool_name, arguments, False, pretty, raw, key, ttl, refresh,
3118
3336
  toon=toon, transport=transport, oauth_provider=oauth_provider,
3337
+ json_output=json_output,
3119
3338
  )
3120
3339
 
3121
3340
  # Record usage after successful execution
@@ -3333,6 +3552,16 @@ def _build_main_parser() -> argparse.ArgumentParser:
3333
3552
  )
3334
3553
  pre.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
3335
3554
  pre.add_argument("--raw", action="store_true", help="Print raw response body")
3555
+ pre.add_argument(
3556
+ "--json",
3557
+ action="store_true",
3558
+ dest="json_output",
3559
+ help=(
3560
+ "Force valid JSON output for every command. --list emits a JSON array of "
3561
+ "commands; MCP tool calls emit the full result envelope including "
3562
+ "structuredContent and isError. Takes precedence over --raw and --toon."
3563
+ ),
3564
+ )
3336
3565
  pre.add_argument(
3337
3566
  "--toon",
3338
3567
  action="store_true",
@@ -3594,32 +3823,28 @@ def _handle_session_operations(
3594
3823
 
3595
3824
  # --- Session client mode ---
3596
3825
  sess_name = pre_args.session
3826
+ _sess_out = dict(
3827
+ pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon,
3828
+ json_output=pre_args.json_output,
3829
+ )
3597
3830
 
3598
3831
  if pre_args.list_resources:
3599
3832
  result = _session_request(sess_name, "list_resources")
3600
- output_result(
3601
- result, pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon,
3602
- )
3833
+ output_result(result, **_sess_out)
3603
3834
  return True
3604
3835
  if pre_args.list_resource_templates:
3605
3836
  result = _session_request(sess_name, "list_resource_templates")
3606
- output_result(
3607
- result, pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon,
3608
- )
3837
+ output_result(result, **_sess_out)
3609
3838
  return True
3610
3839
  if pre_args.read_resource:
3611
3840
  result = _session_request(
3612
3841
  sess_name, "read_resource", {"uri": pre_args.read_resource}
3613
3842
  )
3614
- output_result(
3615
- result, pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon,
3616
- )
3843
+ output_result(result, **_sess_out)
3617
3844
  return True
3618
3845
  if pre_args.list_prompts:
3619
3846
  result = _session_request(sess_name, "list_prompts")
3620
- output_result(
3621
- result, pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon,
3622
- )
3847
+ output_result(result, **_sess_out)
3623
3848
  return True
3624
3849
  if pre_args.get_prompt:
3625
3850
  p_args = {}
@@ -3632,9 +3857,7 @@ def _handle_session_operations(
3632
3857
  "get_prompt",
3633
3858
  {"name": pre_args.get_prompt, "arguments": p_args},
3634
3859
  )
3635
- output_result(
3636
- result, pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon,
3637
- )
3860
+ output_result(result, **_sess_out)
3638
3861
  return True
3639
3862
  if pre_args.list_commands:
3640
3863
  result = _session_request(sess_name, "list_tools")
@@ -3642,21 +3865,36 @@ def _handle_session_operations(
3642
3865
  if search_pattern:
3643
3866
  commands = _filter_commands(commands, search_pattern)
3644
3867
  if not commands:
3645
- print(f"\nNo tools matching '{search_pattern}'.")
3868
+ if pre_args.json_output:
3869
+ list_mcp_commands(
3870
+ commands, verbose=pre_args.verbose,
3871
+ json_output=True, pretty=pre_args.pretty,
3872
+ )
3873
+ else:
3874
+ print(f"\nNo tools matching '{search_pattern}'.")
3646
3875
  return True
3647
- print(f"\nTools matching '{search_pattern}':")
3648
- else:
3876
+ if not pre_args.json_output:
3877
+ print(f"\nTools matching '{search_pattern}':")
3878
+ elif not pre_args.json_output:
3649
3879
  print("\nAvailable tools:")
3650
- list_mcp_commands(commands, verbose=pre_args.verbose)
3880
+ list_mcp_commands(
3881
+ commands, verbose=pre_args.verbose,
3882
+ json_output=pre_args.json_output, pretty=pre_args.pretty,
3883
+ )
3651
3884
  return True
3652
3885
 
3653
3886
  # Tool call via session
3654
3887
  if not remaining:
3655
3888
  result = _session_request(sess_name, "list_tools")
3656
3889
  commands = extract_mcp_commands(result)
3657
- print("Available tools:")
3658
- list_mcp_commands(commands, verbose=pre_args.verbose)
3659
- print("\nUse --list for the same output, or provide a subcommand.")
3890
+ if not pre_args.json_output:
3891
+ print("Available tools:")
3892
+ list_mcp_commands(
3893
+ commands, verbose=pre_args.verbose,
3894
+ json_output=pre_args.json_output, pretty=pre_args.pretty,
3895
+ )
3896
+ if not pre_args.json_output:
3897
+ print("\nUse --list for the same output, or provide a subcommand.")
3660
3898
  return True
3661
3899
 
3662
3900
  tools = _session_request(sess_name, "list_tools")
@@ -3682,9 +3920,7 @@ def _handle_session_operations(
3682
3920
  result = _session_request(
3683
3921
  sess_name, "call_tool", {"name": cmd.tool_name, "arguments": arguments}
3684
3922
  )
3685
- output_result(
3686
- result, pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon
3687
- )
3923
+ output_result(result, **_sess_out)
3688
3924
  return True
3689
3925
 
3690
3926
 
@@ -3749,15 +3985,19 @@ def _handle_openapi_mode(
3749
3985
  list_kwargs = dict(
3750
3986
  verbose=pre_args.verbose, compact=pre_args.compact,
3751
3987
  source_hash=src_hash, sort_mode=pre_args.sort_mode, top=pre_args.top,
3988
+ json_output=pre_args.json_output, pretty=pre_args.pretty,
3752
3989
  )
3753
3990
 
3754
3991
  if pre_args.list_commands:
3755
3992
  if search_pattern:
3756
3993
  commands = _filter_commands(commands, search_pattern)
3757
3994
  if not commands:
3758
- print(f"\nNo tools matching '{search_pattern}'.")
3995
+ if not pre_args.json_output:
3996
+ print(f"\nNo tools matching '{search_pattern}'.")
3997
+ else:
3998
+ list_openapi_commands(commands, **list_kwargs)
3759
3999
  return
3760
- if not pre_args.compact:
4000
+ if not pre_args.compact and not pre_args.json_output:
3761
4001
  print(f"\nTools matching '{search_pattern}':")
3762
4002
  list_openapi_commands(commands, **list_kwargs)
3763
4003
  return
@@ -3801,7 +4041,7 @@ def _handle_openapi_mode(
3801
4041
  execute_openapi(
3802
4042
  args, cmd, base_url, auth_headers,
3803
4043
  pre_args.pretty, pre_args.raw, toon=pre_args.toon,
3804
- oauth_provider=oauth_provider,
4044
+ oauth_provider=oauth_provider, json_output=pre_args.json_output,
3805
4045
  )
3806
4046
 
3807
4047
  # Record usage after successful execution
@@ -3861,6 +4101,7 @@ def _main_impl(argv: list[str], bake_config: BakeConfig | None = None):
3861
4101
  sort_mode=pre_args.sort_mode,
3862
4102
  top=pre_args.top,
3863
4103
  compact=pre_args.compact,
4104
+ json_output=pre_args.json_output,
3864
4105
  )
3865
4106
  return
3866
4107
 
@@ -3895,6 +4136,7 @@ def _main_impl(argv: list[str], bake_config: BakeConfig | None = None):
3895
4136
  sort_mode=pre_args.sort_mode,
3896
4137
  top=pre_args.top,
3897
4138
  compact=pre_args.compact,
4139
+ json_output=pre_args.json_output,
3898
4140
  )
3899
4141
  return
3900
4142
 
File without changes
File without changes