mcp-client-kit 0.7.0__tar.gz → 0.9.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 (64) hide show
  1. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/.claude-plugin/marketplace.json +3 -3
  2. mcp_client_kit-0.9.0/.claude-plugin/plugin.json +13 -0
  3. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/CHANGELOG.md +84 -1
  4. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/PKG-INFO +20 -1
  5. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/README.md +19 -0
  6. mcp_client_kit-0.9.0/doc/EVALS.md +64 -0
  7. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/USAGE.md +22 -1
  8. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/_bridge.py +257 -2
  9. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/cli.py +1 -8
  10. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/codegen.py +22 -4
  11. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/pyproject.toml +1 -1
  12. mcp_client_kit-0.9.0/skills/generate-mcp-wrappers/SKILL.md +686 -0
  13. mcp_client_kit-0.9.0/skills/generate-mcp-wrappers/references/mutating-tools.md +96 -0
  14. mcp_client_kit-0.9.0/skills/generate-mcp-wrappers/references/probe-failures.md +168 -0
  15. mcp_client_kit-0.9.0/skills/generate-mcp-wrappers/references/shape-spec.md +92 -0
  16. mcp_client_kit-0.9.0/skills/generate-mcp-wrappers/references/subagent-execution.md +86 -0
  17. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_bridge.py +717 -4
  18. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_codegen.py +38 -4
  19. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/uv.lock +1 -1
  20. mcp_client_kit-0.7.0/.claude-plugin/plugin.json +0 -5
  21. mcp_client_kit-0.7.0/skills/generate-mcp-wrappers/SKILL.md +0 -516
  22. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/.claude/skills/releasing-a-version/SKILL.md +0 -0
  23. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/.github/workflows/ci.yml +0 -0
  24. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/.github/workflows/publish.yml +0 -0
  25. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/.gitignore +0 -0
  26. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/CLAUDE.md +0 -0
  27. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/LICENSE +0 -0
  28. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/CODEGEN_SKILL_IDEA.md +0 -0
  29. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/DISTRIBUTION.md +0 -0
  30. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/EXTRACTION_ANALYSIS.md +0 -0
  31. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/LANDSCAPE.md +0 -0
  32. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/MARKET_RESEARCH.md +0 -0
  33. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/RUNNING_LOCALLY.md +0 -0
  34. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/VERDICT.md +0 -0
  35. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/docs/superpowers/plans/2026-08-10-audit-m1-m2.md +0 -0
  36. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/docs/superpowers/specs/2026-08-10-audit-m1-m2-design.md +0 -0
  37. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/extract-changelog.sh +0 -0
  38. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/__init__.py +0 -0
  39. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/discovery.py +0 -0
  40. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/manifest.py +0 -0
  41. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/seam.py +0 -0
  42. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/servers.example.json +0 -0
  43. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/skills/generate-mcp-runner/SKILL.md +0 -0
  44. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/skills/generate-mcp-runner/runner_templates/http_bearer.py +0 -0
  45. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/skills/generate-mcp-runner/runner_templates/http_oauth.py +0 -0
  46. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/skills/generate-mcp-runner/runner_templates/http_public.py +0 -0
  47. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/skills/generate-mcp-runner/runner_templates/stdio.py +0 -0
  48. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/integration/__init__.py +0 -0
  49. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/integration/conftest.py +0 -0
  50. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/integration/fixtures/stdio_server.py +0 -0
  51. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/integration/fixtures/stdio_server_changed.py +0 -0
  52. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/integration/test_generated_wrapper_e2e.py +0 -0
  53. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/integration/test_stdio_e2e.py +0 -0
  54. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_auth_errors.py +0 -0
  55. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_call.py +0 -0
  56. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_check.py +0 -0
  57. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_codegen_manifest.py +0 -0
  58. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_env.py +0 -0
  59. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_list.py +0 -0
  60. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_merge.py +0 -0
  61. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_probe.py +0 -0
  62. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_config.py +0 -0
  63. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_discovery.py +0 -0
  64. {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_manifest.py +0 -0
@@ -12,13 +12,13 @@
12
12
  "name": "mcp-client-kit",
13
13
  "displayName": "mcp-client-kit",
14
14
  "source": "./",
15
- "version": "0.1.0",
16
- "description": "Generate typed Python wrappers for any MCP server (skill: generate-mcp-wrappers).",
15
+ "version": "0.3.0",
16
+ "description": "Turn any MCP server into typed Python you own — one async def per tool, return types probed from real responses, drift caught in CI. Requires the mcpgen CLI (pip install mcp-client-kit).",
17
17
  "author": {
18
18
  "name": "Sviatoslav Sviridov",
19
19
  "email": "sviridov@gmail.com"
20
20
  },
21
- "keywords": ["mcp", "codegen", "typed-wrappers", "python", "oauth"],
21
+ "keywords": ["mcp", "codegen", "python", "typed-wrappers", "oauth", "drift-detection"],
22
22
  "license": "MIT"
23
23
  }
24
24
  ]
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "mcp-client-kit",
3
+ "description": "Turn any MCP server into typed Python you own — one async def per tool, return types probed from real responses, drift caught in CI. Requires the mcpgen CLI (pip install mcp-client-kit).",
4
+ "version": "0.3.0",
5
+ "author": {
6
+ "name": "Sviatoslav Sviridov",
7
+ "email": "sviridov@gmail.com"
8
+ },
9
+ "homepage": "https://github.com/svd/mcp-client-kit",
10
+ "repository": "https://github.com/svd/mcp-client-kit",
11
+ "license": "MIT",
12
+ "keywords": ["mcp", "codegen", "python", "typed-wrappers", "oauth", "drift-detection"]
13
+ }
@@ -1,6 +1,89 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased] — 0.8.0
3
+ ## [Unreleased] — 0.10.0
4
+
5
+ ## [0.9.0] — 2026-08-27
6
+
7
+ ### Fixed
8
+
9
+ - **An optional discriminator no longer becomes mandatory.** `_render_overloaded` emitted the
10
+ discriminator with no default whether or not `inputSchema.required` listed it, so a parameter
11
+ the server treats as optional was forced on every call and forwarded even when the caller had
12
+ not chosen a value. Nothing caught it: the parameters are keyword-only, so Python accepts a
13
+ non-defaulted one after defaulted ones and the module parses cleanly — the only symptom was a
14
+ call that used to type-check and no longer did. An optional discriminator now renders an
15
+ additional overload covering its omission, which returns the variant union because the schema
16
+ cannot say which variant the server picks by default; the impl takes `| None = None` and
17
+ forwards the argument only when it is supplied. A discriminator listed in `required` renders
18
+ exactly as before.
19
+
20
+ ## [0.8.0] — 2026-08-26
21
+
22
+ ### Fixed
23
+
24
+ - **`mcpgen login` could not authenticate against a server behind bot protection.**
25
+ The OAuth handshake sent no `User-Agent` at all: `mcp.client.auth.oauth2` hand-builds
26
+ its `httpx.Request` objects and yields them from the auth flow, and httpx does not merge
27
+ client default headers into requests yielded that way — so metadata discovery and dynamic
28
+ client registration went out bare and a Cloudflare filter answered them `403 Access
29
+ Denied`, while the client's own requests succeeded. mcpgen now names itself
30
+ (`mcpgen/<version> (python-httpx)`) through a request event hook, which is the one place
31
+ that fires on both kinds of request, and on the token-refresh client's headers. A
32
+ `User-Agent` supplied in a server's configured headers still wins.
33
+
34
+ - **A caller logging in on demand for a credential that never takes prompted every time.**
35
+ `login()` reports success when the authorization server issued a token, which says nothing
36
+ about whether the store kept it or the result is usable. Two failures follow from that and
37
+ both loop: a store that holds nothing new afterwards — a backend that accepts the write
38
+ without persisting it, an entry another process keeps clearing — and a token whose
39
+ lifetime is too short to survive the freshness margin every read applies, which the next
40
+ call treats as absent. Each browser round keeps succeeding and the condition that
41
+ triggered it keeps being true. `ensure_login` now checks the store on the call that
42
+ prompted and raises `LoginWontHelp` for either, so the loop stops at one prompt.
43
+
44
+ On the `keyring` backend the "nothing new" message distinguishes a split store. A keychain
45
+ `login()`'s own storage instance could not use — a write its ACL denied, or a read that
46
+ failed there and not in the caller — flips that instance to the file mid-login, so the
47
+ token lands in `credentials.json` while every keyring
48
+ backed read — this instance and every later one, since `resolve_cred_backend` re-resolves
49
+ and `_detect_keyring` never probes a write — still consults the keychain. The verdict is
50
+ the same, because nothing will ever read that token and another browser round repeats the
51
+ split; the message names the file and the two fixes rather than claiming the token was
52
+ not kept.
53
+
54
+ The same message stops short of claiming permanence when the *caller's* keychain read is
55
+ what failed: this instance then drops to the file for the rest of its life while `login()`
56
+ may have written the keychain successfully, so it reports that a retry can find what this
57
+ check could not look at.
58
+
59
+ The freshness test is the file's own: `get_tokens` and `_pre_flight_refresh` treat a
60
+ token as absent from `expires_at - _MARGIN` onwards, so the check does too. `expires_at`
61
+ is computed from the local clock at the moment of the write, never sent by the
62
+ authorization server, so a credential failing this test is never a clock to correct.
63
+
64
+ Inside that margin the verdict splits on the lifetime the token endpoint reported, which
65
+ `_serialize_tokens` stores alongside the deadline. A lifetime at or under `_MARGIN` is
66
+ reported whatever else is cached: the token is absent from the instant it is written, and
67
+ a server handing those out on code exchange gives no reason to expect its refresh
68
+ responses differ — where they do not, accepting would renew into another margin-dead
69
+ token and open the browser on every call. A longer lifetime that the post-login check
70
+ merely spent is reported only when nothing cached can renew it;
71
+ where a refresh token, `client_id` and `token_endpoint` are all present, the next call's
72
+ pre-flight renews out-of-band and the setup works, so it is accepted. Short access tokens
73
+ paired with refresh tokens are a recommended hardening pattern, and blocking them on how
74
+ long `initialize()` and `list_tools()` happened to take would be a false alarm.
75
+
76
+ Checked on that call rather than remembered across calls, because later on the two are
77
+ indistinguishable from an ordinary expiry: a revoked grant and a login that never took
78
+ both present as "a token is present and the server refuses it". Comparing the store
79
+ against its own before-state is only possible while that state is still known. A process
80
+ that outlives its grant therefore still logs in again whenever it genuinely needs to.
81
+
82
+ A login that *raises* is untouched — a cancelled consent screen, a callback timeout and an
83
+ unpasted URL keep their own types and their retry. `mcpgen login` and a direct `login()`
84
+ call do not run the check: it belongs to the automatic path, which is the one that can
85
+ loop, and an explicit login should report what happened rather than police a store it had
86
+ no say over.
4
87
 
5
88
  ## [0.7.0] — 2026-08-25
6
89
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-client-kit
3
- Version: 0.7.0
3
+ Version: 0.9.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
@@ -98,6 +98,24 @@ Outside the block nothing changes, so existing code keeps working untouched. Det
98
98
 
99
99
  ---
100
100
 
101
+ ## Real generated output
102
+
103
+ The snippet above is an illustration. These are the actual committed files — generated end-to-end by the skill against live MCP servers, not written by hand:
104
+
105
+ | Server | Generated wrapper | Transport |
106
+ |---|---|---|
107
+ | `github` | [`github.py`](https://github.com/svd/mcp-client-kit/blob/eval/eval/github/github.py) | HTTP + bearer |
108
+ | `filesystem` | [`filesystem.py`](https://github.com/svd/mcp-client-kit/blob/eval/eval/filesystem/filesystem.py) | stdio |
109
+ | `huggingface` | [`huggingface.py`](https://github.com/svd/mcp-client-kit/blob/eval/eval/huggingface/huggingface.py) | HTTP, no auth |
110
+ | `sqlite` | [`sqlite.py`](https://github.com/svd/mcp-client-kit/blob/eval/eval/sqlite/sqlite.py) | stdio |
111
+ | `time` | [`time.py`](https://github.com/svd/mcp-client-kit/blob/eval/eval/time/time.py) | stdio |
112
+
113
+ All thirteen servers: [`eval/`](https://github.com/svd/mcp-client-kit/tree/eval/eval). Each folder also holds the `<server>.shapes.json` sidecar that produced the typed returns, and a `run.py` sample runner.
114
+
115
+ These live on a separate [`eval`](https://github.com/svd/mcp-client-kit/tree/eval) branch because the harness that produces them is its own project, not part of the shipped package — see [`doc/EVALS.md`](doc/EVALS.md).
116
+
117
+ ---
118
+
101
119
  ## Why developers pick it
102
120
 
103
121
  **Real source you own.** Importable `.py` modules — not `.pyi` stubs (mcp2py), not a runtime proxy, not a generated CLI script that needs its generator installed (`fastmcp generate-cli`), not modules that require a tool server running to work (mcpygen). You can diff it, review it, pin it, and read it in your IDE without a server running.
@@ -271,6 +289,7 @@ If you write your agent logic in Python and want generated tool wrappers you can
271
289
 
272
290
  - [`doc/USAGE.md`](doc/USAGE.md) — full end-user guide: install paths, server config, auth, the shape-spec, and calling generated wrappers.
273
291
  - [`doc/RUNNING_LOCALLY.md`](doc/RUNNING_LOCALLY.md) — run from a local clone without installing.
292
+ - [`doc/EVALS.md`](doc/EVALS.md) — generated wrappers for thirteen real MCP servers, plus the evaluation harness and report, on the `eval` branch.
274
293
 
275
294
  ## Status
276
295
 
@@ -74,6 +74,24 @@ Outside the block nothing changes, so existing code keeps working untouched. Det
74
74
 
75
75
  ---
76
76
 
77
+ ## Real generated output
78
+
79
+ The snippet above is an illustration. These are the actual committed files — generated end-to-end by the skill against live MCP servers, not written by hand:
80
+
81
+ | Server | Generated wrapper | Transport |
82
+ |---|---|---|
83
+ | `github` | [`github.py`](https://github.com/svd/mcp-client-kit/blob/eval/eval/github/github.py) | HTTP + bearer |
84
+ | `filesystem` | [`filesystem.py`](https://github.com/svd/mcp-client-kit/blob/eval/eval/filesystem/filesystem.py) | stdio |
85
+ | `huggingface` | [`huggingface.py`](https://github.com/svd/mcp-client-kit/blob/eval/eval/huggingface/huggingface.py) | HTTP, no auth |
86
+ | `sqlite` | [`sqlite.py`](https://github.com/svd/mcp-client-kit/blob/eval/eval/sqlite/sqlite.py) | stdio |
87
+ | `time` | [`time.py`](https://github.com/svd/mcp-client-kit/blob/eval/eval/time/time.py) | stdio |
88
+
89
+ All thirteen servers: [`eval/`](https://github.com/svd/mcp-client-kit/tree/eval/eval). Each folder also holds the `<server>.shapes.json` sidecar that produced the typed returns, and a `run.py` sample runner.
90
+
91
+ These live on a separate [`eval`](https://github.com/svd/mcp-client-kit/tree/eval) branch because the harness that produces them is its own project, not part of the shipped package — see [`doc/EVALS.md`](doc/EVALS.md).
92
+
93
+ ---
94
+
77
95
  ## Why developers pick it
78
96
 
79
97
  **Real source you own.** Importable `.py` modules — not `.pyi` stubs (mcp2py), not a runtime proxy, not a generated CLI script that needs its generator installed (`fastmcp generate-cli`), not modules that require a tool server running to work (mcpygen). You can diff it, review it, pin it, and read it in your IDE without a server running.
@@ -247,6 +265,7 @@ If you write your agent logic in Python and want generated tool wrappers you can
247
265
 
248
266
  - [`doc/USAGE.md`](doc/USAGE.md) — full end-user guide: install paths, server config, auth, the shape-spec, and calling generated wrappers.
249
267
  - [`doc/RUNNING_LOCALLY.md`](doc/RUNNING_LOCALLY.md) — run from a local clone without installing.
268
+ - [`doc/EVALS.md`](doc/EVALS.md) — generated wrappers for thirteen real MCP servers, plus the evaluation harness and report, on the `eval` branch.
250
269
 
251
270
  ## Status
252
271
 
@@ -0,0 +1,64 @@
1
+ # Examples and evaluations
2
+
3
+ Real wrappers that `mcpgen` generated, and the harness that produced them, live on the
4
+ [`eval`](https://github.com/svd/mcp-client-kit/tree/eval) branch of this repository.
5
+
6
+ If you want to read generated code before installing anything, start at
7
+ [`eval/`](https://github.com/svd/mcp-client-kit/tree/eval/eval).
8
+
9
+ ## What's on the `eval` branch
10
+
11
+ A separate project, `mcp-client-kit-eval`, with its own `pyproject.toml`, tests, and CLI
12
+ (`eval-kit`). It consumes `mcp-client-kit` as a local editable dependency and drives the
13
+ `generate-mcp-wrappers` skill against live MCP servers, committing whatever comes out.
14
+
15
+ Its own [`README.md`](https://github.com/svd/mcp-client-kit/blob/eval/README.md) covers
16
+ running the harness yourself.
17
+
18
+ ## Generated code examples
19
+
20
+ One folder per server under
21
+ [`eval/`](https://github.com/svd/mcp-client-kit/tree/eval/eval):
22
+
23
+ [`codegraph`](https://github.com/svd/mcp-client-kit/tree/eval/eval/codegraph) ·
24
+ [`context7`](https://github.com/svd/mcp-client-kit/tree/eval/eval/context7) ·
25
+ [`deepwiki`](https://github.com/svd/mcp-client-kit/tree/eval/eval/deepwiki) ·
26
+ [`everything`](https://github.com/svd/mcp-client-kit/tree/eval/eval/everything) ·
27
+ [`fetch`](https://github.com/svd/mcp-client-kit/tree/eval/eval/fetch) ·
28
+ [`filesystem`](https://github.com/svd/mcp-client-kit/tree/eval/eval/filesystem) ·
29
+ [`git`](https://github.com/svd/mcp-client-kit/tree/eval/eval/git) ·
30
+ [`github`](https://github.com/svd/mcp-client-kit/tree/eval/eval/github) ·
31
+ [`huggingface`](https://github.com/svd/mcp-client-kit/tree/eval/eval/huggingface) ·
32
+ [`memory`](https://github.com/svd/mcp-client-kit/tree/eval/eval/memory) ·
33
+ [`sequential-thinking`](https://github.com/svd/mcp-client-kit/tree/eval/eval/sequential-thinking) ·
34
+ [`sqlite`](https://github.com/svd/mcp-client-kit/tree/eval/eval/sqlite) ·
35
+ [`time`](https://github.com/svd/mcp-client-kit/tree/eval/eval/time)
36
+
37
+ Each holds five files:
38
+
39
+ | File | What it is |
40
+ |---|---|
41
+ | `<server>.py` | the generated wrapper module — one `async def` per tool |
42
+ | `<server>.shapes.json` | the shape-spec sidecar that turned probed responses into typed returns |
43
+ | `<server>.verify.json` | verifier output for that run |
44
+ | `run.py` | a transport-aware sample runner |
45
+ | `session-overview.md` | narrative of how the skill executed, with run metadata |
46
+
47
+ Reading `<server>.py` next to its `<server>.shapes.json` is the fastest way to see how a
48
+ probe becomes a return type. `github` is the largest example and the one exercising the most
49
+ of the lifecycle; `time` is the smallest complete one.
50
+
51
+ ## Evaluation results
52
+
53
+ [`doc/EVAL_REPORT.md`](https://github.com/svd/mcp-client-kit/blob/eval/doc/EVAL_REPORT.md) —
54
+ the aggregate matrix, per server. It is generated by `eval-kit report` and regenerates on
55
+ every run, so it is the source of truth for coverage and this page quotes no numbers from it.
56
+
57
+ ## How the branch relates to `main`
58
+
59
+ `eval` is an orphan branch: it shares no history with `main` and is never merged into it.
60
+ The two are separate projects that happen to live in one repository.
61
+
62
+ `main`'s `.gitignore` excludes `eval/`, so these artifacts cannot be committed here — which
63
+ is why the links above are absolute URLs rather than relative paths. Relative links in
64
+ GitHub markdown resolve within the branch you are reading.
@@ -431,6 +431,27 @@ How it works:
431
431
  absent), it falls back to a full browser login — the in-code equivalent of
432
432
  `mcpgen login <server>`. Credentials are persisted at
433
433
  `~/.mcpgen/credentials.json`.
434
+ - **A login that does not take is reported, not repeated.** After `login()` returns,
435
+ `ensure_login` checks that the store actually holds a new, usable credential. If it
436
+ holds nothing new — a backend that took the write without persisting it, or an entry
437
+ another process cleared — or if the token cannot survive the 120s freshness margin every
438
+ other read applies, it raises `LoginWontHelp` instead of letting the next call prompt
439
+ again. A lifetime at or under that margin is always reported — the token is absent from
440
+ the instant it is written, and accepting it risks renewing into another one just like it;
441
+ a longer one that the post-login check merely spent is reported only when the entry has
442
+ no refresh token, `client_id` and `token_endpoint` to renew with —
443
+ otherwise the next call's pre-flight refresh handles it silently. Repeating an OAuth round
444
+ that already succeeded cannot fix either. A login that *raises* is untouched, so the
445
+ retry a cancelled consent screen or callback timeout invites still works, and a normal
446
+ expiry later on still logs in normally. `mcpgen login` and a direct `login()` call do
447
+ not run this check. On the `keyring` backend it also names the one split-store case
448
+ worth telling apart: a keychain `login()`'s own instance could not use — a write its ACL
449
+ denied, or a read that failed there and not here — makes it fall back to the file, so the
450
+ new token lands somewhere keyring-backend reads never look. The message points at the file and at the two real fixes — repair the keychain
451
+ item's access control, or read from the file with `--cred-backend file` /
452
+ `MCPGEN_CRED_BACKEND=file`, which picks up the token already sitting there. Not
453
+ `migrate-creds`: it merges source over target, so copying the keychain onto the file
454
+ would overwrite the fresh token with the stale one.
434
455
  - **Several servers at once** — `await ensure_login_all(["a", "b"])` runs the same
435
456
  refresh-or-login for each name in turn. Sequential on purpose: parallel logins would
436
457
  open several browser tabs at once and race for stdin in headless mode. Both functions
@@ -679,7 +700,7 @@ accepts values outside the declared enum, the appropriate fix is to update the s
679
700
  | `ReauthenticationRequired` | Run `mcpgen login <server>` |
680
701
  | `PostLoginCheckFailed` | The token was issued and saved, but the check after login failed. Read the cause in the message — logging in again changes nothing |
681
702
  | `TokenRefreshUnavailable` | The cached grant was not renewed, and the authorization server did not name the credential as the reason — a 5xx, a transport error, a `408`/`429`, a `temporarily_unavailable`/`server_error` code, a WAF block page, a `200` that is not a token, or a code faulting the request (`invalid_request`, `unsupported_grant_type`, `invalid_scope`). The message says which, and whether to retry or fix a configuration. It also names `mcpgen login <server>` wherever the cause is ambiguous enough that a fresh registration could still be the fix |
682
- | `LoginWontHelp` | The base class of the two above. Catch it in batch code to abort on the first failure a browser round cannot fix. Also raised directly, with no subclass, when the credential store cannot be parsed *and* cannot be moved aside — see the row below |
703
+ | `LoginWontHelp` | The base class of the two above. Catch it in batch code to abort on the first failure a browser round cannot fix. Also raised directly, with no subclass, where neither subclass would be true: when the credential store cannot be parsed *and* cannot be moved aside (see the row below), and when a `login()` that returned left the store holding nothing new, or a credential that cannot outlive the freshness margin and cannot be renewed. Each of those messages names the store, or the lifetime and what is missing, and says why another browser round would change nothing |
683
704
  | `[mcpgen] error: …` on any command | Every command, not just `login`, reports an auth failure as this one-line message and exit `1` — no traceback. The text is the exception's own message, so the rows above say what to do about it. A traceback from `mcpgen` is a bug worth reporting, never an expired credential |
684
705
  | `credentials.json is not a readable credential store … moved to …` | `mcpgen login` quarantined an unparseable store (`file` backend only) and started clean. Log in again for each server you use; the `.corrupt.<epoch-ns>` file still holds the old entries if you want to salvage one by hand |
685
706
  | `… is not a readable credential store … and could not be moved aside` | The corrupt store could not be quarantined — a permission problem or a file lock. Move or delete `credentials.json` by hand, then run `mcpgen login` again |
@@ -42,8 +42,9 @@ import threading
42
42
  import time
43
43
  import warnings
44
44
  import webbrowser
45
- from collections.abc import Callable, Iterator
45
+ from collections.abc import Awaitable, Callable, Iterator
46
46
  from contextlib import AsyncExitStack, asynccontextmanager, contextmanager, suppress
47
+ from functools import lru_cache
47
48
  from pathlib import Path
48
49
  from typing import Any
49
50
  from urllib.parse import parse_qs, parse_qsl, urlparse
@@ -1340,6 +1341,194 @@ def _oauth_error_code(resp: httpx.Response) -> str | None:
1340
1341
  return codes[0].strip() or None
1341
1342
 
1342
1343
 
1344
+ def _stored_tokens(storage: FileTokenStorage) -> dict:
1345
+ """The token mapping currently in *storage*, or ``{}``.
1346
+
1347
+ Keyed off ``storage._key`` rather than a name passed alongside: the storage already
1348
+ holds the server it was built for, and a caller supplying a different one would read
1349
+ the wrong entry without saying so.
1350
+ """
1351
+ return (storage._load().get(storage._key) or {}).get("tokens") or {}
1352
+
1353
+
1354
+ def _verify_login_took(storage: FileTokenStorage, before: dict, started: float) -> None:
1355
+ """Check that a returned ``login()`` actually left a usable credential behind.
1356
+
1357
+ *started* is the wall clock immediately before ``login()``, used only to tell a write
1358
+ this login made from one that was already there.
1359
+
1360
+ ``login()`` reports success when the authorization server issued a token, which says
1361
+ nothing about whether the store kept it or the result is usable. Both failures leave
1362
+ a caller that logs in on demand prompting forever: the browser round keeps succeeding
1363
+ and the condition that triggered it keeps being true.
1364
+
1365
+ Checked here, on the call that just prompted, rather than remembered across calls. A
1366
+ later call cannot tell these apart from an ordinary expiry — a revoked grant and a
1367
+ login that never took both present as "a token is present and the server refuses it"
1368
+ — so the distinction has to be drawn while the before-state is still known.
1369
+
1370
+ Usable means what the rest of the file means by it: ``get_tokens`` and
1371
+ ``_pre_flight_refresh`` both treat a token as absent from ``expires_at - _MARGIN``
1372
+ onwards, so a credential landing inside that window is one the very next call sends
1373
+ back to the browser. Comparing against bare ``expires_at`` here would wave through
1374
+ exactly the loop this check exists to close.
1375
+
1376
+ Landing inside that window is not by itself a loop, though, and the three cases split
1377
+ on the lifetime the token endpoint reported:
1378
+
1379
+ * ``expires_in <= _MARGIN`` — the token is absent from the instant it is written.
1380
+ Reported whatever else is cached. A refresh *could* in principle renew into a longer
1381
+ lifetime, since the refresh response carries its own ``expires_in``, but a server
1382
+ handing out sub-margin tokens on code exchange has given no reason to expect its
1383
+ refresh responses differ — and accepting on that hope reopens the unbounded prompt
1384
+ loop for the server where they do not: renew, still inside the margin, ``get_tokens``
1385
+ answers ``None``, browser again, every call.
1386
+ * ``expires_in > _MARGIN``, nothing cached to refresh with — the post-login check ate
1387
+ the slack, and with no refresh token, ``client_id`` or ``token_endpoint`` the next
1388
+ call can only prompt. Also a loop.
1389
+ * ``expires_in > _MARGIN`` and refreshable — the next call's pre-flight renews
1390
+ out-of-band into a token that clears the margin. This works, and blocking it would
1391
+ hard-fail a short-lifetime-plus-refresh-token server, which is a recommended OAuth
1392
+ hardening pattern rather than a defect.
1393
+
1394
+ The two loop cases raise ``LoginWontHelp``, as does a store that holds nothing new:
1395
+ repeating an OAuth round that already worked cannot fix any of them. ``from None`` suppresses
1396
+ the ``ReauthenticationRequired`` the re-auth caller is handling when it raises — that
1397
+ cause describes the *stale* credential, not the new one being rejected here. The
1398
+ first-time caller has no active exception, where it is a harmless no-op.
1399
+ """
1400
+ entry = storage._load().get(storage._key) or {}
1401
+ after = entry.get("tokens") or {}
1402
+ if not after or after == before:
1403
+ # Read `_backend` after the load, not before: `_keyring_load` falls back to the file
1404
+ # on a read failure and flips the attribute as it goes, so a name taken earlier can
1405
+ # describe a store the value did not come from.
1406
+ if storage._backend == "keyring":
1407
+ _raise_if_login_fell_back_to_file(storage, before, after, started)
1408
+ store = "the OS keychain" if storage._backend == "keyring" else str(storage._path)
1409
+ # `_lock_backend` keeps the backend this instance resolved to, so the two differing
1410
+ # means the keychain failed a read mid-call and this instance dropped to the file.
1411
+ # `login()` resolves afresh, so it may have written the keychain after all, and the
1412
+ # verdict is then about a store nothing else will read — say so rather than assert
1413
+ # permanence a retry in a new process would contradict.
1414
+ flipped = storage._lock_backend == "keyring" and storage._backend == "file"
1415
+ closing = (
1416
+ "The OS keychain was unusable during this call and this check dropped to the "
1417
+ "file, so a retry may find the credential where this could not look."
1418
+ if flipped
1419
+ else "Logging in again would repeat a round that already succeeded."
1420
+ )
1421
+ raise LoginWontHelp(
1422
+ f"Login for {storage._key} completed, but {store} holds no new credential "
1423
+ f"afterwards. The token was issued and then not kept — a store that accepted the "
1424
+ f"write without persisting it, or another process clearing the entry. {closing}"
1425
+ ) from None
1426
+
1427
+ expires_at = after.get("expires_at")
1428
+ if expires_at is None or time.time() < expires_at - _MARGIN:
1429
+ return
1430
+
1431
+ # `_serialize_tokens` writes the two together, so a stored `expires_at` normally comes
1432
+ # with the lifetime the token endpoint reported. Absent, the margin is all there is to
1433
+ # go on, which is the refreshable question below rather than a verdict of its own.
1434
+ expires_in = after.get("expires_in")
1435
+ refreshable = _can_pre_flight_refresh(entry, after)
1436
+ if expires_in is not None and int(expires_in) <= _MARGIN:
1437
+ # Decided here rather than hedged in the text: a retry costs a browser round when
1438
+ # the entry cannot renew, which is the round the first half of the message just
1439
+ # called pointless.
1440
+ next_step = (
1441
+ "The credential was stored and can refresh, so the next call renews it "
1442
+ "out-of-band and may land on a longer lifetime — retry before logging in again."
1443
+ if refreshable
1444
+ else "Nothing cached can renew it either, so only a server that issues longer lifetimes will help."
1445
+ )
1446
+ raise LoginWontHelp(
1447
+ f"Login for {storage._key} completed, but the token endpoint reported a lifetime of "
1448
+ f"{int(expires_in)}s, inside the {_MARGIN}s margin every reader here applies — the "
1449
+ f"token counts as absent from the moment it is written, so another login would only "
1450
+ f"repeat this. {next_step}"
1451
+ ) from None
1452
+
1453
+ if refreshable:
1454
+ return # the next call's pre-flight renews out-of-band, clear of the margin
1455
+
1456
+ lifetime = f"the {int(expires_in)}s lifetime" if expires_in is not None else "the lifetime"
1457
+ raise LoginWontHelp(
1458
+ f"Login for {storage._key} completed, but {lifetime} it issued is already spent — the "
1459
+ f"post-login check ran inside it, and a token counts as absent {_MARGIN}s before its "
1460
+ f"expiry. Nothing cached can renew it: a pre-flight refresh needs a refresh_token, a "
1461
+ f"client_id and a token_endpoint, and this entry is missing at least one. Every call "
1462
+ f"could only prompt again."
1463
+ ) from None
1464
+
1465
+
1466
+ def _raise_if_login_fell_back_to_file(storage: FileTokenStorage, before: dict, after: dict, started: float) -> None:
1467
+ """Name a split store when this instance reads the keychain and login could not write it.
1468
+
1469
+ Both keychain states are needed and they are not interchangeable. *before* is what the
1470
+ file must differ from to count as a fallback write; *after* is what the keychain holds
1471
+ now, which is what the message describes. They diverge when the keychain accepted
1472
+ login's stash-pop and then refused the token save, leaving it empty while *before* is
1473
+ not — where describing it from *before* would name an entry that is no longer there.
1474
+
1475
+ ``login()`` builds its own ``FileTokenStorage``. A keychain it cannot write — an ACL
1476
+ denying writes, or a read that failed on its instance and not on this one — flips *that*
1477
+ instance to the file backend mid-login, so the new token
1478
+ lands in ``credentials.json`` while this instance — and every later instance, since
1479
+ ``resolve_cred_backend`` re-resolves and ``_detect_keyring`` never probes a write —
1480
+ goes on reading the keychain. The credential exists and nothing will ever read it.
1481
+
1482
+ So the verdict is unchanged and correct: another browser round repeats the same
1483
+ divergence. Only the diagnosis differs, and it has to, because the generic message
1484
+ says the token was not kept, which here is false in a way that sends the operator
1485
+ looking in the wrong place.
1486
+
1487
+ Silent unless the file was written during this login, so drift in ``_keyring_save``'s
1488
+ fallback costs wording rather than a verdict. The mtime is what separates a fallback
1489
+ write from the ordinary residue of ``mcpgen migrate-creds file keyring``, which keeps
1490
+ the source file (``purge`` defaults to false) — that leftover differs from the keychain
1491
+ entry as reliably as a fallback write does, and calling it one would send the operator
1492
+ to a credential months out of date.
1493
+ """
1494
+ try:
1495
+ if storage._path.stat().st_mtime < started:
1496
+ return
1497
+ file_after = (storage._file_load().get(storage._key) or {}).get("tokens") or {}
1498
+ except Exception:
1499
+ return # an unreadable file is not a diagnosis; fall through to the generic message
1500
+ if not file_after or file_after == before:
1501
+ return
1502
+ held = "still holds the old entry" if after else "holds no entry"
1503
+ raise LoginWontHelp(
1504
+ f"Login for {storage._key} completed, but the OS keychain {held} while the new token "
1505
+ f"went to {storage._path}: login could not write the keychain and fell back to the "
1506
+ f"file — which the keyring backend never reads. Logging in again would repeat "
1507
+ f"exactly that. Repair the keychain "
1508
+ f"item's access control, or read from the file instead with `--cred-backend file` "
1509
+ f"or MCPGEN_CRED_BACKEND=file, which picks up the token already there. Not "
1510
+ f"`migrate-creds`: it merges source over target, so copying the keychain onto the "
1511
+ f"file would overwrite that token with the stale one."
1512
+ ) from None
1513
+
1514
+
1515
+ def _can_pre_flight_refresh(entry: dict, tokens: dict) -> bool:
1516
+ """Whether ``_pre_flight_refresh`` has everything it needs to renew *entry*.
1517
+
1518
+ The three guards below it raise ``ReauthenticationRequired`` — a browser prompt —
1519
+ one per missing field, and it keeps those separate messages rather than calling this,
1520
+ since a caller who cannot refresh wants to know *which* piece is missing. This states
1521
+ the same condition for a caller that only needs the answer, and has to stay in step
1522
+ with them: a fourth requirement added there and not here would let
1523
+ ``_verify_login_took`` accept a credential that then prompts on every call.
1524
+ """
1525
+ return bool(
1526
+ tokens.get("refresh_token")
1527
+ and (entry.get("client_info") or {}).get("client_id")
1528
+ and entry.get("token_endpoint")
1529
+ )
1530
+
1531
+
1343
1532
  async def _pre_flight_refresh(server_name: str, storage: FileTokenStorage) -> None:
1344
1533
  """Refresh access token if near/past expiry via plain httpx (no MCP SDK).
1345
1534
 
@@ -1404,7 +1593,9 @@ async def _pre_flight_refresh(server_name: str, storage: FileTokenStorage) -> No
1404
1593
  # form-encoded regardless (GitHub's does) is handled by the form-encoded fallback in
1405
1594
  # `_oauth_error_code`.
1406
1595
  try:
1407
- async with httpx.AsyncClient() as client:
1596
+ # No auth flow runs on this client, so its default headers are enough to carry the
1597
+ # User-Agent — httpx would otherwise name itself here and nothing would name mcpgen.
1598
+ async with httpx.AsyncClient(headers={"User-Agent": _user_agent()}) as client:
1408
1599
  resp = await client.post(token_endpoint, data=payload, headers={"Accept": "application/json"})
1409
1600
  except Exception as exc: # noqa: BLE001 — the Raises contract above admits exactly two types
1410
1601
  # Not `httpx.HTTPError`: `InvalidURL` and `CookieConflict` derive from `Exception`
@@ -1512,6 +1703,44 @@ async def _pre_flight_refresh(server_name: str, storage: FileTokenStorage) -> No
1512
1703
  )
1513
1704
 
1514
1705
 
1706
+ def package_version() -> str:
1707
+ """The installed mcp-client-kit version, or ``"unknown"`` outside an installed tree."""
1708
+ from importlib.metadata import version as _pkg_version
1709
+
1710
+ try:
1711
+ return _pkg_version("mcp-client-kit")
1712
+ except Exception: # noqa: BLE001 — an uninstalled tree still has to run
1713
+ return "unknown"
1714
+
1715
+
1716
+ @lru_cache(maxsize=1)
1717
+ def _user_agent() -> str:
1718
+ """The identity mcpgen presents to a server that has none from the caller.
1719
+
1720
+ Cached because `importlib.metadata` re-walks ``sys.path`` on every lookup and the
1721
+ answer is fixed for the process. A test that fakes the version must `cache_clear()`.
1722
+ """
1723
+ return f"mcpgen/{package_version()} (python-httpx)"
1724
+
1725
+
1726
+ def _user_agent_hook(user_agent: str) -> Callable[[httpx.Request], Awaitable[None]]:
1727
+ """A request event hook that names *user_agent* on requests carrying no UA of their own.
1728
+
1729
+ Load-bearing for OAuth against a bot-protected host: ``mcp.client.auth.oauth2``
1730
+ hand-builds its ``httpx.Request`` objects and yields them from the auth flow, and httpx
1731
+ does not merge client default headers into requests yielded that way. So metadata
1732
+ discovery and dynamic registration go out with no User-Agent at all, and a Cloudflare
1733
+ WAF answers those 403 while the client's own requests — which do carry the client
1734
+ headers — succeed. A request event hook is the one place that fires on both, which is
1735
+ why the client's ``headers=`` cannot do this job alone.
1736
+ """
1737
+
1738
+ async def _ensure_user_agent(request: httpx.Request) -> None:
1739
+ request.headers.setdefault("user-agent", user_agent)
1740
+
1741
+ return _ensure_user_agent
1742
+
1743
+
1515
1744
  @asynccontextmanager
1516
1745
  async def _open_http(url: str, *, headers: dict[str, str] | None = None, auth: httpx.Auth | None = None):
1517
1746
  """Open a StreamableHTTP transport, yielding (read, write, get_session_id).
@@ -1520,7 +1749,26 @@ async def _open_http(url: str, *, headers: dict[str, str] | None = None, auth: h
1520
1749
  removed ``streamablehttp_client``) with an MCP-default httpx client so
1521
1750
  callers can still inject headers or an auth handler.
1522
1751
  """
1752
+ # A User-Agent the caller set is the identity they asked for, and it has to reach the
1753
+ # auth-flow requests too — where only the hook can put it. The caller's spelling is
1754
+ # dropped in favour of the canonical one rather than merged alongside it: the header
1755
+ # block comes out of hand-written server config, where `user-agent` is a natural way
1756
+ # to write it, and keeping both keys would put the field on the wire twice. RFC 9110
1757
+ # §5.5 makes User-Agent a singleton, and a doubled one is the sort of anomaly the bot
1758
+ # filters this hook exists for score against.
1759
+ caller = headers or {}
1760
+ caller_key = next((k for k in caller if k.lower() == "user-agent"), None)
1761
+ user_agent = caller[caller_key] if caller_key else _user_agent()
1762
+ headers = {"User-Agent": user_agent, **{k: v for k, v in caller.items() if k.lower() != "user-agent"}}
1523
1763
  async with create_mcp_http_client(headers=headers, auth=auth) as client:
1764
+ # The OAuth handshake runs on this client, so this is where the User-Agent hook
1765
+ # has to go — the headers above never reach the requests the flow hand-builds.
1766
+ # `create_mcp_http_client` takes no `event_hooks`, hence the assignment after
1767
+ # construction; it keeps whatever hooks the client already carries.
1768
+ client.event_hooks = {
1769
+ **client.event_hooks,
1770
+ "request": [*client.event_hooks.get("request", []), _user_agent_hook(user_agent)],
1771
+ }
1524
1772
  async with streamable_http_client(url, http_client=client) as streams:
1525
1773
  yield streams
1526
1774
 
@@ -2464,6 +2712,7 @@ async def ensure_login(
2464
2712
  try:
2465
2713
  await _pre_flight_refresh(server_name, storage)
2466
2714
  except ReauthenticationRequired:
2715
+ before, started = _stored_tokens(storage), time.time()
2467
2716
  await login(
2468
2717
  server_name,
2469
2718
  creds_path,
@@ -2474,8 +2723,13 @@ async def ensure_login(
2474
2723
  headless=headless,
2475
2724
  callback_timeout=callback_timeout,
2476
2725
  )
2726
+ # Only reached when login() returned: a cancelled consent screen, a callback
2727
+ # timeout and an unpasted URL propagate from the call above, and none of those
2728
+ # says the store is at fault or should cost the caller its retry.
2729
+ _verify_login_took(storage, before, started)
2477
2730
  return
2478
2731
  if await storage.get_tokens() is None: # first-time: no token cached at all
2732
+ before, started = _stored_tokens(storage), time.time()
2479
2733
  await login(
2480
2734
  server_name,
2481
2735
  creds_path,
@@ -2486,6 +2740,7 @@ async def ensure_login(
2486
2740
  headless=headless,
2487
2741
  callback_timeout=callback_timeout,
2488
2742
  )
2743
+ _verify_login_took(storage, before, started)
2489
2744
 
2490
2745
 
2491
2746
  async def ensure_login_all(
@@ -938,15 +938,8 @@ def _add_conn_args(p: argparse.ArgumentParser) -> None:
938
938
 
939
939
 
940
940
  def main(argv: list[str] | None = None) -> int:
941
- from importlib.metadata import version as _pkg_version
942
-
943
- try:
944
- _version = _pkg_version("mcp-client-kit")
945
- except Exception:
946
- _version = "unknown"
947
-
948
941
  parser = argparse.ArgumentParser(prog="mcpgen")
949
- parser.add_argument("--version", action="version", version=f"mcpgen {_version}")
942
+ parser.add_argument("--version", action="version", version=f"mcpgen {_bridge.package_version()}")
950
943
  sub = parser.add_subparsers(dest="cmd", required=True)
951
944
 
952
945
  cg = sub.add_parser("codegen", help="generate typed wrappers for a server")