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.
Files changed (43) hide show
  1. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/CHANGELOG.md +35 -1
  2. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/PKG-INFO +1 -1
  3. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/mcpgen/_bridge.py +109 -31
  4. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/mcpgen/cli.py +30 -12
  5. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/mcpgen/codegen.py +161 -27
  6. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/pyproject.toml +1 -1
  7. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/skills/generate-mcp-wrappers/SKILL.md +39 -21
  8. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_bridge.py +329 -0
  9. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_cli_list.py +111 -2
  10. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_cli_merge.py +51 -1
  11. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_cli_probe.py +60 -7
  12. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_codegen.py +305 -0
  13. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/uv.lock +2 -2
  14. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/.claude/skills/releasing-a-version/SKILL.md +0 -0
  15. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/.claude-plugin/marketplace.json +0 -0
  16. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/.claude-plugin/plugin.json +0 -0
  17. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/.github/workflows/ci.yml +0 -0
  18. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/.github/workflows/publish.yml +0 -0
  19. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/.gitignore +0 -0
  20. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/CLAUDE.md +0 -0
  21. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/LICENSE +0 -0
  22. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/README.md +0 -0
  23. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/CODEGEN_SKILL_IDEA.md +0 -0
  24. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/DISTRIBUTION.md +0 -0
  25. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/EXTRACTION_ANALYSIS.md +0 -0
  26. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/LANDSCAPE.md +0 -0
  27. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/MARKET_RESEARCH.md +0 -0
  28. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/RUNNING_LOCALLY.md +0 -0
  29. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/USAGE.md +0 -0
  30. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/doc/VERDICT.md +0 -0
  31. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/mcpgen/__init__.py +0 -0
  32. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/mcpgen/discovery.py +0 -0
  33. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/mcpgen/seam.py +0 -0
  34. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/servers.example.json +0 -0
  35. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/SKILL.md +0 -0
  36. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/runner_templates/http_bearer.py +0 -0
  37. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/runner_templates/http_oauth.py +0 -0
  38. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/runner_templates/http_public.py +0 -0
  39. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/skills/generate-mcp-runner/runner_templates/stdio.py +0 -0
  40. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_cli_call.py +0 -0
  41. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_cli_env.py +0 -0
  42. {mcp_client_kit-0.2.0 → mcp_client_kit-0.3.0}/tests/test_config.py +0 -0
  43. {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.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.2.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=OAuthClientMetadata(
798
- client_name=client_name or _resolve_client_name(server_name),
799
- redirect_uris=[callback_uri], # type: ignore[list-item] # Pydantic coerces str→AnyUrl
800
- grant_types=["authorization_code", "refresh_token"],
801
- response_types=["code"],
802
- ),
841
+ client_metadata=_client_metadata(server_name, callback_uri, client_name),
803
842
  storage=storage,
804
843
  redirect_handler=_no_browser,
805
844
  callback_handler=_no_callback,
@@ -951,20 +990,61 @@ class McpBridgeCaller:
951
990
  env=self._env,
952
991
  ) as s:
953
992
  result = await s.call_tool(tool, arguments)
954
- content = [{"type": item.type, "text": getattr(item, "text", "")} for item in result.content]
993
+ content = [_summarize_content_item(item) for item in result.content]
955
994
  return parse(content)
956
995
 
957
996
 
997
+ def _summarize_content_item(item: Any) -> dict:
998
+ """Reduce one MCP content block to a compact, JSON-safe dict.
999
+
1000
+ ``text`` blocks keep today's shape ({"type", "text"}). ``image`` / ``resource`` /
1001
+ ``resource_link`` blocks carry no useful `.text` — capturing only `.text` (as the
1002
+ old code did) silently degrades them to an empty string, hiding the payload from
1003
+ shape inference entirely. Record presence/metadata instead (never the raw
1004
+ base64/blob bytes, to keep shape-specs small).
1005
+ """
1006
+ item_type = getattr(item, "type", None)
1007
+ if item_type == "image":
1008
+ return {
1009
+ "type": "image",
1010
+ "mimeType": getattr(item, "mimeType", None),
1011
+ "has_data": bool(getattr(item, "data", None)),
1012
+ }
1013
+ if item_type == "resource":
1014
+ resource = getattr(item, "resource", None)
1015
+ return {
1016
+ "type": "resource",
1017
+ "mimeType": getattr(resource, "mimeType", None),
1018
+ "has_text": bool(getattr(resource, "text", None)),
1019
+ "has_blob": bool(getattr(resource, "blob", None)),
1020
+ }
1021
+ if item_type == "resource_link":
1022
+ uri = getattr(item, "uri", None)
1023
+ return {
1024
+ "type": "resource_link",
1025
+ "uri": str(uri) if uri is not None else None,
1026
+ "name": getattr(item, "name", None),
1027
+ }
1028
+ return {"type": item_type, "text": getattr(item, "text", "")}
1029
+
1030
+
958
1031
  def parse(content_items: list) -> Any:
959
1032
  """Extract and JSON-parse the text payload from an MCP tool result.
960
1033
 
961
1034
  Falls back to ``ast.literal_eval`` for Python-repr payloads (e.g. servers
962
1035
  that return single-quoted dicts like sqlite), then to a plain string as a
963
1036
  last resort so callers can still inspect the response.
1037
+
1038
+ Non-text content blocks (image / resource / resource_link) carry no `.text`
1039
+ to parse — return the block's own summary dict as-is rather than falling
1040
+ through to an empty string, so the shape-spec records something real.
964
1041
  """
965
1042
  if not content_items:
966
1043
  raise ValueError("MCP tool result has empty content")
967
1044
  item = content_items[0]
1045
+ item_type = item.get("type") if isinstance(item, dict) else getattr(item, "type", None)
1046
+ if item_type in ("image", "resource", "resource_link"):
1047
+ return item if isinstance(item, dict) else _summarize_content_item(item)
968
1048
  text = item.get("text", "") if isinstance(item, dict) else getattr(item, "text", "")
969
1049
  try:
970
1050
  return json.loads(text)
@@ -1067,35 +1147,33 @@ async def login(
1067
1147
 
1068
1148
  provider = OAuthClientProvider(
1069
1149
  server_url=server_url,
1070
- client_metadata=OAuthClientMetadata(
1071
- client_name=client_name or _resolve_client_name(server_name),
1072
- redirect_uris=[callback_uri], # type: ignore[list-item] # Pydantic coerces str→AnyUrl
1073
- grant_types=["authorization_code", "refresh_token"],
1074
- response_types=["code"],
1075
- ),
1150
+ client_metadata=_client_metadata(server_name, callback_uri, client_name),
1076
1151
  storage=storage,
1077
1152
  redirect_handler=redirect_handler,
1078
1153
  callback_handler=callback_handler,
1079
1154
  )
1080
1155
 
1081
1156
  try:
1082
- async with _open_http(server_url, auth=provider) as (read, write, _):
1083
- async with ClientSession(read, write) as s:
1084
- await s.initialize()
1085
-
1086
- # Persist the token endpoint for later pre-flight refresh. Do this
1087
- # independently of any tool call — not every server exposes `whoami`.
1088
- if provider.context.oauth_metadata is not None:
1089
- endpoint_url = str(provider.context.oauth_metadata.token_endpoint)
1090
- creds_data = storage._load()
1091
- creds_data.setdefault(server_name, {})["token_endpoint"] = endpoint_url
1092
- storage._save(creds_data)
1093
-
1094
- # Confirm the authenticated session works with a server-agnostic
1095
- # call. `list_tools` is part of the MCP protocol — every server
1096
- # supports it, unlike any specific tool name.
1097
- tools = await s.list_tools()
1098
- print(f"Login OK ({server_name}); {len(tools.tools)} tool(s) available")
1157
+ try:
1158
+ async with _open_http(server_url, auth=provider) as (read, write, _):
1159
+ async with ClientSession(read, write) as s:
1160
+ await s.initialize()
1161
+
1162
+ # Persist the token endpoint for later pre-flight refresh. Do this
1163
+ # independently of any tool call — not every server exposes `whoami`.
1164
+ if provider.context.oauth_metadata is not None:
1165
+ endpoint_url = str(provider.context.oauth_metadata.token_endpoint)
1166
+ creds_data = storage._load()
1167
+ creds_data.setdefault(server_name, {})["token_endpoint"] = endpoint_url
1168
+ storage._save(creds_data)
1169
+
1170
+ # Confirm the authenticated session works with a server-agnostic
1171
+ # call. `list_tools` is part of the MCP protocol — every server
1172
+ # supports it, unlike any specific tool name.
1173
+ tools = await s.list_tools()
1174
+ print(f"Login OK ({server_name}); {len(tools.tools)} tool(s) available")
1175
+ except OAuthRegistrationError as e:
1176
+ raise _explain_registration_error(e) from e
1099
1177
  finally:
1100
1178
  if not callback_future.done():
1101
1179
  callback_future.set_result((None, None))
@@ -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
- return codegen.summarize_shape(raw)
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
- 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]
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.get("input_overrides") or {}
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
- sorted_variants = sorted(variants.items(), key=lambda kv: int(kv[0]))
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 = [(int(k), v.get("return_model", "Any")) for k, v in sorted_variants if v.get("return_model")]
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
- params = _build_params(f"Literal[{val}]")
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('int'))}) -> {impl_ret}:"]
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.get("input_overrides") or {}
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
- for _, variant in sorted(sp["variants"].items(), key=lambda kv: int(kv[0])):
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(tool: str, args_list: list[dict], shapes: list[Any]) -> dict:
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 — the N arg-dicts passed to the tool (one per probe).
749
- shapes — the N summarize_shape() results from those calls.
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
- return {
763
- tool: {
764
- "unwrap": [],
765
- "return_model": None,
766
- "input_overrides": {},
767
- "fields": fields,
768
- "source": "live",
769
- "probed_args": probed_args,
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}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-client-kit"
3
- version = "0.2.0"
3
+ version = "0.3.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" }