mcp2cli 3.3.1__tar.gz → 3.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.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp2cli
3
- Version: 3.3.1
3
+ Version: 3.5.0
4
4
  Summary: Turn any MCP server or OpenAPI spec into a CLI
5
5
  Author: Stephan Fitzpatrick
6
6
  Author-email: Stephan Fitzpatrick <stephan@knowsuchagency.com>
7
7
  License-Expression: MIT
8
8
  Requires-Dist: httpx
9
- Requires-Dist: mcp>=1.0
9
+ Requires-Dist: mcp>=1.26,<3
10
10
  Requires-Dist: pyyaml
11
11
  Requires-Dist: pytest ; extra == 'test'
12
12
  Requires-Dist: pytest-asyncio ; extra == 'test'
@@ -102,6 +102,22 @@ mcp2cli --spec ./openapi.json --base-url https://api.example.com --oauth --list
102
102
  Tokens are persisted in `~/.cache/mcp2cli/oauth/` so subsequent calls reuse existing tokens
103
103
  and refresh automatically when they expire.
104
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
+
105
121
  ### Secrets from environment or files
106
122
 
107
123
  Sensitive values (`--auth-header` values, `--oauth-client-id`, `--oauth-client-secret`) support
@@ -326,6 +342,8 @@ Options:
326
342
  --oauth-client-id ID OAuth client ID (supports env:/file: prefixes)
327
343
  --oauth-client-secret S OAuth client secret (supports env:/file: prefixes)
328
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)
329
347
  --cache-key KEY Custom cache key
330
348
  --cache-ttl SECONDS Cache TTL (default: 3600)
331
349
  --refresh Bypass cache
@@ -365,13 +383,27 @@ Subcommands and their flags are generated dynamically from the spec or MCP serve
365
383
  # Install with test + MCP deps
366
384
  uv sync --extra test
367
385
 
368
- # Run tests (96 tests covering OpenAPI, MCP stdio, MCP HTTP, caching, and token savings)
386
+ # Run tests
369
387
  uv run pytest tests/ -v
370
388
 
371
389
  # Run just the token savings tests
372
390
  uv run pytest tests/test_token_savings.py -v -s
373
391
  ```
374
392
 
393
+ ### MCP SDK compatibility
394
+
395
+ mcp2cli works with **both major versions** of the MCP Python SDK (`mcp>=1.26,<3`),
396
+ so it never forces a resolver conflict with other tools in the same environment.
397
+ CI runs the suite against the declared floor, the latest 1.x, and the latest 2.x.
398
+
399
+ The two majors differ in ways that matter to a client — v2 renamed model fields
400
+ to snake_case, replaced `streamablehttp_client`, moved to `httpx2`, and dropped
401
+ the session-id element from the transport tuple. Those differences are confined
402
+ to a handful of helpers (`_mcp_attr`, `_mcp_dump`, `_streamable_streams`,
403
+ `_list_tools_page`, `_resource_uri`, `_authorization_code_result`); the rest of
404
+ the codebase is version-agnostic. The test fixtures speak the JSON-RPC wire
405
+ protocol directly and import no SDK, so they hold across majors.
406
+
375
407
  ---
376
408
 
377
409
  ## License
@@ -83,6 +83,22 @@ mcp2cli --spec ./openapi.json --base-url https://api.example.com --oauth --list
83
83
  Tokens are persisted in `~/.cache/mcp2cli/oauth/` so subsequent calls reuse existing tokens
84
84
  and refresh automatically when they expire.
85
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
+
86
102
  ### Secrets from environment or files
87
103
 
88
104
  Sensitive values (`--auth-header` values, `--oauth-client-id`, `--oauth-client-secret`) support
@@ -307,6 +323,8 @@ Options:
307
323
  --oauth-client-id ID OAuth client ID (supports env:/file: prefixes)
308
324
  --oauth-client-secret S OAuth client secret (supports env:/file: prefixes)
309
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)
310
328
  --cache-key KEY Custom cache key
311
329
  --cache-ttl SECONDS Cache TTL (default: 3600)
312
330
  --refresh Bypass cache
@@ -346,13 +364,27 @@ Subcommands and their flags are generated dynamically from the spec or MCP serve
346
364
  # Install with test + MCP deps
347
365
  uv sync --extra test
348
366
 
349
- # Run tests (96 tests covering OpenAPI, MCP stdio, MCP HTTP, caching, and token savings)
367
+ # Run tests
350
368
  uv run pytest tests/ -v
351
369
 
352
370
  # Run just the token savings tests
353
371
  uv run pytest tests/test_token_savings.py -v -s
354
372
  ```
355
373
 
374
+ ### MCP SDK compatibility
375
+
376
+ mcp2cli works with **both major versions** of the MCP Python SDK (`mcp>=1.26,<3`),
377
+ so it never forces a resolver conflict with other tools in the same environment.
378
+ CI runs the suite against the declared floor, the latest 1.x, and the latest 2.x.
379
+
380
+ The two majors differ in ways that matter to a client — v2 renamed model fields
381
+ to snake_case, replaced `streamablehttp_client`, moved to `httpx2`, and dropped
382
+ the session-id element from the transport tuple. Those differences are confined
383
+ to a handful of helpers (`_mcp_attr`, `_mcp_dump`, `_streamable_streams`,
384
+ `_list_tools_page`, `_resource_uri`, `_authorization_code_result`); the rest of
385
+ the codebase is version-agnostic. The test fixtures speak the JSON-RPC wire
386
+ protocol directly and import no SDK, so they hold across majors.
387
+
356
388
  ---
357
389
 
358
390
  ## License
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "mcp2cli"
3
+ version = "3.5.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.26,<3",
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.5.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,9 @@ authors = [
10
10
  requires-python = ">=3.10"
11
11
  dependencies = [
12
12
  "httpx",
13
- "mcp>=1.0",
13
+ # Works on both SDK majors; 1.26 is the floor we verify in CI, and the one
14
+ # where `streamable_http_client` + `create_mcp_http_client` are both present.
15
+ "mcp>=1.26,<3",
14
16
  "pyyaml",
15
17
  ]
16
18
 
@@ -23,6 +23,7 @@ import sys
23
23
  import threading
24
24
  import time
25
25
  import webbrowser
26
+ from contextlib import asynccontextmanager
26
27
  from dataclasses import dataclass, field
27
28
  from http.server import HTTPServer, BaseHTTPRequestHandler
28
29
  from pathlib import Path
@@ -85,6 +86,18 @@ class BakeConfig:
85
86
  include: list[str] = field(default_factory=list)
86
87
  exclude: list[str] = field(default_factory=list)
87
88
  methods: list[str] = field(default_factory=list)
89
+ prog: str | None = None
90
+ description: str | None = None
91
+
92
+
93
+ _DEFAULT_PARSER_DESCRIPTION = "Turn any MCP server or OpenAPI spec into a CLI"
94
+
95
+
96
+ def _parser_branding(bake_config: BakeConfig | None) -> tuple[str, str]:
97
+ if bake_config is not None and bake_config.prog is not None:
98
+ description = bake_config.description or _DEFAULT_PARSER_DESCRIPTION
99
+ return bake_config.prog, description
100
+ return "mcp2cli", _DEFAULT_PARSER_DESCRIPTION
88
101
 
89
102
 
90
103
  # ---------------------------------------------------------------------------
@@ -269,6 +282,149 @@ def _toon_encode(json_str: str) -> str | None:
269
282
  return None
270
283
 
271
284
 
285
+ # ---------------------------------------------------------------------------
286
+ # MCP SDK compatibility (v1 and v2)
287
+ # ---------------------------------------------------------------------------
288
+
289
+ # SDK 2.0 renamed model fields from camelCase to snake_case, keeping camelCase
290
+ # only as serialization aliases -- so attribute access has to use the new name
291
+ # while the wire format is unchanged. A name missing from this map is an
292
+ # unhandled rename and raises KeyError rather than silently reading None.
293
+ _MCP_RENAMED_FIELDS = {
294
+ "inputSchema": "input_schema",
295
+ "outputSchema": "output_schema",
296
+ "nextCursor": "next_cursor",
297
+ "resourceTemplates": "resource_templates",
298
+ "uriTemplate": "uri_template",
299
+ "mimeType": "mime_type",
300
+ "structuredContent": "structured_content",
301
+ "isError": "is_error",
302
+ }
303
+
304
+
305
+ def _mcp_attr(obj, name: str):
306
+ """Read an SDK model field across the v1/v2 camelCase -> snake_case rename."""
307
+ try:
308
+ return getattr(obj, name)
309
+ except AttributeError:
310
+ return getattr(obj, _MCP_RENAMED_FIELDS[name])
311
+
312
+
313
+ def _demote_meta_alias(node: dict) -> None:
314
+ """Emit the SDK's ``_meta`` alias as ``meta``, the spelling mcp2cli ships."""
315
+ if "_meta" in node:
316
+ node["meta"] = node.pop("_meta")
317
+
318
+
319
+ def _mcp_dump(model) -> dict:
320
+ """Serialize an SDK model using the camelCase wire names on either major.
321
+
322
+ v2 renamed model attributes to snake_case, so a plain ``model_dump()``
323
+ would silently change mcp2cli's ``--json`` envelope from ``isError`` to
324
+ ``is_error`` depending on which SDK happened to be installed. ``by_alias``
325
+ pins the wire spelling on both.
326
+
327
+ ``meta`` is aliased to ``_meta`` in both majors, and mcp2cli has always
328
+ emitted it as ``meta``, so it is mapped back -- but only on the envelope
329
+ and its content items, which the SDK owns. ``structuredContent`` is the
330
+ tool's own payload and is never rewritten, so a tool that legitimately
331
+ returns a ``_meta`` key keeps it.
332
+ """
333
+ data = model.model_dump(mode="json", by_alias=True)
334
+ _demote_meta_alias(data)
335
+ for item in data.get("content") or ():
336
+ if isinstance(item, dict):
337
+ _demote_meta_alias(item)
338
+ return data
339
+
340
+
341
+ def _resource_uri(uri: str):
342
+ """Coerce a resource URI to what ``resources/read`` expects.
343
+
344
+ v1 types the request param as a pydantic ``AnyUrl``; v2 takes a plain
345
+ string and rejects an ``AnyUrl``.
346
+ """
347
+ from mcp.types import ReadResourceRequestParams
348
+
349
+ if ReadResourceRequestParams.model_fields["uri"].annotation is str:
350
+ return uri
351
+ from pydantic import AnyUrl
352
+
353
+ return AnyUrl(uri)
354
+
355
+
356
+ @asynccontextmanager
357
+ async def _streamable_streams(url: str, headers=None, auth=None):
358
+ """Open a streamable-http transport, yielding ``(read, write)``.
359
+
360
+ Three things differ across SDK majors here, which is why this is the only
361
+ place that talks to that transport (issues #68, #74):
362
+
363
+ * v2 dropped the ``streamablehttp_client`` alias, keeping only
364
+ ``streamable_http_client`` -- the original break.
365
+ * that surviving function takes a pre-built ``http_client`` instead of
366
+ ``headers``/``auth``, and v2 is built on **httpx2**, not httpx, so the
367
+ client has to come from the SDK's own factory to be the right flavour.
368
+ * v1 yields a third element (a get-session-id callback) that v2 dropped.
369
+ mcp2cli never used it, so both shapes collapse to ``(read, write)``.
370
+ """
371
+ from mcp.client.streamable_http import streamable_http_client
372
+ from mcp.shared._httpx_utils import create_mcp_http_client
373
+
374
+ async with create_mcp_http_client(headers=headers, auth=auth) as client:
375
+ async with streamable_http_client(url, http_client=client) as streams:
376
+ yield streams[0], streams[1]
377
+
378
+
379
+ async def _list_tools_page(session, cursor: str | None):
380
+ """Request one page of ``tools/list``.
381
+
382
+ Both majors accept ``params``; v1's ``cursor=`` shorthand is deprecated
383
+ there and gone in v2, so this is the one spelling that works on both.
384
+ """
385
+ from mcp.types import PaginatedRequestParams
386
+
387
+ params = PaginatedRequestParams(cursor=cursor) if cursor else None
388
+ return await session.list_tools(params=params)
389
+
390
+
391
+ def _authorization_code_result(code: str, state: str | None):
392
+ """Wrap a callback result in whatever ``callback_handler`` must return.
393
+
394
+ v1 expects a plain ``(code, state)`` tuple; v2 expects an
395
+ ``AuthorizationCodeResult`` model.
396
+ """
397
+ try:
398
+ from mcp.shared.auth import AuthorizationCodeResult
399
+ except ImportError:
400
+ return (code, state)
401
+ return AuthorizationCodeResult(code=code, state=state)
402
+
403
+
404
+ def _ensure_utf8_output() -> None:
405
+ """Make non-ASCII output safe on consoles that cannot encode it.
406
+
407
+ JSON is emitted with ``ensure_ascii=False`` (issue #62), so CJK and emoji
408
+ reach stdout as real characters instead of ``\\uXXXX``. On a stream whose
409
+ encoding cannot represent them -- a redirected pipe under a legacy
410
+ Windows code page such as cp936 -- ``print()`` would raise
411
+ ``UnicodeEncodeError`` where the old escaped output was merely ugly.
412
+ Prefer UTF-8; if the stream refuses to be reconfigured, degrade to
413
+ backslash escapes, i.e. the pre-#62 shape, rather than crashing.
414
+ """
415
+ for stream in (sys.stdout, sys.stderr):
416
+ reconfigure = getattr(stream, "reconfigure", None)
417
+ if reconfigure is None:
418
+ continue
419
+ try:
420
+ reconfigure(encoding="utf-8")
421
+ except Exception:
422
+ try:
423
+ reconfigure(errors="backslashreplace")
424
+ except Exception:
425
+ pass
426
+
427
+
272
428
 
273
429
  def _apply_head(data, n: int):
274
430
  """Truncate data to first N elements (array) or return as-is (dict/scalar)."""
@@ -280,9 +436,9 @@ def _apply_head(data, n: int):
280
436
  def _emit_json(data, pretty: bool = False) -> None:
281
437
  """Print *data* as JSON. Indented when *pretty* or stdout is a TTY, else compact."""
282
438
  if pretty or sys.stdout.isatty():
283
- print(json.dumps(data, indent=2))
439
+ print(json.dumps(data, indent=2, ensure_ascii=False))
284
440
  else:
285
- print(json.dumps(data))
441
+ print(json.dumps(data, ensure_ascii=False))
286
442
 
287
443
 
288
444
  def output_result(
@@ -312,7 +468,7 @@ def output_result(
312
468
  if isinstance(data, str):
313
469
  print(data)
314
470
  else:
315
- print(json.dumps(data))
471
+ print(json.dumps(data, ensure_ascii=False))
316
472
  return
317
473
  if isinstance(data, str):
318
474
  try:
@@ -323,7 +479,7 @@ def output_result(
323
479
  if head is not None:
324
480
  data = _apply_head(data, head)
325
481
  if toon:
326
- encoded = _toon_encode(json.dumps(data))
482
+ encoded = _toon_encode(json.dumps(data, ensure_ascii=False))
327
483
  if encoded is not None:
328
484
  print(encoded, end="")
329
485
  return
@@ -668,6 +824,63 @@ def _find_free_port() -> int:
668
824
  return s.getsockname()[1]
669
825
 
670
826
 
827
+ def _parse_oauth_callback_input(text: str) -> tuple[str, str]:
828
+ """Extract ``(code, state)`` from a pasted OAuth callback URL.
829
+
830
+ Accepts the full redirect target the browser landed on
831
+ (``http://127.0.0.1:1234/callback?code=...&state=...``) or just its query
832
+ string. PKCE and CSRF verification stay in the MCP SDK -- we only hand it
833
+ the two values it asks for. The SDK compares ``state`` against the one it
834
+ generated with ``secrets.compare_digest`` and treats ``None`` as a
835
+ mismatch, so a paste missing ``state`` is rejected here with a readable
836
+ message instead of surfacing as an opaque
837
+ ``State parameter mismatch: None != ...``.
838
+ """
839
+ text = text.strip().strip("'\"")
840
+ if not text:
841
+ raise ValueError("No callback URL provided.")
842
+ params = parse_qs(urlparse(text).query or text)
843
+ if "error" in params:
844
+ detail = params.get("error_description", [""])[0]
845
+ suffix = f" ({detail})" if detail else ""
846
+ raise RuntimeError(f"OAuth error: {params['error'][0]}{suffix}")
847
+ if "code" not in params:
848
+ raise ValueError(
849
+ "That URL has no 'code' parameter. Paste the entire URL from the "
850
+ "browser's address bar, including everything after the '?'."
851
+ )
852
+ if "state" not in params:
853
+ raise ValueError(
854
+ "That URL has no 'state' parameter. Paste the URL unmodified -- "
855
+ "the MCP SDK verifies state to prevent CSRF and rejects a missing one."
856
+ )
857
+ return params["code"][0], params["state"][0]
858
+
859
+
860
+ def _prompt_oauth_callback(attempts: int = 3) -> tuple[str, str]:
861
+ """Read the OAuth callback URL from stdin.
862
+
863
+ For hosts with no reachable browser -- a VPS over SSH, a container.
864
+ Blocking, so callers run it off the event loop via ``anyio.to_thread``.
865
+ A malformed paste is re-prompted rather than fatal: the authorization
866
+ code is still live, and losing it would mean restarting the whole flow.
867
+ """
868
+ for remaining in reversed(range(attempts)):
869
+ print("Paste the full callback URL here: ", end="", file=sys.stderr, flush=True)
870
+ line = sys.stdin.readline()
871
+ if not line:
872
+ raise RuntimeError(
873
+ "stdin closed before an OAuth callback URL was pasted; "
874
+ "--oauth-manual-callback needs an interactive terminal."
875
+ )
876
+ try:
877
+ return _parse_oauth_callback_input(line)
878
+ except ValueError as exc:
879
+ if not remaining:
880
+ raise
881
+ print(f"{exc} ({remaining} attempt(s) left)", file=sys.stderr)
882
+
883
+
671
884
 
672
885
 
673
886
  def _get_cached_redirect_uri(storage: "FileTokenStorage") -> str | None:
@@ -733,6 +946,7 @@ def build_oauth_provider(
733
946
  scope: str | None = None,
734
947
  redirect_uri: str | None = None,
735
948
  flow: str = "auto",
949
+ manual_callback: bool = False,
736
950
  ) -> "httpx.Auth":
737
951
  """Build an OAuth provider for HTTP connections.
738
952
 
@@ -751,6 +965,10 @@ def build_oauth_provider(
751
965
 
752
966
  redirect_uri controls the full callback URL (scheme, host, port, path).
753
967
  When None, defaults to http://127.0.0.1:<random-free-port>/callback.
968
+
969
+ manual_callback skips the local callback server entirely and reads the
970
+ redirect URL from stdin instead, for hosts where the browser runs on a
971
+ different machine (issue #71).
754
972
  """
755
973
  storage = FileTokenStorage(server_url)
756
974
 
@@ -779,12 +997,21 @@ def build_oauth_provider(
779
997
  await super()._initialize()
780
998
  _restore_token_expiry_from_sidecar(self.context)
781
999
 
1000
+ # v2 renamed the `scopes` argument to `scope`.
1001
+ import inspect
1002
+
1003
+ scope_kwarg = (
1004
+ "scope"
1005
+ if "scope"
1006
+ in inspect.signature(ClientCredentialsOAuthProvider.__init__).parameters
1007
+ else "scopes"
1008
+ )
782
1009
  return _RobustClientCredentialsProvider(
783
1010
  server_url=server_url,
784
1011
  storage=storage,
785
1012
  client_id=client_id,
786
1013
  client_secret=client_secret,
787
- scopes=scope,
1014
+ **{scope_kwarg: scope},
788
1015
  )
789
1016
 
790
1017
  from mcp.client.auth.oauth2 import OAuthClientProvider
@@ -989,41 +1216,65 @@ def build_oauth_provider(
989
1216
  )
990
1217
  storage._client_path.write_text(pre_client_info.model_dump_json())
991
1218
 
992
- # Reset callback handler state
993
- _CallbackHandler.auth_code = None
994
- _CallbackHandler.state = None
995
- _CallbackHandler.error = None
996
- _CallbackHandler.done = threading.Event()
1219
+ if manual_callback:
1220
+ # Nothing on this host can receive the redirect (e.g. a VPS reached
1221
+ # over SSH), so print the URL for a browser elsewhere and take the
1222
+ # redirect back by hand. The loopback redirect_uri is still what gets
1223
+ # registered and sent, so the remote browser's final URL carries
1224
+ # code+state even though no listener exists on that port. (Issue #71.)
1225
+ async def redirect_handler(auth_url: str) -> None:
1226
+ print(
1227
+ "Open this URL in a browser on any machine and authorize:",
1228
+ file=sys.stderr,
1229
+ )
1230
+ print(f"\n{auth_url}\n", file=sys.stderr)
1231
+ print(
1232
+ "The page you land on will fail to load -- that is expected, "
1233
+ "nothing is listening there. Only its URL matters.",
1234
+ file=sys.stderr,
1235
+ )
1236
+
1237
+ async def callback_handler():
1238
+ code, state = await anyio.to_thread.run_sync(_prompt_oauth_callback)
1239
+ return _authorization_code_result(code, state)
1240
+ else:
1241
+ # Reset callback handler state
1242
+ _CallbackHandler.auth_code = None
1243
+ _CallbackHandler.state = None
1244
+ _CallbackHandler.error = None
1245
+ _CallbackHandler.done = threading.Event()
997
1246
 
998
- if callback_host == "::1":
999
- import socket as _socket
1247
+ if callback_host == "::1":
1248
+ import socket as _socket
1000
1249
 
1001
- class _IPv6HTTPServer(HTTPServer):
1002
- address_family = _socket.AF_INET6
1250
+ class _IPv6HTTPServer(HTTPServer):
1251
+ address_family = _socket.AF_INET6
1003
1252
 
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):
1253
+ server = _IPv6HTTPServer((callback_host, port), _CallbackHandler)
1254
+ else:
1255
+ server = HTTPServer((callback_host, port), _CallbackHandler)
1256
+
1257
+ async def redirect_handler(auth_url: str) -> None:
1258
+ print("Opening browser for authorization...", file=sys.stderr)
1259
+ print(f"If browser doesn't open, visit: {auth_url}", file=sys.stderr)
1260
+ webbrowser.open(auth_url)
1261
+
1262
+ async def callback_handler():
1263
+ # Run the HTTP server in a thread, wait for the callback
1264
+ thread = threading.Thread(target=server.handle_request, daemon=True)
1265
+ thread.start()
1266
+ # Wait with timeout
1267
+ if not _CallbackHandler.done.wait(timeout=300):
1268
+ server.server_close()
1269
+ raise TimeoutError("OAuth callback timed out after 5 minutes")
1019
1270
  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)
1271
+ if _CallbackHandler.error:
1272
+ raise RuntimeError(f"OAuth error: {_CallbackHandler.error}")
1273
+ if not _CallbackHandler.auth_code:
1274
+ raise RuntimeError("No authorization code received")
1275
+ return _authorization_code_result(
1276
+ _CallbackHandler.auth_code, _CallbackHandler.state
1277
+ )
1027
1278
 
1028
1279
  return _RobustOAuthClientProvider(
1029
1280
  server_url=server_url,
@@ -1925,6 +2176,8 @@ def _baked_to_argv(config: dict) -> list[str]:
1925
2176
  argv += ["--oauth-redirect-uri", config["oauth_redirect_uri"]]
1926
2177
  if config.get("oauth_flow") and config["oauth_flow"] != "auto":
1927
2178
  argv += ["--oauth-flow", config["oauth_flow"]]
2179
+ if config.get("oauth_manual_callback"):
2180
+ argv.append("--oauth-manual-callback")
1928
2181
  return argv
1929
2182
 
1930
2183
 
@@ -1982,6 +2235,7 @@ def _bake_create(argv: list[str]) -> None:
1982
2235
  p.add_argument("--oauth-client-name", default="mcp2cli")
1983
2236
  p.add_argument("--oauth-scope", default=None)
1984
2237
  p.add_argument("--oauth-redirect-uri", default=None, metavar="URI")
2238
+ p.add_argument("--oauth-manual-callback", action="store_true")
1985
2239
  p.add_argument(
1986
2240
  "--oauth-flow",
1987
2241
  choices=["auto", "authorization_code", "client_credentials"],
@@ -2049,6 +2303,7 @@ def _bake_create(argv: list[str]) -> None:
2049
2303
  "oauth_scope": args.oauth_scope,
2050
2304
  "oauth_redirect_uri": args.oauth_redirect_uri,
2051
2305
  "oauth_flow": args.oauth_flow,
2306
+ "oauth_manual_callback": args.oauth_manual_callback,
2052
2307
  "include": [x.strip() for x in args.include.split(",") if x.strip()],
2053
2308
  "exclude": [x.strip() for x in args.exclude.split(",") if x.strip()],
2054
2309
  "methods": [x.strip().upper() for x in args.methods.split(",") if x.strip()],
@@ -2093,7 +2348,7 @@ def _bake_show(argv: list[str]) -> None:
2093
2348
  else:
2094
2349
  masked.append([name, val[:4] + "****" if len(val) > 4 else "****"])
2095
2350
  display["auth_headers"] = masked
2096
- print(json.dumps(display, indent=2))
2351
+ print(json.dumps(display, indent=2, ensure_ascii=False))
2097
2352
 
2098
2353
 
2099
2354
  def _bake_remove(argv: list[str]) -> None:
@@ -2187,6 +2442,8 @@ def _run_baked(name: str, argv: list[str]) -> None:
2187
2442
  include=cfg.get("include", []),
2188
2443
  exclude=cfg.get("exclude", []),
2189
2444
  methods=cfg.get("methods", []),
2445
+ prog=name,
2446
+ description=cfg.get("description"),
2190
2447
  )
2191
2448
  _main_impl(synthetic_argv, bake_config=bake_config)
2192
2449
 
@@ -2197,11 +2454,15 @@ def _run_baked(name: str, argv: list[str]) -> None:
2197
2454
 
2198
2455
 
2199
2456
  def build_argparse(
2200
- commands: list[CommandDef], pre_parser: argparse.ArgumentParser
2457
+ commands: list[CommandDef],
2458
+ pre_parser: argparse.ArgumentParser,
2459
+ *,
2460
+ prog: str = "mcp2cli",
2461
+ description: str = _DEFAULT_PARSER_DESCRIPTION,
2201
2462
  ) -> argparse.ArgumentParser:
2202
2463
  parser = argparse.ArgumentParser(
2203
- prog="mcp2cli",
2204
- description="Turn any MCP server or OpenAPI spec into a CLI",
2464
+ prog=prog,
2465
+ description=description,
2205
2466
  parents=[pre_parser],
2206
2467
  )
2207
2468
  subparsers = parser.add_subparsers(dest="_command")
@@ -2552,11 +2813,9 @@ def run_mcp_http(
2552
2813
  headers = dict(auth_headers) if auth_headers else None
2553
2814
 
2554
2815
  async def _with_streamable():
2555
- from mcp.client.streamable_http import streamablehttp_client
2556
-
2557
- async with streamablehttp_client(
2816
+ async with _streamable_streams(
2558
2817
  url, headers=headers, auth=oauth_provider
2559
- ) as (read, write, _):
2818
+ ) as (read, write):
2560
2819
  async with ClientSession(read, write) as session:
2561
2820
  await session.initialize()
2562
2821
  return await _mcp_session(
@@ -2729,14 +2988,14 @@ async def _mcp_session(
2729
2988
  )
2730
2989
 
2731
2990
  if list_mode:
2732
- result = await session.list_tools()
2991
+ all_tools = await _list_all_tools(session)
2733
2992
  tools = [
2734
2993
  {
2735
2994
  "name": t.name,
2736
2995
  "description": t.description or "",
2737
- "inputSchema": t.inputSchema or {},
2996
+ "inputSchema": _mcp_attr(t, "inputSchema") or {},
2738
2997
  }
2739
- for t in result.tools
2998
+ for t in all_tools
2740
2999
  ]
2741
3000
  commands = extract_mcp_commands(tools)
2742
3001
  if search_pattern:
@@ -2766,8 +3025,9 @@ async def _mcp_session(
2766
3025
 
2767
3026
  if json_output:
2768
3027
  # Emit the full MCP CallToolResult envelope (content, structuredContent,
2769
- # isError) using the SDK's own serializer — 100% MCP-compatible.
2770
- output_result(result.model_dump(mode="json"), pretty=pretty, head=head, json_output=True)
3028
+ # isError) with the camelCase wire names, so the envelope does not
3029
+ # change shape with the installed SDK major.
3030
+ output_result(_mcp_dump(result), pretty=pretty, head=head, json_output=True)
2771
3031
  return
2772
3032
 
2773
3033
  text = _extract_content_parts(result.content)
@@ -2797,7 +3057,7 @@ async def _handle_resources(
2797
3057
  "name": r.name,
2798
3058
  "uri": str(r.uri),
2799
3059
  "description": r.description or "",
2800
- "mimeType": r.mimeType or "",
3060
+ "mimeType": _mcp_attr(r, "mimeType") or "",
2801
3061
  }
2802
3062
  for r in result.resources
2803
3063
  ]
@@ -2807,17 +3067,15 @@ async def _handle_resources(
2807
3067
  data = [
2808
3068
  {
2809
3069
  "name": t.name,
2810
- "uriTemplate": str(t.uriTemplate),
3070
+ "uriTemplate": str(_mcp_attr(t, "uriTemplate")),
2811
3071
  "description": t.description or "",
2812
- "mimeType": t.mimeType or "",
3072
+ "mimeType": _mcp_attr(t, "mimeType") or "",
2813
3073
  }
2814
- for t in result.resourceTemplates
3074
+ for t in _mcp_attr(result, "resourceTemplates")
2815
3075
  ]
2816
3076
  output_result(data, **_out)
2817
3077
  elif action == "read":
2818
- from pydantic import AnyUrl
2819
-
2820
- result = await session.read_resource(AnyUrl(uri))
3078
+ result = await session.read_resource(_resource_uri(uri))
2821
3079
  parts = []
2822
3080
  for content in result.contents:
2823
3081
  if hasattr(content, "text"):
@@ -2872,7 +3130,7 @@ async def _handle_prompts(
2872
3130
  messages.append({"role": msg.role, "content": content.text})
2873
3131
  else:
2874
3132
  messages.append(
2875
- {"role": msg.role, "content": json.dumps(content.model_dump())}
3133
+ {"role": msg.role, "content": json.dumps(_mcp_dump(content))}
2876
3134
  )
2877
3135
  data = {"description": result.description or "", "messages": messages}
2878
3136
  output_result(data, **_out)
@@ -3032,11 +3290,31 @@ def _extract_content_parts(content_list, *, attrs=("text", "data")) -> str:
3032
3290
  return "\n".join(parts) if parts else ""
3033
3291
 
3034
3292
 
3293
+ async def _list_all_tools(session):
3294
+ """Fetch every tool from an MCP session, following `nextCursor` until
3295
+ exhausted. Per the MCP spec, tools/list is paginated and page size is
3296
+ entirely up to the server, so a single call is not guaranteed to return
3297
+ the full tool set: https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/pagination
3298
+ """
3299
+ tools = []
3300
+ cursor = None
3301
+ while True:
3302
+ result = await _list_tools_page(session, cursor)
3303
+ tools.extend(result.tools)
3304
+ cursor = _mcp_attr(result, "nextCursor")
3305
+ if not cursor:
3306
+ return tools
3307
+
3308
+
3035
3309
  async def _dispatch_list_tools(session, params):
3036
- result = await session.list_tools()
3310
+ tools = await _list_all_tools(session)
3037
3311
  return [
3038
- {"name": t.name, "description": t.description or "", "inputSchema": t.inputSchema or {}}
3039
- for t in result.tools
3312
+ {
3313
+ "name": t.name,
3314
+ "description": t.description or "",
3315
+ "inputSchema": _mcp_attr(t, "inputSchema") or {},
3316
+ }
3317
+ for t in tools
3040
3318
  ]
3041
3319
 
3042
3320
 
@@ -3048,23 +3326,31 @@ async def _dispatch_call_tool(session, params):
3048
3326
  async def _dispatch_list_resources(session, params):
3049
3327
  result = await session.list_resources()
3050
3328
  return [
3051
- {"name": r.name, "uri": str(r.uri), "description": r.description or "", "mimeType": r.mimeType or ""}
3329
+ {
3330
+ "name": r.name,
3331
+ "uri": str(r.uri),
3332
+ "description": r.description or "",
3333
+ "mimeType": _mcp_attr(r, "mimeType") or "",
3334
+ }
3052
3335
  for r in result.resources
3053
3336
  ]
3054
3337
 
3055
3338
 
3056
3339
  async def _dispatch_read_resource(session, params):
3057
- from pydantic import AnyUrl
3058
-
3059
- result = await session.read_resource(AnyUrl(params["uri"]))
3340
+ result = await session.read_resource(_resource_uri(params["uri"]))
3060
3341
  return _extract_content_parts(result.contents, attrs=("text", "blob"))
3061
3342
 
3062
3343
 
3063
3344
  async def _dispatch_list_resource_templates(session, params):
3064
3345
  result = await session.list_resource_templates()
3065
3346
  return [
3066
- {"name": t.name, "uriTemplate": str(t.uriTemplate), "description": t.description or "", "mimeType": t.mimeType or ""}
3067
- for t in result.resourceTemplates
3347
+ {
3348
+ "name": t.name,
3349
+ "uriTemplate": str(_mcp_attr(t, "uriTemplate")),
3350
+ "description": t.description or "",
3351
+ "mimeType": _mcp_attr(t, "mimeType") or "",
3352
+ }
3353
+ for t in _mcp_attr(result, "resourceTemplates")
3068
3354
  ]
3069
3355
 
3070
3356
 
@@ -3091,7 +3377,9 @@ async def _dispatch_get_prompt(session, params):
3091
3377
  if hasattr(content, "text"):
3092
3378
  messages.append({"role": msg.role, "content": content.text})
3093
3379
  else:
3094
- messages.append({"role": msg.role, "content": json.dumps(content.model_dump())})
3380
+ messages.append(
3381
+ {"role": msg.role, "content": json.dumps(_mcp_dump(content))}
3382
+ )
3095
3383
  return {"description": result.description or "", "messages": messages}
3096
3384
 
3097
3385
 
@@ -3241,12 +3529,9 @@ def _run_session_daemon(config_json: str):
3241
3529
  headers = dict(auth_headers) if auth_headers else None
3242
3530
 
3243
3531
  async def _via_streamable():
3244
- from mcp.client.streamable_http import streamablehttp_client
3245
-
3246
- async with streamablehttp_client(source, headers=headers) as (
3532
+ async with _streamable_streams(source, headers=headers) as (
3247
3533
  read,
3248
3534
  write,
3249
- _,
3250
3535
  ):
3251
3536
  async with ClientSession(read, write) as session:
3252
3537
  await _run_with_session(session)
@@ -3477,7 +3762,8 @@ def handle_mcp(
3477
3762
  return
3478
3763
 
3479
3764
  pre = argparse.ArgumentParser(add_help=False)
3480
- parser = build_argparse(commands, pre)
3765
+ prog, description = _parser_branding(bake_config)
3766
+ parser = build_argparse(commands, pre, prog=prog, description=description)
3481
3767
  args = parser.parse_args(remaining)
3482
3768
 
3483
3769
  if not hasattr(args, "_cmd"):
@@ -3517,14 +3803,14 @@ def _fetch_mcp_tools(
3517
3803
  tools_result: list[dict] = []
3518
3804
 
3519
3805
  async def _extract_tools(session):
3520
- result = await session.list_tools()
3806
+ all_tools = await _list_all_tools(session)
3521
3807
  tools_result.extend(
3522
3808
  {
3523
3809
  "name": t.name,
3524
3810
  "description": t.description or "",
3525
- "inputSchema": t.inputSchema or {},
3811
+ "inputSchema": _mcp_attr(t, "inputSchema") or {},
3526
3812
  }
3527
- for t in result.tools
3813
+ for t in all_tools
3528
3814
  )
3529
3815
 
3530
3816
  async def _run():
@@ -3547,11 +3833,9 @@ def _fetch_mcp_tools(
3547
3833
  headers = dict(auth_headers) if auth_headers else None
3548
3834
 
3549
3835
  async def _via_streamable():
3550
- from mcp.client.streamable_http import streamablehttp_client
3551
-
3552
- async with streamablehttp_client(
3836
+ async with _streamable_streams(
3553
3837
  source, headers=headers, auth=oauth_provider
3554
- ) as (read, write, _):
3838
+ ) as (read, write):
3555
3839
  async with ClientSession(read, write) as session:
3556
3840
  await session.initialize()
3557
3841
  await _extract_tools(session)
@@ -3630,6 +3914,7 @@ def _split_at_subcommand(
3630
3914
 
3631
3915
 
3632
3916
  def main():
3917
+ _ensure_utf8_output()
3633
3918
  if len(sys.argv) > 1:
3634
3919
  first = sys.argv[1]
3635
3920
  if first == "bake":
@@ -3805,6 +4090,13 @@ def _build_main_parser() -> argparse.ArgumentParser:
3805
4090
  "client secret (required for confidential-client servers like Slack)."
3806
4091
  ),
3807
4092
  )
4093
+ pre.add_argument(
4094
+ "--oauth-manual-callback",
4095
+ action="store_true",
4096
+ help="Don't run a local callback server; print the authorization URL and read "
4097
+ "the redirect URL back from stdin. For hosts with no reachable browser, "
4098
+ "e.g. a VPS over SSH.",
4099
+ )
3808
4100
  # Resource flags
3809
4101
  pre.add_argument(
3810
4102
  "--list-resources", action="store_true", help="List available resources"
@@ -3934,6 +4226,7 @@ def _setup_oauth(pre_args):
3934
4226
  scope=pre_args.oauth_scope,
3935
4227
  redirect_uri=pre_args.oauth_redirect_uri,
3936
4228
  flow=flow,
4229
+ manual_callback=getattr(pre_args, "oauth_manual_callback", False),
3937
4230
  )
3938
4231
 
3939
4232
 
@@ -4195,7 +4488,8 @@ def _handle_openapi_mode(
4195
4488
  )
4196
4489
  sys.exit(1)
4197
4490
 
4198
- parser = build_argparse(commands, pre)
4491
+ prog, description = _parser_branding(bake_config)
4492
+ parser = build_argparse(commands, pre, prog=prog, description=description)
4199
4493
  args = parser.parse_args(remaining)
4200
4494
 
4201
4495
  if not hasattr(args, "_cmd"):
File without changes
File without changes