mcp2cli 3.3.0__tar.gz → 3.4.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.
@@ -1,3 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp2cli
3
+ Version: 3.4.0
4
+ Summary: Turn any MCP server or OpenAPI spec into a CLI
5
+ Author: Stephan Fitzpatrick
6
+ Author-email: Stephan Fitzpatrick <stephan@knowsuchagency.com>
7
+ License-Expression: MIT
8
+ Requires-Dist: httpx
9
+ Requires-Dist: mcp>=1.0,<2
10
+ Requires-Dist: pyyaml
11
+ Requires-Dist: pytest ; extra == 'test'
12
+ Requires-Dist: pytest-asyncio ; extra == 'test'
13
+ Requires-Dist: tiktoken ; extra == 'test'
14
+ Requires-Python: >=3.10
15
+ Project-URL: Homepage, https://github.com/knowsuchagency/mcp2cli
16
+ Project-URL: Repository, https://github.com/knowsuchagency/mcp2cli
17
+ Provides-Extra: test
18
+ Description-Content-Type: text/markdown
19
+
1
20
  <p align="center">
2
21
  <img src="https://raw.githubusercontent.com/knowsuchagency/mcp2cli/main/assets/hero.png" alt="mcp2cli — one CLI for every API" width="700">
3
22
  </p>
@@ -83,6 +102,22 @@ mcp2cli --spec ./openapi.json --base-url https://api.example.com --oauth --list
83
102
  Tokens are persisted in `~/.cache/mcp2cli/oauth/` so subsequent calls reuse existing tokens
84
103
  and refresh automatically when they expire.
85
104
 
105
+ #### Headless hosts — no browser on the machine running mcp2cli
106
+
107
+ The default authorization-code flow starts a callback server on `127.0.0.1`, which only
108
+ works when the browser runs on the same machine. On a VPS over SSH or in a container,
109
+ add `--oauth-manual-callback`: mcp2cli prints the authorization URL instead of opening a
110
+ browser, and reads the redirect back from stdin.
111
+
112
+ ```bash
113
+ mcp2cli --mcp https://mcp.linear.app/mcp --oauth --oauth-manual-callback --list
114
+ ```
115
+
116
+ Open the printed URL in a browser on any machine, authorize, then paste the URL you land
117
+ on. That page will fail to load — nothing is listening on the loopback port — which is
118
+ expected; only its address matters, because it carries the `code` and `state` parameters.
119
+ PKCE and state verification are unchanged, so paste the URL unmodified.
120
+
86
121
  ### Secrets from environment or files
87
122
 
88
123
  Sensitive values (`--auth-header` values, `--oauth-client-id`, `--oauth-client-secret`) support
@@ -307,6 +342,8 @@ Options:
307
342
  --oauth-client-id ID OAuth client ID (supports env:/file: prefixes)
308
343
  --oauth-client-secret S OAuth client secret (supports env:/file: prefixes)
309
344
  --oauth-scope SCOPE OAuth scope(s) to request
345
+ --oauth-manual-callback Print the auth URL and read the redirect from stdin
346
+ (for hosts with no reachable browser)
310
347
  --cache-key KEY Custom cache key
311
348
  --cache-ttl SECONDS Cache TTL (default: 3600)
312
349
  --refresh Bypass cache
@@ -1,22 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: mcp2cli
3
- Version: 3.3.0
4
- Summary: Turn any MCP server or OpenAPI spec into a CLI
5
- Author: Stephan Fitzpatrick
6
- Author-email: Stephan Fitzpatrick <stephan@knowsuchagency.com>
7
- License-Expression: MIT
8
- Requires-Dist: httpx
9
- Requires-Dist: mcp>=1.0
10
- Requires-Dist: pyyaml
11
- Requires-Dist: pytest ; extra == 'test'
12
- Requires-Dist: pytest-asyncio ; extra == 'test'
13
- Requires-Dist: tiktoken ; extra == 'test'
14
- Requires-Python: >=3.10
15
- Project-URL: Homepage, https://github.com/knowsuchagency/mcp2cli
16
- Project-URL: Repository, https://github.com/knowsuchagency/mcp2cli
17
- Provides-Extra: test
18
- Description-Content-Type: text/markdown
19
-
20
1
  <p align="center">
21
2
  <img src="https://raw.githubusercontent.com/knowsuchagency/mcp2cli/main/assets/hero.png" alt="mcp2cli — one CLI for every API" width="700">
22
3
  </p>
@@ -102,6 +83,22 @@ mcp2cli --spec ./openapi.json --base-url https://api.example.com --oauth --list
102
83
  Tokens are persisted in `~/.cache/mcp2cli/oauth/` so subsequent calls reuse existing tokens
103
84
  and refresh automatically when they expire.
104
85
 
86
+ #### Headless hosts — no browser on the machine running mcp2cli
87
+
88
+ The default authorization-code flow starts a callback server on `127.0.0.1`, which only
89
+ works when the browser runs on the same machine. On a VPS over SSH or in a container,
90
+ add `--oauth-manual-callback`: mcp2cli prints the authorization URL instead of opening a
91
+ browser, and reads the redirect back from stdin.
92
+
93
+ ```bash
94
+ mcp2cli --mcp https://mcp.linear.app/mcp --oauth --oauth-manual-callback --list
95
+ ```
96
+
97
+ Open the printed URL in a browser on any machine, authorize, then paste the URL you land
98
+ on. That page will fail to load — nothing is listening on the loopback port — which is
99
+ expected; only its address matters, because it carries the `code` and `state` parameters.
100
+ PKCE and state verification are unchanged, so paste the URL unmodified.
101
+
105
102
  ### Secrets from environment or files
106
103
 
107
104
  Sensitive values (`--auth-header` values, `--oauth-client-id`, `--oauth-client-secret`) support
@@ -326,6 +323,8 @@ Options:
326
323
  --oauth-client-id ID OAuth client ID (supports env:/file: prefixes)
327
324
  --oauth-client-secret S OAuth client secret (supports env:/file: prefixes)
328
325
  --oauth-scope SCOPE OAuth scope(s) to request
326
+ --oauth-manual-callback Print the auth URL and read the redirect from stdin
327
+ (for hosts with no reachable browser)
329
328
  --cache-key KEY Custom cache key
330
329
  --cache-ttl SECONDS Cache TTL (default: 3600)
331
330
  --refresh Bypass cache
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "mcp2cli"
3
+ version = "3.4.0"
4
+ description = "Turn any MCP server or OpenAPI spec into a CLI"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ dependencies = [
9
+ "httpx",
10
+ "mcp>=1.0,<2",
11
+ "pyyaml",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "Stephan Fitzpatrick"
16
+ email = "stephan@knowsuchagency.com"
17
+
18
+ [project.optional-dependencies]
19
+ test = [
20
+ "pytest",
21
+ "pytest-asyncio",
22
+ "tiktoken",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/knowsuchagency/mcp2cli"
27
+ Repository = "https://github.com/knowsuchagency/mcp2cli"
28
+
29
+ [project.scripts]
30
+ mcp2cli = "mcp2cli:main"
31
+
32
+ [build-system]
33
+ requires = ["uv_build>=0.9.5,<0.10.0"]
34
+ build-backend = "uv_build"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp2cli"
3
- version = "3.3.0"
3
+ version = "3.4.0"
4
4
  description = "Turn any MCP server or OpenAPI spec into a CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -10,7 +10,7 @@ authors = [
10
10
  requires-python = ">=3.10"
11
11
  dependencies = [
12
12
  "httpx",
13
- "mcp>=1.0",
13
+ "mcp>=1.0,<2",
14
14
  "pyyaml",
15
15
  ]
16
16
 
@@ -85,6 +85,18 @@ class BakeConfig:
85
85
  include: list[str] = field(default_factory=list)
86
86
  exclude: list[str] = field(default_factory=list)
87
87
  methods: list[str] = field(default_factory=list)
88
+ prog: str | None = None
89
+ description: str | None = None
90
+
91
+
92
+ _DEFAULT_PARSER_DESCRIPTION = "Turn any MCP server or OpenAPI spec into a CLI"
93
+
94
+
95
+ def _parser_branding(bake_config: BakeConfig | None) -> tuple[str, str]:
96
+ if bake_config is not None and bake_config.prog is not None:
97
+ description = bake_config.description or _DEFAULT_PARSER_DESCRIPTION
98
+ return bake_config.prog, description
99
+ return "mcp2cli", _DEFAULT_PARSER_DESCRIPTION
88
100
 
89
101
 
90
102
  # ---------------------------------------------------------------------------
@@ -269,6 +281,30 @@ def _toon_encode(json_str: str) -> str | None:
269
281
  return None
270
282
 
271
283
 
284
+ def _ensure_utf8_output() -> None:
285
+ """Make non-ASCII output safe on consoles that cannot encode it.
286
+
287
+ JSON is emitted with ``ensure_ascii=False`` (issue #62), so CJK and emoji
288
+ reach stdout as real characters instead of ``\\uXXXX``. On a stream whose
289
+ encoding cannot represent them -- a redirected pipe under a legacy
290
+ Windows code page such as cp936 -- ``print()`` would raise
291
+ ``UnicodeEncodeError`` where the old escaped output was merely ugly.
292
+ Prefer UTF-8; if the stream refuses to be reconfigured, degrade to
293
+ backslash escapes, i.e. the pre-#62 shape, rather than crashing.
294
+ """
295
+ for stream in (sys.stdout, sys.stderr):
296
+ reconfigure = getattr(stream, "reconfigure", None)
297
+ if reconfigure is None:
298
+ continue
299
+ try:
300
+ reconfigure(encoding="utf-8")
301
+ except Exception:
302
+ try:
303
+ reconfigure(errors="backslashreplace")
304
+ except Exception:
305
+ pass
306
+
307
+
272
308
 
273
309
  def _apply_head(data, n: int):
274
310
  """Truncate data to first N elements (array) or return as-is (dict/scalar)."""
@@ -280,9 +316,9 @@ def _apply_head(data, n: int):
280
316
  def _emit_json(data, pretty: bool = False) -> None:
281
317
  """Print *data* as JSON. Indented when *pretty* or stdout is a TTY, else compact."""
282
318
  if pretty or sys.stdout.isatty():
283
- print(json.dumps(data, indent=2))
319
+ print(json.dumps(data, indent=2, ensure_ascii=False))
284
320
  else:
285
- print(json.dumps(data))
321
+ print(json.dumps(data, ensure_ascii=False))
286
322
 
287
323
 
288
324
  def output_result(
@@ -312,7 +348,7 @@ def output_result(
312
348
  if isinstance(data, str):
313
349
  print(data)
314
350
  else:
315
- print(json.dumps(data))
351
+ print(json.dumps(data, ensure_ascii=False))
316
352
  return
317
353
  if isinstance(data, str):
318
354
  try:
@@ -323,7 +359,7 @@ def output_result(
323
359
  if head is not None:
324
360
  data = _apply_head(data, head)
325
361
  if toon:
326
- encoded = _toon_encode(json.dumps(data))
362
+ encoded = _toon_encode(json.dumps(data, ensure_ascii=False))
327
363
  if encoded is not None:
328
364
  print(encoded, end="")
329
365
  return
@@ -668,6 +704,63 @@ def _find_free_port() -> int:
668
704
  return s.getsockname()[1]
669
705
 
670
706
 
707
+ def _parse_oauth_callback_input(text: str) -> tuple[str, str]:
708
+ """Extract ``(code, state)`` from a pasted OAuth callback URL.
709
+
710
+ Accepts the full redirect target the browser landed on
711
+ (``http://127.0.0.1:1234/callback?code=...&state=...``) or just its query
712
+ string. PKCE and CSRF verification stay in the MCP SDK -- we only hand it
713
+ the two values it asks for. The SDK compares ``state`` against the one it
714
+ generated with ``secrets.compare_digest`` and treats ``None`` as a
715
+ mismatch, so a paste missing ``state`` is rejected here with a readable
716
+ message instead of surfacing as an opaque
717
+ ``State parameter mismatch: None != ...``.
718
+ """
719
+ text = text.strip().strip("'\"")
720
+ if not text:
721
+ raise ValueError("No callback URL provided.")
722
+ params = parse_qs(urlparse(text).query or text)
723
+ if "error" in params:
724
+ detail = params.get("error_description", [""])[0]
725
+ suffix = f" ({detail})" if detail else ""
726
+ raise RuntimeError(f"OAuth error: {params['error'][0]}{suffix}")
727
+ if "code" not in params:
728
+ raise ValueError(
729
+ "That URL has no 'code' parameter. Paste the entire URL from the "
730
+ "browser's address bar, including everything after the '?'."
731
+ )
732
+ if "state" not in params:
733
+ raise ValueError(
734
+ "That URL has no 'state' parameter. Paste the URL unmodified -- "
735
+ "the MCP SDK verifies state to prevent CSRF and rejects a missing one."
736
+ )
737
+ return params["code"][0], params["state"][0]
738
+
739
+
740
+ def _prompt_oauth_callback(attempts: int = 3) -> tuple[str, str]:
741
+ """Read the OAuth callback URL from stdin.
742
+
743
+ For hosts with no reachable browser -- a VPS over SSH, a container.
744
+ Blocking, so callers run it off the event loop via ``anyio.to_thread``.
745
+ A malformed paste is re-prompted rather than fatal: the authorization
746
+ code is still live, and losing it would mean restarting the whole flow.
747
+ """
748
+ for remaining in reversed(range(attempts)):
749
+ print("Paste the full callback URL here: ", end="", file=sys.stderr, flush=True)
750
+ line = sys.stdin.readline()
751
+ if not line:
752
+ raise RuntimeError(
753
+ "stdin closed before an OAuth callback URL was pasted; "
754
+ "--oauth-manual-callback needs an interactive terminal."
755
+ )
756
+ try:
757
+ return _parse_oauth_callback_input(line)
758
+ except ValueError as exc:
759
+ if not remaining:
760
+ raise
761
+ print(f"{exc} ({remaining} attempt(s) left)", file=sys.stderr)
762
+
763
+
671
764
 
672
765
 
673
766
  def _get_cached_redirect_uri(storage: "FileTokenStorage") -> str | None:
@@ -733,6 +826,7 @@ def build_oauth_provider(
733
826
  scope: str | None = None,
734
827
  redirect_uri: str | None = None,
735
828
  flow: str = "auto",
829
+ manual_callback: bool = False,
736
830
  ) -> "httpx.Auth":
737
831
  """Build an OAuth provider for HTTP connections.
738
832
 
@@ -751,6 +845,10 @@ def build_oauth_provider(
751
845
 
752
846
  redirect_uri controls the full callback URL (scheme, host, port, path).
753
847
  When None, defaults to http://127.0.0.1:<random-free-port>/callback.
848
+
849
+ manual_callback skips the local callback server entirely and reads the
850
+ redirect URL from stdin instead, for hosts where the browser runs on a
851
+ different machine (issue #71).
754
852
  """
755
853
  storage = FileTokenStorage(server_url)
756
854
 
@@ -810,15 +908,53 @@ def build_oauth_provider(
810
908
  and the CLI hangs on the callback.
811
909
 
812
910
  We patch both by restoring ``token_expiry_time`` from a sidecar
813
- we persist in :class:`FileTokenStorage`, and by wiping the cached
814
- ``client_info`` from disk and memory whenever a refresh fails so
815
- the subsequent re-auth performs fresh Dynamic Client Registration.
911
+ we persist in :class:`FileTokenStorage`, and — only when the token
912
+ endpoint *definitively* rejects the client or grant — by wiping the
913
+ cached ``client_info`` from disk and memory so the subsequent re-auth
914
+ performs fresh Dynamic Client Registration.
915
+
916
+ Issue #59: the wipe must NOT fire on a transient refresh failure (a
917
+ brief 5xx, clock skew, a momentarily-unhappy token endpoint). Erasing
918
+ ``client.json``/``tokens.json`` on a transient blip forces a full
919
+ interactive ``authorization_code`` consent on the next call, which
920
+ permanently bricks any headless/scheduled run that has no browser. We
921
+ therefore preserve the persisted OAuth state on anything that isn't a
922
+ clean ``invalid_client``/``invalid_grant`` so a later retry can refresh
923
+ again on its own.
816
924
  """
817
925
 
818
926
  async def _initialize(self) -> None:
819
927
  await super()._initialize()
820
928
  _restore_token_expiry_from_sidecar(self.context)
821
929
 
930
+ @staticmethod
931
+ async def _refresh_failure_is_definitive(response) -> bool:
932
+ """Return True only when a failed refresh means the cached client
933
+ or grant is genuinely dead and must be discarded to recover.
934
+
935
+ Distinguishes a definitive ``invalid_client`` / ``invalid_grant``
936
+ rejection (RFC 6749 §5.2) from a transient transport/server error.
937
+ Returns False — i.e. "keep the persisted state" — for a 5xx, a
938
+ malformed body, or any other failure a later retry might survive
939
+ (issue #59).
940
+ """
941
+ status = getattr(response, "status_code", None)
942
+ # Per RFC 6749 §5.2 a 401 from the token endpoint means client
943
+ # authentication failed (invalid_client) even when the body
944
+ # carries no machine-readable error code — definitive.
945
+ if status == 401:
946
+ return True
947
+ # Anything that isn't an OAuth 400/401 error response (notably a
948
+ # 5xx) is transient: keep the cache so the next run can retry.
949
+ if status != 400:
950
+ return False
951
+ try:
952
+ error = json.loads(await response.aread()).get("error")
953
+ except Exception:
954
+ # Unparseable body on a 400 — be conservative and preserve.
955
+ return False
956
+ return error in ("invalid_client", "invalid_grant", "unauthorized_client")
957
+
822
958
  async def _handle_refresh_response(self, response) -> bool:
823
959
  # Issue #58: RFC 6749 §5.1 permits a refresh response to omit
824
960
  # refresh_token, in which case the previously issued one stays
@@ -832,15 +968,23 @@ def build_oauth_provider(
832
968
  )
833
969
  ok = await super()._handle_refresh_response(response)
834
970
  if not ok:
835
- # Refresh failed. The cached DCR client_id may have been
836
- # forgotten by the auth server too; clear it so the
837
- # subsequent 401 fallback performs a fresh registration
838
- # instead of /authorize?client_id=<stale> → opaque 500.
839
- self.context.client_info = None
840
- storage = self.context.storage
841
- if isinstance(storage, FileTokenStorage):
842
- storage.clear_client_info()
843
- storage.clear_tokens()
971
+ # Refresh failed. Only when the token endpoint *definitively*
972
+ # rejects us (invalid_client / invalid_grant) do we wipe the
973
+ # cached DCR client_id + tokens so the subsequent 401 fallback
974
+ # performs a fresh registration instead of
975
+ # /authorize?client_id=<stale> → opaque 500 (issue #54).
976
+ #
977
+ # A transient failure (5xx, blip, clock skew) must NOT erase
978
+ # the cache — doing so would force an interactive consent that
979
+ # no headless/scheduled run can complete (issue #59). Leaving
980
+ # client.json/tokens.json in place lets the next run simply
981
+ # retry the refresh.
982
+ if await self._refresh_failure_is_definitive(response):
983
+ self.context.client_info = None
984
+ storage = self.context.storage
985
+ if isinstance(storage, FileTokenStorage):
986
+ storage.clear_client_info()
987
+ storage.clear_tokens()
844
988
  return ok
845
989
  # Carry the prior refresh token forward when the server did not
846
990
  # issue a new one, then re-persist so it survives a restart.
@@ -943,41 +1087,62 @@ def build_oauth_provider(
943
1087
  )
944
1088
  storage._client_path.write_text(pre_client_info.model_dump_json())
945
1089
 
946
- # Reset callback handler state
947
- _CallbackHandler.auth_code = None
948
- _CallbackHandler.state = None
949
- _CallbackHandler.error = None
950
- _CallbackHandler.done = threading.Event()
1090
+ if manual_callback:
1091
+ # Nothing on this host can receive the redirect (e.g. a VPS reached
1092
+ # over SSH), so print the URL for a browser elsewhere and take the
1093
+ # redirect back by hand. The loopback redirect_uri is still what gets
1094
+ # registered and sent, so the remote browser's final URL carries
1095
+ # code+state even though no listener exists on that port. (Issue #71.)
1096
+ async def redirect_handler(auth_url: str) -> None:
1097
+ print(
1098
+ "Open this URL in a browser on any machine and authorize:",
1099
+ file=sys.stderr,
1100
+ )
1101
+ print(f"\n{auth_url}\n", file=sys.stderr)
1102
+ print(
1103
+ "The page you land on will fail to load -- that is expected, "
1104
+ "nothing is listening there. Only its URL matters.",
1105
+ file=sys.stderr,
1106
+ )
951
1107
 
952
- if callback_host == "::1":
953
- import socket as _socket
1108
+ async def callback_handler() -> tuple[str, str | None]:
1109
+ return await anyio.to_thread.run_sync(_prompt_oauth_callback)
1110
+ else:
1111
+ # Reset callback handler state
1112
+ _CallbackHandler.auth_code = None
1113
+ _CallbackHandler.state = None
1114
+ _CallbackHandler.error = None
1115
+ _CallbackHandler.done = threading.Event()
954
1116
 
955
- class _IPv6HTTPServer(HTTPServer):
956
- address_family = _socket.AF_INET6
1117
+ if callback_host == "::1":
1118
+ import socket as _socket
957
1119
 
958
- server = _IPv6HTTPServer((callback_host, port), _CallbackHandler)
959
- else:
960
- server = HTTPServer((callback_host, port), _CallbackHandler)
961
-
962
- async def redirect_handler(auth_url: str) -> None:
963
- print("Opening browser for authorization...", file=sys.stderr)
964
- print(f"If browser doesn't open, visit: {auth_url}", file=sys.stderr)
965
- webbrowser.open(auth_url)
966
-
967
- async def callback_handler() -> tuple[str, str | None]:
968
- # Run the HTTP server in a thread, wait for the callback
969
- thread = threading.Thread(target=server.handle_request, daemon=True)
970
- thread.start()
971
- # Wait with timeout
972
- if not _CallbackHandler.done.wait(timeout=300):
1120
+ class _IPv6HTTPServer(HTTPServer):
1121
+ address_family = _socket.AF_INET6
1122
+
1123
+ server = _IPv6HTTPServer((callback_host, port), _CallbackHandler)
1124
+ else:
1125
+ server = HTTPServer((callback_host, port), _CallbackHandler)
1126
+
1127
+ async def redirect_handler(auth_url: str) -> None:
1128
+ print("Opening browser for authorization...", file=sys.stderr)
1129
+ print(f"If browser doesn't open, visit: {auth_url}", file=sys.stderr)
1130
+ webbrowser.open(auth_url)
1131
+
1132
+ async def callback_handler() -> tuple[str, str | None]:
1133
+ # Run the HTTP server in a thread, wait for the callback
1134
+ thread = threading.Thread(target=server.handle_request, daemon=True)
1135
+ thread.start()
1136
+ # Wait with timeout
1137
+ if not _CallbackHandler.done.wait(timeout=300):
1138
+ server.server_close()
1139
+ raise TimeoutError("OAuth callback timed out after 5 minutes")
973
1140
  server.server_close()
974
- raise TimeoutError("OAuth callback timed out after 5 minutes")
975
- server.server_close()
976
- if _CallbackHandler.error:
977
- raise RuntimeError(f"OAuth error: {_CallbackHandler.error}")
978
- if not _CallbackHandler.auth_code:
979
- raise RuntimeError("No authorization code received")
980
- return (_CallbackHandler.auth_code, _CallbackHandler.state)
1141
+ if _CallbackHandler.error:
1142
+ raise RuntimeError(f"OAuth error: {_CallbackHandler.error}")
1143
+ if not _CallbackHandler.auth_code:
1144
+ raise RuntimeError("No authorization code received")
1145
+ return (_CallbackHandler.auth_code, _CallbackHandler.state)
981
1146
 
982
1147
  return _RobustOAuthClientProvider(
983
1148
  server_url=server_url,
@@ -1879,6 +2044,8 @@ def _baked_to_argv(config: dict) -> list[str]:
1879
2044
  argv += ["--oauth-redirect-uri", config["oauth_redirect_uri"]]
1880
2045
  if config.get("oauth_flow") and config["oauth_flow"] != "auto":
1881
2046
  argv += ["--oauth-flow", config["oauth_flow"]]
2047
+ if config.get("oauth_manual_callback"):
2048
+ argv.append("--oauth-manual-callback")
1882
2049
  return argv
1883
2050
 
1884
2051
 
@@ -1936,6 +2103,7 @@ def _bake_create(argv: list[str]) -> None:
1936
2103
  p.add_argument("--oauth-client-name", default="mcp2cli")
1937
2104
  p.add_argument("--oauth-scope", default=None)
1938
2105
  p.add_argument("--oauth-redirect-uri", default=None, metavar="URI")
2106
+ p.add_argument("--oauth-manual-callback", action="store_true")
1939
2107
  p.add_argument(
1940
2108
  "--oauth-flow",
1941
2109
  choices=["auto", "authorization_code", "client_credentials"],
@@ -2003,6 +2171,7 @@ def _bake_create(argv: list[str]) -> None:
2003
2171
  "oauth_scope": args.oauth_scope,
2004
2172
  "oauth_redirect_uri": args.oauth_redirect_uri,
2005
2173
  "oauth_flow": args.oauth_flow,
2174
+ "oauth_manual_callback": args.oauth_manual_callback,
2006
2175
  "include": [x.strip() for x in args.include.split(",") if x.strip()],
2007
2176
  "exclude": [x.strip() for x in args.exclude.split(",") if x.strip()],
2008
2177
  "methods": [x.strip().upper() for x in args.methods.split(",") if x.strip()],
@@ -2047,7 +2216,7 @@ def _bake_show(argv: list[str]) -> None:
2047
2216
  else:
2048
2217
  masked.append([name, val[:4] + "****" if len(val) > 4 else "****"])
2049
2218
  display["auth_headers"] = masked
2050
- print(json.dumps(display, indent=2))
2219
+ print(json.dumps(display, indent=2, ensure_ascii=False))
2051
2220
 
2052
2221
 
2053
2222
  def _bake_remove(argv: list[str]) -> None:
@@ -2141,6 +2310,8 @@ def _run_baked(name: str, argv: list[str]) -> None:
2141
2310
  include=cfg.get("include", []),
2142
2311
  exclude=cfg.get("exclude", []),
2143
2312
  methods=cfg.get("methods", []),
2313
+ prog=name,
2314
+ description=cfg.get("description"),
2144
2315
  )
2145
2316
  _main_impl(synthetic_argv, bake_config=bake_config)
2146
2317
 
@@ -2151,11 +2322,15 @@ def _run_baked(name: str, argv: list[str]) -> None:
2151
2322
 
2152
2323
 
2153
2324
  def build_argparse(
2154
- commands: list[CommandDef], pre_parser: argparse.ArgumentParser
2325
+ commands: list[CommandDef],
2326
+ pre_parser: argparse.ArgumentParser,
2327
+ *,
2328
+ prog: str = "mcp2cli",
2329
+ description: str = _DEFAULT_PARSER_DESCRIPTION,
2155
2330
  ) -> argparse.ArgumentParser:
2156
2331
  parser = argparse.ArgumentParser(
2157
- prog="mcp2cli",
2158
- description="Turn any MCP server or OpenAPI spec into a CLI",
2332
+ prog=prog,
2333
+ description=description,
2159
2334
  parents=[pre_parser],
2160
2335
  )
2161
2336
  subparsers = parser.add_subparsers(dest="_command")
@@ -2683,14 +2858,14 @@ async def _mcp_session(
2683
2858
  )
2684
2859
 
2685
2860
  if list_mode:
2686
- result = await session.list_tools()
2861
+ all_tools = await _list_all_tools(session)
2687
2862
  tools = [
2688
2863
  {
2689
2864
  "name": t.name,
2690
2865
  "description": t.description or "",
2691
2866
  "inputSchema": t.inputSchema or {},
2692
2867
  }
2693
- for t in result.tools
2868
+ for t in all_tools
2694
2869
  ]
2695
2870
  commands = extract_mcp_commands(tools)
2696
2871
  if search_pattern:
@@ -2986,11 +3161,27 @@ def _extract_content_parts(content_list, *, attrs=("text", "data")) -> str:
2986
3161
  return "\n".join(parts) if parts else ""
2987
3162
 
2988
3163
 
3164
+ async def _list_all_tools(session):
3165
+ """Fetch every tool from an MCP session, following `nextCursor` until
3166
+ exhausted. Per the MCP spec, tools/list is paginated and page size is
3167
+ entirely up to the server, so a single call is not guaranteed to return
3168
+ the full tool set: https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/pagination
3169
+ """
3170
+ tools = []
3171
+ cursor = None
3172
+ while True:
3173
+ result = await session.list_tools(cursor=cursor)
3174
+ tools.extend(result.tools)
3175
+ cursor = result.nextCursor
3176
+ if not cursor:
3177
+ return tools
3178
+
3179
+
2989
3180
  async def _dispatch_list_tools(session, params):
2990
- result = await session.list_tools()
3181
+ tools = await _list_all_tools(session)
2991
3182
  return [
2992
3183
  {"name": t.name, "description": t.description or "", "inputSchema": t.inputSchema or {}}
2993
- for t in result.tools
3184
+ for t in tools
2994
3185
  ]
2995
3186
 
2996
3187
 
@@ -3431,7 +3622,8 @@ def handle_mcp(
3431
3622
  return
3432
3623
 
3433
3624
  pre = argparse.ArgumentParser(add_help=False)
3434
- parser = build_argparse(commands, pre)
3625
+ prog, description = _parser_branding(bake_config)
3626
+ parser = build_argparse(commands, pre, prog=prog, description=description)
3435
3627
  args = parser.parse_args(remaining)
3436
3628
 
3437
3629
  if not hasattr(args, "_cmd"):
@@ -3471,14 +3663,14 @@ def _fetch_mcp_tools(
3471
3663
  tools_result: list[dict] = []
3472
3664
 
3473
3665
  async def _extract_tools(session):
3474
- result = await session.list_tools()
3666
+ all_tools = await _list_all_tools(session)
3475
3667
  tools_result.extend(
3476
3668
  {
3477
3669
  "name": t.name,
3478
3670
  "description": t.description or "",
3479
3671
  "inputSchema": t.inputSchema or {},
3480
3672
  }
3481
- for t in result.tools
3673
+ for t in all_tools
3482
3674
  )
3483
3675
 
3484
3676
  async def _run():
@@ -3584,6 +3776,7 @@ def _split_at_subcommand(
3584
3776
 
3585
3777
 
3586
3778
  def main():
3779
+ _ensure_utf8_output()
3587
3780
  if len(sys.argv) > 1:
3588
3781
  first = sys.argv[1]
3589
3782
  if first == "bake":
@@ -3759,6 +3952,13 @@ def _build_main_parser() -> argparse.ArgumentParser:
3759
3952
  "client secret (required for confidential-client servers like Slack)."
3760
3953
  ),
3761
3954
  )
3955
+ pre.add_argument(
3956
+ "--oauth-manual-callback",
3957
+ action="store_true",
3958
+ help="Don't run a local callback server; print the authorization URL and read "
3959
+ "the redirect URL back from stdin. For hosts with no reachable browser, "
3960
+ "e.g. a VPS over SSH.",
3961
+ )
3762
3962
  # Resource flags
3763
3963
  pre.add_argument(
3764
3964
  "--list-resources", action="store_true", help="List available resources"
@@ -3888,6 +4088,7 @@ def _setup_oauth(pre_args):
3888
4088
  scope=pre_args.oauth_scope,
3889
4089
  redirect_uri=pre_args.oauth_redirect_uri,
3890
4090
  flow=flow,
4091
+ manual_callback=getattr(pre_args, "oauth_manual_callback", False),
3891
4092
  )
3892
4093
 
3893
4094
 
@@ -4149,7 +4350,8 @@ def _handle_openapi_mode(
4149
4350
  )
4150
4351
  sys.exit(1)
4151
4352
 
4152
- parser = build_argparse(commands, pre)
4353
+ prog, description = _parser_branding(bake_config)
4354
+ parser = build_argparse(commands, pre, prog=prog, description=description)
4153
4355
  args = parser.parse_args(remaining)
4154
4356
 
4155
4357
  if not hasattr(args, "_cmd"):
File without changes
File without changes