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.
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/.claude-plugin/marketplace.json +3 -3
- mcp_client_kit-0.9.0/.claude-plugin/plugin.json +13 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/CHANGELOG.md +84 -1
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/PKG-INFO +20 -1
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/README.md +19 -0
- mcp_client_kit-0.9.0/doc/EVALS.md +64 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/USAGE.md +22 -1
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/_bridge.py +257 -2
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/cli.py +1 -8
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/codegen.py +22 -4
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/pyproject.toml +1 -1
- mcp_client_kit-0.9.0/skills/generate-mcp-wrappers/SKILL.md +686 -0
- mcp_client_kit-0.9.0/skills/generate-mcp-wrappers/references/mutating-tools.md +96 -0
- mcp_client_kit-0.9.0/skills/generate-mcp-wrappers/references/probe-failures.md +168 -0
- mcp_client_kit-0.9.0/skills/generate-mcp-wrappers/references/shape-spec.md +92 -0
- mcp_client_kit-0.9.0/skills/generate-mcp-wrappers/references/subagent-execution.md +86 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_bridge.py +717 -4
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_codegen.py +38 -4
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/uv.lock +1 -1
- mcp_client_kit-0.7.0/.claude-plugin/plugin.json +0 -5
- mcp_client_kit-0.7.0/skills/generate-mcp-wrappers/SKILL.md +0 -516
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/.claude/skills/releasing-a-version/SKILL.md +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/.github/workflows/ci.yml +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/.github/workflows/publish.yml +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/.gitignore +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/CLAUDE.md +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/LICENSE +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/CODEGEN_SKILL_IDEA.md +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/DISTRIBUTION.md +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/EXTRACTION_ANALYSIS.md +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/LANDSCAPE.md +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/MARKET_RESEARCH.md +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/RUNNING_LOCALLY.md +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/doc/VERDICT.md +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/docs/superpowers/plans/2026-08-10-audit-m1-m2.md +0 -0
- {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
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/extract-changelog.sh +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/__init__.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/discovery.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/manifest.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/mcpgen/seam.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/servers.example.json +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/skills/generate-mcp-runner/SKILL.md +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/skills/generate-mcp-runner/runner_templates/http_bearer.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/skills/generate-mcp-runner/runner_templates/http_oauth.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/skills/generate-mcp-runner/runner_templates/http_public.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/skills/generate-mcp-runner/runner_templates/stdio.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/integration/__init__.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/integration/conftest.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/integration/fixtures/stdio_server.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/integration/fixtures/stdio_server_changed.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/integration/test_generated_wrapper_e2e.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/integration/test_stdio_e2e.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_auth_errors.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_call.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_check.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_codegen_manifest.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_env.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_list.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_merge.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_cli_probe.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_config.py +0 -0
- {mcp_client_kit-0.7.0 → mcp_client_kit-0.9.0}/tests/test_discovery.py +0 -0
- {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.
|
|
16
|
-
"description": "
|
|
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", "
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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")
|