mcp-client-kit 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/CHANGELOG.md +53 -1
  2. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/PKG-INFO +5 -2
  3. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/README.md +4 -1
  4. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/USAGE.md +46 -1
  5. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/mcpgen/__init__.py +2 -0
  6. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/mcpgen/_bridge.py +159 -17
  7. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/mcpgen/cli.py +36 -0
  8. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/pyproject.toml +1 -1
  9. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_bridge.py +495 -0
  10. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/uv.lock +1 -1
  11. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/.claude/skills/releasing-a-version/SKILL.md +0 -0
  12. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/.claude-plugin/marketplace.json +0 -0
  13. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/.claude-plugin/plugin.json +0 -0
  14. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/.github/workflows/ci.yml +0 -0
  15. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/.github/workflows/publish.yml +0 -0
  16. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/.gitignore +0 -0
  17. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/CLAUDE.md +0 -0
  18. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/LICENSE +0 -0
  19. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/CODEGEN_SKILL_IDEA.md +0 -0
  20. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/DISTRIBUTION.md +0 -0
  21. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/EXTRACTION_ANALYSIS.md +0 -0
  22. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/LANDSCAPE.md +0 -0
  23. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/MARKET_RESEARCH.md +0 -0
  24. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/RUNNING_LOCALLY.md +0 -0
  25. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/VERDICT.md +0 -0
  26. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/mcpgen/codegen.py +0 -0
  27. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/mcpgen/discovery.py +0 -0
  28. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/mcpgen/seam.py +0 -0
  29. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/servers.example.json +0 -0
  30. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/skills/generate-mcp-runner/SKILL.md +0 -0
  31. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/skills/generate-mcp-runner/runner_templates/http_bearer.py +0 -0
  32. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/skills/generate-mcp-runner/runner_templates/http_oauth.py +0 -0
  33. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/skills/generate-mcp-runner/runner_templates/http_public.py +0 -0
  34. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/skills/generate-mcp-runner/runner_templates/stdio.py +0 -0
  35. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/skills/generate-mcp-wrappers/SKILL.md +0 -0
  36. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_cli_call.py +0 -0
  37. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_cli_env.py +0 -0
  38. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_cli_list.py +0 -0
  39. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_cli_merge.py +0 -0
  40. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_cli_probe.py +0 -0
  41. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_codegen.py +0 -0
  42. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_config.py +0 -0
  43. {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_discovery.py +0 -0
@@ -1,6 +1,58 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased] — 0.4.0
3
+ ## [Unreleased] — 0.5.0
4
+
5
+ ## [0.4.0] — 2026-08-09
6
+
7
+ ### Added
8
+
9
+ - **Headless OAuth login — `mcpgen login <server> --headless`** — logging in no longer requires
10
+ a browser on the machine running the command. Instead of opening a browser and binding a local
11
+ callback server, mcpgen prints the authorization URL to stderr, you authorize on any device,
12
+ and paste the resulting callback URL back on stdin. This is the only workable flow inside
13
+ containers, over SSH, and in CI, where `webbrowser.open()` silently does nothing and the
14
+ callback port is unreachable from wherever the browser actually runs. The redirect URI
15
+ registered in headless mode is port-less (`http://localhost/callback`) — nothing ever fetches
16
+ it, and keeping it free of an ephemeral port makes the registered value stable across runs for
17
+ servers that pin `redirect_uris`. Without the flag mcpgen auto-detects: macOS and Windows are
18
+ always treated as interactive, other platforms are headless when neither `DISPLAY` nor
19
+ `WAYLAND_DISPLAY` is set. `MCPGEN_HEADLESS=1`/`0` overrides the detection in either direction
20
+ for environments that guess wrong; an explicit `--headless`/`--no-headless` outranks both.
21
+
22
+ - **`mcpgen login --callback-timeout SECONDS`** — the 300-second bound on the browser redirect
23
+ is a guess about how long a consent screen plus MFA takes, and guesses about human latency are
24
+ wrong for someone: a hardware-token or approve-on-phone flow can outlast it, while a scripted
25
+ login wants to fail fast. The flag sets that bound per invocation, `0` restores the original
26
+ unbounded wait as an escape hatch, and anything negative or non-numeric is rejected by argparse
27
+ with a usage message rather than surfacing as a traceback from inside asyncio. The same value
28
+ is available in code as `callback_timeout=` on `login()`, `ensure_login()`, and
29
+ `ensure_login_all()`. Headless logins ignore it — the pasted-URL prompt is never bounded.
30
+
31
+ - **`ensure_login_all(servers)`** — pre-flight refresh-or-login across several servers in one
32
+ call, for pipelines that talk to more than one MCP server and want every token settled before
33
+ work starts. Deliberately sequential: concurrent logins would open several browser tabs at
34
+ once and, in headless mode, race each other for stdin. Servers with a valid cached token stay
35
+ silent, so the steady-state cost is zero.
36
+
37
+ ### Fixed
38
+
39
+ - **An authorization denial in the browser flow now fails with the same clear error as the
40
+ headless flow.** The local callback server extracted only `code` and `state` and ignored
41
+ `error`/`error_description`, so declining consent resolved the callback to `(None, None)` and
42
+ surfaced later as an opaque downstream failure. Both callback paths now share one parser,
43
+ which raises `ValueError: OAuth authorization failed: <error> — <description>` at the point of
44
+ failure.
45
+
46
+ - **`mcpgen login` no longer hangs forever when the browser never comes back.** The error-redirect
47
+ fix above only helps when the authorization server actually redirects; real ones often don't —
48
+ cancel the consent screen and the tab simply closes, so no request ever reaches the local
49
+ callback server and the wait never ends. The interactive flow now bounds that wait at 300s
50
+ (`_CALLBACK_TIMEOUT`) and raises `TimeoutError` explaining that the browser never returned,
51
+ with a pointer to `--headless` for pasting the redirect URL by hand. The timeout cancels the
52
+ pending callback future, so the background server task shuts down cleanly, and it unwinds
53
+ through the same restore path as any other login failure — a timed-out attempt leaves a
54
+ previously-working credential intact. Headless logins are deliberately unbounded: a human
55
+ pasting a URL may take arbitrarily long.
4
56
 
5
57
  ## [0.3.0] — 2026-07-14
6
58
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-client-kit
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: mcp-client-kit — generate typed Python wrappers for any MCP server (codegen skill + CLI).
5
5
  Project-URL: Homepage, https://github.com/svd/mcp-client-kit
6
6
  Project-URL: Source, https://github.com/svd/mcp-client-kit
@@ -168,11 +168,14 @@ Full workflow and flags: [`doc/USAGE.md`](doc/USAGE.md).
168
168
 
169
169
  ```bash
170
170
  mcpgen login <server> # OAuth (most servers)
171
+ mcpgen login <server> --headless # no browser: print URL, paste callback URL back
171
172
  mcpgen codegen <server> --bearer "$TOKEN" --out s.py # PAT / bearer
172
173
  mcpgen codegen <server> --stdio "python server.py" --out s.py # local stdio, no auth
173
174
  ```
174
175
 
175
- Tokens persist in `~/.mcpgen/credentials.json` (chmod 0600) or your OS keystore via `--cred-backend keyring`. In code, `ensure_login(server, url=...)` refreshes silently and only opens a browser when a real login is required.
176
+ Tokens persist in `~/.mcpgen/credentials.json` (chmod 0600) or your OS keystore via `--cred-backend keyring`. In code, `ensure_login(server, url=...)` refreshes silently and only opens a browser when a real login is required; `ensure_login_all([...])` does the same for several servers, one at a time.
177
+
178
+ In containers, over SSH, or in CI there is no browser to open. `--headless` prints the authorization URL, you authorize on any device, and paste the callback URL back on stdin. It is auto-detected when neither `DISPLAY` nor `WAYLAND_DISPLAY` is set (never on macOS or Windows); `MCPGEN_HEADLESS=1`/`0` overrides the detection, and `--headless`/`--no-headless` overrides both.
176
179
 
177
180
  ---
178
181
 
@@ -144,11 +144,14 @@ Full workflow and flags: [`doc/USAGE.md`](doc/USAGE.md).
144
144
 
145
145
  ```bash
146
146
  mcpgen login <server> # OAuth (most servers)
147
+ mcpgen login <server> --headless # no browser: print URL, paste callback URL back
147
148
  mcpgen codegen <server> --bearer "$TOKEN" --out s.py # PAT / bearer
148
149
  mcpgen codegen <server> --stdio "python server.py" --out s.py # local stdio, no auth
149
150
  ```
150
151
 
151
- Tokens persist in `~/.mcpgen/credentials.json` (chmod 0600) or your OS keystore via `--cred-backend keyring`. In code, `ensure_login(server, url=...)` refreshes silently and only opens a browser when a real login is required.
152
+ Tokens persist in `~/.mcpgen/credentials.json` (chmod 0600) or your OS keystore via `--cred-backend keyring`. In code, `ensure_login(server, url=...)` refreshes silently and only opens a browser when a real login is required; `ensure_login_all([...])` does the same for several servers, one at a time.
153
+
154
+ In containers, over SSH, or in CI there is no browser to open. `--headless` prints the authorization URL, you authorize on any device, and paste the callback URL back on stdin. It is auto-detected when neither `DISPLAY` nor `WAYLAND_DISPLAY` is set (never on macOS or Windows); `MCPGEN_HEADLESS=1`/`0` overrides the detection, and `--headless`/`--no-headless` overrides both.
152
155
 
153
156
  ---
154
157
 
@@ -79,7 +79,7 @@ uv add mcp-client-kit # or: pip install mcp-client-kit
79
79
  | `probe <server> <tool>` | Live call(s) → shape skeleton | `--args` (repeatable), `--emit-shape <path>` (writes `.parts/`) |
80
80
  | `call <server> <tool>` | One live call, raw payload to disk — bootstrap ids / inspect output | `--out <path>` (required) |
81
81
  | `merge <server>` | Consolidate `.parts/` → `<server>.shapes.json`; emit gitignored `verify.json` | `--out <path>` |
82
- | `login <server>` | Browser OAuth login | connection flags below |
82
+ | `login <server>` | Browser OAuth login | `--headless` / `--no-headless`, `--callback-timeout <seconds>`, connection flags below |
83
83
  | `migrate-creds` | Copy stored OAuth tokens between `file`/`keyring` backends | `--from`, `--to`, `--servers`, `--purge`, `--set-default` |
84
84
  | `discover` | List servers from agent hosts | `--host <id>` (repeatable), `--json` |
85
85
 
@@ -138,6 +138,47 @@ mcpgen login myserver # opens browser; token stored at ~/.mcpgen/credentials.j
138
138
 
139
139
  Re-run when you see `ReauthenticationRequired`.
140
140
 
141
+ The browser flow waits up to 300 seconds for the redirect. If you cancel on the consent
142
+ screen, many authorization servers just close the tab without redirecting back, so mcpgen
143
+ never hears anything — the wait then ends with `TimeoutError` instead of hanging. Your
144
+ existing credential is left untouched by a failed or timed-out attempt.
145
+
146
+ Adjust the bound when the default doesn't fit — a hardware token or an approve-on-phone
147
+ step can outlast it, and a scripted login may want to fail sooner:
148
+
149
+ ```bash
150
+ mcpgen login myserver --callback-timeout 900 # 15 minutes
151
+ mcpgen login myserver --callback-timeout 0 # wait forever (no bound)
152
+ ```
153
+
154
+ Negative or non-numeric values are rejected outright. Headless logins ignore the flag —
155
+ the pasted-URL prompt is never bounded. In code, the same value is `callback_timeout=` on
156
+ `login()`, `ensure_login()`, and `ensure_login_all()`.
157
+
158
+ **No browser available (container, SSH, CI):**
159
+
160
+ ```bash
161
+ mcpgen login myserver --headless
162
+ ```
163
+
164
+ mcpgen prints the authorization URL instead of opening it, and waits:
165
+
166
+ ```
167
+ Open this URL in your browser:
168
+
169
+ https://auth.example.com/authorize?response_type=code&client_id=…
170
+
171
+ After authorizing, paste the full callback URL here (http://localhost.../callback?code=...):
172
+ ```
173
+
174
+ Authorize on any device, then copy the URL from the browser's address bar — the page
175
+ itself will fail to load, which is expected, nothing is listening on that address — and
176
+ paste it at the prompt.
177
+
178
+ Headless mode is auto-detected when neither `DISPLAY` nor `WAYLAND_DISPLAY` is set (macOS
179
+ and Windows are always treated as interactive). Set `MCPGEN_HEADLESS=1` or `0` to override
180
+ the detection; an explicit `--headless` / `--no-headless` overrides both.
181
+
141
182
  **PAT / Bearer token:**
142
183
 
143
184
  ```bash
@@ -323,6 +364,10 @@ How it works:
323
364
  absent), it falls back to a full browser login — the in-code equivalent of
324
365
  `mcpgen login <server>`. Credentials are persisted at
325
366
  `~/.mcpgen/credentials.json`.
367
+ - **Several servers at once** — `await ensure_login_all(["a", "b"])` runs the same
368
+ refresh-or-login for each name in turn. Sequential on purpose: parallel logins would
369
+ open several browser tabs at once and race for stdin in headless mode. Both functions
370
+ accept `headless=True`/`False` to force the paste-the-URL or browser flow explicitly.
326
371
  - **Lower-level alternative** — skip `ensure_login` and catch
327
372
  `ReauthenticationRequired` from the first failing `.call()`, then call
328
373
  `login(SERVER, url=URL)` and retry. `ReauthenticationRequired` and `login` are
@@ -5,6 +5,7 @@ from mcpgen._bridge import (
5
5
  ReauthenticationRequired,
6
6
  delete_cred,
7
7
  ensure_login,
8
+ ensure_login_all,
8
9
  list_creds,
9
10
  login,
10
11
  migrate_creds,
@@ -17,6 +18,7 @@ __all__ = [
17
18
  "ReauthenticationRequired",
18
19
  "delete_cred",
19
20
  "ensure_login",
21
+ "ensure_login_all",
20
22
  "list_creds",
21
23
  "login",
22
24
  "migrate_creds",
@@ -48,10 +48,11 @@ import json
48
48
  import os
49
49
  import shlex
50
50
  import stat
51
+ import sys
51
52
  import time
52
53
  import warnings
53
54
  import webbrowser
54
- from contextlib import asynccontextmanager
55
+ from contextlib import asynccontextmanager, suppress
55
56
  from pathlib import Path
56
57
  from typing import Any
57
58
  from urllib.parse import parse_qs, urlparse
@@ -371,6 +372,14 @@ def _explain_registration_error(exc: OAuthRegistrationError) -> OAuthRegistratio
371
372
  # Treat a cached token as expired this many seconds before its real expiry.
372
373
  _MARGIN = 120
373
374
 
375
+ # How long the interactive login waits for the browser to hit the local callback
376
+ # server. Some authorization servers drop the user on cancel without an error
377
+ # redirect, so the callback simply never arrives — bound the wait rather than
378
+ # hang forever. Generous: it has to cover reading a consent screen and an MFA
379
+ # prompt. Headless login is not bounded — a human pasting a URL may take any
380
+ # amount of time.
381
+ _CALLBACK_TIMEOUT = 300
382
+
374
383
 
375
384
  class ReauthenticationRequired(Exception):
376
385
  """Tokens absent or refresh failed. Run: mcpgen login <server>"""
@@ -1060,6 +1069,39 @@ def parse(content_items: list) -> Any:
1060
1069
  # ---------------------------------------------------------------------------
1061
1070
 
1062
1071
 
1072
+ def _parse_callback_query(query: str) -> tuple[str | None, str | None]:
1073
+ """Extract (code, state) from an OAuth redirect query string.
1074
+
1075
+ Shared by both callback paths — the local HTTP server and the headless
1076
+ paste-the-URL prompt — so an authorization denial surfaces identically in
1077
+ either mode.
1078
+
1079
+ Raises ValueError when the authorization server returned an error.
1080
+ """
1081
+ params = parse_qs(query)
1082
+ error = params.get("error", [None])[0]
1083
+ if error:
1084
+ description = params.get("error_description", ["(no description)"])[0]
1085
+ raise ValueError(f"OAuth authorization failed: {error} — {description}")
1086
+ return params.get("code", [None])[0], params.get("state", [None])[0]
1087
+
1088
+
1089
+ def _is_headless() -> bool:
1090
+ """Return True when no interactive browser is reachable (container, no display).
1091
+
1092
+ MCPGEN_HEADLESS overrides the detection in both directions: set it to
1093
+ 1/true/yes/on to force the paste-the-URL flow, or to anything else (e.g. 0)
1094
+ to force the browser flow.
1095
+ """
1096
+ override = os.environ.get("MCPGEN_HEADLESS")
1097
+ if override is not None:
1098
+ return override.strip().lower() in ("1", "true", "yes", "on")
1099
+ # macOS/Windows: the user's own desktop — a browser is always reachable.
1100
+ if sys.platform in ("darwin", "win32"):
1101
+ return False
1102
+ return not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
1103
+
1104
+
1063
1105
  async def _local_callback_server(port: int = 0) -> tuple[int, asyncio.Future]:
1064
1106
  """Start a local HTTP server to receive the OAuth redirect. Returns (port, future)."""
1065
1107
  loop = asyncio.get_event_loop()
@@ -1068,14 +1110,20 @@ async def _local_callback_server(port: int = 0) -> tuple[int, asyncio.Future]:
1068
1110
  async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
1069
1111
  code: str | None = None
1070
1112
  state: str | None = None
1113
+ failure: ValueError | None = None
1071
1114
  try:
1072
1115
  data = await reader.read(4096)
1073
1116
  first_line = data.decode(errors="replace").split("\n")[0]
1074
1117
  path = first_line.split(" ")[1] if " " in first_line else ""
1075
- params = parse_qs(urlparse(path).query)
1076
- code = params.get("code", [None])[0]
1077
- state = params.get("state", [None])[0]
1078
- body = b"<html><body><h1>Login complete. You can close this tab.</h1></body></html>"
1118
+ try:
1119
+ code, state = _parse_callback_query(urlparse(path).query)
1120
+ except ValueError as exc:
1121
+ failure = exc
1122
+ body = (
1123
+ b"<html><body><h1>Login failed. You can close this tab.</h1></body></html>"
1124
+ if failure is not None
1125
+ else b"<html><body><h1>Login complete. You can close this tab.</h1></body></html>"
1126
+ )
1079
1127
  writer.write(
1080
1128
  b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n"
1081
1129
  + f"Content-Length: {len(body)}\r\n\r\n".encode()
@@ -1085,7 +1133,10 @@ async def _local_callback_server(port: int = 0) -> tuple[int, asyncio.Future]:
1085
1133
  finally:
1086
1134
  writer.close()
1087
1135
  if not future.done():
1088
- future.set_result((code, state))
1136
+ if failure is not None:
1137
+ future.set_exception(failure)
1138
+ else:
1139
+ future.set_result((code, state))
1089
1140
 
1090
1141
  try:
1091
1142
  server = await asyncio.start_server(_handle, "localhost", port)
@@ -1099,7 +1150,12 @@ async def _local_callback_server(port: int = 0) -> tuple[int, asyncio.Future]:
1099
1150
 
1100
1151
  async def _serve_until_done() -> None:
1101
1152
  async with server:
1102
- await future
1153
+ # The future may carry an OAuth error (see _handle). Swallow it here —
1154
+ # this task exists only to keep the socket open until the callback
1155
+ # lands; the exception is delivered to callback_handler, which awaits
1156
+ # the same future.
1157
+ with suppress(BaseException):
1158
+ await future
1103
1159
 
1104
1160
  asyncio.create_task(_serve_until_done())
1105
1161
  return actual_port, future
@@ -1113,11 +1169,19 @@ async def login(
1113
1169
  client_name: str | None = None,
1114
1170
  config_path: str | Path | None = None,
1115
1171
  cred_backend: str | None = None,
1172
+ headless: bool | None = None,
1173
+ callback_timeout: float | None = None,
1116
1174
  ) -> None:
1117
1175
  """Full browser-based OAuth login for server_name. Caches tokens + token_endpoint.
1118
1176
 
1119
1177
  url/client_name: inline overrides (no config entry needed).
1120
1178
  config_path: read the server registry from this file instead of the default search.
1179
+ headless: True prints the authorization URL and reads the pasted callback URL
1180
+ from stdin instead of opening a browser; False forces the browser flow;
1181
+ None (default) auto-detects via _is_headless().
1182
+ callback_timeout: seconds to wait for the browser redirect; None (default) uses
1183
+ _CALLBACK_TIMEOUT, and any value <= 0 waits indefinitely. Ignored in
1184
+ headless mode, where the stdin read is never bounded.
1121
1185
  """
1122
1186
  _servers = servers(config_path=config_path)
1123
1187
  server_url = url or _servers.get(server_name)
@@ -1133,17 +1197,63 @@ async def login(
1133
1197
  stashed = data.pop(server_name, None)
1134
1198
  storage._save(data)
1135
1199
 
1136
- try:
1137
- port, callback_future = await _local_callback_server()
1138
- callback_uri = f"http://localhost:{port}/callback"
1200
+ if headless is None:
1201
+ headless = _is_headless()
1202
+ timeout = _CALLBACK_TIMEOUT if callback_timeout is None else callback_timeout
1139
1203
 
1140
- async def redirect_handler(url: str) -> None:
1141
- print(f"\nOpening browser: {url}\n")
1142
- webbrowser.open(url)
1204
+ try:
1205
+ callback_future: asyncio.Future | None = None
1206
+
1207
+ if headless:
1208
+ # No local server: the redirect URI is never actually fetched, the
1209
+ # user pastes it back. Port-less keeps the registered URI stable
1210
+ # across runs, which matters for servers that pin redirect_uris.
1211
+ callback_uri = "http://localhost/callback"
1212
+
1213
+ async def redirect_handler(url: str) -> None:
1214
+ print(f"\nOpen this URL in your browser:\n\n{url}\n", file=sys.stderr, flush=True)
1215
+
1216
+ async def callback_handler() -> tuple[str, str | None]:
1217
+ print(
1218
+ "After authorizing, paste the full callback URL here (http://localhost.../callback?code=...):",
1219
+ file=sys.stderr,
1220
+ flush=True,
1221
+ )
1222
+ # run_in_executor: a bare sys.stdin.readline() would block the loop.
1223
+ loop = asyncio.get_running_loop()
1224
+ line = (await loop.run_in_executor(None, sys.stdin.readline)).strip()
1225
+ if not line:
1226
+ raise ValueError("No URL pasted — login aborted.")
1227
+ code, state = _parse_callback_query(urlparse(line).query)
1228
+ if code is None:
1229
+ raise ValueError(f"Pasted URL has no ?code= parameter: {line[:120]}")
1230
+ return code, state
1143
1231
 
1144
- async def callback_handler() -> tuple[str, str | None]:
1145
- print("Waiting for OAuth callback… (complete login in your browser)")
1146
- return await callback_future
1232
+ else:
1233
+ port, callback_future = await _local_callback_server()
1234
+ callback_uri = f"http://localhost:{port}/callback"
1235
+
1236
+ async def redirect_handler(url: str) -> None:
1237
+ print(f"\nOpening browser: {url}\n")
1238
+ webbrowser.open(url)
1239
+
1240
+ async def callback_handler() -> tuple[str, str | None]:
1241
+ print("Waiting for OAuth callback… (complete login in your browser)")
1242
+ assert callback_future is not None
1243
+ if timeout <= 0: # opt-out: wait indefinitely
1244
+ return await callback_future
1245
+ try:
1246
+ # wait_for cancels the future on timeout; _serve_until_done
1247
+ # awaits the same future under suppress(), so the background
1248
+ # task exits cleanly instead of dangling.
1249
+ return await asyncio.wait_for(callback_future, timeout)
1250
+ except TimeoutError:
1251
+ raise TimeoutError(
1252
+ f"No OAuth callback received within {timeout}s. The browser never "
1253
+ "returned to mcpgen — some authorization servers just close the tab when you "
1254
+ "cancel, without redirecting back. Retry, or use --headless to paste the "
1255
+ "redirect URL manually."
1256
+ ) from None
1147
1257
 
1148
1258
  provider = OAuthClientProvider(
1149
1259
  server_url=server_url,
@@ -1175,7 +1285,7 @@ async def login(
1175
1285
  except OAuthRegistrationError as e:
1176
1286
  raise _explain_registration_error(e) from e
1177
1287
  finally:
1178
- if not callback_future.done():
1288
+ if callback_future is not None and not callback_future.done():
1179
1289
  callback_future.set_result((None, None))
1180
1290
  await asyncio.sleep(0)
1181
1291
 
@@ -1199,6 +1309,8 @@ async def ensure_login(
1199
1309
  client_name: str | None = None,
1200
1310
  config_path: str | Path | None = None,
1201
1311
  cred_backend: str | None = None,
1312
+ headless: bool | None = None,
1313
+ callback_timeout: float | None = None,
1202
1314
  ) -> None:
1203
1315
  """Ensure a usable token exists for server_name, refreshing or logging in.
1204
1316
 
@@ -1224,6 +1336,8 @@ async def ensure_login(
1224
1336
  client_name=client_name,
1225
1337
  config_path=config_path,
1226
1338
  cred_backend=cred_backend,
1339
+ headless=headless,
1340
+ callback_timeout=callback_timeout,
1227
1341
  )
1228
1342
  return
1229
1343
  if await storage.get_tokens() is None: # first-time: no token cached at all
@@ -1234,4 +1348,32 @@ async def ensure_login(
1234
1348
  client_name=client_name,
1235
1349
  config_path=config_path,
1236
1350
  cred_backend=cred_backend,
1351
+ headless=headless,
1352
+ callback_timeout=callback_timeout,
1353
+ )
1354
+
1355
+
1356
+ async def ensure_login_all(
1357
+ server_names: list[str],
1358
+ creds_path: Path = DEFAULT_CREDS_PATH,
1359
+ *,
1360
+ config_path: str | Path | None = None,
1361
+ cred_backend: str | None = None,
1362
+ headless: bool | None = None,
1363
+ callback_timeout: float | None = None,
1364
+ ) -> None:
1365
+ """Run ensure_login() for each server, one at a time.
1366
+
1367
+ Sequential on purpose: a parallel version would open several browser tabs at
1368
+ once and race for stdin in headless mode. Servers with a valid cached token
1369
+ are silent, so the common case costs nothing.
1370
+ """
1371
+ for name in server_names:
1372
+ await ensure_login(
1373
+ name,
1374
+ creds_path,
1375
+ config_path=config_path,
1376
+ cred_backend=cred_backend,
1377
+ headless=headless,
1378
+ callback_timeout=callback_timeout,
1237
1379
  )
@@ -624,6 +624,8 @@ def _cmd_login(ns: argparse.Namespace) -> int:
624
624
  client_name=ns.client_name,
625
625
  config_path=ns.config,
626
626
  cred_backend=ns.cred_backend,
627
+ headless=ns.headless,
628
+ callback_timeout=ns.callback_timeout,
627
629
  )
628
630
  )
629
631
  return 0
@@ -714,6 +716,30 @@ def _cmd_delete_creds(ns: argparse.Namespace) -> int:
714
716
  return 0
715
717
 
716
718
 
719
+ def _add_headless_flag(p: argparse.ArgumentParser) -> None:
720
+ """Add --headless / --no-headless to a subparser (absent → None → auto-detect)."""
721
+ p.add_argument(
722
+ "--headless",
723
+ action=argparse.BooleanOptionalAction,
724
+ default=None,
725
+ help="print the auth URL and read the pasted callback URL from stdin "
726
+ "instead of opening a browser. --no-headless forces the browser flow. "
727
+ "Default: auto-detect (headless without DISPLAY/WAYLAND_DISPLAY; "
728
+ "override with MCPGEN_HEADLESS).",
729
+ )
730
+
731
+
732
+ def _callback_timeout(value: str) -> float:
733
+ """argparse type for --callback-timeout: a non-negative number of seconds."""
734
+ try:
735
+ seconds = float(value)
736
+ except ValueError:
737
+ raise argparse.ArgumentTypeError(f"expected a number of seconds, got {value!r}") from None
738
+ if seconds < 0 or seconds != seconds: # NaN never equals itself
739
+ raise argparse.ArgumentTypeError(f"must be >= 0 (0 waits forever), got {value!r}")
740
+ return seconds
741
+
742
+
717
743
  def _add_conn_args(p: argparse.ArgumentParser) -> None:
718
744
  """Inline server-connection args shared by all commands (override config)."""
719
745
  p.add_argument("--url", help="server URL inline; enables OAuth without a config entry")
@@ -834,6 +860,16 @@ def main(argv: list[str] | None = None) -> int:
834
860
  lg = sub.add_parser("login", help="browser OAuth login for a named server")
835
861
  lg.add_argument("server", help="server name (e.g. acme)")
836
862
  _add_conn_args(lg)
863
+ _add_headless_flag(lg)
864
+ lg.add_argument(
865
+ "--callback-timeout",
866
+ dest="callback_timeout",
867
+ type=_callback_timeout,
868
+ metavar="SECONDS",
869
+ help="how long to wait for the browser redirect before giving up "
870
+ f"(default: {_bridge._CALLBACK_TIMEOUT}). 0 waits forever. "
871
+ "Ignored in headless mode, where the pasted-URL prompt is never bounded.",
872
+ )
837
873
  lg.set_defaults(func=_cmd_login)
838
874
 
839
875
  mc = sub.add_parser(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-client-kit"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "mcp-client-kit — generate typed Python wrappers for any MCP server (codegen skill + CLI)."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -11,9 +11,11 @@ no pytest-asyncio dependency needed).
11
11
  from __future__ import annotations
12
12
 
13
13
  import asyncio
14
+ import io
14
15
  import json
15
16
  import os
16
17
  import stat
18
+ import time
17
19
  import warnings
18
20
  from contextlib import asynccontextmanager
19
21
  from unittest.mock import AsyncMock, MagicMock, patch
@@ -1311,3 +1313,496 @@ def test_mcp_bridge_caller_resource_link_missing_uri_stays_none():
1311
1313
  result = _run_call_with_content(_FakeResourceLinkItem(uri=None, name="x.txt"))
1312
1314
  assert result["uri"] is None
1313
1315
  json.dumps(result) # must not raise TypeError
1316
+
1317
+
1318
+ # ---------------------------------------------------------------------------
1319
+ # Headless login — callback parsing, detection, login()/ensure_login_all(), CLI
1320
+ # ---------------------------------------------------------------------------
1321
+
1322
+
1323
+ def test_parse_callback_query_returns_code_and_state():
1324
+ assert _bridge._parse_callback_query("code=abc&state=xyz") == ("abc", "xyz")
1325
+
1326
+
1327
+ def test_parse_callback_query_raises_on_error_with_description():
1328
+ with pytest.raises(ValueError) as exc:
1329
+ _bridge._parse_callback_query("error=access_denied&error_description=User+said+no")
1330
+ assert "access_denied" in str(exc.value)
1331
+ assert "User said no" in str(exc.value)
1332
+
1333
+
1334
+ def test_parse_callback_query_error_without_description():
1335
+ with pytest.raises(ValueError, match=r"\(no description\)"):
1336
+ _bridge._parse_callback_query("error=server_error")
1337
+
1338
+
1339
+ def test_parse_callback_query_missing_code_returns_nones():
1340
+ """A bare redirect (no code, no error) is not an error — the caller decides."""
1341
+ assert _bridge._parse_callback_query("") == (None, None)
1342
+
1343
+
1344
+ def test_is_headless_env_override_forces_headless(monkeypatch):
1345
+ monkeypatch.setenv("MCPGEN_HEADLESS", "1")
1346
+ with patch("sys.platform", "darwin"):
1347
+ assert _bridge._is_headless() is True
1348
+
1349
+
1350
+ def test_is_headless_env_override_forces_interactive(monkeypatch):
1351
+ monkeypatch.setenv("MCPGEN_HEADLESS", "0")
1352
+ monkeypatch.delenv("DISPLAY", raising=False)
1353
+ monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
1354
+ with patch("sys.platform", "linux"):
1355
+ assert _bridge._is_headless() is False
1356
+
1357
+
1358
+ def test_is_headless_false_on_desktop_platforms(monkeypatch):
1359
+ monkeypatch.delenv("MCPGEN_HEADLESS", raising=False)
1360
+ monkeypatch.delenv("DISPLAY", raising=False)
1361
+ monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
1362
+ for platform in ("darwin", "win32"):
1363
+ with patch("sys.platform", platform):
1364
+ assert _bridge._is_headless() is False
1365
+
1366
+
1367
+ def test_is_headless_true_on_linux_without_display(monkeypatch):
1368
+ monkeypatch.delenv("MCPGEN_HEADLESS", raising=False)
1369
+ monkeypatch.delenv("DISPLAY", raising=False)
1370
+ monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
1371
+ with patch("sys.platform", "linux"):
1372
+ assert _bridge._is_headless() is True
1373
+
1374
+
1375
+ def test_is_headless_false_on_linux_with_x11_display(monkeypatch):
1376
+ monkeypatch.delenv("MCPGEN_HEADLESS", raising=False)
1377
+ monkeypatch.setenv("DISPLAY", ":0")
1378
+ monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
1379
+ with patch("sys.platform", "linux"):
1380
+ assert _bridge._is_headless() is False
1381
+
1382
+
1383
+ def test_is_headless_false_on_linux_with_wayland_display(monkeypatch):
1384
+ monkeypatch.delenv("MCPGEN_HEADLESS", raising=False)
1385
+ monkeypatch.delenv("DISPLAY", raising=False)
1386
+ monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0")
1387
+ with patch("sys.platform", "linux"):
1388
+ assert _bridge._is_headless() is False
1389
+
1390
+
1391
+ def test_login_headless_skips_callback_server(tmp_path):
1392
+ """headless=True must not bind a socket, and must register the port-less URI.
1393
+
1394
+ The redirect URI is never fetched in this mode — the user pastes it back —
1395
+ so it stays port-less, which keeps the registered value stable across runs.
1396
+ """
1397
+ creds = tmp_path / "credentials.json"
1398
+ provider_cls = MagicMock()
1399
+ server = MagicMock()
1400
+
1401
+ async def run():
1402
+ with (
1403
+ patch("mcpgen._bridge._local_callback_server", server),
1404
+ patch("mcpgen._bridge._open_http", _fake_http_fail),
1405
+ patch("mcpgen._bridge.OAuthClientProvider", provider_cls),
1406
+ ):
1407
+ with pytest.raises(RuntimeError, match="network error"):
1408
+ await _bridge.login("acme", creds_path=creds, url="https://acme.example.com/mcp", headless=True)
1409
+
1410
+ asyncio.run(run())
1411
+ server.assert_not_called()
1412
+ metadata = provider_cls.call_args.kwargs["client_metadata"]
1413
+ assert [str(u) for u in metadata.redirect_uris] == ["http://localhost/callback"]
1414
+ assert metadata.token_endpoint_auth_method == "none"
1415
+
1416
+
1417
+ def _capture_provider_and_invoke_callback():
1418
+ """Provider factory + _open_http fake that drives the captured callback_handler.
1419
+
1420
+ _fake_http_fail short-circuits before the handler ever runs; this pair lets a
1421
+ test exercise the headless stdin path end to end inside login().
1422
+ """
1423
+ captured: dict = {}
1424
+
1425
+ def provider(**kwargs):
1426
+ captured.update(kwargs)
1427
+ return MagicMock()
1428
+
1429
+ @asynccontextmanager
1430
+ async def fake_http(*args, **kwargs):
1431
+ await captured["callback_handler"]()
1432
+ yield # unreachable
1433
+
1434
+ return provider, fake_http
1435
+
1436
+
1437
+ def test_login_headless_empty_stdin_aborts_and_restores_credential(tmp_path):
1438
+ """An empty paste aborts the login and leaves the prior credential intact."""
1439
+ creds = tmp_path / "credentials.json"
1440
+ original_entry = {"tokens": {"access_token": "orig_tok", "token_type": "bearer"}}
1441
+ creds.write_text(json.dumps({"acme": original_entry}))
1442
+ os.chmod(creds, 0o600)
1443
+
1444
+ provider, fake_http = _capture_provider_and_invoke_callback()
1445
+
1446
+ async def run():
1447
+ with (
1448
+ patch("mcpgen._bridge._open_http", fake_http),
1449
+ patch("mcpgen._bridge.OAuthClientProvider", provider),
1450
+ patch("sys.stdin", io.StringIO("")),
1451
+ ):
1452
+ with pytest.raises(ValueError, match="No URL pasted"):
1453
+ await _bridge.login("acme", creds_path=creds, url="https://acme.example.com/mcp", headless=True)
1454
+
1455
+ asyncio.run(run())
1456
+ assert json.loads(creds.read_text())["acme"] == original_entry
1457
+
1458
+
1459
+ def test_login_headless_pasted_url_error_surfaces(tmp_path):
1460
+ """A denial pasted back reaches the caller as the shared ValueError."""
1461
+ creds = tmp_path / "credentials.json"
1462
+ provider, fake_http = _capture_provider_and_invoke_callback()
1463
+
1464
+ async def run():
1465
+ with (
1466
+ patch("mcpgen._bridge._open_http", fake_http),
1467
+ patch("mcpgen._bridge.OAuthClientProvider", provider),
1468
+ patch("sys.stdin", io.StringIO("http://localhost/callback?error=access_denied\n")),
1469
+ ):
1470
+ with pytest.raises(ValueError, match="access_denied"):
1471
+ await _bridge.login("acme", creds_path=creds, url="https://acme.example.com/mcp", headless=True)
1472
+
1473
+ asyncio.run(run())
1474
+
1475
+
1476
+ def test_login_explicit_headless_false_beats_env(monkeypatch, tmp_path):
1477
+ """headless=False wins over MCPGEN_HEADLESS=1 — the argument is the top priority."""
1478
+ monkeypatch.setenv("MCPGEN_HEADLESS", "1")
1479
+ creds = tmp_path / "credentials.json"
1480
+ provider_cls = MagicMock()
1481
+
1482
+ async def run():
1483
+ with (
1484
+ patch("mcpgen._bridge._local_callback_server", _fake_callback_server_factory()),
1485
+ patch("mcpgen._bridge._open_http", _fake_http_fail),
1486
+ patch("mcpgen._bridge.OAuthClientProvider", provider_cls),
1487
+ ):
1488
+ with pytest.raises(RuntimeError, match="network error"):
1489
+ await _bridge.login("acme", creds_path=creds, url="https://acme.example.com/mcp", headless=False)
1490
+
1491
+ asyncio.run(run())
1492
+ metadata = provider_cls.call_args.kwargs["client_metadata"]
1493
+ assert [str(u) for u in metadata.redirect_uris] == ["http://localhost:9999/callback"]
1494
+
1495
+
1496
+ def test_ensure_login_all_runs_servers_in_order_with_kwargs(tmp_path):
1497
+ """Sequential by design — parallel logins would race for the browser and stdin."""
1498
+ creds = tmp_path / "credentials.json"
1499
+ calls = []
1500
+
1501
+ async def fake_ensure_login(name, creds_path=None, **kwargs):
1502
+ calls.append((name, creds_path, kwargs))
1503
+
1504
+ async def run():
1505
+ with patch("mcpgen._bridge.ensure_login", fake_ensure_login):
1506
+ await _bridge.ensure_login_all(
1507
+ ["acme", "beta"],
1508
+ creds,
1509
+ config_path="/tmp/servers.json",
1510
+ cred_backend="keyring",
1511
+ headless=True,
1512
+ )
1513
+
1514
+ asyncio.run(run())
1515
+ assert [c[0] for c in calls] == ["acme", "beta"]
1516
+ for _, creds_path, kwargs in calls:
1517
+ assert creds_path == creds
1518
+ assert kwargs == {
1519
+ "config_path": "/tmp/servers.json",
1520
+ "cred_backend": "keyring",
1521
+ "headless": True,
1522
+ "callback_timeout": None,
1523
+ }
1524
+
1525
+
1526
+ def test_ensure_login_threads_headless_into_login(tmp_path):
1527
+ """ensure_login() with no cached token must pass headless through to login()."""
1528
+ creds = tmp_path / "credentials.json"
1529
+ login_mock = AsyncMock()
1530
+
1531
+ async def run():
1532
+ with patch("mcpgen._bridge.login", login_mock):
1533
+ await _bridge.ensure_login("acme", creds, url="https://acme.example.com/mcp", headless=True)
1534
+
1535
+ asyncio.run(run())
1536
+ assert login_mock.await_args.kwargs["headless"] is True
1537
+
1538
+
1539
+ @pytest.mark.parametrize(
1540
+ ("argv_flag", "expected"),
1541
+ [(["--headless"], True), (["--no-headless"], False), ([], None)],
1542
+ )
1543
+ def test_cli_login_headless_flag(argv_flag, expected):
1544
+ """--headless / --no-headless / absent → True / False / None at _bridge.login."""
1545
+ from mcpgen.cli import main
1546
+
1547
+ login_mock = AsyncMock()
1548
+ with patch("mcpgen._bridge.login", login_mock):
1549
+ assert main(["login", "acme", *argv_flag]) == 0
1550
+ assert login_mock.await_args.kwargs["headless"] is expected
1551
+
1552
+
1553
+ def test_login_headless_pasted_url_without_code_rejected(tmp_path):
1554
+ """A URL pasted without ?code= (e.g. the bare redirect) fails loudly."""
1555
+ creds = tmp_path / "credentials.json"
1556
+ provider, fake_http = _capture_provider_and_invoke_callback()
1557
+
1558
+ async def run():
1559
+ with (
1560
+ patch("mcpgen._bridge._open_http", fake_http),
1561
+ patch("mcpgen._bridge.OAuthClientProvider", provider),
1562
+ patch("sys.stdin", io.StringIO("http://localhost/callback\n")),
1563
+ ):
1564
+ with pytest.raises(ValueError, match="no \\?code="):
1565
+ await _bridge.login("acme", creds_path=creds, url="https://acme.example.com/mcp", headless=True)
1566
+
1567
+ asyncio.run(run())
1568
+
1569
+
1570
+ def test_login_interactive_times_out_when_callback_never_arrives(tmp_path):
1571
+ """A browser that never returns must fail, not hang.
1572
+
1573
+ Some authorization servers close the tab on cancel without an error
1574
+ redirect, so no callback request ever reaches the local server and the
1575
+ future stays pending forever.
1576
+ """
1577
+ creds = tmp_path / "credentials.json"
1578
+ original_entry = {"tokens": {"access_token": "orig_tok", "token_type": "bearer"}}
1579
+ creds.write_text(json.dumps({"acme": original_entry}))
1580
+ os.chmod(creds, 0o600)
1581
+
1582
+ async def never_resolving_callback_server():
1583
+ return 9999, asyncio.get_running_loop().create_future()
1584
+
1585
+ provider, fake_http = _capture_provider_and_invoke_callback()
1586
+
1587
+ async def run():
1588
+ with (
1589
+ patch("mcpgen._bridge._local_callback_server", never_resolving_callback_server),
1590
+ patch("mcpgen._bridge._CALLBACK_TIMEOUT", 0.01),
1591
+ patch("mcpgen._bridge._open_http", fake_http),
1592
+ patch("mcpgen._bridge.OAuthClientProvider", provider),
1593
+ ):
1594
+ with pytest.raises(TimeoutError) as exc:
1595
+ await _bridge.login("acme", creds_path=creds, url="https://acme.example.com/mcp", headless=False)
1596
+ assert "--headless" in str(exc.value)
1597
+
1598
+ asyncio.run(run())
1599
+ assert json.loads(creds.read_text())["acme"] == original_entry, (
1600
+ "a timed-out login must restore the prior credential"
1601
+ )
1602
+
1603
+
1604
+ def test_login_headless_stdin_read_is_not_timed_out(tmp_path):
1605
+ """The stdin paste must not inherit the browser timeout — humans are slow."""
1606
+ creds = tmp_path / "credentials.json"
1607
+ captured: dict = {}
1608
+ result: dict = {}
1609
+
1610
+ def provider(**kwargs):
1611
+ captured.update(kwargs)
1612
+ return MagicMock()
1613
+
1614
+ @asynccontextmanager
1615
+ async def fake_http(*args, **kwargs):
1616
+ result["callback"] = await captured["callback_handler"]()
1617
+ raise RuntimeError("callback returned")
1618
+ yield # unreachable
1619
+
1620
+ async def run():
1621
+ with (
1622
+ patch("mcpgen._bridge._CALLBACK_TIMEOUT", 0.01),
1623
+ patch("mcpgen._bridge._open_http", fake_http),
1624
+ patch("mcpgen._bridge.OAuthClientProvider", provider),
1625
+ patch("sys.stdin", _SlowStdin("http://localhost/callback?code=abc&state=xyz\n")),
1626
+ ):
1627
+ with pytest.raises(RuntimeError, match="callback returned"):
1628
+ await _bridge.login("acme", creds_path=creds, url="https://acme.example.com/mcp", headless=True)
1629
+
1630
+ asyncio.run(run())
1631
+ # A TimeoutError instead would mean the stdin read inherited the browser bound.
1632
+ assert result["callback"] == ("abc", "xyz")
1633
+
1634
+
1635
+ class _SlowStdin:
1636
+ """stdin whose readline() takes longer than the patched callback timeout."""
1637
+
1638
+ def __init__(self, line: str):
1639
+ self._line = line
1640
+
1641
+ def readline(self) -> str:
1642
+ time.sleep(0.05)
1643
+ return self._line
1644
+
1645
+
1646
+ def _never_resolving_callback_server():
1647
+ async def fake_callback_server():
1648
+ return 9999, asyncio.get_running_loop().create_future()
1649
+
1650
+ return fake_callback_server
1651
+
1652
+
1653
+ def test_login_callback_timeout_overrides_the_constant(tmp_path):
1654
+ """An explicit callback_timeout wins over _CALLBACK_TIMEOUT and reaches wait_for."""
1655
+ creds = tmp_path / "credentials.json"
1656
+ provider, fake_http = _capture_provider_and_invoke_callback()
1657
+ seen: dict = {}
1658
+ real_wait_for = asyncio.wait_for
1659
+
1660
+ async def spy_wait_for(awaitable, timeout):
1661
+ seen["timeout"] = timeout
1662
+ return await real_wait_for(awaitable, timeout)
1663
+
1664
+ async def run():
1665
+ with (
1666
+ patch("mcpgen._bridge._local_callback_server", _never_resolving_callback_server()),
1667
+ patch("mcpgen._bridge._CALLBACK_TIMEOUT", 999),
1668
+ patch("mcpgen._bridge.asyncio.wait_for", spy_wait_for),
1669
+ patch("mcpgen._bridge._open_http", fake_http),
1670
+ patch("mcpgen._bridge.OAuthClientProvider", provider),
1671
+ ):
1672
+ with pytest.raises(TimeoutError, match="0.01s"):
1673
+ await _bridge.login(
1674
+ "acme",
1675
+ creds_path=creds,
1676
+ url="https://acme.example.com/mcp",
1677
+ headless=False,
1678
+ callback_timeout=0.01,
1679
+ )
1680
+
1681
+ asyncio.run(run())
1682
+ assert seen["timeout"] == 0.01
1683
+
1684
+
1685
+ def test_login_callback_timeout_zero_waits_forever(tmp_path):
1686
+ """callback_timeout=0 restores the unbounded wait — no wait_for, no timeout."""
1687
+ creds = tmp_path / "credentials.json"
1688
+ captured: dict = {}
1689
+
1690
+ def provider(**kwargs):
1691
+ captured.update(kwargs)
1692
+ return MagicMock()
1693
+
1694
+ @asynccontextmanager
1695
+ async def fake_http(*args, **kwargs):
1696
+ # The handler must still be pending well past the patched 0.01s bound.
1697
+ handler = asyncio.ensure_future(captured["callback_handler"]())
1698
+ await asyncio.sleep(0.05)
1699
+ assert not handler.done(), "callback_timeout=0 must not time out"
1700
+ handler.cancel()
1701
+ raise RuntimeError("stopped waiting")
1702
+ yield # unreachable
1703
+
1704
+ async def run():
1705
+ with (
1706
+ patch("mcpgen._bridge._local_callback_server", _never_resolving_callback_server()),
1707
+ patch("mcpgen._bridge._CALLBACK_TIMEOUT", 0.01),
1708
+ patch("mcpgen._bridge._open_http", fake_http),
1709
+ patch("mcpgen._bridge.OAuthClientProvider", provider),
1710
+ ):
1711
+ with pytest.raises(RuntimeError, match="stopped waiting"):
1712
+ await _bridge.login(
1713
+ "acme",
1714
+ creds_path=creds,
1715
+ url="https://acme.example.com/mcp",
1716
+ headless=False,
1717
+ callback_timeout=0,
1718
+ )
1719
+
1720
+ asyncio.run(run())
1721
+
1722
+
1723
+ def test_login_headless_ignores_callback_timeout(tmp_path):
1724
+ """The flag must not bound the stdin read — a human paste may take any time."""
1725
+ creds = tmp_path / "credentials.json"
1726
+ captured: dict = {}
1727
+ result: dict = {}
1728
+
1729
+ def provider(**kwargs):
1730
+ captured.update(kwargs)
1731
+ return MagicMock()
1732
+
1733
+ @asynccontextmanager
1734
+ async def fake_http(*args, **kwargs):
1735
+ result["callback"] = await captured["callback_handler"]()
1736
+ raise RuntimeError("callback returned")
1737
+ yield # unreachable
1738
+
1739
+ async def run():
1740
+ with (
1741
+ patch("mcpgen._bridge._open_http", fake_http),
1742
+ patch("mcpgen._bridge.OAuthClientProvider", provider),
1743
+ patch("sys.stdin", _SlowStdin("http://localhost/callback?code=abc&state=xyz\n")),
1744
+ ):
1745
+ with pytest.raises(RuntimeError, match="callback returned"):
1746
+ await _bridge.login(
1747
+ "acme",
1748
+ creds_path=creds,
1749
+ url="https://acme.example.com/mcp",
1750
+ headless=True,
1751
+ callback_timeout=0.01,
1752
+ )
1753
+
1754
+ asyncio.run(run())
1755
+ assert result["callback"] == ("abc", "xyz")
1756
+
1757
+
1758
+ def test_ensure_login_all_threads_callback_timeout(tmp_path):
1759
+ """callback_timeout rides along with headless through both ensure_* helpers."""
1760
+ creds = tmp_path / "credentials.json"
1761
+ calls = []
1762
+
1763
+ async def fake_ensure_login(name, creds_path=None, **kwargs):
1764
+ calls.append(kwargs)
1765
+
1766
+ async def run():
1767
+ with patch("mcpgen._bridge.ensure_login", fake_ensure_login):
1768
+ await _bridge.ensure_login_all(["acme", "beta"], creds, callback_timeout=42)
1769
+
1770
+ asyncio.run(run())
1771
+ assert [c["callback_timeout"] for c in calls] == [42, 42]
1772
+
1773
+
1774
+ def test_ensure_login_threads_callback_timeout_into_login(tmp_path):
1775
+ creds = tmp_path / "credentials.json"
1776
+ login_mock = AsyncMock()
1777
+
1778
+ async def run():
1779
+ with patch("mcpgen._bridge.login", login_mock):
1780
+ await _bridge.ensure_login("acme", creds, url="https://acme.example.com/mcp", callback_timeout=42)
1781
+
1782
+ asyncio.run(run())
1783
+ assert login_mock.await_args.kwargs["callback_timeout"] == 42
1784
+
1785
+
1786
+ @pytest.mark.parametrize(
1787
+ ("argv_flag", "expected"),
1788
+ [(["--callback-timeout", "45"], 45.0), (["--callback-timeout", "0"], 0.0), ([], None)],
1789
+ )
1790
+ def test_cli_login_callback_timeout_flag(argv_flag, expected):
1791
+ """--callback-timeout reaches _bridge.login; absent → None → module default."""
1792
+ from mcpgen.cli import main
1793
+
1794
+ login_mock = AsyncMock()
1795
+ with patch("mcpgen._bridge.login", login_mock):
1796
+ assert main(["login", "acme", *argv_flag]) == 0
1797
+ assert login_mock.await_args.kwargs["callback_timeout"] == expected
1798
+
1799
+
1800
+ @pytest.mark.parametrize("bad", ["-1", "abc", "nan"])
1801
+ def test_cli_login_callback_timeout_rejects_invalid(bad, capsys):
1802
+ """Nonsense values fail at argparse with a usage message, not deep in asyncio."""
1803
+ from mcpgen.cli import main
1804
+
1805
+ with pytest.raises(SystemExit) as exc:
1806
+ main(["login", "acme", "--callback-timeout", bad])
1807
+ assert exc.value.code == 2
1808
+ assert "--callback-timeout" in capsys.readouterr().err
@@ -535,7 +535,7 @@ cli = [
535
535
 
536
536
  [[package]]
537
537
  name = "mcp-client-kit"
538
- version = "0.3.0"
538
+ version = "0.4.0"
539
539
  source = { editable = "." }
540
540
  dependencies = [
541
541
  { name = "httpx" },
File without changes
File without changes