mcp-client-kit 0.0.4__tar.gz → 0.3.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 (43) hide show
  1. mcp_client_kit-0.3.0/.claude/skills/releasing-a-version/SKILL.md +127 -0
  2. mcp_client_kit-0.3.0/CHANGELOG.md +102 -0
  3. mcp_client_kit-0.3.0/CLAUDE.md +86 -0
  4. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/PKG-INFO +3 -3
  5. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/README.md +2 -2
  6. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/doc/USAGE.md +11 -2
  7. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/mcpgen/_bridge.py +109 -31
  8. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/mcpgen/cli.py +40 -7
  9. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/mcpgen/codegen.py +262 -30
  10. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/pyproject.toml +1 -1
  11. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/skills/generate-mcp-wrappers/SKILL.md +63 -36
  12. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/tests/test_bridge.py +329 -0
  13. mcp_client_kit-0.3.0/tests/test_cli_list.py +282 -0
  14. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/tests/test_cli_merge.py +51 -1
  15. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/tests/test_cli_probe.py +60 -7
  16. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/tests/test_codegen.py +467 -0
  17. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/uv.lock +2 -2
  18. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/.claude-plugin/marketplace.json +0 -0
  19. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/.claude-plugin/plugin.json +0 -0
  20. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/.github/workflows/ci.yml +0 -0
  21. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/.github/workflows/publish.yml +0 -0
  22. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/.gitignore +0 -0
  23. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/LICENSE +0 -0
  24. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/doc/CODEGEN_SKILL_IDEA.md +0 -0
  25. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/doc/DISTRIBUTION.md +0 -0
  26. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/doc/EXTRACTION_ANALYSIS.md +0 -0
  27. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/doc/LANDSCAPE.md +0 -0
  28. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/doc/MARKET_RESEARCH.md +0 -0
  29. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/doc/RUNNING_LOCALLY.md +0 -0
  30. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/doc/VERDICT.md +0 -0
  31. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/mcpgen/__init__.py +0 -0
  32. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/mcpgen/discovery.py +0 -0
  33. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/mcpgen/seam.py +0 -0
  34. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/servers.example.json +0 -0
  35. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/SKILL.md +0 -0
  36. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/runner_templates/http_bearer.py +0 -0
  37. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/runner_templates/http_oauth.py +0 -0
  38. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/runner_templates/http_public.py +0 -0
  39. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/runner_templates/stdio.py +0 -0
  40. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/tests/test_cli_call.py +0 -0
  41. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/tests/test_cli_env.py +0 -0
  42. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/tests/test_config.py +0 -0
  43. {mcp_client_kit-0.0.4 → mcp_client_kit-0.3.0}/tests/test_discovery.py +0 -0
@@ -0,0 +1,127 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Releasing a version
7
+
8
+ Two artifacts ship from one repo. Full rationale in `doc/DISTRIBUTION.md`.
9
+
10
+ | Artifact | Version file | Tag form | Fires PyPI | Tag branch |
11
+ |---|---|---|---|---|
12
+ | **engine** | `pyproject.toml` | `vX.Y.Z` | yes | `main` |
13
+ | **plugin** | `.claude-plugin/plugin.json` | `plugin-vX.Y.Z` | no | `main` |
14
+
15
+ **Pick the flow:**
16
+ - `mcpgen/**` code changed → **Engine release**
17
+ - Skill/docs/SKILL.md only → **Plugin-only release**
18
+ - Both changed → run Engine release first (it implicitly bumps the product too)
19
+
20
+ ---
21
+
22
+ ## Engine release
23
+
24
+ Run from the `dev` branch.
25
+
26
+ **1. Precheck**
27
+ ```bash
28
+ git checkout dev && git pull
29
+ git status # must be clean
30
+ gh pr checks # CI green
31
+ ```
32
+ `CHANGELOG.md` must have a `## [Unreleased] — X.Y.Z` entry ready. If not, add one.
33
+
34
+ **2. Bump to stable**
35
+ ```bash
36
+ uv version --bump stable --dry-run # preview: 0.2.0.dev0 => 0.2.0
37
+ uv version --bump stable # writes pyproject.toml + uv.lock
38
+ ```
39
+
40
+ **3. Finalize CHANGELOG**
41
+ Change the unreleased header to a dated release header, e.g.:
42
+ ```
43
+ ## [Unreleased] — 0.2.0
44
+ ```
45
+ →
46
+ ```
47
+ ## [0.2.0] — 2026-06-20
48
+ ```
49
+ Add a new blank `## [Unreleased] — 0.3.0` placeholder at the top (you'll fill it during the post-release step).
50
+
51
+ **4. Commit + PR → main**
52
+ ```bash
53
+ git add pyproject.toml uv.lock CHANGELOG.md
54
+ git commit -m "release: engine v0.2.0"
55
+ git push origin dev
56
+ gh pr create --base main --title "release: engine v0.2.0"
57
+ # merge the PR (squash or merge commit)
58
+ ```
59
+
60
+ **5. Tag on main**
61
+ ```bash
62
+ git checkout main && git pull
63
+ git tag v0.2.0
64
+ git push --tags # bare v* tag triggers publish.yml → PyPI
65
+ ```
66
+ `publish.yml` asserts the tag (minus `v`) matches `pyproject.toml version` — if they differ the job fails. Fix by ensuring step 2 ran on the same commit.
67
+
68
+ **6. Re-advance dev**
69
+ ```bash
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
73
+ # Add fresh unreleased section to CHANGELOG if not already there:
74
+ # ## [Unreleased] — 0.3.0
75
+ git add pyproject.toml uv.lock CHANGELOG.md
76
+ git commit -m "chore: bump version to 0.3.0.dev0"
77
+ git push origin dev
78
+ ```
79
+
80
+ ---
81
+
82
+ ## Plugin-only release
83
+
84
+ Use when only skill files, SKILL.md, or docs changed — no engine code.
85
+
86
+ **1. Bump plugin product version on dev**
87
+
88
+ Files to update:
89
+ - `.claude-plugin/plugin.json` → `"version"` field
90
+ - `.claude-plugin/marketplace.json` → `"plugins"[0]["version"]` field only
91
+ *(leave top-level `"version"` — that's the catalog version, bumped only when the catalog listing itself changes, not per plugin release)*
92
+
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:
94
+ - Line with `mcpgen >= X.Y.Z` in the prose
95
+ - `min=X.Y.Z` in the bash version check below it
96
+
97
+ **2. Commit + PR → main**
98
+ ```bash
99
+ git add .claude-plugin/
100
+ git commit -m "release: plugin v0.1.1 (engine unchanged 0.2.0)"
101
+ git push origin dev
102
+ gh pr create --base main --title "release: plugin v0.1.1"
103
+ # merge the PR
104
+ ```
105
+
106
+ **3. Tag on main**
107
+ ```bash
108
+ git checkout main && git pull
109
+ git tag plugin-v0.1.1
110
+ git push origin plugin-v0.1.1 # plugin-v* does NOT trigger PyPI publish
111
+ ```
112
+
113
+ **4. Update the marketplace entry**
114
+ In the `svd-agent-skills` marketplace repo, bump the `mcp-client-kit` entry's `ref` to `plugin-v0.1.1`. This points consumers at the stable `main` tag, not a dev pre-release.
115
+
116
+ ---
117
+
118
+ ## Common mistakes
119
+
120
+ | Mistake | Effect |
121
+ |---|---|
122
+ | Tag on `dev` instead of `main` | Marketplace/PyPI points at a dev pre-release |
123
+ | 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 |
125
+ | Bump `pyproject.toml` for plugin-only | Triggers unnecessary PyPI republish |
126
+ | 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
+ | Raise SKILL.md floor preemptively | Floor should only move when the skill actually needs the new engine feature |
@@ -0,0 +1,102 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased] — 0.4.0
4
+
5
+ ## [0.3.0] — 2026-07-14
6
+
7
+ ### Fixed
8
+
9
+ - **`mcpgen login` — OAuth token exchange failed with `400 invalid_request`** on authorization
10
+ servers that strictly enforce RFC 6749 §2.3 (`"Client must not use multiple authentication
11
+ methods"`). Dynamic client registration omitted `token_endpoint_auth_method`, so the server
12
+ defaulted us to `client_secret_basic` and issued a `client_secret`; the MCP SDK then sent an
13
+ `Authorization: Basic` header *and* `client_id` in the form body — two client authentication
14
+ methods in one request. mcpgen now registers as a public client
15
+ (`token_endpoint_auth_method: "none"`), which is the correct posture for a distributed CLI
16
+ (RFC 8252 §8.4) and is fully secured by the PKCE (S256) the SDK already performs on every
17
+ authorization code grant. No security regression: the discarded `client_secret` was issued
18
+ per-install by dynamic registration and stored in the same `credentials.json` as the refresh
19
+ token, so anyone who could read the secret could already read the token — it never added a
20
+ layer of defense.
21
+
22
+ - **Discriminated-union codegen mistyped `string` discriminators with numeric-looking
23
+ enum values as `int`.** Discriminator type was inferred solely by sniffing whether
24
+ shape-spec variant keys looked numeric (e.g. `"1"`, `"2"`), so a genuinely
25
+ `type: "string"` discriminator with those enum values got a wrong `int` `Literal`
26
+ in the generated `@overload` stubs. Discriminator type is now resolved from an
27
+ explicit `input_overrides` entry, then the tool's declared `inputSchema` type,
28
+ and only falls back to key-sniffing when neither is available.
29
+
30
+ - **`mcpgen call`/`probe` crashed on `resource_link` content items** — real MCP SDK
31
+ `ResourceLink.uri` is a pydantic `AnyUrl`, not `str`; `json.dumps` on the summarized
32
+ content raised `TypeError`. The summary now coerces `uri` to `str`.
33
+
34
+ - **`mcpgen probe` reported inflated payload sizes for non-ASCII content** — size was
35
+ measured as the character count of `json.dumps`'s default `ensure_ascii=True`
36
+ output, which escapes every non-ASCII character to a `\uXXXX` sequence. Size is
37
+ now measured as real UTF-8 byte length.
38
+
39
+ ## [0.2.0] — 2026-06-20
40
+
41
+ ### Added
42
+
43
+ - **`mcpgen list --schema`** — include raw `inputSchema` JSON per tool in list output.
44
+ Useful for inspecting required params and enum constraints without a separate probe.
45
+
46
+ - **`mcpgen codegen --embed-schema`** — embed `fn.__schema__ = {<inputSchema>}` on each
47
+ generated function, plus an Args docstring section (per-param description, enum values,
48
+ default). Enables introspection at runtime (`mod.get_issue.__schema__`) and richer IDE
49
+ hover docs.
50
+
51
+ - **Enum params → `Literal[...]`** (default, no flag) — `py_type()` now maps JSON Schema
52
+ `enum` arrays to `Literal[v1, v2, ...]` instead of bare `str`/`int`. Applies to direct
53
+ enum params and array items (`list[Literal[...]]`). Static analysis and call-site type
54
+ narrowing work without any extra configuration.
55
+
56
+ ## [0.1.0] — initial release
57
+
58
+ ### Added
59
+
60
+ - **`mcpgen codegen`** — generate typed async Python wrappers from a live MCP server's
61
+ `tools/list`. Every tool becomes an `async def` typed from `inputSchema`; returns `Any`
62
+ by default (shape-spec refines to `TypedDict`).
63
+
64
+ - **Shape-spec sidecar** (`<server>.shapes.json`) — hand-editable file driving typed
65
+ return models. Fields: `unwrap` (key path), `return_model` (TypedDict name), `fields`,
66
+ `input_overrides`, `overloads`. Intermediate probe parts land in `.parts/` until `merge`.
67
+
68
+ - **Discriminator detection + overloads** — `mcpgen list` / `probe` emit a stderr advisory
69
+ when a param is shared across ≥2 tools (polymorphic-suspect). Codegen emits one
70
+ `@overload` per discriminator variant (`Literal[<val>]`) plus a union impl.
71
+
72
+ - **`mcpgen list`** — print all tools on a server as JSON `[{name, description}]`.
73
+ Includes discriminator advisory on stderr.
74
+
75
+ - **`mcpgen probe`** — live call(s) → response-shape skeleton. Writes per-probe
76
+ intermediates to `.parts/` for later merge.
77
+
78
+ - **`mcpgen merge`** — consolidate `.parts/` into `<server>.shapes.json`; emits a
79
+ gitignored `verify.json` sidecar with pre-scrub `probed_args`.
80
+
81
+ - **`mcpgen call`** — single live call, raw payload written to disk; useful for
82
+ bootstrapping ids and inspecting raw output.
83
+
84
+ - **`mcpgen discover`** — enumerate MCP servers configured in installed agent hosts
85
+ (reads Claude Code CLI / `~/.claude.json`).
86
+
87
+ - **`mcpgen login`** — browser OAuth login; tokens stored at
88
+ `~/.mcpgen/credentials.json` (chmod 0600) or OS keychain via `--cred-backend keyring`.
89
+
90
+ - **`mcpgen migrate-creds`** — move stored OAuth tokens between `file` / `keyring`
91
+ backends.
92
+
93
+ - **`mcpgen list-creds` / `delete-creds`** — inspect and remove stored credentials.
94
+
95
+ - **Transport flags** — `--stdio` (launch command), `--url` (HTTP), `--bearer` (PAT),
96
+ `--config` (config-file server resolution); shared across `codegen`/`list`/`probe`/`call`/`login`.
97
+
98
+ - **`generate-mcp-wrappers` plugin skill** — agent skill that drives the full
99
+ probe → shape-spec → codegen workflow interactively.
100
+
101
+ - **`generate-mcp-runner` plugin skill** — agent skill for building typed runner
102
+ scripts on top of generated wrappers.
@@ -0,0 +1,86 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ # Setup (once)
9
+ uv venv && uv pip install -e .
10
+
11
+ # Tests
12
+ uv run pytest # all tests
13
+ uv run pytest tests/test_codegen.py # single file
14
+ uv run pytest -k "test_name" # single test
15
+
16
+ # Lint / type-check
17
+ uv run ruff check mcpgen/
18
+ uv run mypy
19
+
20
+ # Build dist
21
+ uv build
22
+
23
+ # Run CLI from clone (no install)
24
+ .venv/bin/mcpgen codegen <server> --out <server>.py
25
+ ```
26
+
27
+ ## Architecture
28
+
29
+ Four layers, strict one-way dependency:
30
+
31
+ ```
32
+ CLI (cli.py)
33
+ └── Bridge (_bridge.py) ← auth, transport, live MCP sessions
34
+ └── Codegen (codegen.py) ← pure functions: JSON Schema → Python source
35
+ └── Discovery (discovery.py)← pure enumeration of host-configured servers
36
+ Generated wrappers (.py files)
37
+ └── Seam (seam.py) ← McpCaller Protocol only; no concrete client
38
+ ```
39
+
40
+ **`seam.py`** — the only thing generated wrappers import from this package. A single `McpCaller` Protocol (`async def call(server, tool, arguments) → Any`). Changing it breaks all generated files.
41
+
42
+ **`codegen.py`** — pure functions (no I/O). `render_module()` takes tool dicts + optional shape-spec and emits Python source. `summarize_shape()` converts a live response to a shape skeleton. `merge_skeletons()` merges multiple probe skeletons into one.
43
+
44
+ **`_bridge.py`** — all async/network code. `McpBridgeCaller` implements `McpCaller`. `session()` is the `asynccontextmanager` for raw MCP sessions. Contains OAuth pre-flight refresh logic (critical — see module docstring; bounded to `mcp<2` because SDK `_initialize` never calls `update_token_expiry`).
45
+
46
+ **`discovery.py`** — sync, no network. Reads Claude Code CLI / `~/.claude.json` to enumerate configured MCP servers.
47
+
48
+ **`cli.py`** — thin argparse wiring over the above. Each `_cmd_*` function maps to a subcommand.
49
+
50
+ ## Shape-spec sidecar
51
+
52
+ `<server>.shapes.json` is the hand-editable file that drives typed codegen. Key fields per tool entry: `unwrap` (key path to unwrap), `return_model` (TypedDict name), `fields` (field → Python type), `input_overrides`, `overloads`. The `.parts/` directory holds per-probe intermediates until `mcpgen merge` consolidates them.
53
+
54
+ ## Running the skill locally
55
+
56
+ ```bash
57
+ KIT=~/src/mcp-client-kit
58
+ PATH="$KIT/.venv/bin:$PATH" claude --plugin-dir "$KIT"
59
+ ```
60
+
61
+ The skill is `/mcp-client-kit:generate-mcp-wrappers`. It requires `mcpgen` on PATH.
62
+
63
+ ## Server config
64
+
65
+ `servers.example.json` → copy to `servers.json` (or set `$MCPGEN_SERVERS`). Config is `{"mcpServers": {"name": {"type": "http", "url": "..."}}}` — same shape as Claude Code's MCP config.
66
+
67
+ ## Credentials
68
+
69
+ Stored at `~/.mcpgen/credentials.json` (chmod 0600) or OS keychain via `--cred-backend keyring`. `migrate-creds` moves between backends.
70
+
71
+ ## Versioning & release
72
+
73
+ Two artifacts, independent versions — see `doc/DISTRIBUTION.md` for full spec.
74
+
75
+ | Artifact | Tag form | Source of truth | Effect |
76
+ |---|---|---|---|
77
+ | engine (CLI) | `vX.Y.Z` | `pyproject.toml` | triggers PyPI publish |
78
+ | plugin (skill) | `plugin-vX.Y.Z` | `.claude-plugin/plugin.json` + `marketplace.json` | marketplace ref only, no publish |
79
+
80
+ ```bash
81
+ # Plugin-only release (skill/SKILL.md change, no engine code change)
82
+ git tag plugin-vX.Y.Z origin/main # tag on main after PR merge
83
+ git push origin plugin-vX.Y.Z
84
+
85
+ # Engine release — see doc/DISTRIBUTION.md:108 (fires PyPI publish workflow)
86
+ ```
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-client-kit
3
- Version: 0.0.4
3
+ Version: 0.3.0
4
4
  Summary: mcp-client-kit — generate typed Python wrappers for any MCP server (codegen skill + CLI).
5
5
  Project-URL: Homepage, https://github.com/svd/mcp-client-kit
6
6
  Project-URL: Source, https://github.com/svd/mcp-client-kit
@@ -151,8 +151,8 @@ A companion skill, `generate-mcp-runner`, writes a standalone smoke-test `run.py
151
151
 
152
152
  | Command | What it does |
153
153
  |---------|--------------|
154
- | `codegen <server>` | Emit typed wrappers; `--shapes` applies the shape-spec, `--probe` records a response shape inline. |
155
- | `list <server>` | Print a server's tools as JSON. |
154
+ | `codegen <server>` | Emit typed wrappers; `--shapes` applies the shape-spec, `--probe` records a response shape inline, `--embed-schema` embeds `fn.__schema__` + Args docstring per function. |
155
+ | `list <server>` | Print a server's tools as JSON; `--schema` adds raw `inputSchema` per tool. |
156
156
  | `probe <server> <tool>` | Live call(s) → response-shape skeleton. |
157
157
  | `call <server> <tool> --out <p>` | One live call, raw payload to disk — bootstrap ids or inspect output. |
158
158
  | `merge <server>` | Consolidate probe parts into `<server>.shapes.json`. |
@@ -127,8 +127,8 @@ A companion skill, `generate-mcp-runner`, writes a standalone smoke-test `run.py
127
127
 
128
128
  | Command | What it does |
129
129
  |---------|--------------|
130
- | `codegen <server>` | Emit typed wrappers; `--shapes` applies the shape-spec, `--probe` records a response shape inline. |
131
- | `list <server>` | Print a server's tools as JSON. |
130
+ | `codegen <server>` | Emit typed wrappers; `--shapes` applies the shape-spec, `--probe` records a response shape inline, `--embed-schema` embeds `fn.__schema__` + Args docstring per function. |
131
+ | `list <server>` | Print a server's tools as JSON; `--schema` adds raw `inputSchema` per tool. |
132
132
  | `probe <server> <tool>` | Live call(s) → response-shape skeleton. |
133
133
  | `call <server> <tool> --out <p>` | One live call, raw payload to disk — bootstrap ids or inspect output. |
134
134
  | `merge <server>` | Consolidate probe parts into `<server>.shapes.json`. |
@@ -74,8 +74,8 @@ uv add mcp-client-kit # or: pip install mcp-client-kit
74
74
 
75
75
  | Command | Purpose | Key flags |
76
76
  |---------|---------|-----------|
77
- | `codegen <server>` | Emit typed wrappers | `--out`, `--shapes <path>`, `--probe <tool>` / `--probe-args` |
78
- | `list <server>` | Tools as JSON `[{name, description}]`; discriminator advisory on stderr | — |
77
+ | `codegen <server>` | Emit typed wrappers | `--out`, `--shapes <path>`, `--probe <tool>` / `--probe-args`, `--embed-schema` |
78
+ | `list <server>` | Tools as JSON `[{name, description}]` (add `--schema` for `inputSchema` per tool); discriminator advisory on stderr | `--schema` |
79
79
  | `probe <server> <tool>` | Live call(s) → shape skeleton | `--args` (repeatable), `--emit-shape <path>` (writes `.parts/`) |
80
80
  | `call <server> <tool>` | One live call, raw payload to disk — bootstrap ids / inspect output | `--out <path>` (required) |
81
81
  | `merge <server>` | Consolidate `.parts/` → `<server>.shapes.json`; emit gitignored `verify.json` | `--out <path>` |
@@ -420,6 +420,15 @@ Caveats: the discriminator is **always required** (even if the tool schema marks
420
420
  it optional). An unmodeled discriminator value hits the `int` impl and returns the
421
421
  union — it never raises.
422
422
 
423
+ ### Enum params → `Literal[...]`
424
+
425
+ Any input param whose `inputSchema` carries an `enum` array is automatically typed as
426
+ `Literal[<v1>, <v2>, ...]` — no flag required. This applies to all scalar enums
427
+ (string, int, …). Use `mcpgen list <server> --schema` to inspect the raw `enum` arrays
428
+ before generating. Do not widen enum params to `str` in your calling code; if the server
429
+ accepts values outside the declared enum, the appropriate fix is to update the shape-spec
430
+ `input_overrides` for that param rather than loosening the call site.
431
+
423
432
  ---
424
433
 
425
434
  ## Troubleshooting
@@ -58,7 +58,7 @@ from urllib.parse import parse_qs, urlparse
58
58
 
59
59
  import httpx
60
60
  from mcp import ClientSession
61
- from mcp.client.auth import OAuthClientProvider, TokenStorage
61
+ from mcp.client.auth import OAuthClientProvider, OAuthRegistrationError, TokenStorage
62
62
  from mcp.client.stdio import StdioServerParameters, stdio_client
63
63
  from mcp.client.streamable_http import streamable_http_client
64
64
  from mcp.shared._httpx_utils import create_mcp_http_client
@@ -330,6 +330,44 @@ def _resolve_client_name(server_name: str) -> str:
330
330
  return _client_names_cache.get(server_name) or f"mcpgen ({server_name})"
331
331
 
332
332
 
333
+ def _client_metadata(server_name: str, callback_uri: str, client_name: str | None = None) -> OAuthClientMetadata:
334
+ """Dynamic-registration metadata (RFC 7591). Shared by both OAuth entry points.
335
+
336
+ `token_endpoint_auth_method="none"` is load-bearing. Omit it and the AS applies
337
+ the RFC 7591 §2 default of `client_secret_basic` and issues a `client_secret`;
338
+ the SDK then sends an `Authorization: Basic` header *and* `client_id` in the form
339
+ body — two client authentication methods in one request, which servers that
340
+ enforce RFC 6749 §2.3 reject with `400 invalid_request`. Registering as a public
341
+ client (RFC 8252 §8.4) is the correct posture for a distributed CLI anyway, and
342
+ the SDK's PKCE (S256, unconditional) is what actually secures the flow.
343
+ """
344
+ return OAuthClientMetadata(
345
+ client_name=client_name or _resolve_client_name(server_name),
346
+ redirect_uris=[callback_uri], # type: ignore[list-item] # Pydantic coerces str→AnyUrl
347
+ grant_types=["authorization_code", "refresh_token"],
348
+ response_types=["code"],
349
+ token_endpoint_auth_method="none",
350
+ )
351
+
352
+
353
+ def _explain_registration_error(exc: OAuthRegistrationError) -> OAuthRegistrationError:
354
+ """Annotate `invalid_client_metadata` with its most likely cause.
355
+
356
+ An AS that does not support public clients MUST reject our registration
357
+ (RFC 7591 §2) rather than downgrade it. No such server is known, so rather than
358
+ carry a speculative override flag we make the failure legible if one turns up.
359
+ """
360
+ if "invalid_client_metadata" not in str(exc):
361
+ return exc
362
+ return OAuthRegistrationError(
363
+ f"{exc}\n\n"
364
+ "Likely cause: mcpgen registers as a public client "
365
+ "(token_endpoint_auth_method=none), and this authorization server appears to "
366
+ "require a client_secret. Please report this at "
367
+ "https://github.com/svd/mcp-client-kit/issues with the message above."
368
+ )
369
+
370
+
333
371
  # Treat a cached token as expired this many seconds before its real expiry.
334
372
  _MARGIN = 120
335
373
 
@@ -782,6 +820,12 @@ async def _http_session(
782
820
  storage = FileTokenStorage(server_name, backend=resolve_cred_backend(cred_backend))
783
821
  await _pre_flight_refresh(server_name, storage)
784
822
 
823
+ # Unlike login(), this does NOT clear a stale confidential-client registration,
824
+ # and must not: a pre-fix credential still refreshes fine here, because
825
+ # _pre_flight_refresh sends client_id + client_secret in the body — that is
826
+ # client_secret_post, a *single* auth method, so it never hit the double-auth
827
+ # bug. Once the refresh token dies, _no_browser raises ReauthenticationRequired
828
+ # → "Run: mcpgen login" → which does clear and re-register as a public client.
785
829
  data = storage._load()
786
830
  redirect_uris = data.get(server_name, {}).get("client_info", {}).get("redirect_uris", [])
787
831
  callback_uri = redirect_uris[0] if redirect_uris else "http://localhost:0/callback"
@@ -794,12 +838,7 @@ async def _http_session(
794
838
 
795
839
  provider = OAuthClientProvider(
796
840
  server_url=server_url,
797
- client_metadata=OAuthClientMetadata(
798
- client_name=client_name or _resolve_client_name(server_name),
799
- redirect_uris=[callback_uri], # type: ignore[list-item] # Pydantic coerces str→AnyUrl
800
- grant_types=["authorization_code", "refresh_token"],
801
- response_types=["code"],
802
- ),
841
+ client_metadata=_client_metadata(server_name, callback_uri, client_name),
803
842
  storage=storage,
804
843
  redirect_handler=_no_browser,
805
844
  callback_handler=_no_callback,
@@ -951,20 +990,61 @@ class McpBridgeCaller:
951
990
  env=self._env,
952
991
  ) as s:
953
992
  result = await s.call_tool(tool, arguments)
954
- content = [{"type": item.type, "text": getattr(item, "text", "")} for item in result.content]
993
+ content = [_summarize_content_item(item) for item in result.content]
955
994
  return parse(content)
956
995
 
957
996
 
997
+ def _summarize_content_item(item: Any) -> dict:
998
+ """Reduce one MCP content block to a compact, JSON-safe dict.
999
+
1000
+ ``text`` blocks keep today's shape ({"type", "text"}). ``image`` / ``resource`` /
1001
+ ``resource_link`` blocks carry no useful `.text` — capturing only `.text` (as the
1002
+ old code did) silently degrades them to an empty string, hiding the payload from
1003
+ shape inference entirely. Record presence/metadata instead (never the raw
1004
+ base64/blob bytes, to keep shape-specs small).
1005
+ """
1006
+ item_type = getattr(item, "type", None)
1007
+ if item_type == "image":
1008
+ return {
1009
+ "type": "image",
1010
+ "mimeType": getattr(item, "mimeType", None),
1011
+ "has_data": bool(getattr(item, "data", None)),
1012
+ }
1013
+ if item_type == "resource":
1014
+ resource = getattr(item, "resource", None)
1015
+ return {
1016
+ "type": "resource",
1017
+ "mimeType": getattr(resource, "mimeType", None),
1018
+ "has_text": bool(getattr(resource, "text", None)),
1019
+ "has_blob": bool(getattr(resource, "blob", None)),
1020
+ }
1021
+ if item_type == "resource_link":
1022
+ uri = getattr(item, "uri", None)
1023
+ return {
1024
+ "type": "resource_link",
1025
+ "uri": str(uri) if uri is not None else None,
1026
+ "name": getattr(item, "name", None),
1027
+ }
1028
+ return {"type": item_type, "text": getattr(item, "text", "")}
1029
+
1030
+
958
1031
  def parse(content_items: list) -> Any:
959
1032
  """Extract and JSON-parse the text payload from an MCP tool result.
960
1033
 
961
1034
  Falls back to ``ast.literal_eval`` for Python-repr payloads (e.g. servers
962
1035
  that return single-quoted dicts like sqlite), then to a plain string as a
963
1036
  last resort so callers can still inspect the response.
1037
+
1038
+ Non-text content blocks (image / resource / resource_link) carry no `.text`
1039
+ to parse — return the block's own summary dict as-is rather than falling
1040
+ through to an empty string, so the shape-spec records something real.
964
1041
  """
965
1042
  if not content_items:
966
1043
  raise ValueError("MCP tool result has empty content")
967
1044
  item = content_items[0]
1045
+ item_type = item.get("type") if isinstance(item, dict) else getattr(item, "type", None)
1046
+ if item_type in ("image", "resource", "resource_link"):
1047
+ return item if isinstance(item, dict) else _summarize_content_item(item)
968
1048
  text = item.get("text", "") if isinstance(item, dict) else getattr(item, "text", "")
969
1049
  try:
970
1050
  return json.loads(text)
@@ -1067,35 +1147,33 @@ async def login(
1067
1147
 
1068
1148
  provider = OAuthClientProvider(
1069
1149
  server_url=server_url,
1070
- client_metadata=OAuthClientMetadata(
1071
- client_name=client_name or _resolve_client_name(server_name),
1072
- redirect_uris=[callback_uri], # type: ignore[list-item] # Pydantic coerces str→AnyUrl
1073
- grant_types=["authorization_code", "refresh_token"],
1074
- response_types=["code"],
1075
- ),
1150
+ client_metadata=_client_metadata(server_name, callback_uri, client_name),
1076
1151
  storage=storage,
1077
1152
  redirect_handler=redirect_handler,
1078
1153
  callback_handler=callback_handler,
1079
1154
  )
1080
1155
 
1081
1156
  try:
1082
- async with _open_http(server_url, auth=provider) as (read, write, _):
1083
- async with ClientSession(read, write) as s:
1084
- await s.initialize()
1085
-
1086
- # Persist the token endpoint for later pre-flight refresh. Do this
1087
- # independently of any tool call — not every server exposes `whoami`.
1088
- if provider.context.oauth_metadata is not None:
1089
- endpoint_url = str(provider.context.oauth_metadata.token_endpoint)
1090
- creds_data = storage._load()
1091
- creds_data.setdefault(server_name, {})["token_endpoint"] = endpoint_url
1092
- storage._save(creds_data)
1093
-
1094
- # Confirm the authenticated session works with a server-agnostic
1095
- # call. `list_tools` is part of the MCP protocol — every server
1096
- # supports it, unlike any specific tool name.
1097
- tools = await s.list_tools()
1098
- print(f"Login OK ({server_name}); {len(tools.tools)} tool(s) available")
1157
+ try:
1158
+ async with _open_http(server_url, auth=provider) as (read, write, _):
1159
+ async with ClientSession(read, write) as s:
1160
+ await s.initialize()
1161
+
1162
+ # Persist the token endpoint for later pre-flight refresh. Do this
1163
+ # independently of any tool call — not every server exposes `whoami`.
1164
+ if provider.context.oauth_metadata is not None:
1165
+ endpoint_url = str(provider.context.oauth_metadata.token_endpoint)
1166
+ creds_data = storage._load()
1167
+ creds_data.setdefault(server_name, {})["token_endpoint"] = endpoint_url
1168
+ storage._save(creds_data)
1169
+
1170
+ # Confirm the authenticated session works with a server-agnostic
1171
+ # call. `list_tools` is part of the MCP protocol — every server
1172
+ # supports it, unlike any specific tool name.
1173
+ tools = await s.list_tools()
1174
+ print(f"Login OK ({server_name}); {len(tools.tools)} tool(s) available")
1175
+ except OAuthRegistrationError as e:
1176
+ raise _explain_registration_error(e) from e
1099
1177
  finally:
1100
1178
  if not callback_future.done():
1101
1179
  callback_future.set_result((None, None))