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.
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/CHANGELOG.md +53 -1
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/PKG-INFO +5 -2
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/README.md +4 -1
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/USAGE.md +46 -1
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/mcpgen/__init__.py +2 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/mcpgen/_bridge.py +159 -17
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/mcpgen/cli.py +36 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/pyproject.toml +1 -1
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_bridge.py +495 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/uv.lock +1 -1
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/.claude/skills/releasing-a-version/SKILL.md +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/.claude-plugin/marketplace.json +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/.claude-plugin/plugin.json +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/.github/workflows/ci.yml +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/.github/workflows/publish.yml +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/.gitignore +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/CLAUDE.md +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/LICENSE +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/CODEGEN_SKILL_IDEA.md +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/DISTRIBUTION.md +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/EXTRACTION_ANALYSIS.md +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/LANDSCAPE.md +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/MARKET_RESEARCH.md +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/RUNNING_LOCALLY.md +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/doc/VERDICT.md +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/mcpgen/codegen.py +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/mcpgen/discovery.py +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/mcpgen/seam.py +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/servers.example.json +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/skills/generate-mcp-runner/SKILL.md +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/skills/generate-mcp-runner/runner_templates/http_bearer.py +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/skills/generate-mcp-runner/runner_templates/http_oauth.py +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/skills/generate-mcp-runner/runner_templates/http_public.py +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/skills/generate-mcp-runner/runner_templates/stdio.py +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/skills/generate-mcp-wrappers/SKILL.md +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_cli_call.py +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_cli_env.py +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_cli_list.py +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_cli_merge.py +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_cli_probe.py +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_codegen.py +0 -0
- {mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/tests/test_config.py +0 -0
- {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.
|
|
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
|
+
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
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1200
|
+
if headless is None:
|
|
1201
|
+
headless = _is_headless()
|
|
1202
|
+
timeout = _CALLBACK_TIMEOUT if callback_timeout is None else callback_timeout
|
|
1139
1203
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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(
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_client_kit-0.3.0 → mcp_client_kit-0.4.0}/skills/generate-mcp-runner/runner_templates/stdio.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|