mcp2cli 2.3.0__tar.gz → 2.4.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: 2.3.0
3
+ Version: 2.4.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 = "2.3.0"
3
+ version = "2.4.0"
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
@@ -64,6 +65,7 @@ class CommandDef:
64
65
  # OpenAPI
65
66
  method: str | None = None
66
67
  path: str | None = None
68
+ content_type: str | None = None # None = json, "multipart/form-data", etc.
67
69
  # MCP
68
70
  tool_name: str | None = None
69
71
  # GraphQL
@@ -322,10 +324,11 @@ def output_result(
322
324
  print(json.dumps(data))
323
325
 
324
326
 
325
- def _build_http_headers(auth_headers: list[tuple[str, str]]) -> dict[str, str]:
327
+ def _build_http_headers(auth_headers: list[tuple[str, str]], multipart: bool = False) -> dict[str, str]:
326
328
  """Build HTTP headers dict from auth_headers with a Content-Type default."""
327
329
  headers = dict(auth_headers)
328
- headers.setdefault("Content-Type", "application/json")
330
+ if not multipart:
331
+ headers.setdefault("Content-Type", "application/json")
329
332
  return headers
330
333
 
331
334
 
@@ -662,19 +665,43 @@ def extract_openapi_commands(spec: dict) -> list[CommandDef]:
662
665
  )
663
666
  params.append(p)
664
667
 
665
- # Request body
666
- rb_schema = (
667
- details.get("requestBody", {})
668
- .get("content", {})
669
- .get("application/json", {})
670
- .get("schema", {})
668
+ # Request body — negotiate content type
669
+ rb_content = details.get("requestBody", {}).get("content", {})
670
+ multipart_schema = rb_content.get("multipart/form-data", {}).get("schema", {})
671
+ json_schema = rb_content.get("application/json", {}).get("schema", {})
672
+
673
+ mp_props = multipart_schema.get("properties", {})
674
+ has_binary = any(
675
+ p.get("format") == "binary" for p in mp_props.values()
671
676
  )
677
+
678
+ if has_binary:
679
+ rb_schema = multipart_schema
680
+ cmd_content_type = "multipart/form-data"
681
+ elif json_schema:
682
+ rb_schema = json_schema
683
+ cmd_content_type = None
684
+ elif mp_props:
685
+ rb_schema = multipart_schema
686
+ cmd_content_type = "multipart/form-data"
687
+ else:
688
+ rb_schema = {}
689
+ cmd_content_type = None
690
+
672
691
  required_fields = set(rb_schema.get("required", []))
673
692
  properties = rb_schema.get("properties", {})
674
693
  has_body = bool(properties)
675
694
 
676
695
  for prop_name, prop_schema in properties.items():
677
- py_type, suffix = schema_type_to_python(prop_schema)
696
+ is_binary = (
697
+ cmd_content_type == "multipart/form-data"
698
+ and prop_schema.get("format") == "binary"
699
+ )
700
+ if is_binary:
701
+ loc, py_type, suffix = "file", str, " (file path)"
702
+ else:
703
+ py_type, suffix = schema_type_to_python(prop_schema)
704
+ loc = "body"
678
705
  p = ParamDef(
679
706
  name=to_kebab(prop_name),
680
707
  original_name=prop_name,
@@ -682,7 +709,7 @@ def extract_openapi_commands(spec: dict) -> list[CommandDef]:
682
709
  required=prop_name in required_fields,
683
710
  description=(prop_schema.get("description") or prop_name) + suffix,
684
711
  choices=prop_schema.get("enum"),
685
- location="body",
712
+ location=loc,
686
713
  )
687
714
  params.append(p)
688
715
 
@@ -694,6 +721,7 @@ def extract_openapi_commands(spec: dict) -> list[CommandDef]:
694
721
  has_body=has_body,
695
722
  method=method,
696
723
  path=path,
724
+ content_type=cmd_content_type,
697
725
  )
698
726
  )
699
727
 
@@ -1662,16 +1690,17 @@ def _filter_commands(commands: list[CommandDef], pattern: str) -> list[CommandDe
1662
1690
  def _collect_openapi_params(
1663
1691
  cmd: CommandDef,
1664
1692
  args: argparse.Namespace,
1665
- ) -> tuple[str, dict[str, str], dict[str, str], dict | None]:
1693
+ ) -> tuple[str, dict[str, str], dict[str, str], dict | None, dict | None]:
1666
1694
  """Collect OpenAPI params from parsed args, separated by location.
1667
1695
 
1668
- Returns (path, query_params, extra_headers, body_or_none) where *path*
1669
- has ``{param}`` placeholders substituted with actual values.
1696
+ Returns (path, query_params, extra_headers, body_or_none, files_or_none)
1697
+ where *path* has ``{param}`` placeholders substituted with actual values.
1670
1698
  """
1671
1699
  path = cmd.path or ""
1672
1700
  query_params: dict[str, str] = {}
1673
1701
  extra_headers: dict[str, str] = {}
1674
1702
  body: dict | None = None
1703
+ files: dict | None = None
1675
1704
 
1676
1705
  for p in cmd.params:
1677
1706
  if p.location == "path":
@@ -1701,6 +1730,17 @@ def _collect_openapi_params(
1701
1730
  continue
1702
1731
  if p.location == "path":
1703
1732
  continue
1733
+ if p.location == "file":
1734
+ if val is not None:
1735
+ fp = Path(val)
1736
+ if not fp.is_file():
1737
+ print(f"Error: file not found: {val}", file=sys.stderr)
1738
+ sys.exit(1)
1739
+ mime = mimetypes.guess_type(val)[0] or "application/octet-stream"
1740
+ if files is None:
1741
+ files = {}
1742
+ files[p.original_name] = (fp.name, open(fp, "rb"), mime)
1743
+ continue
1704
1744
  if val is not None:
1705
1745
  body[p.original_name] = val
1706
1746
  if not body:
@@ -1712,7 +1752,7 @@ def _collect_openapi_params(
1712
1752
  if val is not None:
1713
1753
  query_params[p.original_name] = val
1714
1754
 
1715
- return path, query_params, extra_headers, body
1755
+ return path, query_params, extra_headers, body, files
1716
1756
 
1717
1757
 
1718
1758
  def execute_openapi(
@@ -1727,21 +1767,45 @@ def execute_openapi(
1727
1767
  jq_expr: str | None = None,
1728
1768
  head: int | None = None,
1729
1769
  ):
1730
- path, query_params, extra_headers, body = _collect_openapi_params(cmd, args)
1770
+ path, query_params, extra_headers, body, files = _collect_openapi_params(cmd, args)
1731
1771
  url = base_url.rstrip("/") + path
1732
1772
 
1733
- headers = _build_http_headers(auth_headers)
1773
+ is_multipart = files is not None or cmd.content_type == "multipart/form-data"
1774
+ headers = _build_http_headers(auth_headers, multipart=is_multipart)
1734
1775
  headers.update(extra_headers)
1735
1776
 
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)
1777
+ try:
1778
+ with httpx.Client(timeout=60, auth=oauth_provider) as client:
1779
+ if files is not None:
1780
+ resp = client.request(
1781
+ (cmd.method or "get").upper(),
1782
+ url,
1783
+ headers=headers,
1784
+ params=query_params or None,
1785
+ data=body,
1786
+ files=files,
1787
+ )
1788
+ elif cmd.content_type == "multipart/form-data":
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
+ )
1796
+ else:
1797
+ resp = client.request(
1798
+ (cmd.method or "get").upper(),
1799
+ url,
1800
+ headers=headers,
1801
+ params=query_params or None,
1802
+ json=body,
1803
+ )
1804
+ _handle_http_error(resp)
1805
+ finally:
1806
+ if files:
1807
+ for _, file_tuple in files.items():
1808
+ file_tuple[1].close()
1745
1809
 
1746
1810
  if raw:
1747
1811
  sys.stdout.buffer.write(resp.content)
File without changes
File without changes
File without changes