mcp2cli 3.5.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp2cli
3
- Version: 3.5.0
3
+ Version: 3.6.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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp2cli"
3
- version = "3.5.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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp2cli"
3
- version = "3.5.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"
@@ -61,6 +61,7 @@ class ParamDef:
61
61
  choices: list | None = None
62
62
  location: str = "body" # path|query|header|body|tool_input
63
63
  schema: dict = field(default_factory=dict)
64
+ cli_name: str | None = None # collision-free argparse flag name
64
65
 
65
66
 
66
67
  @dataclass
@@ -178,8 +179,17 @@ def read_stdin_json(context: str):
178
179
  sys.exit(1)
179
180
 
180
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
+
181
191
  def schema_type_to_python(schema: dict) -> tuple[type | None, str]:
182
- t = schema.get("type")
192
+ t = _normalize_schema_type(schema.get("type"))
183
193
  if t == "integer":
184
194
  return int, ""
185
195
  if t == "number":
@@ -190,6 +200,16 @@ def schema_type_to_python(schema: dict) -> tuple[type | None, str]:
190
200
  return str, " (JSON array)"
191
201
  if t == "object":
192
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, ""
193
213
  return str, ""
194
214
 
195
215
 
@@ -207,7 +227,7 @@ def _coerce_item(value: str, item_type: str | None):
207
227
  def coerce_value(value, schema: dict):
208
228
  if value is None:
209
229
  return None
210
- t = schema.get("type")
230
+ t = _normalize_schema_type(schema.get("type"))
211
231
  if t == "array":
212
232
  if isinstance(value, list):
213
233
  return value
@@ -218,7 +238,7 @@ def coerce_value(value, schema: dict):
218
238
  return parsed
219
239
  except (json.JSONDecodeError, TypeError):
220
240
  pass
221
- item_type = schema.get("items", {}).get("type")
241
+ item_type = _normalize_schema_type(schema.get("items", {}).get("type"))
222
242
  if "," in value:
223
243
  return [_coerce_item(v.strip(), item_type) for v in value.split(",")]
224
244
  return [_coerce_item(value, item_type)]
@@ -252,13 +272,16 @@ def to_kebab(name: str) -> str:
252
272
  return s.replace("_", "-").lower()
253
273
 
254
274
 
255
- def _find_toon_cli() -> str | None:
256
- """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."""
257
277
  if shutil.which("toon"):
258
- return "toon"
259
- # 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.
260
283
  if shutil.which("npx"):
261
- return "npx @toon-format/cli"
284
+ return ("npx", "--no", "@toon-format/cli")
262
285
  return None
263
286
 
264
287
 
@@ -269,7 +292,7 @@ def _toon_encode(json_str: str) -> str | None:
269
292
  return None
270
293
  try:
271
294
  result = subprocess.run(
272
- cmd.split(),
295
+ cmd,
273
296
  input=json_str,
274
297
  capture_output=True,
275
298
  text=True,
@@ -499,9 +522,34 @@ def _python_type_name(t: type | None) -> str:
499
522
  return getattr(t, "__name__", str(t))
500
523
 
501
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
+
502
550
  def _param_to_dict(p: "ParamDef") -> dict:
503
551
  d = {
504
- "name": p.name,
552
+ "name": p.cli_name or p.name,
505
553
  "type": _python_type_name(p.python_type),
506
554
  "required": p.required,
507
555
  "description": p.description,
@@ -1494,8 +1542,34 @@ def extract_openapi_commands(spec: dict) -> list[CommandDef]:
1494
1542
 
1495
1543
  def extract_mcp_commands(tools: list[dict]) -> list[CommandDef]:
1496
1544
  commands: list[CommandDef] = []
1497
- for tool in tools:
1498
- 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):
1499
1573
  desc = tool.get("description", "")
1500
1574
  schema = tool.get("inputSchema", {})
1501
1575
  required_fields = set(schema.get("required", []))
@@ -1934,12 +2008,12 @@ def _build_graphql_document(
1934
2008
  types_by_name = {t["name"]: t for t in schema.get("types", []) if t.get("name")}
1935
2009
 
1936
2010
  # Build variables dict from args
1937
- if getattr(args, "stdin", False):
2011
+ if getattr(args, "stdin", False) is True:
1938
2012
  variables = read_stdin_json("GraphQL variables")
1939
2013
  else:
1940
2014
  variables = {}
1941
2015
  for p in cmd.params:
1942
- val = getattr(args, p.name.replace("-", "_"), None)
2016
+ val = getattr(args, _param_dest(p), None)
1943
2017
  if val is not None:
1944
2018
  variables[p.original_name] = coerce_value(val, p.schema)
1945
2019
 
@@ -2473,6 +2547,7 @@ def build_argparse(
2473
2547
  help=escape_argparse_help(cmd.description),
2474
2548
  description=escape_argparse_help(cmd.description),
2475
2549
  )
2550
+ _allocate_param_cli_names(cmd)
2476
2551
  sub.set_defaults(_cmd=cmd)
2477
2552
 
2478
2553
  if cmd.has_body:
@@ -2483,12 +2558,8 @@ def build_argparse(
2483
2558
  help="Read JSON body/arguments from stdin",
2484
2559
  )
2485
2560
 
2486
- seen_flags: set[str] = set()
2487
2561
  for p in cmd.params:
2488
- flag = f"--{p.name}"
2489
- if flag in seen_flags:
2490
- continue # skip duplicate param names (e.g. path + body both have same name)
2491
- seen_flags.add(flag)
2562
+ flag = f"--{p.cli_name or p.name}"
2492
2563
  kwargs: dict = {}
2493
2564
  if p.python_type is not None:
2494
2565
  kwargs["type"] = p.python_type
@@ -2506,6 +2577,7 @@ def build_argparse(
2506
2577
  kwargs["help"] = escape_argparse_help(p.description)
2507
2578
  if p.choices:
2508
2579
  kwargs["choices"] = p.choices
2580
+ kwargs["dest"] = _param_dest(p)
2509
2581
  sub.add_argument(flag, **kwargs)
2510
2582
 
2511
2583
  return parser
@@ -2634,13 +2706,13 @@ def _collect_openapi_params(
2634
2706
 
2635
2707
  for p in cmd.params:
2636
2708
  if p.location == "path":
2637
- val = getattr(args, p.name.replace("-", "_"), None)
2709
+ val = getattr(args, _param_dest(p), None)
2638
2710
  if val is not None:
2639
2711
  path = path.replace(f"{{{p.original_name}}}", str(val))
2640
2712
 
2641
2713
  if cmd.method == "get":
2642
2714
  for p in cmd.params:
2643
- val = getattr(args, p.name.replace("-", "_"), None)
2715
+ val = getattr(args, _param_dest(p), None)
2644
2716
  if val is None:
2645
2717
  continue
2646
2718
  if p.location == "query":
@@ -2648,12 +2720,12 @@ def _collect_openapi_params(
2648
2720
  elif p.location == "header":
2649
2721
  extra_headers[p.original_name] = str(val)
2650
2722
  else:
2651
- if getattr(args, "stdin", False):
2723
+ if getattr(args, "stdin", False) is True:
2652
2724
  body = read_stdin_json("OpenAPI request body")
2653
2725
  else:
2654
2726
  body = {}
2655
2727
  for p in cmd.params:
2656
- val = getattr(args, p.name.replace("-", "_"), None)
2728
+ val = getattr(args, _param_dest(p), None)
2657
2729
  if p.location == "header":
2658
2730
  if val is not None:
2659
2731
  extra_headers[p.original_name] = str(val)
@@ -2678,7 +2750,7 @@ def _collect_openapi_params(
2678
2750
  # Also collect query params for non-GET
2679
2751
  for p in cmd.params:
2680
2752
  if p.location == "query":
2681
- val = getattr(args, p.name.replace("-", "_"), None)
2753
+ val = getattr(args, _param_dest(p), None)
2682
2754
  if val is not None:
2683
2755
  query_params[p.original_name] = coerce_value(val, p.schema)
2684
2756
 
@@ -2763,6 +2835,55 @@ def execute_openapi(
2763
2835
  # ---------------------------------------------------------------------------
2764
2836
 
2765
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
+
2766
2887
  def run_mcp_http(
2767
2888
  url: str,
2768
2889
  auth_headers: list[tuple[str, str]],
@@ -2865,7 +2986,9 @@ def run_mcp_http(
2865
2986
  except Exception:
2866
2987
  return await _with_sse()
2867
2988
 
2868
- anyio.run(_run)
2989
+ rc = _run_mcp_clean(_run, url)
2990
+ if rc:
2991
+ sys.exit(rc)
2869
2992
 
2870
2993
 
2871
2994
  def run_mcp_stdio(
@@ -2923,7 +3046,7 @@ def run_mcp_stdio(
2923
3046
  async with stdio_client(params) as (read, write):
2924
3047
  async with ClientSession(read, write) as session:
2925
3048
  await session.initialize()
2926
- await _mcp_session(
3049
+ return await _mcp_session(
2927
3050
  session,
2928
3051
  tool_name,
2929
3052
  arguments,
@@ -2937,7 +3060,9 @@ def run_mcp_stdio(
2937
3060
  **extra,
2938
3061
  )
2939
3062
 
2940
- anyio.run(_run)
3063
+ rc = _run_mcp_clean(_run, command_str)
3064
+ if rc:
3065
+ sys.exit(rc)
2941
3066
 
2942
3067
 
2943
3068
  async def _mcp_session(
@@ -3028,10 +3153,26 @@ async def _mcp_session(
3028
3153
  # isError) with the camelCase wire names, so the envelope does not
3029
3154
  # change shape with the installed SDK major.
3030
3155
  output_result(_mcp_dump(result), pretty=pretty, head=head, json_output=True)
3031
- return
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
3032
3159
 
3033
3160
  text = _extract_content_parts(result.content)
3034
- 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
3035
3176
 
3036
3177
 
3037
3178
  # ---------------------------------------------------------------------------
@@ -3280,13 +3421,25 @@ def session_start(
3280
3421
 
3281
3422
 
3282
3423
  def _extract_content_parts(content_list, *, attrs=("text", "data")) -> str:
3283
- """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
+ """
3284
3430
  parts = []
3285
- for c in content_list:
3431
+ for content in content_list:
3432
+ is_mapping = isinstance(content, dict)
3286
3433
  for attr in attrs:
3287
- if hasattr(c, attr):
3288
- 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)
3289
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))
3290
3443
  return "\n".join(parts) if parts else ""
3291
3444
 
3292
3445
 
@@ -3320,7 +3473,7 @@ async def _dispatch_list_tools(session, params):
3320
3473
 
3321
3474
  async def _dispatch_call_tool(session, params):
3322
3475
  result = await session.call_tool(params["name"], params.get("arguments", {}))
3323
- return _extract_content_parts(result.content)
3476
+ return _mcp_dump(result)
3324
3477
 
3325
3478
 
3326
3479
  async def _dispatch_list_resources(session, params):
@@ -3772,12 +3925,12 @@ def handle_mcp(
3772
3925
 
3773
3926
  cmd: CommandDef = args._cmd
3774
3927
 
3775
- if getattr(args, "stdin", False):
3928
+ if getattr(args, "stdin", False) is True:
3776
3929
  arguments = read_stdin_json("MCP tool arguments")
3777
3930
  else:
3778
3931
  arguments = {}
3779
3932
  for p in cmd.params:
3780
- val = getattr(args, p.name.replace("-", "_"), None)
3933
+ val = getattr(args, _param_dest(p), None)
3781
3934
  if val is not None:
3782
3935
  arguments[p.original_name] = coerce_value(val, p.schema)
3783
3936
 
@@ -3861,7 +4014,7 @@ def _fetch_mcp_tools(
3861
4014
  except Exception:
3862
4015
  await _via_sse()
3863
4016
 
3864
- anyio.run(_run)
4017
+ _run_mcp_clean(_run, source)
3865
4018
  return tools_result
3866
4019
 
3867
4020
 
@@ -4366,18 +4519,41 @@ def _handle_session_operations(
4366
4519
  sys.exit(1)
4367
4520
 
4368
4521
  cmd: CommandDef = args._cmd
4369
- if getattr(args, "stdin", False):
4522
+ if getattr(args, "stdin", False) is True:
4370
4523
  arguments = read_stdin_json(f"session {sess_name} tool arguments")
4371
4524
  else:
4372
4525
  arguments = {}
4373
4526
  for p in cmd.params:
4374
- val = getattr(args, p.name.replace("-", "_"), None)
4527
+ val = getattr(args, _param_dest(p), None)
4375
4528
  if val is not None:
4376
4529
  arguments[p.original_name] = coerce_value(val, p.schema)
4377
4530
 
4378
4531
  result = _session_request(
4379
4532
  sess_name, "call_tool", {"name": cmd.tool_name, "arguments": arguments}
4380
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
4381
4557
  output_result(result, **_sess_out)
4382
4558
  return True
4383
4559
 
File without changes
File without changes
File without changes