mcp2cli 2.3.0__tar.gz → 2.4.1__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: 2.3.0
3
+ Version: 2.4.1
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 = "2.3.0"
3
+ version = "2.4.1"
4
4
  description = "Turn any MCP server or OpenAPI spec into a CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -2,12 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "2.3.0"
5
+ __version__ = "2.4.0"
6
6
 
7
7
  import argparse
8
8
  import copy
9
9
  import hashlib
10
10
  import json
11
+ import mimetypes
11
12
  import os
12
13
  import fnmatch
13
14
  import re
@@ -36,6 +37,7 @@ CONFIG_DIR = Path(
36
37
  os.environ.get("MCP2CLI_CONFIG_DIR", Path.home() / ".config" / "mcp2cli")
37
38
  )
38
39
  BAKED_FILE = CONFIG_DIR / "baked.json"
40
+ ARGPARSE_HELP_PERCENT_RE = re.compile(r"(?<!%)%(?![%\(])")
39
41
 
40
42
 
41
43
  # ---------------------------------------------------------------------------
@@ -64,6 +66,7 @@ class CommandDef:
64
66
  # OpenAPI
65
67
  method: str | None = None
66
68
  path: str | None = None
69
+ content_type: str | None = None # None = json, "multipart/form-data", etc.
67
70
  # MCP
68
71
  tool_name: str | None = None
69
72
  # GraphQL
@@ -108,6 +111,11 @@ def resolve_secret(value: str) -> str:
108
111
  return value
109
112
 
110
113
 
114
+ def escape_argparse_help(help_text: str) -> str:
115
+ """Escape literal percent signs in help text for argparse."""
116
+ return ARGPARSE_HELP_PERCENT_RE.sub("%%", help_text)
117
+
118
+
111
119
  def _parse_kv_list(
112
120
  items: list[str],
113
121
  delimiter: str,
@@ -322,10 +330,11 @@ def output_result(
322
330
  print(json.dumps(data))
323
331
 
324
332
 
325
- def _build_http_headers(auth_headers: list[tuple[str, str]]) -> dict[str, str]:
333
+ def _build_http_headers(auth_headers: list[tuple[str, str]], multipart: bool = False) -> dict[str, str]:
326
334
  """Build HTTP headers dict from auth_headers with a Content-Type default."""
327
335
  headers = dict(auth_headers)
328
- headers.setdefault("Content-Type", "application/json")
336
+ if not multipart:
337
+ headers.setdefault("Content-Type", "application/json")
329
338
  return headers
330
339
 
331
340
 
@@ -662,19 +671,43 @@ def extract_openapi_commands(spec: dict) -> list[CommandDef]:
662
671
  )
663
672
  params.append(p)
664
673
 
665
- # Request body
666
- rb_schema = (
667
- details.get("requestBody", {})
668
- .get("content", {})
669
- .get("application/json", {})
670
- .get("schema", {})
674
+ # Request body — negotiate content type
675
+ rb_content = details.get("requestBody", {}).get("content", {})
676
+ multipart_schema = rb_content.get("multipart/form-data", {}).get("schema", {})
677
+ json_schema = rb_content.get("application/json", {}).get("schema", {})
678
+
679
+ mp_props = multipart_schema.get("properties", {})
680
+ has_binary = any(
681
+ p.get("format") == "binary" for p in mp_props.values()
671
682
  )
683
+
684
+ if has_binary:
685
+ rb_schema = multipart_schema
686
+ cmd_content_type = "multipart/form-data"
687
+ elif json_schema:
688
+ rb_schema = json_schema
689
+ cmd_content_type = None
690
+ elif mp_props:
691
+ rb_schema = multipart_schema
692
+ cmd_content_type = "multipart/form-data"
693
+ else:
694
+ rb_schema = {}
695
+ cmd_content_type = None
696
+
672
697
  required_fields = set(rb_schema.get("required", []))
673
698
  properties = rb_schema.get("properties", {})
674
699
  has_body = bool(properties)
675
700
 
676
701
  for prop_name, prop_schema in properties.items():
677
- py_type, suffix = schema_type_to_python(prop_schema)
702
+ is_binary = (
703
+ cmd_content_type == "multipart/form-data"
704
+ and prop_schema.get("format") == "binary"
705
+ )
706
+ if is_binary:
707
+ loc, py_type, suffix = "file", str, " (file path)"
708
+ else:
709
+ py_type, suffix = schema_type_to_python(prop_schema)
710
+ loc = "body"
678
711
  p = ParamDef(
679
712
  name=to_kebab(prop_name),
680
713
  original_name=prop_name,
@@ -682,7 +715,7 @@ def extract_openapi_commands(spec: dict) -> list[CommandDef]:
682
715
  required=prop_name in required_fields,
683
716
  description=(prop_schema.get("description") or prop_name) + suffix,
684
717
  choices=prop_schema.get("enum"),
685
- location="body",
718
+ location=loc,
686
719
  )
687
720
  params.append(p)
688
721
 
@@ -694,6 +727,7 @@ def extract_openapi_commands(spec: dict) -> list[CommandDef]:
694
727
  has_body=has_body,
695
728
  method=method,
696
729
  path=path,
730
+ content_type=cmd_content_type,
697
731
  )
698
732
  )
699
733
 
@@ -1577,7 +1611,10 @@ def build_argparse(
1577
1611
  subparsers = parser.add_subparsers(dest="_command")
1578
1612
 
1579
1613
  for cmd in commands:
1580
- sub = subparsers.add_parser(cmd.name, help=cmd.description)
1614
+ sub = subparsers.add_parser(
1615
+ cmd.name,
1616
+ help=escape_argparse_help(cmd.description),
1617
+ )
1581
1618
  sub.set_defaults(_cmd=cmd)
1582
1619
 
1583
1620
  if cmd.has_body:
@@ -1608,7 +1645,7 @@ def build_argparse(
1608
1645
  kwargs["required"] = True
1609
1646
  else:
1610
1647
  kwargs.setdefault("default", None)
1611
- kwargs["help"] = p.description
1648
+ kwargs["help"] = escape_argparse_help(p.description)
1612
1649
  if p.choices:
1613
1650
  kwargs["choices"] = p.choices
1614
1651
  sub.add_argument(flag, **kwargs)
@@ -1662,16 +1699,17 @@ def _filter_commands(commands: list[CommandDef], pattern: str) -> list[CommandDe
1662
1699
  def _collect_openapi_params(
1663
1700
  cmd: CommandDef,
1664
1701
  args: argparse.Namespace,
1665
- ) -> tuple[str, dict[str, str], dict[str, str], dict | None]:
1702
+ ) -> tuple[str, dict[str, str], dict[str, str], dict | None, dict | None]:
1666
1703
  """Collect OpenAPI params from parsed args, separated by location.
1667
1704
 
1668
- Returns (path, query_params, extra_headers, body_or_none) where *path*
1669
- has ``{param}`` placeholders substituted with actual values.
1705
+ Returns (path, query_params, extra_headers, body_or_none, files_or_none)
1706
+ where *path* has ``{param}`` placeholders substituted with actual values.
1670
1707
  """
1671
1708
  path = cmd.path or ""
1672
1709
  query_params: dict[str, str] = {}
1673
1710
  extra_headers: dict[str, str] = {}
1674
1711
  body: dict | None = None
1712
+ files: dict | None = None
1675
1713
 
1676
1714
  for p in cmd.params:
1677
1715
  if p.location == "path":
@@ -1701,6 +1739,17 @@ def _collect_openapi_params(
1701
1739
  continue
1702
1740
  if p.location == "path":
1703
1741
  continue
1742
+ if p.location == "file":
1743
+ if val is not None:
1744
+ fp = Path(val)
1745
+ if not fp.is_file():
1746
+ print(f"Error: file not found: {val}", file=sys.stderr)
1747
+ sys.exit(1)
1748
+ mime = mimetypes.guess_type(val)[0] or "application/octet-stream"
1749
+ if files is None:
1750
+ files = {}
1751
+ files[p.original_name] = (fp.name, open(fp, "rb"), mime)
1752
+ continue
1704
1753
  if val is not None:
1705
1754
  body[p.original_name] = val
1706
1755
  if not body:
@@ -1712,7 +1761,7 @@ def _collect_openapi_params(
1712
1761
  if val is not None:
1713
1762
  query_params[p.original_name] = val
1714
1763
 
1715
- return path, query_params, extra_headers, body
1764
+ return path, query_params, extra_headers, body, files
1716
1765
 
1717
1766
 
1718
1767
  def execute_openapi(
@@ -1727,21 +1776,45 @@ def execute_openapi(
1727
1776
  jq_expr: str | None = None,
1728
1777
  head: int | None = None,
1729
1778
  ):
1730
- path, query_params, extra_headers, body = _collect_openapi_params(cmd, args)
1779
+ path, query_params, extra_headers, body, files = _collect_openapi_params(cmd, args)
1731
1780
  url = base_url.rstrip("/") + path
1732
1781
 
1733
- headers = _build_http_headers(auth_headers)
1782
+ is_multipart = files is not None or cmd.content_type == "multipart/form-data"
1783
+ headers = _build_http_headers(auth_headers, multipart=is_multipart)
1734
1784
  headers.update(extra_headers)
1735
1785
 
1736
- with httpx.Client(timeout=60, auth=oauth_provider) as client:
1737
- resp = client.request(
1738
- (cmd.method or "get").upper(),
1739
- url,
1740
- headers=headers,
1741
- params=query_params or None,
1742
- json=body,
1743
- )
1744
- _handle_http_error(resp)
1786
+ try:
1787
+ with httpx.Client(timeout=60, auth=oauth_provider) as client:
1788
+ if files is not None:
1789
+ resp = client.request(
1790
+ (cmd.method or "get").upper(),
1791
+ url,
1792
+ headers=headers,
1793
+ params=query_params or None,
1794
+ data=body,
1795
+ files=files,
1796
+ )
1797
+ elif cmd.content_type == "multipart/form-data":
1798
+ resp = client.request(
1799
+ (cmd.method or "get").upper(),
1800
+ url,
1801
+ headers=headers,
1802
+ params=query_params or None,
1803
+ data=body,
1804
+ )
1805
+ else:
1806
+ resp = client.request(
1807
+ (cmd.method or "get").upper(),
1808
+ url,
1809
+ headers=headers,
1810
+ params=query_params or None,
1811
+ json=body,
1812
+ )
1813
+ _handle_http_error(resp)
1814
+ finally:
1815
+ if files:
1816
+ for _, file_tuple in files.items():
1817
+ file_tuple[1].close()
1745
1818
 
1746
1819
  if raw:
1747
1820
  sys.stdout.buffer.write(resp.content)
File without changes
File without changes
File without changes