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.
- {mcp2cli-3.7.0 → mcp2cli-3.8.0}/PKG-INFO +10 -8
- {mcp2cli-3.7.0 → mcp2cli-3.8.0}/README.md +9 -7
- {mcp2cli-3.7.0 → mcp2cli-3.8.0}/pyproject.toml +1 -1
- {mcp2cli-3.7.0 → mcp2cli-3.8.0}/pyproject.toml.orig +1 -1
- {mcp2cli-3.7.0 → mcp2cli-3.8.0}/src/mcp2cli/__init__.py +693 -128
- {mcp2cli-3.7.0 → mcp2cli-3.8.0}/src/mcp2cli/__main__.py +0 -0
- {mcp2cli-3.7.0 → mcp2cli-3.8.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.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
|
|
236
|
-
--include "search-*,list-*" --exclude "
|
|
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 @
|
|
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
|
|
217
|
-
--include "search-*,list-*" --exclude "
|
|
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 @
|
|
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>
|
|
@@ -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
|
-
|
|
639
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
-
|
|
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
|
|
1799
|
+
if method not in _OPENAPI_METHODS:
|
|
1436
1800
|
continue
|
|
1437
1801
|
if not isinstance(details, dict):
|
|
1438
1802
|
continue
|
|
1439
1803
|
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
-
|
|
1463
|
-
|
|
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
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
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
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
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
|
-
|
|
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\
|
|
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
|
-
|
|
2503
|
-
|
|
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(
|
|
3237
|
+
path = path.replace(
|
|
3238
|
+
f"{{{p.original_name}}}", _serialize_openapi_path_param(val, p)
|
|
3239
|
+
)
|
|
2715
3240
|
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
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 =
|
|
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
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
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 =
|
|
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
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|