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.
- mcp2cli-3.3.0/README.md → mcp2cli-3.4.0/PKG-INFO +37 -0
- mcp2cli-3.3.0/PKG-INFO → mcp2cli-3.4.0/README.md +18 -19
- mcp2cli-3.4.0/pyproject.toml +34 -0
- mcp2cli-3.3.0/pyproject.toml → mcp2cli-3.4.0/pyproject.toml.orig +2 -2
- {mcp2cli-3.3.0 → mcp2cli-3.4.0}/src/mcp2cli/__init__.py +261 -59
- {mcp2cli-3.3.0 → mcp2cli-3.4.0}/src/mcp2cli/__main__.py +0 -0
- {mcp2cli-3.3.0 → 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.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
|
+
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
|
|
814
|
-
|
|
815
|
-
the subsequent re-auth
|
|
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.
|
|
836
|
-
#
|
|
837
|
-
#
|
|
838
|
-
#
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
953
|
-
|
|
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
|
-
|
|
956
|
-
|
|
1117
|
+
if callback_host == "::1":
|
|
1118
|
+
import socket as _socket
|
|
957
1119
|
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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],
|
|
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=
|
|
2158
|
-
description=
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|