mcp2cli 3.0.2__tar.gz → 3.1.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.0.2
3
+ Version: 3.1.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 = "3.0.2"
3
+ version = "3.1.0"
4
4
  description = "Turn any MCP server or OpenAPI spec into a CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -469,13 +469,21 @@ OAUTH_DIR = CACHE_DIR / "oauth"
469
469
 
470
470
 
471
471
  class FileTokenStorage:
472
- """File-based token storage for OAuth tokens and client info."""
472
+ """File-based token storage for OAuth tokens and client info.
473
+
474
+ Persists an absolute ``expires_at`` timestamp alongside ``tokens.json``
475
+ (in ``tokens_meta.json``) so that on a fresh process the SDK can tell an
476
+ access token has expired and trigger a refresh, rather than blindly
477
+ re-sending the stale Bearer and falling through to a full re-auth that
478
+ reuses a possibly-forgotten DCR client_id (issue #50).
479
+ """
473
480
 
474
481
  def __init__(self, server_url: str):
475
482
  key = hashlib.sha256(server_url.encode()).hexdigest()[:16]
476
483
  self._dir = OAUTH_DIR / key
477
484
  self._dir.mkdir(parents=True, exist_ok=True)
478
485
  self._tokens_path = self._dir / "tokens.json"
486
+ self._tokens_meta_path = self._dir / "tokens_meta.json"
479
487
  self._client_path = self._dir / "client.json"
480
488
 
481
489
  async def get_tokens(self):
@@ -491,6 +499,50 @@ class FileTokenStorage:
491
499
 
492
500
  async def set_tokens(self, tokens) -> None:
493
501
  self._tokens_path.write_text(tokens.model_dump_json())
502
+ # Persist an absolute expiry timestamp so we can detect on a
503
+ # later process start that the access token has expired.
504
+ if tokens.expires_in is not None:
505
+ try:
506
+ meta = {"expires_at": time.time() + float(tokens.expires_in)}
507
+ self._tokens_meta_path.write_text(json.dumps(meta))
508
+ except Exception:
509
+ pass
510
+ else:
511
+ # No expiry info; drop any stale meta sidecar.
512
+ try:
513
+ self._tokens_meta_path.unlink()
514
+ except FileNotFoundError:
515
+ pass
516
+
517
+ def get_expires_at(self) -> float | None:
518
+ """Return the absolute expiry timestamp persisted alongside tokens.
519
+
520
+ Returns None when no sidecar exists (older caches or tokens without
521
+ an ``expires_in`` value).
522
+ """
523
+ if not self._tokens_meta_path.exists():
524
+ return None
525
+ try:
526
+ data = json.loads(self._tokens_meta_path.read_text())
527
+ value = data.get("expires_at")
528
+ return float(value) if value is not None else None
529
+ except Exception:
530
+ return None
531
+
532
+ def clear_tokens(self) -> None:
533
+ """Remove persisted tokens and their expiry sidecar."""
534
+ for path in (self._tokens_path, self._tokens_meta_path):
535
+ try:
536
+ path.unlink()
537
+ except FileNotFoundError:
538
+ pass
539
+
540
+ def clear_client_info(self) -> None:
541
+ """Remove persisted DCR client info so a fresh registration runs."""
542
+ try:
543
+ self._client_path.unlink()
544
+ except FileNotFoundError:
545
+ pass
494
546
 
495
547
  async def get_client_info(self):
496
548
  from mcp.shared.auth import OAuthClientInformationFull
@@ -601,6 +653,53 @@ def build_oauth_provider(
601
653
  from mcp.client.auth.oauth2 import OAuthClientProvider
602
654
  from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
603
655
 
656
+ class _RobustOAuthClientProvider(OAuthClientProvider):
657
+ """OAuthClientProvider with cross-process expiry + stale-DCR recovery.
658
+
659
+ Fixes for issue #50 — Atlassian "Internal Server Error" on next-day
660
+ OAuth calls. The upstream SDK has two gaps when state is reloaded
661
+ from disk in a new process:
662
+
663
+ 1. ``_initialize`` reloads ``current_tokens`` from storage but never
664
+ recomputes ``token_expiry_time`` from the bare ``OAuthToken``
665
+ dump (which only carries ``expires_in``). An expired access
666
+ token therefore looks valid, is sent, and only the 401 fallback
667
+ runs — skipping the refresh-token grant entirely.
668
+
669
+ 2. The 401 fallback re-uses the cached DCR ``client_id`` because
670
+ ``client_info`` was reloaded from disk; if the auth server has
671
+ since forgotten that registration, ``/authorize`` returns an
672
+ opaque 500 page rather than a clean ``invalid_client`` redirect
673
+ and the CLI hangs on the callback.
674
+
675
+ We patch both by restoring ``token_expiry_time`` from a sidecar
676
+ we persist in :class:`FileTokenStorage`, and by wiping the cached
677
+ ``client_info`` from disk and memory whenever a refresh fails so
678
+ the subsequent re-auth performs fresh Dynamic Client Registration.
679
+ """
680
+
681
+ async def _initialize(self) -> None:
682
+ 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
688
+
689
+ async def _handle_refresh_response(self, response) -> bool:
690
+ ok = await super()._handle_refresh_response(response)
691
+ if not ok:
692
+ # Refresh failed. The cached DCR client_id may have been
693
+ # forgotten by the auth server too; clear it so the
694
+ # subsequent 401 fallback performs a fresh registration
695
+ # instead of /authorize?client_id=<stale> → opaque 500.
696
+ self.context.client_info = None
697
+ storage = self.context.storage
698
+ if isinstance(storage, FileTokenStorage):
699
+ storage.clear_client_info()
700
+ storage.clear_tokens()
701
+ return ok
702
+
604
703
  _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
605
704
 
606
705
  if redirect_uri is not None:
@@ -700,7 +799,7 @@ def build_oauth_provider(
700
799
  raise RuntimeError("No authorization code received")
701
800
  return (_CallbackHandler.auth_code, _CallbackHandler.state)
702
801
 
703
- return OAuthClientProvider(
802
+ return _RobustOAuthClientProvider(
704
803
  server_url=server_url,
705
804
  client_metadata=client_metadata,
706
805
  storage=storage,
@@ -1875,6 +1974,7 @@ def build_argparse(
1875
1974
  sub = subparsers.add_parser(
1876
1975
  cmd.name,
1877
1976
  help=escape_argparse_help(cmd.description),
1977
+ description=escape_argparse_help(cmd.description),
1878
1978
  )
1879
1979
  sub.set_defaults(_cmd=cmd)
1880
1980
 
@@ -2347,14 +2447,15 @@ async def _mcp_session(
2347
2447
  # Handle resource operations
2348
2448
  if resource_action:
2349
2449
  await _handle_resources(
2350
- session, resource_action, resource_uri, pretty, raw, toon,
2450
+ session, resource_action, resource_uri, pretty, raw, toon, head=head,
2351
2451
  )
2352
2452
  return
2353
2453
 
2354
2454
  # Handle prompt operations
2355
2455
  if prompt_action:
2356
2456
  await _handle_prompts(
2357
- session, prompt_action, prompt_name, prompt_arguments, pretty, raw, toon,
2457
+ session, prompt_action, prompt_name, prompt_arguments,
2458
+ pretty, raw, toon, head=head,
2358
2459
  )
2359
2460
  return
2360
2461
 
@@ -2406,7 +2507,13 @@ async def _mcp_session(
2406
2507
 
2407
2508
 
2408
2509
  async def _handle_resources(
2409
- session, action: str, uri: str | None, pretty: bool, raw: bool, toon: bool,
2510
+ session,
2511
+ action: str,
2512
+ uri: str | None,
2513
+ pretty: bool,
2514
+ raw: bool,
2515
+ toon: bool,
2516
+ head: int | None = None,
2410
2517
  ):
2411
2518
  _out = dict(pretty=pretty, raw=raw, toon=toon, head=head)
2412
2519
  if action == "list":
File without changes
File without changes
File without changes