mcp-client-kit 0.0.4__tar.gz → 0.2.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.2.0/.claude/skills/releasing-a-version/SKILL.md +127 -0
  2. mcp_client_kit-0.2.0/CHANGELOG.md +68 -0
  3. mcp_client_kit-0.2.0/CLAUDE.md +86 -0
  4. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/PKG-INFO +3 -3
  5. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/README.md +2 -2
  6. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/USAGE.md +11 -2
  7. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/mcpgen/cli.py +17 -2
  8. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/mcpgen/codegen.py +107 -9
  9. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/pyproject.toml +1 -1
  10. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/skills/generate-mcp-wrappers/SKILL.md +29 -20
  11. mcp_client_kit-0.2.0/tests/test_cli_list.py +173 -0
  12. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_codegen.py +162 -0
  13. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/uv.lock +1 -1
  14. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/.claude-plugin/marketplace.json +0 -0
  15. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/.claude-plugin/plugin.json +0 -0
  16. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/.github/workflows/ci.yml +0 -0
  17. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/.github/workflows/publish.yml +0 -0
  18. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/.gitignore +0 -0
  19. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/LICENSE +0 -0
  20. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/CODEGEN_SKILL_IDEA.md +0 -0
  21. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/DISTRIBUTION.md +0 -0
  22. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/EXTRACTION_ANALYSIS.md +0 -0
  23. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/LANDSCAPE.md +0 -0
  24. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/MARKET_RESEARCH.md +0 -0
  25. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/RUNNING_LOCALLY.md +0 -0
  26. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/VERDICT.md +0 -0
  27. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/mcpgen/__init__.py +0 -0
  28. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/mcpgen/_bridge.py +0 -0
  29. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/mcpgen/discovery.py +0 -0
  30. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/mcpgen/seam.py +0 -0
  31. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/servers.example.json +0 -0
  32. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/skills/generate-mcp-runner/SKILL.md +0 -0
  33. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/skills/generate-mcp-runner/runner_templates/http_bearer.py +0 -0
  34. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/skills/generate-mcp-runner/runner_templates/http_oauth.py +0 -0
  35. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/skills/generate-mcp-runner/runner_templates/http_public.py +0 -0
  36. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/skills/generate-mcp-runner/runner_templates/stdio.py +0 -0
  37. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_bridge.py +0 -0
  38. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_cli_call.py +0 -0
  39. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_cli_env.py +0 -0
  40. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_cli_merge.py +0 -0
  41. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_cli_probe.py +0 -0
  42. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_config.py +0 -0
  43. {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.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,68 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased] — 0.3.0
4
+
5
+ ## [0.2.0] — 2026-06-20
6
+
7
+ ### Added
8
+
9
+ - **`mcpgen list --schema`** — include raw `inputSchema` JSON per tool in list output.
10
+ Useful for inspecting required params and enum constraints without a separate probe.
11
+
12
+ - **`mcpgen codegen --embed-schema`** — embed `fn.__schema__ = {<inputSchema>}` on each
13
+ generated function, plus an Args docstring section (per-param description, enum values,
14
+ default). Enables introspection at runtime (`mod.get_issue.__schema__`) and richer IDE
15
+ hover docs.
16
+
17
+ - **Enum params → `Literal[...]`** (default, no flag) — `py_type()` now maps JSON Schema
18
+ `enum` arrays to `Literal[v1, v2, ...]` instead of bare `str`/`int`. Applies to direct
19
+ enum params and array items (`list[Literal[...]]`). Static analysis and call-site type
20
+ narrowing work without any extra configuration.
21
+
22
+ ## [0.1.0] — initial release
23
+
24
+ ### Added
25
+
26
+ - **`mcpgen codegen`** — generate typed async Python wrappers from a live MCP server's
27
+ `tools/list`. Every tool becomes an `async def` typed from `inputSchema`; returns `Any`
28
+ by default (shape-spec refines to `TypedDict`).
29
+
30
+ - **Shape-spec sidecar** (`<server>.shapes.json`) — hand-editable file driving typed
31
+ return models. Fields: `unwrap` (key path), `return_model` (TypedDict name), `fields`,
32
+ `input_overrides`, `overloads`. Intermediate probe parts land in `.parts/` until `merge`.
33
+
34
+ - **Discriminator detection + overloads** — `mcpgen list` / `probe` emit a stderr advisory
35
+ when a param is shared across ≥2 tools (polymorphic-suspect). Codegen emits one
36
+ `@overload` per discriminator variant (`Literal[<val>]`) plus a union impl.
37
+
38
+ - **`mcpgen list`** — print all tools on a server as JSON `[{name, description}]`.
39
+ Includes discriminator advisory on stderr.
40
+
41
+ - **`mcpgen probe`** — live call(s) → response-shape skeleton. Writes per-probe
42
+ intermediates to `.parts/` for later merge.
43
+
44
+ - **`mcpgen merge`** — consolidate `.parts/` into `<server>.shapes.json`; emits a
45
+ gitignored `verify.json` sidecar with pre-scrub `probed_args`.
46
+
47
+ - **`mcpgen call`** — single live call, raw payload written to disk; useful for
48
+ bootstrapping ids and inspecting raw output.
49
+
50
+ - **`mcpgen discover`** — enumerate MCP servers configured in installed agent hosts
51
+ (reads Claude Code CLI / `~/.claude.json`).
52
+
53
+ - **`mcpgen login`** — browser OAuth login; tokens stored at
54
+ `~/.mcpgen/credentials.json` (chmod 0600) or OS keychain via `--cred-backend keyring`.
55
+
56
+ - **`mcpgen migrate-creds`** — move stored OAuth tokens between `file` / `keyring`
57
+ backends.
58
+
59
+ - **`mcpgen list-creds` / `delete-creds`** — inspect and remove stored credentials.
60
+
61
+ - **Transport flags** — `--stdio` (launch command), `--url` (HTTP), `--bearer` (PAT),
62
+ `--config` (config-file server resolution); shared across `codegen`/`list`/`probe`/`call`/`login`.
63
+
64
+ - **`generate-mcp-wrappers` plugin skill** — agent skill that drives the full
65
+ probe → shape-spec → codegen workflow interactively.
66
+
67
+ - **`generate-mcp-runner` plugin skill** — agent skill for building typed runner
68
+ 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.2.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
@@ -290,7 +290,9 @@ def _cmd_codegen(ns: argparse.Namespace) -> int:
290
290
  shape_json = json.dumps(shape, indent=2)
291
291
  probe_note = f"\nObserved response shape of {ns.probe!r} (keys/types/nesting only):\n" + shape_json
292
292
 
293
- source = codegen.render_module(ns.server, tools, shapes=shapes, probe_note=probe_note)
293
+ source = codegen.render_module(
294
+ ns.server, tools, shapes=shapes, probe_note=probe_note, embed_schema=getattr(ns, "embed_schema", False)
295
+ )
294
296
  if ns.out:
295
297
  Path(ns.out).write_text(source)
296
298
  print(f"[codegen] wrote {ns.out} ({len(source)} bytes)", file=sys.stderr)
@@ -574,7 +576,13 @@ def _cmd_list(ns: argparse.Namespace) -> int:
574
576
  except (FileNotFoundError, ValueError) as exc:
575
577
  print(f"[list] error: {exc}", file=sys.stderr)
576
578
  return 1
577
- out = [{"name": t["name"], "description": t.get("description") or ""} for t in tools]
579
+ if getattr(ns, "schema", False):
580
+ out = [
581
+ {"name": t["name"], "description": t.get("description") or "", "inputSchema": t.get("inputSchema") or {}}
582
+ for t in tools
583
+ ]
584
+ else:
585
+ out = [{"name": t["name"], "description": t.get("description") or ""} for t in tools]
578
586
  sys.stdout.write(json.dumps(out, indent=2) + "\n")
579
587
 
580
588
  candidates = codegen.detect_discriminators(tools)
@@ -743,6 +751,12 @@ def main(argv: list[str] | None = None) -> int:
743
751
  cg.add_argument("--probe", help="tool to call live and record response shape (docstring note only)")
744
752
  cg.add_argument("--probe-args", help="JSON args for --probe (default: {})")
745
753
  cg.add_argument("--stdio", metavar="CMD", help="use stdio transport: 'python server.py' (no auth)")
754
+ cg.add_argument(
755
+ "--embed-schema",
756
+ action="store_true",
757
+ dest="embed_schema",
758
+ help="embed raw inputSchema as __schema__ attribute and Args docstring per tool",
759
+ )
746
760
  _add_conn_args(cg)
747
761
  cg.set_defaults(func=_cmd_codegen)
748
762
 
@@ -794,6 +808,7 @@ def main(argv: list[str] | None = None) -> int:
794
808
 
795
809
  ls = sub.add_parser("list", help="list tools for a server as JSON [{name, description}]")
796
810
  ls.add_argument("server", help="server name (e.g. acme) or URL")
811
+ ls.add_argument("--schema", action="store_true", help="include raw inputSchema JSON per tool")
797
812
  ls.add_argument("--stdio", metavar="CMD", help="use stdio transport: 'python server.py' (no auth)")
798
813
  _add_conn_args(ls)
799
814
  ls.set_defaults(func=_cmd_list)
@@ -32,6 +32,17 @@ def py_type(schema: dict | None) -> str:
32
32
  if any(k in schema for k in ("anyOf", "oneOf", "allOf")):
33
33
  return "Any"
34
34
 
35
+ # Enum: emit Literal[...] only when every member is a PEP 586-safe scalar.
36
+ # Floats, lists, and dicts are not valid Literal parameters; fall through to
37
+ # the type-based path so we emit the declared type (or Any) instead.
38
+ enum_vals = schema.get("enum")
39
+ if isinstance(enum_vals, list) and enum_vals:
40
+ if all(type(v) in (str, bool, int, bytes, type(None)) for v in enum_vals):
41
+ t = schema.get("type")
42
+ nullable = isinstance(t, list) and "null" in t
43
+ inner = "Literal[" + ", ".join(repr(v) for v in enum_vals) + "]"
44
+ return f"{inner} | None" if nullable else inner
45
+
35
46
  t = schema.get("type")
36
47
  if isinstance(t, list): # e.g. ["string", "null"]
37
48
  non_null = [x for x in t if x != "null"]
@@ -204,7 +215,61 @@ _DIG_LIST = '''def _dig_list(obj: Any, path: tuple[str, ...]) -> list:
204
215
  return cur'''
205
216
 
206
217
 
207
- def _render_overloaded(tool: dict, fn: str, raw_name: str, props: dict, required: set, shape: dict) -> str:
218
+ def _docstring_with_args(tool: dict, ordered: list[tuple[str, dict]], indent: str) -> str:
219
+ """Build docstring with description + Args section from inputSchema properties."""
220
+ desc = (tool.get("description") or "").strip()
221
+ props_lines = []
222
+ for pname, pschema in ordered:
223
+ py = sanitize(pname)
224
+ parts: list[str] = []
225
+ if not isinstance(pschema, dict):
226
+ props_lines.append(f" {py}:")
227
+ continue
228
+ pdesc = (pschema.get("description") or "").strip()
229
+ if pdesc:
230
+ parts.append(pdesc)
231
+ if pschema.get("enum"):
232
+ parts.append(f"One of: {', '.join(repr(v) for v in pschema['enum'])}")
233
+ if "default" in pschema:
234
+ parts.append(f"Default: {pschema['default']!r}")
235
+ detail = ". ".join(parts)
236
+ if detail:
237
+ props_lines.append(f" {py}: {detail}")
238
+ else:
239
+ props_lines.append(f" {py}:")
240
+
241
+ if not props_lines:
242
+ return _docstring(desc or None, indent)
243
+
244
+ if desc:
245
+ body = desc + "\n\nArgs:\n" + "\n".join(props_lines)
246
+ else:
247
+ body = "Args:\n" + "\n".join(props_lines)
248
+
249
+ # Use repr() to ensure injection safety (description is server-controlled text)
250
+ # But we want multi-line docstring format where possible.
251
+ # Fall back to repr() only if text contains backslash, triple-quotes, or trailing quote.
252
+ text = body.strip()
253
+ if "\\" in text or '"""' in text or text.endswith('"'):
254
+ return f"{indent}{repr(text)}"
255
+ lines_list = text.splitlines()
256
+ if len(lines_list) == 1:
257
+ return f'{indent}"""{lines_list[0]}"""'
258
+ parts = [lines_list[0]] + [f"{indent}{ln}" if ln else "" for ln in lines_list[1:]]
259
+ joined = "\n".join(parts)
260
+ return f'{indent}"""{joined}\n{indent}"""'
261
+
262
+
263
+ def _render_overloaded(
264
+ tool: dict,
265
+ fn: str,
266
+ raw_name: str,
267
+ props: dict,
268
+ required: set,
269
+ shape: dict,
270
+ embed_schema: bool = False,
271
+ schema: dict | None = None,
272
+ ) -> str:
208
273
  """Emit @overload stubs (one per discriminator value) + impl signature."""
209
274
  disc: str = shape["discriminator"]
210
275
  variants: dict = shape["variants"]
@@ -245,7 +310,10 @@ def _render_overloaded(tool: dict, fn: str, raw_name: str, props: dict, required
245
310
 
246
311
  impl_ret = f"list[{union_ret}]" if container == "list" else union_ret
247
312
  impl_lines = [f"async def {fn}({_sig(_build_params('int'))}) -> {impl_ret}:"]
248
- impl_lines.append(_docstring(tool.get("description"), " "))
313
+ if embed_schema:
314
+ impl_lines.append(_docstring_with_args(tool, ordered, " "))
315
+ else:
316
+ impl_lines.append(_docstring(tool.get("description"), " "))
249
317
 
250
318
  body_args = [(sanitize(pname), pname, pname in required or pname == disc) for pname, _ in ordered]
251
319
  req_pairs = [(py, pname) for py, pname, is_req in body_args if is_req]
@@ -274,11 +342,15 @@ def _render_overloaded(tool: dict, fn: str, raw_name: str, props: dict, required
274
342
  else:
275
343
  impl_lines.append(f' return cast("{impl_ret}", {call})')
276
344
 
277
- blocks.append("\n".join(impl_lines))
345
+ impl_block = "\n".join(impl_lines)
346
+ if embed_schema:
347
+ _schema = schema if schema is not None else {}
348
+ impl_block += f"\n\n{fn}.__schema__ = {repr(_schema)}"
349
+ blocks.append(impl_block)
278
350
  return "\n\n".join(blocks)
279
351
 
280
352
 
281
- def render_tool(tool: dict, shape: dict | None = None) -> str:
353
+ def render_tool(tool: dict, shape: dict | None = None, embed_schema: bool = False) -> str:
282
354
  """Render one MCP tool into a typed `async def` against the McpCaller seam.
283
355
 
284
356
  With a probe-derived `shape` (unwrap path / return model / input overrides),
@@ -293,7 +365,7 @@ def render_tool(tool: dict, shape: dict | None = None) -> str:
293
365
 
294
366
  shape = shape or {}
295
367
  if shape.get("discriminator") and shape.get("variants"):
296
- return _render_overloaded(tool, fn, raw_name, props, required, shape)
368
+ return _render_overloaded(tool, fn, raw_name, props, required, shape, embed_schema=embed_schema, schema=schema)
297
369
 
298
370
  unwrap: list = shape.get("unwrap") or []
299
371
  return_model: str | None = shape.get("return_model")
@@ -327,7 +399,10 @@ def render_tool(tool: dict, shape: dict | None = None) -> str:
327
399
  sig = ", ".join(sig_params)
328
400
 
329
401
  lines = [f"async def {fn}({sig}) -> {ret_ann}:"]
330
- lines.append(_docstring(tool.get("description"), " "))
402
+ if embed_schema:
403
+ lines.append(_docstring_with_args(tool, ordered, " "))
404
+ else:
405
+ lines.append(_docstring(tool.get("description"), " "))
331
406
 
332
407
  req_pairs = [(py, pname) for py, pname, is_req in body_args if is_req]
333
408
  opt_pairs = [(py, pname) for py, pname, is_req in body_args if not is_req]
@@ -358,7 +433,10 @@ def render_tool(tool: dict, shape: dict | None = None) -> str:
358
433
  else:
359
434
  lines.append(f" return {call}")
360
435
 
361
- return "\n".join(lines)
436
+ result = "\n".join(lines)
437
+ if embed_schema:
438
+ result += f"\n\n{fn}.__schema__ = {repr(schema)}"
439
+ return result
362
440
 
363
441
 
364
442
  _HEADER = '''"""Generated MCP wrappers for the {server!r} server. DO NOT hand-edit signatures.
@@ -380,7 +458,9 @@ SERVER = {server!r}
380
458
  _PY_BUILTINS = frozenset({"str", "int", "float", "list", "dict", "bool", "bytes", "object", "type", "set", "tuple"})
381
459
 
382
460
 
383
- def render_module(server: str, tools: list[dict], shapes: dict | None = None, probe_note: str = "") -> str:
461
+ def render_module(
462
+ server: str, tools: list[dict], shapes: dict | None = None, probe_note: str = "", embed_schema: bool = False
463
+ ) -> str:
384
464
  """Render a full wrapper module.
385
465
 
386
466
  `shapes` maps tool name -> probe-derived shape-spec (see render_tool). When
@@ -392,10 +472,28 @@ def render_module(server: str, tools: list[dict], shapes: dict | None = None, pr
392
472
  has_disc = any(s.get("discriminator") and s.get("variants") for s in shapes.values())
393
473
  needs_json = bool(shapes) and any((shapes.get(t["name"]) or {}).get("unwrap") for t in tools)
394
474
  has_typed_return = has_disc or any(s.get("return_model") for s in shapes.values())
475
+
476
+ def _schema_has_enum(s: dict | None) -> bool:
477
+ if not isinstance(s, dict):
478
+ return False
479
+ if s.get("enum"):
480
+ return True
481
+ # recurse into array items (py_type can emit Literal for nested enums)
482
+ items = s.get("items")
483
+ return isinstance(items, dict) and bool(items.get("enum"))
484
+
485
+ has_enum = any(
486
+ any(_schema_has_enum(pschema) for pschema in (((t.get("inputSchema") or {}).get("properties") or {}).values()))
487
+ for t in tools
488
+ )
395
489
  if has_disc:
396
490
  type_imports = "from typing import Any, Literal, TypedDict, cast, overload"
491
+ elif has_typed_return and has_enum:
492
+ type_imports = "from typing import Any, Literal, TypedDict, cast"
397
493
  elif has_typed_return:
398
494
  type_imports = "from typing import Any, TypedDict, cast"
495
+ elif has_enum:
496
+ type_imports = "from typing import Any, Literal"
399
497
  else:
400
498
  type_imports = "from typing import Any"
401
499
  imports = ("import json\n" + type_imports) if needs_json else type_imports
@@ -447,7 +545,7 @@ def render_module(server: str, tools: list[dict], shapes: dict | None = None, pr
447
545
  parts.append(_DIG_LIST)
448
546
 
449
547
  for tool in sorted(tools, key=lambda t: t["name"]):
450
- parts.append(render_tool(tool, shapes.get(tool["name"])))
548
+ parts.append(render_tool(tool, shapes.get(tool["name"]), embed_schema=embed_schema))
451
549
  return "\n\n\n".join(parts) + "\n"
452
550
 
453
551
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-client-kit"
3
- version = "0.0.4"
3
+ version = "0.2.0"
4
4
  description = "mcp-client-kit — generate typed Python wrappers for any MCP server (codegen skill + CLI)."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -78,31 +78,35 @@ For dispatch mechanics see `superpowers:dispatching-parallel-agents`.
78
78
  etc.) to the launched process. `MCPGEN_SERVERS` must prefix the command in the **same
79
79
  shell invocation** — it does not persist across calls.
80
80
  ```bash
81
- MCPGEN_SERVERS=servers.json mcpgen codegen <server> --out <server>/<server>.py
81
+ MCPGEN_SERVERS=servers.json mcpgen codegen <server> --out <server>/<server>.py --embed-schema
82
82
  ```
83
83
 
84
84
  **Alternative — pass transport values directly, without a config file:**
85
85
  ```bash
86
86
  # stdio (pass the full launch command):
87
- mcpgen codegen <server> --stdio "uvx mcp-server-time" --out <server>/<server>.py
87
+ mcpgen codegen <server> --stdio "uvx mcp-server-time" --out <server>/<server>.py --embed-schema
88
88
 
89
89
  # HTTP no-auth:
90
- mcpgen codegen <server> --url "https://mcp.example.com/mcp" --out <server>/<server>.py
90
+ mcpgen codegen <server> --url "https://mcp.example.com/mcp" --out <server>/<server>.py --embed-schema
91
91
 
92
92
  # HTTP Bearer:
93
- mcpgen codegen <server> --url "https://api.example.com/mcp/" --bearer "$MY_TOKEN" --out <server>/<server>.py
93
+ mcpgen codegen <server> --url "https://api.example.com/mcp/" --bearer "$MY_TOKEN" --out <server>/<server>.py --embed-schema
94
94
  ```
95
95
  `codegen`, `list`, `probe`, and `call` each require the same transport flags
96
96
  (`--stdio` / `--url` / `--bearer` / `--config`) on every invocation — they do **not**
97
97
  inherit flags from a prior run. `merge` and `discover` accept no transport flags.
98
98
 
99
- Parses; every tool typed from `inputSchema`; returns `Any`.
99
+ Parses; every tool typed from `inputSchema`; returns `Any`. `--embed-schema` (used
100
+ above) also emits `fn.__schema__ = {<raw inputSchema>}` on each function and an Args
101
+ docstring section listing each param's description, enum values, and default.
100
102
 
101
103
  2. **Select tools to probe (interactive gate).**
102
104
 
103
- a. Run `mcpgen list <server>` → get `[{name, description}]` for every tool.
104
- **Probe only tools that appear in this output.** Do not add tools from
105
- system-prompt context, documentation, or prior knowledge.
105
+ a. Run `mcpgen list <server> --schema` → get `[{name, description, inputSchema}]`
106
+ for every tool. **Probe only tools that appear in this output.** Do not add tools
107
+ from system-prompt context, documentation, or prior knowledge. The `inputSchema`
108
+ field in this output is the authoritative source for steps 3's required-arg and
109
+ enum-constraint checks — no separate schema fetch is needed.
106
110
 
107
111
  b. Print a report of all tools in this format:
108
112
  ```
@@ -257,21 +261,22 @@ For dispatch mechanics see `superpowers:dispatching-parallel-agents`.
257
261
  `get_entity_fields` per `entityType`, `get_acme_glossary`); (c) `AskUserQuestion`
258
262
  if not discoverable from available tools.
259
263
 
260
- **Check `inputSchema.required` before constructing probe args.** If the array is
261
- non-empty, never probe with `'{}'` — call the tool with minimal valid args on the first
262
- attempt. Invent realistic-looking but fake values for required string/ID args that carry
263
- no `enum`. For GitHub servers, prefer `owner: "microsoft", repo: "vscode"` as the default
264
- probe repo (`octocat/Hello-World` lacks releases/tags/issue fields and produces `[<empty>]`
265
- for those tools).
264
+ **Check `inputSchema.required` before constructing probe args** (read from `list --schema`
265
+ output). If the array is non-empty, never probe with `'{}'` — call the tool with minimal
266
+ valid args on the first attempt. Invent realistic-looking but fake values for required
267
+ string/ID args that carry no `enum`. For GitHub servers, prefer `owner: "microsoft",
268
+ repo: "vscode"` as the default probe repo (`octocat/Hello-World` lacks releases/tags/issue
269
+ fields and produces `[<empty>]` for those tools).
266
270
 
267
- **Inspect `inputSchema` for enum constraints before constructing probe args.** For each
268
- required param, check `inputSchema.properties[param].enum`. If an `enum` array is
269
- present, use its **first listed value** as the probe arg instead of inventing a value —
271
+ **Inspect `inputSchema` for enum constraints before constructing probe args** (read from
272
+ `list --schema` output, field `inputSchema.properties[param].enum`). If an `enum` array
273
+ is present, use its **first listed value** as the probe arg instead of inventing a value —
270
274
  invented values will be rejected with an MCP validation error. Record the chosen value
271
- in `probed_args`.
275
+ in `probed_args`. Note: codegen maps enum params to `Literal[...]` automatically, so the
276
+ generated signature already encodes the allowed values.
272
277
 
273
278
  Example: `"city": {"type": "string", "enum": ["New York", "Chicago", "Los Angeles"]}`
274
- → probe with `city="New York"`.
279
+ → probe with `city="New York"` → generated type `Literal['New York', 'Chicago', 'Los Angeles']`.
275
280
 
276
281
  **JSON-in-string detection.** Some servers serialize structured data as a JSON string
277
282
  inside the MCP envelope (e.g. `directory_tree` returns a JSON string, not a parsed
@@ -406,7 +411,7 @@ For dispatch mechanics see `superpowers:dispatching-parallel-agents`.
406
411
 
407
412
  5. **Regenerate.** (same transport flags as step 1)
408
413
  ```bash
409
- mcpgen codegen <server> --stdio "uvx mcp-server-time" --out <server>/<server>.py --shapes <server>/<server>.shapes.json
414
+ mcpgen codegen <server> --stdio "uvx mcp-server-time" --out <server>/<server>.py --shapes <server>/<server>.shapes.json --embed-schema
410
415
  # or: --url / --bearer for HTTP servers
411
416
  ```
412
417
  (`<server>.shapes.json` sitting beside `--out` is auto-detected; `--shapes` is the
@@ -487,3 +492,7 @@ For dispatch mechanics see `superpowers:dispatching-parallel-agents`.
487
492
  values or resolved to a base model / `Any`. A single-variant model is a silent lie
488
493
  for all other variants — the exact mistake that typed `query_acme` as `list[Person]`
489
494
  when entityType=2/7/… return completely different shapes.
495
+ - **Enum params render as `Literal[...]` automatically** — do not hand-widen them to
496
+ `str`. Codegen's `py_type()` emits `Literal['a', 'b', ...]` for any param whose
497
+ `inputSchema` carries an `enum` array (no flag required). Widen to `str` only if
498
+ the server's actual validation rejects values that aren't in the declared enum.
@@ -0,0 +1,173 @@
1
+ """Tests for `mcpgen list` — network-free; _list_tools is monkeypatched."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from types import SimpleNamespace
7
+ from unittest.mock import AsyncMock, patch
8
+
9
+ from mcpgen.cli import _cmd_list
10
+
11
+ # ── helpers ───────────────────────────────────────────────────────────────────
12
+
13
+
14
+ def _ns(server: str, schema: bool = False) -> SimpleNamespace:
15
+ return SimpleNamespace(
16
+ server=server,
17
+ schema=schema,
18
+ stdio=None,
19
+ url=None,
20
+ bearer=None,
21
+ client_name=None,
22
+ config=None,
23
+ cred_backend=None,
24
+ env=None,
25
+ )
26
+
27
+
28
+ FAKE_TOOLS = [
29
+ {
30
+ "name": "get_user",
31
+ "description": "Get user by ID",
32
+ "inputSchema": {"type": "object", "properties": {"user_id": {"type": "string"}}, "required": ["user_id"]},
33
+ },
34
+ {
35
+ "name": "list_users",
36
+ "description": "List all users",
37
+ "inputSchema": {"type": "object", "properties": {"limit": {"type": "integer"}}},
38
+ },
39
+ ]
40
+
41
+
42
+ # ── without --schema ──────────────────────────────────────────────────────────
43
+
44
+
45
+ def test_list_without_schema_includes_name_and_description(capsys):
46
+ """Without --schema, output is {name, description} only."""
47
+ ns = _ns("acme", schema=False)
48
+
49
+ with patch("mcpgen.cli._list_tools", new_callable=AsyncMock, return_value=FAKE_TOOLS):
50
+ rc = _cmd_list(ns)
51
+
52
+ assert rc == 0
53
+ out = capsys.readouterr().out
54
+ result = json.loads(out)
55
+
56
+ assert len(result) == 2
57
+ assert result[0] == {"name": "get_user", "description": "Get user by ID"}
58
+ assert result[1] == {"name": "list_users", "description": "List all users"}
59
+
60
+
61
+ def test_list_without_schema_no_inputschema_in_output(capsys):
62
+ """Without --schema, inputSchema should not be in the output."""
63
+ ns = _ns("acme", schema=False)
64
+
65
+ with patch("mcpgen.cli._list_tools", new_callable=AsyncMock, return_value=FAKE_TOOLS):
66
+ _cmd_list(ns)
67
+
68
+ out = capsys.readouterr().out
69
+ result = json.loads(out)
70
+
71
+ for item in result:
72
+ assert "inputSchema" not in item
73
+
74
+
75
+ def test_list_without_schema_empty_description_fallback(capsys):
76
+ """When description is None or absent, it should fallback to empty string."""
77
+ tools = [
78
+ {"name": "no_desc_none", "description": None, "inputSchema": {}},
79
+ {"name": "no_desc_absent", "inputSchema": {}},
80
+ ]
81
+ ns = _ns("acme", schema=False)
82
+
83
+ with patch("mcpgen.cli._list_tools", new_callable=AsyncMock, return_value=tools):
84
+ _cmd_list(ns)
85
+
86
+ out = capsys.readouterr().out
87
+ result = json.loads(out)
88
+
89
+ assert result[0] == {"name": "no_desc_none", "description": ""}
90
+ assert result[1] == {"name": "no_desc_absent", "description": ""}
91
+
92
+
93
+ # ── with --schema ─────────────────────────────────────────────────────────────
94
+
95
+
96
+ def test_list_with_schema_includes_inputschema(capsys):
97
+ """With --schema, output includes inputSchema."""
98
+ ns = _ns("acme", schema=True)
99
+
100
+ with patch("mcpgen.cli._list_tools", new_callable=AsyncMock, return_value=FAKE_TOOLS):
101
+ rc = _cmd_list(ns)
102
+
103
+ assert rc == 0
104
+ out = capsys.readouterr().out
105
+ result = json.loads(out)
106
+
107
+ assert len(result) == 2
108
+ assert "inputSchema" in result[0]
109
+ assert result[0]["inputSchema"] == FAKE_TOOLS[0]["inputSchema"]
110
+ assert result[1]["inputSchema"] == FAKE_TOOLS[1]["inputSchema"]
111
+
112
+
113
+ def test_list_with_schema_includes_name_and_description(capsys):
114
+ """With --schema, output still includes name and description."""
115
+ ns = _ns("acme", schema=True)
116
+
117
+ with patch("mcpgen.cli._list_tools", new_callable=AsyncMock, return_value=FAKE_TOOLS):
118
+ _cmd_list(ns)
119
+
120
+ out = capsys.readouterr().out
121
+ result = json.loads(out)
122
+
123
+ assert result[0]["name"] == "get_user"
124
+ assert result[0]["description"] == "Get user by ID"
125
+ assert result[1]["name"] == "list_users"
126
+ assert result[1]["description"] == "List all users"
127
+
128
+
129
+ def test_list_with_schema_empty_inputschema_fallback(capsys):
130
+ """When inputSchema is None or absent, it should fallback to empty dict."""
131
+ tools = [
132
+ {"name": "no_schema_none", "description": "A tool", "inputSchema": None},
133
+ {"name": "no_schema_absent", "description": "A tool"},
134
+ ]
135
+ ns = _ns("acme", schema=True)
136
+
137
+ with patch("mcpgen.cli._list_tools", new_callable=AsyncMock, return_value=tools):
138
+ _cmd_list(ns)
139
+
140
+ out = capsys.readouterr().out
141
+ result = json.loads(out)
142
+
143
+ assert result[0]["inputSchema"] == {}
144
+ assert result[1]["inputSchema"] == {}
145
+
146
+
147
+ def test_list_with_schema_all_fields_present(capsys):
148
+ """With --schema, each tool has all three fields: name, description, inputSchema."""
149
+ ns = _ns("acme", schema=True)
150
+
151
+ with patch("mcpgen.cli._list_tools", new_callable=AsyncMock, return_value=FAKE_TOOLS):
152
+ _cmd_list(ns)
153
+
154
+ out = capsys.readouterr().out
155
+ result = json.loads(out)
156
+
157
+ for item in result:
158
+ assert set(item.keys()) == {"name", "description", "inputSchema"}
159
+
160
+
161
+ # ── error handling ────────────────────────────────────────────────────────────
162
+
163
+
164
+ def test_list_handles_list_tools_error(capsys):
165
+ """When _list_tools raises an error, _cmd_list should return 1."""
166
+ ns = _ns("acme", schema=False)
167
+
168
+ with patch("mcpgen.cli._list_tools", new_callable=AsyncMock, side_effect=FileNotFoundError("server not found")):
169
+ rc = _cmd_list(ns)
170
+
171
+ assert rc == 1
172
+ err = capsys.readouterr().err
173
+ assert "error" in err.lower()
@@ -961,3 +961,165 @@ def test_detect_discriminators_denylist_camelcase_compound_forms():
961
961
  result = codegen.detect_discriminators(tools)
962
962
  for param in compound_params:
963
963
  assert param not in result, f"compound param {param!r} must be in denylist"
964
+
965
+
966
+ # ---------------------------------------------------------------------------
967
+ # Task C — enum → Literal[...] in py_type and render_module
968
+ # ---------------------------------------------------------------------------
969
+
970
+
971
+ def test_py_type_enum_string_literal():
972
+ assert codegen.py_type({"type": "string", "enum": ["asc", "desc"]}) == "Literal['asc', 'desc']"
973
+
974
+
975
+ def test_py_type_enum_int_literal():
976
+ assert codegen.py_type({"enum": [1, 2, 3]}) == "Literal[1, 2, 3]"
977
+
978
+
979
+ def test_py_type_enum_nullable():
980
+ assert codegen.py_type({"type": ["string", "null"], "enum": ["a", "b"]}) == "Literal['a', 'b'] | None"
981
+
982
+
983
+ def test_py_type_enum_empty_falls_through():
984
+ # Empty enum list must not produce Literal[] (syntax error); fall through to scalar.
985
+ assert codegen.py_type({"type": "string", "enum": []}) == "str"
986
+
987
+
988
+ def test_py_type_enum_float_falls_through_to_type():
989
+ # Floats are not PEP 586-safe; fall through to declared type.
990
+ assert codegen.py_type({"type": "number", "enum": [1.5, 2.5]}) == "float"
991
+
992
+
993
+ def test_py_type_enum_float_no_type_falls_through_to_any():
994
+ # No type key + unsafe enum members → Any.
995
+ assert codegen.py_type({"enum": [1.5, 2.5]}) == "Any"
996
+
997
+
998
+ def test_py_type_enum_list_members_falls_through():
999
+ # List values are not valid Literal parameters.
1000
+ assert codegen.py_type({"enum": [[1, 2], [3, 4]]}) == "Any"
1001
+
1002
+
1003
+ def test_py_type_enum_dict_members_falls_through():
1004
+ # Dict values are not valid Literal parameters.
1005
+ assert codegen.py_type({"enum": [{"a": 1}]}) == "Any"
1006
+
1007
+
1008
+ def test_py_type_enum_mixed_safe_unsafe_falls_through():
1009
+ # Mixed scalars + float: all-or-nothing, fall through.
1010
+ assert codegen.py_type({"type": "string", "enum": ["ok", 1.5]}) == "str"
1011
+
1012
+
1013
+ def test_render_module_enum_param_imports_literal():
1014
+ tools = [
1015
+ {
1016
+ "name": "sort_items",
1017
+ "description": "Sort items.",
1018
+ "inputSchema": {
1019
+ "type": "object",
1020
+ "properties": {
1021
+ "direction": {"type": "string", "enum": ["asc", "desc"]},
1022
+ },
1023
+ "required": ["direction"],
1024
+ },
1025
+ }
1026
+ ]
1027
+ src = codegen.render_module("demo", tools)
1028
+ assert "from typing import Any, Literal" in src
1029
+
1030
+
1031
+ def test_render_tool_enum_param_uses_literal():
1032
+ tool = {
1033
+ "name": "sort_items",
1034
+ "description": "Sort items.",
1035
+ "inputSchema": {
1036
+ "type": "object",
1037
+ "properties": {
1038
+ "direction": {"type": "string", "enum": ["asc", "desc"]},
1039
+ },
1040
+ "required": ["direction"],
1041
+ },
1042
+ }
1043
+ src = codegen.render_tool(tool)
1044
+ assert "Literal[" in src
1045
+ assert "Literal['asc', 'desc']" in src
1046
+ # Generated source must be valid Python when embedded in a module.
1047
+ tools = [tool]
1048
+ module_src = codegen.render_module("demo", tools)
1049
+ ast.parse(module_src)
1050
+
1051
+
1052
+ # ── --embed-schema tests ──────────────────────────────────────────────────────
1053
+
1054
+ _EMBED_TOOL = {
1055
+ "name": "get_item",
1056
+ "description": "Fetch an item.",
1057
+ "inputSchema": {
1058
+ "type": "object",
1059
+ "properties": {
1060
+ "item_id": {"type": "string", "description": "The item identifier"},
1061
+ "format": {"type": "string", "enum": ["json", "xml"], "default": "json", "description": "Output format"},
1062
+ "verbose": {"type": "boolean"},
1063
+ },
1064
+ "required": ["item_id"],
1065
+ },
1066
+ }
1067
+
1068
+
1069
+ def test_render_tool_embed_schema_adds_schema_attr():
1070
+ src = codegen.render_tool(_EMBED_TOOL, embed_schema=True)
1071
+ assert ".__schema__ = " in src
1072
+ # Must be valid Python and the attribute must be accessible.
1073
+ ns: dict = {}
1074
+ # Wrap in a module-like context so the async def can be exec'd.
1075
+ module_src = (
1076
+ "from __future__ import annotations\n"
1077
+ "from typing import Any\n"
1078
+ "from mcpgen.seam import McpCaller\n"
1079
+ "SERVER = 'demo'\n\n" + src
1080
+ )
1081
+ exec(compile(module_src, "<test>", "exec"), ns)
1082
+ fn = ns["get_item"]
1083
+ assert hasattr(fn, "__schema__")
1084
+ assert fn.__schema__ == (_EMBED_TOOL.get("inputSchema") or {})
1085
+
1086
+
1087
+ def test_render_tool_embed_schema_adds_args_docstring():
1088
+ src = codegen.render_tool(_EMBED_TOOL, embed_schema=True)
1089
+ assert "Args:" in src
1090
+ assert "item_id:" in src
1091
+ assert "The item identifier" in src
1092
+ assert "One of:" in src
1093
+ assert "'json'" in src
1094
+ assert "Default:" in src
1095
+ # Tighten assertion to check exact indentation
1096
+ assert " Args:" in src # 4 spaces (not 8)
1097
+ assert " item_id:" in src # 8 spaces (not 12)
1098
+
1099
+
1100
+ def test_render_tool_no_embed_schema_unchanged():
1101
+ src_default = codegen.render_tool(_EMBED_TOOL)
1102
+ src_false = codegen.render_tool(_EMBED_TOOL, embed_schema=False)
1103
+ src_no_flag = codegen.render_tool(_EMBED_TOOL)
1104
+ assert src_default == src_false == src_no_flag
1105
+ assert ".__schema__" not in src_default
1106
+ assert "Args:" not in src_default
1107
+
1108
+
1109
+ def test_render_module_embed_schema_passes_through():
1110
+ tools = [_EMBED_TOOL]
1111
+ src = codegen.render_module("demo", tools, embed_schema=True)
1112
+ ast.parse(src)
1113
+ assert ".__schema__ = " in src
1114
+
1115
+
1116
+ def test_render_tool_embed_schema_no_args_no_args_section():
1117
+ tool = {
1118
+ "name": "ping",
1119
+ "description": "Ping the server.",
1120
+ "inputSchema": {"type": "object", "properties": {}, "required": []},
1121
+ }
1122
+ src = codegen.render_tool(tool, embed_schema=True)
1123
+ assert "Args:" not in src
1124
+ # __schema__ attr should still be present (empty dict).
1125
+ assert ".__schema__ = " in src
@@ -535,7 +535,7 @@ cli = [
535
535
 
536
536
  [[package]]
537
537
  name = "mcp-client-kit"
538
- version = "0.0.4"
538
+ version = "0.2.0"
539
539
  source = { editable = "." }
540
540
  dependencies = [
541
541
  { name = "httpx" },
File without changes