mcp-client-kit 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/CHANGELOG.md +35 -1
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/PKG-INFO +1 -1
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/mcpgen/_bridge.py +109 -31
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/mcpgen/cli.py +30 -12
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/mcpgen/codegen.py +161 -27
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/pyproject.toml +1 -1
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/skills/generate-mcp-wrappers/SKILL.md +39 -21
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_bridge.py +329 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_cli_list.py +111 -2
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_cli_merge.py +51 -1
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_cli_probe.py +60 -7
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_codegen.py +305 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/uv.lock +2 -2
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/.claude/skills/releasing-a-version/SKILL.md +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/.claude-plugin/marketplace.json +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/.claude-plugin/plugin.json +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/.github/workflows/ci.yml +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/.github/workflows/publish.yml +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/.gitignore +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/CLAUDE.md +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/LICENSE +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/README.md +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/CODEGEN_SKILL_IDEA.md +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/DISTRIBUTION.md +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/EXTRACTION_ANALYSIS.md +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/LANDSCAPE.md +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/MARKET_RESEARCH.md +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/RUNNING_LOCALLY.md +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/USAGE.md +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/VERDICT.md +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/mcpgen/__init__.py +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/mcpgen/discovery.py +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/mcpgen/seam.py +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/servers.example.json +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/SKILL.md +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/runner_templates/http_bearer.py +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/runner_templates/http_oauth.py +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/runner_templates/http_public.py +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/runner_templates/stdio.py +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_cli_call.py +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_cli_env.py +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_config.py +0 -0
- {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_discovery.py +0 -0
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [Unreleased] — 0.
|
|
3
|
+
## [Unreleased] — 0.4.0
|
|
4
|
+
|
|
5
|
+
## [0.3.0] — 2026-07-14
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`mcpgen login` — OAuth token exchange failed with `400 invalid_request`** on authorization
|
|
10
|
+
servers that strictly enforce RFC 6749 §2.3 (`"Client must not use multiple authentication
|
|
11
|
+
methods"`). Dynamic client registration omitted `token_endpoint_auth_method`, so the server
|
|
12
|
+
defaulted us to `client_secret_basic` and issued a `client_secret`; the MCP SDK then sent an
|
|
13
|
+
`Authorization: Basic` header *and* `client_id` in the form body — two client authentication
|
|
14
|
+
methods in one request. mcpgen now registers as a public client
|
|
15
|
+
(`token_endpoint_auth_method: "none"`), which is the correct posture for a distributed CLI
|
|
16
|
+
(RFC 8252 §8.4) and is fully secured by the PKCE (S256) the SDK already performs on every
|
|
17
|
+
authorization code grant. No security regression: the discarded `client_secret` was issued
|
|
18
|
+
per-install by dynamic registration and stored in the same `credentials.json` as the refresh
|
|
19
|
+
token, so anyone who could read the secret could already read the token — it never added a
|
|
20
|
+
layer of defense.
|
|
21
|
+
|
|
22
|
+
- **Discriminated-union codegen mistyped `string` discriminators with numeric-looking
|
|
23
|
+
enum values as `int`.** Discriminator type was inferred solely by sniffing whether
|
|
24
|
+
shape-spec variant keys looked numeric (e.g. `"1"`, `"2"`), so a genuinely
|
|
25
|
+
`type: "string"` discriminator with those enum values got a wrong `int` `Literal`
|
|
26
|
+
in the generated `@overload` stubs. Discriminator type is now resolved from an
|
|
27
|
+
explicit `input_overrides` entry, then the tool's declared `inputSchema` type,
|
|
28
|
+
and only falls back to key-sniffing when neither is available.
|
|
29
|
+
|
|
30
|
+
- **`mcpgen call`/`probe` crashed on `resource_link` content items** — real MCP SDK
|
|
31
|
+
`ResourceLink.uri` is a pydantic `AnyUrl`, not `str`; `json.dumps` on the summarized
|
|
32
|
+
content raised `TypeError`. The summary now coerces `uri` to `str`.
|
|
33
|
+
|
|
34
|
+
- **`mcpgen probe` reported inflated payload sizes for non-ASCII content** — size was
|
|
35
|
+
measured as the character count of `json.dumps`'s default `ensure_ascii=True`
|
|
36
|
+
output, which escapes every non-ASCII character to a `\uXXXX` sequence. Size is
|
|
37
|
+
now measured as real UTF-8 byte length.
|
|
4
38
|
|
|
5
39
|
## [0.2.0] — 2026-06-20
|
|
6
40
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-client-kit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: mcp-client-kit — generate typed Python wrappers for any MCP server (codegen skill + CLI).
|
|
5
5
|
Project-URL: Homepage, https://github.com/svd/mcp-client-kit
|
|
6
6
|
Project-URL: Source, https://github.com/svd/mcp-client-kit
|
|
@@ -58,7 +58,7 @@ from urllib.parse import parse_qs, urlparse
|
|
|
58
58
|
|
|
59
59
|
import httpx
|
|
60
60
|
from mcp import ClientSession
|
|
61
|
-
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
|
61
|
+
from mcp.client.auth import OAuthClientProvider, OAuthRegistrationError, TokenStorage
|
|
62
62
|
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
63
63
|
from mcp.client.streamable_http import streamable_http_client
|
|
64
64
|
from mcp.shared._httpx_utils import create_mcp_http_client
|
|
@@ -330,6 +330,44 @@ def _resolve_client_name(server_name: str) -> str:
|
|
|
330
330
|
return _client_names_cache.get(server_name) or f"mcpgen ({server_name})"
|
|
331
331
|
|
|
332
332
|
|
|
333
|
+
def _client_metadata(server_name: str, callback_uri: str, client_name: str | None = None) -> OAuthClientMetadata:
|
|
334
|
+
"""Dynamic-registration metadata (RFC 7591). Shared by both OAuth entry points.
|
|
335
|
+
|
|
336
|
+
`token_endpoint_auth_method="none"` is load-bearing. Omit it and the AS applies
|
|
337
|
+
the RFC 7591 §2 default of `client_secret_basic` and issues a `client_secret`;
|
|
338
|
+
the SDK then sends an `Authorization: Basic` header *and* `client_id` in the form
|
|
339
|
+
body — two client authentication methods in one request, which servers that
|
|
340
|
+
enforce RFC 6749 §2.3 reject with `400 invalid_request`. Registering as a public
|
|
341
|
+
client (RFC 8252 §8.4) is the correct posture for a distributed CLI anyway, and
|
|
342
|
+
the SDK's PKCE (S256, unconditional) is what actually secures the flow.
|
|
343
|
+
"""
|
|
344
|
+
return OAuthClientMetadata(
|
|
345
|
+
client_name=client_name or _resolve_client_name(server_name),
|
|
346
|
+
redirect_uris=[callback_uri], # type: ignore[list-item] # Pydantic coerces str→AnyUrl
|
|
347
|
+
grant_types=["authorization_code", "refresh_token"],
|
|
348
|
+
response_types=["code"],
|
|
349
|
+
token_endpoint_auth_method="none",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _explain_registration_error(exc: OAuthRegistrationError) -> OAuthRegistrationError:
|
|
354
|
+
"""Annotate `invalid_client_metadata` with its most likely cause.
|
|
355
|
+
|
|
356
|
+
An AS that does not support public clients MUST reject our registration
|
|
357
|
+
(RFC 7591 §2) rather than downgrade it. No such server is known, so rather than
|
|
358
|
+
carry a speculative override flag we make the failure legible if one turns up.
|
|
359
|
+
"""
|
|
360
|
+
if "invalid_client_metadata" not in str(exc):
|
|
361
|
+
return exc
|
|
362
|
+
return OAuthRegistrationError(
|
|
363
|
+
f"{exc}\n\n"
|
|
364
|
+
"Likely cause: mcpgen registers as a public client "
|
|
365
|
+
"(token_endpoint_auth_method=none), and this authorization server appears to "
|
|
366
|
+
"require a client_secret. Please report this at "
|
|
367
|
+
"https://github.com/svd/mcp-client-kit/issues with the message above."
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
333
371
|
# Treat a cached token as expired this many seconds before its real expiry.
|
|
334
372
|
_MARGIN = 120
|
|
335
373
|
|
|
@@ -782,6 +820,12 @@ async def _http_session(
|
|
|
782
820
|
storage = FileTokenStorage(server_name, backend=resolve_cred_backend(cred_backend))
|
|
783
821
|
await _pre_flight_refresh(server_name, storage)
|
|
784
822
|
|
|
823
|
+
# Unlike login(), this does NOT clear a stale confidential-client registration,
|
|
824
|
+
# and must not: a pre-fix credential still refreshes fine here, because
|
|
825
|
+
# _pre_flight_refresh sends client_id + client_secret in the body — that is
|
|
826
|
+
# client_secret_post, a *single* auth method, so it never hit the double-auth
|
|
827
|
+
# bug. Once the refresh token dies, _no_browser raises ReauthenticationRequired
|
|
828
|
+
# → "Run: mcpgen login" → which does clear and re-register as a public client.
|
|
785
829
|
data = storage._load()
|
|
786
830
|
redirect_uris = data.get(server_name, {}).get("client_info", {}).get("redirect_uris", [])
|
|
787
831
|
callback_uri = redirect_uris[0] if redirect_uris else "http://localhost:0/callback"
|
|
@@ -794,12 +838,7 @@ async def _http_session(
|
|
|
794
838
|
|
|
795
839
|
provider = OAuthClientProvider(
|
|
796
840
|
server_url=server_url,
|
|
797
|
-
client_metadata=
|
|
798
|
-
client_name=client_name or _resolve_client_name(server_name),
|
|
799
|
-
redirect_uris=[callback_uri], # type: ignore[list-item] # Pydantic coerces str→AnyUrl
|
|
800
|
-
grant_types=["authorization_code", "refresh_token"],
|
|
801
|
-
response_types=["code"],
|
|
802
|
-
),
|
|
841
|
+
client_metadata=_client_metadata(server_name, callback_uri, client_name),
|
|
803
842
|
storage=storage,
|
|
804
843
|
redirect_handler=_no_browser,
|
|
805
844
|
callback_handler=_no_callback,
|
|
@@ -951,20 +990,61 @@ class McpBridgeCaller:
|
|
|
951
990
|
env=self._env,
|
|
952
991
|
) as s:
|
|
953
992
|
result = await s.call_tool(tool, arguments)
|
|
954
|
-
content = [
|
|
993
|
+
content = [_summarize_content_item(item) for item in result.content]
|
|
955
994
|
return parse(content)
|
|
956
995
|
|
|
957
996
|
|
|
997
|
+
def _summarize_content_item(item: Any) -> dict:
|
|
998
|
+
"""Reduce one MCP content block to a compact, JSON-safe dict.
|
|
999
|
+
|
|
1000
|
+
``text`` blocks keep today's shape ({"type", "text"}). ``image`` / ``resource`` /
|
|
1001
|
+
``resource_link`` blocks carry no useful `.text` — capturing only `.text` (as the
|
|
1002
|
+
old code did) silently degrades them to an empty string, hiding the payload from
|
|
1003
|
+
shape inference entirely. Record presence/metadata instead (never the raw
|
|
1004
|
+
base64/blob bytes, to keep shape-specs small).
|
|
1005
|
+
"""
|
|
1006
|
+
item_type = getattr(item, "type", None)
|
|
1007
|
+
if item_type == "image":
|
|
1008
|
+
return {
|
|
1009
|
+
"type": "image",
|
|
1010
|
+
"mimeType": getattr(item, "mimeType", None),
|
|
1011
|
+
"has_data": bool(getattr(item, "data", None)),
|
|
1012
|
+
}
|
|
1013
|
+
if item_type == "resource":
|
|
1014
|
+
resource = getattr(item, "resource", None)
|
|
1015
|
+
return {
|
|
1016
|
+
"type": "resource",
|
|
1017
|
+
"mimeType": getattr(resource, "mimeType", None),
|
|
1018
|
+
"has_text": bool(getattr(resource, "text", None)),
|
|
1019
|
+
"has_blob": bool(getattr(resource, "blob", None)),
|
|
1020
|
+
}
|
|
1021
|
+
if item_type == "resource_link":
|
|
1022
|
+
uri = getattr(item, "uri", None)
|
|
1023
|
+
return {
|
|
1024
|
+
"type": "resource_link",
|
|
1025
|
+
"uri": str(uri) if uri is not None else None,
|
|
1026
|
+
"name": getattr(item, "name", None),
|
|
1027
|
+
}
|
|
1028
|
+
return {"type": item_type, "text": getattr(item, "text", "")}
|
|
1029
|
+
|
|
1030
|
+
|
|
958
1031
|
def parse(content_items: list) -> Any:
|
|
959
1032
|
"""Extract and JSON-parse the text payload from an MCP tool result.
|
|
960
1033
|
|
|
961
1034
|
Falls back to ``ast.literal_eval`` for Python-repr payloads (e.g. servers
|
|
962
1035
|
that return single-quoted dicts like sqlite), then to a plain string as a
|
|
963
1036
|
last resort so callers can still inspect the response.
|
|
1037
|
+
|
|
1038
|
+
Non-text content blocks (image / resource / resource_link) carry no `.text`
|
|
1039
|
+
to parse — return the block's own summary dict as-is rather than falling
|
|
1040
|
+
through to an empty string, so the shape-spec records something real.
|
|
964
1041
|
"""
|
|
965
1042
|
if not content_items:
|
|
966
1043
|
raise ValueError("MCP tool result has empty content")
|
|
967
1044
|
item = content_items[0]
|
|
1045
|
+
item_type = item.get("type") if isinstance(item, dict) else getattr(item, "type", None)
|
|
1046
|
+
if item_type in ("image", "resource", "resource_link"):
|
|
1047
|
+
return item if isinstance(item, dict) else _summarize_content_item(item)
|
|
968
1048
|
text = item.get("text", "") if isinstance(item, dict) else getattr(item, "text", "")
|
|
969
1049
|
try:
|
|
970
1050
|
return json.loads(text)
|
|
@@ -1067,35 +1147,33 @@ async def login(
|
|
|
1067
1147
|
|
|
1068
1148
|
provider = OAuthClientProvider(
|
|
1069
1149
|
server_url=server_url,
|
|
1070
|
-
client_metadata=
|
|
1071
|
-
client_name=client_name or _resolve_client_name(server_name),
|
|
1072
|
-
redirect_uris=[callback_uri], # type: ignore[list-item] # Pydantic coerces str→AnyUrl
|
|
1073
|
-
grant_types=["authorization_code", "refresh_token"],
|
|
1074
|
-
response_types=["code"],
|
|
1075
|
-
),
|
|
1150
|
+
client_metadata=_client_metadata(server_name, callback_uri, client_name),
|
|
1076
1151
|
storage=storage,
|
|
1077
1152
|
redirect_handler=redirect_handler,
|
|
1078
1153
|
callback_handler=callback_handler,
|
|
1079
1154
|
)
|
|
1080
1155
|
|
|
1081
1156
|
try:
|
|
1082
|
-
|
|
1083
|
-
async with
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1157
|
+
try:
|
|
1158
|
+
async with _open_http(server_url, auth=provider) as (read, write, _):
|
|
1159
|
+
async with ClientSession(read, write) as s:
|
|
1160
|
+
await s.initialize()
|
|
1161
|
+
|
|
1162
|
+
# Persist the token endpoint for later pre-flight refresh. Do this
|
|
1163
|
+
# independently of any tool call — not every server exposes `whoami`.
|
|
1164
|
+
if provider.context.oauth_metadata is not None:
|
|
1165
|
+
endpoint_url = str(provider.context.oauth_metadata.token_endpoint)
|
|
1166
|
+
creds_data = storage._load()
|
|
1167
|
+
creds_data.setdefault(server_name, {})["token_endpoint"] = endpoint_url
|
|
1168
|
+
storage._save(creds_data)
|
|
1169
|
+
|
|
1170
|
+
# Confirm the authenticated session works with a server-agnostic
|
|
1171
|
+
# call. `list_tools` is part of the MCP protocol — every server
|
|
1172
|
+
# supports it, unlike any specific tool name.
|
|
1173
|
+
tools = await s.list_tools()
|
|
1174
|
+
print(f"Login OK ({server_name}); {len(tools.tools)} tool(s) available")
|
|
1175
|
+
except OAuthRegistrationError as e:
|
|
1176
|
+
raise _explain_registration_error(e) from e
|
|
1099
1177
|
finally:
|
|
1100
1178
|
if not callback_future.done():
|
|
1101
1179
|
callback_future.set_result((None, None))
|
|
@@ -48,11 +48,14 @@ async def _list_tools(
|
|
|
48
48
|
result = await s.list_tools()
|
|
49
49
|
tools = []
|
|
50
50
|
for t in result.tools:
|
|
51
|
+
raw_annotations = getattr(t, "annotations", None)
|
|
52
|
+
annotations = raw_annotations.model_dump(exclude_none=True) if raw_annotations else None
|
|
51
53
|
tools.append(
|
|
52
54
|
{
|
|
53
55
|
"name": t.name,
|
|
54
56
|
"description": t.description,
|
|
55
57
|
"inputSchema": t.inputSchema,
|
|
58
|
+
"annotations": annotations,
|
|
56
59
|
}
|
|
57
60
|
)
|
|
58
61
|
return tools
|
|
@@ -70,7 +73,8 @@ async def _probe(
|
|
|
70
73
|
config_path: str | None = None,
|
|
71
74
|
cred_backend: str | None = None,
|
|
72
75
|
env: dict[str, str] | None = None,
|
|
73
|
-
) -> Any:
|
|
76
|
+
) -> tuple[Any, int]:
|
|
77
|
+
"""Return `(observed_shape, observed_byte_size)` for one live probe call."""
|
|
74
78
|
caller = _bridge.McpBridgeCaller(
|
|
75
79
|
cmd=cmd,
|
|
76
80
|
url=url,
|
|
@@ -81,7 +85,8 @@ async def _probe(
|
|
|
81
85
|
env=env,
|
|
82
86
|
)
|
|
83
87
|
raw = await caller.call(server, tool, args)
|
|
84
|
-
|
|
88
|
+
size = len(json.dumps(raw, default=str, ensure_ascii=False).encode("utf-8"))
|
|
89
|
+
return codegen.summarize_shape(raw), size
|
|
85
90
|
|
|
86
91
|
|
|
87
92
|
async def _call(
|
|
@@ -283,7 +288,7 @@ def _cmd_codegen(ns: argparse.Namespace) -> int:
|
|
|
283
288
|
args = json.loads(ns.probe_args) if ns.probe_args else {}
|
|
284
289
|
print(f"[codegen] probing {ns.probe}({args}) …", file=sys.stderr)
|
|
285
290
|
try:
|
|
286
|
-
shape = asyncio.run(_probe(ns.server, ns.probe, args, cmd=cmd, **conn))
|
|
291
|
+
shape, _size = asyncio.run(_probe(ns.server, ns.probe, args, cmd=cmd, **conn))
|
|
287
292
|
except (FileNotFoundError, ValueError) as exc:
|
|
288
293
|
print(f"[codegen] error: {exc}", file=sys.stderr)
|
|
289
294
|
return 1
|
|
@@ -327,17 +332,19 @@ def _cmd_probe(ns: argparse.Namespace) -> int:
|
|
|
327
332
|
env=_parse_env(ns),
|
|
328
333
|
)
|
|
329
334
|
shapes = []
|
|
335
|
+
sizes = []
|
|
330
336
|
for i, args in enumerate(args_list):
|
|
331
337
|
print(f"[probe] [{i + 1}/{n}] args={args}", file=sys.stderr)
|
|
332
338
|
# one session per probe (prototype); pooling is out of scope
|
|
333
339
|
try:
|
|
334
|
-
shape = asyncio.run(_probe(ns.server, ns.tool, args, cmd=cmd, **conn))
|
|
340
|
+
shape, size = asyncio.run(_probe(ns.server, ns.tool, args, cmd=cmd, **conn))
|
|
335
341
|
except (FileNotFoundError, ValueError) as exc:
|
|
336
342
|
print(f"[probe] error: {exc}", file=sys.stderr)
|
|
337
343
|
return 1
|
|
338
344
|
shapes.append(shape)
|
|
345
|
+
sizes.append(size)
|
|
339
346
|
|
|
340
|
-
skeleton = codegen.probe_skeleton(ns.tool, args_list, shapes)
|
|
347
|
+
skeleton = codegen.probe_skeleton(ns.tool, args_list, shapes, observed_bytes=sizes)
|
|
341
348
|
out = json.dumps(skeleton, indent=2)
|
|
342
349
|
if ns.emit_shape:
|
|
343
350
|
target = Path(ns.emit_shape)
|
|
@@ -470,6 +477,9 @@ def _cmd_merge(ns: argparse.Namespace) -> int:
|
|
|
470
477
|
pa = entry.get("probed_args")
|
|
471
478
|
if pa: # non-empty dict or non-empty list
|
|
472
479
|
verify_map[tool_name] = pa
|
|
480
|
+
# Prune entries for tools no longer part of the merged shapes (e.g. the
|
|
481
|
+
# server dropped the tool) — otherwise dead entries persist forever.
|
|
482
|
+
verify_map = {k: v for k, v in verify_map.items() if k in merged}
|
|
473
483
|
if verify_map:
|
|
474
484
|
_atomic_write_text(verify_target, json.dumps(verify_map, indent=2) + "\n")
|
|
475
485
|
print(
|
|
@@ -477,6 +487,10 @@ def _cmd_merge(ns: argparse.Namespace) -> int:
|
|
|
477
487
|
" — ⚠ raw args/PII, git-ignored (verify sidecar)",
|
|
478
488
|
file=sys.stderr,
|
|
479
489
|
)
|
|
490
|
+
elif verify_target.is_file():
|
|
491
|
+
# Every entry was pruned as stale — don't leave dead content on disk.
|
|
492
|
+
verify_target.unlink()
|
|
493
|
+
print(f"[merge] removed {verify_target} (all entries stale)", file=sys.stderr)
|
|
480
494
|
|
|
481
495
|
if not ns.keep_parts:
|
|
482
496
|
shutil.rmtree(parts_d)
|
|
@@ -576,13 +590,17 @@ def _cmd_list(ns: argparse.Namespace) -> int:
|
|
|
576
590
|
except (FileNotFoundError, ValueError) as exc:
|
|
577
591
|
print(f"[list] error: {exc}", file=sys.stderr)
|
|
578
592
|
return 1
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
593
|
+
|
|
594
|
+
def _entry(t: dict) -> dict:
|
|
595
|
+
entry = {"name": t["name"], "description": t.get("description") or ""}
|
|
596
|
+
if getattr(ns, "schema", False):
|
|
597
|
+
entry["inputSchema"] = t.get("inputSchema") or {}
|
|
598
|
+
annotations = t.get("annotations")
|
|
599
|
+
if annotations is not None:
|
|
600
|
+
entry["annotations"] = annotations
|
|
601
|
+
return entry
|
|
602
|
+
|
|
603
|
+
out = [_entry(t) for t in tools]
|
|
586
604
|
sys.stdout.write(json.dumps(out, indent=2) + "\n")
|
|
587
605
|
|
|
588
606
|
candidates = codegen.detect_discriminators(tools)
|
|
@@ -116,6 +116,26 @@ def _str_literal(s: str) -> str:
|
|
|
116
116
|
return repr(s)
|
|
117
117
|
|
|
118
118
|
|
|
119
|
+
_LARGE_PAYLOAD_THRESHOLD_BYTES = 64 * 1024
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _size_note(shape: dict | None) -> str | None:
|
|
123
|
+
"""Docstring note when a probe recorded an unusually large response size."""
|
|
124
|
+
n = (shape or {}).get("_observed_bytes")
|
|
125
|
+
if not isinstance(n, (int, float)) or n < _LARGE_PAYLOAD_THRESHOLD_BYTES:
|
|
126
|
+
return None
|
|
127
|
+
kb = round(n / 1024)
|
|
128
|
+
return f"Returns a large payload (~{kb} KB observed) — prefer narrower queries where available."
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _with_size_note(desc: str | None, shape: dict | None) -> str | None:
|
|
132
|
+
note = _size_note(shape)
|
|
133
|
+
if not note:
|
|
134
|
+
return desc
|
|
135
|
+
desc = (desc or "").strip()
|
|
136
|
+
return f"{desc}\n\n{note}" if desc else note
|
|
137
|
+
|
|
138
|
+
|
|
119
139
|
def _docstring(text: str | None, indent: str) -> str:
|
|
120
140
|
text = (text or "").strip()
|
|
121
141
|
if not text:
|
|
@@ -215,9 +235,9 @@ _DIG_LIST = '''def _dig_list(obj: Any, path: tuple[str, ...]) -> list:
|
|
|
215
235
|
return cur'''
|
|
216
236
|
|
|
217
237
|
|
|
218
|
-
def _docstring_with_args(tool: dict, ordered: list[tuple[str, dict]], indent: str) -> str:
|
|
238
|
+
def _docstring_with_args(tool: dict, ordered: list[tuple[str, dict]], indent: str, shape: dict | None = None) -> str:
|
|
219
239
|
"""Build docstring with description + Args section from inputSchema properties."""
|
|
220
|
-
desc = (tool.get("description") or "").strip()
|
|
240
|
+
desc = _with_size_note((tool.get("description") or "").strip(), shape) or ""
|
|
221
241
|
props_lines = []
|
|
222
242
|
for pname, pschema in ordered:
|
|
223
243
|
py = sanitize(pname)
|
|
@@ -260,6 +280,108 @@ def _docstring_with_args(tool: dict, ordered: list[tuple[str, dict]], indent: st
|
|
|
260
280
|
return f'{indent}"""{joined}\n{indent}"""'
|
|
261
281
|
|
|
262
282
|
|
|
283
|
+
def _variants_are_numeric(variants: dict) -> bool:
|
|
284
|
+
"""True iff every discriminator variant key parses as an int.
|
|
285
|
+
|
|
286
|
+
Fallback only — used when the discriminator's declared schema type can't be
|
|
287
|
+
determined (see `_discriminator_type`). Sniffing keys alone is unsound:
|
|
288
|
+
a `type: "string"` discriminator can have numeric-looking enum values
|
|
289
|
+
(e.g. `"1"`, `"2"`), which would be misclassified as int.
|
|
290
|
+
"""
|
|
291
|
+
if not variants:
|
|
292
|
+
return True
|
|
293
|
+
for k in variants:
|
|
294
|
+
try:
|
|
295
|
+
int(k)
|
|
296
|
+
except ValueError:
|
|
297
|
+
return False
|
|
298
|
+
return True
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _discriminator_type(disc: str, props: dict, overrides: dict, variants: dict) -> str:
|
|
302
|
+
"""The discriminator argument's Python type — `"int"` or `"str"` — schema/override first.
|
|
303
|
+
|
|
304
|
+
Priority: an explicit `input_overrides` entry for the discriminator, then the
|
|
305
|
+
tool's declared `inputSchema` type for that property (a JSON Schema `type` may
|
|
306
|
+
also be a list, e.g. `["string", "null"]` — if the list names both `"integer"`
|
|
307
|
+
and `"string"`, `"integer"` wins), then (only if neither is available) a
|
|
308
|
+
best-effort sniff of the shape-spec's variant keys.
|
|
309
|
+
|
|
310
|
+
`float` is deliberately unsupported: `typing.Literal` cannot hold float values,
|
|
311
|
+
so a float discriminator can't be expressed as `@overload` stubs. An explicit
|
|
312
|
+
`"float"` override raises; a schema `type: "number"` (which permits floats)
|
|
313
|
+
falls back to key-sniffing rather than assuming int, so real float variant
|
|
314
|
+
keys (e.g. `"1.5"`) resolve to `"str"` instead of crashing on `int()`.
|
|
315
|
+
|
|
316
|
+
Any other explicit override (not `int`/`str`/`float`) also raises — a
|
|
317
|
+
discriminator's `Literal` type can only ever be `int` or `str`, so silently
|
|
318
|
+
ignoring an unrecognized override (e.g. a typo like `"strr"`) would drop
|
|
319
|
+
the user's intent without warning.
|
|
320
|
+
"""
|
|
321
|
+
override = overrides.get(disc)
|
|
322
|
+
if override == "int":
|
|
323
|
+
return "int"
|
|
324
|
+
if override == "str":
|
|
325
|
+
return "str"
|
|
326
|
+
if override is not None:
|
|
327
|
+
raise ValueError(
|
|
328
|
+
f"discriminator {disc!r} has unsupported input_overrides type {override!r} — "
|
|
329
|
+
"only 'int' or 'str' are valid for a discriminator (Literal can't hold floats "
|
|
330
|
+
"or other types)"
|
|
331
|
+
)
|
|
332
|
+
pschema = props.get(disc)
|
|
333
|
+
if isinstance(pschema, dict):
|
|
334
|
+
t = pschema.get("type")
|
|
335
|
+
types = t if isinstance(t, list) else [t]
|
|
336
|
+
if "integer" in types:
|
|
337
|
+
return "int"
|
|
338
|
+
if "string" in types:
|
|
339
|
+
return "str"
|
|
340
|
+
if "number" in types:
|
|
341
|
+
return "int" if _variants_are_numeric(variants) else "str"
|
|
342
|
+
return "int" if _variants_are_numeric(variants) else "str"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _variant_key(k: str, disc: str, numeric: bool) -> int | str:
|
|
346
|
+
"""Coerce a variant key to the discriminator's resolved type, erroring with context on mismatch."""
|
|
347
|
+
if not numeric:
|
|
348
|
+
return k
|
|
349
|
+
try:
|
|
350
|
+
return int(k)
|
|
351
|
+
except ValueError:
|
|
352
|
+
raise ValueError(
|
|
353
|
+
f"discriminator {disc!r} resolved to int, but variant key {k!r} isn't a valid int — "
|
|
354
|
+
"fix the shape-spec's input_overrides/variant keys or the tool's inputSchema type"
|
|
355
|
+
) from None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _get_overrides(shape: dict) -> dict:
|
|
359
|
+
"""`input_overrides` sidecar entry, validated as a dict.
|
|
360
|
+
|
|
361
|
+
A hand-edited shape-spec can set this to the wrong JSON type (e.g. a list,
|
|
362
|
+
from a typo); failing here with a clear message beats a confusing
|
|
363
|
+
`AttributeError` from `.get()` deep inside signature rendering.
|
|
364
|
+
"""
|
|
365
|
+
overrides = shape.get("input_overrides") or {}
|
|
366
|
+
if not isinstance(overrides, dict):
|
|
367
|
+
raise ValueError(f"shape-spec input_overrides must be a dict, got {type(overrides).__name__}: {overrides!r}")
|
|
368
|
+
return overrides
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _discriminator_context(tool: dict, shape: dict) -> tuple[str, bool]:
|
|
372
|
+
"""Resolve (discriminator name, is-numeric) for a discriminated-union shape-spec.
|
|
373
|
+
|
|
374
|
+
Shared by `_render_overloaded` (signature/Literal generation) and
|
|
375
|
+
`render_module` (return-model registration) so the two call sites can't
|
|
376
|
+
drift on discriminator-type resolution.
|
|
377
|
+
"""
|
|
378
|
+
disc: str = shape["discriminator"]
|
|
379
|
+
variants: dict = shape["variants"]
|
|
380
|
+
props = (tool.get("inputSchema") or {}).get("properties") or {}
|
|
381
|
+
numeric = _discriminator_type(disc, props, _get_overrides(shape), variants) == "int"
|
|
382
|
+
return disc, numeric
|
|
383
|
+
|
|
384
|
+
|
|
263
385
|
def _render_overloaded(
|
|
264
386
|
tool: dict,
|
|
265
387
|
fn: str,
|
|
@@ -271,14 +393,16 @@ def _render_overloaded(
|
|
|
271
393
|
schema: dict | None = None,
|
|
272
394
|
) -> str:
|
|
273
395
|
"""Emit @overload stubs (one per discriminator value) + impl signature."""
|
|
274
|
-
disc: str = shape["discriminator"]
|
|
275
396
|
variants: dict = shape["variants"]
|
|
276
397
|
unwrap: list = shape.get("unwrap") or []
|
|
277
|
-
overrides: dict = shape
|
|
398
|
+
overrides: dict = _get_overrides(shape)
|
|
278
399
|
container: str | None = shape.get("return_container")
|
|
279
400
|
|
|
401
|
+
disc, numeric = _discriminator_context(tool, shape)
|
|
402
|
+
disc_type = "int" if numeric else "str"
|
|
403
|
+
|
|
280
404
|
ordered = sorted(props.items(), key=lambda kv: kv[0] not in required)
|
|
281
|
-
|
|
405
|
+
coerced_variants = sorted(((_variant_key(k, disc, numeric), v) for k, v in variants.items()), key=lambda kv: kv[0])
|
|
282
406
|
|
|
283
407
|
def _build_params(disc_type: str) -> list[str]:
|
|
284
408
|
out = []
|
|
@@ -298,22 +422,23 @@ def _render_overloaded(
|
|
|
298
422
|
all_p = ["caller: McpCaller", "*", *params] if params else ["caller: McpCaller"]
|
|
299
423
|
return ", ".join(all_p)
|
|
300
424
|
|
|
301
|
-
variant_models = [(
|
|
425
|
+
variant_models = [(val, v.get("return_model", "Any")) for val, v in coerced_variants if v.get("return_model")]
|
|
302
426
|
union_ret = " | ".join(m for _, m in variant_models) if variant_models else "Any"
|
|
303
427
|
|
|
304
428
|
blocks: list[str] = []
|
|
305
429
|
|
|
306
430
|
for val, model in variant_models:
|
|
307
431
|
ret = f"list[{model}]" if container == "list" else model
|
|
308
|
-
|
|
432
|
+
literal = str(val) if numeric else repr(val)
|
|
433
|
+
params = _build_params(f"Literal[{literal}]")
|
|
309
434
|
blocks.append(f"@overload\nasync def {fn}({_sig(params)}) -> {ret}: ...")
|
|
310
435
|
|
|
311
436
|
impl_ret = f"list[{union_ret}]" if container == "list" else union_ret
|
|
312
|
-
impl_lines = [f"async def {fn}({_sig(_build_params(
|
|
437
|
+
impl_lines = [f"async def {fn}({_sig(_build_params(disc_type))}) -> {impl_ret}:"]
|
|
313
438
|
if embed_schema:
|
|
314
|
-
impl_lines.append(_docstring_with_args(tool, ordered, " "))
|
|
439
|
+
impl_lines.append(_docstring_with_args(tool, ordered, " ", shape=shape))
|
|
315
440
|
else:
|
|
316
|
-
impl_lines.append(_docstring(tool.get("description"), " "))
|
|
441
|
+
impl_lines.append(_docstring(_with_size_note(tool.get("description"), shape), " "))
|
|
317
442
|
|
|
318
443
|
body_args = [(sanitize(pname), pname, pname in required or pname == disc) for pname, _ in ordered]
|
|
319
444
|
req_pairs = [(py, pname) for py, pname, is_req in body_args if is_req]
|
|
@@ -369,7 +494,7 @@ def render_tool(tool: dict, shape: dict | None = None, embed_schema: bool = Fals
|
|
|
369
494
|
|
|
370
495
|
unwrap: list = shape.get("unwrap") or []
|
|
371
496
|
return_model: str | None = shape.get("return_model")
|
|
372
|
-
overrides: dict = shape
|
|
497
|
+
overrides: dict = _get_overrides(shape)
|
|
373
498
|
# return_container="list" => the unwrapped value is a LIST of return_model
|
|
374
499
|
# records (e.g. query_acme's data.results). Annotation/cast become
|
|
375
500
|
# list[Model] and the body digs via _dig_list. Default (None) = dict/scalar.
|
|
@@ -400,9 +525,9 @@ def render_tool(tool: dict, shape: dict | None = None, embed_schema: bool = Fals
|
|
|
400
525
|
|
|
401
526
|
lines = [f"async def {fn}({sig}) -> {ret_ann}:"]
|
|
402
527
|
if embed_schema:
|
|
403
|
-
lines.append(_docstring_with_args(tool, ordered, " "))
|
|
528
|
+
lines.append(_docstring_with_args(tool, ordered, " ", shape=shape))
|
|
404
529
|
else:
|
|
405
|
-
lines.append(_docstring(tool.get("description"), " "))
|
|
530
|
+
lines.append(_docstring(_with_size_note(tool.get("description"), shape), " "))
|
|
406
531
|
|
|
407
532
|
req_pairs = [(py, pname) for py, pname, is_req in body_args if is_req]
|
|
408
533
|
opt_pairs = [(py, pname) for py, pname, is_req in body_args if not is_req]
|
|
@@ -532,7 +657,9 @@ def render_module(
|
|
|
532
657
|
if not sp:
|
|
533
658
|
continue
|
|
534
659
|
if sp.get("discriminator") and sp.get("variants"):
|
|
535
|
-
|
|
660
|
+
variants = sp["variants"]
|
|
661
|
+
disc, numeric = _discriminator_context(tool, sp)
|
|
662
|
+
for _, variant in sorted(variants.items(), key=lambda kv: _variant_key(kv[0], disc, numeric)):
|
|
536
663
|
if variant.get("return_model"):
|
|
537
664
|
_append_model(variant["return_model"], variant.get("fields") or {})
|
|
538
665
|
elif sp.get("return_model"):
|
|
@@ -742,11 +869,17 @@ def merge_skeletons(skeletons: list[dict]) -> dict:
|
|
|
742
869
|
return out
|
|
743
870
|
|
|
744
871
|
|
|
745
|
-
def probe_skeleton(
|
|
872
|
+
def probe_skeleton(
|
|
873
|
+
tool: str, args_list: list[dict], shapes: list[Any], observed_bytes: list[int] | None = None
|
|
874
|
+
) -> dict:
|
|
746
875
|
"""Build a shape-spec skeleton from N probe calls.
|
|
747
876
|
|
|
748
|
-
args_list
|
|
749
|
-
shapes
|
|
877
|
+
args_list — the N arg-dicts passed to the tool (one per probe).
|
|
878
|
+
shapes — the N summarize_shape() results from those calls.
|
|
879
|
+
observed_bytes — optional raw response sizes (one per probe); the largest
|
|
880
|
+
is recorded as `_observed_shape` so the generated docstring
|
|
881
|
+
can warn callers about outsized payloads. Omitted entirely
|
|
882
|
+
when not passed (byte-stable with existing shapes.json).
|
|
750
883
|
|
|
751
884
|
When args_list has a single entry, probed_args is that dict (byte-stable
|
|
752
885
|
with current single-probe output). With multiple entries, probed_args is
|
|
@@ -759,14 +892,15 @@ def probe_skeleton(tool: str, args_list: list[dict], shapes: list[Any]) -> dict:
|
|
|
759
892
|
{k: _merge_scalar({v}) for k, v in merged.items() if isinstance(v, str)} if isinstance(merged, dict) else {}
|
|
760
893
|
)
|
|
761
894
|
probed_args: Any = args_list[0] if len(args_list) == 1 else list(args_list)
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
"_observed_shape": merged,
|
|
771
|
-
}
|
|
895
|
+
entry: dict[str, Any] = {
|
|
896
|
+
"unwrap": [],
|
|
897
|
+
"return_model": None,
|
|
898
|
+
"input_overrides": {},
|
|
899
|
+
"fields": fields,
|
|
900
|
+
"source": "live",
|
|
901
|
+
"probed_args": probed_args,
|
|
902
|
+
"_observed_shape": merged,
|
|
772
903
|
}
|
|
904
|
+
if observed_bytes:
|
|
905
|
+
entry["_observed_bytes"] = max(observed_bytes)
|
|
906
|
+
return {tool: entry}
|