mcp2cli 2.4.3__tar.gz → 2.6.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.3
3
+ Version: 2.6.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.3"
3
+ version = "2.6.0"
4
4
  description = "Turn any MCP server or OpenAPI spec into a CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "2.4.0"
5
+ __version__ = "2.6.0"
6
6
 
7
7
  import argparse
8
8
  import copy
@@ -352,9 +352,25 @@ def _handle_http_error(resp) -> None:
352
352
  # ---------------------------------------------------------------------------
353
353
 
354
354
 
355
- def cache_key_for(source: str) -> str:
356
- return hashlib.sha256(source.encode()).hexdigest()[:16]
355
+ def cache_key_for(config: dict) -> str:
356
+ """
357
+ Generate cache key from tool configuration dict.
357
358
 
359
+ Hashes all config fields that affect MCP server behavior to ensure
360
+ unique caching for tools with the same URL but different configurations.
361
+ """
362
+ # Exclude fields that don't affect which tools are returned
363
+ cache_config = {
364
+ k: v for k, v in config.items()
365
+ if k not in ('cache_ttl', 'description', 'include', 'exclude', 'methods')
366
+ }
367
+ # Ensure auth_headers are sorted for stable hashing
368
+ if 'auth_headers' in cache_config and cache_config['auth_headers']:
369
+ cache_config['auth_headers'] = sorted(cache_config['auth_headers'])
370
+
371
+ return hashlib.sha256(
372
+ json.dumps(cache_config, sort_keys=True).encode()
373
+ ).hexdigest()[:16]
358
374
 
359
375
  def load_cached(key: str, ttl: int) -> dict | None:
360
376
  path = CACHE_DIR / f"{key}.json"
@@ -467,21 +483,34 @@ def build_oauth_provider(
467
483
  client_secret: str | None = None,
468
484
  scope: str | None = None,
469
485
  redirect_uri: str | None = None,
486
+ flow: str = "auto",
470
487
  ) -> "httpx.Auth":
471
488
  """Build an OAuth provider for HTTP connections.
472
489
 
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.
490
+ The ``flow`` parameter controls which grant type is used:
491
+
492
+ - ``"auto"`` (default): client_id + client_secret → client credentials;
493
+ client_id only → authorization code + PKCE (public client);
494
+ neither → authorization code + PKCE with dynamic client registration.
495
+ - ``"authorization_code"``: always use authorization code + PKCE.
496
+ When a client_secret is also provided the token-endpoint request
497
+ authenticates as a confidential client (``client_secret_post``).
498
+ This is required by servers like Slack that issue confidential OAuth
499
+ clients but only support the authorization-code grant.
500
+ - ``"client_credentials"``: always use client credentials (requires both
501
+ client_id and client_secret).
478
502
 
479
503
  redirect_uri controls the full callback URL (scheme, host, port, path).
480
504
  When None, defaults to http://127.0.0.1:<random-free-port>/callback.
481
505
  """
482
506
  storage = FileTokenStorage(server_url)
483
507
 
484
- if client_id and client_secret:
508
+ use_client_credentials = (
509
+ flow == "client_credentials"
510
+ or (flow == "auto" and client_id and client_secret)
511
+ )
512
+
513
+ if use_client_credentials:
485
514
  from mcp.client.auth.extensions.client_credentials import (
486
515
  ClientCredentialsOAuthProvider,
487
516
  )
@@ -540,10 +569,18 @@ def build_oauth_provider(
540
569
  # Pre-seed storage with the caller-supplied client_id so the OAuth
541
570
  # provider skips dynamic client registration entirely. The write is
542
571
  # synchronous (plain file I/O) so no async context is needed here.
572
+ #
573
+ # When a client_secret is provided (confidential client, e.g. Slack),
574
+ # include it and use client_secret_post so the token-endpoint request
575
+ # sends the secret in the POST body.
576
+ if client_secret:
577
+ auth_method = "client_secret_post"
578
+ else:
579
+ auth_method = "none"
543
580
  pre_client_info = OAuthClientInformationFull(
544
581
  client_id=client_id,
545
- client_secret=None,
546
- token_endpoint_auth_method="none",
582
+ client_secret=client_secret,
583
+ token_endpoint_auth_method=auth_method,
547
584
  redirect_uris=[redirect_uri],
548
585
  grant_types=["authorization_code", "refresh_token"],
549
586
  response_types=["code"],
@@ -642,7 +679,10 @@ def load_openapi_spec(
642
679
  is_url = source.startswith("http://") or source.startswith("https://")
643
680
 
644
681
  if is_url:
645
- key = cache_key or cache_key_for(source)
682
+ key = cache_key or cache_key_for({
683
+ 'source': source,
684
+ 'auth_headers': auth_headers,
685
+ })
646
686
  if not refresh:
647
687
  cached = load_cached(key, ttl)
648
688
  if cached is not None:
@@ -1024,7 +1064,10 @@ def load_graphql_schema(
1024
1064
  oauth_provider: "httpx.Auth | None" = None,
1025
1065
  ) -> dict:
1026
1066
  """POST introspection query to a GraphQL endpoint, with caching."""
1027
- key = cache_key or cache_key_for(f"graphql:{url}")
1067
+ key = cache_key or cache_key_for({
1068
+ 'source': f"graphql:{url}",
1069
+ 'auth_headers': auth_headers,
1070
+ })
1028
1071
  if not refresh:
1029
1072
  cached = load_cached(key, ttl)
1030
1073
  if cached is not None:
@@ -1161,8 +1204,28 @@ def extract_graphql_commands(schema: dict) -> list[CommandDef]:
1161
1204
  return commands
1162
1205
 
1163
1206
 
1164
- def list_graphql_commands(commands: list[CommandDef]):
1207
+ def _truncate_description(description: str, max_len: int) -> str:
1208
+ """Truncate at a word boundary and append '...'."""
1209
+ if len(description) <= max_len:
1210
+ return description
1211
+ return description[:max_len].rsplit(" ", 1)[0].rstrip() + "..."
1212
+
1213
+
1214
+ def _wrap_description(description: str, indent: int, total_width: int = 110) -> str:
1215
+ """Wrap long descriptions; indent continuation lines to align with description column."""
1216
+ import textwrap
1217
+ return textwrap.fill(
1218
+ description,
1219
+ width=total_width,
1220
+ subsequent_indent=" " * indent,
1221
+ break_long_words=False,
1222
+ break_on_hyphens=False,
1223
+ )
1224
+
1225
+
1226
+ def list_graphql_commands(commands: list[CommandDef], verbose: bool = False):
1165
1227
  """Group commands by operation type and print."""
1228
+
1166
1229
  groups: dict[str, list[CommandDef]] = {}
1167
1230
  for cmd in commands:
1168
1231
  key = cmd.graphql_operation_type or "other"
@@ -1175,7 +1238,14 @@ def list_graphql_commands(commands: list[CommandDef]):
1175
1238
  label = "queries" if group == "query" else "mutations"
1176
1239
  print(f"\n{label}:")
1177
1240
  for cmd in cmds:
1178
- desc = f" {cmd.description[:60]}" if cmd.description else ""
1241
+ if cmd.description:
1242
+ if verbose:
1243
+ wrapped = _wrap_description(cmd.description, indent=42, total_width=100)
1244
+ desc = f" {wrapped}"
1245
+ else:
1246
+ desc = f" {_truncate_description(cmd.description, 60)}"
1247
+ else:
1248
+ desc = ""
1179
1249
  print(f" {cmd.name:<40}{desc}")
1180
1250
 
1181
1251
 
@@ -1291,18 +1361,19 @@ def handle_graphql(
1291
1361
  oauth_provider: "httpx.Auth | None" = None,
1292
1362
  jq_expr: str | None = None,
1293
1363
  head: int | None = None,
1364
+ verbose: bool = False,
1294
1365
  ):
1295
1366
  """Top-level handler for --graphql mode."""
1296
1367
  schema = load_graphql_schema(url, auth_headers, cache_key, ttl, refresh, oauth_provider=oauth_provider)
1297
1368
  commands = extract_graphql_commands(schema)
1298
1369
 
1299
1370
  if list_mode:
1300
- list_graphql_commands(commands)
1371
+ list_graphql_commands(commands, verbose=verbose)
1301
1372
  return
1302
1373
 
1303
1374
  if not remaining:
1304
1375
  print("Available operations:")
1305
- list_graphql_commands(commands)
1376
+ list_graphql_commands(commands, verbose=verbose)
1306
1377
  print("\nUse --list for the same output, or provide a subcommand.")
1307
1378
  return
1308
1379
 
@@ -1414,6 +1485,8 @@ def _baked_to_argv(config: dict) -> list[str]:
1414
1485
  argv += ["--oauth-scope", config["oauth_scope"]]
1415
1486
  if config.get("oauth_redirect_uri"):
1416
1487
  argv += ["--oauth-redirect-uri", config["oauth_redirect_uri"]]
1488
+ if config.get("oauth_flow") and config["oauth_flow"] != "auto":
1489
+ argv += ["--oauth-flow", config["oauth_flow"]]
1417
1490
  return argv
1418
1491
 
1419
1492
 
@@ -1462,6 +1535,17 @@ def _bake_create(argv: list[str]) -> None:
1462
1535
  p.add_argument("--oauth-client-secret", default=None)
1463
1536
  p.add_argument("--oauth-scope", default=None)
1464
1537
  p.add_argument("--oauth-redirect-uri", default=None, metavar="URI")
1538
+ p.add_argument(
1539
+ "--oauth-flow",
1540
+ choices=["auto", "authorization_code", "client_credentials"],
1541
+ default="auto",
1542
+ help=(
1543
+ "OAuth flow to use. 'auto' (default) picks client_credentials when both "
1544
+ "client-id and client-secret are provided, otherwise authorization_code. "
1545
+ "Use 'authorization_code' to force the auth code + PKCE flow even with a "
1546
+ "client secret (required for confidential-client servers like Slack)."
1547
+ ),
1548
+ )
1465
1549
  p.add_argument("--include", default="", help="Comma-separated include globs")
1466
1550
  p.add_argument("--exclude", default="", help="Comma-separated exclude globs")
1467
1551
  p.add_argument("--methods", default="", help="Comma-separated HTTP methods")
@@ -1516,6 +1600,7 @@ def _bake_create(argv: list[str]) -> None:
1516
1600
  "oauth_client_secret": args.oauth_client_secret,
1517
1601
  "oauth_scope": args.oauth_scope,
1518
1602
  "oauth_redirect_uri": args.oauth_redirect_uri,
1603
+ "oauth_flow": args.oauth_flow,
1519
1604
  "include": [x.strip() for x in args.include.split(",") if x.strip()],
1520
1605
  "exclude": [x.strip() for x in args.exclude.split(",") if x.strip()],
1521
1606
  "methods": [x.strip().upper() for x in args.methods.split(",") if x.strip()],
@@ -1721,7 +1806,7 @@ def build_argparse(
1721
1806
  # ---------------------------------------------------------------------------
1722
1807
 
1723
1808
 
1724
- def list_openapi_commands(commands: list[CommandDef]):
1809
+ def list_openapi_commands(commands: list[CommandDef], verbose: bool = False):
1725
1810
  groups: dict[str, list[CommandDef]] = {}
1726
1811
  for cmd in commands:
1727
1812
  prefix = cmd.name.split("-", 1)[0] if "-" in cmd.name else "other"
@@ -1733,13 +1818,24 @@ def list_openapi_commands(commands: list[CommandDef]):
1733
1818
  method = (cmd.method or "").upper()
1734
1819
  line = f" {cmd.name:<45} {method:<6}"
1735
1820
  if cmd.description:
1736
- line += f" {cmd.description[:60]}"
1821
+ if verbose:
1822
+ wrapped = _wrap_description(cmd.description, indent=54, total_width=110)
1823
+ line += f" {wrapped}"
1824
+ else:
1825
+ line += f" {_truncate_description(cmd.description, 60)}"
1737
1826
  print(line)
1738
1827
 
1739
1828
 
1740
- def list_mcp_commands(commands: list[CommandDef]):
1829
+ def list_mcp_commands(commands: list[CommandDef], verbose: bool = False):
1741
1830
  for cmd in commands:
1742
- desc = f" {cmd.description[:70]}" if cmd.description else ""
1831
+ if cmd.description:
1832
+ if verbose:
1833
+ wrapped = _wrap_description(cmd.description, indent=42, total_width=110)
1834
+ desc = f" {wrapped}"
1835
+ else:
1836
+ desc = f" {_truncate_description(cmd.description, 70)}"
1837
+ else:
1838
+ desc = ""
1743
1839
  print(f" {cmd.name:<40}{desc}")
1744
1840
 
1745
1841
 
@@ -1919,6 +2015,7 @@ def run_mcp_http(
1919
2015
  search_pattern: str | None = None,
1920
2016
  jq_expr: str | None = None,
1921
2017
  head: int | None = None,
2018
+ verbose: bool = False,
1922
2019
  ):
1923
2020
  extra = dict(
1924
2021
  resource_action=resource_action,
@@ -1929,6 +2026,7 @@ def run_mcp_http(
1929
2026
  search_pattern=search_pattern,
1930
2027
  jq_expr=jq_expr,
1931
2028
  head=head,
2029
+ verbose=verbose,
1932
2030
  )
1933
2031
 
1934
2032
  async def _run():
@@ -2014,6 +2112,7 @@ def run_mcp_stdio(
2014
2112
  search_pattern: str | None = None,
2015
2113
  jq_expr: str | None = None,
2016
2114
  head: int | None = None,
2115
+ verbose: bool = False,
2017
2116
  ):
2018
2117
  extra = dict(
2019
2118
  resource_action=resource_action,
@@ -2024,6 +2123,7 @@ def run_mcp_stdio(
2024
2123
  search_pattern=search_pattern,
2025
2124
  jq_expr=jq_expr,
2026
2125
  head=head,
2126
+ verbose=verbose,
2027
2127
  )
2028
2128
 
2029
2129
  import anyio
@@ -2075,6 +2175,7 @@ async def _mcp_session(
2075
2175
  search_pattern: str | None = None,
2076
2176
  jq_expr: str | None = None,
2077
2177
  head: int | None = None,
2178
+ verbose: bool = False,
2078
2179
  ):
2079
2180
  # Handle resource operations
2080
2181
  if resource_action:
@@ -2111,7 +2212,7 @@ async def _mcp_session(
2111
2212
  print(f"\nTools matching '{search_pattern}':")
2112
2213
  else:
2113
2214
  print("\nAvailable tools:")
2114
- list_mcp_commands(commands)
2215
+ list_mcp_commands(commands, verbose=verbose)
2115
2216
  return
2116
2217
 
2117
2218
  if tool_name is None:
@@ -2729,8 +2830,18 @@ def handle_mcp(
2729
2830
  bake_config: BakeConfig | None = None,
2730
2831
  jq_expr: str | None = None,
2731
2832
  head: int | None = None,
2833
+ verbose: bool = False,
2732
2834
  ):
2733
- key = cache_key_override or cache_key_for(source)
2835
+ # Build a config dict for cache key generation (future-proof)
2836
+ config_for_cache = {
2837
+ 'source': source,
2838
+ 'auth_headers': auth_headers,
2839
+ 'transport': transport,
2840
+ 'env_vars': env_vars,
2841
+ 'is_stdio': is_stdio,
2842
+ }
2843
+
2844
+ key = cache_key_override or cache_key_for(config_for_cache)
2734
2845
 
2735
2846
  # Resource/prompt operations skip the tool flow entirely
2736
2847
  if resource_action or prompt_action:
@@ -2763,7 +2874,7 @@ def handle_mcp(
2763
2874
  commands, bake_config.include, bake_config.exclude, bake_config.methods,
2764
2875
  )
2765
2876
  print("\nAvailable tools:")
2766
- list_mcp_commands(commands)
2877
+ list_mcp_commands(commands, verbose=verbose)
2767
2878
  return
2768
2879
  _dispatch_mcp_call(
2769
2880
  source, is_stdio, auth_headers, env_vars,
@@ -2771,6 +2882,7 @@ def handle_mcp(
2771
2882
  toon=toon, transport=transport, oauth_provider=oauth_provider,
2772
2883
  search_pattern=search_pattern,
2773
2884
  jq_expr=jq_expr, head=head,
2885
+ verbose=verbose,
2774
2886
  )
2775
2887
  return
2776
2888
 
@@ -2788,7 +2900,7 @@ def handle_mcp(
2788
2900
 
2789
2901
  if not remaining:
2790
2902
  print("Available tools:")
2791
- list_mcp_commands(commands)
2903
+ list_mcp_commands(commands, verbose=verbose)
2792
2904
  print("\nUse --list for the same output, or provide a subcommand.")
2793
2905
  return
2794
2906
 
@@ -2986,6 +3098,12 @@ def _build_main_parser() -> argparse.ArgumentParser:
2986
3098
  metavar="PATTERN",
2987
3099
  help="Search tools by name or description (case-insensitive substring match)",
2988
3100
  )
3101
+ pre.add_argument(
3102
+ "--verbose",
3103
+ action="store_true",
3104
+ dest="verbose",
3105
+ help="Show full tool descriptions in --list output, wrapped to terminal width (default: truncated with ...)",
3106
+ )
2989
3107
  pre.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
2990
3108
  pre.add_argument("--raw", action="store_true", help="Print raw response body")
2991
3109
  pre.add_argument(
@@ -3055,6 +3173,17 @@ def _build_main_parser() -> argparse.ArgumentParser:
3055
3173
  help="Full redirect URI for the OAuth callback (e.g. http://localhost:3334/oauth/callback). "
3056
3174
  "Overrides the default http://127.0.0.1:<random-port>/callback.",
3057
3175
  )
3176
+ pre.add_argument(
3177
+ "--oauth-flow",
3178
+ choices=["auto", "authorization_code", "client_credentials"],
3179
+ default="auto",
3180
+ help=(
3181
+ "OAuth flow to use. 'auto' (default) picks client_credentials when both "
3182
+ "client-id and client-secret are provided, otherwise authorization_code. "
3183
+ "Use 'authorization_code' to force the auth code + PKCE flow even with a "
3184
+ "client secret (required for confidential-client servers like Slack)."
3185
+ ),
3186
+ )
3058
3187
  # Resource flags
3059
3188
  pre.add_argument(
3060
3189
  "--list-resources", action="store_true", help="List available resources"
@@ -3168,12 +3297,21 @@ def _setup_oauth(pre_args):
3168
3297
  if pre_args.oauth_client_secret
3169
3298
  else None
3170
3299
  )
3300
+ flow = getattr(pre_args, "oauth_flow", "auto")
3301
+ if flow == "client_credentials" and not (client_id and client_secret):
3302
+ print(
3303
+ "Error: --oauth-flow=client_credentials requires both "
3304
+ "--oauth-client-id and --oauth-client-secret",
3305
+ file=sys.stderr,
3306
+ )
3307
+ sys.exit(1)
3171
3308
  return build_oauth_provider(
3172
3309
  server_url,
3173
3310
  client_id=client_id,
3174
3311
  client_secret=client_secret,
3175
3312
  scope=pre_args.oauth_scope,
3176
3313
  redirect_uri=pre_args.oauth_redirect_uri,
3314
+ flow=flow,
3177
3315
  )
3178
3316
 
3179
3317
 
@@ -3286,7 +3424,7 @@ def _handle_session_operations(
3286
3424
  print(f"\nTools matching '{search_pattern}':")
3287
3425
  else:
3288
3426
  print("\nAvailable tools:")
3289
- list_mcp_commands(commands)
3427
+ list_mcp_commands(commands, verbose=pre_args.verbose)
3290
3428
  return True
3291
3429
 
3292
3430
  # Tool call via session
@@ -3294,7 +3432,7 @@ def _handle_session_operations(
3294
3432
  result = _session_request(sess_name, "list_tools")
3295
3433
  commands = extract_mcp_commands(result)
3296
3434
  print("Available tools:")
3297
- list_mcp_commands(commands)
3435
+ list_mcp_commands(commands, verbose=pre_args.verbose)
3298
3436
  print("\nUse --list for the same output, or provide a subcommand.")
3299
3437
  return True
3300
3438
 
@@ -3391,7 +3529,7 @@ def _handle_openapi_mode(
3391
3529
  print(f"\nNo tools matching '{search_pattern}'.")
3392
3530
  return
3393
3531
  print(f"\nTools matching '{search_pattern}':")
3394
- list_openapi_commands(commands)
3532
+ list_openapi_commands(commands, verbose=pre_args.verbose)
3395
3533
  return
3396
3534
 
3397
3535
  if not remaining:
@@ -3493,6 +3631,7 @@ def _main_impl(argv: list[str], bake_config: BakeConfig | None = None):
3493
3631
  oauth_provider=oauth_provider,
3494
3632
  jq_expr=pre_args.jq,
3495
3633
  head=pre_args.head,
3634
+ verbose=pre_args.verbose,
3496
3635
  )
3497
3636
  return
3498
3637
 
@@ -3524,6 +3663,7 @@ def _main_impl(argv: list[str], bake_config: BakeConfig | None = None):
3524
3663
  bake_config=bake_config,
3525
3664
  jq_expr=pre_args.jq,
3526
3665
  head=pre_args.head,
3666
+ verbose=pre_args.verbose,
3527
3667
  )
3528
3668
  return
3529
3669
 
File without changes
File without changes
File without changes