mcp-client-kit 0.4.0__tar.gz → 0.5.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.4.0 → mcp_client_kit-0.5.0}/CHANGELOG.md +35 -1
  2. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/PKG-INFO +1 -1
  3. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/doc/USAGE.md +23 -2
  4. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/mcpgen/__init__.py +2 -0
  5. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/mcpgen/_bridge.py +23 -3
  6. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/mcpgen/cli.py +53 -2
  7. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/pyproject.toml +1 -1
  8. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/tests/test_bridge.py +165 -2
  9. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/uv.lock +1 -1
  10. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/.claude/skills/releasing-a-version/SKILL.md +0 -0
  11. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/.claude-plugin/marketplace.json +0 -0
  12. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/.claude-plugin/plugin.json +0 -0
  13. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/.github/workflows/ci.yml +0 -0
  14. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/.github/workflows/publish.yml +0 -0
  15. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/.gitignore +0 -0
  16. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/CLAUDE.md +0 -0
  17. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/LICENSE +0 -0
  18. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/README.md +0 -0
  19. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/doc/CODEGEN_SKILL_IDEA.md +0 -0
  20. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/doc/DISTRIBUTION.md +0 -0
  21. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/doc/EXTRACTION_ANALYSIS.md +0 -0
  22. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/doc/LANDSCAPE.md +0 -0
  23. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/doc/MARKET_RESEARCH.md +0 -0
  24. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/doc/RUNNING_LOCALLY.md +0 -0
  25. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/doc/VERDICT.md +0 -0
  26. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/mcpgen/codegen.py +0 -0
  27. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/mcpgen/discovery.py +0 -0
  28. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/mcpgen/seam.py +0 -0
  29. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/servers.example.json +0 -0
  30. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/skills/generate-mcp-runner/SKILL.md +0 -0
  31. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/skills/generate-mcp-runner/runner_templates/http_bearer.py +0 -0
  32. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/skills/generate-mcp-runner/runner_templates/http_oauth.py +0 -0
  33. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/skills/generate-mcp-runner/runner_templates/http_public.py +0 -0
  34. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/skills/generate-mcp-runner/runner_templates/stdio.py +0 -0
  35. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/skills/generate-mcp-wrappers/SKILL.md +0 -0
  36. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/tests/test_cli_call.py +0 -0
  37. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/tests/test_cli_env.py +0 -0
  38. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/tests/test_cli_list.py +0 -0
  39. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/tests/test_cli_merge.py +0 -0
  40. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/tests/test_cli_probe.py +0 -0
  41. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/tests/test_codegen.py +0 -0
  42. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/tests/test_config.py +0 -0
  43. {mcp_client_kit-0.4.0 → mcp_client_kit-0.5.0}/tests/test_discovery.py +0 -0
@@ -1,6 +1,40 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased] — 0.5.0
3
+ ## [Unreleased] — 0.6.0
4
+
5
+ ## [0.5.0] — 2026-08-09
6
+
7
+ ### Fixed
8
+
9
+ - **A caller-supplied credentials path was silently ignored once past login, so tokens were
10
+ written to one file and read from another.** `login()` and `ensure_login()` accepted
11
+ `creds_path` and honoured it, but the call path never did: `_http_session()` built its
12
+ `FileTokenStorage` on the default `~/.mcpgen/credentials.json`, and neither `session()` nor
13
+ `McpBridgeCaller` even accepted the parameter, so there was no way to route a call to the file
14
+ the login had just written. Anyone keeping tokens outside the shared store — a project-local
15
+ credentials file, two identities side by side, a test fixture — logged in successfully and then
16
+ got `ReauthenticationRequired` on the very next call, in a loop, with both files on disk looking
17
+ plausible. `creds_path` now flows through `_http_session()`, `session()`, and
18
+ `McpBridgeCaller`, defaulting to `DEFAULT_CREDS_PATH` when omitted, so existing behaviour is
19
+ unchanged. The stdio, bearer, static-header, and raw-URL transports do not take it: they store
20
+ no credentials at all, and a parameter that silently does nothing is worse than its absence.
21
+
22
+ ### Added
23
+
24
+ - **`--creds PATH`** on every command that touches stored credentials — `codegen`, `list`,
25
+ `probe`, `call`, `login`, and the management trio `list-creds`, `delete-creds`,
26
+ `migrate-creds` — the CLI surface for the fix above. `mcpgen login` previously dropped the
27
+ path entirely, having no flag to drop it from; it now logs in to exactly the file the
28
+ subsequent calls will read. The management commands were the other half of the same gap: all
29
+ three already took a `credentials_path` internally but exposed no way to set it, so after a
30
+ `--creds` login `list-creds` reported an empty store, `delete-creds` deleted nothing while
31
+ saying so, and `migrate-creds` migrated the wrong file. A flag that exists on half the
32
+ commands that read a file is worse than no flag at all — it makes the other half look broken.
33
+
34
+ - **`DEFAULT_CREDS_PATH` is exported from `mcpgen`** — consumers that thread a credentials path
35
+ through their own CLI need the default as a value. Re-deriving
36
+ `Path.home() / ".mcpgen" / "credentials.json"` downstream would silently drift the day mcpgen
37
+ changes it.
4
38
 
5
39
  ## [0.4.0] — 2026-08-09
6
40
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-client-kit
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -80,13 +80,17 @@ uv add mcp-client-kit # or: pip install mcp-client-kit
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>` |
82
82
  | `login <server>` | Browser OAuth login | `--headless` / `--no-headless`, `--callback-timeout <seconds>`, connection flags below |
83
- | `migrate-creds` | Copy stored OAuth tokens between `file`/`keyring` backends | `--from`, `--to`, `--servers`, `--purge`, `--set-default` |
83
+ | `migrate-creds` | Copy stored OAuth tokens between `file`/`keyring` backends | `--from`, `--to`, `--servers`, `--purge`, `--set-default`, `--creds <path>` |
84
84
  | `discover` | List servers from agent hosts | `--host <id>` (repeatable), `--json` |
85
85
 
86
86
  Connection flags shared by `codegen`/`list`/`probe`/`call`/`login`: `--url`,
87
- `--bearer`, `--stdio`, `--config`, `--client-name`, `--cred-backend`
87
+ `--bearer`, `--stdio`, `--config`, `--client-name`, `--cred-backend`, `--creds`
88
88
  (see [§ Authenticate](#authenticate)).
89
89
 
90
+ `--creds` is also accepted by the credential-management commands — `list-creds`,
91
+ `delete-creds`, `migrate-creds` — so a non-default store stays addressable from every
92
+ command that reads or writes it.
93
+
90
94
  > **PII:** `call` writes the raw, unscrubbed payload. Name the file `*.probe-raw.json`
91
95
  > (gitignored) and never commit it.
92
96
 
@@ -210,6 +214,23 @@ by `--cred-backend`, then `$MCPGEN_CRED_BACKEND`, then `~/.mcpgen/config.json`
210
214
  mcpgen login myserver --cred-backend keyring
211
215
  ```
212
216
 
217
+ **Credential file location:** `--creds PATH` moves the `file` backend off
218
+ `~/.mcpgen/credentials.json` — useful to keep one project's tokens out of the shared
219
+ store, or to run two identities side by side. Pass the same path to every command
220
+ that touches those tokens; a login written to one file is invisible to a call reading
221
+ another:
222
+
223
+ ```bash
224
+ mcpgen login myserver --creds ./.mcp-creds.json
225
+ mcpgen call myserver whoami --creds ./.mcp-creds.json --out out.json
226
+ mcpgen list-creds --creds ./.mcp-creds.json
227
+ ```
228
+
229
+ The flag has no effect with `--bearer` or `--stdio`, which store no credentials. In
230
+ code the same value is `creds_path=` on `login()`, `ensure_login()`,
231
+ `ensure_login_all()`, `session()`, and `McpBridgeCaller(...)`; `mcpgen.DEFAULT_CREDS_PATH`
232
+ is exported as the default.
233
+
213
234
  **Validate keyring storage** — confirm the token landed in the OS keystore, not the
214
235
  fallback file (service `mcpgen`, username `credentials`):
215
236
 
@@ -1,6 +1,7 @@
1
1
  """mcpgen: typed Python wrapper generator for MCP servers."""
2
2
 
3
3
  from mcpgen._bridge import (
4
+ DEFAULT_CREDS_PATH,
4
5
  McpBridgeCaller,
5
6
  ReauthenticationRequired,
6
7
  delete_cred,
@@ -13,6 +14,7 @@ from mcpgen._bridge import (
13
14
  from mcpgen.seam import McpCaller
14
15
 
15
16
  __all__ = [
17
+ "DEFAULT_CREDS_PATH",
16
18
  "McpBridgeCaller",
17
19
  "McpCaller",
18
20
  "ReauthenticationRequired",
@@ -824,9 +824,16 @@ async def _http_session(
824
824
  *,
825
825
  client_name: str | None = None,
826
826
  cred_backend: str | None = None,
827
+ creds_path: Path | None = None,
827
828
  ):
828
- """OAuth-authenticated HTTP MCP session. Pre-flight refresh before connecting."""
829
- storage = FileTokenStorage(server_name, backend=resolve_cred_backend(cred_backend))
829
+ """OAuth-authenticated HTTP MCP session. Pre-flight refresh before connecting.
830
+
831
+ creds_path: read tokens from this file instead of DEFAULT_CREDS_PATH. Must match
832
+ whatever login() wrote to, or the session reads an empty store.
833
+ """
834
+ storage = FileTokenStorage(
835
+ server_name, creds_path or DEFAULT_CREDS_PATH, backend=resolve_cred_backend(cred_backend)
836
+ )
830
837
  await _pre_flight_refresh(server_name, storage)
831
838
 
832
839
  # Unlike login(), this does NOT clear a stale confidential-client registration,
@@ -913,6 +920,7 @@ async def session(
913
920
  client_name: str | None = None,
914
921
  config_path: str | Path | None = None,
915
922
  cred_backend: str | None = None,
923
+ creds_path: Path | None = None,
916
924
  env: dict[str, str] | None = None,
917
925
  ):
918
926
  """Yield an initialized MCP ClientSession.
@@ -924,6 +932,9 @@ async def session(
924
932
  url: inline server URL — routes through HTTP + OAuth keyed by `server` name,
925
933
  overriding config. client_name: inline OAuth client_name override.
926
934
  config_path: read the server registry from this file instead of the default search.
935
+ creds_path: read OAuth tokens from this file instead of DEFAULT_CREDS_PATH. Only
936
+ meaningful on the OAuth path — the stdio, bearer, static-header, and raw-URL
937
+ transports store no credentials.
927
938
  server: a configured name (servers()) → HTTP + OAuth; otherwise a raw URL.
928
939
  env: extra env vars forwarded to the stdio subprocess (merged over the SDK's
929
940
  safe allowlist). Keys NOT in ``get_default_environment()`` (e.g. API keys)
@@ -953,7 +964,13 @@ async def session(
953
964
  async with _static_headers_session(resolved_url, _headers_cache[server]) as s:
954
965
  yield s
955
966
  elif resolved_url is not None:
956
- async with _http_session(server, resolved_url, client_name=client_name, cred_backend=cred_backend) as s:
967
+ async with _http_session(
968
+ server,
969
+ resolved_url,
970
+ client_name=client_name,
971
+ cred_backend=cred_backend,
972
+ creds_path=creds_path,
973
+ ) as s:
957
974
  yield s
958
975
  elif "://" not in server:
959
976
  raise ValueError(f"server {server!r} not found in config and is not a URL")
@@ -977,6 +994,7 @@ class McpBridgeCaller:
977
994
  client_name: str | None = None,
978
995
  config_path: str | Path | None = None,
979
996
  cred_backend: str | None = None,
997
+ creds_path: Path | None = None,
980
998
  env: dict[str, str] | None = None,
981
999
  ) -> None:
982
1000
  self._cmd = cmd
@@ -985,6 +1003,7 @@ class McpBridgeCaller:
985
1003
  self._client_name = client_name
986
1004
  self._config_path = config_path
987
1005
  self._cred_backend = cred_backend
1006
+ self._creds_path = creds_path
988
1007
  self._env = env
989
1008
 
990
1009
  async def call(self, server: str, tool: str, arguments: dict) -> Any:
@@ -996,6 +1015,7 @@ class McpBridgeCaller:
996
1015
  client_name=self._client_name,
997
1016
  config_path=self._config_path,
998
1017
  cred_backend=self._cred_backend,
1018
+ creds_path=self._creds_path,
999
1019
  env=self._env,
1000
1020
  ) as s:
1001
1021
  result = await s.call_tool(tool, arguments)
@@ -33,6 +33,7 @@ async def _list_tools(
33
33
  client_name: str | None = None,
34
34
  config_path: str | None = None,
35
35
  cred_backend: str | None = None,
36
+ creds_path: Path | None = None,
36
37
  env: dict[str, str] | None = None,
37
38
  ) -> list[dict]:
38
39
  async with _bridge.session(
@@ -43,6 +44,7 @@ async def _list_tools(
43
44
  client_name=client_name,
44
45
  config_path=config_path,
45
46
  cred_backend=cred_backend,
47
+ creds_path=creds_path,
46
48
  env=env,
47
49
  ) as s:
48
50
  result = await s.list_tools()
@@ -72,6 +74,7 @@ async def _probe(
72
74
  client_name: str | None = None,
73
75
  config_path: str | None = None,
74
76
  cred_backend: str | None = None,
77
+ creds_path: Path | None = None,
75
78
  env: dict[str, str] | None = None,
76
79
  ) -> tuple[Any, int]:
77
80
  """Return `(observed_shape, observed_byte_size)` for one live probe call."""
@@ -82,6 +85,7 @@ async def _probe(
82
85
  client_name=client_name,
83
86
  config_path=config_path,
84
87
  cred_backend=cred_backend,
88
+ creds_path=creds_path,
85
89
  env=env,
86
90
  )
87
91
  raw = await caller.call(server, tool, args)
@@ -100,6 +104,7 @@ async def _call(
100
104
  client_name: str | None = None,
101
105
  config_path: str | None = None,
102
106
  cred_backend: str | None = None,
107
+ creds_path: Path | None = None,
103
108
  env: dict[str, str] | None = None,
104
109
  ) -> Any:
105
110
  caller = _bridge.McpBridgeCaller(
@@ -109,6 +114,7 @@ async def _call(
109
114
  client_name=client_name,
110
115
  config_path=config_path,
111
116
  cred_backend=cred_backend,
117
+ creds_path=creds_path,
112
118
  env=env,
113
119
  )
114
120
  return await caller.call(server, tool, args)
@@ -272,6 +278,7 @@ def _cmd_codegen(ns: argparse.Namespace) -> int:
272
278
  client_name=ns.client_name,
273
279
  config_path=ns.config,
274
280
  cred_backend=ns.cred_backend,
281
+ creds_path=_creds_path(ns),
275
282
  env=_parse_env(ns),
276
283
  )
277
284
  try:
@@ -329,6 +336,7 @@ def _cmd_probe(ns: argparse.Namespace) -> int:
329
336
  client_name=ns.client_name,
330
337
  config_path=ns.config,
331
338
  cred_backend=ns.cred_backend,
339
+ creds_path=_creds_path(ns),
332
340
  env=_parse_env(ns),
333
341
  )
334
342
  shapes = []
@@ -400,6 +408,7 @@ def _cmd_call(ns: argparse.Namespace) -> int:
400
408
  client_name=ns.client_name,
401
409
  config_path=ns.config,
402
410
  cred_backend=ns.cred_backend,
411
+ creds_path=_creds_path(ns),
403
412
  env=_parse_env(ns),
404
413
  )
405
414
 
@@ -583,6 +592,7 @@ def _cmd_list(ns: argparse.Namespace) -> int:
583
592
  client_name=ns.client_name,
584
593
  config_path=ns.config,
585
594
  cred_backend=ns.cred_backend,
595
+ creds_path=_creds_path(ns),
586
596
  env=_parse_env(ns),
587
597
  )
588
598
  try:
@@ -620,6 +630,7 @@ def _cmd_login(ns: argparse.Namespace) -> int:
620
630
  asyncio.run(
621
631
  _bridge.login(
622
632
  ns.server,
633
+ _creds_path(ns) or _bridge.DEFAULT_CREDS_PATH,
623
634
  url=ns.url,
624
635
  client_name=ns.client_name,
625
636
  config_path=ns.config,
@@ -642,6 +653,7 @@ def _cmd_migrate_creds(ns: argparse.Namespace) -> int:
642
653
  ns.from_backend,
643
654
  ns.to_backend,
644
655
  servers=parsed_servers,
656
+ credentials_path=_creds_path(ns) or _bridge.DEFAULT_CREDS_PATH,
645
657
  purge=ns.purge,
646
658
  set_default=ns.set_default,
647
659
  )
@@ -661,7 +673,11 @@ def _cmd_migrate_creds(ns: argparse.Namespace) -> int:
661
673
 
662
674
  def _cmd_list_creds(ns: argparse.Namespace) -> int:
663
675
  """Print stored credentials as a human table (default) or JSON (--json)."""
664
- rows = _bridge.list_creds(backend=ns.cred_backend, expired_only=ns.expired)
676
+ rows = _bridge.list_creds(
677
+ backend=ns.cred_backend,
678
+ credentials_path=_creds_path(ns) or _bridge.DEFAULT_CREDS_PATH,
679
+ expired_only=ns.expired,
680
+ )
665
681
 
666
682
  if ns.json:
667
683
  sys.stdout.write(json.dumps(rows, indent=2) + "\n")
@@ -708,7 +724,11 @@ def _cmd_delete_creds(ns: argparse.Namespace) -> int:
708
724
  print("[delete-creds] aborted", file=sys.stderr)
709
725
  return 0
710
726
 
711
- existed = _bridge.delete_cred(ns.server, backend=ns.cred_backend)
727
+ existed = _bridge.delete_cred(
728
+ ns.server,
729
+ backend=ns.cred_backend,
730
+ credentials_path=_creds_path(ns) or _bridge.DEFAULT_CREDS_PATH,
731
+ )
712
732
  if existed:
713
733
  print(f"[delete-creds] deleted {ns.server!r}", file=sys.stderr)
714
734
  else:
@@ -729,6 +749,33 @@ def _add_headless_flag(p: argparse.ArgumentParser) -> None:
729
749
  )
730
750
 
731
751
 
752
+ def _add_creds_flag(p: argparse.ArgumentParser, *, transport_note: bool = True) -> None:
753
+ """Add --creds PATH — the file backend's credentials store.
754
+
755
+ Shared by the connection commands and the credential-management ones, so a
756
+ non-default store stays addressable end to end: login writes it, calls read
757
+ it, list/delete/migrate operate on it.
758
+ """
759
+ note = (
760
+ " No effect with --bearer or --stdio, which store no credentials."
761
+ if transport_note
762
+ else " Only the file backend has a path; the keyring backend ignores it."
763
+ )
764
+ p.add_argument(
765
+ "--creds",
766
+ dest="creds",
767
+ metavar="PATH",
768
+ help=f"OAuth credentials file for both storing and reading tokens "
769
+ f"(default: {_bridge.DEFAULT_CREDS_PATH}).{note}",
770
+ )
771
+
772
+
773
+ def _creds_path(ns: argparse.Namespace) -> Path | None:
774
+ """--creds as a Path, or None to let _bridge use DEFAULT_CREDS_PATH."""
775
+ value = getattr(ns, "creds", None)
776
+ return Path(value) if value else None
777
+
778
+
732
779
  def _callback_timeout(value: str) -> float:
733
780
  """argparse type for --callback-timeout: a non-negative number of seconds."""
734
781
  try:
@@ -756,6 +803,7 @@ def _add_conn_args(p: argparse.ArgumentParser) -> None:
756
803
  p.add_argument(
757
804
  "--config", dest="config", help="servers config path; overrides $MCPGEN_SERVERS and the default search"
758
805
  )
806
+ _add_creds_flag(p)
759
807
  p.add_argument(
760
808
  "--cred-backend",
761
809
  dest="cred_backend",
@@ -907,6 +955,7 @@ def main(argv: list[str] | None = None) -> int:
907
955
  help="write cred_backend=<to> into ~/.mcpgen/config.json so future "
908
956
  "commands default to the target backend (default: leave config untouched)",
909
957
  )
958
+ _add_creds_flag(mc, transport_note=False)
910
959
  mc.set_defaults(func=_cmd_migrate_creds)
911
960
 
912
961
  lc = sub.add_parser("list-creds", help="list stored credentials (flags expired)")
@@ -926,6 +975,7 @@ def main(argv: list[str] | None = None) -> int:
926
975
  choices=["file", "keyring", "auto"],
927
976
  help="credential storage backend (default: resolved from env/config, else file)",
928
977
  )
978
+ _add_creds_flag(lc, transport_note=False)
929
979
  lc.set_defaults(func=_cmd_list_creds)
930
980
 
931
981
  dl = sub.add_parser("delete-creds", help="delete the stored credential for one server")
@@ -942,6 +992,7 @@ def main(argv: list[str] | None = None) -> int:
942
992
  choices=["file", "keyring", "auto"],
943
993
  help="credential storage backend (default: resolved from env/config, else file)",
944
994
  )
995
+ _add_creds_flag(dl, transport_note=False)
945
996
  dl.set_defaults(func=_cmd_delete_creds)
946
997
 
947
998
  dc = sub.add_parser("discover", help="list MCP servers from installed agent hosts")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-client-kit"
3
- version = "0.4.0"
3
+ version = "0.5.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" }
@@ -18,6 +18,7 @@ import stat
18
18
  import time
19
19
  import warnings
20
20
  from contextlib import asynccontextmanager
21
+ from pathlib import Path
21
22
  from unittest.mock import AsyncMock, MagicMock, patch
22
23
 
23
24
  import pytest
@@ -113,7 +114,7 @@ def test_session_routes_bearer_not_oauth_when_bearer_provided():
113
114
  yield _make_mock_session()
114
115
 
115
116
  @asynccontextmanager
116
- async def fake_oauth(name, url, *, client_name=None, cred_backend=None):
117
+ async def fake_oauth(name, url, *, client_name=None, cred_backend=None, creds_path=None):
117
118
  oauth_calls.append(name)
118
119
  yield _make_mock_session()
119
120
 
@@ -161,7 +162,7 @@ def test_session_oauth_path_unchanged_without_bearer():
161
162
  oauth_calls: list = []
162
163
 
163
164
  @asynccontextmanager
164
- async def fake_oauth(name, url, *, client_name=None, cred_backend=None):
165
+ async def fake_oauth(name, url, *, client_name=None, cred_backend=None, creds_path=None):
165
166
  oauth_calls.append(name)
166
167
  yield _make_mock_session()
167
168
 
@@ -1806,3 +1807,165 @@ def test_cli_login_callback_timeout_rejects_invalid(bad, capsys):
1806
1807
  main(["login", "acme", "--callback-timeout", bad])
1807
1808
  assert exc.value.code == 2
1808
1809
  assert "--callback-timeout" in capsys.readouterr().err
1810
+
1811
+
1812
+ # ---------------------------------------------------------------------------
1813
+ # creds_path on the CALL path (not just login)
1814
+ # ---------------------------------------------------------------------------
1815
+
1816
+
1817
+ def _recording_storage_factory(recorded: dict):
1818
+ """Stand-in for FileTokenStorage that records how it was constructed."""
1819
+
1820
+ def factory(server_name, credentials_path=_bridge.DEFAULT_CREDS_PATH, *, backend=None):
1821
+ recorded["server_name"] = server_name
1822
+ recorded["credentials_path"] = credentials_path
1823
+ recorded["backend"] = backend
1824
+ storage = MagicMock()
1825
+ storage._load.return_value = {}
1826
+ return storage
1827
+
1828
+ return factory
1829
+
1830
+
1831
+ def _run_failing_call(recorded: dict, **caller_kwargs):
1832
+ """Drive McpBridgeCaller.call() far enough to construct the token storage."""
1833
+
1834
+ async def run():
1835
+ with (
1836
+ patch("mcpgen._bridge.FileTokenStorage", _recording_storage_factory(recorded)),
1837
+ patch("mcpgen._bridge._pre_flight_refresh", AsyncMock()),
1838
+ patch("mcpgen._bridge.OAuthClientProvider", MagicMock()),
1839
+ patch("mcpgen._bridge._open_http", _fake_http_fail),
1840
+ ):
1841
+ caller = _bridge.McpBridgeCaller(url="https://acme.example.com/mcp", **caller_kwargs)
1842
+ with pytest.raises(RuntimeError, match="network error"):
1843
+ await caller.call("acme", "whoami", {})
1844
+
1845
+ asyncio.run(run())
1846
+
1847
+
1848
+ def test_call_path_uses_custom_creds_path(tmp_path):
1849
+ """A caller-supplied creds_path must reach the token storage on the call path.
1850
+
1851
+ login() honoured creds_path but the session did not, so tokens were written
1852
+ to one file and read from another.
1853
+ """
1854
+ creds = tmp_path / "alt-credentials.json"
1855
+ recorded: dict = {}
1856
+ _run_failing_call(recorded, creds_path=creds)
1857
+ assert recorded["credentials_path"] == creds
1858
+
1859
+
1860
+ def test_call_path_defaults_to_default_creds_path():
1861
+ """No creds_path → DEFAULT_CREDS_PATH; existing behaviour is unchanged."""
1862
+ recorded: dict = {}
1863
+ _run_failing_call(recorded)
1864
+ assert recorded["credentials_path"] == _bridge.DEFAULT_CREDS_PATH
1865
+
1866
+
1867
+ def test_session_forwards_creds_path_to_http_session(tmp_path):
1868
+ """session() is a public entry point too — it must forward the path."""
1869
+ creds = tmp_path / "alt-credentials.json"
1870
+ recorded: dict = {}
1871
+
1872
+ async def run():
1873
+ with (
1874
+ patch("mcpgen._bridge.FileTokenStorage", _recording_storage_factory(recorded)),
1875
+ patch("mcpgen._bridge._pre_flight_refresh", AsyncMock()),
1876
+ patch("mcpgen._bridge.OAuthClientProvider", MagicMock()),
1877
+ patch("mcpgen._bridge._open_http", _fake_http_fail),
1878
+ ):
1879
+ with pytest.raises(RuntimeError, match="network error"):
1880
+ async with _bridge.session("acme", url="https://acme.example.com/mcp", creds_path=creds):
1881
+ pass
1882
+
1883
+ asyncio.run(run())
1884
+ assert recorded["credentials_path"] == creds
1885
+
1886
+
1887
+ def test_default_creds_path_is_exported():
1888
+ """Consumers need the default as a value instead of re-deriving the path."""
1889
+ import mcpgen
1890
+
1891
+ assert mcpgen.DEFAULT_CREDS_PATH is _bridge.DEFAULT_CREDS_PATH
1892
+ assert "DEFAULT_CREDS_PATH" in mcpgen.__all__
1893
+
1894
+
1895
+ def _fake_session_factory(captured: dict):
1896
+ @asynccontextmanager
1897
+ async def fake_session(*args, **kwargs):
1898
+ captured.update(kwargs)
1899
+ s = MagicMock()
1900
+ s.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
1901
+ yield s
1902
+
1903
+ return fake_session
1904
+
1905
+
1906
+ @pytest.mark.parametrize(
1907
+ ("argv_flag", "expected"),
1908
+ [(["--creds", "/tmp/alt-creds.json"], Path("/tmp/alt-creds.json")), ([], None)],
1909
+ )
1910
+ def test_cli_creds_flag_reaches_session(argv_flag, expected):
1911
+ """`--creds` on a session-opening command arrives at _bridge.session."""
1912
+ from mcpgen.cli import main
1913
+
1914
+ captured: dict = {}
1915
+ with patch("mcpgen._bridge.session", _fake_session_factory(captured)):
1916
+ assert main(["list", "acme", "--url", "https://acme.example.com/mcp", *argv_flag]) == 0
1917
+ assert captured["creds_path"] == expected
1918
+
1919
+
1920
+ @pytest.mark.parametrize(
1921
+ ("argv_flag", "expected"),
1922
+ [(["--creds", "/tmp/alt-creds.json"], Path("/tmp/alt-creds.json")), ([], _bridge.DEFAULT_CREDS_PATH)],
1923
+ )
1924
+ def test_cli_login_passes_creds_path(argv_flag, expected):
1925
+ """`mcpgen login` previously dropped --creds entirely — it must log in to that file."""
1926
+ from mcpgen.cli import main
1927
+
1928
+ login_mock = AsyncMock()
1929
+ with patch("mcpgen._bridge.login", login_mock):
1930
+ assert main(["login", "acme", *argv_flag]) == 0
1931
+ assert login_mock.await_args.args[1] == expected
1932
+
1933
+
1934
+ @pytest.mark.parametrize(
1935
+ ("argv_flag", "expected"),
1936
+ [(["--creds", "/tmp/alt-creds.json"], Path("/tmp/alt-creds.json")), ([], _bridge.DEFAULT_CREDS_PATH)],
1937
+ )
1938
+ def test_cli_list_creds_passes_creds_path(argv_flag, expected):
1939
+ """`list-creds` must inspect the same store `login --creds` wrote to."""
1940
+ from mcpgen.cli import main
1941
+
1942
+ with patch("mcpgen._bridge.list_creds", return_value=[]) as list_mock:
1943
+ assert main(["list-creds", *argv_flag]) == 0
1944
+ assert list_mock.call_args.kwargs["credentials_path"] == expected
1945
+
1946
+
1947
+ @pytest.mark.parametrize(
1948
+ ("argv_flag", "expected"),
1949
+ [(["--creds", "/tmp/alt-creds.json"], Path("/tmp/alt-creds.json")), ([], _bridge.DEFAULT_CREDS_PATH)],
1950
+ )
1951
+ def test_cli_delete_creds_passes_creds_path(argv_flag, expected):
1952
+ """Deleting from the default store when the user named another would be a silent no-op."""
1953
+ from mcpgen.cli import main
1954
+
1955
+ with patch("mcpgen._bridge.delete_cred", return_value=True) as delete_mock:
1956
+ assert main(["delete-creds", "acme", "--yes", *argv_flag]) == 0
1957
+ assert delete_mock.call_args.kwargs["credentials_path"] == expected
1958
+
1959
+
1960
+ @pytest.mark.parametrize(
1961
+ ("argv_flag", "expected"),
1962
+ [(["--creds", "/tmp/alt-creds.json"], Path("/tmp/alt-creds.json")), ([], _bridge.DEFAULT_CREDS_PATH)],
1963
+ )
1964
+ def test_cli_migrate_creds_passes_creds_path(argv_flag, expected):
1965
+ """migrate-creds reads and writes the file backend — it needs the path too."""
1966
+ from mcpgen.cli import main
1967
+
1968
+ result = {"from": "file", "to": "keyring", "migrated": 0, "overwritten": 0, "purged": False, "set_default": False}
1969
+ with patch("mcpgen._bridge.migrate_creds", return_value=result) as migrate_mock:
1970
+ assert main(["migrate-creds", "--from", "file", "--to", "keyring", *argv_flag]) == 0
1971
+ assert migrate_mock.call_args.kwargs["credentials_path"] == expected
@@ -535,7 +535,7 @@ cli = [
535
535
 
536
536
  [[package]]
537
537
  name = "mcp-client-kit"
538
- version = "0.4.0"
538
+ version = "0.5.0"
539
539
  source = { editable = "." }
540
540
  dependencies = [
541
541
  { name = "httpx" },
File without changes
File without changes
File without changes