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.
- {mcp2cli-3.5.0 → mcp2cli-3.7.0}/PKG-INFO +27 -1
- {mcp2cli-3.5.0 → mcp2cli-3.7.0}/README.md +26 -0
- {mcp2cli-3.5.0 → mcp2cli-3.7.0}/pyproject.toml +1 -1
- {mcp2cli-3.5.0 → mcp2cli-3.7.0}/pyproject.toml.orig +1 -1
- {mcp2cli-3.5.0 → mcp2cli-3.7.0}/src/mcp2cli/__init__.py +388 -50
- {mcp2cli-3.5.0 → mcp2cli-3.7.0}/src/mcp2cli/__main__.py +0 -0
- {mcp2cli-3.5.0 → mcp2cli-3.7.0}/src/mcp2cli/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp2cli
|
|
3
|
-
Version: 3.
|
|
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)
|
|
@@ -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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3550
|
+
for content in content_list:
|
|
3551
|
+
is_mapping = isinstance(content, dict)
|
|
3286
3552
|
for attr in attrs:
|
|
3287
|
-
if
|
|
3288
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|