mcp2cli 3.7.0__tar.gz → 3.8.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.7.0
3
+ Version: 3.8.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>
@@ -197,6 +197,9 @@ mcp2cli --spec ./spec.json --auth-header "Authorization:Bearer tok_..." create-i
197
197
  # POST with JSON body from stdin
198
198
  echo '{"name": "Fido", "tag": "dog"}' | mcp2cli --spec ./spec.json create-pet --stdin
199
199
 
200
+ # Flags given with --stdin are merged into the JSON (a conflicting value is an error)
201
+ echo '{"name": "Fido"}' | mcp2cli --spec ./spec.json create-pet --tag dog --stdin
202
+
200
203
  # Local YAML spec
201
204
  mcp2cli --spec ./api.yaml --base-url http://localhost:8000 --list
202
205
  ```
@@ -232,13 +235,14 @@ mcp2cli bake create petstore --spec https://api.example.com/spec.json \
232
235
  --exclude "delete-*,update-*" --methods GET,POST --cache-ttl 7200
233
236
 
234
237
  # Create a baked tool from an MCP stdio server
235
- mcp2cli bake create mygit --mcp-stdio "npx @mcp/github" \
236
- --include "search-*,list-*" --exclude "delete-*"
238
+ mcp2cli bake create myfs --mcp-stdio "npx -y @modelcontextprotocol/server-filesystem /tmp" \
239
+ --include "search-*,list-*" --exclude "list-allowed-*"
237
240
 
238
241
  # Use a baked tool with @ prefix — no connection flags needed
239
242
  mcp2cli @petstore --list
240
243
  mcp2cli @petstore list-pets --limit 10
241
- mcp2cli @mygit search-repos --query "rust"
244
+ mcp2cli @myfs --list # search-files, list-directory, list-directory-with-sizes
245
+ mcp2cli @myfs search-files --path /tmp --pattern "**/*.md" # pattern is a glob, relative to --path
242
246
 
243
247
  # Manage baked tools
244
248
  mcp2cli bake list # show all baked tools
@@ -256,6 +260,8 @@ Filtering options:
256
260
 
257
261
  Configs are stored in `~/.config/mcp2cli/baked.json`. Override with `MCP2CLI_CONFIG_DIR`.
258
262
 
263
+ `bake show` masks the credential values it knows about — the OAuth client secret and every `--auth-header` value — while leaving `env:`/`file:` references readable so the config stays diagnosable. Values baked in with `--env` are printed as stored, so scrub those before pasting the output if an env var carries a token.
264
+
259
265
  ### Usage-aware tool ranking
260
266
 
261
267
  mcp2cli tracks tool invocations locally and uses that data to rank `--list` output, reducing token costs for LLM agents working with large servers.
@@ -435,7 +441,3 @@ protocol directly and import no SDK, so they hold across majors.
435
441
  ## License
436
442
 
437
443
  [MIT](LICENSE)
438
-
439
- ---
440
-
441
- <sub>mcp2cli builds on ideas from [CLIHub](https://kanyilmaz.me/2026/02/23/cli-vs-mcp.html) by Kagan Yilmaz (CLI-based tool access for token efficiency)</sub>
@@ -178,6 +178,9 @@ mcp2cli --spec ./spec.json --auth-header "Authorization:Bearer tok_..." create-i
178
178
  # POST with JSON body from stdin
179
179
  echo '{"name": "Fido", "tag": "dog"}' | mcp2cli --spec ./spec.json create-pet --stdin
180
180
 
181
+ # Flags given with --stdin are merged into the JSON (a conflicting value is an error)
182
+ echo '{"name": "Fido"}' | mcp2cli --spec ./spec.json create-pet --tag dog --stdin
183
+
181
184
  # Local YAML spec
182
185
  mcp2cli --spec ./api.yaml --base-url http://localhost:8000 --list
183
186
  ```
@@ -213,13 +216,14 @@ mcp2cli bake create petstore --spec https://api.example.com/spec.json \
213
216
  --exclude "delete-*,update-*" --methods GET,POST --cache-ttl 7200
214
217
 
215
218
  # Create a baked tool from an MCP stdio server
216
- mcp2cli bake create mygit --mcp-stdio "npx @mcp/github" \
217
- --include "search-*,list-*" --exclude "delete-*"
219
+ mcp2cli bake create myfs --mcp-stdio "npx -y @modelcontextprotocol/server-filesystem /tmp" \
220
+ --include "search-*,list-*" --exclude "list-allowed-*"
218
221
 
219
222
  # Use a baked tool with @ prefix — no connection flags needed
220
223
  mcp2cli @petstore --list
221
224
  mcp2cli @petstore list-pets --limit 10
222
- mcp2cli @mygit search-repos --query "rust"
225
+ mcp2cli @myfs --list # search-files, list-directory, list-directory-with-sizes
226
+ mcp2cli @myfs search-files --path /tmp --pattern "**/*.md" # pattern is a glob, relative to --path
223
227
 
224
228
  # Manage baked tools
225
229
  mcp2cli bake list # show all baked tools
@@ -237,6 +241,8 @@ Filtering options:
237
241
 
238
242
  Configs are stored in `~/.config/mcp2cli/baked.json`. Override with `MCP2CLI_CONFIG_DIR`.
239
243
 
244
+ `bake show` masks the credential values it knows about — the OAuth client secret and every `--auth-header` value — while leaving `env:`/`file:` references readable so the config stays diagnosable. Values baked in with `--env` are printed as stored, so scrub those before pasting the output if an env var carries a token.
245
+
240
246
  ### Usage-aware tool ranking
241
247
 
242
248
  mcp2cli tracks tool invocations locally and uses that data to rank `--list` output, reducing token costs for LLM agents working with large servers.
@@ -416,7 +422,3 @@ protocol directly and import no SDK, so they hold across majors.
416
422
  ## License
417
423
 
418
424
  [MIT](LICENSE)
419
-
420
- ---
421
-
422
- <sub>mcp2cli builds on ideas from [CLIHub](https://kanyilmaz.me/2026/02/23/cli-vs-mcp.html) by Kagan Yilmaz (CLI-based tool access for token efficiency)</sub>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp2cli"
3
- version = "3.7.0"
3
+ version = "3.8.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.7.0"
3
+ version = "3.8.0"
4
4
  description = "Turn any MCP server or OpenAPI spec into a CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -20,6 +20,7 @@ import signal
20
20
  import socket
21
21
  import subprocess
22
22
  import sys
23
+ import tempfile
23
24
  import threading
24
25
  import time
25
26
  import webbrowser
@@ -27,7 +28,7 @@ from contextlib import asynccontextmanager
27
28
  from dataclasses import dataclass, field
28
29
  from http.server import HTTPServer, BaseHTTPRequestHandler
29
30
  from pathlib import Path
30
- from urllib.parse import parse_qs, urlparse
31
+ from urllib.parse import parse_qs, quote, unquote, urlparse
31
32
 
32
33
  from datetime import datetime, timezone
33
34
 
@@ -64,6 +65,8 @@ class ParamDef:
64
65
  location: str = "body" # path|query|header|body|tool_input
65
66
  schema: dict = field(default_factory=dict)
66
67
  cli_name: str | None = None # collision-free argparse flag name
68
+ style: str | None = None # OpenAPI parameter serialization
69
+ explode: bool = False
67
70
 
68
71
 
69
72
  @dataclass
@@ -180,6 +183,72 @@ def read_stdin_json(context: str):
180
183
  )
181
184
  sys.exit(1)
182
185
 
186
+ _STDIN_MERGE_LOCATIONS = ("body", "tool_input", "graphql_arg")
187
+
188
+
189
+ def _merge_stdin_flag_args(data, cmd, args, context: str):
190
+ """Merge explicitly passed parameter flags into the --stdin JSON object.
191
+
192
+ Without this, --stdin used the JSON verbatim and silently dropped every
193
+ parameter flag given alongside it. A flag that disagrees with the same key
194
+ in the JSON is an error rather than a silent override in either direction.
195
+ """
196
+ flags = {}
197
+ for p in cmd.params:
198
+ val = getattr(args, _param_dest(p), None)
199
+ if val is None:
200
+ continue
201
+ flag = f"--{p.cli_name or p.name}"
202
+ if p.location == "file":
203
+ print(f"Error: {flag} cannot be combined with --stdin.", file=sys.stderr)
204
+ sys.exit(1)
205
+ if p.location in _STDIN_MERGE_LOCATIONS:
206
+ flags[p.original_name] = (flag, coerce_value(val, p.schema))
207
+ if not flags:
208
+ return data
209
+ if not isinstance(data, dict):
210
+ names = ", ".join(flag for flag, _ in flags.values())
211
+ print(
212
+ f"Error: {names} can only be merged into a JSON object on --stdin "
213
+ f"for {context}.",
214
+ file=sys.stderr,
215
+ )
216
+ sys.exit(1)
217
+ merged = dict(data)
218
+ conflicts = []
219
+ for key, (flag, value) in flags.items():
220
+ if key in merged and merged[key] != value:
221
+ conflicts.append(
222
+ f"{flag} {json.dumps(value)} vs \"{key}\": {json.dumps(merged[key])}"
223
+ )
224
+ merged[key] = value
225
+ if conflicts:
226
+ print(
227
+ f"Error: flags conflict with the --stdin JSON for {context}: "
228
+ + "; ".join(conflicts)
229
+ + ". Pass each value in one place, or make them agree.",
230
+ file=sys.stderr,
231
+ )
232
+ sys.exit(1)
233
+ return merged
234
+
235
+
236
+ def split_shell_command(command: str) -> list[str]:
237
+ """Split a shell-style command string into an argv list.
238
+
239
+ ``shlex.split`` defaults to POSIX rules, which treat backslash as an escape
240
+ and therefore mangle native Windows paths (``D:\\tools\\python.exe`` becomes
241
+ ``D:toolspython.exe``). On Windows keep the backslashes and strip any
242
+ surrounding quotes instead.
243
+ """
244
+ if os.name == "nt":
245
+ parts = shlex.split(command, posix=False)
246
+ return [
247
+ p[1:-1] if len(p) >= 2 and p[0] == p[-1] and p[0] in "\"'" else p
248
+ for p in parts
249
+ ]
250
+ return shlex.split(command)
251
+
183
252
 
184
253
  def _normalize_schema_type(t):
185
254
  """JSON Schema allows "type": ["integer", "null"] (array form). Reduce it
@@ -190,6 +259,87 @@ def _normalize_schema_type(t):
190
259
  return t
191
260
 
192
261
 
262
+ def _collapse_nullable(schema: dict) -> dict:
263
+ """Pydantic (FastMCP, FastAPI) writes Optional[T] as
264
+ {"anyOf": [{T}, {"type": "null"}]} with no top-level "type". Reduce that
265
+ to {T}, keeping outer keys such as description; anything else passes
266
+ through."""
267
+ if not isinstance(schema, dict) or "type" in schema:
268
+ return schema
269
+ unions = [k for k in ("anyOf", "oneOf") if k in schema]
270
+ if len(unions) != 1 or not isinstance(schema[unions[0]], list):
271
+ return schema
272
+ branches = schema[unions[0]]
273
+ concrete = [b for b in branches if not (isinstance(b, dict) and b.get("type") == "null")]
274
+ if len(branches) != 2 or len(concrete) != 1 or not isinstance(concrete[0], dict):
275
+ return schema
276
+ outer = {k: v for k, v in schema.items() if k != unions[0]}
277
+ return {**concrete[0], **outer}
278
+
279
+
280
+ def _union_branch_types(schema: dict) -> set[str]:
281
+ """Collect the JSON types an anyOf/oneOf union admits, recursing into
282
+ nested unions. A branch without a usable "type" (a bare const, a $ref)
283
+ counts as "other": it may accept non-numeric values."""
284
+ types: set[str] = set()
285
+ alternatives = schema.get("anyOf") or schema.get("oneOf") or []
286
+ if not isinstance(alternatives, list):
287
+ return types
288
+ for alt in alternatives:
289
+ if not isinstance(alt, dict):
290
+ types.add("other")
291
+ continue
292
+ t = alt.get("type")
293
+ if isinstance(t, list):
294
+ types.update(x for x in t if isinstance(x, str))
295
+ elif isinstance(t, str):
296
+ types.add(t)
297
+ elif alt.get("anyOf") or alt.get("oneOf"):
298
+ types |= _union_branch_types(alt)
299
+ else:
300
+ types.add("other")
301
+ return types
302
+
303
+
304
+ def _union_prefer_numeric(schema: dict):
305
+ """Pick an argparse type for an anyOf/oneOf union with a numeric branch.
306
+
307
+ Returns None when no branch is integer/number, so callers fall back to
308
+ default logic. A purely numeric union gets int or float. A union that also
309
+ admits other values (the common MCP pattern
310
+ ``anyOf: [{type: string, const: ""}, {type: integer, minimum: 1}]``, or a
311
+ plain ``int | str``) gets a converter that sends the value as a number
312
+ when it parses as one and keeps the string otherwise, so ``--precision 6``
313
+ goes out as 6 while ``--id abc`` still goes out as "abc".
314
+ """
315
+ types = _union_branch_types(schema)
316
+ converters = []
317
+ if "integer" in types:
318
+ converters.append(int)
319
+ if "number" in types:
320
+ converters.append(float)
321
+ if not converters:
322
+ return None
323
+ keeps_string = bool(types - {"integer", "number", "null"})
324
+ if len(converters) == 1 and not keeps_string:
325
+ return converters[0]
326
+
327
+ def convert(value: str):
328
+ for conv in converters:
329
+ try:
330
+ return conv(value)
331
+ except ValueError:
332
+ pass
333
+ if keeps_string:
334
+ return value
335
+ raise ValueError(value)
336
+
337
+ convert.__name__ = "|".join(
338
+ [c.__name__ for c in converters] + (["str"] if keeps_string else [])
339
+ )
340
+ return convert
341
+
342
+
193
343
  def schema_type_to_python(schema: dict) -> tuple[type | None, str]:
194
344
  t = _normalize_schema_type(schema.get("type"))
195
345
  if t == "integer":
@@ -212,6 +362,13 @@ def schema_type_to_python(schema: dict) -> tuple[type | None, str]:
212
362
  return int, ""
213
363
  if all(isinstance(v, (int, float)) for v in enum):
214
364
  return float, ""
365
+ # No "type" but an anyOf/oneOf union with a numeric branch: send numbers
366
+ # as numbers (see _union_prefer_numeric). A top-level "type" constrains
367
+ # every branch, so it wins and is handled above.
368
+ if t is None and (schema.get("anyOf") is not None or schema.get("oneOf") is not None):
369
+ union_type = _union_prefer_numeric(schema)
370
+ if union_type is not None:
371
+ return union_type, ""
215
372
  return str, ""
216
373
 
217
374
 
@@ -414,17 +571,20 @@ async def _list_tools_page(session, cursor: str | None):
414
571
  return await session.list_tools(params=params)
415
572
 
416
573
 
417
- def _authorization_code_result(code: str, state: str | None):
574
+ def _authorization_code_result(code: str, state: str | None, iss: str | None = None):
418
575
  """Wrap a callback result in whatever ``callback_handler`` must return.
419
576
 
420
577
  v1 expects a plain ``(code, state)`` tuple; v2 expects an
421
- ``AuthorizationCodeResult`` model.
578
+ ``AuthorizationCodeResult`` model. ``iss`` is the RFC 9207
579
+ authorization-response issuer: when the authorization server advertises it
580
+ in its metadata, the SDK validates the redirect's ``iss`` and fails the
581
+ flow if the value never reaches it.
422
582
  """
423
583
  try:
424
584
  from mcp.shared.auth import AuthorizationCodeResult
425
585
  except ImportError:
426
586
  return (code, state)
427
- return AuthorizationCodeResult(code=code, state=state)
587
+ return AuthorizationCodeResult(code=code, state=state, iss=iss)
428
588
 
429
589
 
430
590
  def _ensure_utf8_output() -> None:
@@ -451,7 +611,6 @@ def _ensure_utf8_output() -> None:
451
611
  pass
452
612
 
453
613
 
454
-
455
614
  def _apply_head(data, n: int):
456
615
  """Truncate data to first N elements (array) or return as-is (dict/scalar)."""
457
616
  if isinstance(data, list):
@@ -632,18 +791,62 @@ def cache_key_for(config: dict) -> str:
632
791
  ).hexdigest()[:16]
633
792
 
634
793
  def load_cached(key: str, ttl: int) -> dict | None:
794
+ """Read a cache entry, or None when it is missing, stale or unusable.
795
+
796
+ A corrupt entry is treated as a miss rather than an error. The cache is
797
+ an optimisation, so a bad file should cost one refetch -- not every
798
+ later invocation, until someone works out which file to delete by hand.
799
+ This matches _load_usage() and _load_baked_all(), which already tolerate
800
+ exactly this.
801
+ """
635
802
  path = CACHE_DIR / f"{key}.json"
636
803
  if not path.exists():
637
804
  return None
638
- age = time.time() - path.stat().st_mtime
639
- if age >= ttl:
805
+ try:
806
+ age = time.time() - path.stat().st_mtime
807
+ if age >= ttl:
808
+ return None
809
+ return json.loads(path.read_text())
810
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
640
811
  return None
641
- return json.loads(path.read_text())
642
812
 
643
813
 
644
814
  def save_cache(key: str, data: dict):
815
+ """Write a cache entry atomically.
816
+
817
+ A plain write_text() is not atomic: an interrupt, a full disk or two
818
+ concurrent mcp2cli runs can leave a half-written file behind, which is
819
+ how a cache entry becomes corrupt in the first place. Serialise first,
820
+ write to a freshly created private temp file in the same directory, then
821
+ os.replace() it into place -- readers only ever observe the old file or
822
+ the new one.
823
+
824
+ The temp file comes from tempfile.mkstemp(), so every writer gets storage
825
+ of its own. A pid-derived name does not: two threads of one process share
826
+ it, and each open() truncates whatever the other has written so far, so
827
+ the file that gets replaced into place is a splice of both payloads.
828
+
829
+ Any failure -- including Ctrl-C -- removes the temp file and leaves the
830
+ entry already on disk untouched.
831
+ """
645
832
  CACHE_DIR.mkdir(parents=True, exist_ok=True)
646
- (CACHE_DIR / f"{key}.json").write_text(json.dumps(data))
833
+ path = CACHE_DIR / f"{key}.json"
834
+ payload = json.dumps(data)
835
+ fd, tmp = tempfile.mkstemp(
836
+ dir=str(CACHE_DIR), prefix=f"{path.name}.", suffix=".tmp"
837
+ )
838
+ try:
839
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
840
+ fh.write(payload)
841
+ fh.flush()
842
+ os.fsync(fh.fileno())
843
+ os.replace(tmp, path)
844
+ except BaseException:
845
+ try:
846
+ os.unlink(tmp)
847
+ except OSError:
848
+ pass
849
+ raise
647
850
 
648
851
 
649
852
  # ---------------------------------------------------------------------------
@@ -837,6 +1040,7 @@ class _CallbackHandler(BaseHTTPRequestHandler):
837
1040
 
838
1041
  auth_code: str | None = None
839
1042
  state: str | None = None
1043
+ iss: str | None = None
840
1044
  error: str | None = None
841
1045
  done = threading.Event()
842
1046
 
@@ -849,6 +1053,10 @@ class _CallbackHandler(BaseHTTPRequestHandler):
849
1053
  elif "code" in params:
850
1054
  _CallbackHandler.auth_code = params["code"][0]
851
1055
  _CallbackHandler.state = params.get("state", [None])[0]
1056
+ # RFC 9207: an absent issuer must stay absent -- the SDK only
1057
+ # rejects a missing ``iss`` when the server advertised it, and an
1058
+ # empty ``iss=`` would instead fail as a mismatch.
1059
+ _CallbackHandler.iss = params.get("iss", [None])[0] or None
852
1060
 
853
1061
  self.send_response(200)
854
1062
  self.send_header("Content-Type", "text/html")
@@ -875,17 +1083,22 @@ def _find_free_port() -> int:
875
1083
  return s.getsockname()[1]
876
1084
 
877
1085
 
878
- def _parse_oauth_callback_input(text: str) -> tuple[str, str]:
879
- """Extract ``(code, state)`` from a pasted OAuth callback URL.
1086
+ def _parse_oauth_callback_input(text: str) -> tuple[str, str, str | None]:
1087
+ """Extract ``(code, state, iss)`` from a pasted OAuth callback URL.
880
1088
 
881
1089
  Accepts the full redirect target the browser landed on
882
1090
  (``http://127.0.0.1:1234/callback?code=...&state=...``) or just its query
883
1091
  string. PKCE and CSRF verification stay in the MCP SDK -- we only hand it
884
- the two values it asks for. The SDK compares ``state`` against the one it
1092
+ the values it asks for. The SDK compares ``state`` against the one it
885
1093
  generated with ``secrets.compare_digest`` and treats ``None`` as a
886
1094
  mismatch, so a paste missing ``state`` is rejected here with a readable
887
1095
  message instead of surfacing as an opaque
888
1096
  ``State parameter mismatch: None != ...``.
1097
+
1098
+ ``iss`` is optional in the redirect but mandatory to forward: under
1099
+ RFC 9207 the SDK validates it whenever the authorization server advertises
1100
+ it, and dropping it fails the flow with "Authorization response missing
1101
+ iss parameter advertised by the authorization server".
889
1102
  """
890
1103
  text = text.strip().strip("'\"")
891
1104
  if not text:
@@ -905,10 +1118,13 @@ def _parse_oauth_callback_input(text: str) -> tuple[str, str]:
905
1118
  "That URL has no 'state' parameter. Paste the URL unmodified -- "
906
1119
  "the MCP SDK verifies state to prevent CSRF and rejects a missing one."
907
1120
  )
908
- return params["code"][0], params["state"][0]
1121
+ # ``or None``: an empty ``iss=`` is treated as absent, which the SDK
1122
+ # accepts unless the server advertised the parameter, rather than as a
1123
+ # mismatching issuer.
1124
+ return params["code"][0], params["state"][0], params.get("iss", [None])[0] or None
909
1125
 
910
1126
 
911
- def _prompt_oauth_callback(attempts: int = 3) -> tuple[str, str]:
1127
+ def _prompt_oauth_callback(attempts: int = 3) -> tuple[str, str, str | None]:
912
1128
  """Read the OAuth callback URL from stdin.
913
1129
 
914
1130
  For hosts with no reachable browser -- a VPS over SSH, a container.
@@ -932,8 +1148,6 @@ def _prompt_oauth_callback(attempts: int = 3) -> tuple[str, str]:
932
1148
  print(f"{exc} ({remaining} attempt(s) left)", file=sys.stderr)
933
1149
 
934
1150
 
935
-
936
-
937
1151
  def _get_cached_redirect_uri(storage: "FileTokenStorage") -> str | None:
938
1152
  """Return the cached client's redirect_uri, if any.
939
1153
 
@@ -1286,12 +1500,13 @@ def build_oauth_provider(
1286
1500
  )
1287
1501
 
1288
1502
  async def callback_handler():
1289
- code, state = await anyio.to_thread.run_sync(_prompt_oauth_callback)
1290
- return _authorization_code_result(code, state)
1503
+ code, state, iss = await anyio.to_thread.run_sync(_prompt_oauth_callback)
1504
+ return _authorization_code_result(code, state, iss)
1291
1505
  else:
1292
1506
  # Reset callback handler state
1293
1507
  _CallbackHandler.auth_code = None
1294
1508
  _CallbackHandler.state = None
1509
+ _CallbackHandler.iss = None
1295
1510
  _CallbackHandler.error = None
1296
1511
  _CallbackHandler.done = threading.Event()
1297
1512
 
@@ -1324,7 +1539,9 @@ def build_oauth_provider(
1324
1539
  if not _CallbackHandler.auth_code:
1325
1540
  raise RuntimeError("No authorization code received")
1326
1541
  return _authorization_code_result(
1327
- _CallbackHandler.auth_code, _CallbackHandler.state
1542
+ _CallbackHandler.auth_code,
1543
+ _CallbackHandler.state,
1544
+ _CallbackHandler.iss,
1328
1545
  )
1329
1546
 
1330
1547
  return _RobustOAuthClientProvider(
@@ -1341,8 +1558,70 @@ def build_oauth_provider(
1341
1558
  # ---------------------------------------------------------------------------
1342
1559
 
1343
1560
 
1561
+ def _json_pointer_unescape(token: str) -> str:
1562
+ """Decode RFC 6901 escapes: ``~1`` is ``/`` and ``~0`` is ``~``.
1563
+
1564
+ Order matters -- ``~1`` first, or ``~01`` would wrongly become ``/``.
1565
+ """
1566
+ return token.replace("~1", "/").replace("~0", "~")
1567
+
1568
+
1569
+ def _json_pointer_tokens(fragment: str) -> list[str]:
1570
+ """Split a URI fragment into decoded JSON Pointer reference tokens.
1571
+
1572
+ A ``$ref`` is a URI, so RFC 6901 section 6 percent-encodes the pointer it
1573
+ carries in the fragment. Decoding therefore unwinds the two layers in the
1574
+ order they were applied: percent-decode the whole fragment first, then
1575
+ split on the ``/`` separators it now spells out, then decode the ``~``
1576
+ escapes the encoding was applied over. A member name holding a ``/`` is
1577
+ ``~1`` (or ``%7E1``), a ``~`` is ``~0``, and a literal ``%`` is ``%25``.
1578
+ """
1579
+ return [_json_pointer_unescape(t) for t in unquote(fragment).split("/")]
1580
+
1581
+
1582
+ # RFC 6901: an array index is "0", or a digit string with no leading zero.
1583
+ # ``int()`` is far more generous -- it accepts "-1" (which would silently
1584
+ # select the *last* element), "+1", "01", " 1 ", "1_0" and Unicode digits --
1585
+ # so a token is screened before conversion.
1586
+ _ARRAY_INDEX_RE = re.compile(r"0|[1-9][0-9]*")
1587
+
1588
+
1589
+ def _array_index(token: str, length: int) -> int | None:
1590
+ """Index *token* addresses in a list of *length*, or None if it does not."""
1591
+ if not length or not _ARRAY_INDEX_RE.fullmatch(token):
1592
+ return None
1593
+ # Both strings are canonical decimals, so (width, lexicographic) orders
1594
+ # them numerically. Comparing them before converting keeps a spec from
1595
+ # handing int() an arbitrarily long digit run, where CPython's
1596
+ # integer-string conversion limit -- which a host may raise or disable --
1597
+ # would decide what happens instead of us.
1598
+ largest = str(length - 1)
1599
+ if (len(token), token) > (len(largest), largest):
1600
+ return None
1601
+ return int(token)
1602
+
1603
+
1604
+ def _json_pointer_lookup(root, tokens: list[str]):
1605
+ """Walk *root* by decoded pointer tokens. Raises LookupError on a dangle."""
1606
+ target = root
1607
+ for token in tokens:
1608
+ if isinstance(target, dict):
1609
+ if token not in target:
1610
+ raise LookupError(token)
1611
+ target = target[token]
1612
+ elif isinstance(target, list):
1613
+ index = _array_index(token, len(target))
1614
+ if index is None:
1615
+ raise LookupError(token)
1616
+ target = target[index]
1617
+ else:
1618
+ raise LookupError(token)
1619
+ return target
1620
+
1621
+
1344
1622
  def resolve_refs(spec: dict) -> dict:
1345
1623
  spec = copy.deepcopy(spec)
1624
+ warned: set[str] = set()
1346
1625
 
1347
1626
  def _resolve(node, root, seen):
1348
1627
  if isinstance(node, dict):
@@ -1352,10 +1631,23 @@ def resolve_refs(spec: dict) -> dict:
1352
1631
  return node
1353
1632
  seen = seen | {ref}
1354
1633
  if ref.startswith("#/"):
1355
- parts = ref[2:].split("/")
1356
- target = root
1357
- for p in parts:
1358
- target = target[p]
1634
+ try:
1635
+ target = _json_pointer_lookup(
1636
+ root, _json_pointer_tokens(ref[2:])
1637
+ )
1638
+ except LookupError:
1639
+ # A dangling reference in a spec we did not write must
1640
+ # not take down the CLI. Leave the node unresolved --
1641
+ # the same shape external refs and cycles already
1642
+ # produce -- and say so once.
1643
+ if ref not in warned:
1644
+ warned.add(ref)
1645
+ print(
1646
+ f"Warning: unresolvable $ref {ref!r} in spec; "
1647
+ "leaving it unresolved.",
1648
+ file=sys.stderr,
1649
+ )
1650
+ return node
1359
1651
  return _resolve(copy.deepcopy(target), root, seen)
1360
1652
  return node
1361
1653
  return {k: _resolve(v, root, seen) for k, v in node.items()}
@@ -1424,32 +1716,98 @@ def load_openapi_spec(
1424
1716
  # ---------------------------------------------------------------------------
1425
1717
 
1426
1718
 
1719
+ def _merge_openapi_parameters(path_level, operation_level) -> list[dict]:
1720
+ """Combine path-item parameters with an operation's own.
1721
+
1722
+ OpenAPI 3.x declares that ``parameters`` on a path item apply to every
1723
+ operation under that path. An operation may *override* an inherited
1724
+ parameter by redeclaring the same ``name``/``in`` pair, but it cannot
1725
+ remove one. Operation-level entries therefore win on collision, and the
1726
+ inherited ones are kept otherwise.
1727
+
1728
+ Malformed entries (non-dicts, or missing ``name``) are skipped rather
1729
+ than raising: a spec we did not write should not crash the CLI.
1730
+ """
1731
+ merged: dict[tuple[str, str], dict] = {}
1732
+ for group in (path_level, operation_level):
1733
+ if not isinstance(group, list):
1734
+ continue
1735
+ for param in group:
1736
+ if not isinstance(param, dict) or "name" not in param:
1737
+ continue
1738
+ merged[(param["name"], param.get("in", "query"))] = param
1739
+ return list(merged.values())
1740
+ _OPENAPI_METHODS = ("get", "post", "put", "delete", "patch")
1741
+
1742
+
1743
+ def _openapi_operation_name(path: str, method: str, details: dict) -> str:
1744
+ """The natural CLI name for an operation, before collision handling."""
1745
+ op_id = details.get("operationId")
1746
+ if op_id:
1747
+ return to_kebab(op_id)
1748
+ slug = path.strip("/").replace("/", "-").replace("{", "").replace("}", "")
1749
+ return f"{method}-{slug}" if slug else method
1750
+
1751
+
1752
+ def _openapi_natural_names(spec: dict) -> set[str]:
1753
+ """Every operation's natural name, so an alias can never shadow one."""
1754
+ names: set[str] = set()
1755
+ for path, methods in spec.get("paths", {}).items():
1756
+ if not isinstance(methods, dict):
1757
+ continue
1758
+ for method, details in methods.items():
1759
+ if method not in _OPENAPI_METHODS or not isinstance(details, dict):
1760
+ continue
1761
+ names.add(_openapi_operation_name(path, method, details))
1762
+ return names
1763
+
1764
+
1765
+ def _unique_openapi_name(
1766
+ name: str, method: str, reserved: set[str], used: set[str]
1767
+ ) -> str:
1768
+ """Return a free CLI name for an operation.
1769
+
1770
+ The first operation to claim a name keeps it. A later collision is
1771
+ disambiguated by the HTTP method, and then by a numeric suffix if that is
1772
+ taken too. A generated alias is never allowed to equal another
1773
+ operation's natural name, which would make that operation unreachable.
1774
+ """
1775
+ if name not in used:
1776
+ return name
1777
+ candidate = f"{name}-{method}"
1778
+ if candidate not in reserved and candidate not in used:
1779
+ return candidate
1780
+ suffix = 2
1781
+ while True:
1782
+ numbered = f"{candidate}-{suffix}"
1783
+ if numbered not in reserved and numbered not in used:
1784
+ return numbered
1785
+ suffix += 1
1786
+
1787
+
1427
1788
  def extract_openapi_commands(spec: dict) -> list[CommandDef]:
1428
1789
  commands: list[CommandDef] = []
1429
- seen_names: dict[str, int] = {}
1790
+ natural_names = _openapi_natural_names(spec)
1791
+ used_names: set[str] = set()
1430
1792
 
1431
1793
  for path, methods in spec.get("paths", {}).items():
1432
1794
  if not isinstance(methods, dict):
1433
1795
  continue
1796
+ # Applies to every operation under this path (OpenAPI 3.x).
1797
+ shared_params = methods.get("parameters")
1434
1798
  for method, details in methods.items():
1435
- if method not in ("get", "post", "put", "delete", "patch"):
1799
+ if method not in _OPENAPI_METHODS:
1436
1800
  continue
1437
1801
  if not isinstance(details, dict):
1438
1802
  continue
1439
1803
 
1440
- op_id = details.get("operationId")
1441
- if op_id:
1442
- name = to_kebab(op_id)
1443
- else:
1444
- slug = (
1445
- path.strip("/").replace("/", "-").replace("{", "").replace("}", "")
1446
- )
1447
- name = f"{method}-{slug}" if slug else method
1448
-
1449
- if name in seen_names:
1450
- seen_names[name] += 1
1451
- name = f"{name}-{method}"
1452
- seen_names[name] = 1
1804
+ name = _unique_openapi_name(
1805
+ _openapi_operation_name(path, method, details),
1806
+ method,
1807
+ natural_names,
1808
+ used_names,
1809
+ )
1810
+ used_names.add(name)
1453
1811
 
1454
1812
  desc = (
1455
1813
  details.get("summary")
@@ -1458,9 +1816,12 @@ def extract_openapi_commands(spec: dict) -> list[CommandDef]:
1458
1816
  )
1459
1817
  params: list[ParamDef] = []
1460
1818
 
1461
- # Parameters (path, query, header)
1462
- for param in details.get("parameters", []):
1463
- schema = param.get("schema", {})
1819
+ # Parameters (path, query, header) -- inherited from the
1820
+ # path item, then overridden by the operation's own.
1821
+ for param in _merge_openapi_parameters(
1822
+ shared_params, details.get("parameters")
1823
+ ):
1824
+ schema = _collapse_nullable(param.get("schema", {}))
1464
1825
  py_type, suffix = schema_type_to_python(schema)
1465
1826
  p = ParamDef(
1466
1827
  name=to_kebab(param["name"]),
@@ -1471,6 +1832,8 @@ def extract_openapi_commands(spec: dict) -> list[CommandDef]:
1471
1832
  choices=schema.get("enum"),
1472
1833
  location=param.get("in", "query"),
1473
1834
  schema=schema,
1835
+ style=param.get("style"),
1836
+ explode=param.get("explode", False),
1474
1837
  )
1475
1838
  params.append(p)
1476
1839
 
@@ -1502,6 +1865,7 @@ def extract_openapi_commands(spec: dict) -> list[CommandDef]:
1502
1865
  has_body = bool(properties)
1503
1866
 
1504
1867
  for prop_name, prop_schema in properties.items():
1868
+ prop_schema = _collapse_nullable(prop_schema)
1505
1869
  is_binary = (
1506
1870
  cmd_content_type == "multipart/form-data"
1507
1871
  and prop_schema.get("format") == "binary"
@@ -1579,6 +1943,7 @@ def extract_mcp_commands(tools: list[dict]) -> list[CommandDef]:
1579
1943
  params: list[ParamDef] = []
1580
1944
 
1581
1945
  for prop_name, prop_schema in schema.get("properties", {}).items():
1946
+ prop_schema = _collapse_nullable(prop_schema)
1582
1947
  py_type, suffix = schema_type_to_python(prop_schema)
1583
1948
  params.append(
1584
1949
  ParamDef(
@@ -1883,6 +2248,29 @@ def _build_graphql_param(arg: dict, types_by_name: dict) -> ParamDef:
1883
2248
  )
1884
2249
 
1885
2250
 
2251
+ def _unique_graphql_name(
2252
+ name: str, op_type: str, reserved: set[str], used: set[str]
2253
+ ) -> str:
2254
+ """Return a free CLI name for a GraphQL field.
2255
+
2256
+ The first field to claim a name keeps it. A later collision is
2257
+ disambiguated by the operation type, then by a numeric suffix if that is
2258
+ taken too. A generated alias is never allowed to equal another field's
2259
+ own name, which would make that field unreachable.
2260
+ """
2261
+ if name not in used:
2262
+ return name
2263
+ candidate = f"{op_type}-{name}"
2264
+ if candidate not in reserved and candidate not in used:
2265
+ return candidate
2266
+ suffix = 2
2267
+ while True:
2268
+ numbered = f"{candidate}-{suffix}"
2269
+ if numbered not in reserved and numbered not in used:
2270
+ return numbered
2271
+ suffix += 1
2272
+
2273
+
1886
2274
  def extract_graphql_commands(schema: dict) -> list[CommandDef]:
1887
2275
  """Convert introspection schema into CommandDef list."""
1888
2276
  types_by_name = {t["name"]: t for t in schema.get("types", []) if t.get("name")}
@@ -1891,46 +2279,52 @@ def extract_graphql_commands(schema: dict) -> list[CommandDef]:
1891
2279
  mutation_type_name = (schema.get("mutationType") or {}).get("name")
1892
2280
 
1893
2281
  commands: list[CommandDef] = []
1894
- seen_names: set[str] = set()
1895
2282
 
1896
2283
  query_fields = types_by_name.get(query_type_name, {}).get("fields", []) if query_type_name else []
1897
2284
  mutation_fields = types_by_name.get(mutation_type_name, {}).get("fields", []) if mutation_type_name else []
1898
2285
  collisions = _detect_field_collisions(query_fields, mutation_fields)
1899
2286
 
1900
- for op_type, type_name, fields in [
1901
- ("query", query_type_name, query_fields),
1902
- ("mutation", mutation_type_name, mutation_fields),
1903
- ]:
2287
+ # Pass 1: every field's natural CLI name. A field whose *wire* name exists
2288
+ # under both Query and Mutation is prefixed with its operation type on both
2289
+ # sides, which is the pre-existing symmetric naming and is kept as-is.
2290
+ entries: list[tuple[str, str, dict]] = []
2291
+ for op_type, fields in (("query", query_fields), ("mutation", mutation_fields)):
1904
2292
  for field_def in fields:
1905
2293
  field_name = field_def["name"]
1906
2294
  if field_name.startswith("__"):
1907
2295
  continue
1908
-
1909
- cli_name = to_kebab(field_name)
2296
+ base = to_kebab(field_name)
1910
2297
  if field_name in collisions:
1911
- cli_name = f"{op_type}-{cli_name}"
1912
-
1913
- if cli_name in seen_names:
1914
- cli_name = f"{op_type}-{cli_name}"
1915
- seen_names.add(cli_name)
1916
-
1917
- desc = field_def.get("description") or f"{op_type} {field_name}"
1918
- params = [
1919
- _build_graphql_param(arg, types_by_name)
1920
- for arg in field_def.get("args", [])
1921
- ]
2298
+ base = f"{op_type}-{base}"
2299
+ entries.append((base, op_type, field_def))
2300
+
2301
+ # Pass 2: allocate against the complete set of natural names, so a
2302
+ # generated alias can never shadow a field that owns that name.
2303
+ reserved = {base for base, _, _ in entries}
2304
+ used: set[str] = set()
2305
+
2306
+ for base, op_type, field_def in entries:
2307
+ field_name = field_def["name"]
2308
+ cli_name = _unique_graphql_name(base, op_type, reserved, used)
2309
+ used.add(cli_name)
2310
+
2311
+ desc = field_def.get("description") or f"{op_type} {field_name}"
2312
+ params = [
2313
+ _build_graphql_param(arg, types_by_name)
2314
+ for arg in field_def.get("args", [])
2315
+ ]
1922
2316
 
1923
- commands.append(
1924
- CommandDef(
1925
- name=cli_name,
1926
- description=desc,
1927
- params=params,
1928
- has_body=bool(params),
1929
- graphql_operation_type=op_type,
1930
- graphql_field_name=field_name,
1931
- graphql_return_type=field_def.get("type"),
1932
- )
2317
+ commands.append(
2318
+ CommandDef(
2319
+ name=cli_name,
2320
+ description=desc,
2321
+ params=params,
2322
+ has_body=bool(params),
2323
+ graphql_operation_type=op_type,
2324
+ graphql_field_name=field_name,
2325
+ graphql_return_type=field_def.get("type"),
1933
2326
  )
2327
+ )
1934
2328
 
1935
2329
  return commands
1936
2330
 
@@ -2012,7 +2406,9 @@ def _build_graphql_document(
2012
2406
 
2013
2407
  # Build variables dict from args
2014
2408
  if getattr(args, "stdin", False) is True:
2015
- variables = read_stdin_json("GraphQL variables")
2409
+ variables = _merge_stdin_flag_args(
2410
+ read_stdin_json("GraphQL variables"), cmd, args, "GraphQL variables"
2411
+ )
2016
2412
  else:
2017
2413
  variables = {}
2018
2414
  for p in cmd.params:
@@ -2407,6 +2803,21 @@ def _bake_list() -> None:
2407
2803
  print(f"{name:<20} {st:<10} {src:<50}")
2408
2804
 
2409
2805
 
2806
+ def _mask_secret(value: str) -> str:
2807
+ """Mask a secret for display.
2808
+
2809
+ ``env:`` and ``file:`` values are references, not secrets, and stay
2810
+ readable so the config remains diagnosable.
2811
+ """
2812
+ if value.startswith("env:") or value.startswith("file:"):
2813
+ return value
2814
+ return value[:4] + "****" if len(value) > 4 else "****"
2815
+
2816
+
2817
+ # Baked-config fields whose values must never be printed in the clear.
2818
+ _SECRET_BAKE_FIELDS = ("oauth_client_secret",)
2819
+
2820
+
2410
2821
  def _bake_show(argv: list[str]) -> None:
2411
2822
  p = argparse.ArgumentParser(prog="mcp2cli bake show")
2412
2823
  p.add_argument("name")
@@ -2415,19 +2826,78 @@ def _bake_show(argv: list[str]) -> None:
2415
2826
  if cfg is None:
2416
2827
  print(f"Error: no baked tool named '{args.name}'", file=sys.stderr)
2417
2828
  sys.exit(1)
2418
- # Mask secrets in auth headers for display
2829
+ # Mask secrets for display: auth header values and any secret-bearing
2830
+ # top-level field (the README promises `bake show` output is safe to
2831
+ # share).
2419
2832
  display = dict(cfg)
2420
2833
  if display.get("auth_headers"):
2421
- masked = []
2422
- for name, val in display["auth_headers"]:
2423
- if val.startswith("env:") or val.startswith("file:"):
2424
- masked.append([name, val])
2425
- else:
2426
- masked.append([name, val[:4] + "****" if len(val) > 4 else "****"])
2427
- display["auth_headers"] = masked
2834
+ display["auth_headers"] = [
2835
+ [name, _mask_secret(val)] for name, val in display["auth_headers"]
2836
+ ]
2837
+ for field in _SECRET_BAKE_FIELDS:
2838
+ if display.get(field):
2839
+ display[field] = _mask_secret(display[field])
2428
2840
  print(json.dumps(display, indent=2, ensure_ascii=False))
2429
2841
 
2430
2842
 
2843
+ _WRAPPER_MARKER = "# installed by mcp2cli bake install"
2844
+
2845
+
2846
+ def _wrapper_is_ours(path: Path) -> bool:
2847
+ """True only for a wrapper script mcp2cli itself wrote.
2848
+
2849
+ A symlink is never ours: `bake install` writes a regular file, and reading
2850
+ the marker through a link would answer for the link's target rather than
2851
+ for the path we are about to unlink.
2852
+ """
2853
+ if path.is_symlink() or not path.is_file():
2854
+ return False
2855
+ try:
2856
+ return _WRAPPER_MARKER in path.read_text()
2857
+ except (OSError, UnicodeDecodeError):
2858
+ return False
2859
+
2860
+
2861
+ def _remove_installed_wrapper(name: str, cfg: dict) -> None:
2862
+ """Delete the wrapper for *name*, but only if mcp2cli installed it.
2863
+
2864
+ `bake install` records its destination as an absolute path, so a wrapper
2865
+ placed by --dir is found again from any working directory. Anything we
2866
+ cannot positively identify as ours is left alone and reported: deleting an
2867
+ unrelated executable that merely shares the baked tool's name is far worse
2868
+ than leaving a stale wrapper behind.
2869
+ """
2870
+ recorded = cfg.get("wrapper_path")
2871
+ if recorded:
2872
+ wrapper = Path(recorded)
2873
+ if not wrapper.is_absolute():
2874
+ # Recorded by a version that stored --dir verbatim: interpreting it
2875
+ # against the current directory would point at a different file.
2876
+ print(
2877
+ f"Note: left the wrapper for '{name}' in place -- its recorded "
2878
+ f"location {recorded!r} is relative to an unknown directory.",
2879
+ file=sys.stderr,
2880
+ )
2881
+ return
2882
+ else:
2883
+ wrapper = Path.home() / ".local" / "bin" / name
2884
+ if not wrapper.exists() and not wrapper.is_symlink():
2885
+ return
2886
+ if not _wrapper_is_ours(wrapper):
2887
+ print(
2888
+ f"Note: left {wrapper} in place -- it was not installed by "
2889
+ f"mcp2cli bake install.",
2890
+ file=sys.stderr,
2891
+ )
2892
+ return
2893
+ try:
2894
+ wrapper.unlink()
2895
+ except OSError as exc:
2896
+ print(f"Warning: could not remove {wrapper}: {exc}", file=sys.stderr)
2897
+ return
2898
+ print(f"Removed installed wrapper: {wrapper}")
2899
+
2900
+
2431
2901
  def _bake_remove(argv: list[str]) -> None:
2432
2902
  p = argparse.ArgumentParser(prog="mcp2cli bake remove")
2433
2903
  p.add_argument("name")
@@ -2436,13 +2906,10 @@ def _bake_remove(argv: list[str]) -> None:
2436
2906
  if args.name not in all_configs:
2437
2907
  print(f"Error: no baked tool named '{args.name}'", file=sys.stderr)
2438
2908
  sys.exit(1)
2909
+ cfg = all_configs[args.name]
2439
2910
  del all_configs[args.name]
2440
2911
  _save_baked_all(all_configs)
2441
- # Clean up any installed wrapper
2442
- wrapper = Path.home() / ".local" / "bin" / args.name
2443
- if wrapper.exists():
2444
- wrapper.unlink()
2445
- print(f"Removed installed wrapper: {wrapper}")
2912
+ _remove_installed_wrapper(args.name, cfg)
2446
2913
  print(f"Baked tool '{args.name}' removed.")
2447
2914
 
2448
2915
 
@@ -2489,18 +2956,32 @@ def _bake_install(argv: list[str]) -> None:
2489
2956
  if cfg is None:
2490
2957
  print(f"Error: no baked tool named '{args.name}'", file=sys.stderr)
2491
2958
  sys.exit(1)
2492
- bin_dir = Path(args.dir) if args.dir else Path.home() / ".local" / "bin"
2959
+ bin_dir = Path(args.dir).expanduser() if args.dir else Path.home() / ".local" / "bin"
2493
2960
  bin_dir.mkdir(parents=True, exist_ok=True)
2961
+ # Anchor the destination absolutely: a relative --dir like ./scripts means
2962
+ # nothing to a later `bake remove` run from a different directory. Only the
2963
+ # directory is resolved -- keeping the final component as-is means a
2964
+ # symlink named after the tool is unlinked itself rather than removal
2965
+ # following it into whatever unrelated file it points at.
2966
+ bin_dir = bin_dir.resolve()
2494
2967
  wrapper = bin_dir / args.name
2495
2968
  # Resolve mcp2cli path
2496
2969
  mcp2cli_bin = shutil.which("mcp2cli") or "mcp2cli"
2497
2970
  wrapper.write_text(
2498
- f"#!/bin/sh\nexec {shlex.quote(mcp2cli_bin)} @{args.name} \"$@\"\n"
2971
+ f"#!/bin/sh\n{_WRAPPER_MARKER}\n"
2972
+ f"exec {shlex.quote(mcp2cli_bin)} @{args.name} \"$@\"\n"
2499
2973
  )
2500
2974
  wrapper.chmod(0o755)
2975
+ # Remember where it landed so `bake remove` can find it again, including
2976
+ # when --dir put it somewhere other than ~/.local/bin.
2977
+ all_configs = _load_baked_all()
2978
+ if args.name in all_configs:
2979
+ all_configs[args.name]["wrapper_path"] = str(wrapper)
2980
+ _save_baked_all(all_configs)
2501
2981
  print(f"Installed wrapper: {wrapper}")
2502
- if args.dir is None and str(bin_dir) not in os.environ.get("PATH", ""):
2503
- print(f" Note: {bin_dir} may not be in your PATH")
2982
+ default_dir = Path.home() / ".local" / "bin"
2983
+ if args.dir is None and str(default_dir) not in os.environ.get("PATH", ""):
2984
+ print(f" Note: {default_dir} may not be in your PATH")
2504
2985
 
2505
2986
 
2506
2987
  # ---------------------------------------------------------------------------
@@ -2558,7 +3039,8 @@ def build_argparse(
2558
3039
  "--stdin",
2559
3040
  action="store_true",
2560
3041
  default=False,
2561
- help="Read JSON body/arguments from stdin",
3042
+ help="Read JSON body/arguments from stdin; parameter flags "
3043
+ "are merged in (a conflicting value is an error)",
2562
3044
  )
2563
3045
 
2564
3046
  for p in cmd.params:
@@ -2692,6 +3174,47 @@ def _filter_commands(commands: list[CommandDef], pattern: str) -> list[CommandDe
2692
3174
  # ---------------------------------------------------------------------------
2693
3175
 
2694
3176
 
3177
+ def _serialize_openapi_path_param(value, param: ParamDef) -> str:
3178
+ """Serialize flat path values, encoding data before adding style delimiters."""
3179
+ value = coerce_value(value, param.schema)
3180
+ style = param.style or "simple"
3181
+
3182
+ def encode(atom):
3183
+ text = str(atom).lower() if isinstance(atom, bool) else str(atom)
3184
+ # HTTP clients normalize literal dot segments before sending a request.
3185
+ if text in (".", ".."):
3186
+ return text.replace(".", "%2E")
3187
+ return quote(text, safe="")
3188
+
3189
+ name = encode(param.original_name)
3190
+ if isinstance(value, dict):
3191
+ pairs = [(encode(k), encode(v)) for k, v in value.items() if v is not None]
3192
+ if not pairs:
3193
+ return ""
3194
+ if param.explode:
3195
+ parts = [f"{k}={v}" for k, v in pairs]
3196
+ if style == "matrix":
3197
+ return ";" + ";".join(parts)
3198
+ else:
3199
+ parts = [atom for pair in pairs for atom in pair]
3200
+ elif isinstance(value, list):
3201
+ parts = [encode(v) for v in value if v is not None]
3202
+ if not parts:
3203
+ return ""
3204
+ if style == "matrix" and param.explode:
3205
+ return "".join(f";{name}={part}" for part in parts)
3206
+ else:
3207
+ parts = [encode(value)]
3208
+
3209
+ separator = "." if style == "label" and param.explode else ","
3210
+ serialized = separator.join(parts)
3211
+ if style == "label":
3212
+ return "." + serialized
3213
+ if style == "matrix":
3214
+ return f";{name}={serialized}" if serialized else f";{name}"
3215
+ return serialized
3216
+
3217
+
2695
3218
  def _collect_openapi_params(
2696
3219
  cmd: CommandDef,
2697
3220
  args: argparse.Namespace,
@@ -2711,30 +3234,36 @@ def _collect_openapi_params(
2711
3234
  if p.location == "path":
2712
3235
  val = getattr(args, _param_dest(p), None)
2713
3236
  if val is not None:
2714
- path = path.replace(f"{{{p.original_name}}}", str(val))
3237
+ path = path.replace(
3238
+ f"{{{p.original_name}}}", _serialize_openapi_path_param(val, p)
3239
+ )
2715
3240
 
2716
- if cmd.method == "get":
2717
- for p in cmd.params:
2718
- val = getattr(args, _param_dest(p), None)
2719
- if val is None:
2720
- continue
2721
- if p.location == "query":
2722
- query_params[p.original_name] = coerce_value(val, p.schema)
2723
- elif p.location == "header":
2724
- extra_headers[p.original_name] = str(val)
2725
- else:
3241
+ # Query and header values are transport metadata on every verb, so gather
3242
+ # them once here -- including when --stdin supplies the body, which used to
3243
+ # skip the header pass entirely and silently drop header parameters.
3244
+ for p in cmd.params:
3245
+ val = getattr(args, _param_dest(p), None)
3246
+ if val is None:
3247
+ continue
3248
+ if p.location == "query":
3249
+ query_params[p.original_name] = coerce_value(val, p.schema)
3250
+ elif p.location == "header":
3251
+ extra_headers[p.original_name] = str(val)
3252
+
3253
+ if cmd.method != "get":
2726
3254
  if getattr(args, "stdin", False) is True:
2727
- body = read_stdin_json("OpenAPI request body")
3255
+ body = _merge_stdin_flag_args(
3256
+ read_stdin_json("OpenAPI request body"), cmd, args, "OpenAPI request body"
3257
+ )
2728
3258
  else:
2729
3259
  body = {}
2730
3260
  for p in cmd.params:
2731
- val = getattr(args, _param_dest(p), None)
2732
- if p.location == "header":
2733
- if val is not None:
2734
- extra_headers[p.original_name] = str(val)
2735
- continue
2736
- if p.location == "path":
3261
+ if p.location in ("path", "query", "header", "cookie"):
3262
+ # Path values travel in the URL, query and header values are
3263
+ # collected above, and a cookie parameter is not a body field
3264
+ # either. None of them belong in the JSON payload.
2737
3265
  continue
3266
+ val = getattr(args, _param_dest(p), None)
2738
3267
  if p.location == "file":
2739
3268
  if val is not None:
2740
3269
  fp = Path(val)
@@ -2750,12 +3279,6 @@ def _collect_openapi_params(
2750
3279
  body[p.original_name] = coerce_value(val, p.schema)
2751
3280
  if not body:
2752
3281
  body = None
2753
- # Also collect query params for non-GET
2754
- for p in cmd.params:
2755
- if p.location == "query":
2756
- val = getattr(args, _param_dest(p), None)
2757
- if val is not None:
2758
- query_params[p.original_name] = coerce_value(val, p.schema)
2759
3282
 
2760
3283
  return path, query_params, extra_headers, body, files
2761
3284
 
@@ -2855,6 +3378,29 @@ def _exc_message(exc: BaseException) -> str:
2855
3378
  return "; ".join(part for part in parts if part) or exc.__class__.__name__
2856
3379
 
2857
3380
 
3381
+ def _is_disconnect(exc: BaseException) -> bool:
3382
+ """Report whether a leaf exception means the server dropped the connection."""
3383
+ if isinstance(
3384
+ exc,
3385
+ (
3386
+ BrokenPipeError,
3387
+ ConnectionResetError,
3388
+ ConnectionAbortedError,
3389
+ anyio.EndOfStream,
3390
+ anyio.BrokenResourceError,
3391
+ anyio.ClosedResourceError,
3392
+ ),
3393
+ ):
3394
+ return True
3395
+ try: # MCP SDK v2
3396
+ from mcp.shared.exceptions import MCPError
3397
+ from mcp_types import CONNECTION_CLOSED
3398
+ except ImportError: # MCP SDK v1
3399
+ from mcp.shared.exceptions import McpError as MCPError
3400
+ from mcp.types import CONNECTION_CLOSED
3401
+ return isinstance(exc, MCPError) and exc.error.code == CONNECTION_CLOSED
3402
+
3403
+
2858
3404
  def _run_mcp_clean(fn, source: str):
2859
3405
  """Run an MCP coroutine, reporting failures as one clean error line.
2860
3406
 
@@ -2881,6 +3427,11 @@ def _run_mcp_clean(fn, source: str):
2881
3427
  " — the server rejected the request; pass credentials with "
2882
3428
  "--auth-header 'Name:Value' or use the --oauth-* options"
2883
3429
  )
3430
+ elif any(_is_disconnect(leaf) for leaf in leaves):
3431
+ hint = (
3432
+ " — the server disconnected unexpectedly; it may have crashed "
3433
+ "or exited. Check the server command and its logs"
3434
+ )
2884
3435
  else:
2885
3436
  hint = ""
2886
3437
  print(f"Error: cannot use MCP server at {source}: {message}{hint}", file=sys.stderr)
@@ -3086,7 +3637,7 @@ def run_mcp_stdio(
3086
3637
  from mcp import ClientSession
3087
3638
  from mcp.client.stdio import StdioServerParameters, stdio_client
3088
3639
 
3089
- parts = shlex.split(command_str)
3640
+ parts = split_shell_command(command_str)
3090
3641
  env = {**os.environ, **env_vars}
3091
3642
  params = StdioServerParameters(command=parts[0], args=parts[1:], env=env)
3092
3643
 
@@ -3507,17 +4058,26 @@ def session_start(
3507
4058
  )
3508
4059
 
3509
4060
  log_path = _session_log_path(name)
3510
- proc = subprocess.Popen(
3511
- [
3512
- sys.executable,
3513
- "-c",
3514
- f"import mcp2cli; mcp2cli._run_session_daemon({json.dumps(daemon_script)})",
3515
- ],
3516
- start_new_session=True,
3517
- stdout=subprocess.DEVNULL,
3518
- stderr=open(log_path, "a"),
3519
- stdin=subprocess.DEVNULL,
3520
- )
4061
+ # Route both streams to the session log rather than /dev/null: sandboxes
4062
+ # that deny opening /dev/null (agent runners, hardened containers) would
4063
+ # otherwise fail the spawn with PermissionError before the daemon starts.
4064
+ # Popen resolves DEVNULL by opening /dev/null in this process.
4065
+ with open(log_path, "a") as log_file:
4066
+ proc = subprocess.Popen(
4067
+ [
4068
+ sys.executable,
4069
+ "-c",
4070
+ f"import mcp2cli; mcp2cli._run_session_daemon({json.dumps(daemon_script)})",
4071
+ ],
4072
+ start_new_session=True,
4073
+ stdout=log_file,
4074
+ stderr=log_file,
4075
+ stdin=subprocess.PIPE,
4076
+ )
4077
+ # The daemon never reads stdin; closing the write end hands it EOF and
4078
+ # leaves no descriptor behind in the parent.
4079
+ if proc.stdin is not None:
4080
+ proc.stdin.close()
3521
4081
 
3522
4082
  # Wait for socket to appear
3523
4083
  sock_path = _session_sock_path(name)
@@ -3797,7 +4357,7 @@ def _run_session_daemon(config_json: str):
3797
4357
  if is_stdio:
3798
4358
  from mcp.client.stdio import StdioServerParameters, stdio_client
3799
4359
 
3800
- parts = shlex.split(source)
4360
+ parts = split_shell_command(source)
3801
4361
  env = {**os.environ, **env_vars}
3802
4362
  params = StdioServerParameters(command=parts[0], args=parts[1:], env=env)
3803
4363
  async with stdio_client(params) as (read, write):
@@ -4053,7 +4613,9 @@ def handle_mcp(
4053
4613
  cmd: CommandDef = args._cmd
4054
4614
 
4055
4615
  if getattr(args, "stdin", False) is True:
4056
- arguments = read_stdin_json("MCP tool arguments")
4616
+ arguments = _merge_stdin_flag_args(
4617
+ read_stdin_json("MCP tool arguments"), cmd, args, "MCP tool arguments"
4618
+ )
4057
4619
  else:
4058
4620
  arguments = {}
4059
4621
  for p in cmd.params:
@@ -4100,7 +4662,7 @@ def _fetch_mcp_tools(
4100
4662
  from mcp import ClientSession
4101
4663
  from mcp.client.stdio import StdioServerParameters, stdio_client
4102
4664
 
4103
- parts = shlex.split(source)
4665
+ parts = split_shell_command(source)
4104
4666
  env = {**os.environ, **env_vars}
4105
4667
  params = StdioServerParameters(command=parts[0], args=parts[1:], env=env)
4106
4668
  async with stdio_client(params) as (read, write):
@@ -4675,7 +5237,10 @@ def _handle_session_operations(
4675
5237
 
4676
5238
  cmd: CommandDef = args._cmd
4677
5239
  if getattr(args, "stdin", False) is True:
4678
- arguments = read_stdin_json(f"session {sess_name} tool arguments")
5240
+ arguments = _merge_stdin_flag_args(
5241
+ read_stdin_json(f"session {sess_name} tool arguments"),
5242
+ cmd, args, f"session {sess_name} tool arguments",
5243
+ )
4679
5244
  else:
4680
5245
  arguments = {}
4681
5246
  for p in cmd.params:
File without changes
File without changes