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.
- mcp2cli-3.3.1/README.md → mcp2cli-3.4.0/PKG-INFO +37 -0
- mcp2cli-3.3.1/PKG-INFO → mcp2cli-3.4.0/README.md +18 -19
- mcp2cli-3.4.0/pyproject.toml +34 -0
- mcp2cli-3.3.1/pyproject.toml → mcp2cli-3.4.0/pyproject.toml.orig +2 -2
- {mcp2cli-3.3.1 → mcp2cli-3.4.0}/src/mcp2cli/__init__.py +203 -47
- {mcp2cli-3.3.1 → mcp2cli-3.4.0}/src/mcp2cli/__main__.py +0 -0
- {mcp2cli-3.3.1 → mcp2cli-3.4.0}/src/mcp2cli/py.typed +0 -0
|
@@ -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
|
+
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
999
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1117
|
+
if callback_host == "::1":
|
|
1118
|
+
import socket as _socket
|
|
1003
1119
|
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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],
|
|
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=
|
|
2204
|
-
description=
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|