mcp-client-kit 0.6.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.
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/.claude/skills/releasing-a-version/SKILL.md +13 -7
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/.github/workflows/publish.yml +19 -0
- mcp_client_kit-0.7.0/CHANGELOG.md +502 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/CLAUDE.md +10 -2
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/PKG-INFO +7 -3
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/README.md +6 -2
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/doc/DISTRIBUTION.md +40 -10
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/doc/USAGE.md +28 -9
- mcp_client_kit-0.7.0/extract-changelog.sh +10 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/mcpgen/__init__.py +4 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/mcpgen/_bridge.py +972 -186
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/mcpgen/cli.py +17 -4
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/pyproject.toml +1 -1
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/skills/generate-mcp-runner/SKILL.md +12 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/skills/generate-mcp-runner/runner_templates/http_oauth.py +6 -5
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/test_bridge.py +3958 -1372
- mcp_client_kit-0.7.0/tests/test_cli_auth_errors.py +80 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/uv.lock +1 -1
- mcp_client_kit-0.6.0/CHANGELOG.md +0 -238
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/.claude-plugin/marketplace.json +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/.claude-plugin/plugin.json +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/.github/workflows/ci.yml +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/.gitignore +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/LICENSE +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/doc/CODEGEN_SKILL_IDEA.md +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/doc/EXTRACTION_ANALYSIS.md +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/doc/LANDSCAPE.md +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/doc/MARKET_RESEARCH.md +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/doc/RUNNING_LOCALLY.md +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/doc/VERDICT.md +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/docs/superpowers/plans/2026-08-10-audit-m1-m2.md +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/docs/superpowers/specs/2026-08-10-audit-m1-m2-design.md +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/mcpgen/codegen.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/mcpgen/discovery.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/mcpgen/manifest.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/mcpgen/seam.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/servers.example.json +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/skills/generate-mcp-runner/runner_templates/http_bearer.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/skills/generate-mcp-runner/runner_templates/http_public.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/skills/generate-mcp-runner/runner_templates/stdio.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/skills/generate-mcp-wrappers/SKILL.md +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/integration/__init__.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/integration/conftest.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/integration/fixtures/stdio_server.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/integration/fixtures/stdio_server_changed.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/integration/test_generated_wrapper_e2e.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/integration/test_stdio_e2e.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/test_cli_call.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/test_cli_check.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/test_cli_codegen_manifest.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/test_cli_env.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/test_cli_list.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/test_cli_merge.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/test_cli_probe.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/test_codegen.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/test_config.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/test_discovery.py +0 -0
- {mcp_client_kit-0.6.0 → mcp_client_kit-0.7.0}/tests/test_manifest.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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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 `.
|
|
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
|
|
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:
|