mcp2cli 2.4.2__tar.gz → 2.5.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: 2.4.2
3
+ Version: 2.5.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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp2cli"
3
- version = "2.4.2"
3
+ version = "2.5.0"
4
4
  description = "Turn any MCP server or OpenAPI spec into a CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -466,11 +466,18 @@ def build_oauth_provider(
466
466
  client_id: str | None = None,
467
467
  client_secret: str | None = None,
468
468
  scope: str | None = None,
469
+ redirect_uri: str | None = None,
469
470
  ) -> "httpx.Auth":
470
471
  """Build an OAuth provider for HTTP connections.
471
472
 
472
- If client_id and client_secret are provided, uses client credentials flow.
473
- Otherwise, uses authorization code + PKCE with a local callback server.
473
+ - client_id + client_secret client credentials flow (machine-to-machine).
474
+ - client_id only → authorization code + PKCE, pre-configured client
475
+ (no dynamic client registration).
476
+ - neither → authorization code + PKCE with dynamic client
477
+ registration.
478
+
479
+ redirect_uri controls the full callback URL (scheme, host, port, path).
480
+ When None, defaults to http://127.0.0.1:<random-free-port>/callback.
474
481
  """
475
482
  storage = FileTokenStorage(server_url)
476
483
 
@@ -488,10 +495,39 @@ def build_oauth_provider(
488
495
  )
489
496
 
490
497
  from mcp.client.auth.oauth2 import OAuthClientProvider
491
- from mcp.shared.auth import OAuthClientMetadata
498
+ from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
499
+
500
+ _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
492
501
 
493
- port = _find_free_port()
494
- redirect_uri = f"http://127.0.0.1:{port}/callback"
502
+ if redirect_uri is not None:
503
+ parsed = urlparse(redirect_uri)
504
+ if parsed.scheme != "http":
505
+ print(
506
+ f"Error: --oauth-redirect-uri must use http://, got '{parsed.scheme}://'. "
507
+ "The local callback server is plain HTTP; use http://<host>:<port>/path.",
508
+ file=sys.stderr,
509
+ )
510
+ sys.exit(1)
511
+ if parsed.port is None:
512
+ print(
513
+ "Error: --oauth-redirect-uri must include an explicit port number "
514
+ "(e.g. http://localhost:3334/oauth/callback).",
515
+ file=sys.stderr,
516
+ )
517
+ sys.exit(1)
518
+ if (parsed.hostname or "") not in _LOOPBACK_HOSTS:
519
+ print(
520
+ f"Error: --oauth-redirect-uri host must be a loopback address "
521
+ f"(localhost, 127.0.0.1, or ::1), got '{parsed.hostname}'.",
522
+ file=sys.stderr,
523
+ )
524
+ sys.exit(1)
525
+ callback_host = parsed.hostname
526
+ port = parsed.port
527
+ else:
528
+ port = _find_free_port()
529
+ callback_host = "127.0.0.1"
530
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
495
531
 
496
532
  client_metadata = OAuthClientMetadata(
497
533
  redirect_uris=[redirect_uri],
@@ -500,13 +536,36 @@ def build_oauth_provider(
500
536
  scope=scope,
501
537
  )
502
538
 
539
+ if client_id:
540
+ # Pre-seed storage with the caller-supplied client_id so the OAuth
541
+ # provider skips dynamic client registration entirely. The write is
542
+ # synchronous (plain file I/O) so no async context is needed here.
543
+ pre_client_info = OAuthClientInformationFull(
544
+ client_id=client_id,
545
+ client_secret=None,
546
+ token_endpoint_auth_method="none",
547
+ redirect_uris=[redirect_uri],
548
+ grant_types=["authorization_code", "refresh_token"],
549
+ response_types=["code"],
550
+ scope=scope,
551
+ )
552
+ storage._client_path.write_text(pre_client_info.model_dump_json())
553
+
503
554
  # Reset callback handler state
504
555
  _CallbackHandler.auth_code = None
505
556
  _CallbackHandler.state = None
506
557
  _CallbackHandler.error = None
507
558
  _CallbackHandler.done = threading.Event()
508
559
 
509
- server = HTTPServer(("127.0.0.1", port), _CallbackHandler)
560
+ if callback_host == "::1":
561
+ import socket as _socket
562
+
563
+ class _IPv6HTTPServer(HTTPServer):
564
+ address_family = _socket.AF_INET6
565
+
566
+ server = _IPv6HTTPServer((callback_host, port), _CallbackHandler)
567
+ else:
568
+ server = HTTPServer((callback_host, port), _CallbackHandler)
510
569
 
511
570
  async def redirect_handler(auth_url: str) -> None:
512
571
  print(f"Opening browser for authorization...", file=sys.stderr)
@@ -1102,8 +1161,28 @@ def extract_graphql_commands(schema: dict) -> list[CommandDef]:
1102
1161
  return commands
1103
1162
 
1104
1163
 
1105
- def list_graphql_commands(commands: list[CommandDef]):
1164
+ def _truncate_description(description: str, max_len: int) -> str:
1165
+ """Truncate at a word boundary and append '...'."""
1166
+ if len(description) <= max_len:
1167
+ return description
1168
+ return description[:max_len].rsplit(" ", 1)[0].rstrip() + "..."
1169
+
1170
+
1171
+ def _wrap_description(description: str, indent: int, total_width: int = 110) -> str:
1172
+ """Wrap long descriptions; indent continuation lines to align with description column."""
1173
+ import textwrap
1174
+ return textwrap.fill(
1175
+ description,
1176
+ width=total_width,
1177
+ subsequent_indent=" " * indent,
1178
+ break_long_words=False,
1179
+ break_on_hyphens=False,
1180
+ )
1181
+
1182
+
1183
+ def list_graphql_commands(commands: list[CommandDef], verbose: bool = False):
1106
1184
  """Group commands by operation type and print."""
1185
+
1107
1186
  groups: dict[str, list[CommandDef]] = {}
1108
1187
  for cmd in commands:
1109
1188
  key = cmd.graphql_operation_type or "other"
@@ -1116,7 +1195,14 @@ def list_graphql_commands(commands: list[CommandDef]):
1116
1195
  label = "queries" if group == "query" else "mutations"
1117
1196
  print(f"\n{label}:")
1118
1197
  for cmd in cmds:
1119
- desc = f" {cmd.description[:60]}" if cmd.description else ""
1198
+ if cmd.description:
1199
+ if verbose:
1200
+ wrapped = _wrap_description(cmd.description, indent=42, total_width=100)
1201
+ desc = f" {wrapped}"
1202
+ else:
1203
+ desc = f" {_truncate_description(cmd.description, 60)}"
1204
+ else:
1205
+ desc = ""
1120
1206
  print(f" {cmd.name:<40}{desc}")
1121
1207
 
1122
1208
 
@@ -1232,18 +1318,19 @@ def handle_graphql(
1232
1318
  oauth_provider: "httpx.Auth | None" = None,
1233
1319
  jq_expr: str | None = None,
1234
1320
  head: int | None = None,
1321
+ verbose: bool = False,
1235
1322
  ):
1236
1323
  """Top-level handler for --graphql mode."""
1237
1324
  schema = load_graphql_schema(url, auth_headers, cache_key, ttl, refresh, oauth_provider=oauth_provider)
1238
1325
  commands = extract_graphql_commands(schema)
1239
1326
 
1240
1327
  if list_mode:
1241
- list_graphql_commands(commands)
1328
+ list_graphql_commands(commands, verbose=verbose)
1242
1329
  return
1243
1330
 
1244
1331
  if not remaining:
1245
1332
  print("Available operations:")
1246
- list_graphql_commands(commands)
1333
+ list_graphql_commands(commands, verbose=verbose)
1247
1334
  print("\nUse --list for the same output, or provide a subcommand.")
1248
1335
  return
1249
1336
 
@@ -1353,6 +1440,8 @@ def _baked_to_argv(config: dict) -> list[str]:
1353
1440
  argv += ["--oauth-client-secret", config["oauth_client_secret"]]
1354
1441
  if config.get("oauth_scope"):
1355
1442
  argv += ["--oauth-scope", config["oauth_scope"]]
1443
+ if config.get("oauth_redirect_uri"):
1444
+ argv += ["--oauth-redirect-uri", config["oauth_redirect_uri"]]
1356
1445
  return argv
1357
1446
 
1358
1447
 
@@ -1400,6 +1489,7 @@ def _bake_create(argv: list[str]) -> None:
1400
1489
  p.add_argument("--oauth-client-id", default=None)
1401
1490
  p.add_argument("--oauth-client-secret", default=None)
1402
1491
  p.add_argument("--oauth-scope", default=None)
1492
+ p.add_argument("--oauth-redirect-uri", default=None, metavar="URI")
1403
1493
  p.add_argument("--include", default="", help="Comma-separated include globs")
1404
1494
  p.add_argument("--exclude", default="", help="Comma-separated exclude globs")
1405
1495
  p.add_argument("--methods", default="", help="Comma-separated HTTP methods")
@@ -1453,6 +1543,7 @@ def _bake_create(argv: list[str]) -> None:
1453
1543
  "oauth_client_id": args.oauth_client_id,
1454
1544
  "oauth_client_secret": args.oauth_client_secret,
1455
1545
  "oauth_scope": args.oauth_scope,
1546
+ "oauth_redirect_uri": args.oauth_redirect_uri,
1456
1547
  "include": [x.strip() for x in args.include.split(",") if x.strip()],
1457
1548
  "exclude": [x.strip() for x in args.exclude.split(",") if x.strip()],
1458
1549
  "methods": [x.strip().upper() for x in args.methods.split(",") if x.strip()],
@@ -1658,7 +1749,7 @@ def build_argparse(
1658
1749
  # ---------------------------------------------------------------------------
1659
1750
 
1660
1751
 
1661
- def list_openapi_commands(commands: list[CommandDef]):
1752
+ def list_openapi_commands(commands: list[CommandDef], verbose: bool = False):
1662
1753
  groups: dict[str, list[CommandDef]] = {}
1663
1754
  for cmd in commands:
1664
1755
  prefix = cmd.name.split("-", 1)[0] if "-" in cmd.name else "other"
@@ -1670,13 +1761,24 @@ def list_openapi_commands(commands: list[CommandDef]):
1670
1761
  method = (cmd.method or "").upper()
1671
1762
  line = f" {cmd.name:<45} {method:<6}"
1672
1763
  if cmd.description:
1673
- line += f" {cmd.description[:60]}"
1764
+ if verbose:
1765
+ wrapped = _wrap_description(cmd.description, indent=54, total_width=110)
1766
+ line += f" {wrapped}"
1767
+ else:
1768
+ line += f" {_truncate_description(cmd.description, 60)}"
1674
1769
  print(line)
1675
1770
 
1676
1771
 
1677
- def list_mcp_commands(commands: list[CommandDef]):
1772
+ def list_mcp_commands(commands: list[CommandDef], verbose: bool = False):
1678
1773
  for cmd in commands:
1679
- desc = f" {cmd.description[:70]}" if cmd.description else ""
1774
+ if cmd.description:
1775
+ if verbose:
1776
+ wrapped = _wrap_description(cmd.description, indent=42, total_width=110)
1777
+ desc = f" {wrapped}"
1778
+ else:
1779
+ desc = f" {_truncate_description(cmd.description, 70)}"
1780
+ else:
1781
+ desc = ""
1680
1782
  print(f" {cmd.name:<40}{desc}")
1681
1783
 
1682
1784
 
@@ -1856,6 +1958,7 @@ def run_mcp_http(
1856
1958
  search_pattern: str | None = None,
1857
1959
  jq_expr: str | None = None,
1858
1960
  head: int | None = None,
1961
+ verbose: bool = False,
1859
1962
  ):
1860
1963
  extra = dict(
1861
1964
  resource_action=resource_action,
@@ -1866,6 +1969,7 @@ def run_mcp_http(
1866
1969
  search_pattern=search_pattern,
1867
1970
  jq_expr=jq_expr,
1868
1971
  head=head,
1972
+ verbose=verbose,
1869
1973
  )
1870
1974
 
1871
1975
  async def _run():
@@ -1951,6 +2055,7 @@ def run_mcp_stdio(
1951
2055
  search_pattern: str | None = None,
1952
2056
  jq_expr: str | None = None,
1953
2057
  head: int | None = None,
2058
+ verbose: bool = False,
1954
2059
  ):
1955
2060
  extra = dict(
1956
2061
  resource_action=resource_action,
@@ -1961,6 +2066,7 @@ def run_mcp_stdio(
1961
2066
  search_pattern=search_pattern,
1962
2067
  jq_expr=jq_expr,
1963
2068
  head=head,
2069
+ verbose=verbose,
1964
2070
  )
1965
2071
 
1966
2072
  import anyio
@@ -2012,6 +2118,7 @@ async def _mcp_session(
2012
2118
  search_pattern: str | None = None,
2013
2119
  jq_expr: str | None = None,
2014
2120
  head: int | None = None,
2121
+ verbose: bool = False,
2015
2122
  ):
2016
2123
  # Handle resource operations
2017
2124
  if resource_action:
@@ -2048,7 +2155,7 @@ async def _mcp_session(
2048
2155
  print(f"\nTools matching '{search_pattern}':")
2049
2156
  else:
2050
2157
  print("\nAvailable tools:")
2051
- list_mcp_commands(commands)
2158
+ list_mcp_commands(commands, verbose=verbose)
2052
2159
  return
2053
2160
 
2054
2161
  if tool_name is None:
@@ -2666,6 +2773,7 @@ def handle_mcp(
2666
2773
  bake_config: BakeConfig | None = None,
2667
2774
  jq_expr: str | None = None,
2668
2775
  head: int | None = None,
2776
+ verbose: bool = False,
2669
2777
  ):
2670
2778
  key = cache_key_override or cache_key_for(source)
2671
2779
 
@@ -2700,7 +2808,7 @@ def handle_mcp(
2700
2808
  commands, bake_config.include, bake_config.exclude, bake_config.methods,
2701
2809
  )
2702
2810
  print("\nAvailable tools:")
2703
- list_mcp_commands(commands)
2811
+ list_mcp_commands(commands, verbose=verbose)
2704
2812
  return
2705
2813
  _dispatch_mcp_call(
2706
2814
  source, is_stdio, auth_headers, env_vars,
@@ -2708,6 +2816,7 @@ def handle_mcp(
2708
2816
  toon=toon, transport=transport, oauth_provider=oauth_provider,
2709
2817
  search_pattern=search_pattern,
2710
2818
  jq_expr=jq_expr, head=head,
2819
+ verbose=verbose,
2711
2820
  )
2712
2821
  return
2713
2822
 
@@ -2725,7 +2834,7 @@ def handle_mcp(
2725
2834
 
2726
2835
  if not remaining:
2727
2836
  print("Available tools:")
2728
- list_mcp_commands(commands)
2837
+ list_mcp_commands(commands, verbose=verbose)
2729
2838
  print("\nUse --list for the same output, or provide a subcommand.")
2730
2839
  return
2731
2840
 
@@ -2923,6 +3032,12 @@ def _build_main_parser() -> argparse.ArgumentParser:
2923
3032
  metavar="PATTERN",
2924
3033
  help="Search tools by name or description (case-insensitive substring match)",
2925
3034
  )
3035
+ pre.add_argument(
3036
+ "--verbose",
3037
+ action="store_true",
3038
+ dest="verbose",
3039
+ help="Show full tool descriptions in --list output, wrapped to terminal width (default: truncated with ...)",
3040
+ )
2926
3041
  pre.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
2927
3042
  pre.add_argument("--raw", action="store_true", help="Print raw response body")
2928
3043
  pre.add_argument(
@@ -2985,6 +3100,13 @@ def _build_main_parser() -> argparse.ArgumentParser:
2985
3100
  default=None,
2986
3101
  help="OAuth scope(s) to request",
2987
3102
  )
3103
+ pre.add_argument(
3104
+ "--oauth-redirect-uri",
3105
+ default=None,
3106
+ metavar="URI",
3107
+ help="Full redirect URI for the OAuth callback (e.g. http://localhost:3334/oauth/callback). "
3108
+ "Overrides the default http://127.0.0.1:<random-port>/callback.",
3109
+ )
2988
3110
  # Resource flags
2989
3111
  pre.add_argument(
2990
3112
  "--list-resources", action="store_true", help="List available resources"
@@ -3066,12 +3188,6 @@ def _setup_oauth(pre_args):
3066
3188
  if not use_oauth:
3067
3189
  return None
3068
3190
 
3069
- if pre_args.oauth_client_id and not pre_args.oauth_client_secret:
3070
- print(
3071
- "Error: --oauth-client-secret is required with --oauth-client-id",
3072
- file=sys.stderr,
3073
- )
3074
- sys.exit(1)
3075
3191
  if pre_args.oauth_client_secret and not pre_args.oauth_client_id:
3076
3192
  print(
3077
3193
  "Error: --oauth-client-id is required with --oauth-client-secret",
@@ -3109,6 +3225,7 @@ def _setup_oauth(pre_args):
3109
3225
  client_id=client_id,
3110
3226
  client_secret=client_secret,
3111
3227
  scope=pre_args.oauth_scope,
3228
+ redirect_uri=pre_args.oauth_redirect_uri,
3112
3229
  )
3113
3230
 
3114
3231
 
@@ -3221,7 +3338,7 @@ def _handle_session_operations(
3221
3338
  print(f"\nTools matching '{search_pattern}':")
3222
3339
  else:
3223
3340
  print("\nAvailable tools:")
3224
- list_mcp_commands(commands)
3341
+ list_mcp_commands(commands, verbose=pre_args.verbose)
3225
3342
  return True
3226
3343
 
3227
3344
  # Tool call via session
@@ -3229,7 +3346,7 @@ def _handle_session_operations(
3229
3346
  result = _session_request(sess_name, "list_tools")
3230
3347
  commands = extract_mcp_commands(result)
3231
3348
  print("Available tools:")
3232
- list_mcp_commands(commands)
3349
+ list_mcp_commands(commands, verbose=pre_args.verbose)
3233
3350
  print("\nUse --list for the same output, or provide a subcommand.")
3234
3351
  return True
3235
3352
 
@@ -3326,7 +3443,7 @@ def _handle_openapi_mode(
3326
3443
  print(f"\nNo tools matching '{search_pattern}'.")
3327
3444
  return
3328
3445
  print(f"\nTools matching '{search_pattern}':")
3329
- list_openapi_commands(commands)
3446
+ list_openapi_commands(commands, verbose=pre_args.verbose)
3330
3447
  return
3331
3448
 
3332
3449
  if not remaining:
@@ -3428,6 +3545,7 @@ def _main_impl(argv: list[str], bake_config: BakeConfig | None = None):
3428
3545
  oauth_provider=oauth_provider,
3429
3546
  jq_expr=pre_args.jq,
3430
3547
  head=pre_args.head,
3548
+ verbose=pre_args.verbose,
3431
3549
  )
3432
3550
  return
3433
3551
 
@@ -3459,6 +3577,7 @@ def _main_impl(argv: list[str], bake_config: BakeConfig | None = None):
3459
3577
  bake_config=bake_config,
3460
3578
  jq_expr=pre_args.jq,
3461
3579
  head=pre_args.head,
3580
+ verbose=pre_args.verbose,
3462
3581
  )
3463
3582
  return
3464
3583
 
File without changes
File without changes
File without changes