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.
- {mcp2cli-3.3.1 → mcp2cli-3.5.0}/PKG-INFO +35 -3
- {mcp2cli-3.3.1 → mcp2cli-3.5.0}/README.md +33 -1
- mcp2cli-3.5.0/pyproject.toml +34 -0
- mcp2cli-3.3.1/pyproject.toml → mcp2cli-3.5.0/pyproject.toml.orig +4 -2
- {mcp2cli-3.3.1 → mcp2cli-3.5.0}/src/mcp2cli/__init__.py +374 -80
- {mcp2cli-3.3.1 → mcp2cli-3.5.0}/src/mcp2cli/__main__.py +0 -0
- {mcp2cli-3.3.1 → mcp2cli-3.5.0}/src/mcp2cli/py.typed +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp2cli
|
|
3
|
-
Version: 3.
|
|
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.
|
|
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
|
|
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
|
|
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
|
+
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1247
|
+
if callback_host == "::1":
|
|
1248
|
+
import socket as _socket
|
|
1000
1249
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1250
|
+
class _IPv6HTTPServer(HTTPServer):
|
|
1251
|
+
address_family = _socket.AF_INET6
|
|
1003
1252
|
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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],
|
|
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=
|
|
2204
|
-
description=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2996
|
+
"inputSchema": _mcp_attr(t, "inputSchema") or {},
|
|
2738
2997
|
}
|
|
2739
|
-
for t in
|
|
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)
|
|
2770
|
-
|
|
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
|
|
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
|
|
3070
|
+
"uriTemplate": str(_mcp_attr(t, "uriTemplate")),
|
|
2811
3071
|
"description": t.description or "",
|
|
2812
|
-
"mimeType": t
|
|
3072
|
+
"mimeType": _mcp_attr(t, "mimeType") or "",
|
|
2813
3073
|
}
|
|
2814
|
-
for t in result
|
|
3074
|
+
for t in _mcp_attr(result, "resourceTemplates")
|
|
2815
3075
|
]
|
|
2816
3076
|
output_result(data, **_out)
|
|
2817
3077
|
elif action == "read":
|
|
2818
|
-
|
|
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
|
|
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
|
-
|
|
3310
|
+
tools = await _list_all_tools(session)
|
|
3037
3311
|
return [
|
|
3038
|
-
{
|
|
3039
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
{
|
|
3067
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3811
|
+
"inputSchema": _mcp_attr(t, "inputSchema") or {},
|
|
3526
3812
|
}
|
|
3527
|
-
for t in
|
|
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
|
-
|
|
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
|
-
|
|
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
|