mcp2cli 3.4.0__tar.gz → 3.6.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.4.0/README.md → mcp2cli-3.6.0/PKG-INFO +34 -1
- mcp2cli-3.4.0/PKG-INFO → mcp2cli-3.6.0/README.md +15 -20
- {mcp2cli-3.4.0 → mcp2cli-3.6.0}/pyproject.toml +2 -2
- {mcp2cli-3.4.0 → mcp2cli-3.6.0}/pyproject.toml.orig +4 -2
- {mcp2cli-3.4.0 → mcp2cli-3.6.0}/src/mcp2cli/__init__.py +392 -78
- {mcp2cli-3.4.0 → mcp2cli-3.6.0}/src/mcp2cli/__main__.py +0 -0
- {mcp2cli-3.4.0 → mcp2cli-3.6.0}/src/mcp2cli/py.typed +0 -0
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp2cli
|
|
3
|
+
Version: 3.6.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.26,<3
|
|
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>
|
|
@@ -364,13 +383,27 @@ Subcommands and their flags are generated dynamically from the spec or MCP serve
|
|
|
364
383
|
# Install with test + MCP deps
|
|
365
384
|
uv sync --extra test
|
|
366
385
|
|
|
367
|
-
# Run tests
|
|
386
|
+
# Run tests
|
|
368
387
|
uv run pytest tests/ -v
|
|
369
388
|
|
|
370
389
|
# Run just the token savings tests
|
|
371
390
|
uv run pytest tests/test_token_savings.py -v -s
|
|
372
391
|
```
|
|
373
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
|
+
|
|
374
407
|
---
|
|
375
408
|
|
|
376
409
|
## License
|
|
@@ -1,22 +1,3 @@
|
|
|
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
|
-
|
|
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>
|
|
@@ -383,13 +364,27 @@ Subcommands and their flags are generated dynamically from the spec or MCP serve
|
|
|
383
364
|
# Install with test + MCP deps
|
|
384
365
|
uv sync --extra test
|
|
385
366
|
|
|
386
|
-
# Run tests
|
|
367
|
+
# Run tests
|
|
387
368
|
uv run pytest tests/ -v
|
|
388
369
|
|
|
389
370
|
# Run just the token savings tests
|
|
390
371
|
uv run pytest tests/test_token_savings.py -v -s
|
|
391
372
|
```
|
|
392
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
|
+
|
|
393
388
|
---
|
|
394
389
|
|
|
395
390
|
## License
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mcp2cli"
|
|
3
|
-
version = "3.
|
|
3
|
+
version = "3.6.0"
|
|
4
4
|
description = "Turn any MCP server or OpenAPI spec into a CLI"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
requires-python = ">=3.10"
|
|
8
8
|
dependencies = [
|
|
9
9
|
"httpx",
|
|
10
|
-
"mcp>=1.
|
|
10
|
+
"mcp>=1.26,<3",
|
|
11
11
|
"pyyaml",
|
|
12
12
|
]
|
|
13
13
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mcp2cli"
|
|
3
|
-
version = "3.
|
|
3
|
+
version = "3.6.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
|
|
@@ -60,6 +61,7 @@ class ParamDef:
|
|
|
60
61
|
choices: list | None = None
|
|
61
62
|
location: str = "body" # path|query|header|body|tool_input
|
|
62
63
|
schema: dict = field(default_factory=dict)
|
|
64
|
+
cli_name: str | None = None # collision-free argparse flag name
|
|
63
65
|
|
|
64
66
|
|
|
65
67
|
@dataclass
|
|
@@ -177,8 +179,17 @@ def read_stdin_json(context: str):
|
|
|
177
179
|
sys.exit(1)
|
|
178
180
|
|
|
179
181
|
|
|
182
|
+
def _normalize_schema_type(t):
|
|
183
|
+
"""JSON Schema allows "type": ["integer", "null"] (array form). Reduce it
|
|
184
|
+
to the single concrete type, dropping "null"; anything else passes through."""
|
|
185
|
+
if isinstance(t, list):
|
|
186
|
+
concrete = [x for x in t if x != "null"]
|
|
187
|
+
return concrete[0] if len(concrete) == 1 else None
|
|
188
|
+
return t
|
|
189
|
+
|
|
190
|
+
|
|
180
191
|
def schema_type_to_python(schema: dict) -> tuple[type | None, str]:
|
|
181
|
-
t = schema.get("type")
|
|
192
|
+
t = _normalize_schema_type(schema.get("type"))
|
|
182
193
|
if t == "integer":
|
|
183
194
|
return int, ""
|
|
184
195
|
if t == "number":
|
|
@@ -189,6 +200,16 @@ def schema_type_to_python(schema: dict) -> tuple[type | None, str]:
|
|
|
189
200
|
return str, " (JSON array)"
|
|
190
201
|
if t == "object":
|
|
191
202
|
return str, " (JSON object)"
|
|
203
|
+
if t is None:
|
|
204
|
+
# No "type" but an enum: infer the argparse type from the values, so
|
|
205
|
+
# numeric enums stay callable (otherwise argparse parses the flag as a
|
|
206
|
+
# string and rejects it against numeric choices).
|
|
207
|
+
enum = schema.get("enum")
|
|
208
|
+
if enum and not any(isinstance(v, bool) for v in enum):
|
|
209
|
+
if all(isinstance(v, int) for v in enum):
|
|
210
|
+
return int, ""
|
|
211
|
+
if all(isinstance(v, (int, float)) for v in enum):
|
|
212
|
+
return float, ""
|
|
192
213
|
return str, ""
|
|
193
214
|
|
|
194
215
|
|
|
@@ -206,7 +227,7 @@ def _coerce_item(value: str, item_type: str | None):
|
|
|
206
227
|
def coerce_value(value, schema: dict):
|
|
207
228
|
if value is None:
|
|
208
229
|
return None
|
|
209
|
-
t = schema.get("type")
|
|
230
|
+
t = _normalize_schema_type(schema.get("type"))
|
|
210
231
|
if t == "array":
|
|
211
232
|
if isinstance(value, list):
|
|
212
233
|
return value
|
|
@@ -217,7 +238,7 @@ def coerce_value(value, schema: dict):
|
|
|
217
238
|
return parsed
|
|
218
239
|
except (json.JSONDecodeError, TypeError):
|
|
219
240
|
pass
|
|
220
|
-
item_type = schema.get("items", {}).get("type")
|
|
241
|
+
item_type = _normalize_schema_type(schema.get("items", {}).get("type"))
|
|
221
242
|
if "," in value:
|
|
222
243
|
return [_coerce_item(v.strip(), item_type) for v in value.split(",")]
|
|
223
244
|
return [_coerce_item(value, item_type)]
|
|
@@ -251,13 +272,16 @@ def to_kebab(name: str) -> str:
|
|
|
251
272
|
return s.replace("_", "-").lower()
|
|
252
273
|
|
|
253
274
|
|
|
254
|
-
def _find_toon_cli() -> str | None:
|
|
255
|
-
"""Return
|
|
275
|
+
def _find_toon_cli() -> tuple[str, ...] | None:
|
|
276
|
+
"""Return argv for the TOON CLI, or None if unavailable."""
|
|
256
277
|
if shutil.which("toon"):
|
|
257
|
-
return "toon"
|
|
258
|
-
#
|
|
278
|
+
return ("toon",)
|
|
279
|
+
# npx ships with Node.js, but having it says nothing about the package.
|
|
280
|
+
# `--no` forbids npx's implicit registry download, so a missing package
|
|
281
|
+
# fails in about a second instead of turning --toon into a network fetch
|
|
282
|
+
# racing the timeout in _toon_encode.
|
|
259
283
|
if shutil.which("npx"):
|
|
260
|
-
return "npx @toon-format/cli"
|
|
284
|
+
return ("npx", "--no", "@toon-format/cli")
|
|
261
285
|
return None
|
|
262
286
|
|
|
263
287
|
|
|
@@ -268,7 +292,7 @@ def _toon_encode(json_str: str) -> str | None:
|
|
|
268
292
|
return None
|
|
269
293
|
try:
|
|
270
294
|
result = subprocess.run(
|
|
271
|
-
cmd
|
|
295
|
+
cmd,
|
|
272
296
|
input=json_str,
|
|
273
297
|
capture_output=True,
|
|
274
298
|
text=True,
|
|
@@ -281,6 +305,125 @@ def _toon_encode(json_str: str) -> str | None:
|
|
|
281
305
|
return None
|
|
282
306
|
|
|
283
307
|
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# MCP SDK compatibility (v1 and v2)
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
# SDK 2.0 renamed model fields from camelCase to snake_case, keeping camelCase
|
|
313
|
+
# only as serialization aliases -- so attribute access has to use the new name
|
|
314
|
+
# while the wire format is unchanged. A name missing from this map is an
|
|
315
|
+
# unhandled rename and raises KeyError rather than silently reading None.
|
|
316
|
+
_MCP_RENAMED_FIELDS = {
|
|
317
|
+
"inputSchema": "input_schema",
|
|
318
|
+
"outputSchema": "output_schema",
|
|
319
|
+
"nextCursor": "next_cursor",
|
|
320
|
+
"resourceTemplates": "resource_templates",
|
|
321
|
+
"uriTemplate": "uri_template",
|
|
322
|
+
"mimeType": "mime_type",
|
|
323
|
+
"structuredContent": "structured_content",
|
|
324
|
+
"isError": "is_error",
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _mcp_attr(obj, name: str):
|
|
329
|
+
"""Read an SDK model field across the v1/v2 camelCase -> snake_case rename."""
|
|
330
|
+
try:
|
|
331
|
+
return getattr(obj, name)
|
|
332
|
+
except AttributeError:
|
|
333
|
+
return getattr(obj, _MCP_RENAMED_FIELDS[name])
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _demote_meta_alias(node: dict) -> None:
|
|
337
|
+
"""Emit the SDK's ``_meta`` alias as ``meta``, the spelling mcp2cli ships."""
|
|
338
|
+
if "_meta" in node:
|
|
339
|
+
node["meta"] = node.pop("_meta")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _mcp_dump(model) -> dict:
|
|
343
|
+
"""Serialize an SDK model using the camelCase wire names on either major.
|
|
344
|
+
|
|
345
|
+
v2 renamed model attributes to snake_case, so a plain ``model_dump()``
|
|
346
|
+
would silently change mcp2cli's ``--json`` envelope from ``isError`` to
|
|
347
|
+
``is_error`` depending on which SDK happened to be installed. ``by_alias``
|
|
348
|
+
pins the wire spelling on both.
|
|
349
|
+
|
|
350
|
+
``meta`` is aliased to ``_meta`` in both majors, and mcp2cli has always
|
|
351
|
+
emitted it as ``meta``, so it is mapped back -- but only on the envelope
|
|
352
|
+
and its content items, which the SDK owns. ``structuredContent`` is the
|
|
353
|
+
tool's own payload and is never rewritten, so a tool that legitimately
|
|
354
|
+
returns a ``_meta`` key keeps it.
|
|
355
|
+
"""
|
|
356
|
+
data = model.model_dump(mode="json", by_alias=True)
|
|
357
|
+
_demote_meta_alias(data)
|
|
358
|
+
for item in data.get("content") or ():
|
|
359
|
+
if isinstance(item, dict):
|
|
360
|
+
_demote_meta_alias(item)
|
|
361
|
+
return data
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _resource_uri(uri: str):
|
|
365
|
+
"""Coerce a resource URI to what ``resources/read`` expects.
|
|
366
|
+
|
|
367
|
+
v1 types the request param as a pydantic ``AnyUrl``; v2 takes a plain
|
|
368
|
+
string and rejects an ``AnyUrl``.
|
|
369
|
+
"""
|
|
370
|
+
from mcp.types import ReadResourceRequestParams
|
|
371
|
+
|
|
372
|
+
if ReadResourceRequestParams.model_fields["uri"].annotation is str:
|
|
373
|
+
return uri
|
|
374
|
+
from pydantic import AnyUrl
|
|
375
|
+
|
|
376
|
+
return AnyUrl(uri)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@asynccontextmanager
|
|
380
|
+
async def _streamable_streams(url: str, headers=None, auth=None):
|
|
381
|
+
"""Open a streamable-http transport, yielding ``(read, write)``.
|
|
382
|
+
|
|
383
|
+
Three things differ across SDK majors here, which is why this is the only
|
|
384
|
+
place that talks to that transport (issues #68, #74):
|
|
385
|
+
|
|
386
|
+
* v2 dropped the ``streamablehttp_client`` alias, keeping only
|
|
387
|
+
``streamable_http_client`` -- the original break.
|
|
388
|
+
* that surviving function takes a pre-built ``http_client`` instead of
|
|
389
|
+
``headers``/``auth``, and v2 is built on **httpx2**, not httpx, so the
|
|
390
|
+
client has to come from the SDK's own factory to be the right flavour.
|
|
391
|
+
* v1 yields a third element (a get-session-id callback) that v2 dropped.
|
|
392
|
+
mcp2cli never used it, so both shapes collapse to ``(read, write)``.
|
|
393
|
+
"""
|
|
394
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
395
|
+
from mcp.shared._httpx_utils import create_mcp_http_client
|
|
396
|
+
|
|
397
|
+
async with create_mcp_http_client(headers=headers, auth=auth) as client:
|
|
398
|
+
async with streamable_http_client(url, http_client=client) as streams:
|
|
399
|
+
yield streams[0], streams[1]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
async def _list_tools_page(session, cursor: str | None):
|
|
403
|
+
"""Request one page of ``tools/list``.
|
|
404
|
+
|
|
405
|
+
Both majors accept ``params``; v1's ``cursor=`` shorthand is deprecated
|
|
406
|
+
there and gone in v2, so this is the one spelling that works on both.
|
|
407
|
+
"""
|
|
408
|
+
from mcp.types import PaginatedRequestParams
|
|
409
|
+
|
|
410
|
+
params = PaginatedRequestParams(cursor=cursor) if cursor else None
|
|
411
|
+
return await session.list_tools(params=params)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _authorization_code_result(code: str, state: str | None):
|
|
415
|
+
"""Wrap a callback result in whatever ``callback_handler`` must return.
|
|
416
|
+
|
|
417
|
+
v1 expects a plain ``(code, state)`` tuple; v2 expects an
|
|
418
|
+
``AuthorizationCodeResult`` model.
|
|
419
|
+
"""
|
|
420
|
+
try:
|
|
421
|
+
from mcp.shared.auth import AuthorizationCodeResult
|
|
422
|
+
except ImportError:
|
|
423
|
+
return (code, state)
|
|
424
|
+
return AuthorizationCodeResult(code=code, state=state)
|
|
425
|
+
|
|
426
|
+
|
|
284
427
|
def _ensure_utf8_output() -> None:
|
|
285
428
|
"""Make non-ASCII output safe on consoles that cannot encode it.
|
|
286
429
|
|
|
@@ -379,9 +522,34 @@ def _python_type_name(t: type | None) -> str:
|
|
|
379
522
|
return getattr(t, "__name__", str(t))
|
|
380
523
|
|
|
381
524
|
|
|
525
|
+
def _param_dest(p: "ParamDef") -> str:
|
|
526
|
+
"""Return the argparse destination allocated for a parameter."""
|
|
527
|
+
return (p.cli_name or p.name).replace("-", "_")
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _allocate_param_cli_names(cmd: "CommandDef") -> None:
|
|
531
|
+
"""Assign unique parameter flags without shadowing built-in options."""
|
|
532
|
+
natural_names = {p.name for p in cmd.params}
|
|
533
|
+
used = {"help"}
|
|
534
|
+
if cmd.has_body:
|
|
535
|
+
used.add("stdin")
|
|
536
|
+
|
|
537
|
+
for p in cmd.params:
|
|
538
|
+
name = p.name
|
|
539
|
+
if name in used:
|
|
540
|
+
stem = f"arg-{name}"
|
|
541
|
+
name = stem
|
|
542
|
+
suffix = 2
|
|
543
|
+
while name in used or name in natural_names:
|
|
544
|
+
name = f"{stem}-{suffix}"
|
|
545
|
+
suffix += 1
|
|
546
|
+
p.cli_name = name
|
|
547
|
+
used.add(name)
|
|
548
|
+
|
|
549
|
+
|
|
382
550
|
def _param_to_dict(p: "ParamDef") -> dict:
|
|
383
551
|
d = {
|
|
384
|
-
"name": p.name,
|
|
552
|
+
"name": p.cli_name or p.name,
|
|
385
553
|
"type": _python_type_name(p.python_type),
|
|
386
554
|
"required": p.required,
|
|
387
555
|
"description": p.description,
|
|
@@ -877,12 +1045,21 @@ def build_oauth_provider(
|
|
|
877
1045
|
await super()._initialize()
|
|
878
1046
|
_restore_token_expiry_from_sidecar(self.context)
|
|
879
1047
|
|
|
1048
|
+
# v2 renamed the `scopes` argument to `scope`.
|
|
1049
|
+
import inspect
|
|
1050
|
+
|
|
1051
|
+
scope_kwarg = (
|
|
1052
|
+
"scope"
|
|
1053
|
+
if "scope"
|
|
1054
|
+
in inspect.signature(ClientCredentialsOAuthProvider.__init__).parameters
|
|
1055
|
+
else "scopes"
|
|
1056
|
+
)
|
|
880
1057
|
return _RobustClientCredentialsProvider(
|
|
881
1058
|
server_url=server_url,
|
|
882
1059
|
storage=storage,
|
|
883
1060
|
client_id=client_id,
|
|
884
1061
|
client_secret=client_secret,
|
|
885
|
-
|
|
1062
|
+
**{scope_kwarg: scope},
|
|
886
1063
|
)
|
|
887
1064
|
|
|
888
1065
|
from mcp.client.auth.oauth2 import OAuthClientProvider
|
|
@@ -1105,8 +1282,9 @@ def build_oauth_provider(
|
|
|
1105
1282
|
file=sys.stderr,
|
|
1106
1283
|
)
|
|
1107
1284
|
|
|
1108
|
-
async def callback_handler()
|
|
1109
|
-
|
|
1285
|
+
async def callback_handler():
|
|
1286
|
+
code, state = await anyio.to_thread.run_sync(_prompt_oauth_callback)
|
|
1287
|
+
return _authorization_code_result(code, state)
|
|
1110
1288
|
else:
|
|
1111
1289
|
# Reset callback handler state
|
|
1112
1290
|
_CallbackHandler.auth_code = None
|
|
@@ -1129,7 +1307,7 @@ def build_oauth_provider(
|
|
|
1129
1307
|
print(f"If browser doesn't open, visit: {auth_url}", file=sys.stderr)
|
|
1130
1308
|
webbrowser.open(auth_url)
|
|
1131
1309
|
|
|
1132
|
-
async def callback_handler()
|
|
1310
|
+
async def callback_handler():
|
|
1133
1311
|
# Run the HTTP server in a thread, wait for the callback
|
|
1134
1312
|
thread = threading.Thread(target=server.handle_request, daemon=True)
|
|
1135
1313
|
thread.start()
|
|
@@ -1142,7 +1320,9 @@ def build_oauth_provider(
|
|
|
1142
1320
|
raise RuntimeError(f"OAuth error: {_CallbackHandler.error}")
|
|
1143
1321
|
if not _CallbackHandler.auth_code:
|
|
1144
1322
|
raise RuntimeError("No authorization code received")
|
|
1145
|
-
return (
|
|
1323
|
+
return _authorization_code_result(
|
|
1324
|
+
_CallbackHandler.auth_code, _CallbackHandler.state
|
|
1325
|
+
)
|
|
1146
1326
|
|
|
1147
1327
|
return _RobustOAuthClientProvider(
|
|
1148
1328
|
server_url=server_url,
|
|
@@ -1362,8 +1542,34 @@ def extract_openapi_commands(spec: dict) -> list[CommandDef]:
|
|
|
1362
1542
|
|
|
1363
1543
|
def extract_mcp_commands(tools: list[dict]) -> list[CommandDef]:
|
|
1364
1544
|
commands: list[CommandDef] = []
|
|
1365
|
-
for tool in tools
|
|
1366
|
-
|
|
1545
|
+
base_names = [to_kebab(tool.get("name", "unknown")) for tool in tools]
|
|
1546
|
+
reserved_names = set(base_names)
|
|
1547
|
+
cli_names = [""] * len(tools)
|
|
1548
|
+
used_names: set[str] = set()
|
|
1549
|
+
groups: dict[str, list[int]] = {}
|
|
1550
|
+
for index, base_name in enumerate(base_names):
|
|
1551
|
+
groups.setdefault(base_name, []).append(index)
|
|
1552
|
+
|
|
1553
|
+
# Assign aliases from the complete tool-name set so a generated suffix can
|
|
1554
|
+
# never shadow another tool's natural name. Sorting each collision group by
|
|
1555
|
+
# wire name keeps aliases stable when a server reorders tools/list.
|
|
1556
|
+
for base_name in sorted(groups):
|
|
1557
|
+
indices = sorted(
|
|
1558
|
+
groups[base_name],
|
|
1559
|
+
key=lambda index: (tools[index].get("name", "unknown"), index),
|
|
1560
|
+
)
|
|
1561
|
+
for rank, index in enumerate(indices):
|
|
1562
|
+
name = base_name
|
|
1563
|
+
if rank:
|
|
1564
|
+
suffix = 2
|
|
1565
|
+
name = f"{base_name}-{suffix}"
|
|
1566
|
+
while name in reserved_names or name in used_names:
|
|
1567
|
+
suffix += 1
|
|
1568
|
+
name = f"{base_name}-{suffix}"
|
|
1569
|
+
cli_names[index] = name
|
|
1570
|
+
used_names.add(name)
|
|
1571
|
+
|
|
1572
|
+
for tool, name in zip(tools, cli_names):
|
|
1367
1573
|
desc = tool.get("description", "")
|
|
1368
1574
|
schema = tool.get("inputSchema", {})
|
|
1369
1575
|
required_fields = set(schema.get("required", []))
|
|
@@ -1802,12 +2008,12 @@ def _build_graphql_document(
|
|
|
1802
2008
|
types_by_name = {t["name"]: t for t in schema.get("types", []) if t.get("name")}
|
|
1803
2009
|
|
|
1804
2010
|
# Build variables dict from args
|
|
1805
|
-
if getattr(args, "stdin", False):
|
|
2011
|
+
if getattr(args, "stdin", False) is True:
|
|
1806
2012
|
variables = read_stdin_json("GraphQL variables")
|
|
1807
2013
|
else:
|
|
1808
2014
|
variables = {}
|
|
1809
2015
|
for p in cmd.params:
|
|
1810
|
-
val = getattr(args, p
|
|
2016
|
+
val = getattr(args, _param_dest(p), None)
|
|
1811
2017
|
if val is not None:
|
|
1812
2018
|
variables[p.original_name] = coerce_value(val, p.schema)
|
|
1813
2019
|
|
|
@@ -2341,6 +2547,7 @@ def build_argparse(
|
|
|
2341
2547
|
help=escape_argparse_help(cmd.description),
|
|
2342
2548
|
description=escape_argparse_help(cmd.description),
|
|
2343
2549
|
)
|
|
2550
|
+
_allocate_param_cli_names(cmd)
|
|
2344
2551
|
sub.set_defaults(_cmd=cmd)
|
|
2345
2552
|
|
|
2346
2553
|
if cmd.has_body:
|
|
@@ -2351,12 +2558,8 @@ def build_argparse(
|
|
|
2351
2558
|
help="Read JSON body/arguments from stdin",
|
|
2352
2559
|
)
|
|
2353
2560
|
|
|
2354
|
-
seen_flags: set[str] = set()
|
|
2355
2561
|
for p in cmd.params:
|
|
2356
|
-
flag = f"--{p.name}"
|
|
2357
|
-
if flag in seen_flags:
|
|
2358
|
-
continue # skip duplicate param names (e.g. path + body both have same name)
|
|
2359
|
-
seen_flags.add(flag)
|
|
2562
|
+
flag = f"--{p.cli_name or p.name}"
|
|
2360
2563
|
kwargs: dict = {}
|
|
2361
2564
|
if p.python_type is not None:
|
|
2362
2565
|
kwargs["type"] = p.python_type
|
|
@@ -2374,6 +2577,7 @@ def build_argparse(
|
|
|
2374
2577
|
kwargs["help"] = escape_argparse_help(p.description)
|
|
2375
2578
|
if p.choices:
|
|
2376
2579
|
kwargs["choices"] = p.choices
|
|
2580
|
+
kwargs["dest"] = _param_dest(p)
|
|
2377
2581
|
sub.add_argument(flag, **kwargs)
|
|
2378
2582
|
|
|
2379
2583
|
return parser
|
|
@@ -2502,13 +2706,13 @@ def _collect_openapi_params(
|
|
|
2502
2706
|
|
|
2503
2707
|
for p in cmd.params:
|
|
2504
2708
|
if p.location == "path":
|
|
2505
|
-
val = getattr(args, p
|
|
2709
|
+
val = getattr(args, _param_dest(p), None)
|
|
2506
2710
|
if val is not None:
|
|
2507
2711
|
path = path.replace(f"{{{p.original_name}}}", str(val))
|
|
2508
2712
|
|
|
2509
2713
|
if cmd.method == "get":
|
|
2510
2714
|
for p in cmd.params:
|
|
2511
|
-
val = getattr(args, p
|
|
2715
|
+
val = getattr(args, _param_dest(p), None)
|
|
2512
2716
|
if val is None:
|
|
2513
2717
|
continue
|
|
2514
2718
|
if p.location == "query":
|
|
@@ -2516,12 +2720,12 @@ def _collect_openapi_params(
|
|
|
2516
2720
|
elif p.location == "header":
|
|
2517
2721
|
extra_headers[p.original_name] = str(val)
|
|
2518
2722
|
else:
|
|
2519
|
-
if getattr(args, "stdin", False):
|
|
2723
|
+
if getattr(args, "stdin", False) is True:
|
|
2520
2724
|
body = read_stdin_json("OpenAPI request body")
|
|
2521
2725
|
else:
|
|
2522
2726
|
body = {}
|
|
2523
2727
|
for p in cmd.params:
|
|
2524
|
-
val = getattr(args, p
|
|
2728
|
+
val = getattr(args, _param_dest(p), None)
|
|
2525
2729
|
if p.location == "header":
|
|
2526
2730
|
if val is not None:
|
|
2527
2731
|
extra_headers[p.original_name] = str(val)
|
|
@@ -2546,7 +2750,7 @@ def _collect_openapi_params(
|
|
|
2546
2750
|
# Also collect query params for non-GET
|
|
2547
2751
|
for p in cmd.params:
|
|
2548
2752
|
if p.location == "query":
|
|
2549
|
-
val = getattr(args, p
|
|
2753
|
+
val = getattr(args, _param_dest(p), None)
|
|
2550
2754
|
if val is not None:
|
|
2551
2755
|
query_params[p.original_name] = coerce_value(val, p.schema)
|
|
2552
2756
|
|
|
@@ -2631,6 +2835,55 @@ def execute_openapi(
|
|
|
2631
2835
|
# ---------------------------------------------------------------------------
|
|
2632
2836
|
|
|
2633
2837
|
|
|
2838
|
+
def _exc_leaves(exc: BaseException) -> list[BaseException]:
|
|
2839
|
+
"""Flatten nested exception groups into their leaf exceptions."""
|
|
2840
|
+
nested = getattr(exc, "exceptions", None)
|
|
2841
|
+
if not nested:
|
|
2842
|
+
return [exc]
|
|
2843
|
+
return [leaf for child in nested for leaf in _exc_leaves(child)]
|
|
2844
|
+
|
|
2845
|
+
|
|
2846
|
+
def _exc_message(exc: BaseException) -> str:
|
|
2847
|
+
"""Flatten an exception group into one terminal-safe line."""
|
|
2848
|
+
parts = []
|
|
2849
|
+
for leaf in _exc_leaves(exc):
|
|
2850
|
+
message = str(leaf) or leaf.__class__.__name__
|
|
2851
|
+
parts.append("; ".join(line.strip() for line in message.splitlines() if line.strip()))
|
|
2852
|
+
return "; ".join(part for part in parts if part) or exc.__class__.__name__
|
|
2853
|
+
|
|
2854
|
+
|
|
2855
|
+
def _run_mcp_clean(fn, source: str):
|
|
2856
|
+
"""Run an MCP coroutine, reporting failures as one clean error line.
|
|
2857
|
+
|
|
2858
|
+
Transport failures (bad URL, refused connection, 401/403) otherwise reach
|
|
2859
|
+
the terminal as a multi-level anyio ExceptionGroup traceback with the
|
|
2860
|
+
actual cause buried at the bottom. Set MCP2CLI_DEBUG=1 for the traceback.
|
|
2861
|
+
"""
|
|
2862
|
+
try:
|
|
2863
|
+
return anyio.run(fn)
|
|
2864
|
+
except SystemExit:
|
|
2865
|
+
raise
|
|
2866
|
+
except KeyboardInterrupt: # pragma: no cover - interactive only
|
|
2867
|
+
raise
|
|
2868
|
+
except BaseException as exc:
|
|
2869
|
+
leaves = _exc_leaves(exc)
|
|
2870
|
+
if len(leaves) == 1 and isinstance(leaves[0], (SystemExit, KeyboardInterrupt)):
|
|
2871
|
+
raise leaves[0]
|
|
2872
|
+
if os.environ.get("MCP2CLI_DEBUG"):
|
|
2873
|
+
raise
|
|
2874
|
+
message = _exc_message(exc)
|
|
2875
|
+
lowered = message.lower()
|
|
2876
|
+
if "401" in lowered or "403" in lowered:
|
|
2877
|
+
hint = (
|
|
2878
|
+
" — the server rejected the request; pass credentials with "
|
|
2879
|
+
"--auth-header 'Name:Value' or use the --oauth-* options"
|
|
2880
|
+
)
|
|
2881
|
+
else:
|
|
2882
|
+
hint = ""
|
|
2883
|
+
print(f"Error: cannot use MCP server at {source}: {message}{hint}", file=sys.stderr)
|
|
2884
|
+
sys.exit(1)
|
|
2885
|
+
|
|
2886
|
+
|
|
2634
2887
|
def run_mcp_http(
|
|
2635
2888
|
url: str,
|
|
2636
2889
|
auth_headers: list[tuple[str, str]],
|
|
@@ -2681,11 +2934,9 @@ def run_mcp_http(
|
|
|
2681
2934
|
headers = dict(auth_headers) if auth_headers else None
|
|
2682
2935
|
|
|
2683
2936
|
async def _with_streamable():
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
async with streamablehttp_client(
|
|
2937
|
+
async with _streamable_streams(
|
|
2687
2938
|
url, headers=headers, auth=oauth_provider
|
|
2688
|
-
) as (read, write
|
|
2939
|
+
) as (read, write):
|
|
2689
2940
|
async with ClientSession(read, write) as session:
|
|
2690
2941
|
await session.initialize()
|
|
2691
2942
|
return await _mcp_session(
|
|
@@ -2735,7 +2986,9 @@ def run_mcp_http(
|
|
|
2735
2986
|
except Exception:
|
|
2736
2987
|
return await _with_sse()
|
|
2737
2988
|
|
|
2738
|
-
|
|
2989
|
+
rc = _run_mcp_clean(_run, url)
|
|
2990
|
+
if rc:
|
|
2991
|
+
sys.exit(rc)
|
|
2739
2992
|
|
|
2740
2993
|
|
|
2741
2994
|
def run_mcp_stdio(
|
|
@@ -2793,7 +3046,7 @@ def run_mcp_stdio(
|
|
|
2793
3046
|
async with stdio_client(params) as (read, write):
|
|
2794
3047
|
async with ClientSession(read, write) as session:
|
|
2795
3048
|
await session.initialize()
|
|
2796
|
-
await _mcp_session(
|
|
3049
|
+
return await _mcp_session(
|
|
2797
3050
|
session,
|
|
2798
3051
|
tool_name,
|
|
2799
3052
|
arguments,
|
|
@@ -2807,7 +3060,9 @@ def run_mcp_stdio(
|
|
|
2807
3060
|
**extra,
|
|
2808
3061
|
)
|
|
2809
3062
|
|
|
2810
|
-
|
|
3063
|
+
rc = _run_mcp_clean(_run, command_str)
|
|
3064
|
+
if rc:
|
|
3065
|
+
sys.exit(rc)
|
|
2811
3066
|
|
|
2812
3067
|
|
|
2813
3068
|
async def _mcp_session(
|
|
@@ -2863,7 +3118,7 @@ async def _mcp_session(
|
|
|
2863
3118
|
{
|
|
2864
3119
|
"name": t.name,
|
|
2865
3120
|
"description": t.description or "",
|
|
2866
|
-
"inputSchema": t
|
|
3121
|
+
"inputSchema": _mcp_attr(t, "inputSchema") or {},
|
|
2867
3122
|
}
|
|
2868
3123
|
for t in all_tools
|
|
2869
3124
|
]
|
|
@@ -2895,12 +3150,29 @@ async def _mcp_session(
|
|
|
2895
3150
|
|
|
2896
3151
|
if json_output:
|
|
2897
3152
|
# Emit the full MCP CallToolResult envelope (content, structuredContent,
|
|
2898
|
-
# isError)
|
|
2899
|
-
|
|
2900
|
-
|
|
3153
|
+
# isError) with the camelCase wire names, so the envelope does not
|
|
3154
|
+
# change shape with the installed SDK major.
|
|
3155
|
+
output_result(_mcp_dump(result), pretty=pretty, head=head, json_output=True)
|
|
3156
|
+
# A failed tool still exits non-zero under --json so callers can detect
|
|
3157
|
+
# it; the envelope on stdout already carries isError for machines.
|
|
3158
|
+
return 1 if _mcp_attr(result, "isError") else 0
|
|
2901
3159
|
|
|
2902
3160
|
text = _extract_content_parts(result.content)
|
|
2903
|
-
|
|
3161
|
+
payload = text
|
|
3162
|
+
if not payload:
|
|
3163
|
+
# A tool may return only structuredContent with an empty content list.
|
|
3164
|
+
structured = _mcp_attr(result, "structuredContent")
|
|
3165
|
+
if structured is not None:
|
|
3166
|
+
payload = structured
|
|
3167
|
+
|
|
3168
|
+
if _mcp_attr(result, "isError"):
|
|
3169
|
+
# Return the code instead of raising inside the anyio task group, which
|
|
3170
|
+
# would wrap SystemExit in a BaseExceptionGroup traceback.
|
|
3171
|
+
error = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False)
|
|
3172
|
+
print(f"Error: {error or f'tool {tool_name!r} reported an error'}", file=sys.stderr)
|
|
3173
|
+
return 1
|
|
3174
|
+
output_result(payload, pretty=pretty, raw=raw, toon=toon, head=head)
|
|
3175
|
+
return 0
|
|
2904
3176
|
|
|
2905
3177
|
|
|
2906
3178
|
# ---------------------------------------------------------------------------
|
|
@@ -2926,7 +3198,7 @@ async def _handle_resources(
|
|
|
2926
3198
|
"name": r.name,
|
|
2927
3199
|
"uri": str(r.uri),
|
|
2928
3200
|
"description": r.description or "",
|
|
2929
|
-
"mimeType": r
|
|
3201
|
+
"mimeType": _mcp_attr(r, "mimeType") or "",
|
|
2930
3202
|
}
|
|
2931
3203
|
for r in result.resources
|
|
2932
3204
|
]
|
|
@@ -2936,17 +3208,15 @@ async def _handle_resources(
|
|
|
2936
3208
|
data = [
|
|
2937
3209
|
{
|
|
2938
3210
|
"name": t.name,
|
|
2939
|
-
"uriTemplate": str(t
|
|
3211
|
+
"uriTemplate": str(_mcp_attr(t, "uriTemplate")),
|
|
2940
3212
|
"description": t.description or "",
|
|
2941
|
-
"mimeType": t
|
|
3213
|
+
"mimeType": _mcp_attr(t, "mimeType") or "",
|
|
2942
3214
|
}
|
|
2943
|
-
for t in result
|
|
3215
|
+
for t in _mcp_attr(result, "resourceTemplates")
|
|
2944
3216
|
]
|
|
2945
3217
|
output_result(data, **_out)
|
|
2946
3218
|
elif action == "read":
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
result = await session.read_resource(AnyUrl(uri))
|
|
3219
|
+
result = await session.read_resource(_resource_uri(uri))
|
|
2950
3220
|
parts = []
|
|
2951
3221
|
for content in result.contents:
|
|
2952
3222
|
if hasattr(content, "text"):
|
|
@@ -3001,7 +3271,7 @@ async def _handle_prompts(
|
|
|
3001
3271
|
messages.append({"role": msg.role, "content": content.text})
|
|
3002
3272
|
else:
|
|
3003
3273
|
messages.append(
|
|
3004
|
-
{"role": msg.role, "content": json.dumps(content
|
|
3274
|
+
{"role": msg.role, "content": json.dumps(_mcp_dump(content))}
|
|
3005
3275
|
)
|
|
3006
3276
|
data = {"description": result.description or "", "messages": messages}
|
|
3007
3277
|
output_result(data, **_out)
|
|
@@ -3151,13 +3421,25 @@ def session_start(
|
|
|
3151
3421
|
|
|
3152
3422
|
|
|
3153
3423
|
def _extract_content_parts(content_list, *, attrs=("text", "data")) -> str:
|
|
3154
|
-
"""Extract text/data/blob from MCP content objects, joined by newline.
|
|
3424
|
+
"""Extract text/data/blob from MCP content objects, joined by newline.
|
|
3425
|
+
|
|
3426
|
+
``resource_link`` blocks carry neither ``text`` nor ``data`` — only
|
|
3427
|
+
``uri``/``name`` — so they used to be dropped silently. Render them as
|
|
3428
|
+
``name: uri`` (or just the URI) instead.
|
|
3429
|
+
"""
|
|
3155
3430
|
parts = []
|
|
3156
|
-
for
|
|
3431
|
+
for content in content_list:
|
|
3432
|
+
is_mapping = isinstance(content, dict)
|
|
3157
3433
|
for attr in attrs:
|
|
3158
|
-
if
|
|
3159
|
-
|
|
3434
|
+
value = content.get(attr) if is_mapping else getattr(content, attr, None)
|
|
3435
|
+
if value is not None:
|
|
3436
|
+
parts.append(value)
|
|
3160
3437
|
break
|
|
3438
|
+
else:
|
|
3439
|
+
uri = content.get("uri") if is_mapping else getattr(content, "uri", None)
|
|
3440
|
+
if uri is not None:
|
|
3441
|
+
name = content.get("name") if is_mapping else getattr(content, "name", None)
|
|
3442
|
+
parts.append(f"{name}: {uri}" if name else str(uri))
|
|
3161
3443
|
return "\n".join(parts) if parts else ""
|
|
3162
3444
|
|
|
3163
3445
|
|
|
@@ -3170,9 +3452,9 @@ async def _list_all_tools(session):
|
|
|
3170
3452
|
tools = []
|
|
3171
3453
|
cursor = None
|
|
3172
3454
|
while True:
|
|
3173
|
-
result = await session
|
|
3455
|
+
result = await _list_tools_page(session, cursor)
|
|
3174
3456
|
tools.extend(result.tools)
|
|
3175
|
-
cursor = result
|
|
3457
|
+
cursor = _mcp_attr(result, "nextCursor")
|
|
3176
3458
|
if not cursor:
|
|
3177
3459
|
return tools
|
|
3178
3460
|
|
|
@@ -3180,36 +3462,48 @@ async def _list_all_tools(session):
|
|
|
3180
3462
|
async def _dispatch_list_tools(session, params):
|
|
3181
3463
|
tools = await _list_all_tools(session)
|
|
3182
3464
|
return [
|
|
3183
|
-
{
|
|
3465
|
+
{
|
|
3466
|
+
"name": t.name,
|
|
3467
|
+
"description": t.description or "",
|
|
3468
|
+
"inputSchema": _mcp_attr(t, "inputSchema") or {},
|
|
3469
|
+
}
|
|
3184
3470
|
for t in tools
|
|
3185
3471
|
]
|
|
3186
3472
|
|
|
3187
3473
|
|
|
3188
3474
|
async def _dispatch_call_tool(session, params):
|
|
3189
3475
|
result = await session.call_tool(params["name"], params.get("arguments", {}))
|
|
3190
|
-
return
|
|
3476
|
+
return _mcp_dump(result)
|
|
3191
3477
|
|
|
3192
3478
|
|
|
3193
3479
|
async def _dispatch_list_resources(session, params):
|
|
3194
3480
|
result = await session.list_resources()
|
|
3195
3481
|
return [
|
|
3196
|
-
{
|
|
3482
|
+
{
|
|
3483
|
+
"name": r.name,
|
|
3484
|
+
"uri": str(r.uri),
|
|
3485
|
+
"description": r.description or "",
|
|
3486
|
+
"mimeType": _mcp_attr(r, "mimeType") or "",
|
|
3487
|
+
}
|
|
3197
3488
|
for r in result.resources
|
|
3198
3489
|
]
|
|
3199
3490
|
|
|
3200
3491
|
|
|
3201
3492
|
async def _dispatch_read_resource(session, params):
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
result = await session.read_resource(AnyUrl(params["uri"]))
|
|
3493
|
+
result = await session.read_resource(_resource_uri(params["uri"]))
|
|
3205
3494
|
return _extract_content_parts(result.contents, attrs=("text", "blob"))
|
|
3206
3495
|
|
|
3207
3496
|
|
|
3208
3497
|
async def _dispatch_list_resource_templates(session, params):
|
|
3209
3498
|
result = await session.list_resource_templates()
|
|
3210
3499
|
return [
|
|
3211
|
-
{
|
|
3212
|
-
|
|
3500
|
+
{
|
|
3501
|
+
"name": t.name,
|
|
3502
|
+
"uriTemplate": str(_mcp_attr(t, "uriTemplate")),
|
|
3503
|
+
"description": t.description or "",
|
|
3504
|
+
"mimeType": _mcp_attr(t, "mimeType") or "",
|
|
3505
|
+
}
|
|
3506
|
+
for t in _mcp_attr(result, "resourceTemplates")
|
|
3213
3507
|
]
|
|
3214
3508
|
|
|
3215
3509
|
|
|
@@ -3236,7 +3530,9 @@ async def _dispatch_get_prompt(session, params):
|
|
|
3236
3530
|
if hasattr(content, "text"):
|
|
3237
3531
|
messages.append({"role": msg.role, "content": content.text})
|
|
3238
3532
|
else:
|
|
3239
|
-
messages.append(
|
|
3533
|
+
messages.append(
|
|
3534
|
+
{"role": msg.role, "content": json.dumps(_mcp_dump(content))}
|
|
3535
|
+
)
|
|
3240
3536
|
return {"description": result.description or "", "messages": messages}
|
|
3241
3537
|
|
|
3242
3538
|
|
|
@@ -3386,12 +3682,9 @@ def _run_session_daemon(config_json: str):
|
|
|
3386
3682
|
headers = dict(auth_headers) if auth_headers else None
|
|
3387
3683
|
|
|
3388
3684
|
async def _via_streamable():
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
async with streamablehttp_client(source, headers=headers) as (
|
|
3685
|
+
async with _streamable_streams(source, headers=headers) as (
|
|
3392
3686
|
read,
|
|
3393
3687
|
write,
|
|
3394
|
-
_,
|
|
3395
3688
|
):
|
|
3396
3689
|
async with ClientSession(read, write) as session:
|
|
3397
3690
|
await _run_with_session(session)
|
|
@@ -3632,12 +3925,12 @@ def handle_mcp(
|
|
|
3632
3925
|
|
|
3633
3926
|
cmd: CommandDef = args._cmd
|
|
3634
3927
|
|
|
3635
|
-
if getattr(args, "stdin", False):
|
|
3928
|
+
if getattr(args, "stdin", False) is True:
|
|
3636
3929
|
arguments = read_stdin_json("MCP tool arguments")
|
|
3637
3930
|
else:
|
|
3638
3931
|
arguments = {}
|
|
3639
3932
|
for p in cmd.params:
|
|
3640
|
-
val = getattr(args, p
|
|
3933
|
+
val = getattr(args, _param_dest(p), None)
|
|
3641
3934
|
if val is not None:
|
|
3642
3935
|
arguments[p.original_name] = coerce_value(val, p.schema)
|
|
3643
3936
|
|
|
@@ -3668,7 +3961,7 @@ def _fetch_mcp_tools(
|
|
|
3668
3961
|
{
|
|
3669
3962
|
"name": t.name,
|
|
3670
3963
|
"description": t.description or "",
|
|
3671
|
-
"inputSchema": t
|
|
3964
|
+
"inputSchema": _mcp_attr(t, "inputSchema") or {},
|
|
3672
3965
|
}
|
|
3673
3966
|
for t in all_tools
|
|
3674
3967
|
)
|
|
@@ -3693,11 +3986,9 @@ def _fetch_mcp_tools(
|
|
|
3693
3986
|
headers = dict(auth_headers) if auth_headers else None
|
|
3694
3987
|
|
|
3695
3988
|
async def _via_streamable():
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
async with streamablehttp_client(
|
|
3989
|
+
async with _streamable_streams(
|
|
3699
3990
|
source, headers=headers, auth=oauth_provider
|
|
3700
|
-
) as (read, write
|
|
3991
|
+
) as (read, write):
|
|
3701
3992
|
async with ClientSession(read, write) as session:
|
|
3702
3993
|
await session.initialize()
|
|
3703
3994
|
await _extract_tools(session)
|
|
@@ -3723,7 +4014,7 @@ def _fetch_mcp_tools(
|
|
|
3723
4014
|
except Exception:
|
|
3724
4015
|
await _via_sse()
|
|
3725
4016
|
|
|
3726
|
-
|
|
4017
|
+
_run_mcp_clean(_run, source)
|
|
3727
4018
|
return tools_result
|
|
3728
4019
|
|
|
3729
4020
|
|
|
@@ -4228,18 +4519,41 @@ def _handle_session_operations(
|
|
|
4228
4519
|
sys.exit(1)
|
|
4229
4520
|
|
|
4230
4521
|
cmd: CommandDef = args._cmd
|
|
4231
|
-
if getattr(args, "stdin", False):
|
|
4522
|
+
if getattr(args, "stdin", False) is True:
|
|
4232
4523
|
arguments = read_stdin_json(f"session {sess_name} tool arguments")
|
|
4233
4524
|
else:
|
|
4234
4525
|
arguments = {}
|
|
4235
4526
|
for p in cmd.params:
|
|
4236
|
-
val = getattr(args, p
|
|
4527
|
+
val = getattr(args, _param_dest(p), None)
|
|
4237
4528
|
if val is not None:
|
|
4238
4529
|
arguments[p.original_name] = coerce_value(val, p.schema)
|
|
4239
4530
|
|
|
4240
4531
|
result = _session_request(
|
|
4241
4532
|
sess_name, "call_tool", {"name": cmd.tool_name, "arguments": arguments}
|
|
4242
4533
|
)
|
|
4534
|
+
if isinstance(result, dict) and "isError" in result:
|
|
4535
|
+
content = result.get("content") or []
|
|
4536
|
+
text = _extract_content_parts(content) if isinstance(content, list) else content
|
|
4537
|
+
payload = text or result.get("structuredContent") or ""
|
|
4538
|
+
|
|
4539
|
+
if pre_args.json_output:
|
|
4540
|
+
output_result(result, **_sess_out)
|
|
4541
|
+
if result.get("isError"):
|
|
4542
|
+
sys.exit(1)
|
|
4543
|
+
return True
|
|
4544
|
+
|
|
4545
|
+
if result.get("isError"):
|
|
4546
|
+
error = (
|
|
4547
|
+
payload
|
|
4548
|
+
if isinstance(payload, str)
|
|
4549
|
+
else json.dumps(payload, ensure_ascii=False)
|
|
4550
|
+
)
|
|
4551
|
+
print(
|
|
4552
|
+
f"Error: {error or f'tool {cmd.tool_name!r} reported an error'}",
|
|
4553
|
+
file=sys.stderr,
|
|
4554
|
+
)
|
|
4555
|
+
sys.exit(1)
|
|
4556
|
+
result = payload
|
|
4243
4557
|
output_result(result, **_sess_out)
|
|
4244
4558
|
return True
|
|
4245
4559
|
|
|
File without changes
|
|
File without changes
|