mcp2cli 3.5.0__tar.gz → 3.7.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.7.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>
@@ -158,6 +158,30 @@ mcp2cli --mcp-stdio "node server.js" --env API_KEY=sk-... --env DEBUG=1 \
158
158
  search --query "test"
159
159
  ```
160
160
 
161
+ ### MCP roots and completion
162
+
163
+ Expose one or more filesystem roots when a server scopes operations to a
164
+ workspace. Paths are converted to `file://` URIs; explicit roots must also use
165
+ the `file://` scheme.
166
+
167
+ ```bash
168
+ mcp2cli --mcp-stdio "npx @modelcontextprotocol/server-filesystem /tmp" \
169
+ --root "$PWD" --root file:///var/shared --list
170
+ ```
171
+
172
+ Request prompt-argument or resource-template completions with
173
+ `REF:ARG=PREFIX`:
174
+
175
+ ```bash
176
+ mcp2cli --mcp https://example.com/mcp \
177
+ --complete "greeting:name=San"
178
+ mcp2cli --mcp https://example.com/mcp \
179
+ --complete "file:///docs/{topic}:topic=api"
180
+ ```
181
+
182
+ Both options work when starting a persistent session; roots are retained by
183
+ the session daemon and completion requests can be sent through `--session`.
184
+
161
185
  ### OpenAPI mode
162
186
 
163
187
  ```bash
@@ -338,6 +362,8 @@ Options:
338
362
  --base-url URL Override base URL from spec
339
363
  --transport TYPE MCP HTTP transport: auto|sse|streamable (default: auto)
340
364
  --env KEY=VALUE Env var for MCP stdio server (repeatable)
365
+ --root PATH|FILE_URI Expose a filesystem root to an MCP server (repeatable)
366
+ --complete SPEC Complete an MCP prompt or resource-template argument
341
367
  --oauth Enable OAuth (authorization code + PKCE flow)
342
368
  --oauth-client-id ID OAuth client ID (supports env:/file: prefixes)
343
369
  --oauth-client-secret S OAuth client secret (supports env:/file: prefixes)
@@ -139,6 +139,30 @@ mcp2cli --mcp-stdio "node server.js" --env API_KEY=sk-... --env DEBUG=1 \
139
139
  search --query "test"
140
140
  ```
141
141
 
142
+ ### MCP roots and completion
143
+
144
+ Expose one or more filesystem roots when a server scopes operations to a
145
+ workspace. Paths are converted to `file://` URIs; explicit roots must also use
146
+ the `file://` scheme.
147
+
148
+ ```bash
149
+ mcp2cli --mcp-stdio "npx @modelcontextprotocol/server-filesystem /tmp" \
150
+ --root "$PWD" --root file:///var/shared --list
151
+ ```
152
+
153
+ Request prompt-argument or resource-template completions with
154
+ `REF:ARG=PREFIX`:
155
+
156
+ ```bash
157
+ mcp2cli --mcp https://example.com/mcp \
158
+ --complete "greeting:name=San"
159
+ mcp2cli --mcp https://example.com/mcp \
160
+ --complete "file:///docs/{topic}:topic=api"
161
+ ```
162
+
163
+ Both options work when starting a persistent session; roots are retained by
164
+ the session daemon and completion requests can be sent through `--session`.
165
+
142
166
  ### OpenAPI mode
143
167
 
144
168
  ```bash
@@ -319,6 +343,8 @@ Options:
319
343
  --base-url URL Override base URL from spec
320
344
  --transport TYPE MCP HTTP transport: auto|sse|streamable (default: auto)
321
345
  --env KEY=VALUE Env var for MCP stdio server (repeatable)
346
+ --root PATH|FILE_URI Expose a filesystem root to an MCP server (repeatable)
347
+ --complete SPEC Complete an MCP prompt or resource-template argument
322
348
  --oauth Enable OAuth (authorization code + PKCE flow)
323
349
  --oauth-client-id ID OAuth client ID (supports env:/file: prefixes)
324
350
  --oauth-client-secret S OAuth client secret (supports env:/file: prefixes)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp2cli"
3
- version = "3.5.0"
3
+ version = "3.7.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.7.0"
4
4
  description = "Turn any MCP server or OpenAPI spec into a CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -38,6 +38,8 @@ CACHE_DIR = Path(
38
38
  os.environ.get("MCP2CLI_CACHE_DIR", Path.home() / ".cache" / "mcp2cli")
39
39
  )
40
40
  DEFAULT_CACHE_TTL = 3600
41
+ # Client-side capability the user opted into (see --root).
42
+ _ROOTS: list[str] = []
41
43
  USAGE_FILE = CACHE_DIR / "usage.json"
42
44
  CONFIG_DIR = Path(
43
45
  os.environ.get("MCP2CLI_CONFIG_DIR", Path.home() / ".config" / "mcp2cli")
@@ -61,6 +63,7 @@ class ParamDef:
61
63
  choices: list | None = None
62
64
  location: str = "body" # path|query|header|body|tool_input
63
65
  schema: dict = field(default_factory=dict)
66
+ cli_name: str | None = None # collision-free argparse flag name
64
67
 
65
68
 
66
69
  @dataclass
@@ -178,8 +181,17 @@ def read_stdin_json(context: str):
178
181
  sys.exit(1)
179
182
 
180
183
 
184
+ def _normalize_schema_type(t):
185
+ """JSON Schema allows "type": ["integer", "null"] (array form). Reduce it
186
+ to the single concrete type, dropping "null"; anything else passes through."""
187
+ if isinstance(t, list):
188
+ concrete = [x for x in t if x != "null"]
189
+ return concrete[0] if len(concrete) == 1 else None
190
+ return t
191
+
192
+
181
193
  def schema_type_to_python(schema: dict) -> tuple[type | None, str]:
182
- t = schema.get("type")
194
+ t = _normalize_schema_type(schema.get("type"))
183
195
  if t == "integer":
184
196
  return int, ""
185
197
  if t == "number":
@@ -190,6 +202,16 @@ def schema_type_to_python(schema: dict) -> tuple[type | None, str]:
190
202
  return str, " (JSON array)"
191
203
  if t == "object":
192
204
  return str, " (JSON object)"
205
+ if t is None:
206
+ # No "type" but an enum: infer the argparse type from the values, so
207
+ # numeric enums stay callable (otherwise argparse parses the flag as a
208
+ # string and rejects it against numeric choices).
209
+ enum = schema.get("enum")
210
+ if enum and not any(isinstance(v, bool) for v in enum):
211
+ if all(isinstance(v, int) for v in enum):
212
+ return int, ""
213
+ if all(isinstance(v, (int, float)) for v in enum):
214
+ return float, ""
193
215
  return str, ""
194
216
 
195
217
 
@@ -207,7 +229,7 @@ def _coerce_item(value: str, item_type: str | None):
207
229
  def coerce_value(value, schema: dict):
208
230
  if value is None:
209
231
  return None
210
- t = schema.get("type")
232
+ t = _normalize_schema_type(schema.get("type"))
211
233
  if t == "array":
212
234
  if isinstance(value, list):
213
235
  return value
@@ -218,7 +240,7 @@ def coerce_value(value, schema: dict):
218
240
  return parsed
219
241
  except (json.JSONDecodeError, TypeError):
220
242
  pass
221
- item_type = schema.get("items", {}).get("type")
243
+ item_type = _normalize_schema_type(schema.get("items", {}).get("type"))
222
244
  if "," in value:
223
245
  return [_coerce_item(v.strip(), item_type) for v in value.split(",")]
224
246
  return [_coerce_item(value, item_type)]
@@ -252,13 +274,16 @@ def to_kebab(name: str) -> str:
252
274
  return s.replace("_", "-").lower()
253
275
 
254
276
 
255
- def _find_toon_cli() -> str | None:
256
- """Return the command to invoke the TOON CLI, or None if unavailable."""
277
+ def _find_toon_cli() -> tuple[str, ...] | None:
278
+ """Return argv for the TOON CLI, or None if unavailable."""
257
279
  if shutil.which("toon"):
258
- return "toon"
259
- # Check for npx (ships with Node.js)
280
+ return ("toon",)
281
+ # npx ships with Node.js, but having it says nothing about the package.
282
+ # `--no` forbids npx's implicit registry download, so a missing package
283
+ # fails in about a second instead of turning --toon into a network fetch
284
+ # racing the timeout in _toon_encode.
260
285
  if shutil.which("npx"):
261
- return "npx @toon-format/cli"
286
+ return ("npx", "--no", "@toon-format/cli")
262
287
  return None
263
288
 
264
289
 
@@ -269,7 +294,7 @@ def _toon_encode(json_str: str) -> str | None:
269
294
  return None
270
295
  try:
271
296
  result = subprocess.run(
272
- cmd.split(),
297
+ cmd,
273
298
  input=json_str,
274
299
  capture_output=True,
275
300
  text=True,
@@ -299,6 +324,7 @@ _MCP_RENAMED_FIELDS = {
299
324
  "mimeType": "mime_type",
300
325
  "structuredContent": "structured_content",
301
326
  "isError": "is_error",
327
+ "hasMore": "has_more",
302
328
  }
303
329
 
304
330
 
@@ -499,9 +525,34 @@ def _python_type_name(t: type | None) -> str:
499
525
  return getattr(t, "__name__", str(t))
500
526
 
501
527
 
528
+ def _param_dest(p: "ParamDef") -> str:
529
+ """Return the argparse destination allocated for a parameter."""
530
+ return (p.cli_name or p.name).replace("-", "_")
531
+
532
+
533
+ def _allocate_param_cli_names(cmd: "CommandDef") -> None:
534
+ """Assign unique parameter flags without shadowing built-in options."""
535
+ natural_names = {p.name for p in cmd.params}
536
+ used = {"help"}
537
+ if cmd.has_body:
538
+ used.add("stdin")
539
+
540
+ for p in cmd.params:
541
+ name = p.name
542
+ if name in used:
543
+ stem = f"arg-{name}"
544
+ name = stem
545
+ suffix = 2
546
+ while name in used or name in natural_names:
547
+ name = f"{stem}-{suffix}"
548
+ suffix += 1
549
+ p.cli_name = name
550
+ used.add(name)
551
+
552
+
502
553
  def _param_to_dict(p: "ParamDef") -> dict:
503
554
  d = {
504
- "name": p.name,
555
+ "name": p.cli_name or p.name,
505
556
  "type": _python_type_name(p.python_type),
506
557
  "required": p.required,
507
558
  "description": p.description,
@@ -1494,8 +1545,34 @@ def extract_openapi_commands(spec: dict) -> list[CommandDef]:
1494
1545
 
1495
1546
  def extract_mcp_commands(tools: list[dict]) -> list[CommandDef]:
1496
1547
  commands: list[CommandDef] = []
1497
- for tool in tools:
1498
- name = to_kebab(tool.get("name", "unknown"))
1548
+ base_names = [to_kebab(tool.get("name", "unknown")) for tool in tools]
1549
+ reserved_names = set(base_names)
1550
+ cli_names = [""] * len(tools)
1551
+ used_names: set[str] = set()
1552
+ groups: dict[str, list[int]] = {}
1553
+ for index, base_name in enumerate(base_names):
1554
+ groups.setdefault(base_name, []).append(index)
1555
+
1556
+ # Assign aliases from the complete tool-name set so a generated suffix can
1557
+ # never shadow another tool's natural name. Sorting each collision group by
1558
+ # wire name keeps aliases stable when a server reorders tools/list.
1559
+ for base_name in sorted(groups):
1560
+ indices = sorted(
1561
+ groups[base_name],
1562
+ key=lambda index: (tools[index].get("name", "unknown"), index),
1563
+ )
1564
+ for rank, index in enumerate(indices):
1565
+ name = base_name
1566
+ if rank:
1567
+ suffix = 2
1568
+ name = f"{base_name}-{suffix}"
1569
+ while name in reserved_names or name in used_names:
1570
+ suffix += 1
1571
+ name = f"{base_name}-{suffix}"
1572
+ cli_names[index] = name
1573
+ used_names.add(name)
1574
+
1575
+ for tool, name in zip(tools, cli_names):
1499
1576
  desc = tool.get("description", "")
1500
1577
  schema = tool.get("inputSchema", {})
1501
1578
  required_fields = set(schema.get("required", []))
@@ -1934,12 +2011,12 @@ def _build_graphql_document(
1934
2011
  types_by_name = {t["name"]: t for t in schema.get("types", []) if t.get("name")}
1935
2012
 
1936
2013
  # Build variables dict from args
1937
- if getattr(args, "stdin", False):
2014
+ if getattr(args, "stdin", False) is True:
1938
2015
  variables = read_stdin_json("GraphQL variables")
1939
2016
  else:
1940
2017
  variables = {}
1941
2018
  for p in cmd.params:
1942
- val = getattr(args, p.name.replace("-", "_"), None)
2019
+ val = getattr(args, _param_dest(p), None)
1943
2020
  if val is not None:
1944
2021
  variables[p.original_name] = coerce_value(val, p.schema)
1945
2022
 
@@ -2473,6 +2550,7 @@ def build_argparse(
2473
2550
  help=escape_argparse_help(cmd.description),
2474
2551
  description=escape_argparse_help(cmd.description),
2475
2552
  )
2553
+ _allocate_param_cli_names(cmd)
2476
2554
  sub.set_defaults(_cmd=cmd)
2477
2555
 
2478
2556
  if cmd.has_body:
@@ -2483,12 +2561,8 @@ def build_argparse(
2483
2561
  help="Read JSON body/arguments from stdin",
2484
2562
  )
2485
2563
 
2486
- seen_flags: set[str] = set()
2487
2564
  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)
2565
+ flag = f"--{p.cli_name or p.name}"
2492
2566
  kwargs: dict = {}
2493
2567
  if p.python_type is not None:
2494
2568
  kwargs["type"] = p.python_type
@@ -2506,6 +2580,7 @@ def build_argparse(
2506
2580
  kwargs["help"] = escape_argparse_help(p.description)
2507
2581
  if p.choices:
2508
2582
  kwargs["choices"] = p.choices
2583
+ kwargs["dest"] = _param_dest(p)
2509
2584
  sub.add_argument(flag, **kwargs)
2510
2585
 
2511
2586
  return parser
@@ -2634,13 +2709,13 @@ def _collect_openapi_params(
2634
2709
 
2635
2710
  for p in cmd.params:
2636
2711
  if p.location == "path":
2637
- val = getattr(args, p.name.replace("-", "_"), None)
2712
+ val = getattr(args, _param_dest(p), None)
2638
2713
  if val is not None:
2639
2714
  path = path.replace(f"{{{p.original_name}}}", str(val))
2640
2715
 
2641
2716
  if cmd.method == "get":
2642
2717
  for p in cmd.params:
2643
- val = getattr(args, p.name.replace("-", "_"), None)
2718
+ val = getattr(args, _param_dest(p), None)
2644
2719
  if val is None:
2645
2720
  continue
2646
2721
  if p.location == "query":
@@ -2648,12 +2723,12 @@ def _collect_openapi_params(
2648
2723
  elif p.location == "header":
2649
2724
  extra_headers[p.original_name] = str(val)
2650
2725
  else:
2651
- if getattr(args, "stdin", False):
2726
+ if getattr(args, "stdin", False) is True:
2652
2727
  body = read_stdin_json("OpenAPI request body")
2653
2728
  else:
2654
2729
  body = {}
2655
2730
  for p in cmd.params:
2656
- val = getattr(args, p.name.replace("-", "_"), None)
2731
+ val = getattr(args, _param_dest(p), None)
2657
2732
  if p.location == "header":
2658
2733
  if val is not None:
2659
2734
  extra_headers[p.original_name] = str(val)
@@ -2678,7 +2753,7 @@ def _collect_openapi_params(
2678
2753
  # Also collect query params for non-GET
2679
2754
  for p in cmd.params:
2680
2755
  if p.location == "query":
2681
- val = getattr(args, p.name.replace("-", "_"), None)
2756
+ val = getattr(args, _param_dest(p), None)
2682
2757
  if val is not None:
2683
2758
  query_params[p.original_name] = coerce_value(val, p.schema)
2684
2759
 
@@ -2763,6 +2838,95 @@ def execute_openapi(
2763
2838
  # ---------------------------------------------------------------------------
2764
2839
 
2765
2840
 
2841
+ def _exc_leaves(exc: BaseException) -> list[BaseException]:
2842
+ """Flatten nested exception groups into their leaf exceptions."""
2843
+ nested = getattr(exc, "exceptions", None)
2844
+ if not nested:
2845
+ return [exc]
2846
+ return [leaf for child in nested for leaf in _exc_leaves(child)]
2847
+
2848
+
2849
+ def _exc_message(exc: BaseException) -> str:
2850
+ """Flatten an exception group into one terminal-safe line."""
2851
+ parts = []
2852
+ for leaf in _exc_leaves(exc):
2853
+ message = str(leaf) or leaf.__class__.__name__
2854
+ parts.append("; ".join(line.strip() for line in message.splitlines() if line.strip()))
2855
+ return "; ".join(part for part in parts if part) or exc.__class__.__name__
2856
+
2857
+
2858
+ def _run_mcp_clean(fn, source: str):
2859
+ """Run an MCP coroutine, reporting failures as one clean error line.
2860
+
2861
+ Transport failures (bad URL, refused connection, 401/403) otherwise reach
2862
+ the terminal as a multi-level anyio ExceptionGroup traceback with the
2863
+ actual cause buried at the bottom. Set MCP2CLI_DEBUG=1 for the traceback.
2864
+ """
2865
+ try:
2866
+ return anyio.run(fn)
2867
+ except SystemExit:
2868
+ raise
2869
+ except KeyboardInterrupt: # pragma: no cover - interactive only
2870
+ raise
2871
+ except BaseException as exc:
2872
+ leaves = _exc_leaves(exc)
2873
+ if len(leaves) == 1 and isinstance(leaves[0], (SystemExit, KeyboardInterrupt)):
2874
+ raise leaves[0]
2875
+ if os.environ.get("MCP2CLI_DEBUG"):
2876
+ raise
2877
+ message = _exc_message(exc)
2878
+ lowered = message.lower()
2879
+ if "401" in lowered or "403" in lowered:
2880
+ hint = (
2881
+ " — the server rejected the request; pass credentials with "
2882
+ "--auth-header 'Name:Value' or use the --oauth-* options"
2883
+ )
2884
+ else:
2885
+ hint = ""
2886
+ print(f"Error: cannot use MCP server at {source}: {message}{hint}", file=sys.stderr)
2887
+ sys.exit(1)
2888
+
2889
+
2890
+ def _normalize_root(raw: str) -> str:
2891
+ """Return a validated file URI for one ``--root`` value."""
2892
+ if "://" in raw:
2893
+ if not raw.casefold().startswith("file://"):
2894
+ raise ValueError(
2895
+ f"--root expects a filesystem path or file:// URI, got {raw!r}"
2896
+ )
2897
+ uri = raw
2898
+ else:
2899
+ uri = Path(raw).expanduser().resolve().as_uri()
2900
+
2901
+ from mcp import types
2902
+
2903
+ try:
2904
+ return str(types.Root(uri=uri).uri)
2905
+ except (TypeError, ValueError) as exc:
2906
+ raise ValueError(f"invalid --root value {raw!r}: {exc}") from exc
2907
+
2908
+
2909
+ def _roots_callback(root_uris: list[str] | None = None):
2910
+ """Answer ``roots/list`` from validated file URIs, if any were given."""
2911
+ configured_roots = tuple(_ROOTS if root_uris is None else root_uris)
2912
+ if not configured_roots:
2913
+ return None
2914
+
2915
+ from mcp import types
2916
+
2917
+ async def list_roots(context=None):
2918
+ roots = [
2919
+ types.Root(
2920
+ uri=uri,
2921
+ name=Path(urlparse(uri).path).name or uri,
2922
+ )
2923
+ for uri in configured_roots
2924
+ ]
2925
+ return types.ListRootsResult(roots=roots)
2926
+
2927
+ return list_roots
2928
+
2929
+
2766
2930
  def run_mcp_http(
2767
2931
  url: str,
2768
2932
  auth_headers: list[tuple[str, str]],
@@ -2782,6 +2946,7 @@ def run_mcp_http(
2782
2946
  prompt_action: str | None = None,
2783
2947
  prompt_name: str | None = None,
2784
2948
  prompt_arguments: dict | None = None,
2949
+ complete_spec: str | None = None,
2785
2950
  search_pattern: str | None = None,
2786
2951
  head: int | None = None,
2787
2952
  verbose: bool = False,
@@ -2797,6 +2962,7 @@ def run_mcp_http(
2797
2962
  prompt_action=prompt_action,
2798
2963
  prompt_name=prompt_name,
2799
2964
  prompt_arguments=prompt_arguments,
2965
+ complete_spec=complete_spec,
2800
2966
  search_pattern=search_pattern,
2801
2967
  head=head,
2802
2968
  verbose=verbose,
@@ -2816,7 +2982,7 @@ def run_mcp_http(
2816
2982
  async with _streamable_streams(
2817
2983
  url, headers=headers, auth=oauth_provider
2818
2984
  ) as (read, write):
2819
- async with ClientSession(read, write) as session:
2985
+ async with ClientSession(read, write, list_roots_callback=_roots_callback()) as session:
2820
2986
  await session.initialize()
2821
2987
  return await _mcp_session(
2822
2988
  session,
@@ -2839,7 +3005,7 @@ def run_mcp_http(
2839
3005
  read,
2840
3006
  write,
2841
3007
  ):
2842
- async with ClientSession(read, write) as session:
3008
+ async with ClientSession(read, write, list_roots_callback=_roots_callback()) as session:
2843
3009
  await session.initialize()
2844
3010
  return await _mcp_session(
2845
3011
  session,
@@ -2865,7 +3031,9 @@ def run_mcp_http(
2865
3031
  except Exception:
2866
3032
  return await _with_sse()
2867
3033
 
2868
- anyio.run(_run)
3034
+ rc = _run_mcp_clean(_run, url)
3035
+ if rc:
3036
+ sys.exit(rc)
2869
3037
 
2870
3038
 
2871
3039
  def run_mcp_stdio(
@@ -2885,6 +3053,7 @@ def run_mcp_stdio(
2885
3053
  prompt_action: str | None = None,
2886
3054
  prompt_name: str | None = None,
2887
3055
  prompt_arguments: dict | None = None,
3056
+ complete_spec: str | None = None,
2888
3057
  search_pattern: str | None = None,
2889
3058
  head: int | None = None,
2890
3059
  verbose: bool = False,
@@ -2900,6 +3069,7 @@ def run_mcp_stdio(
2900
3069
  prompt_action=prompt_action,
2901
3070
  prompt_name=prompt_name,
2902
3071
  prompt_arguments=prompt_arguments,
3072
+ complete_spec=complete_spec,
2903
3073
  search_pattern=search_pattern,
2904
3074
  head=head,
2905
3075
  verbose=verbose,
@@ -2921,9 +3091,9 @@ def run_mcp_stdio(
2921
3091
  params = StdioServerParameters(command=parts[0], args=parts[1:], env=env)
2922
3092
 
2923
3093
  async with stdio_client(params) as (read, write):
2924
- async with ClientSession(read, write) as session:
3094
+ async with ClientSession(read, write, list_roots_callback=_roots_callback()) as session:
2925
3095
  await session.initialize()
2926
- await _mcp_session(
3096
+ return await _mcp_session(
2927
3097
  session,
2928
3098
  tool_name,
2929
3099
  arguments,
@@ -2937,7 +3107,9 @@ def run_mcp_stdio(
2937
3107
  **extra,
2938
3108
  )
2939
3109
 
2940
- anyio.run(_run)
3110
+ rc = _run_mcp_clean(_run, command_str)
3111
+ if rc:
3112
+ sys.exit(rc)
2941
3113
 
2942
3114
 
2943
3115
  async def _mcp_session(
@@ -2956,6 +3128,7 @@ async def _mcp_session(
2956
3128
  prompt_action: str | None = None,
2957
3129
  prompt_name: str | None = None,
2958
3130
  prompt_arguments: dict | None = None,
3131
+ complete_spec: str | None = None,
2959
3132
  search_pattern: str | None = None,
2960
3133
  head: int | None = None,
2961
3134
  verbose: bool = False,
@@ -2965,6 +3138,14 @@ async def _mcp_session(
2965
3138
  source_hash: str = "",
2966
3139
  json_output: bool = False,
2967
3140
  ):
3141
+ # Handle completion requests
3142
+ if complete_spec:
3143
+ await _handle_completion(
3144
+ session, complete_spec, pretty, raw, toon, head=head,
3145
+ json_output=json_output,
3146
+ )
3147
+ return
3148
+
2968
3149
  # Handle resource operations
2969
3150
  if resource_action:
2970
3151
  await _handle_resources(
@@ -3028,10 +3209,26 @@ async def _mcp_session(
3028
3209
  # isError) with the camelCase wire names, so the envelope does not
3029
3210
  # change shape with the installed SDK major.
3030
3211
  output_result(_mcp_dump(result), pretty=pretty, head=head, json_output=True)
3031
- return
3212
+ # A failed tool still exits non-zero under --json so callers can detect
3213
+ # it; the envelope on stdout already carries isError for machines.
3214
+ return 1 if _mcp_attr(result, "isError") else 0
3032
3215
 
3033
3216
  text = _extract_content_parts(result.content)
3034
- output_result(text, pretty=pretty, raw=raw, toon=toon, head=head)
3217
+ payload = text
3218
+ if not payload:
3219
+ # A tool may return only structuredContent with an empty content list.
3220
+ structured = _mcp_attr(result, "structuredContent")
3221
+ if structured is not None:
3222
+ payload = structured
3223
+
3224
+ if _mcp_attr(result, "isError"):
3225
+ # Return the code instead of raising inside the anyio task group, which
3226
+ # would wrap SystemExit in a BaseExceptionGroup traceback.
3227
+ error = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False)
3228
+ print(f"Error: {error or f'tool {tool_name!r} reported an error'}", file=sys.stderr)
3229
+ return 1
3230
+ output_result(payload, pretty=pretty, raw=raw, toon=toon, head=head)
3231
+ return 0
3035
3232
 
3036
3233
 
3037
3234
  # ---------------------------------------------------------------------------
@@ -3091,6 +3288,67 @@ async def _handle_resources(
3091
3288
  # ---------------------------------------------------------------------------
3092
3289
 
3093
3290
 
3291
+ def parse_complete_spec(spec: str) -> tuple[str, str, str]:
3292
+ """Parse ``REF:ARG=PREFIX`` into (ref, argument, prefix).
3293
+
3294
+ ``REF`` is a prompt name, or a resource URI template when it contains
3295
+ ``://`` — in which case the scheme's own colons must not be mistaken for
3296
+ the ref/arg separator.
3297
+ """
3298
+ ref_part, sep, prefix = spec.partition("=")
3299
+ if not sep:
3300
+ print(
3301
+ "Error: --complete expects REF:ARG=PREFIX (e.g. 'my-prompt:city=San')",
3302
+ file=sys.stderr,
3303
+ )
3304
+ sys.exit(1)
3305
+ ref, sep, argument = ref_part.rpartition(":")
3306
+ if not sep or not ref or not argument:
3307
+ print(
3308
+ f"Error: --complete could not split {ref_part!r} into REF:ARG",
3309
+ file=sys.stderr,
3310
+ )
3311
+ sys.exit(1)
3312
+ return ref, argument, prefix
3313
+
3314
+
3315
+ async def _completion_data(session, spec: str) -> dict:
3316
+ """Run ``completion/complete`` and return its stable wire-shaped payload."""
3317
+ from mcp import types
3318
+
3319
+ ref_name, argument, prefix = parse_complete_spec(spec)
3320
+ if "://" in ref_name or "{" in ref_name:
3321
+ ref = types.ResourceTemplateReference(
3322
+ type="ref/resource", uri=ref_name
3323
+ )
3324
+ else:
3325
+ ref = types.PromptReference(type="ref/prompt", name=ref_name)
3326
+
3327
+ result = await session.complete(ref, {"name": argument, "value": prefix})
3328
+ completion = result.completion
3329
+ return {
3330
+ "values": list(completion.values or []),
3331
+ "total": completion.total,
3332
+ "hasMore": _mcp_attr(completion, "hasMore"),
3333
+ }
3334
+
3335
+
3336
+ async def _handle_completion(
3337
+ session,
3338
+ spec: str,
3339
+ pretty: bool,
3340
+ raw: bool,
3341
+ toon: bool,
3342
+ head: int | None = None,
3343
+ json_output: bool = False,
3344
+ ):
3345
+ """Run ``completion/complete`` for a prompt or resource-template argument."""
3346
+ data = await _completion_data(session, spec)
3347
+ output_result(
3348
+ data, pretty=pretty, raw=raw, toon=toon, head=head, json_output=json_output
3349
+ )
3350
+
3351
+
3094
3352
  async def _handle_prompts(
3095
3353
  session,
3096
3354
  action: str,
@@ -3213,6 +3471,7 @@ def session_start(
3213
3471
  auth_headers: list[tuple[str, str]],
3214
3472
  env_vars: dict[str, str],
3215
3473
  transport: str = "auto",
3474
+ roots: list[str] | None = None,
3216
3475
  ):
3217
3476
  """Start a persistent session daemon."""
3218
3477
  SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
@@ -3243,6 +3502,7 @@ def session_start(
3243
3502
  "auth_headers": auth_headers,
3244
3503
  "env_vars": env_vars,
3245
3504
  "transport": transport,
3505
+ "roots": list(roots or []),
3246
3506
  }
3247
3507
  )
3248
3508
 
@@ -3280,13 +3540,25 @@ def session_start(
3280
3540
 
3281
3541
 
3282
3542
  def _extract_content_parts(content_list, *, attrs=("text", "data")) -> str:
3283
- """Extract text/data/blob from MCP content objects, joined by newline."""
3543
+ """Extract text/data/blob from MCP content objects, joined by newline.
3544
+
3545
+ ``resource_link`` blocks carry neither ``text`` nor ``data`` — only
3546
+ ``uri``/``name`` — so they used to be dropped silently. Render them as
3547
+ ``name: uri`` (or just the URI) instead.
3548
+ """
3284
3549
  parts = []
3285
- for c in content_list:
3550
+ for content in content_list:
3551
+ is_mapping = isinstance(content, dict)
3286
3552
  for attr in attrs:
3287
- if hasattr(c, attr):
3288
- parts.append(getattr(c, attr))
3553
+ value = content.get(attr) if is_mapping else getattr(content, attr, None)
3554
+ if value is not None:
3555
+ parts.append(value)
3289
3556
  break
3557
+ else:
3558
+ uri = content.get("uri") if is_mapping else getattr(content, "uri", None)
3559
+ if uri is not None:
3560
+ name = content.get("name") if is_mapping else getattr(content, "name", None)
3561
+ parts.append(f"{name}: {uri}" if name else str(uri))
3290
3562
  return "\n".join(parts) if parts else ""
3291
3563
 
3292
3564
 
@@ -3320,7 +3592,7 @@ async def _dispatch_list_tools(session, params):
3320
3592
 
3321
3593
  async def _dispatch_call_tool(session, params):
3322
3594
  result = await session.call_tool(params["name"], params.get("arguments", {}))
3323
- return _extract_content_parts(result.content)
3595
+ return _mcp_dump(result)
3324
3596
 
3325
3597
 
3326
3598
  async def _dispatch_list_resources(session, params):
@@ -3383,6 +3655,10 @@ async def _dispatch_get_prompt(session, params):
3383
3655
  return {"description": result.description or "", "messages": messages}
3384
3656
 
3385
3657
 
3658
+ async def _dispatch_complete(session, params):
3659
+ return await _completion_data(session, params["spec"])
3660
+
3661
+
3386
3662
  _SESSION_DISPATCH = {
3387
3663
  "list_tools": _dispatch_list_tools,
3388
3664
  "call_tool": _dispatch_call_tool,
@@ -3391,6 +3667,7 @@ _SESSION_DISPATCH = {
3391
3667
  "list_resource_templates": _dispatch_list_resource_templates,
3392
3668
  "list_prompts": _dispatch_list_prompts,
3393
3669
  "get_prompt": _dispatch_get_prompt,
3670
+ "complete": _dispatch_complete,
3394
3671
  }
3395
3672
 
3396
3673
 
@@ -3403,6 +3680,7 @@ def _run_session_daemon(config_json: str):
3403
3680
  auth_headers = [tuple(h) for h in config["auth_headers"]]
3404
3681
  env_vars = config["env_vars"]
3405
3682
  transport = config["transport"]
3683
+ roots = config.get("roots", [])
3406
3684
 
3407
3685
  sock_path = _session_sock_path(name)
3408
3686
  meta_path = _session_meta_path(name)
@@ -3523,7 +3801,7 @@ def _run_session_daemon(config_json: str):
3523
3801
  env = {**os.environ, **env_vars}
3524
3802
  params = StdioServerParameters(command=parts[0], args=parts[1:], env=env)
3525
3803
  async with stdio_client(params) as (read, write):
3526
- async with ClientSession(read, write) as session:
3804
+ async with ClientSession(read, write, list_roots_callback=_roots_callback(roots)) as session:
3527
3805
  await _run_with_session(session)
3528
3806
  else:
3529
3807
  headers = dict(auth_headers) if auth_headers else None
@@ -3533,14 +3811,14 @@ def _run_session_daemon(config_json: str):
3533
3811
  read,
3534
3812
  write,
3535
3813
  ):
3536
- async with ClientSession(read, write) as session:
3814
+ async with ClientSession(read, write, list_roots_callback=_roots_callback(roots)) as session:
3537
3815
  await _run_with_session(session)
3538
3816
 
3539
3817
  async def _via_sse():
3540
3818
  from mcp.client.sse import sse_client
3541
3819
 
3542
3820
  async with sse_client(source, headers=headers) as (read, write):
3543
- async with ClientSession(read, write) as session:
3821
+ async with ClientSession(read, write, list_roots_callback=_roots_callback(roots)) as session:
3544
3822
  await _run_with_session(session)
3545
3823
 
3546
3824
  if transport == "sse":
@@ -3668,6 +3946,7 @@ def handle_mcp(
3668
3946
  prompt_action: str | None = None,
3669
3947
  prompt_name: str | None = None,
3670
3948
  prompt_arguments: dict | None = None,
3949
+ complete_spec: str | None = None,
3671
3950
  search_pattern: str | None = None,
3672
3951
  bake_config: BakeConfig | None = None,
3673
3952
  head: int | None = None,
@@ -3689,14 +3968,15 @@ def handle_mcp(
3689
3968
  key = cache_key_override or cache_key_for(config_for_cache)
3690
3969
  src_hash = _source_hash_for(source)
3691
3970
 
3692
- # Resource/prompt operations skip the tool flow entirely
3693
- if resource_action or prompt_action:
3971
+ # Resource/prompt/completion operations skip the tool flow entirely
3972
+ if resource_action or prompt_action or complete_spec:
3694
3973
  extra = dict(
3695
3974
  resource_action=resource_action,
3696
3975
  resource_uri=resource_uri,
3697
3976
  prompt_action=prompt_action,
3698
3977
  prompt_name=prompt_name,
3699
3978
  prompt_arguments=prompt_arguments,
3979
+ complete_spec=complete_spec,
3700
3980
  head=head,
3701
3981
  json_output=json_output,
3702
3982
  )
@@ -3772,12 +4052,12 @@ def handle_mcp(
3772
4052
 
3773
4053
  cmd: CommandDef = args._cmd
3774
4054
 
3775
- if getattr(args, "stdin", False):
4055
+ if getattr(args, "stdin", False) is True:
3776
4056
  arguments = read_stdin_json("MCP tool arguments")
3777
4057
  else:
3778
4058
  arguments = {}
3779
4059
  for p in cmd.params:
3780
- val = getattr(args, p.name.replace("-", "_"), None)
4060
+ val = getattr(args, _param_dest(p), None)
3781
4061
  if val is not None:
3782
4062
  arguments[p.original_name] = coerce_value(val, p.schema)
3783
4063
 
@@ -3824,7 +4104,7 @@ def _fetch_mcp_tools(
3824
4104
  env = {**os.environ, **env_vars}
3825
4105
  params = StdioServerParameters(command=parts[0], args=parts[1:], env=env)
3826
4106
  async with stdio_client(params) as (read, write):
3827
- async with ClientSession(read, write) as session:
4107
+ async with ClientSession(read, write, list_roots_callback=_roots_callback()) as session:
3828
4108
  await session.initialize()
3829
4109
  await _extract_tools(session)
3830
4110
  else:
@@ -3836,7 +4116,7 @@ def _fetch_mcp_tools(
3836
4116
  async with _streamable_streams(
3837
4117
  source, headers=headers, auth=oauth_provider
3838
4118
  ) as (read, write):
3839
- async with ClientSession(read, write) as session:
4119
+ async with ClientSession(read, write, list_roots_callback=_roots_callback()) as session:
3840
4120
  await session.initialize()
3841
4121
  await _extract_tools(session)
3842
4122
 
@@ -3847,7 +4127,7 @@ def _fetch_mcp_tools(
3847
4127
  read,
3848
4128
  write,
3849
4129
  ):
3850
- async with ClientSession(read, write) as session:
4130
+ async with ClientSession(read, write, list_roots_callback=_roots_callback()) as session:
3851
4131
  await session.initialize()
3852
4132
  await _extract_tools(session)
3853
4133
 
@@ -3861,7 +4141,7 @@ def _fetch_mcp_tools(
3861
4141
  except Exception:
3862
4142
  await _via_sse()
3863
4143
 
3864
- anyio.run(_run)
4144
+ _run_mcp_clean(_run, source)
3865
4145
  return tools_result
3866
4146
 
3867
4147
 
@@ -4046,6 +4326,26 @@ def _build_main_parser() -> argparse.ArgumentParser:
4046
4326
  default=[],
4047
4327
  help="Environment variable KEY=VALUE for MCP stdio (repeatable)",
4048
4328
  )
4329
+ pre.add_argument(
4330
+ "--root",
4331
+ action="append",
4332
+ default=[],
4333
+ metavar="PATH|FILE_URI",
4334
+ help=(
4335
+ "Expose a filesystem path or file:// URI to the server (repeatable). "
4336
+ "Workspace-scoped servers request these via roots/list."
4337
+ ),
4338
+ )
4339
+ pre.add_argument(
4340
+ "--complete",
4341
+ default=None,
4342
+ metavar="REF:ARG=PREFIX",
4343
+ help=(
4344
+ "Ask the server to complete an argument value, e.g. "
4345
+ "--complete 'my-prompt:city=San'. REF is a prompt name, or a "
4346
+ "resource URI template when it contains '://'."
4347
+ ),
4348
+ )
4049
4349
  pre.add_argument(
4050
4350
  "--oauth",
4051
4351
  action="store_true",
@@ -4273,6 +4573,7 @@ def _handle_session_operations(
4273
4573
  auth_headers,
4274
4574
  env_vars,
4275
4575
  transport=pre_args.transport,
4576
+ roots=_ROOTS,
4276
4577
  )
4277
4578
  return True
4278
4579
 
@@ -4285,6 +4586,13 @@ def _handle_session_operations(
4285
4586
  pretty=pre_args.pretty, raw=pre_args.raw, toon=pre_args.toon,
4286
4587
  json_output=pre_args.json_output,
4287
4588
  )
4589
+ if pre_args.complete:
4590
+ result = _session_request(
4591
+ sess_name, "complete", {"spec": pre_args.complete}
4592
+ )
4593
+ output_result(result, head=pre_args.head, **_sess_out)
4594
+ return True
4595
+
4288
4596
 
4289
4597
  if pre_args.list_resources:
4290
4598
  result = _session_request(sess_name, "list_resources")
@@ -4366,18 +4674,41 @@ def _handle_session_operations(
4366
4674
  sys.exit(1)
4367
4675
 
4368
4676
  cmd: CommandDef = args._cmd
4369
- if getattr(args, "stdin", False):
4677
+ if getattr(args, "stdin", False) is True:
4370
4678
  arguments = read_stdin_json(f"session {sess_name} tool arguments")
4371
4679
  else:
4372
4680
  arguments = {}
4373
4681
  for p in cmd.params:
4374
- val = getattr(args, p.name.replace("-", "_"), None)
4682
+ val = getattr(args, _param_dest(p), None)
4375
4683
  if val is not None:
4376
4684
  arguments[p.original_name] = coerce_value(val, p.schema)
4377
4685
 
4378
4686
  result = _session_request(
4379
4687
  sess_name, "call_tool", {"name": cmd.tool_name, "arguments": arguments}
4380
4688
  )
4689
+ if isinstance(result, dict) and "isError" in result:
4690
+ content = result.get("content") or []
4691
+ text = _extract_content_parts(content) if isinstance(content, list) else content
4692
+ payload = text or result.get("structuredContent") or ""
4693
+
4694
+ if pre_args.json_output:
4695
+ output_result(result, **_sess_out)
4696
+ if result.get("isError"):
4697
+ sys.exit(1)
4698
+ return True
4699
+
4700
+ if result.get("isError"):
4701
+ error = (
4702
+ payload
4703
+ if isinstance(payload, str)
4704
+ else json.dumps(payload, ensure_ascii=False)
4705
+ )
4706
+ print(
4707
+ f"Error: {error or f'tool {cmd.tool_name!r} reported an error'}",
4708
+ file=sys.stderr,
4709
+ )
4710
+ sys.exit(1)
4711
+ result = payload
4381
4712
  output_result(result, **_sess_out)
4382
4713
  return True
4383
4714
 
@@ -4517,6 +4848,12 @@ def _main_impl(argv: list[str], bake_config: BakeConfig | None = None):
4517
4848
  pre_args, leftover = pre.parse_known_args(global_argv)
4518
4849
  remaining = leftover + tool_argv
4519
4850
 
4851
+ try:
4852
+ _ROOTS[:] = [_normalize_root(raw) for raw in pre_args.root]
4853
+ except ValueError as exc:
4854
+ print(f"Error: {exc}", file=sys.stderr)
4855
+ sys.exit(1)
4856
+
4520
4857
  # --search implies --list
4521
4858
  search_pattern = pre_args.search_pattern
4522
4859
  if search_pattern:
@@ -4588,6 +4925,7 @@ def _main_impl(argv: list[str], bake_config: BakeConfig | None = None):
4588
4925
  prompt_action=prompt_action,
4589
4926
  prompt_name=prompt_name,
4590
4927
  prompt_arguments=prompt_arguments,
4928
+ complete_spec=pre_args.complete,
4591
4929
  search_pattern=search_pattern,
4592
4930
  bake_config=bake_config,
4593
4931
  head=pre_args.head,
File without changes
File without changes