mcp2cli 2.5.0__tar.gz → 2.6.1__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.5.0
3
+ Version: 2.6.1
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.5.0"
3
+ version = "2.6.1"
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.
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'])
357
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"
@@ -465,23 +481,37 @@ def build_oauth_provider(
465
481
  *,
466
482
  client_id: str | None = None,
467
483
  client_secret: str | None = None,
484
+ client_name: str = "mcp2cli",
468
485
  scope: str | None = None,
469
486
  redirect_uri: str | None = None,
487
+ flow: str = "auto",
470
488
  ) -> "httpx.Auth":
471
489
  """Build an OAuth provider for HTTP connections.
472
490
 
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.
491
+ The ``flow`` parameter controls which grant type is used:
492
+
493
+ - ``"auto"`` (default): client_id + client_secret → client credentials;
494
+ client_id only → authorization code + PKCE (public client);
495
+ neither → authorization code + PKCE with dynamic client registration.
496
+ - ``"authorization_code"``: always use authorization code + PKCE.
497
+ When a client_secret is also provided the token-endpoint request
498
+ authenticates as a confidential client (``client_secret_post``).
499
+ This is required by servers like Slack that issue confidential OAuth
500
+ clients but only support the authorization-code grant.
501
+ - ``"client_credentials"``: always use client credentials (requires both
502
+ client_id and client_secret).
478
503
 
479
504
  redirect_uri controls the full callback URL (scheme, host, port, path).
480
505
  When None, defaults to http://127.0.0.1:<random-free-port>/callback.
481
506
  """
482
507
  storage = FileTokenStorage(server_url)
483
508
 
484
- if client_id and client_secret:
509
+ use_client_credentials = (
510
+ flow == "client_credentials"
511
+ or (flow == "auto" and client_id and client_secret)
512
+ )
513
+
514
+ if use_client_credentials:
485
515
  from mcp.client.auth.extensions.client_credentials import (
486
516
  ClientCredentialsOAuthProvider,
487
517
  )
@@ -530,6 +560,7 @@ def build_oauth_provider(
530
560
  redirect_uri = f"http://127.0.0.1:{port}/callback"
531
561
 
532
562
  client_metadata = OAuthClientMetadata(
563
+ client_name=client_name,
533
564
  redirect_uris=[redirect_uri],
534
565
  grant_types=["authorization_code", "refresh_token"],
535
566
  response_types=["code"],
@@ -540,10 +571,18 @@ def build_oauth_provider(
540
571
  # Pre-seed storage with the caller-supplied client_id so the OAuth
541
572
  # provider skips dynamic client registration entirely. The write is
542
573
  # synchronous (plain file I/O) so no async context is needed here.
574
+ #
575
+ # When a client_secret is provided (confidential client, e.g. Slack),
576
+ # include it and use client_secret_post so the token-endpoint request
577
+ # sends the secret in the POST body.
578
+ if client_secret:
579
+ auth_method = "client_secret_post"
580
+ else:
581
+ auth_method = "none"
543
582
  pre_client_info = OAuthClientInformationFull(
544
583
  client_id=client_id,
545
- client_secret=None,
546
- token_endpoint_auth_method="none",
584
+ client_secret=client_secret,
585
+ token_endpoint_auth_method=auth_method,
547
586
  redirect_uris=[redirect_uri],
548
587
  grant_types=["authorization_code", "refresh_token"],
549
588
  response_types=["code"],
@@ -642,7 +681,10 @@ def load_openapi_spec(
642
681
  is_url = source.startswith("http://") or source.startswith("https://")
643
682
 
644
683
  if is_url:
645
- key = cache_key or cache_key_for(source)
684
+ key = cache_key or cache_key_for({
685
+ 'source': source,
686
+ 'auth_headers': auth_headers,
687
+ })
646
688
  if not refresh:
647
689
  cached = load_cached(key, ttl)
648
690
  if cached is not None:
@@ -1024,7 +1066,10 @@ def load_graphql_schema(
1024
1066
  oauth_provider: "httpx.Auth | None" = None,
1025
1067
  ) -> dict:
1026
1068
  """POST introspection query to a GraphQL endpoint, with caching."""
1027
- key = cache_key or cache_key_for(f"graphql:{url}")
1069
+ key = cache_key or cache_key_for({
1070
+ 'source': f"graphql:{url}",
1071
+ 'auth_headers': auth_headers,
1072
+ })
1028
1073
  if not refresh:
1029
1074
  cached = load_cached(key, ttl)
1030
1075
  if cached is not None:
@@ -1438,10 +1483,14 @@ def _baked_to_argv(config: dict) -> list[str]:
1438
1483
  argv += ["--oauth-client-id", config["oauth_client_id"]]
1439
1484
  if config.get("oauth_client_secret"):
1440
1485
  argv += ["--oauth-client-secret", config["oauth_client_secret"]]
1486
+ if config.get("oauth_client_name") and config["oauth_client_name"] != "mcp2cli":
1487
+ argv += ["--oauth-client-name", config["oauth_client_name"]]
1441
1488
  if config.get("oauth_scope"):
1442
1489
  argv += ["--oauth-scope", config["oauth_scope"]]
1443
1490
  if config.get("oauth_redirect_uri"):
1444
1491
  argv += ["--oauth-redirect-uri", config["oauth_redirect_uri"]]
1492
+ if config.get("oauth_flow") and config["oauth_flow"] != "auto":
1493
+ argv += ["--oauth-flow", config["oauth_flow"]]
1445
1494
  return argv
1446
1495
 
1447
1496
 
@@ -1488,8 +1537,20 @@ def _bake_create(argv: list[str]) -> None:
1488
1537
  p.add_argument("--oauth", action="store_true")
1489
1538
  p.add_argument("--oauth-client-id", default=None)
1490
1539
  p.add_argument("--oauth-client-secret", default=None)
1540
+ p.add_argument("--oauth-client-name", default="mcp2cli")
1491
1541
  p.add_argument("--oauth-scope", default=None)
1492
1542
  p.add_argument("--oauth-redirect-uri", default=None, metavar="URI")
1543
+ p.add_argument(
1544
+ "--oauth-flow",
1545
+ choices=["auto", "authorization_code", "client_credentials"],
1546
+ default="auto",
1547
+ help=(
1548
+ "OAuth flow to use. 'auto' (default) picks client_credentials when both "
1549
+ "client-id and client-secret are provided, otherwise authorization_code. "
1550
+ "Use 'authorization_code' to force the auth code + PKCE flow even with a "
1551
+ "client secret (required for confidential-client servers like Slack)."
1552
+ ),
1553
+ )
1493
1554
  p.add_argument("--include", default="", help="Comma-separated include globs")
1494
1555
  p.add_argument("--exclude", default="", help="Comma-separated exclude globs")
1495
1556
  p.add_argument("--methods", default="", help="Comma-separated HTTP methods")
@@ -1542,8 +1603,10 @@ def _bake_create(argv: list[str]) -> None:
1542
1603
  "oauth": args.oauth,
1543
1604
  "oauth_client_id": args.oauth_client_id,
1544
1605
  "oauth_client_secret": args.oauth_client_secret,
1606
+ "oauth_client_name": args.oauth_client_name,
1545
1607
  "oauth_scope": args.oauth_scope,
1546
1608
  "oauth_redirect_uri": args.oauth_redirect_uri,
1609
+ "oauth_flow": args.oauth_flow,
1547
1610
  "include": [x.strip() for x in args.include.split(",") if x.strip()],
1548
1611
  "exclude": [x.strip() for x in args.exclude.split(",") if x.strip()],
1549
1612
  "methods": [x.strip().upper() for x in args.methods.split(",") if x.strip()],
@@ -2775,7 +2838,16 @@ def handle_mcp(
2775
2838
  head: int | None = None,
2776
2839
  verbose: bool = False,
2777
2840
  ):
2778
- key = cache_key_override or cache_key_for(source)
2841
+ # Build a config dict for cache key generation (future-proof)
2842
+ config_for_cache = {
2843
+ 'source': source,
2844
+ 'auth_headers': auth_headers,
2845
+ 'transport': transport,
2846
+ 'env_vars': env_vars,
2847
+ 'is_stdio': is_stdio,
2848
+ }
2849
+
2850
+ key = cache_key_override or cache_key_for(config_for_cache)
2779
2851
 
2780
2852
  # Resource/prompt operations skip the tool flow entirely
2781
2853
  if resource_action or prompt_action:
@@ -3095,6 +3167,12 @@ def _build_main_parser() -> argparse.ArgumentParser:
3095
3167
  default=None,
3096
3168
  help="OAuth client secret — supports env:VAR and file:/path prefixes",
3097
3169
  )
3170
+ pre.add_argument(
3171
+ "--oauth-client-name",
3172
+ default="mcp2cli",
3173
+ help="Client name sent during OAuth Dynamic Client Registration (default: mcp2cli). "
3174
+ "Some servers require a specific client name for DCR to succeed.",
3175
+ )
3098
3176
  pre.add_argument(
3099
3177
  "--oauth-scope",
3100
3178
  default=None,
@@ -3107,6 +3185,17 @@ def _build_main_parser() -> argparse.ArgumentParser:
3107
3185
  help="Full redirect URI for the OAuth callback (e.g. http://localhost:3334/oauth/callback). "
3108
3186
  "Overrides the default http://127.0.0.1:<random-port>/callback.",
3109
3187
  )
3188
+ pre.add_argument(
3189
+ "--oauth-flow",
3190
+ choices=["auto", "authorization_code", "client_credentials"],
3191
+ default="auto",
3192
+ help=(
3193
+ "OAuth flow to use. 'auto' (default) picks client_credentials when both "
3194
+ "client-id and client-secret are provided, otherwise authorization_code. "
3195
+ "Use 'authorization_code' to force the auth code + PKCE flow even with a "
3196
+ "client secret (required for confidential-client servers like Slack)."
3197
+ ),
3198
+ )
3110
3199
  # Resource flags
3111
3200
  pre.add_argument(
3112
3201
  "--list-resources", action="store_true", help="List available resources"
@@ -3220,12 +3309,22 @@ def _setup_oauth(pre_args):
3220
3309
  if pre_args.oauth_client_secret
3221
3310
  else None
3222
3311
  )
3312
+ flow = getattr(pre_args, "oauth_flow", "auto")
3313
+ if flow == "client_credentials" and not (client_id and client_secret):
3314
+ print(
3315
+ "Error: --oauth-flow=client_credentials requires both "
3316
+ "--oauth-client-id and --oauth-client-secret",
3317
+ file=sys.stderr,
3318
+ )
3319
+ sys.exit(1)
3223
3320
  return build_oauth_provider(
3224
3321
  server_url,
3225
3322
  client_id=client_id,
3226
3323
  client_secret=client_secret,
3324
+ client_name=getattr(pre_args, "oauth_client_name", "mcp2cli"),
3227
3325
  scope=pre_args.oauth_scope,
3228
3326
  redirect_uri=pre_args.oauth_redirect_uri,
3327
+ flow=flow,
3229
3328
  )
3230
3329
 
3231
3330
 
File without changes
File without changes
File without changes