mcp-client-kit 0.0.3__py3-none-any.whl

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.
mcpgen/_bridge.py ADDED
@@ -0,0 +1,1159 @@
1
+ """Standalone MCP backend.
2
+
3
+ Everything above this file (generated wrappers, codegen, seam) is unchanged —
4
+ only this backend swaps. Auth uses the official `mcp` SDK OAuthClientProvider with a thin
5
+ FileTokenStorage that stores an absolute `expires_at` per token.
6
+
7
+ Pre-flight refresh: `get_tokens()` returns None for a near/expired access token
8
+ (see below). To stop that None from reaching the SDK — which would trigger a full
9
+ browser re-auth (authorization_code flow) instead of a silent refresh —
10
+ `_pre_flight_refresh()` renews the access token out-of-band (plain httpx, RFC 8414
11
+ discovery) before the session opens.
12
+
13
+ VERIFIED (2026-06-14, eval_preflight.py + mcp 1.27.2 source read): pre-flight IS
14
+ load-bearing — but the precise
15
+ reason is subtler than "the SDK can't refresh". The SDK's `async_auth_flow` DOES
16
+ have a silent `refresh_token`-grant branch (`if not is_token_valid() and
17
+ can_refresh_token(): _refresh_token()`). It is simply UNREACHABLE for a
18
+ fresh-process CLI: `_initialize()` loads tokens from storage but never calls
19
+ `update_token_expiry`, so `token_expiry_time` stays None and `is_token_valid()`
20
+ (`not token_expiry_time or now <= token_expiry_time`) reports ANY disk-loaded
21
+ access token as valid regardless of real expiry. The proactive branch never fires;
22
+ the stale token is sent blind → 401. On 401 the SDK runs `authorization_code`
23
+ (browser), NOT a refresh grant. Net: every fresh CLI invocation that finds an
24
+ expired access token would re-auth in a browser without pre-flight. Conclusion:
25
+ do NOT drop pre-flight. (The server supports refresh grants — the SDK just
26
+ never reaches the code that issues them at cold start.)
27
+
28
+ The `get_tokens` None-gate (line 125) is redundant but harmless: without pre-flight
29
+ both gate-ON and gate-OFF lead to browser re-auth on expired tokens. It short-
30
+ circuits one unnecessary 401 round-trip when pre-flight fails for other reasons.
31
+
32
+ VERSION-SENSITIVE: this is mcp 1.27.2 behavior (dep is bounded `<2`). If a future
33
+ SDK calls `update_token_expiry` inside `_initialize`, the cold-start gap closes and
34
+ the SDK's own proactive refresh fires — re-run eval_preflight.py and re-evaluate
35
+ whether pre-flight is still needed.
36
+
37
+ Unrelated: FastMCP is not a dependency. FastMCP issue #3425 (stale expires_in on
38
+ reload) is a FastMCP bug fixed in fastmcp 3.2.0; our FileTokenStorage stores
39
+ absolute `expires_at` so that class of bug cannot occur regardless.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import ast
45
+ import asyncio
46
+ import errno as _errno
47
+ import json
48
+ import os
49
+ import shlex
50
+ import stat
51
+ import time
52
+ import warnings
53
+ import webbrowser
54
+ from contextlib import asynccontextmanager
55
+ from pathlib import Path
56
+ from typing import Any
57
+ from urllib.parse import parse_qs, urlparse
58
+
59
+ import httpx
60
+ from mcp import ClientSession
61
+ from mcp.client.auth import OAuthClientProvider, TokenStorage
62
+ from mcp.client.stdio import StdioServerParameters, stdio_client
63
+ from mcp.client.streamable_http import streamable_http_client
64
+ from mcp.shared._httpx_utils import create_mcp_http_client
65
+ from mcp.shared.auth import (
66
+ OAuthClientInformationFull,
67
+ OAuthClientMetadata,
68
+ OAuthToken,
69
+ )
70
+
71
+ DEFAULT_CREDS_PATH = Path.home() / ".mcpgen" / "credentials.json"
72
+ DEFAULT_CONFIG_PATH = Path.home() / ".mcpgen" / "config.json"
73
+
74
+ # Credential backend — selects where OAuth tokens are stored.
75
+ # Resolution order (first wins):
76
+ # 1. CLI --cred-backend flag (passed through to FileTokenStorage)
77
+ # 2. MCPGEN_CRED_BACKEND env var
78
+ # 3. ~/.mcpgen/config.json "cred_backend" key
79
+ # 4. default: "file"
80
+ _CRED_BACKEND_ENV = "MCPGEN_CRED_BACKEND"
81
+ _VALID_BACKENDS: frozenset[str] = frozenset({"file", "keyring", "auto"})
82
+ _KEYRING_SERVICE: str = "mcpgen"
83
+ _KEYRING_USER: str = "credentials"
84
+
85
+
86
+ def _load_client_config(path: Path | None = None) -> dict:
87
+ """Load ~/.mcpgen/config.json (or override path). Returns {} if absent/invalid."""
88
+ target = path or DEFAULT_CONFIG_PATH
89
+ if not target.exists():
90
+ return {}
91
+ try:
92
+ return json.loads(target.read_text())
93
+ except (json.JSONDecodeError, OSError):
94
+ return {}
95
+
96
+
97
+ def _save_client_config(updates: dict, path: Path | None = None) -> None:
98
+ """Merge *updates* into the client config file, creating it if absent.
99
+
100
+ Reads the existing config (if any), overlays *updates*, then writes back
101
+ atomically with 0600 permissions. Other keys in the config are preserved.
102
+ """
103
+ target = path or DEFAULT_CONFIG_PATH
104
+ data = _load_client_config(target)
105
+ data.update(updates)
106
+ target.parent.mkdir(parents=True, exist_ok=True)
107
+ tmp = target.with_suffix(".tmp")
108
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
109
+ try:
110
+ os.write(fd, json.dumps(data, indent=2).encode())
111
+ except BaseException:
112
+ os.close(fd)
113
+ try:
114
+ os.unlink(tmp)
115
+ except OSError:
116
+ pass
117
+ raise
118
+ os.close(fd)
119
+ os.replace(tmp, target)
120
+
121
+
122
+ def resolve_cred_backend(cli_value: str | None = None) -> str:
123
+ """Return the resolved credential backend name.
124
+
125
+ Resolution order: CLI arg → MCPGEN_CRED_BACKEND env → config file → "file".
126
+ Raises ValueError for unknown values at any level.
127
+ """
128
+ if cli_value is not None:
129
+ if cli_value not in _VALID_BACKENDS:
130
+ raise ValueError(f"Unknown cred backend {cli_value!r}. Valid choices: {sorted(_VALID_BACKENDS)}")
131
+ return cli_value
132
+ env = os.environ.get(_CRED_BACKEND_ENV)
133
+ if env:
134
+ if env not in _VALID_BACKENDS:
135
+ raise ValueError(f"{_CRED_BACKEND_ENV}={env!r} unknown. Valid choices: {sorted(_VALID_BACKENDS)}")
136
+ return env
137
+ cfg = _load_client_config()
138
+ backend = cfg.get("cred_backend")
139
+ if backend:
140
+ if backend not in _VALID_BACKENDS:
141
+ raise ValueError(f"config cred_backend={backend!r} unknown. Valid choices: {sorted(_VALID_BACKENDS)}")
142
+ return backend
143
+ return "file"
144
+
145
+
146
+ def _detect_keyring() -> str:
147
+ """Return 'keyring' if a working OS keyring backend is available, else 'file'."""
148
+ try:
149
+ import keyring as _kr
150
+
151
+ ring = _kr.get_keyring()
152
+ module = getattr(type(ring), "__module__", "") or ""
153
+ if "fail" in module:
154
+ return "file"
155
+ return "keyring"
156
+ except Exception:
157
+ return "file"
158
+
159
+
160
+ # ── Raw keyring helpers (raise on error — no silent fallback) ────────────────
161
+
162
+
163
+ def _keyring_read_raw() -> dict:
164
+ """Read all credentials from the OS keyring. Raises on any failure."""
165
+ import keyring as _kr # lazy — tests can monkeypatch sys.modules["keyring"]
166
+
167
+ raw = _kr.get_password(_KEYRING_SERVICE, _KEYRING_USER)
168
+ return json.loads(raw) if raw else {}
169
+
170
+
171
+ def _keyring_write_raw(data: dict) -> None:
172
+ """Write all credentials to the OS keyring. Raises on any failure."""
173
+ import keyring as _kr
174
+
175
+ _kr.set_password(_KEYRING_SERVICE, _KEYRING_USER, json.dumps(data, indent=2))
176
+
177
+
178
+ def _keyring_clear_raw() -> None:
179
+ """Delete the credentials entry from the OS keyring (no-op if absent).
180
+
181
+ Only suppresses PasswordDeleteError (entry not found). All other failures
182
+ — locked keychain, access denied, backend error — propagate so callers
183
+ can surface them rather than falsely reporting deletion success.
184
+ """
185
+ import keyring as _kr
186
+
187
+ try:
188
+ _kr.delete_password(_KEYRING_SERVICE, _KEYRING_USER)
189
+ except _kr.errors.PasswordDeleteError:
190
+ pass # already absent
191
+
192
+
193
+ # Named HTTP+OAuth servers are loaded from a user config — never hardcoded, so no
194
+ # org-specific endpoints land in this repo. Search order:
195
+ # 1. $MCPGEN_SERVERS (explicit path)
196
+ # 2. ~/.mcpgen/servers.json ({"name": "url", ...} or mcpServers)
197
+ # 3. ./.mcp.json (Claude Code format: {"mcpServers": {...}})
198
+ # Any name not found here is treated as a raw URL (no auth). See servers.example.json.
199
+ _SERVERS_CONFIG_ENV = "MCPGEN_SERVERS"
200
+ _SERVERS_SEARCH = [
201
+ Path.home() / ".mcpgen" / "servers.json",
202
+ Path.cwd() / ".mcp.json",
203
+ ]
204
+ _servers_cache: dict[str, str] | None = None
205
+ # Per-server OAuth client_name overrides, keyed by server name. Populated alongside
206
+ # _servers_cache by servers(). A server with no override in config is absent here and
207
+ # falls back to the default template (see _resolve_client_name()).
208
+ _client_names_cache: dict[str, str] = {}
209
+ # Stdio server specs keyed by server name. Each value is a dict with keys "command"
210
+ # (str), "args" (list[str]), and "env" (dict[str, str] | None). Populated alongside
211
+ # _servers_cache by servers() from config entries that have "command" but no "url".
212
+ _stdio_cache: dict[str, dict] = {}
213
+ # Static HTTP headers keyed by server name. Populated alongside _servers_cache by
214
+ # servers() from config entries that have both "url" and "headers". Values in "headers"
215
+ # have ``${VAR}`` references expanded at parse time (same as stdio "env").
216
+ _headers_cache: dict[str, dict[str, str]] = {}
217
+
218
+
219
+ def _filter_str_dict(raw: dict, *, require_nonempty_key: bool = False) -> dict[str, str]:
220
+ """Filter a raw config dict to {str: str} keeping only scalar (non-bool) values.
221
+
222
+ Values have ``${VAR}`` references expanded against the host environment.
223
+ Entries with non-scalar values are dropped silently. When
224
+ ``require_nonempty_key`` is True, entries with empty-string keys are also
225
+ dropped (used for HTTP headers where RFC 9110 forbids empty field names).
226
+ """
227
+ result = {}
228
+ for k, v in raw.items():
229
+ if isinstance(v, (str, int, float)) and not isinstance(v, bool):
230
+ sk = str(k)
231
+ if require_nonempty_key and not sk:
232
+ continue
233
+ result[sk] = os.path.expandvars(str(v))
234
+ return result
235
+
236
+
237
+ def _parse_servers(
238
+ raw: dict,
239
+ ) -> tuple[dict[str, str], dict[str, str], dict[str, dict], dict[str, dict[str, str]]]:
240
+ """Parse config into ({name: url}, {name: client_name}, {name: stdio_spec}, {name: headers}).
241
+
242
+ Accept {"name": "url"} or Claude Code {"mcpServers": {"name": {"url": ...}}}.
243
+ The dict form may carry an optional "clientName" (or "client_name" alias) that
244
+ overrides the OAuth client_name sent at Dynamic Client Registration.
245
+
246
+ Stdio entries (those with "command" but no "url") are collected in the third
247
+ return dict. Each stdio_spec has keys "command" (str), "args" (list[str]), and
248
+ "env" (dict[str, str] | None). Values in "env" have ``${VAR}`` references
249
+ expanded against the host environment so secrets stored as env-var references
250
+ resolve at parse time.
251
+
252
+ HTTP entries with a "headers" dict have those headers collected in the fourth
253
+ return dict with ``${VAR}`` references expanded, enabling static header auth
254
+ (e.g. ``Authorization: Bearer ${GITHUB_PAT}``) without ``--bearer``.
255
+ """
256
+ block = raw.get("mcpServers", raw)
257
+ urls: dict[str, str] = {}
258
+ names: dict[str, str] = {}
259
+ cmds: dict[str, dict] = {}
260
+ hdrs: dict[str, dict[str, str]] = {}
261
+ for name, val in block.items():
262
+ if isinstance(val, str):
263
+ urls[name] = val
264
+ elif isinstance(val, dict) and val.get("url"):
265
+ urls[name] = val["url"]
266
+ override = val.get("clientName") or val.get("client_name")
267
+ if override:
268
+ names[name] = override
269
+ raw_headers = val.get("headers") or {}
270
+ if isinstance(raw_headers, dict):
271
+ parsed_h = _filter_str_dict(raw_headers, require_nonempty_key=True)
272
+ if parsed_h:
273
+ hdrs[name] = parsed_h
274
+ elif isinstance(val, dict) and val.get("command"):
275
+ raw_args = val.get("args") or []
276
+ args = [str(a) for a in raw_args] if isinstance(raw_args, list) else []
277
+ raw_env = val.get("env") or {}
278
+ env: dict[str, str] | None = None
279
+ if isinstance(raw_env, dict):
280
+ parsed_e = _filter_str_dict(raw_env)
281
+ if parsed_e:
282
+ env = parsed_e
283
+ cmds[name] = {"command": str(val["command"]), "args": args, "env": env}
284
+ return urls, names, cmds, hdrs
285
+
286
+
287
+ def servers(*, refresh: bool = False, config_path: str | Path | None = None) -> dict[str, str]:
288
+ """Return the {name: url} registry loaded from user config (cached).
289
+
290
+ config_path: if given, read that file exclusively (authoritative — no env or
291
+ search-order fallback) and always fresh, bypassing the cache. A missing or
292
+ unparseable explicit config raises rather than silently returning an empty dict.
293
+ """
294
+ global _servers_cache, _client_names_cache, _stdio_cache, _headers_cache
295
+ if config_path is None and _servers_cache is not None and not refresh:
296
+ return _servers_cache
297
+ if config_path is not None:
298
+ # Authoritative path — fail fast, no search-order fallback.
299
+ path = Path(config_path)
300
+ if not path.exists():
301
+ raise FileNotFoundError(f"config not found: {config_path}")
302
+ try:
303
+ _servers_cache, _client_names_cache, _stdio_cache, _headers_cache = _parse_servers(
304
+ json.loads(path.read_text())
305
+ )
306
+ except (json.JSONDecodeError, OSError, AttributeError) as e:
307
+ raise ValueError(f"failed to parse config {path}: {e}") from e
308
+ return _servers_cache
309
+ candidates: list[Path] = []
310
+ if os.environ.get(_SERVERS_CONFIG_ENV):
311
+ candidates.append(Path(os.environ[_SERVERS_CONFIG_ENV]))
312
+ candidates += _SERVERS_SEARCH
313
+ for path in candidates:
314
+ if path.exists():
315
+ try:
316
+ _servers_cache, _client_names_cache, _stdio_cache, _headers_cache = _parse_servers(
317
+ json.loads(path.read_text())
318
+ )
319
+ return _servers_cache
320
+ except (json.JSONDecodeError, OSError, AttributeError):
321
+ continue
322
+ _servers_cache, _client_names_cache, _stdio_cache, _headers_cache = {}, {}, {}, {}
323
+ return _servers_cache
324
+
325
+
326
+ def _resolve_client_name(server_name: str) -> str:
327
+ """OAuth client_name for a server: config override, else default template."""
328
+ if _servers_cache is None:
329
+ servers()
330
+ return _client_names_cache.get(server_name) or f"mcpgen ({server_name})"
331
+
332
+
333
+ # Treat a cached token as expired this many seconds before its real expiry.
334
+ _MARGIN = 120
335
+
336
+
337
+ class ReauthenticationRequired(Exception):
338
+ """Tokens absent or refresh failed. Run: mcpgen login <server>"""
339
+
340
+
341
+ class FileTokenStorage(TokenStorage):
342
+ """OAuth token + client info store, keyed by server name.
343
+
344
+ Backend selection via the ``backend`` argument (already resolved by
345
+ ``resolve_cred_backend()`` at construction site):
346
+
347
+ - ``"file"`` (default) — hardened plaintext JSON at *credentials_path*
348
+ (``chmod 0600`` file, ``0700`` dir, atomic write via tmp file).
349
+ - ``"keyring"`` — OS keyring (macOS Keychain / Windows Credential Locker /
350
+ Linux SecretService). Falls back to the hardened file if no
351
+ working backend is available, with a warning.
352
+ - ``"auto"`` — keyring if ``_detect_keyring()`` finds a working backend,
353
+ else file silently.
354
+
355
+ The public ``_load()`` / ``_save()`` seam routes to the active backend so
356
+ ``_pre_flight_refresh`` and ``login()`` need no changes.
357
+ """
358
+
359
+ def __init__(
360
+ self,
361
+ server_name: str,
362
+ credentials_path: Path = DEFAULT_CREDS_PATH,
363
+ backend: str = "file",
364
+ ) -> None:
365
+ self._key = server_name
366
+ self._path = credentials_path
367
+ self._backend = _detect_keyring() if backend == "auto" else backend
368
+
369
+ # ── File backend ──────────────────────────────────────────────────────────
370
+
371
+ def _file_load(self) -> dict:
372
+ if not self._path.exists():
373
+ return {}
374
+ mode = stat.S_IMODE(os.stat(self._path).st_mode)
375
+ if mode != 0o600:
376
+ os.chmod(self._path, 0o600)
377
+ warnings.warn(
378
+ f"[mcpgen] {self._path} had permissions {oct(mode)}; fixed to 0600.",
379
+ stacklevel=3,
380
+ )
381
+ return json.loads(self._path.read_text())
382
+
383
+ def _file_save(self, data: dict) -> None:
384
+ parent = self._path.parent
385
+ parent.mkdir(parents=True, exist_ok=True)
386
+ os.chmod(parent, 0o700)
387
+ tmp = self._path.with_suffix(".tmp")
388
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
389
+ try:
390
+ os.write(fd, json.dumps(data, indent=2).encode())
391
+ except BaseException:
392
+ # Close and remove the partial tmp so it doesn't accumulate or leak.
393
+ os.close(fd)
394
+ try:
395
+ os.unlink(tmp)
396
+ except OSError:
397
+ pass
398
+ raise
399
+ os.close(fd)
400
+ os.replace(tmp, self._path)
401
+
402
+ # ── Keyring backend ───────────────────────────────────────────────────────
403
+
404
+ def _keyring_load(self) -> dict:
405
+ try:
406
+ return _keyring_read_raw()
407
+ except Exception as exc:
408
+ self._warn_keyring_fallback(str(exc))
409
+ return self._file_load()
410
+
411
+ def _keyring_save(self, data: dict) -> None:
412
+ try:
413
+ _keyring_write_raw(data)
414
+ except Exception as exc:
415
+ self._warn_keyring_fallback(str(exc))
416
+ self._file_save(data)
417
+
418
+ def _warn_keyring_fallback(self, reason: str) -> None:
419
+ """Warn and permanently downgrade to the file backend for this instance.
420
+
421
+ Mutation is intentional: after one keyring failure all subsequent
422
+ _load/_save calls use the hardened file, avoiding repeated failures and
423
+ warnings within the same process lifetime.
424
+ """
425
+ warnings.warn(
426
+ f"[mcpgen] keyring unavailable ({reason}); falling back to hardened file at {self._path}.",
427
+ stacklevel=3,
428
+ )
429
+ self._backend = "file"
430
+
431
+ # ── Dispatcher (public seam used by _pre_flight_refresh and login) ────────
432
+
433
+ def _load(self) -> dict:
434
+ if self._backend == "keyring":
435
+ return self._keyring_load()
436
+ return self._file_load()
437
+
438
+ def _save(self, data: dict) -> None:
439
+ if self._backend == "keyring":
440
+ self._keyring_save(data)
441
+ else:
442
+ self._file_save(data)
443
+
444
+ # ── TokenStorage protocol ─────────────────────────────────────────────────
445
+
446
+ async def get_tokens(self) -> OAuthToken | None:
447
+ data = self._load()
448
+ raw = data.get(self._key, {}).get("tokens")
449
+ if raw is None:
450
+ return None
451
+ expires_at = raw.get("expires_at")
452
+ if expires_at is not None and time.time() >= expires_at - _MARGIN:
453
+ # Pre-flight refresh should have run; if still here, treat as absent.
454
+ return None
455
+ return OAuthToken(**raw)
456
+
457
+ async def set_tokens(self, tokens: OAuthToken) -> None:
458
+ data = self._load()
459
+ serialized = tokens.model_dump(mode="json", exclude_none=True)
460
+ if tokens.expires_in is not None:
461
+ serialized["expires_at"] = int(time.time()) + int(tokens.expires_in)
462
+ data.setdefault(self._key, {})["tokens"] = serialized
463
+ self._save(data)
464
+
465
+ async def get_client_info(self) -> OAuthClientInformationFull | None:
466
+ data = self._load()
467
+ raw = data.get(self._key, {}).get("client_info")
468
+ if raw is None:
469
+ return None
470
+ return OAuthClientInformationFull(**raw)
471
+
472
+ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
473
+ data = self._load()
474
+ data.setdefault(self._key, {})["client_info"] = client_info.model_dump(mode="json", exclude_none=True)
475
+ self._save(data)
476
+
477
+
478
+ # ── Backend-agnostic migration helpers ──────────────────────────────────────
479
+
480
+
481
+ def _read_backend(backend: str, credentials_path: Path) -> dict:
482
+ """Read the full credentials dict from *backend* (raises on keyring failure)."""
483
+ if backend == "keyring":
484
+ return _keyring_read_raw()
485
+ return FileTokenStorage("_migrate", credentials_path, backend="file")._file_load()
486
+
487
+
488
+ def _write_backend(backend: str, credentials_path: Path, data: dict) -> None:
489
+ """Write the full credentials dict to *backend* (raises on keyring failure)."""
490
+ if backend == "keyring":
491
+ _keyring_write_raw(data)
492
+ else:
493
+ FileTokenStorage("_migrate", credentials_path, backend="file")._file_save(data)
494
+
495
+
496
+ def _clear_backend(backend: str, credentials_path: Path) -> None:
497
+ """Remove all credentials from *backend*."""
498
+ if backend == "keyring":
499
+ _keyring_clear_raw()
500
+ else:
501
+ credentials_path.unlink(missing_ok=True)
502
+
503
+
504
+ def migrate_creds(
505
+ from_backend: str,
506
+ to_backend: str,
507
+ *,
508
+ servers: list[str] | None = None,
509
+ credentials_path: Path = DEFAULT_CREDS_PATH,
510
+ purge: bool = False,
511
+ set_default: bool = False,
512
+ config_path: Path | None = None,
513
+ ) -> dict:
514
+ """Copy stored credentials from one backend to another.
515
+
516
+ Parameters
517
+ ----------
518
+ from_backend:
519
+ Source backend — ``"file"`` or ``"keyring"``.
520
+ to_backend:
521
+ Target backend — ``"file"`` or ``"keyring"``.
522
+ servers:
523
+ Optional list of server names to migrate. ``None`` migrates all.
524
+ Raises ``ValueError`` if a requested name is absent in the source.
525
+ credentials_path:
526
+ Path to the file backend's credentials JSON (default:
527
+ ``~/.mcpgen/credentials.json``).
528
+ purge:
529
+ When ``True``, remove only the migrated entries from the source backend
530
+ after a verified write. When ``False`` (default), the source is kept.
531
+ set_default:
532
+ When ``True``, write ``cred_backend=<to_backend>`` into the client
533
+ config file so future commands default to the new backend.
534
+ config_path:
535
+ Override path for the client config (default:
536
+ ``~/.mcpgen/config.json``).
537
+
538
+ Returns
539
+ -------
540
+ dict
541
+ ``{"from": str, "to": str, "migrated": int, "overwritten": int,
542
+ "purged": bool, "set_default": bool}``
543
+ """
544
+ # Validate
545
+ concrete = {"file", "keyring"}
546
+ for label, val in [("from_backend", from_backend), ("to_backend", to_backend)]:
547
+ resolved = _detect_keyring() if val == "auto" else val
548
+ if resolved not in concrete:
549
+ raise ValueError(f"{label}={val!r} is not a concrete backend. Valid choices: {sorted(concrete)}")
550
+ from_backend = _detect_keyring() if from_backend == "auto" else from_backend
551
+ to_backend = _detect_keyring() if to_backend == "auto" else to_backend
552
+
553
+ if from_backend == to_backend:
554
+ raise ValueError(f"from_backend and to_backend are both {from_backend!r}; nothing to migrate.")
555
+
556
+ # Read source
557
+ source_all = _read_backend(from_backend, credentials_path)
558
+
559
+ # Filter to requested servers
560
+ if servers is not None:
561
+ missing = [s for s in servers if s not in source_all]
562
+ if missing:
563
+ raise ValueError(
564
+ f"Requested server(s) not found in {from_backend!r} backend: " + ", ".join(repr(s) for s in missing)
565
+ )
566
+ source_subset = {k: source_all[k] for k in servers}
567
+ else:
568
+ source_subset = source_all
569
+
570
+ if not source_subset:
571
+ return {
572
+ "from": from_backend,
573
+ "to": to_backend,
574
+ "migrated": 0,
575
+ "overwritten": 0,
576
+ "purged": False,
577
+ "set_default": False,
578
+ }
579
+
580
+ # Merge into target (source wins on collision)
581
+ target = _read_backend(to_backend, credentials_path)
582
+ overwritten = sum(1 for k in source_subset if k in target)
583
+ merged = {**target, **source_subset}
584
+ _write_backend(to_backend, credentials_path, merged)
585
+
586
+ # Verify write
587
+ verified = _read_backend(to_backend, credentials_path)
588
+ missing_after = [k for k in source_subset if k not in verified]
589
+ if missing_after:
590
+ raise RuntimeError(
591
+ f"Migration verification failed: the following server(s) are absent from "
592
+ f"the {to_backend!r} backend after write: {', '.join(missing_after)}"
593
+ )
594
+
595
+ # Optional purge (remove only the migrated keys from source)
596
+ did_purge = False
597
+ if purge:
598
+ source_remaining = _read_backend(from_backend, credentials_path)
599
+ for k in source_subset:
600
+ source_remaining.pop(k, None)
601
+ if source_remaining:
602
+ _write_backend(from_backend, credentials_path, source_remaining)
603
+ else:
604
+ _clear_backend(from_backend, credentials_path)
605
+ did_purge = True
606
+
607
+ # Optional config default
608
+ did_set_default = False
609
+ if set_default:
610
+ _save_client_config({"cred_backend": to_backend}, config_path)
611
+ did_set_default = True
612
+
613
+ return {
614
+ "from": from_backend,
615
+ "to": to_backend,
616
+ "migrated": len(source_subset),
617
+ "overwritten": overwritten,
618
+ "purged": did_purge,
619
+ "set_default": did_set_default,
620
+ }
621
+
622
+
623
+ def list_creds(
624
+ *,
625
+ backend: str | None = None,
626
+ credentials_path: Path = DEFAULT_CREDS_PATH,
627
+ expired_only: bool = False,
628
+ ) -> list[dict]:
629
+ """List stored credentials.
630
+
631
+ Returns a list of dicts — one per server, sorted by name — with the keys:
632
+
633
+ - ``name``: server name
634
+ - ``expires_at``: absolute Unix epoch (int) or ``None`` if no expiry
635
+ - ``expired``: ``True`` when ``time.time() >= expires_at - _MARGIN``
636
+ (same rule as :py:meth:`FileTokenStorage.get_tokens`)
637
+ - ``has_refresh_token``: ``True`` when a refresh_token is stored
638
+
639
+ Parameters
640
+ ----------
641
+ backend:
642
+ Credential backend to read. Resolved via :func:`resolve_cred_backend`
643
+ when ``None`` (env → config → ``"file"``).
644
+ credentials_path:
645
+ Path to the file backend (default ``~/.mcpgen/credentials.json``).
646
+ expired_only:
647
+ When ``True``, omit entries that are valid or have no expiry information.
648
+ """
649
+ resolved = resolve_cred_backend(backend)
650
+ resolved = _detect_keyring() if resolved == "auto" else resolved
651
+ data = _read_backend(resolved, credentials_path)
652
+ now = time.time()
653
+ out = []
654
+ for name, entry in sorted(data.items()):
655
+ tok = (entry or {}).get("tokens") or {}
656
+ exp = tok.get("expires_at")
657
+ expired = exp is not None and now >= exp - _MARGIN
658
+ if expired_only and not expired:
659
+ continue
660
+ out.append(
661
+ {
662
+ "name": name,
663
+ "expires_at": exp,
664
+ "expired": expired,
665
+ "has_refresh_token": bool(tok.get("refresh_token")),
666
+ }
667
+ )
668
+ return out
669
+
670
+
671
+ def delete_cred(
672
+ name: str,
673
+ *,
674
+ backend: str | None = None,
675
+ credentials_path: Path = DEFAULT_CREDS_PATH,
676
+ ) -> bool:
677
+ """Delete the stored credential for *name*.
678
+
679
+ Returns ``True`` if the entry existed (and was removed), ``False`` if it
680
+ was not found. When the removed entry was the last one, the whole backend
681
+ store is cleared (file unlinked / keyring key cleared) to avoid leaving an
682
+ empty JSON object.
683
+
684
+ Parameters
685
+ ----------
686
+ name:
687
+ Server name whose credential to delete.
688
+ backend:
689
+ Credential backend to write. Resolved via :func:`resolve_cred_backend`
690
+ when ``None``.
691
+ credentials_path:
692
+ Path to the file backend (default ``~/.mcpgen/credentials.json``).
693
+ """
694
+ resolved = resolve_cred_backend(backend)
695
+ resolved = _detect_keyring() if resolved == "auto" else resolved
696
+ data = _read_backend(resolved, credentials_path)
697
+ if name not in data:
698
+ return False
699
+ data.pop(name)
700
+ if data:
701
+ _write_backend(resolved, credentials_path, data)
702
+ else:
703
+ _clear_backend(resolved, credentials_path)
704
+ return True
705
+
706
+
707
+ async def _pre_flight_refresh(server_name: str, storage: FileTokenStorage) -> None:
708
+ """Refresh access token if near/past expiry via plain httpx (no MCP SDK).
709
+
710
+ Renews the access token out-of-band before the session opens, so
711
+ get_tokens() returns a live credential instead of None. Load-bearing: the
712
+ official `mcp` SDK's silent refresh branch is unreachable at cold start, so
713
+ without this the SDK sends the stale token blind → 401 → browser re-auth.
714
+ Mirrors the mcpgen pre-flight. See the module docstring
715
+ for the verified mechanism and version caveat.
716
+ """
717
+ data = storage._load()
718
+ entry = data.get(server_name, {})
719
+ tokens_raw = entry.get("tokens") or {}
720
+
721
+ expires_at = tokens_raw.get("expires_at")
722
+ if expires_at is None or time.time() < expires_at - _MARGIN:
723
+ return # token fresh or no expiry info; nothing to do
724
+
725
+ refresh_token = tokens_raw.get("refresh_token")
726
+ if not refresh_token:
727
+ raise ReauthenticationRequired(f"No refresh_token for '{server_name}'. Run: mcpgen login {server_name}")
728
+
729
+ client_id = entry.get("client_info", {}).get("client_id")
730
+ if not client_id:
731
+ raise ReauthenticationRequired(f"No client_id cached for '{server_name}'. Run: mcpgen login {server_name}")
732
+
733
+ token_endpoint = entry.get("token_endpoint")
734
+ if not token_endpoint:
735
+ raise ReauthenticationRequired(
736
+ f"No token_endpoint cached for '{server_name}' (credentials pre-date this version). "
737
+ f"Run: mcpgen login {server_name}"
738
+ )
739
+
740
+ payload: dict[str, str] = {
741
+ "grant_type": "refresh_token",
742
+ "refresh_token": refresh_token,
743
+ "client_id": client_id,
744
+ }
745
+ client_secret = entry.get("client_info", {}).get("client_secret")
746
+ if client_secret:
747
+ payload["client_secret"] = client_secret
748
+
749
+ async with httpx.AsyncClient() as client:
750
+ resp = await client.post(token_endpoint, data=payload)
751
+
752
+ if resp.status_code != 200:
753
+ raise ReauthenticationRequired(
754
+ f"Token refresh failed ({resp.status_code}): {resp.text[:200]}. Run: mcpgen login {server_name}"
755
+ )
756
+
757
+ await storage.set_tokens(OAuthToken(**resp.json()))
758
+
759
+
760
+ @asynccontextmanager
761
+ async def _open_http(url: str, *, headers: dict[str, str] | None = None, auth: httpx.Auth | None = None):
762
+ """Open a StreamableHTTP transport, yielding (read, write, get_session_id).
763
+
764
+ Wraps ``streamable_http_client`` (the non-deprecated successor to the
765
+ removed ``streamablehttp_client``) with an MCP-default httpx client so
766
+ callers can still inject headers or an auth handler.
767
+ """
768
+ async with create_mcp_http_client(headers=headers, auth=auth) as client:
769
+ async with streamable_http_client(url, http_client=client) as streams:
770
+ yield streams
771
+
772
+
773
+ @asynccontextmanager
774
+ async def _http_session(
775
+ server_name: str,
776
+ server_url: str,
777
+ *,
778
+ client_name: str | None = None,
779
+ cred_backend: str | None = None,
780
+ ):
781
+ """OAuth-authenticated HTTP MCP session. Pre-flight refresh before connecting."""
782
+ storage = FileTokenStorage(server_name, backend=resolve_cred_backend(cred_backend))
783
+ await _pre_flight_refresh(server_name, storage)
784
+
785
+ data = storage._load()
786
+ redirect_uris = data.get(server_name, {}).get("client_info", {}).get("redirect_uris", [])
787
+ callback_uri = redirect_uris[0] if redirect_uris else "http://localhost:0/callback"
788
+
789
+ async def _no_browser(url: str) -> None:
790
+ raise ReauthenticationRequired(f"OAuth re-auth needed for '{server_name}'. Run: mcpgen login {server_name}")
791
+
792
+ async def _no_callback() -> tuple[str, str | None]:
793
+ raise ReauthenticationRequired(f"OAuth re-auth needed for '{server_name}'. Run: mcpgen login {server_name}")
794
+
795
+ provider = OAuthClientProvider(
796
+ server_url=server_url,
797
+ client_metadata=OAuthClientMetadata(
798
+ client_name=client_name or _resolve_client_name(server_name),
799
+ redirect_uris=[callback_uri], # type: ignore[list-item] # Pydantic coerces str→AnyUrl
800
+ grant_types=["authorization_code", "refresh_token"],
801
+ response_types=["code"],
802
+ ),
803
+ storage=storage,
804
+ redirect_handler=_no_browser,
805
+ callback_handler=_no_callback,
806
+ )
807
+
808
+ async with _open_http(server_url, auth=provider) as (read, write, _):
809
+ async with ClientSession(read, write) as s:
810
+ await s.initialize()
811
+ yield s
812
+
813
+
814
+ @asynccontextmanager
815
+ async def _stdio_session(command: str, args: list[str], env: dict[str, str] | None = None):
816
+ """Stdio MCP session — no auth.
817
+
818
+ env keys are merged on top of the SDK's safe inherited environment:
819
+ ``{**get_default_environment(), **env}``. The SDK's default allowlist is
820
+ ``HOME, LOGNAME, PATH, SHELL, TERM, USER``; any keys not in that list (e.g.
821
+ ``CONTEXT7_API_KEY``) must be passed explicitly via ``env`` — they are NOT
822
+ automatically inherited from ``os.environ``.
823
+
824
+ When ``env`` is None the child receives only ``get_default_environment()``.
825
+ To forward shell env vars, use the ``--env KEY[=VAL]`` CLI flag, which
826
+ constructs the ``env`` dict before calling this function.
827
+ """
828
+ params = StdioServerParameters(command=command, args=args, env=env)
829
+ async with stdio_client(params) as (read, write):
830
+ async with ClientSession(read, write) as s:
831
+ await s.initialize()
832
+ yield s
833
+
834
+
835
+ @asynccontextmanager
836
+ async def _static_headers_session(url: str, headers: dict[str, str]):
837
+ """HTTP MCP session with arbitrary static headers (e.g. from a config ``headers`` block).
838
+
839
+ Bypasses OAuth entirely. Supports any header, not just Authorization.
840
+ """
841
+ async with _open_http(url, headers=headers) as (read, write, _):
842
+ async with ClientSession(read, write) as s:
843
+ await s.initialize()
844
+ yield s
845
+
846
+
847
+ @asynccontextmanager
848
+ async def _bearer_session(url: str, bearer: str):
849
+ """HTTP MCP session authenticated with a static Bearer token (e.g. a GitHub PAT).
850
+
851
+ Bypasses OAuth entirely — the caller is responsible for providing a valid token.
852
+ The token is held only in memory and never written to disk.
853
+ """
854
+ async with _static_headers_session(url, {"Authorization": f"Bearer {bearer}"}) as s:
855
+ yield s
856
+
857
+
858
+ @asynccontextmanager
859
+ async def session(
860
+ server: str,
861
+ *,
862
+ cmd: str | None = None,
863
+ url: str | None = None,
864
+ bearer: str | None = None,
865
+ client_name: str | None = None,
866
+ config_path: str | Path | None = None,
867
+ cred_backend: str | None = None,
868
+ env: dict[str, str] | None = None,
869
+ ):
870
+ """Yield an initialized MCP ClientSession.
871
+
872
+ cmd: if provided, use stdio transport (no auth).
873
+ bearer: static Bearer token — routes through HTTP with Authorization header,
874
+ bypassing OAuth. Intended for APIs that use PATs (e.g. GitHub). Takes
875
+ precedence over OAuth when both url and bearer are provided.
876
+ url: inline server URL — routes through HTTP + OAuth keyed by `server` name,
877
+ overriding config. client_name: inline OAuth client_name override.
878
+ config_path: read the server registry from this file instead of the default search.
879
+ server: a configured name (servers()) → HTTP + OAuth; otherwise a raw URL.
880
+ env: extra env vars forwarded to the stdio subprocess (merged over the SDK's
881
+ safe allowlist). Keys NOT in ``get_default_environment()`` (e.g. API keys)
882
+ must be supplied here; they are NOT inherited from ``os.environ`` otherwise.
883
+ No-op for non-stdio transports.
884
+ """
885
+ _servers = servers(config_path=config_path)
886
+ resolved_url = url or _servers.get(server)
887
+ # Resolve a stdio spec: explicit --stdio flag takes precedence over config.
888
+ stdio_spec: dict | None = None
889
+ if cmd is not None:
890
+ parts = shlex.split(cmd)
891
+ stdio_spec = {"command": parts[0], "args": parts[1:], "env": env}
892
+ elif server in _stdio_cache and url is None and bearer is None:
893
+ stdio_spec = dict(_stdio_cache[server])
894
+ if env:
895
+ stdio_spec["env"] = {**(stdio_spec.get("env") or {}), **env}
896
+ if stdio_spec is not None:
897
+ async with _stdio_session(**stdio_spec) as s:
898
+ yield s
899
+ elif bearer is not None:
900
+ target = resolved_url or server
901
+ async with _bearer_session(target, bearer) as s:
902
+ yield s
903
+ elif resolved_url is not None and _headers_cache.get(server):
904
+ # Config-supplied static headers (e.g. Authorization: Bearer ${PAT}) — bypass OAuth.
905
+ async with _static_headers_session(resolved_url, _headers_cache[server]) as s:
906
+ yield s
907
+ elif resolved_url is not None:
908
+ async with _http_session(server, resolved_url, client_name=client_name, cred_backend=cred_backend) as s:
909
+ yield s
910
+ elif "://" not in server:
911
+ raise ValueError(f"server {server!r} not found in config and is not a URL")
912
+ else:
913
+ # Raw URL, no auth
914
+ async with _open_http(server) as (read, write, _):
915
+ async with ClientSession(read, write) as s:
916
+ await s.initialize()
917
+ yield s
918
+
919
+
920
+ class McpBridgeCaller:
921
+ """McpCaller implementation backed by the standalone MCP client."""
922
+
923
+ def __init__(
924
+ self,
925
+ *,
926
+ cmd: str | None = None,
927
+ url: str | None = None,
928
+ bearer: str | None = None,
929
+ client_name: str | None = None,
930
+ config_path: str | Path | None = None,
931
+ cred_backend: str | None = None,
932
+ env: dict[str, str] | None = None,
933
+ ) -> None:
934
+ self._cmd = cmd
935
+ self._url = url
936
+ self._bearer = bearer
937
+ self._client_name = client_name
938
+ self._config_path = config_path
939
+ self._cred_backend = cred_backend
940
+ self._env = env
941
+
942
+ async def call(self, server: str, tool: str, arguments: dict) -> Any:
943
+ async with session(
944
+ server,
945
+ cmd=self._cmd,
946
+ url=self._url,
947
+ bearer=self._bearer,
948
+ client_name=self._client_name,
949
+ config_path=self._config_path,
950
+ cred_backend=self._cred_backend,
951
+ env=self._env,
952
+ ) as s:
953
+ result = await s.call_tool(tool, arguments)
954
+ content = [{"type": item.type, "text": getattr(item, "text", "")} for item in result.content]
955
+ return parse(content)
956
+
957
+
958
+ def parse(content_items: list) -> Any:
959
+ """Extract and JSON-parse the text payload from an MCP tool result.
960
+
961
+ Falls back to ``ast.literal_eval`` for Python-repr payloads (e.g. servers
962
+ that return single-quoted dicts like sqlite), then to a plain string as a
963
+ last resort so callers can still inspect the response.
964
+ """
965
+ if not content_items:
966
+ raise ValueError("MCP tool result has empty content")
967
+ item = content_items[0]
968
+ text = item.get("text", "") if isinstance(item, dict) else getattr(item, "text", "")
969
+ try:
970
+ return json.loads(text)
971
+ except json.JSONDecodeError:
972
+ try:
973
+ return ast.literal_eval(text)
974
+ except (ValueError, SyntaxError):
975
+ return text
976
+
977
+
978
+ # ---------------------------------------------------------------------------
979
+ # Login flow (browser-based initial OAuth)
980
+ # ---------------------------------------------------------------------------
981
+
982
+
983
+ async def _local_callback_server(port: int = 0) -> tuple[int, asyncio.Future]:
984
+ """Start a local HTTP server to receive the OAuth redirect. Returns (port, future)."""
985
+ loop = asyncio.get_event_loop()
986
+ future: asyncio.Future[tuple[str | None, str | None]] = loop.create_future()
987
+
988
+ async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
989
+ code: str | None = None
990
+ state: str | None = None
991
+ try:
992
+ data = await reader.read(4096)
993
+ first_line = data.decode(errors="replace").split("\n")[0]
994
+ path = first_line.split(" ")[1] if " " in first_line else ""
995
+ params = parse_qs(urlparse(path).query)
996
+ code = params.get("code", [None])[0]
997
+ state = params.get("state", [None])[0]
998
+ body = b"<html><body><h1>Login complete. You can close this tab.</h1></body></html>"
999
+ writer.write(
1000
+ b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n"
1001
+ + f"Content-Length: {len(body)}\r\n\r\n".encode()
1002
+ + body
1003
+ )
1004
+ await writer.drain()
1005
+ finally:
1006
+ writer.close()
1007
+ if not future.done():
1008
+ future.set_result((code, state))
1009
+
1010
+ try:
1011
+ server = await asyncio.start_server(_handle, "localhost", port)
1012
+ except OSError as exc:
1013
+ if exc.errno == _errno.EADDRINUSE and port != 0:
1014
+ server = await asyncio.start_server(_handle, "localhost", 0)
1015
+ else:
1016
+ raise
1017
+
1018
+ actual_port = server.sockets[0].getsockname()[1]
1019
+
1020
+ async def _serve_until_done() -> None:
1021
+ async with server:
1022
+ await future
1023
+
1024
+ asyncio.create_task(_serve_until_done())
1025
+ return actual_port, future
1026
+
1027
+
1028
+ async def login(
1029
+ server_name: str,
1030
+ creds_path: Path = DEFAULT_CREDS_PATH,
1031
+ *,
1032
+ url: str | None = None,
1033
+ client_name: str | None = None,
1034
+ config_path: str | Path | None = None,
1035
+ cred_backend: str | None = None,
1036
+ ) -> None:
1037
+ """Full browser-based OAuth login for server_name. Caches tokens + token_endpoint.
1038
+
1039
+ url/client_name: inline overrides (no config entry needed).
1040
+ config_path: read the server registry from this file instead of the default search.
1041
+ """
1042
+ _servers = servers(config_path=config_path)
1043
+ server_url = url or _servers.get(server_name)
1044
+ if server_url is None:
1045
+ raise ValueError(f"Unknown server {server_name!r}. Pass --url or add it to config. Known: {list(_servers)}")
1046
+
1047
+ storage = FileTokenStorage(server_name, creds_path, backend=resolve_cred_backend(cred_backend))
1048
+
1049
+ # Stash the existing entry before clearing it. If the OAuth flow fails for
1050
+ # any reason (user cancel, network error, bad registration), we restore it
1051
+ # so the caller is not locked out of a previously-working server.
1052
+ data = storage._load()
1053
+ stashed = data.pop(server_name, None)
1054
+ storage._save(data)
1055
+
1056
+ try:
1057
+ port, callback_future = await _local_callback_server()
1058
+ callback_uri = f"http://localhost:{port}/callback"
1059
+
1060
+ async def redirect_handler(url: str) -> None:
1061
+ print(f"\nOpening browser: {url}\n")
1062
+ webbrowser.open(url)
1063
+
1064
+ async def callback_handler() -> tuple[str, str | None]:
1065
+ print("Waiting for OAuth callback… (complete login in your browser)")
1066
+ return await callback_future
1067
+
1068
+ provider = OAuthClientProvider(
1069
+ server_url=server_url,
1070
+ client_metadata=OAuthClientMetadata(
1071
+ client_name=client_name or _resolve_client_name(server_name),
1072
+ redirect_uris=[callback_uri], # type: ignore[list-item] # Pydantic coerces str→AnyUrl
1073
+ grant_types=["authorization_code", "refresh_token"],
1074
+ response_types=["code"],
1075
+ ),
1076
+ storage=storage,
1077
+ redirect_handler=redirect_handler,
1078
+ callback_handler=callback_handler,
1079
+ )
1080
+
1081
+ try:
1082
+ async with _open_http(server_url, auth=provider) as (read, write, _):
1083
+ async with ClientSession(read, write) as s:
1084
+ await s.initialize()
1085
+
1086
+ # Persist the token endpoint for later pre-flight refresh. Do this
1087
+ # independently of any tool call — not every server exposes `whoami`.
1088
+ if provider.context.oauth_metadata is not None:
1089
+ endpoint_url = str(provider.context.oauth_metadata.token_endpoint)
1090
+ creds_data = storage._load()
1091
+ creds_data.setdefault(server_name, {})["token_endpoint"] = endpoint_url
1092
+ storage._save(creds_data)
1093
+
1094
+ # Confirm the authenticated session works with a server-agnostic
1095
+ # call. `list_tools` is part of the MCP protocol — every server
1096
+ # supports it, unlike any specific tool name.
1097
+ tools = await s.list_tools()
1098
+ print(f"Login OK ({server_name}); {len(tools.tools)} tool(s) available")
1099
+ finally:
1100
+ if not callback_future.done():
1101
+ callback_future.set_result((None, None))
1102
+ await asyncio.sleep(0)
1103
+
1104
+ except BaseException:
1105
+ # Restore the stashed credential so a failed login attempt doesn't lock
1106
+ # the user out of a previously-working server.
1107
+ if stashed is not None:
1108
+ restore = storage._load()
1109
+ restore[server_name] = stashed
1110
+ storage._save(restore)
1111
+ raise
1112
+
1113
+ print(f"Credentials saved to {creds_path}")
1114
+
1115
+
1116
+ async def ensure_login(
1117
+ server_name: str,
1118
+ creds_path: Path = DEFAULT_CREDS_PATH,
1119
+ *,
1120
+ url: str | None = None,
1121
+ client_name: str | None = None,
1122
+ config_path: str | Path | None = None,
1123
+ cred_backend: str | None = None,
1124
+ ) -> None:
1125
+ """Ensure a usable token exists for server_name, refreshing or logging in.
1126
+
1127
+ Silent when a valid (or refreshable) token is cached — runs the same
1128
+ pre-flight refresh as a normal call. Opens the browser via login() only when
1129
+ there is no token, or the refresh token itself is gone/expired. Idempotent:
1130
+ safe to call before every run.
1131
+
1132
+ Cases:
1133
+ - Fresh token: no-op.
1134
+ - Near/past expiry, refresh_token present: silent out-of-band renewal.
1135
+ - Near/past expiry, no refresh_token (or renewal fails): browser login.
1136
+ - No token at all: browser login.
1137
+ """
1138
+ storage = FileTokenStorage(server_name, creds_path, backend=resolve_cred_backend(cred_backend))
1139
+ try:
1140
+ await _pre_flight_refresh(server_name, storage)
1141
+ except ReauthenticationRequired:
1142
+ await login(
1143
+ server_name,
1144
+ creds_path,
1145
+ url=url,
1146
+ client_name=client_name,
1147
+ config_path=config_path,
1148
+ cred_backend=cred_backend,
1149
+ )
1150
+ return
1151
+ if await storage.get_tokens() is None: # first-time: no token cached at all
1152
+ await login(
1153
+ server_name,
1154
+ creds_path,
1155
+ url=url,
1156
+ client_name=client_name,
1157
+ config_path=config_path,
1158
+ cred_backend=cred_backend,
1159
+ )