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.
- mcp_client_kit-0.2.0/.claude/skills/releasing-a-version/SKILL.md +127 -0
- mcp_client_kit-0.2.0/CHANGELOG.md +68 -0
- mcp_client_kit-0.2.0/CLAUDE.md +86 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/PKG-INFO +3 -3
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/README.md +2 -2
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/USAGE.md +11 -2
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/mcpgen/cli.py +17 -2
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/mcpgen/codegen.py +107 -9
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/pyproject.toml +1 -1
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/skills/generate-mcp-wrappers/SKILL.md +29 -20
- mcp_client_kit-0.2.0/tests/test_cli_list.py +173 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_codegen.py +162 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/uv.lock +1 -1
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/.claude-plugin/marketplace.json +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/.claude-plugin/plugin.json +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/.github/workflows/ci.yml +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/.github/workflows/publish.yml +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/.gitignore +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/LICENSE +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/CODEGEN_SKILL_IDEA.md +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/DISTRIBUTION.md +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/EXTRACTION_ANALYSIS.md +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/LANDSCAPE.md +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/MARKET_RESEARCH.md +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/RUNNING_LOCALLY.md +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/doc/VERDICT.md +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/mcpgen/__init__.py +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/mcpgen/_bridge.py +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/mcpgen/discovery.py +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/mcpgen/seam.py +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/servers.example.json +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/skills/generate-mcp-runner/SKILL.md +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/skills/generate-mcp-runner/runner_templates/http_bearer.py +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/skills/generate-mcp-runner/runner_templates/http_oauth.py +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/skills/generate-mcp-runner/runner_templates/http_public.py +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/skills/generate-mcp-runner/runner_templates/stdio.py +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_bridge.py +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_cli_call.py +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_cli_env.py +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_cli_merge.py +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_cli_probe.py +0 -0
- {mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/tests/test_config.py +0 -0
- {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
|
|
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}]
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
|
@@ -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
|
|
104
|
-
**Probe only tools that appear in this output.** Do not add tools
|
|
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
|
|
261
|
-
non-empty, never probe with `'{}'` — call the tool with minimal
|
|
262
|
-
attempt. Invent realistic-looking but fake values for required
|
|
263
|
-
no `enum`. For GitHub servers, prefer `owner: "microsoft",
|
|
264
|
-
probe repo (`octocat/Hello-World` lacks releases/tags/issue
|
|
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
|
|
268
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_client_kit-0.0.4 → mcp_client_kit-0.2.0}/skills/generate-mcp-runner/runner_templates/stdio.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|