mcp2cli 2.5.0__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.5.0
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.5.0"
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.
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"
@@ -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:
@@ -1442,6 +1485,8 @@ def _baked_to_argv(config: dict) -> list[str]:
1442
1485
  argv += ["--oauth-scope", config["oauth_scope"]]
1443
1486
  if config.get("oauth_redirect_uri"):
1444
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"]]
1445
1490
  return argv
1446
1491
 
1447
1492
 
@@ -1490,6 +1535,17 @@ def _bake_create(argv: list[str]) -> None:
1490
1535
  p.add_argument("--oauth-client-secret", default=None)
1491
1536
  p.add_argument("--oauth-scope", default=None)
1492
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
+ )
1493
1549
  p.add_argument("--include", default="", help="Comma-separated include globs")
1494
1550
  p.add_argument("--exclude", default="", help="Comma-separated exclude globs")
1495
1551
  p.add_argument("--methods", default="", help="Comma-separated HTTP methods")
@@ -1544,6 +1600,7 @@ def _bake_create(argv: list[str]) -> None:
1544
1600
  "oauth_client_secret": args.oauth_client_secret,
1545
1601
  "oauth_scope": args.oauth_scope,
1546
1602
  "oauth_redirect_uri": args.oauth_redirect_uri,
1603
+ "oauth_flow": args.oauth_flow,
1547
1604
  "include": [x.strip() for x in args.include.split(",") if x.strip()],
1548
1605
  "exclude": [x.strip() for x in args.exclude.split(",") if x.strip()],
1549
1606
  "methods": [x.strip().upper() for x in args.methods.split(",") if x.strip()],
@@ -2775,7 +2832,16 @@ def handle_mcp(
2775
2832
  head: int | None = None,
2776
2833
  verbose: bool = False,
2777
2834
  ):
2778
- 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)
2779
2845
 
2780
2846
  # Resource/prompt operations skip the tool flow entirely
2781
2847
  if resource_action or prompt_action:
@@ -3107,6 +3173,17 @@ def _build_main_parser() -> argparse.ArgumentParser:
3107
3173
  help="Full redirect URI for the OAuth callback (e.g. http://localhost:3334/oauth/callback). "
3108
3174
  "Overrides the default http://127.0.0.1:<random-port>/callback.",
3109
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
+ )
3110
3187
  # Resource flags
3111
3188
  pre.add_argument(
3112
3189
  "--list-resources", action="store_true", help="List available resources"
@@ -3220,12 +3297,21 @@ def _setup_oauth(pre_args):
3220
3297
  if pre_args.oauth_client_secret
3221
3298
  else None
3222
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)
3223
3308
  return build_oauth_provider(
3224
3309
  server_url,
3225
3310
  client_id=client_id,
3226
3311
  client_secret=client_secret,
3227
3312
  scope=pre_args.oauth_scope,
3228
3313
  redirect_uri=pre_args.oauth_redirect_uri,
3314
+ flow=flow,
3229
3315
  )
3230
3316
 
3231
3317
 
File without changes
File without changes
File without changes