mcp-client-kit 0.5.0__tar.gz → 0.7.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 (62) hide show
  1. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/.claude/skills/releasing-a-version/SKILL.md +13 -7
  2. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/.github/workflows/publish.yml +19 -0
  3. mcp_client_kit-0.7.0/CHANGELOG.md +502 -0
  4. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/CLAUDE.md +10 -2
  5. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/PKG-INFO +99 -17
  6. mcp_client_kit-0.7.0/README.md +259 -0
  7. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/doc/DISTRIBUTION.md +40 -10
  8. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/doc/USAGE.md +202 -19
  9. mcp_client_kit-0.7.0/docs/superpowers/plans/2026-08-10-audit-m1-m2.md +3817 -0
  10. mcp_client_kit-0.7.0/docs/superpowers/specs/2026-08-10-audit-m1-m2-design.md +386 -0
  11. mcp_client_kit-0.7.0/extract-changelog.sh +10 -0
  12. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/mcpgen/__init__.py +6 -0
  13. mcp_client_kit-0.7.0/mcpgen/_bridge.py +2514 -0
  14. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/mcpgen/cli.py +168 -12
  15. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/mcpgen/discovery.py +14 -5
  16. mcp_client_kit-0.7.0/mcpgen/manifest.py +246 -0
  17. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/pyproject.toml +1 -1
  18. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/skills/generate-mcp-runner/SKILL.md +28 -11
  19. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/skills/generate-mcp-runner/runner_templates/http_bearer.py +4 -1
  20. mcp_client_kit-0.7.0/skills/generate-mcp-runner/runner_templates/http_oauth.py +45 -0
  21. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/skills/generate-mcp-runner/runner_templates/http_public.py +4 -1
  22. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/skills/generate-mcp-runner/runner_templates/stdio.py +3 -0
  23. mcp_client_kit-0.7.0/tests/integration/__init__.py +0 -0
  24. mcp_client_kit-0.7.0/tests/integration/conftest.py +27 -0
  25. mcp_client_kit-0.7.0/tests/integration/fixtures/stdio_server.py +49 -0
  26. mcp_client_kit-0.7.0/tests/integration/fixtures/stdio_server_changed.py +46 -0
  27. mcp_client_kit-0.7.0/tests/integration/test_generated_wrapper_e2e.py +188 -0
  28. mcp_client_kit-0.7.0/tests/integration/test_stdio_e2e.py +110 -0
  29. mcp_client_kit-0.7.0/tests/test_bridge.py +5722 -0
  30. mcp_client_kit-0.7.0/tests/test_cli_auth_errors.py +80 -0
  31. mcp_client_kit-0.7.0/tests/test_cli_check.py +353 -0
  32. mcp_client_kit-0.7.0/tests/test_cli_codegen_manifest.py +116 -0
  33. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/tests/test_config.py +26 -0
  34. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/tests/test_discovery.py +96 -0
  35. mcp_client_kit-0.7.0/tests/test_manifest.py +309 -0
  36. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/uv.lock +1 -1
  37. mcp_client_kit-0.5.0/CHANGELOG.md +0 -188
  38. mcp_client_kit-0.5.0/README.md +0 -177
  39. mcp_client_kit-0.5.0/mcpgen/_bridge.py +0 -1399
  40. mcp_client_kit-0.5.0/skills/generate-mcp-runner/runner_templates/http_oauth.py +0 -34
  41. mcp_client_kit-0.5.0/tests/test_bridge.py +0 -1971
  42. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/.claude-plugin/marketplace.json +0 -0
  43. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/.claude-plugin/plugin.json +0 -0
  44. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/.github/workflows/ci.yml +0 -0
  45. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/.gitignore +0 -0
  46. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/LICENSE +0 -0
  47. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/doc/CODEGEN_SKILL_IDEA.md +0 -0
  48. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/doc/EXTRACTION_ANALYSIS.md +0 -0
  49. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/doc/LANDSCAPE.md +0 -0
  50. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/doc/MARKET_RESEARCH.md +0 -0
  51. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/doc/RUNNING_LOCALLY.md +0 -0
  52. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/doc/VERDICT.md +0 -0
  53. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/mcpgen/codegen.py +0 -0
  54. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/mcpgen/seam.py +0 -0
  55. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/servers.example.json +0 -0
  56. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/skills/generate-mcp-wrappers/SKILL.md +0 -0
  57. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/tests/test_cli_call.py +0 -0
  58. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/tests/test_cli_env.py +0 -0
  59. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/tests/test_cli_list.py +0 -0
  60. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/tests/test_cli_merge.py +0 -0
  61. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/tests/test_cli_probe.py +0 -0
  62. {mcp_client_kit-0.5.0 → mcp_client_kit-0.7.0}/tests/test_codegen.py +0 -0
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: releasing-a-version
3
- description: Use when releasing, cutting, or shipping a new version of mcp-client-kit — publishing to PyPI, bumping the version for release, or tagging vX.Y.Z / plugin-vX.Y.Z. Covers the engine (PyPI) and plugin-only (marketplace) flows, including the dev-branch dev0→stable→PR→tag→minor+dev sequence.
3
+ description: Use when releasing, cutting, or shipping a new version of mcp-client-kit — publishing to PyPI, bumping the version for release, or tagging vX.Y.Z / plugin-vX.Y.Z. Covers the engine (PyPI) and plugin-only (marketplace) flows, including the dev-branch devN→stable→PR→tag→minor+dev sequence.
4
4
  ---
5
5
 
6
6
  # Releasing a version
@@ -33,7 +33,7 @@ gh pr checks # CI green
33
33
 
34
34
  **2. Bump to stable**
35
35
  ```bash
36
- uv version --bump stable --dry-run # preview: 0.2.0.dev0 => 0.2.0
36
+ uv version --bump stable --dry-run # preview: 0.2.0.dev1 => 0.2.0
37
37
  uv version --bump stable # writes pyproject.toml + uv.lock
38
38
  ```
39
39
 
@@ -68,12 +68,11 @@ git push --tags # bare v* tag triggers publish.yml → PyPI
68
68
  **6. Re-advance dev**
69
69
  ```bash
70
70
  git checkout dev
71
- uv version --bump minor # 0.2.0 → 0.3.0
72
- uv version --bump dev # 0.3.0 → 0.3.0.dev0
71
+ uv version --bump minor --bump dev # 0.2.0 → 0.3.0.dev1 (one call, not two)
73
72
  # Add fresh unreleased section to CHANGELOG if not already there:
74
73
  # ## [Unreleased] — 0.3.0
75
74
  git add pyproject.toml uv.lock CHANGELOG.md
76
- git commit -m "chore: bump version to 0.3.0.dev0"
75
+ git commit -m "chore: bump version to 0.3.0.dev1"
77
76
  git push origin dev
78
77
  ```
79
78
 
@@ -90,9 +89,15 @@ Files to update:
90
89
  - `.claude-plugin/marketplace.json` → `"plugins"[0]["version"]` field only
91
90
  *(leave top-level `"version"` — that's the catalog version, bumped only when the catalog listing itself changes, not per plugin release)*
92
91
 
93
- Optionally raise the version floor in `skills/generate-mcp-wrappers/SKILL.md` — but only if the skill now requires a CLI feature from a newer engine. Two coupled spots:
92
+ Optionally raise the version floor — but only if the skill now requires a CLI feature from a newer engine. Each skill carries its own floor, and they move independently: `skills/generate-mcp-wrappers/SKILL.md` (step 0) and `skills/generate-mcp-runner/SKILL.md` (step 0). The runner's floor is the one that moves most often, because its templates `import` from `mcpgen` directly. Coupled spots per skill — grep the file for the old version, do not fix two of three:
94
93
  - Line with `mcpgen >= X.Y.Z` in the prose
95
94
  - `min=X.Y.Z` in the bash version check below it
95
+ - Any sentence naming the version in the rationale (`generate-mcp-runner` has one: "does not
96
+ exist before X.Y.Z"). A floor bump that misses it leaves the guard contradicting its reason.
97
+
98
+ **A plugin tag whose floor exceeds the latest published engine must not be cut.** The floor
99
+ promises a version users can install; publish the engine tag (`vX.Y.Z`) first, then the
100
+ plugin tag. Otherwise the guard fails with nothing to upgrade to.
96
101
 
97
102
  **2. Commit + PR → main**
98
103
  ```bash
@@ -121,7 +126,8 @@ In the `svd-agent-skills` marketplace repo, bump the `mcp-client-kit` entry's `r
121
126
  |---|---|
122
127
  | Tag on `dev` instead of `main` | Marketplace/PyPI points at a dev pre-release |
123
128
  | Tag version ≠ `pyproject.toml` version | `publish.yml` fails the tag-vs-version assertion |
124
- | Skip step 6 (re-advance dev) | dev stays at release version, no `.dev0` signal |
129
+ | Skip step 6 (re-advance dev) | dev stays at release version, no `.devN` signal |
130
+ | Split step 6 into `--bump minor` then `--bump dev` | Second call fails: `--bump dev` alone computes `0.3.0.dev1`, which sorts *below* `0.3.0`, so uv rejects it as a decrease. Both bumps must be in one call. `--bump` never produces `.dev0` — uv's dev segment starts at 1 |
125
131
  | Bump `pyproject.toml` for plugin-only | Triggers unnecessary PyPI republish |
126
132
  | Bump `marketplace.json` top-level `version` for every plugin release | Top-level is catalog metadata, not plugin version — leave it unless the catalog listing changed |
127
133
  | Raise SKILL.md floor preemptively | Floor should only move when the skill actually needs the new engine feature |
@@ -12,6 +12,7 @@ jobs:
12
12
  environment: pypi # must match the PyPI Trusted Publisher config
13
13
  permissions:
14
14
  id-token: write # OIDC token for Trusted Publishing (no API token needed)
15
+ contents: write # create the GitHub Release for the tag
15
16
  steps:
16
17
  - uses: actions/checkout@v4
17
18
 
@@ -34,3 +35,21 @@ jobs:
34
35
 
35
36
  - name: Publish to PyPI (Trusted Publishing)
36
37
  run: uv publish --trusted-publishing always
38
+
39
+ # Notes come from CHANGELOG.md, never a commit list. No build artifacts are
40
+ # attached: PyPI is the single source of truth for distribution.
41
+ - name: Publish GitHub Release
42
+ env:
43
+ GH_TOKEN: ${{ github.token }}
44
+ run: |
45
+ version="${GITHUB_REF_NAME#v}"
46
+ ./extract-changelog.sh "$version" > notes.md
47
+ if [ ! -s notes.md ]; then
48
+ echo "No CHANGELOG.md section for $version"; exit 1
49
+ fi
50
+ printf '\n---\n\nInstall: `uv add mcp-client-kit==%s` · [PyPI](https://pypi.org/project/mcp-client-kit/%s/)\n' \
51
+ "$version" "$version" >> notes.md
52
+ gh release create "$GITHUB_REF_NAME" \
53
+ --title "$GITHUB_REF_NAME" \
54
+ --notes-file notes.md \
55
+ --latest --verify-tag
@@ -0,0 +1,502 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased] — 0.8.0
4
+
5
+ ## [0.7.0] — 2026-08-25
6
+
7
+ ### Fixed
8
+
9
+ - **Two mcpgen processes writing credentials at the same time silently dropped one of the
10
+ updates.** The store is a single JSON document keyed by server, so every write is a
11
+ read-modify-write of the *whole* file: a process touching one server saves everyone's
12
+ entry. With the read outside any lock, the second writer's snapshot predates the first
13
+ writer's `os.replace`, and saving it puts the stale view back — the first server's
14
+ freshly-issued token is gone, with no error anywhere, and its next run goes to the
15
+ browser. Two halves needed closing. Corruption: both `FileTokenStorage._file_save()` and
16
+ the client-config writer staged through a fixed `.tmp` path, so two processes wrote into
17
+ one partial file; the staging name now carries the pid, matching the convention already
18
+ used for generated files. Lost updates: whole-store cycles now run under an advisory lock —
19
+ a sidecar `credentials.json.lock`, `flock` on POSIX and `msvcrt.locking` on Windows —
20
+ and, critically, **re-read the store inside the lock**, so no write is ever built on a
21
+ snapshot older than the lock it is under. `delete_cred` was the sharpest edge: it clears
22
+ the entire store when its view says the entry it removed was the last one, so a login
23
+ landing in that window had its brand-new credential unlinked by a command that was never
24
+ asked to touch it. Also covered: `set_tokens`, `set_client_info`, the token-endpoint
25
+ cache, `login()`'s stash and restore, and `migrate-creds` — whose merge and purge are now
26
+ one operation rather than four races. Both primitives are released by the OS when the
27
+ holder's descriptor closes, so a killed process leaves nothing stale to break or time out.
28
+
29
+ The lock set follows the *backend*, not the path — a fix covering only the file backend
30
+ would have left half the surface open. The keyring is one global item, and `--creds` is
31
+ documented as ignored there, so two keyring processes carrying different paths would
32
+ otherwise lock different sidecars around the same document and exclude nothing. The
33
+ keyring backend therefore takes a fixed `~/.mcpgen/keyring.lock` *and* the path sidecar,
34
+ always in that order (the second is not redundant: a keyring failure falls back to
35
+ writing the file, and that write has to be covered too).
36
+
37
+ Three visible consequences. Keyring-only users now get empty sidecar lock files (0600,
38
+ holding nothing) under `~/.mcpgen`, the first files mcpgen writes there on that backend.
39
+ A lock that cannot be created or acquired reports on stderr and proceeds *unlocked*
40
+ rather than failing the operation — no lock is a lost update under concurrency, while
41
+ raising would be a failed credential write on a path that worked before (it is not a
42
+ `warning`, so `-W error` cannot turn that degrade back into the failure it avoids). And
43
+ the lock is taken before the read that decides whether anything needs writing — that
44
+ ordering *is* the fix — so deleting an absent credential, or migrating an empty source,
45
+ now leaves a directory and an empty lock file where both were previously filesystem
46
+ no-ops. Taking a lock never changes the permissions of a directory it did not create;
47
+ saving credentials still hardens the directory to 0700, as it always has.
48
+
49
+ Three limits. `login()` does **not** hold the lock across the browser round — one person
50
+ clicking through a consent screen would stall every other mcpgen process for as long as
51
+ they take — so two concurrent logins for the *same* server remain last-writer-wins; the
52
+ restore's re-check bounds the damage but does not remove it. The lock is advisory and
53
+ mcpgen-to-mcpgen: another program writing the same keyring entry is not coordinated, and
54
+ cannot be. And on Windows the primitive is `msvcrt.locking(LK_LOCK)`, which is not a
55
+ blocking acquire but a bounded one — about ten retries a second apart, then `OSError`,
56
+ which is the "cannot be acquired" case above and therefore proceeds unlocked with a line
57
+ on stderr. Contention lasting longer than ten seconds there is the pre-fix behaviour, and
58
+ `migrate-creds` is the one operation that holds the lock long enough to reach it. POSIX
59
+ has no such ceiling: `flock` waits. The 0600 sidecars are likewise a POSIX statement —
60
+ the mode bits are written on Windows too, and NTFS ACLs are what actually decide.
61
+
62
+ - **A credential that arrived during a token refresh could be overwritten by the refresh
63
+ that was already in flight.** No lock can span this one: `_pre_flight_refresh` reads a
64
+ refresh token, `await`s an HTTP round-trip, and writes what comes back. A login landing
65
+ inside that window was replaced by a response chained to the refresh token the request
66
+ had *started* from — and where the authorization server rotates refresh tokens, that
67
+ chain is already invalid, so the overwrite cached a dead credential and sent the next run
68
+ to the browser. It now compares and sets: the response is stored only while the store
69
+ still holds the refresh token it was derived from. A credential deleted meanwhile is not
70
+ resurrected either, for the same reason. `login()`'s restore does the same check over its
71
+ own, much longer window.
72
+
73
+ - **A refresh response that left out `refresh_token` erased the stored one, so the next
74
+ expiry had nothing to refresh with.** RFC 6749 §6 makes that member optional in a
75
+ refresh response and says to discard the old token only when a *new* one is issued;
76
+ Google's token endpoint omits it. mcpgen replaced the whole cached entry with whatever
77
+ came back, so on a server that does not rotate, the refresh token vanished on the first
78
+ successful refresh and every token lifetime after that ended in a browser prompt.
79
+ Both writers now carry the stored refresh token forward when the response has none.
80
+ A rotated one still replaces it, as §6 requires; revocation is unaffected, because a
81
+ revoked grant arrives as `invalid_grant` on next use, which is already read as dead.
82
+
83
+ - **A brief outage at the authorization server sent every run to the browser for a login
84
+ that could not fix it.** `_pre_flight_refresh()` turned *any* non-200 from the token
85
+ endpoint into `ReauthenticationRequired`, which `ensure_login()` converts straight into a
86
+ browser prompt — so a `502`, a `503`, a `429`, or a Cloudflare error page in front of the
87
+ authorization server produced an interactive re-login that then asked the same unreachable
88
+ host for a token. Across a batch that is one impossible prompt per item. `httpx` transport
89
+ errors escaped unclassified entirely, so callers could not branch on them at all. This is
90
+ the 0.6.0 `login()` bug one layer up: there a completed login was discarded, here a live
91
+ refresh token was declared dead because the endpoint that would renew it was briefly down.
92
+ The failure is now classified by who answered and what they faulted, read from the RFC
93
+ 6749 §5.2 JSON `error` body rather than the status. The grant is treated as dead for
94
+ exactly three codes — `invalid_grant` (the refresh token) and `invalid_client` /
95
+ `unauthorized_client` (the registration, which `login()` replaces by dropping the cached
96
+ `client_info` and re-running dynamic client registration) — on whatever status they
97
+ arrive with, which covers a non-compliant server reporting a revoked grant as `403`.
98
+ Everything else raises the new `TokenRefreshUnavailable` and leaves the refresh token
99
+ untouched: a `5xx`, a transport error, a `408` from a proxy that never passed the request
100
+ on, a `429`, a `temporarily_unavailable` or `server_error` code, a WAF block page, a `3xx`
101
+ to a captive portal, a `404` from a moved endpoint, a `200` whose body is not a token, and
102
+ the codes that fault the request rather than the credential (`invalid_request`,
103
+ `unsupported_grant_type`, `invalid_scope`) — for those, a browser round produces a fresh
104
+ token and then resends the identical bad request. §5.2 requires the `error` body on every
105
+ `400` and `401`, so a bare or HTML one is a proxy rather than the authorization server.
106
+ Nothing that stays out of the browser path
107
+ is a dead end: every message whose cause is ambiguous enough that a fresh registration
108
+ could still be the fix names `mcpgen login <server>` as the manual next step. A `200` is
109
+ decided by whether it parses as a token, so a server that pads a good response with a
110
+ blank `error` member still refreshes, and one that reports failure in-band with a `200`
111
+ (Slack's rotation endpoint) is still classified rather than swallowed.
112
+
113
+ - **A refresh response that failed to parse printed the token it contained to stderr.** A
114
+ `200` from a token endpoint *is* the token response, so the near-misses that fail
115
+ validation — a `token_type` outside the `Bearer` literal, a non-integer `expires_in`, a
116
+ rotation response with no `access_token` — carry a live `access_token` and
117
+ `refresh_token`. The error naming that failure is printed by `mcpgen login` and by the
118
+ `generate-mcp-runner` template, i.e. into CI logs. Response bodies quoted in an error now
119
+ go through a redaction pass that drops `access_token`, `refresh_token`, `id_token`, and
120
+ `client_secret` values in both JSON and form-encoded bodies, at any nesting depth and
121
+ whatever casing or word separator the responder spelled them with — `accessToken` from a
122
+ serializer left on its defaults and `access-token` from a kebab-case house style are the
123
+ same credential as the RFC's spelling, and the responders that reach this code are exactly
124
+ the ones re-serialising through a convention of their own — Slack wraps the token
125
+ in `authed_user`, and a body a proxy truncated mid-response no longer parses as JSON at all
126
+ yet still carries one — and the pydantic error — which quotes the
127
+ whole input back on a `missing` field — is reported by type rather than by message.
128
+ Everything else in the body is kept: `error`, `error_description`, and block-page text are
129
+ what make the message worth printing.
130
+
131
+ Redacting the message was not enough on its own, because the two raises on that path
132
+ chained the pydantic error with `from exc`, and a chain travels with the exception to
133
+ every caller. Only `mcpgen login` caught these types; on every other command the
134
+ interpreter printed the whole chain — including the quoted body — to stderr, which is the
135
+ CI log this redaction exists for. Both now raise `from None`. What that costs is the
136
+ per-field pydantic detail, and the redacted body already shows the offending non-secret
137
+ members; what it buys applies to library callers and the generated runner too, neither of
138
+ which any CLI-side catch could have covered. (pydantic truncates the quoted value, so what
139
+ used to escape was a prefix of the credential — a smaller leak, not a different one, and a
140
+ short secret escaped whole.)
141
+
142
+ - **The same credential leak was open on the login path, where the SDK does the parsing.**
143
+ Closing it in `_pre_flight_refresh` covered the refresh only. `login()`'s post-login check
144
+ goes through the MCP SDK, which reports a token or registration response that fails
145
+ validation as `OAuthTokenError(f"Invalid token response: {pydantic_error}")` — and that
146
+ text quotes the rejected body back as a *Python repr*, single-quoted. Neither existing
147
+ pattern could see it: one needs a double quote, the other an `=`. `_describe()` put it
148
+ straight into `PostLoginCheckFailed`, and `cli.py` printed that to stderr, so a gateway
149
+ that camelCased its members turned a failed post-login check into a live refresh token in
150
+ the CI log. Registration is the same shape with a longer-lived secret — RFC 7591 responses
151
+ carry `client_secret`, which never expires on its own.
152
+
153
+ Redaction is now one helper over four spellings, shared by `_describe()` and
154
+ `_body_excerpt()` so they cannot drift, and both raise sites drop the chain. The fourth
155
+ pattern drops pydantic's `input_value=` frame whole rather than scrubbing it member by
156
+ member, because member-wise redaction is unreliable there by construction: pydantic
157
+ truncates the quoted repr in the middle, and the cut lands mid-*key* as readily as
158
+ mid-value — `{'accessToken': 'SECRET1'...efreshToken': 'SECRET2'}` is real output, where
159
+ the second key has lost the `r` that any pattern would match on and its value is intact.
160
+ Where the cut falls depends on the total length, which the server controls, so anything
161
+ key-anchored closes one offset and leaves the rest. What survives the frame is what was
162
+ ever diagnostic: the field name, the error type, `input_type`. That fourth pattern is
163
+ scoped to *our own* exception messages and does not run over response bodies — a body
164
+ never carries a frame this module produced, and an authorization server that puts
165
+ `str(validation_error)` in its own `error_description` would otherwise lose the rest of
166
+ that line to a pattern with nothing to do there.
167
+
168
+ - **Only `mcpgen login` handled an auth failure; every other command printed a traceback.**
169
+ `codegen`, `probe`, `call`, `list` and `check` caught `(FileNotFoundError, ValueError)`,
170
+ and the auth taxonomy is neither — so an expired credential or an unreachable token
171
+ endpoint, both routine, ended in a stack trace. `main()` now catches the two roots,
172
+ `ReauthenticationRequired` and `LoginWontHelp`, prints the message and exits 1. Catching
173
+ the roots rather than widening each command's `except` covers the subclasses and whatever
174
+ command is added next. It stays narrow deliberately: a `KeyError` from a real defect must
175
+ still reach the interpreter, so it is reported as a bug rather than dressed up as an
176
+ operational condition. `login`'s own handler runs first and keeps its wording.
177
+
178
+ - **A form-encoded rejection was never classified, so a dead grant on GitHub-style servers
179
+ never prompted for a login.** The refresh request now sends `Accept: application/json`,
180
+ which is what most servers need to answer in the shape §5.2 describes — but `Accept` is a
181
+ request, not a guarantee, and a server that answers form-encoded anyway (GitHub's token
182
+ endpoint does by default) still presented every rejection, `invalid_grant` included, as a
183
+ body with no OAuth error code in it: an unidentified proxy response, permanently
184
+ unrecoverable for a headless caller. The error code is now also read out of a body
185
+ *labelled* `application/x-www-form-urlencoded`. The label is the whole point: an HTML block
186
+ page containing the text `error=invalid_grant` is a body the authorization server did not
187
+ send, and scraping it would manufacture the speaker evidence the classification turns on.
188
+ An unlabelled form body therefore stays unclassified, which is what it did before, so the
189
+ fallback adds no new way to be wrong. A body that parses as JSON is never re-read as a
190
+ form, and a form body carrying `error` twice, or empty, is treated as carrying none —
191
+ picking a winner would be a guess about which half the server sent.
192
+
193
+ - **A credential store that broke mid-login replaced the error the operator needed to
194
+ see.** `login()`'s `except BaseException` handler re-reads storage to decide whether the
195
+ flow got far enough to save a token; a corrupt `credentials.json` or a keyring backend
196
+ that started failing raised from inside that handler, discarding the original transport
197
+ error. An unreadable store is now treated as "nothing can be said about what was
198
+ produced": the original failure propagates, and the previous credential — removed from
199
+ disk when the flow began — is not written back, which means it is lost. That is the
200
+ accepted half of the trade, not an oversight: writing blind onto a store that cannot be
201
+ read risks clobbering whatever the unreadable bytes still hold, and when the choice is
202
+ between one credential and every other server's, the store wins. The restore that follows
203
+ it is guarded the same way —
204
+ a store that refuses the *write* (a read-only filesystem, a keyring that has started
205
+ refusing) would otherwise mask the original failure through the adjacent door.
206
+
207
+ - **A corrupt credential store broke the very command that repairs it.** `mcpgen login`
208
+ reads the store before it writes, and that read was bare, so unparseable JSON — a write
209
+ interrupted by a SIGKILL, a hand-edit gone wrong — met the one command whose job is
210
+ producing a fresh entry with a raw `JSONDecodeError` and no route back but deleting the
211
+ file. On the `file` backend `login()` now moves the unreadable file aside as
212
+ `credentials.json.corrupt.<epoch-ns>`, says so on stderr, and starts from an empty store.
213
+ The bad bytes are kept because they hold the other servers' entries; falling through to an
214
+ empty store without them would let the next save erase credentials that were still
215
+ recoverable by hand. If the file cannot be moved aside either — a permission problem, a
216
+ lock — `login()` raises `LoginWontHelp` and stops, for the same reason: continuing would
217
+ save an empty store over the bytes the quarantine exists to keep. It is the bare base class
218
+ deliberately, since no subclass fits a store that was never read, and both the CLI and the
219
+ generated runner already catch the base — so it reaches the user as a message rather than a
220
+ traceback. All of this is scoped to `login()`: the other readers run with nobody at the
221
+ keyboard, and there "start fresh" is not theirs to decide. (A keyring blob that will not
222
+ parse is a different path: `_keyring_load` already falls back to the file store with a
223
+ warning, and nothing is quarantined.)
224
+
225
+ A store that parses into something *other* than an object — `[]`, `null`, a bare string,
226
+ the other plausible outcome of a hand-edit gone wrong — reached the same dead end one door
227
+ over: it survived the parse and died two lines later inside `data.pop(...)` as a raw
228
+ `TypeError`. Both credential readers now reject a non-object store where they read it,
229
+ with the same `JSONDecodeError` unparseable bytes already raise, so the quarantine covers
230
+ it unchanged and `get_tokens`/`get_client_info` fail with a sentence rather than an
231
+ `AttributeError`. Who *raises* widened; who *quarantines* did not.
232
+
233
+ - **A wide exception group could still produce an unreadable "one-line" error.**
234
+ `_describe()` capped each leaf but not their number, so an N-leaf `BaseExceptionGroup`
235
+ rendered as N × ~215 characters on a single CLI line. The joined result is now capped too.
236
+
237
+ ### Added
238
+
239
+ - **`LoginWontHelp`**, exported from `mcpgen` — the base class for every auth failure
240
+ another browser round cannot fix. `PostLoginCheckFailed` and the new
241
+ `TokenRefreshUnavailable` both inherit from it, so batch callers catch one type and abort
242
+ instead of tracking a growing list. `ReauthenticationRequired` deliberately stays outside
243
+ it: there the browser *is* the fix, and folding it in would make one `except` clause
244
+ swallow both answers.
245
+
246
+ - **`TokenRefreshUnavailable`**, exported from `mcpgen`. Raised when a cached grant was not
247
+ renewed for any reason other than the authorization server naming the credential dead.
248
+ The refresh token is untouched in every one of them, so the browser has nothing to
249
+ replace; the message says which case it was and whether retrying is the move.
250
+
251
+ ### Changed
252
+
253
+ - **A token endpoint that rejects a dead grant with a bare `400`/`401` and no JSON `error`
254
+ body no longer triggers an automatic browser login.** This is the most visible change
255
+ here, and it is a deliberate trade. RFC 6749 §5.2 requires that body on exactly those two
256
+ statuses, so a bare or HTML one is far more often a proxy, a gateway, or a WAF than the
257
+ authorization server — and the old behaviour opened a browser that met the same block,
258
+ once per item across a batch. Servers that violate §5.2 lose the automatic prompt: they
259
+ now raise `TokenRefreshUnavailable`, whose message names `mcpgen login <server>` as the
260
+ manual route. If you hit this against a real authorization server, that command still
261
+ recovers it in one step.
262
+
263
+ - **A refresh-time `502` no longer raises `ReauthenticationRequired`.** Consumers following
264
+ `doc/USAGE.md` catch only that type today, so their handler stops firing for this case —
265
+ which is the point, since it opened a browser that could not help. Catching
266
+ `LoginWontHelp` alongside it is the one-line migration; `mcpgen login` and the
267
+ `generate-mcp-runner` OAuth template already do.
268
+
269
+ ## [0.6.0] — 2026-08-24
270
+
271
+ ### Fixed
272
+
273
+ - **A completed login was thrown away whenever the server failed right after it, turning a
274
+ transient outage into an endless browser re-prompt.** `login()` stashed the existing
275
+ credential, ran the OAuth flow, then smoke-tested the result with `initialize()` +
276
+ `list_tools()` — with the whole block under one `except BaseException` that restored the
277
+ stash. But the SDK's `OAuthClientProvider` saves the exchanged token from inside the auth
278
+ handshake, before `initialize()` returns, so any failure after that point rolled a valid
279
+ new token back to the stale one. A `502` from the resource server was indistinguishable
280
+ from "your login failed", and nothing was ever cached, so the next run re-prompted — one
281
+ reported batch produced 16 completed browser logins, all discarded, and no useful work.
282
+ The restore is now conditional: it fires only when the flow produced no token at all. When
283
+ a token *was* issued it is kept, together with the token endpoint needed to refresh it later,
284
+ and the failure surfaces as the new `PostLoginCheckFailed` rather than the raw transport
285
+ error. A login that fails *before* the token exchange — a cancelled consent screen, a
286
+ callback timeout — still restores the previous credential whole, `client_info` included:
287
+ pairing a freshly registered `client_id` with a refresh token the authorization server
288
+ issued to the *old* client would break the refresh the restore exists to preserve.
289
+
290
+ - **Servers that publish no OAuth discovery document re-prompted at every token expiry.**
291
+ The token endpoint was cached only when discovery returned metadata, so for those servers
292
+ `login()` stored a token with no way to renew it, and the next pre-flight refresh had no
293
+ choice but to demand a new browser login — every hour, forever. The endpoint now comes from
294
+ the SDK's own resolution, which covers the no-discovery case with its `<origin>/token`
295
+ fallback, so the cached URL is the one that demonstrably just issued the token rather than a
296
+ guess.
297
+
298
+ ### Added
299
+
300
+ - **`PostLoginCheckFailed`**, exported from `mcpgen`. The counterpart to
301
+ `ReauthenticationRequired`, and deliberately not named for a cause: the token was issued
302
+ and cached, which says nothing about whether the resource server accepted it. A `502` from
303
+ the origin, a post-login `401` over scope or audience, and an MCP-level error from
304
+ `list_tools()` all raise it; what they share is that another browser round cannot fix them.
305
+ Batch callers can now abort on the first failure instead of walking a user through one
306
+ browser prompt per item. `mcpgen login` reports it as a single `[login] error: …` line —
307
+ naming the underlying cause, which anyio otherwise hides behind "unhandled errors in a
308
+ TaskGroup (1 sub-exception)" — and exits `1`. User interrupts (`KeyboardInterrupt`,
309
+ `SystemExit`, cancellation) are never relabelled, even when a task group wraps them.
310
+
311
+ ### Changed
312
+
313
+ - **Generated OAuth runners now stop on `PostLoginCheckFailed` instead of printing a
314
+ traceback.** The `generate-mcp-runner` skill's HTTP + OAuth template called `ensure_login()`
315
+ bare, so the one place mcpgen writes caller code did not demonstrate the contract it
316
+ documents. It now prints the message and exits `1` — the failure is not something a rerun,
317
+ or another trip through the browser, can clear.
318
+
319
+ ## [0.5.0] — 2026-08-09
320
+
321
+ ### Fixed
322
+
323
+ - **A caller-supplied credentials path was silently ignored once past login, so tokens were
324
+ written to one file and read from another.** `login()` and `ensure_login()` accepted
325
+ `creds_path` and honoured it, but the call path never did: `_http_session()` built its
326
+ `FileTokenStorage` on the default `~/.mcpgen/credentials.json`, and neither `session()` nor
327
+ `McpBridgeCaller` even accepted the parameter, so there was no way to route a call to the file
328
+ the login had just written. Anyone keeping tokens outside the shared store — a project-local
329
+ credentials file, two identities side by side, a test fixture — logged in successfully and then
330
+ got `ReauthenticationRequired` on the very next call, in a loop, with both files on disk looking
331
+ plausible. `creds_path` now flows through `_http_session()`, `session()`, and
332
+ `McpBridgeCaller`, defaulting to `DEFAULT_CREDS_PATH` when omitted, so existing behaviour is
333
+ unchanged. The stdio, bearer, static-header, and raw-URL transports do not take it: they store
334
+ no credentials at all, and a parameter that silently does nothing is worse than its absence.
335
+
336
+ ### Added
337
+
338
+ - **`--creds PATH`** on every command that touches stored credentials — `codegen`, `list`,
339
+ `probe`, `call`, `login`, and the management trio `list-creds`, `delete-creds`,
340
+ `migrate-creds` — the CLI surface for the fix above. `mcpgen login` previously dropped the
341
+ path entirely, having no flag to drop it from; it now logs in to exactly the file the
342
+ subsequent calls will read. The management commands were the other half of the same gap: all
343
+ three already took a `credentials_path` internally but exposed no way to set it, so after a
344
+ `--creds` login `list-creds` reported an empty store, `delete-creds` deleted nothing while
345
+ saying so, and `migrate-creds` migrated the wrong file. A flag that exists on half the
346
+ commands that read a file is worse than no flag at all — it makes the other half look broken.
347
+
348
+ - **`DEFAULT_CREDS_PATH` is exported from `mcpgen`** — consumers that thread a credentials path
349
+ through their own CLI need the default as a value. Re-deriving
350
+ `Path.home() / ".mcpgen" / "credentials.json"` downstream would silently drift the day mcpgen
351
+ changes it.
352
+
353
+ ## [0.4.0] — 2026-08-09
354
+
355
+ ### Added
356
+
357
+ - **Headless OAuth login — `mcpgen login <server> --headless`** — logging in no longer requires
358
+ a browser on the machine running the command. Instead of opening a browser and binding a local
359
+ callback server, mcpgen prints the authorization URL to stderr, you authorize on any device,
360
+ and paste the resulting callback URL back on stdin. This is the only workable flow inside
361
+ containers, over SSH, and in CI, where `webbrowser.open()` silently does nothing and the
362
+ callback port is unreachable from wherever the browser actually runs. The redirect URI
363
+ registered in headless mode is port-less (`http://localhost/callback`) — nothing ever fetches
364
+ it, and keeping it free of an ephemeral port makes the registered value stable across runs for
365
+ servers that pin `redirect_uris`. Without the flag mcpgen auto-detects: macOS and Windows are
366
+ always treated as interactive, other platforms are headless when neither `DISPLAY` nor
367
+ `WAYLAND_DISPLAY` is set. `MCPGEN_HEADLESS=1`/`0` overrides the detection in either direction
368
+ for environments that guess wrong; an explicit `--headless`/`--no-headless` outranks both.
369
+
370
+ - **`mcpgen login --callback-timeout SECONDS`** — the 300-second bound on the browser redirect
371
+ is a guess about how long a consent screen plus MFA takes, and guesses about human latency are
372
+ wrong for someone: a hardware-token or approve-on-phone flow can outlast it, while a scripted
373
+ login wants to fail fast. The flag sets that bound per invocation, `0` restores the original
374
+ unbounded wait as an escape hatch, and anything negative or non-numeric is rejected by argparse
375
+ with a usage message rather than surfacing as a traceback from inside asyncio. The same value
376
+ is available in code as `callback_timeout=` on `login()`, `ensure_login()`, and
377
+ `ensure_login_all()`. Headless logins ignore it — the pasted-URL prompt is never bounded.
378
+
379
+ - **`ensure_login_all(servers)`** — pre-flight refresh-or-login across several servers in one
380
+ call, for pipelines that talk to more than one MCP server and want every token settled before
381
+ work starts. Deliberately sequential: concurrent logins would open several browser tabs at
382
+ once and, in headless mode, race each other for stdin. Servers with a valid cached token stay
383
+ silent, so the steady-state cost is zero.
384
+
385
+ ### Fixed
386
+
387
+ - **An authorization denial in the browser flow now fails with the same clear error as the
388
+ headless flow.** The local callback server extracted only `code` and `state` and ignored
389
+ `error`/`error_description`, so declining consent resolved the callback to `(None, None)` and
390
+ surfaced later as an opaque downstream failure. Both callback paths now share one parser,
391
+ which raises `ValueError: OAuth authorization failed: <error> — <description>` at the point of
392
+ failure.
393
+
394
+ - **`mcpgen login` no longer hangs forever when the browser never comes back.** The error-redirect
395
+ fix above only helps when the authorization server actually redirects; real ones often don't —
396
+ cancel the consent screen and the tab simply closes, so no request ever reaches the local
397
+ callback server and the wait never ends. The interactive flow now bounds that wait at 300s
398
+ (`_CALLBACK_TIMEOUT`) and raises `TimeoutError` explaining that the browser never returned,
399
+ with a pointer to `--headless` for pasting the redirect URL by hand. The timeout cancels the
400
+ pending callback future, so the background server task shuts down cleanly, and it unwinds
401
+ through the same restore path as any other login failure — a timed-out attempt leaves a
402
+ previously-working credential intact. Headless logins are deliberately unbounded: a human
403
+ pasting a URL may take arbitrarily long.
404
+
405
+ ## [0.3.0] — 2026-07-14
406
+
407
+ ### Fixed
408
+
409
+ - **`mcpgen login` — OAuth token exchange failed with `400 invalid_request`** on authorization
410
+ servers that strictly enforce RFC 6749 §2.3 (`"Client must not use multiple authentication
411
+ methods"`). Dynamic client registration omitted `token_endpoint_auth_method`, so the server
412
+ defaulted us to `client_secret_basic` and issued a `client_secret`; the MCP SDK then sent an
413
+ `Authorization: Basic` header *and* `client_id` in the form body — two client authentication
414
+ methods in one request. mcpgen now registers as a public client
415
+ (`token_endpoint_auth_method: "none"`), which is the correct posture for a distributed CLI
416
+ (RFC 8252 §8.4) and is fully secured by the PKCE (S256) the SDK already performs on every
417
+ authorization code grant. No security regression: the discarded `client_secret` was issued
418
+ per-install by dynamic registration and stored in the same `credentials.json` as the refresh
419
+ token, so anyone who could read the secret could already read the token — it never added a
420
+ layer of defense.
421
+
422
+ - **Discriminated-union codegen mistyped `string` discriminators with numeric-looking
423
+ enum values as `int`.** Discriminator type was inferred solely by sniffing whether
424
+ shape-spec variant keys looked numeric (e.g. `"1"`, `"2"`), so a genuinely
425
+ `type: "string"` discriminator with those enum values got a wrong `int` `Literal`
426
+ in the generated `@overload` stubs. Discriminator type is now resolved from an
427
+ explicit `input_overrides` entry, then the tool's declared `inputSchema` type,
428
+ and only falls back to key-sniffing when neither is available.
429
+
430
+ - **`mcpgen call`/`probe` crashed on `resource_link` content items** — real MCP SDK
431
+ `ResourceLink.uri` is a pydantic `AnyUrl`, not `str`; `json.dumps` on the summarized
432
+ content raised `TypeError`. The summary now coerces `uri` to `str`.
433
+
434
+ - **`mcpgen probe` reported inflated payload sizes for non-ASCII content** — size was
435
+ measured as the character count of `json.dumps`'s default `ensure_ascii=True`
436
+ output, which escapes every non-ASCII character to a `\uXXXX` sequence. Size is
437
+ now measured as real UTF-8 byte length.
438
+
439
+ ## [0.2.0] — 2026-06-20
440
+
441
+ ### Added
442
+
443
+ - **`mcpgen list --schema`** — include raw `inputSchema` JSON per tool in list output.
444
+ Useful for inspecting required params and enum constraints without a separate probe.
445
+
446
+ - **`mcpgen codegen --embed-schema`** — embed `fn.__schema__ = {<inputSchema>}` on each
447
+ generated function, plus an Args docstring section (per-param description, enum values,
448
+ default). Enables introspection at runtime (`mod.get_issue.__schema__`) and richer IDE
449
+ hover docs.
450
+
451
+ - **Enum params → `Literal[...]`** (default, no flag) — `py_type()` now maps JSON Schema
452
+ `enum` arrays to `Literal[v1, v2, ...]` instead of bare `str`/`int`. Applies to direct
453
+ enum params and array items (`list[Literal[...]]`). Static analysis and call-site type
454
+ narrowing work without any extra configuration.
455
+
456
+ ## [0.1.0] — initial release
457
+
458
+ ### Added
459
+
460
+ - **`mcpgen codegen`** — generate typed async Python wrappers from a live MCP server's
461
+ `tools/list`. Every tool becomes an `async def` typed from `inputSchema`; returns `Any`
462
+ by default (shape-spec refines to `TypedDict`).
463
+
464
+ - **Shape-spec sidecar** (`<server>.shapes.json`) — hand-editable file driving typed
465
+ return models. Fields: `unwrap` (key path), `return_model` (TypedDict name), `fields`,
466
+ `input_overrides`, `overloads`. Intermediate probe parts land in `.parts/` until `merge`.
467
+
468
+ - **Discriminator detection + overloads** — `mcpgen list` / `probe` emit a stderr advisory
469
+ when a param is shared across ≥2 tools (polymorphic-suspect). Codegen emits one
470
+ `@overload` per discriminator variant (`Literal[<val>]`) plus a union impl.
471
+
472
+ - **`mcpgen list`** — print all tools on a server as JSON `[{name, description}]`.
473
+ Includes discriminator advisory on stderr.
474
+
475
+ - **`mcpgen probe`** — live call(s) → response-shape skeleton. Writes per-probe
476
+ intermediates to `.parts/` for later merge.
477
+
478
+ - **`mcpgen merge`** — consolidate `.parts/` into `<server>.shapes.json`; emits a
479
+ gitignored `verify.json` sidecar with pre-scrub `probed_args`.
480
+
481
+ - **`mcpgen call`** — single live call, raw payload written to disk; useful for
482
+ bootstrapping ids and inspecting raw output.
483
+
484
+ - **`mcpgen discover`** — enumerate MCP servers configured in installed agent hosts
485
+ (reads Claude Code CLI / `~/.claude.json`).
486
+
487
+ - **`mcpgen login`** — browser OAuth login; tokens stored at
488
+ `~/.mcpgen/credentials.json` (chmod 0600) or OS keychain via `--cred-backend keyring`.
489
+
490
+ - **`mcpgen migrate-creds`** — move stored OAuth tokens between `file` / `keyring`
491
+ backends.
492
+
493
+ - **`mcpgen list-creds` / `delete-creds`** — inspect and remove stored credentials.
494
+
495
+ - **Transport flags** — `--stdio` (launch command), `--url` (HTTP), `--bearer` (PAT),
496
+ `--config` (config-file server resolution); shared across `codegen`/`list`/`probe`/`call`/`login`.
497
+
498
+ - **`generate-mcp-wrappers` plugin skill** — agent skill that drives the full
499
+ probe → shape-spec → codegen workflow interactively.
500
+
501
+ - **`generate-mcp-runner` plugin skill** — agent skill for building typed runner
502
+ scripts on top of generated wrappers.
@@ -13,8 +13,9 @@ uv run pytest # all tests
13
13
  uv run pytest tests/test_codegen.py # single file
14
14
  uv run pytest -k "test_name" # single test
15
15
 
16
- # Lint / type-check
17
- uv run ruff check mcpgen/
16
+ # Lint / type-check — same three commands CI runs; all must pass before pushing
17
+ uv run ruff check . # `skills/` is excluded in pyproject.toml (Template placeholders)
18
+ uv run ruff format --check . # CI fails on formatting alone; `ruff format .` to fix
18
19
  uv run mypy
19
20
 
20
21
  # Build dist
@@ -24,6 +25,13 @@ uv build
24
25
  .venv/bin/mcpgen codegen <server> --out <server>.py
25
26
  ```
26
27
 
28
+ ## Comments
29
+
30
+ Comments describe the code as it stands, not the changes that produced it. Write what
31
+ constrains the current implementation — RFC citations, ordering requirements, known gaps —
32
+ and leave the history to git. Do not append a new paragraph per fix: revise the existing
33
+ comment instead, so it never explains behaviour that is no longer there.
34
+
27
35
  ## Architecture
28
36
 
29
37
  Four layers, strict one-way dependency: