mcp2cli 3.8.0__tar.gz → 3.8.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: 3.8.0
3
+ Version: 3.8.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>
@@ -217,7 +217,7 @@ mcp2cli --graphql https://api.example.com/graphql users --limit 10
217
217
  mcp2cli --graphql https://api.example.com/graphql create-user --name "Alice" --email "alice@example.com"
218
218
 
219
219
  # Override auto-generated selection set fields
220
- mcp2cli --graphql https://api.example.com/graphql users --fields "id name email"
220
+ mcp2cli --graphql https://api.example.com/graphql --fields "id name email" users
221
221
 
222
222
  # With auth
223
223
  mcp2cli --graphql https://api.example.com/graphql --auth-header "Authorization:Bearer tok_..." users
@@ -322,7 +322,7 @@ mcp2cli --spec ./spec.json --pretty list-pets
322
322
  mcp2cli --spec ./spec.json --raw get-data
323
323
 
324
324
  # Truncate large responses to first N records
325
- mcp2cli --spec ./spec.json list-records --head 5
325
+ mcp2cli --spec ./spec.json --head 5 list-records
326
326
 
327
327
  # Pipe-friendly (compact JSON when not a TTY)
328
328
  mcp2cli --spec ./spec.json list-pets | jq '.[] | .name'
@@ -198,7 +198,7 @@ mcp2cli --graphql https://api.example.com/graphql users --limit 10
198
198
  mcp2cli --graphql https://api.example.com/graphql create-user --name "Alice" --email "alice@example.com"
199
199
 
200
200
  # Override auto-generated selection set fields
201
- mcp2cli --graphql https://api.example.com/graphql users --fields "id name email"
201
+ mcp2cli --graphql https://api.example.com/graphql --fields "id name email" users
202
202
 
203
203
  # With auth
204
204
  mcp2cli --graphql https://api.example.com/graphql --auth-header "Authorization:Bearer tok_..." users
@@ -303,7 +303,7 @@ mcp2cli --spec ./spec.json --pretty list-pets
303
303
  mcp2cli --spec ./spec.json --raw get-data
304
304
 
305
305
  # Truncate large responses to first N records
306
- mcp2cli --spec ./spec.json list-records --head 5
306
+ mcp2cli --spec ./spec.json --head 5 list-records
307
307
 
308
308
  # Pipe-friendly (compact JSON when not a TTY)
309
309
  mcp2cli --spec ./spec.json list-pets | jq '.[] | .name'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp2cli"
3
- version = "3.8.0"
3
+ version = "3.8.1"
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.8.0"
3
+ version = "3.8.1"
4
4
  description = "Turn any MCP server or OpenAPI spec into a CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,7 +24,7 @@ import tempfile
24
24
  import threading
25
25
  import time
26
26
  import webbrowser
27
- from contextlib import asynccontextmanager
27
+ from contextlib import asynccontextmanager, suppress
28
28
  from dataclasses import dataclass, field
29
29
  from http.server import HTTPServer, BaseHTTPRequestHandler
30
30
  from pathlib import Path
@@ -58,7 +58,7 @@ ARGPARSE_HELP_PERCENT_RE = re.compile(r"(?<!%)%(?![%\(])")
58
58
  class ParamDef:
59
59
  name: str # kebab-case CLI flag
60
60
  original_name: str # original name for API/tool call
61
- python_type: type | None # None means boolean (store_true)
61
+ python_type: type | None # None means boolean (--flag / --no-flag)
62
62
  required: bool = False
63
63
  description: str = ""
64
64
  choices: list | None = None
@@ -1833,7 +1833,11 @@ def extract_openapi_commands(spec: dict) -> list[CommandDef]:
1833
1833
  location=param.get("in", "query"),
1834
1834
  schema=schema,
1835
1835
  style=param.get("style"),
1836
- explode=param.get("explode", False),
1836
+ explode=param.get(
1837
+ "explode",
1838
+ param.get("in", "query") == "query"
1839
+ and param.get("style", "form") == "form",
1840
+ ),
1837
1841
  )
1838
1842
  params.append(p)
1839
1843
 
@@ -2103,7 +2107,7 @@ def graphql_type_to_python(
2103
2107
  "ID": str,
2104
2108
  "Int": int,
2105
2109
  "Float": float,
2106
- "Boolean": None, # store_true
2110
+ "Boolean": None, # boolean flag
2107
2111
  }
2108
2112
  py_type = scalar_map.get(type_name, str)
2109
2113
  return py_type, is_non_null, None
@@ -3043,13 +3047,18 @@ def build_argparse(
3043
3047
  "are merged in (a conflicting value is an error)",
3044
3048
  )
3045
3049
 
3050
+ cli_names = {p.cli_name or p.name for p in cmd.params}
3046
3051
  for p in cmd.params:
3047
3052
  flag = f"--{p.cli_name or p.name}"
3048
3053
  kwargs: dict = {}
3049
3054
  if p.python_type is not None:
3050
3055
  kwargs["type"] = p.python_type
3051
- else:
3056
+ elif f"no-{p.cli_name or p.name}" in cli_names:
3057
+ # Another parameter already owns --no-<name>; keep the plain switch.
3052
3058
  kwargs["action"] = "store_true"
3059
+ else:
3060
+ # --<name> sends true, --no-<name> sends false, omitted sends nothing.
3061
+ kwargs["action"] = argparse.BooleanOptionalAction
3053
3062
  # Body/tool_input params are never argparse-required (--stdin bypasses them)
3054
3063
  if (
3055
3064
  p.required
@@ -3283,6 +3292,30 @@ def _collect_openapi_params(
3283
3292
  return path, query_params, extra_headers, body, files
3284
3293
 
3285
3294
 
3295
+ def _serialize_openapi_query_params(cmd: CommandDef, query_params: dict) -> str:
3296
+ """Encode query array atoms before joining their OpenAPI delimiters."""
3297
+ parameters = {p.original_name: p for p in cmd.params if p.location == "query"}
3298
+ parts = []
3299
+ for name, value in query_params.items():
3300
+ param = parameters[name]
3301
+ separator = {"form": ",", "spaceDelimited": "%20", "pipeDelimited": "%7C"}.get(
3302
+ param.style or "form"
3303
+ )
3304
+ if isinstance(value, list) and not param.explode and separator is not None:
3305
+ if not value:
3306
+ continue
3307
+ atoms = [
3308
+ quote(str(item).lower() if isinstance(item, bool) else str(item), safe="")
3309
+ for item in value
3310
+ if item is not None
3311
+ ]
3312
+ if atoms:
3313
+ parts.append(f"{quote(name, safe='')}={separator.join(atoms)}")
3314
+ else:
3315
+ parts.append(str(httpx.QueryParams({name: value})))
3316
+ return "&".join(part for part in parts if part)
3317
+
3318
+
3286
3319
  def execute_openapi(
3287
3320
  args: argparse.Namespace,
3288
3321
  cmd: CommandDef,
@@ -3297,6 +3330,14 @@ def execute_openapi(
3297
3330
  ):
3298
3331
  path, query_params, extra_headers, body, files = _collect_openapi_params(cmd, args)
3299
3332
  url = base_url.rstrip("/") + path
3333
+ if query_params:
3334
+ # Append to, rather than replace, any query string the spec path or
3335
+ # base URL already carries (e.g. `/search?type=user`).
3336
+ parsed = httpx.URL(url)
3337
+ query = _serialize_openapi_query_params(cmd, query_params).encode("ascii")
3338
+ if parsed.query:
3339
+ query = parsed.query + b"&" + query
3340
+ url = parsed.copy_with(query=query)
3300
3341
 
3301
3342
  is_multipart = files is not None or cmd.content_type == "multipart/form-data"
3302
3343
  headers = _build_http_headers(auth_headers, multipart=is_multipart)
@@ -3309,7 +3350,6 @@ def execute_openapi(
3309
3350
  (cmd.method or "get").upper(),
3310
3351
  url,
3311
3352
  headers=headers,
3312
- params=query_params or None,
3313
3353
  data=body,
3314
3354
  files=files,
3315
3355
  )
@@ -3318,7 +3358,6 @@ def execute_openapi(
3318
3358
  (cmd.method or "get").upper(),
3319
3359
  url,
3320
3360
  headers=headers,
3321
- params=query_params or None,
3322
3361
  data=body,
3323
3362
  )
3324
3363
  else:
@@ -3326,7 +3365,6 @@ def execute_openapi(
3326
3365
  (cmd.method or "get").upper(),
3327
3366
  url,
3328
3367
  headers=headers,
3329
- params=query_params or None,
3330
3368
  json=body,
3331
3369
  )
3332
3370
  _handle_http_error(resp)
@@ -4045,7 +4083,7 @@ def session_start(
4045
4083
  _session_sock_path(name).unlink(missing_ok=True)
4046
4084
 
4047
4085
  # Spawn daemon
4048
- daemon_script = json.dumps(
4086
+ daemon_config = json.dumps(
4049
4087
  {
4050
4088
  "name": name,
4051
4089
  "source": source,
@@ -4067,17 +4105,24 @@ def session_start(
4067
4105
  [
4068
4106
  sys.executable,
4069
4107
  "-c",
4070
- f"import mcp2cli; mcp2cli._run_session_daemon({json.dumps(daemon_script)})",
4108
+ (
4109
+ "import sys; config = sys.stdin.read(); "
4110
+ "import mcp2cli; mcp2cli._run_session_daemon(config)"
4111
+ ),
4071
4112
  ],
4072
4113
  start_new_session=True,
4073
4114
  stdout=log_file,
4074
4115
  stderr=log_file,
4075
4116
  stdin=subprocess.PIPE,
4076
4117
  )
4077
- # The daemon never reads stdin; closing the write end hands it EOF and
4078
- # leaves no descriptor behind in the parent.
4118
+ # Keep resolved headers, environment values and source URLs out of argv.
4119
+ # Read before importing mcp2cli so a large config cannot block our write
4120
+ # during slow imports, before the startup timeout below takes effect.
4121
+ # Closing the pipe completes the daemon's read and releases our descriptor.
4079
4122
  if proc.stdin is not None:
4080
- proc.stdin.close()
4123
+ # An early exit can break either write or close; report it below.
4124
+ with suppress(BrokenPipeError), proc.stdin:
4125
+ proc.stdin.write(daemon_config.encode("utf-8"))
4081
4126
 
4082
4127
  # Wait for socket to appear
4083
4128
  sock_path = _session_sock_path(name)
File without changes
File without changes