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.
@@ -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 (96 tests covering OpenAPI, MCP stdio, MCP HTTP, caching, and token savings)
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 (96 tests covering OpenAPI, MCP stdio, MCP HTTP, caching, and token savings)
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.4.0"
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.0,<2",
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.4.0"
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
- "mcp>=1.0,<2",
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 the command to invoke the TOON CLI, or None if unavailable."""
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
- # Check for npx (ships with Node.js)
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.split(),
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
- scopes=scope,
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() -> tuple[str, str | None]:
1109
- return await anyio.to_thread.run_sync(_prompt_oauth_callback)
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() -> tuple[str, str | None]:
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 (_CallbackHandler.auth_code, _CallbackHandler.state)
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
- name = to_kebab(tool.get("name", "unknown"))
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.name.replace("-", "_"), None)
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.name.replace("-", "_"), None)
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.name.replace("-", "_"), None)
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.name.replace("-", "_"), None)
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.name.replace("-", "_"), None)
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
- from mcp.client.streamable_http import streamablehttp_client
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
- anyio.run(_run)
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
- anyio.run(_run)
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.inputSchema or {},
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) using the SDK's own serializer — 100% MCP-compatible.
2899
- output_result(result.model_dump(mode="json"), pretty=pretty, head=head, json_output=True)
2900
- return
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
- output_result(text, pretty=pretty, raw=raw, toon=toon, head=head)
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.mimeType or "",
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.uriTemplate),
3211
+ "uriTemplate": str(_mcp_attr(t, "uriTemplate")),
2940
3212
  "description": t.description or "",
2941
- "mimeType": t.mimeType or "",
3213
+ "mimeType": _mcp_attr(t, "mimeType") or "",
2942
3214
  }
2943
- for t in result.resourceTemplates
3215
+ for t in _mcp_attr(result, "resourceTemplates")
2944
3216
  ]
2945
3217
  output_result(data, **_out)
2946
3218
  elif action == "read":
2947
- from pydantic import AnyUrl
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.model_dump())}
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 c in content_list:
3431
+ for content in content_list:
3432
+ is_mapping = isinstance(content, dict)
3157
3433
  for attr in attrs:
3158
- if hasattr(c, attr):
3159
- parts.append(getattr(c, attr))
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.list_tools(cursor=cursor)
3455
+ result = await _list_tools_page(session, cursor)
3174
3456
  tools.extend(result.tools)
3175
- cursor = result.nextCursor
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
- {"name": t.name, "description": t.description or "", "inputSchema": t.inputSchema or {}}
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 _extract_content_parts(result.content)
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
- {"name": r.name, "uri": str(r.uri), "description": r.description or "", "mimeType": r.mimeType or ""}
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
- from pydantic import AnyUrl
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
- {"name": t.name, "uriTemplate": str(t.uriTemplate), "description": t.description or "", "mimeType": t.mimeType or ""}
3212
- for t in result.resourceTemplates
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({"role": msg.role, "content": json.dumps(content.model_dump())})
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
- from mcp.client.streamable_http import streamablehttp_client
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.name.replace("-", "_"), None)
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.inputSchema or {},
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
- from mcp.client.streamable_http import streamablehttp_client
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
- anyio.run(_run)
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.name.replace("-", "_"), None)
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