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.
- mcp_client_kit-0.0.3.dist-info/METADATA +196 -0
- mcp_client_kit-0.0.3.dist-info/RECORD +11 -0
- mcp_client_kit-0.0.3.dist-info/WHEEL +4 -0
- mcp_client_kit-0.0.3.dist-info/entry_points.txt +2 -0
- mcp_client_kit-0.0.3.dist-info/licenses/LICENSE +21 -0
- mcpgen/__init__.py +23 -0
- mcpgen/_bridge.py +1159 -0
- mcpgen/cli.py +896 -0
- mcpgen/codegen.py +674 -0
- mcpgen/discovery.py +442 -0
- mcpgen/seam.py +18 -0
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
|
+
)
|