mcp2cli 3.3.1__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.1
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.1"
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
 
@@ -989,41 +1087,62 @@ def build_oauth_provider(
989
1087
  )
990
1088
  storage._client_path.write_text(pre_client_info.model_dump_json())
991
1089
 
992
- # Reset callback handler state
993
- _CallbackHandler.auth_code = None
994
- _CallbackHandler.state = None
995
- _CallbackHandler.error = None
996
- _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
+ )
997
1107
 
998
- if callback_host == "::1":
999
- 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()
1000
1116
 
1001
- class _IPv6HTTPServer(HTTPServer):
1002
- address_family = _socket.AF_INET6
1117
+ if callback_host == "::1":
1118
+ import socket as _socket
1003
1119
 
1004
- server = _IPv6HTTPServer((callback_host, port), _CallbackHandler)
1005
- else:
1006
- server = HTTPServer((callback_host, port), _CallbackHandler)
1007
-
1008
- async def redirect_handler(auth_url: str) -> None:
1009
- print("Opening browser for authorization...", file=sys.stderr)
1010
- print(f"If browser doesn't open, visit: {auth_url}", file=sys.stderr)
1011
- webbrowser.open(auth_url)
1012
-
1013
- async def callback_handler() -> tuple[str, str | None]:
1014
- # Run the HTTP server in a thread, wait for the callback
1015
- thread = threading.Thread(target=server.handle_request, daemon=True)
1016
- thread.start()
1017
- # Wait with timeout
1018
- 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")
1019
1140
  server.server_close()
1020
- raise TimeoutError("OAuth callback timed out after 5 minutes")
1021
- server.server_close()
1022
- if _CallbackHandler.error:
1023
- raise RuntimeError(f"OAuth error: {_CallbackHandler.error}")
1024
- if not _CallbackHandler.auth_code:
1025
- raise RuntimeError("No authorization code received")
1026
- 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)
1027
1146
 
1028
1147
  return _RobustOAuthClientProvider(
1029
1148
  server_url=server_url,
@@ -1925,6 +2044,8 @@ def _baked_to_argv(config: dict) -> list[str]:
1925
2044
  argv += ["--oauth-redirect-uri", config["oauth_redirect_uri"]]
1926
2045
  if config.get("oauth_flow") and config["oauth_flow"] != "auto":
1927
2046
  argv += ["--oauth-flow", config["oauth_flow"]]
2047
+ if config.get("oauth_manual_callback"):
2048
+ argv.append("--oauth-manual-callback")
1928
2049
  return argv
1929
2050
 
1930
2051
 
@@ -1982,6 +2103,7 @@ def _bake_create(argv: list[str]) -> None:
1982
2103
  p.add_argument("--oauth-client-name", default="mcp2cli")
1983
2104
  p.add_argument("--oauth-scope", default=None)
1984
2105
  p.add_argument("--oauth-redirect-uri", default=None, metavar="URI")
2106
+ p.add_argument("--oauth-manual-callback", action="store_true")
1985
2107
  p.add_argument(
1986
2108
  "--oauth-flow",
1987
2109
  choices=["auto", "authorization_code", "client_credentials"],
@@ -2049,6 +2171,7 @@ def _bake_create(argv: list[str]) -> None:
2049
2171
  "oauth_scope": args.oauth_scope,
2050
2172
  "oauth_redirect_uri": args.oauth_redirect_uri,
2051
2173
  "oauth_flow": args.oauth_flow,
2174
+ "oauth_manual_callback": args.oauth_manual_callback,
2052
2175
  "include": [x.strip() for x in args.include.split(",") if x.strip()],
2053
2176
  "exclude": [x.strip() for x in args.exclude.split(",") if x.strip()],
2054
2177
  "methods": [x.strip().upper() for x in args.methods.split(",") if x.strip()],
@@ -2093,7 +2216,7 @@ def _bake_show(argv: list[str]) -> None:
2093
2216
  else:
2094
2217
  masked.append([name, val[:4] + "****" if len(val) > 4 else "****"])
2095
2218
  display["auth_headers"] = masked
2096
- print(json.dumps(display, indent=2))
2219
+ print(json.dumps(display, indent=2, ensure_ascii=False))
2097
2220
 
2098
2221
 
2099
2222
  def _bake_remove(argv: list[str]) -> None:
@@ -2187,6 +2310,8 @@ def _run_baked(name: str, argv: list[str]) -> None:
2187
2310
  include=cfg.get("include", []),
2188
2311
  exclude=cfg.get("exclude", []),
2189
2312
  methods=cfg.get("methods", []),
2313
+ prog=name,
2314
+ description=cfg.get("description"),
2190
2315
  )
2191
2316
  _main_impl(synthetic_argv, bake_config=bake_config)
2192
2317
 
@@ -2197,11 +2322,15 @@ def _run_baked(name: str, argv: list[str]) -> None:
2197
2322
 
2198
2323
 
2199
2324
  def build_argparse(
2200
- 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,
2201
2330
  ) -> argparse.ArgumentParser:
2202
2331
  parser = argparse.ArgumentParser(
2203
- prog="mcp2cli",
2204
- description="Turn any MCP server or OpenAPI spec into a CLI",
2332
+ prog=prog,
2333
+ description=description,
2205
2334
  parents=[pre_parser],
2206
2335
  )
2207
2336
  subparsers = parser.add_subparsers(dest="_command")
@@ -2729,14 +2858,14 @@ async def _mcp_session(
2729
2858
  )
2730
2859
 
2731
2860
  if list_mode:
2732
- result = await session.list_tools()
2861
+ all_tools = await _list_all_tools(session)
2733
2862
  tools = [
2734
2863
  {
2735
2864
  "name": t.name,
2736
2865
  "description": t.description or "",
2737
2866
  "inputSchema": t.inputSchema or {},
2738
2867
  }
2739
- for t in result.tools
2868
+ for t in all_tools
2740
2869
  ]
2741
2870
  commands = extract_mcp_commands(tools)
2742
2871
  if search_pattern:
@@ -3032,11 +3161,27 @@ def _extract_content_parts(content_list, *, attrs=("text", "data")) -> str:
3032
3161
  return "\n".join(parts) if parts else ""
3033
3162
 
3034
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
+
3035
3180
  async def _dispatch_list_tools(session, params):
3036
- result = await session.list_tools()
3181
+ tools = await _list_all_tools(session)
3037
3182
  return [
3038
3183
  {"name": t.name, "description": t.description or "", "inputSchema": t.inputSchema or {}}
3039
- for t in result.tools
3184
+ for t in tools
3040
3185
  ]
3041
3186
 
3042
3187
 
@@ -3477,7 +3622,8 @@ def handle_mcp(
3477
3622
  return
3478
3623
 
3479
3624
  pre = argparse.ArgumentParser(add_help=False)
3480
- parser = build_argparse(commands, pre)
3625
+ prog, description = _parser_branding(bake_config)
3626
+ parser = build_argparse(commands, pre, prog=prog, description=description)
3481
3627
  args = parser.parse_args(remaining)
3482
3628
 
3483
3629
  if not hasattr(args, "_cmd"):
@@ -3517,14 +3663,14 @@ def _fetch_mcp_tools(
3517
3663
  tools_result: list[dict] = []
3518
3664
 
3519
3665
  async def _extract_tools(session):
3520
- result = await session.list_tools()
3666
+ all_tools = await _list_all_tools(session)
3521
3667
  tools_result.extend(
3522
3668
  {
3523
3669
  "name": t.name,
3524
3670
  "description": t.description or "",
3525
3671
  "inputSchema": t.inputSchema or {},
3526
3672
  }
3527
- for t in result.tools
3673
+ for t in all_tools
3528
3674
  )
3529
3675
 
3530
3676
  async def _run():
@@ -3630,6 +3776,7 @@ def _split_at_subcommand(
3630
3776
 
3631
3777
 
3632
3778
  def main():
3779
+ _ensure_utf8_output()
3633
3780
  if len(sys.argv) > 1:
3634
3781
  first = sys.argv[1]
3635
3782
  if first == "bake":
@@ -3805,6 +3952,13 @@ def _build_main_parser() -> argparse.ArgumentParser:
3805
3952
  "client secret (required for confidential-client servers like Slack)."
3806
3953
  ),
3807
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
+ )
3808
3962
  # Resource flags
3809
3963
  pre.add_argument(
3810
3964
  "--list-resources", action="store_true", help="List available resources"
@@ -3934,6 +4088,7 @@ def _setup_oauth(pre_args):
3934
4088
  scope=pre_args.oauth_scope,
3935
4089
  redirect_uri=pre_args.oauth_redirect_uri,
3936
4090
  flow=flow,
4091
+ manual_callback=getattr(pre_args, "oauth_manual_callback", False),
3937
4092
  )
3938
4093
 
3939
4094
 
@@ -4195,7 +4350,8 @@ def _handle_openapi_mode(
4195
4350
  )
4196
4351
  sys.exit(1)
4197
4352
 
4198
- parser = build_argparse(commands, pre)
4353
+ prog, description = _parser_branding(bake_config)
4354
+ parser = build_argparse(commands, pre, prog=prog, description=description)
4199
4355
  args = parser.parse_args(remaining)
4200
4356
 
4201
4357
  if not hasattr(args, "_cmd"):
File without changes
File without changes