mcp2cli 3.1.0__tar.gz → 3.3.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.1.0
3
+ Version: 3.3.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.1.0"
3
+ version = "3.3.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]:
@@ -602,6 +668,62 @@ def _find_free_port() -> int:
602
668
  return s.getsockname()[1]
603
669
 
604
670
 
671
+
672
+
673
+ def _get_cached_redirect_uri(storage: "FileTokenStorage") -> str | None:
674
+ """Return the cached client's redirect_uri, if any.
675
+
676
+ Reads client.json from disk without async. Returns None when
677
+ no cached client exists or the redirect_uri cannot be parsed.
678
+
679
+ Used by build_oauth_provider to reuse the registered redirect
680
+ port from a prior DCR run (issue #54).
681
+ """
682
+ if not storage._client_path.exists():
683
+ return None
684
+ try:
685
+ data = json.loads(storage._client_path.read_text())
686
+ uris = data.get("redirect_uris") or []
687
+ if uris:
688
+ return uris[0]
689
+ except Exception:
690
+ pass
691
+ return None
692
+
693
+
694
+ def _port_available(host: str, port: int) -> bool:
695
+ """Check whether *port* is free on *host*.
696
+
697
+ Note: this is a best-effort probe — a TOCTOU race exists where another
698
+ process could bind the port between this check and the caller's use.
699
+ Callers must handle bind failures gracefully.
700
+ """
701
+ try:
702
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
703
+ s.bind((host, port))
704
+ return True
705
+ except OSError:
706
+ return False
707
+
708
+
709
+ def _restore_token_expiry_from_sidecar(context) -> None:
710
+ """Recompute ``token_expiry_time`` from the persisted ``expires_at`` sidecar.
711
+
712
+ The upstream SDK's ``_initialize`` reloads ``current_tokens`` from disk but
713
+ never restores ``token_expiry_time`` — the persisted ``OAuthToken`` only
714
+ carries the relative ``expires_in``. Without this, ``is_token_valid()``
715
+ treats an already-expired access token as valid on a fresh process and
716
+ sends a stale Bearer, wasting a 401 round-trip before re-auth (issues #50
717
+ and #57). Shared by the authorization-code and client-credentials
718
+ providers.
719
+ """
720
+ storage = context.storage
721
+ if isinstance(storage, FileTokenStorage) and context.current_tokens:
722
+ expires_at = storage.get_expires_at()
723
+ if expires_at is not None:
724
+ context.token_expiry_time = expires_at
725
+
726
+
605
727
  def build_oauth_provider(
606
728
  server_url: str,
607
729
  *,
@@ -642,7 +764,22 @@ def build_oauth_provider(
642
764
  ClientCredentialsOAuthProvider,
643
765
  )
644
766
 
645
- return ClientCredentialsOAuthProvider(
767
+ class _RobustClientCredentialsProvider(ClientCredentialsOAuthProvider):
768
+ """Client-credentials provider that restores expiry across restarts.
769
+
770
+ Issue #57: the upstream ``_initialize`` reloads tokens from disk
771
+ without recomputing ``token_expiry_time``, so an expired access
772
+ token looks valid after a process restart and a stale Bearer is
773
+ sent — triggering a 401 before the provider re-authenticates.
774
+ Restore the expiry from the sidecar so expired tokens are detected
775
+ proactively.
776
+ """
777
+
778
+ async def _initialize(self) -> None:
779
+ await super()._initialize()
780
+ _restore_token_expiry_from_sidecar(self.context)
781
+
782
+ return _RobustClientCredentialsProvider(
646
783
  server_url=server_url,
647
784
  storage=storage,
648
785
  client_id=client_id,
@@ -680,13 +817,19 @@ def build_oauth_provider(
680
817
 
681
818
  async def _initialize(self) -> None:
682
819
  await super()._initialize()
683
- storage = self.context.storage
684
- if isinstance(storage, FileTokenStorage) and self.context.current_tokens:
685
- expires_at = storage.get_expires_at()
686
- if expires_at is not None:
687
- self.context.token_expiry_time = expires_at
820
+ _restore_token_expiry_from_sidecar(self.context)
688
821
 
689
822
  async def _handle_refresh_response(self, response) -> bool:
823
+ # Issue #58: RFC 6749 §5.1 permits a refresh response to omit
824
+ # refresh_token, in which case the previously issued one stays
825
+ # valid. The upstream SDK replaces current_tokens wholesale, so
826
+ # refresh_token becomes None and every later refresh fails for
827
+ # the lack of one. Remember the old token to carry it forward.
828
+ old_refresh_token = (
829
+ self.context.current_tokens.refresh_token
830
+ if self.context.current_tokens
831
+ else None
832
+ )
690
833
  ok = await super()._handle_refresh_response(response)
691
834
  if not ok:
692
835
  # Refresh failed. The cached DCR client_id may have been
@@ -698,6 +841,16 @@ def build_oauth_provider(
698
841
  if isinstance(storage, FileTokenStorage):
699
842
  storage.clear_client_info()
700
843
  storage.clear_tokens()
844
+ return ok
845
+ # Carry the prior refresh token forward when the server did not
846
+ # issue a new one, then re-persist so it survives a restart.
847
+ tokens = self.context.current_tokens
848
+ if tokens is not None and not tokens.refresh_token and old_refresh_token:
849
+ tokens = tokens.model_copy(update={"refresh_token": old_refresh_token})
850
+ self.context.current_tokens = tokens
851
+ storage = self.context.storage
852
+ if isinstance(storage, FileTokenStorage):
853
+ await storage.set_tokens(tokens)
701
854
  return ok
702
855
 
703
856
  _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
@@ -728,9 +881,36 @@ def build_oauth_provider(
728
881
  callback_host = parsed.hostname
729
882
  port = parsed.port
730
883
  else:
731
- port = _find_free_port()
732
- callback_host = "127.0.0.1"
733
- redirect_uri = f"http://127.0.0.1:{port}/callback"
884
+ # Issue #54: When DCR is used without an explicit redirect_uri,
885
+ # _find_free_port() picks a new random port on every run. If a
886
+ # cached client.json already exists (from a prior run), its
887
+ # registered redirect_uris[0] carries the ORIGINAL port. Sending
888
+ # an auth request with a different port causes the auth server to
889
+ # reject the mismatch ("callback URL is invalid").
890
+ #
891
+ # Fix: reuse the cached redirect_uri if its port is still available.
892
+ # If the cached port is occupied (unlikely for loopback), clear the
893
+ # stale client so DCR re-registers with the fresh port.
894
+ cached_uri = _get_cached_redirect_uri(storage)
895
+ if cached_uri is not None:
896
+ from urllib.parse import urlparse as _urlparse
897
+ _parsed = _urlparse(cached_uri)
898
+ _cached_port = _parsed.port
899
+ _cached_host = _parsed.hostname or "127.0.0.1"
900
+ if _cached_port and _port_available(_cached_host, _cached_port):
901
+ callback_host = _cached_host
902
+ port = _cached_port
903
+ redirect_uri = cached_uri
904
+ else:
905
+ # Port no longer free — clear stale client and re-register
906
+ storage.clear_client_info()
907
+ port = _find_free_port()
908
+ callback_host = "127.0.0.1"
909
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
910
+ else:
911
+ port = _find_free_port()
912
+ callback_host = "127.0.0.1"
913
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
734
914
 
735
915
  client_metadata = OAuthClientMetadata(
736
916
  client_name=client_name,
@@ -780,7 +960,7 @@ def build_oauth_provider(
780
960
  server = HTTPServer((callback_host, port), _CallbackHandler)
781
961
 
782
962
  async def redirect_handler(auth_url: str) -> None:
783
- print(f"Opening browser for authorization...", file=sys.stderr)
963
+ print("Opening browser for authorization...", file=sys.stderr)
784
964
  print(f"If browser doesn't open, visit: {auth_url}", file=sys.stderr)
785
965
  webbrowser.open(auth_url)
786
966
 
@@ -1407,10 +1587,16 @@ def list_graphql_commands(
1407
1587
  source_hash: str = "",
1408
1588
  sort_mode: str | None = None,
1409
1589
  top: int | None = None,
1590
+ json_output: bool = False,
1591
+ pretty: bool = False,
1410
1592
  ):
1411
1593
  """Group commands by operation type and print."""
1412
1594
  commands = _apply_list_options(commands, source_hash, sort_mode, top)
1413
1595
 
1596
+ if json_output:
1597
+ print_commands_json(commands, compact=compact, pretty=pretty)
1598
+ return
1599
+
1414
1600
  if compact:
1415
1601
  print(" ".join(cmd.name for cmd in commands))
1416
1602
  return
@@ -1502,6 +1688,7 @@ def execute_graphql(
1502
1688
  fields_override: str | None = None,
1503
1689
  oauth_provider: "httpx.Auth | None" = None,
1504
1690
  head: int | None = None,
1691
+ json_output: bool = False,
1505
1692
  ):
1506
1693
  """Build and execute a GraphQL query/mutation."""
1507
1694
  document, variables, field_name = _build_graphql_document(
@@ -1525,13 +1712,13 @@ def execute_graphql(
1525
1712
  print(f"GraphQL error: {msgs}", file=sys.stderr)
1526
1713
  sys.exit(1)
1527
1714
  # Partial errors — include them in output
1528
- output_result(result, pretty=pretty, raw=raw, toon=toon, head=head)
1715
+ output_result(result, pretty=pretty, raw=raw, toon=toon, head=head, json_output=json_output)
1529
1716
  return
1530
1717
 
1531
1718
  data = result.get("data", {})
1532
1719
  # Extract the specific field's data
1533
1720
  field_data = data.get(field_name, data)
1534
- output_result(field_data, pretty=pretty, raw=raw, toon=toon, head=head)
1721
+ output_result(field_data, pretty=pretty, raw=raw, toon=toon, head=head, json_output=json_output)
1535
1722
 
1536
1723
 
1537
1724
  def handle_graphql(
@@ -1552,6 +1739,7 @@ def handle_graphql(
1552
1739
  sort_mode: str | None = None,
1553
1740
  top: int | None = None,
1554
1741
  compact: bool = False,
1742
+ json_output: bool = False,
1555
1743
  ):
1556
1744
  """Top-level handler for --graphql mode."""
1557
1745
  src_hash = _source_hash_for(url)
@@ -1561,6 +1749,7 @@ def handle_graphql(
1561
1749
  list_kwargs = dict(
1562
1750
  verbose=verbose, compact=compact,
1563
1751
  source_hash=src_hash, sort_mode=sort_mode, top=top,
1752
+ json_output=json_output, pretty=pretty,
1564
1753
  )
1565
1754
 
1566
1755
  if list_mode:
@@ -1568,10 +1757,10 @@ def handle_graphql(
1568
1757
  return
1569
1758
 
1570
1759
  if not remaining:
1571
- if not compact:
1760
+ if not compact and not json_output:
1572
1761
  print("Available operations:")
1573
1762
  list_graphql_commands(commands, **list_kwargs)
1574
- if not compact:
1763
+ if not compact and not json_output:
1575
1764
  print("\nUse --list for the same output, or provide a subcommand.")
1576
1765
  return
1577
1766
 
@@ -1587,6 +1776,7 @@ def handle_graphql(
1587
1776
  execute_graphql(
1588
1777
  args, cmd, url, schema, auth_headers, pretty, raw, toon=toon,
1589
1778
  fields_override=fields_override, oauth_provider=oauth_provider,
1779
+ json_output=json_output,
1590
1780
  )
1591
1781
 
1592
1782
  # Record usage after successful execution
@@ -2040,9 +2230,15 @@ def list_openapi_commands(
2040
2230
  source_hash: str = "",
2041
2231
  sort_mode: str | None = None,
2042
2232
  top: int | None = None,
2233
+ json_output: bool = False,
2234
+ pretty: bool = False,
2043
2235
  ):
2044
2236
  commands = _apply_list_options(commands, source_hash, sort_mode, top)
2045
2237
 
2238
+ if json_output:
2239
+ print_commands_json(commands, compact=compact, pretty=pretty)
2240
+ return
2241
+
2046
2242
  if compact:
2047
2243
  print(" ".join(cmd.name for cmd in commands))
2048
2244
  return
@@ -2073,9 +2269,15 @@ def list_mcp_commands(
2073
2269
  source_hash: str = "",
2074
2270
  sort_mode: str | None = None,
2075
2271
  top: int | None = None,
2272
+ json_output: bool = False,
2273
+ pretty: bool = False,
2076
2274
  ):
2077
2275
  commands = _apply_list_options(commands, source_hash, sort_mode, top)
2078
2276
 
2277
+ if json_output:
2278
+ print_commands_json(commands, compact=compact, pretty=pretty)
2279
+ return
2280
+
2079
2281
  if compact:
2080
2282
  print(" ".join(cmd.name for cmd in commands))
2081
2283
  return
@@ -2186,6 +2388,7 @@ def execute_openapi(
2186
2388
  toon: bool = False,
2187
2389
  oauth_provider: "httpx.Auth | None" = None,
2188
2390
  head: int | None = None,
2391
+ json_output: bool = False,
2189
2392
  ):
2190
2393
  path, query_params, extra_headers, body, files = _collect_openapi_params(cmd, args)
2191
2394
  url = base_url.rstrip("/") + path
@@ -2227,6 +2430,14 @@ def execute_openapi(
2227
2430
  for _, file_tuple in files.items():
2228
2431
  file_tuple[1].close()
2229
2432
 
2433
+ if json_output:
2434
+ try:
2435
+ data = resp.json()
2436
+ except Exception:
2437
+ data = resp.text
2438
+ output_result(data, pretty=pretty, head=head, json_output=True)
2439
+ return
2440
+
2230
2441
  if raw:
2231
2442
  sys.stdout.buffer.write(resp.content)
2232
2443
  return
@@ -2271,6 +2482,7 @@ def run_mcp_http(
2271
2482
  top: int | None = None,
2272
2483
  compact: bool = False,
2273
2484
  source_hash: str = "",
2485
+ json_output: bool = False,
2274
2486
  ):
2275
2487
  extra = dict(
2276
2488
  resource_action=resource_action,
@@ -2285,6 +2497,7 @@ def run_mcp_http(
2285
2497
  top=top,
2286
2498
  compact=compact,
2287
2499
  source_hash=source_hash,
2500
+ json_output=json_output,
2288
2501
  )
2289
2502
 
2290
2503
  async def _run():
@@ -2374,6 +2587,7 @@ def run_mcp_stdio(
2374
2587
  top: int | None = None,
2375
2588
  compact: bool = False,
2376
2589
  source_hash: str = "",
2590
+ json_output: bool = False,
2377
2591
  ):
2378
2592
  extra = dict(
2379
2593
  resource_action=resource_action,
@@ -2388,6 +2602,7 @@ def run_mcp_stdio(
2388
2602
  top=top,
2389
2603
  compact=compact,
2390
2604
  source_hash=source_hash,
2605
+ json_output=json_output,
2391
2606
  )
2392
2607
 
2393
2608
  import anyio
@@ -2443,11 +2658,13 @@ async def _mcp_session(
2443
2658
  top: int | None = None,
2444
2659
  compact: bool = False,
2445
2660
  source_hash: str = "",
2661
+ json_output: bool = False,
2446
2662
  ):
2447
2663
  # Handle resource operations
2448
2664
  if resource_action:
2449
2665
  await _handle_resources(
2450
2666
  session, resource_action, resource_uri, pretty, raw, toon, head=head,
2667
+ json_output=json_output,
2451
2668
  )
2452
2669
  return
2453
2670
 
@@ -2455,13 +2672,14 @@ async def _mcp_session(
2455
2672
  if prompt_action:
2456
2673
  await _handle_prompts(
2457
2674
  session, prompt_action, prompt_name, prompt_arguments,
2458
- pretty, raw, toon, head=head,
2675
+ pretty, raw, toon, head=head, json_output=json_output,
2459
2676
  )
2460
2677
  return
2461
2678
 
2462
2679
  list_kwargs = dict(
2463
2680
  verbose=verbose, compact=compact,
2464
2681
  source_hash=source_hash, sort_mode=sort_mode, top=top,
2682
+ json_output=json_output, pretty=pretty,
2465
2683
  )
2466
2684
 
2467
2685
  if list_mode:
@@ -2478,12 +2696,15 @@ async def _mcp_session(
2478
2696
  if search_pattern:
2479
2697
  commands = _filter_commands(commands, search_pattern)
2480
2698
  if not commands:
2481
- print(f"\nNo tools matching '{search_pattern}'.")
2699
+ if json_output:
2700
+ list_mcp_commands(commands, **list_kwargs)
2701
+ else:
2702
+ print(f"\nNo tools matching '{search_pattern}'.")
2482
2703
  return
2483
- if not compact:
2704
+ if not compact and not json_output:
2484
2705
  print(f"\nTools matching '{search_pattern}':")
2485
2706
  else:
2486
- if not compact:
2707
+ if not compact and not json_output:
2487
2708
  print("\nAvailable tools:")
2488
2709
  list_mcp_commands(commands, **list_kwargs)
2489
2710
  return
@@ -2497,6 +2718,12 @@ async def _mcp_session(
2497
2718
 
2498
2719
  result = await session.call_tool(tool_name, arguments or {})
2499
2720
 
2721
+ if json_output:
2722
+ # Emit the full MCP CallToolResult envelope (content, structuredContent,
2723
+ # isError) using the SDK's own serializer — 100% MCP-compatible.
2724
+ output_result(result.model_dump(mode="json"), pretty=pretty, head=head, json_output=True)
2725
+ return
2726
+
2500
2727
  text = _extract_content_parts(result.content)
2501
2728
  output_result(text, pretty=pretty, raw=raw, toon=toon, head=head)
2502
2729
 
@@ -2514,8 +2741,9 @@ async def _handle_resources(
2514
2741
  raw: bool,
2515
2742
  toon: bool,
2516
2743
  head: int | None = None,
2744
+ json_output: bool = False,
2517
2745
  ):
2518
- _out = dict(pretty=pretty, raw=raw, toon=toon, head=head)
2746
+ _out = dict(pretty=pretty, raw=raw, toon=toon, head=head, json_output=json_output)
2519
2747
  if action == "list":
2520
2748
  result = await session.list_resources()
2521
2749
  data = [
@@ -2568,8 +2796,9 @@ async def _handle_prompts(
2568
2796
  raw: bool,
2569
2797
  toon: bool,
2570
2798
  head: int | None = None,
2799
+ json_output: bool = False,
2571
2800
  ):
2572
- _out = dict(pretty=pretty, raw=raw, toon=toon, head=head)
2801
+ _out = dict(pretty=pretty, raw=raw, toon=toon, head=head, json_output=json_output)
2573
2802
  if action == "list":
2574
2803
  result = await session.list_prompts()
2575
2804
  data = [
@@ -2617,6 +2846,8 @@ def _session_meta_path(name: str) -> Path:
2617
2846
  def _session_sock_path(name: str) -> Path:
2618
2847
  return SESSIONS_DIR / f"{name}.sock"
2619
2848
 
2849
+ def _session_log_path(name: str) -> Path:
2850
+ return SESSIONS_DIR / f"{name}.log"
2620
2851
 
2621
2852
  def _session_is_alive(meta: dict) -> bool:
2622
2853
  pid = meta.get("pid")
@@ -2650,6 +2881,7 @@ def session_stop(name: str):
2650
2881
  """Stop a named session."""
2651
2882
  meta_path = _session_meta_path(name)
2652
2883
  sock_path = _session_sock_path(name)
2884
+ log_path = _session_log_path(name)
2653
2885
  if meta_path.exists():
2654
2886
  try:
2655
2887
  meta = json.loads(meta_path.read_text())
@@ -2667,6 +2899,7 @@ def session_stop(name: str):
2667
2899
  pass
2668
2900
  meta_path.unlink(missing_ok=True)
2669
2901
  sock_path.unlink(missing_ok=True)
2902
+ log_path.unlink(missing_ok=True)
2670
2903
 
2671
2904
 
2672
2905
  def session_start(
@@ -2709,6 +2942,7 @@ def session_start(
2709
2942
  }
2710
2943
  )
2711
2944
 
2945
+ log_path = _session_log_path(name)
2712
2946
  proc = subprocess.Popen(
2713
2947
  [
2714
2948
  sys.executable,
@@ -2717,7 +2951,7 @@ def session_start(
2717
2951
  ],
2718
2952
  start_new_session=True,
2719
2953
  stdout=subprocess.DEVNULL,
2720
- stderr=subprocess.DEVNULL,
2954
+ stderr=open(log_path, "a"),
2721
2955
  stdin=subprocess.DEVNULL,
2722
2956
  )
2723
2957
 
@@ -3110,6 +3344,7 @@ def handle_mcp(
3110
3344
  sort_mode: str | None = None,
3111
3345
  top: int | None = None,
3112
3346
  compact: bool = False,
3347
+ json_output: bool = False,
3113
3348
  ):
3114
3349
  # Build a config dict for cache key generation (future-proof)
3115
3350
  config_for_cache = {
@@ -3132,6 +3367,7 @@ def handle_mcp(
3132
3367
  prompt_name=prompt_name,
3133
3368
  prompt_arguments=prompt_arguments,
3134
3369
  head=head,
3370
+ json_output=json_output,
3135
3371
  )
3136
3372
  _dispatch_mcp_call(
3137
3373
  source, is_stdio, auth_headers, env_vars,
@@ -3144,6 +3380,7 @@ def handle_mcp(
3144
3380
  list_kwargs = dict(
3145
3381
  verbose=verbose, compact=compact,
3146
3382
  source_hash=src_hash, sort_mode=sort_mode, top=top,
3383
+ json_output=json_output, pretty=pretty,
3147
3384
  )
3148
3385
 
3149
3386
  if list_mode:
@@ -3157,7 +3394,7 @@ def handle_mcp(
3157
3394
  commands = filter_commands(
3158
3395
  commands, bake_config.include, bake_config.exclude, bake_config.methods,
3159
3396
  )
3160
- if not compact:
3397
+ if not compact and not json_output:
3161
3398
  print("\nAvailable tools:")
3162
3399
  list_mcp_commands(commands, **list_kwargs)
3163
3400
  return
@@ -3169,6 +3406,7 @@ def handle_mcp(
3169
3406
  verbose=verbose,
3170
3407
  sort_mode=sort_mode, top=top, compact=compact,
3171
3408
  source_hash=src_hash,
3409
+ json_output=json_output,
3172
3410
  )
3173
3411
  return
3174
3412
 
@@ -3185,10 +3423,10 @@ def handle_mcp(
3185
3423
  )
3186
3424
 
3187
3425
  if not remaining:
3188
- if not compact:
3426
+ if not compact and not json_output:
3189
3427
  print("Available tools:")
3190
3428
  list_mcp_commands(commands, **list_kwargs)
3191
- if not compact:
3429
+ if not compact and not json_output:
3192
3430
  print("\nUse --list for the same output, or provide a subcommand.")
3193
3431
  return
3194
3432
 
@@ -3215,6 +3453,7 @@ def handle_mcp(
3215
3453
  source, is_stdio, auth_headers, env_vars,
3216
3454
  cmd.tool_name, arguments, False, pretty, raw, key, ttl, refresh,
3217
3455
  toon=toon, transport=transport, oauth_provider=oauth_provider,
3456
+ json_output=json_output,
3218
3457
  )
3219
3458
 
3220
3459
  # Record usage after successful execution
@@ -3432,6 +3671,16 @@ def _build_main_parser() -> argparse.ArgumentParser:
3432
3671
  )
3433
3672
  pre.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
3434
3673
  pre.add_argument("--raw", action="store_true", help="Print raw response body")
3674
+ pre.add_argument(
3675
+ "--json",
3676
+ action="store_true",
3677
+ dest="json_output",
3678
+ help=(
3679
+ "Force valid JSON output for every command. --list emits a JSON array of "
3680
+ "commands; MCP tool calls emit the full result envelope including "
3681
+ "structuredContent and isError. Takes precedence over --raw and --toon."
3682
+ ),
3683
+ )
3435
3684
  pre.add_argument(
3436
3685
  "--toon",
3437
3686
  action="store_true",
@@ -3693,32 +3942,28 @@ def _handle_session_operations(
3693
3942
 
3694
3943
  # --- Session client mode ---
3695
3944
  sess_name = pre_args.session
3945
+ _sess_out = dict(
3946
+ pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon,
3947
+ json_output=pre_args.json_output,
3948
+ )
3696
3949
 
3697
3950
  if pre_args.list_resources:
3698
3951
  result = _session_request(sess_name, "list_resources")
3699
- output_result(
3700
- result, pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon,
3701
- )
3952
+ output_result(result, **_sess_out)
3702
3953
  return True
3703
3954
  if pre_args.list_resource_templates:
3704
3955
  result = _session_request(sess_name, "list_resource_templates")
3705
- output_result(
3706
- result, pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon,
3707
- )
3956
+ output_result(result, **_sess_out)
3708
3957
  return True
3709
3958
  if pre_args.read_resource:
3710
3959
  result = _session_request(
3711
3960
  sess_name, "read_resource", {"uri": pre_args.read_resource}
3712
3961
  )
3713
- output_result(
3714
- result, pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon,
3715
- )
3962
+ output_result(result, **_sess_out)
3716
3963
  return True
3717
3964
  if pre_args.list_prompts:
3718
3965
  result = _session_request(sess_name, "list_prompts")
3719
- output_result(
3720
- result, pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon,
3721
- )
3966
+ output_result(result, **_sess_out)
3722
3967
  return True
3723
3968
  if pre_args.get_prompt:
3724
3969
  p_args = {}
@@ -3731,9 +3976,7 @@ def _handle_session_operations(
3731
3976
  "get_prompt",
3732
3977
  {"name": pre_args.get_prompt, "arguments": p_args},
3733
3978
  )
3734
- output_result(
3735
- result, pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon,
3736
- )
3979
+ output_result(result, **_sess_out)
3737
3980
  return True
3738
3981
  if pre_args.list_commands:
3739
3982
  result = _session_request(sess_name, "list_tools")
@@ -3741,21 +3984,36 @@ def _handle_session_operations(
3741
3984
  if search_pattern:
3742
3985
  commands = _filter_commands(commands, search_pattern)
3743
3986
  if not commands:
3744
- print(f"\nNo tools matching '{search_pattern}'.")
3987
+ if pre_args.json_output:
3988
+ list_mcp_commands(
3989
+ commands, verbose=pre_args.verbose,
3990
+ json_output=True, pretty=pre_args.pretty,
3991
+ )
3992
+ else:
3993
+ print(f"\nNo tools matching '{search_pattern}'.")
3745
3994
  return True
3746
- print(f"\nTools matching '{search_pattern}':")
3747
- else:
3995
+ if not pre_args.json_output:
3996
+ print(f"\nTools matching '{search_pattern}':")
3997
+ elif not pre_args.json_output:
3748
3998
  print("\nAvailable tools:")
3749
- list_mcp_commands(commands, verbose=pre_args.verbose)
3999
+ list_mcp_commands(
4000
+ commands, verbose=pre_args.verbose,
4001
+ json_output=pre_args.json_output, pretty=pre_args.pretty,
4002
+ )
3750
4003
  return True
3751
4004
 
3752
4005
  # Tool call via session
3753
4006
  if not remaining:
3754
4007
  result = _session_request(sess_name, "list_tools")
3755
4008
  commands = extract_mcp_commands(result)
3756
- print("Available tools:")
3757
- list_mcp_commands(commands, verbose=pre_args.verbose)
3758
- print("\nUse --list for the same output, or provide a subcommand.")
4009
+ if not pre_args.json_output:
4010
+ print("Available tools:")
4011
+ list_mcp_commands(
4012
+ commands, verbose=pre_args.verbose,
4013
+ json_output=pre_args.json_output, pretty=pre_args.pretty,
4014
+ )
4015
+ if not pre_args.json_output:
4016
+ print("\nUse --list for the same output, or provide a subcommand.")
3759
4017
  return True
3760
4018
 
3761
4019
  tools = _session_request(sess_name, "list_tools")
@@ -3781,9 +4039,7 @@ def _handle_session_operations(
3781
4039
  result = _session_request(
3782
4040
  sess_name, "call_tool", {"name": cmd.tool_name, "arguments": arguments}
3783
4041
  )
3784
- output_result(
3785
- result, pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon
3786
- )
4042
+ output_result(result, **_sess_out)
3787
4043
  return True
3788
4044
 
3789
4045
 
@@ -3848,15 +4104,19 @@ def _handle_openapi_mode(
3848
4104
  list_kwargs = dict(
3849
4105
  verbose=pre_args.verbose, compact=pre_args.compact,
3850
4106
  source_hash=src_hash, sort_mode=pre_args.sort_mode, top=pre_args.top,
4107
+ json_output=pre_args.json_output, pretty=pre_args.pretty,
3851
4108
  )
3852
4109
 
3853
4110
  if pre_args.list_commands:
3854
4111
  if search_pattern:
3855
4112
  commands = _filter_commands(commands, search_pattern)
3856
4113
  if not commands:
3857
- print(f"\nNo tools matching '{search_pattern}'.")
4114
+ if not pre_args.json_output:
4115
+ print(f"\nNo tools matching '{search_pattern}'.")
4116
+ else:
4117
+ list_openapi_commands(commands, **list_kwargs)
3858
4118
  return
3859
- if not pre_args.compact:
4119
+ if not pre_args.compact and not pre_args.json_output:
3860
4120
  print(f"\nTools matching '{search_pattern}':")
3861
4121
  list_openapi_commands(commands, **list_kwargs)
3862
4122
  return
@@ -3900,7 +4160,7 @@ def _handle_openapi_mode(
3900
4160
  execute_openapi(
3901
4161
  args, cmd, base_url, auth_headers,
3902
4162
  pre_args.pretty, pre_args.raw, toon=pre_args.toon,
3903
- oauth_provider=oauth_provider,
4163
+ oauth_provider=oauth_provider, json_output=pre_args.json_output,
3904
4164
  )
3905
4165
 
3906
4166
  # Record usage after successful execution
@@ -3960,6 +4220,7 @@ def _main_impl(argv: list[str], bake_config: BakeConfig | None = None):
3960
4220
  sort_mode=pre_args.sort_mode,
3961
4221
  top=pre_args.top,
3962
4222
  compact=pre_args.compact,
4223
+ json_output=pre_args.json_output,
3963
4224
  )
3964
4225
  return
3965
4226
 
@@ -3994,6 +4255,7 @@ def _main_impl(argv: list[str], bake_config: BakeConfig | None = None):
3994
4255
  sort_mode=pre_args.sort_mode,
3995
4256
  top=pre_args.top,
3996
4257
  compact=pre_args.compact,
4258
+ json_output=pre_args.json_output,
3997
4259
  )
3998
4260
  return
3999
4261
 
File without changes
File without changes