mcp-cli-skill 0.8.4__tar.gz → 0.9.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.
- {mcp_cli_skill-0.8.4 → mcp_cli_skill-0.9.0}/PKG-INFO +11 -1
- {mcp_cli_skill-0.8.4 → mcp_cli_skill-0.9.0}/README.md +10 -0
- {mcp_cli_skill-0.8.4 → mcp_cli_skill-0.9.0}/scripts/mcp_call.py +116 -119
- {mcp_cli_skill-0.8.4 → mcp_cli_skill-0.9.0}/src/mcp_cli_skill/__init__.py +1 -1
- {mcp_cli_skill-0.8.4 → mcp_cli_skill-0.9.0}/src/mcp_cli_skill/cli.py +116 -119
- {mcp_cli_skill-0.8.4 → mcp_cli_skill-0.9.0}/.github/workflows/publish.yml +0 -0
- {mcp_cli_skill-0.8.4 → mcp_cli_skill-0.9.0}/.gitignore +0 -0
- {mcp_cli_skill-0.8.4 → mcp_cli_skill-0.9.0}/SKILL.md +0 -0
- {mcp_cli_skill-0.8.4 → mcp_cli_skill-0.9.0}/pyproject.toml +0 -0
- {mcp_cli_skill-0.8.4 → mcp_cli_skill-0.9.0}/tests/test_args.py +0 -0
- {mcp_cli_skill-0.8.4 → mcp_cli_skill-0.9.0}/tests/test_oauth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mcp-cli-skill
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Call any MCP server tool from the command line with shell composition support
|
|
5
5
|
Project-URL: Homepage, https://github.com/wise-toddler/mcp-cli-skill
|
|
6
6
|
Project-URL: Repository, https://github.com/wise-toddler/mcp-cli-skill
|
|
@@ -188,3 +188,13 @@ The key insight: an LLM agent writes this script in one shot, runs it via its Ba
|
|
|
188
188
|
## How it works
|
|
189
189
|
|
|
190
190
|
Reads MCP server config from `~/.mcp-cli/servers.json` (standalone, agent-agnostic). On first run, seeds from `~/.claude/settings.json` and `~/.claude.json`. For stdio servers, spawns the server as a subprocess and speaks JSON-RPC over stdin/stdout. For HTTP servers, sends JSON-RPC over HTTP with session ID tracking. Zero dependencies — pure Python stdlib.
|
|
191
|
+
|
|
192
|
+
## Releasing
|
|
193
|
+
|
|
194
|
+
Bump `__version__` in `src/mcp_cli_skill/__init__.py`, sync `scripts/mcp_call.py`, commit, then push a matching tag:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
git tag v0.8.5 && git push origin v0.8.5
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
CI runs the tests, checks the tag matches `__version__`, and publishes to PyPI via Trusted Publishing (no token).
|
|
@@ -172,3 +172,13 @@ The key insight: an LLM agent writes this script in one shot, runs it via its Ba
|
|
|
172
172
|
## How it works
|
|
173
173
|
|
|
174
174
|
Reads MCP server config from `~/.mcp-cli/servers.json` (standalone, agent-agnostic). On first run, seeds from `~/.claude/settings.json` and `~/.claude.json`. For stdio servers, spawns the server as a subprocess and speaks JSON-RPC over stdin/stdout. For HTTP servers, sends JSON-RPC over HTTP with session ID tracking. Zero dependencies — pure Python stdlib.
|
|
175
|
+
|
|
176
|
+
## Releasing
|
|
177
|
+
|
|
178
|
+
Bump `__version__` in `src/mcp_cli_skill/__init__.py`, sync `scripts/mcp_call.py`, commit, then push a matching tag:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
git tag v0.8.5 && git push origin v0.8.5
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
CI runs the tests, checks the tag matches `__version__`, and publishes to PyPI via Trusted Publishing (no token).
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Call any MCP server tool from CLI with --flag=value args."""
|
|
3
3
|
import base64
|
|
4
|
+
import contextlib
|
|
4
5
|
import difflib
|
|
5
6
|
import hashlib
|
|
6
7
|
import http.server
|
|
@@ -48,6 +49,14 @@ SERVER_FLAGS = ("--tools", "--discover", "--help")
|
|
|
48
49
|
TOOL_FLAGS = ("--help", "--schema", "--input-json")
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
class McpError(Exception):
|
|
53
|
+
"""Fatal error from library code; main() prints msg to stderr and exits with code."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, msg, code=1):
|
|
56
|
+
super().__init__(msg)
|
|
57
|
+
self.msg, self.code = msg, code
|
|
58
|
+
|
|
59
|
+
|
|
51
60
|
def _load_json(path):
|
|
52
61
|
"""Load JSON file if it exists."""
|
|
53
62
|
if os.path.exists(path):
|
|
@@ -57,9 +66,10 @@ def _load_json(path):
|
|
|
57
66
|
|
|
58
67
|
|
|
59
68
|
def _save_config(servers):
|
|
60
|
-
"""Save servers to standalone config."""
|
|
69
|
+
"""Save servers to standalone config (mode 0600: headers may hold tokens)."""
|
|
61
70
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
62
|
-
with open(CONFIG_PATH, "w") as f:
|
|
71
|
+
with os.fdopen(os.open(CONFIG_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f:
|
|
72
|
+
os.chmod(CONFIG_PATH, 0o600) # also tightens a config written before 0600
|
|
63
73
|
json.dump(servers, f, indent=2)
|
|
64
74
|
|
|
65
75
|
|
|
@@ -453,26 +463,22 @@ class HttpSession:
|
|
|
453
463
|
if self._oauth_token:
|
|
454
464
|
return self.rpc(method, params, msg_id, _retried=True)
|
|
455
465
|
if self._oauth_token:
|
|
456
|
-
|
|
457
|
-
sys.exit(EXIT_NEEDS_LOGIN)
|
|
466
|
+
raise McpError(f"Error: {name} rejected a freshly refreshed token (HTTP 401). Ask the user to run: mcp-call --logout {name} && mcp-call --login {name}", EXIT_NEEDS_LOGIN)
|
|
458
467
|
if "resource_metadata=" in e.headers.get("WWW-Authenticate", ""):
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
print(f"Error: HTTP {e.code} from {self.url}", file=sys.stderr)
|
|
468
|
+
raise McpError(f"Error: {name} needs interactive browser login. Ask the user to run: mcp-call --login {name} (do not run it yourself; it opens a browser).", EXIT_NEEDS_LOGIN)
|
|
469
|
+
msg = f"Error: HTTP {e.code} from {self.url}"
|
|
462
470
|
if body.strip():
|
|
463
471
|
# strip HTML, show first 200 chars
|
|
464
472
|
clean = body.strip()
|
|
465
473
|
if "<html" in clean.lower():
|
|
466
474
|
clean = "Server returned HTML error page (auth required?)"
|
|
467
|
-
|
|
468
|
-
|
|
475
|
+
msg += "\n" + clean[:500]
|
|
476
|
+
raise McpError(msg)
|
|
469
477
|
except urllib.error.URLError as e:
|
|
470
|
-
|
|
471
|
-
sys.exit(1)
|
|
478
|
+
raise McpError(f"Error: cannot connect to {self.url}: {e.reason}")
|
|
472
479
|
except TimeoutError:
|
|
473
480
|
# urlopen raises bare TimeoutError in Python 3.10+, not URLError.
|
|
474
|
-
|
|
475
|
-
sys.exit(1)
|
|
481
|
+
raise McpError(f"Error: request to {self.url} timed out. Set MCP_CALL_HTTP_TIMEOUT=<sec> for longer.")
|
|
476
482
|
|
|
477
483
|
def notify(self, method, params=None):
|
|
478
484
|
"""Send JSON-RPC notification (no id, ignore response)."""
|
|
@@ -489,6 +495,15 @@ class HttpSession:
|
|
|
489
495
|
except Exception:
|
|
490
496
|
pass
|
|
491
497
|
|
|
498
|
+
def close(self):
|
|
499
|
+
"""No-op: each request opens and closes its own connection."""
|
|
500
|
+
|
|
501
|
+
def __enter__(self):
|
|
502
|
+
return self
|
|
503
|
+
|
|
504
|
+
def __exit__(self, *exc):
|
|
505
|
+
self.close()
|
|
506
|
+
|
|
492
507
|
|
|
493
508
|
def _parse_sse(body, expected_id):
|
|
494
509
|
"""Parse SSE response and extract JSON-RPC message matching expected_id."""
|
|
@@ -503,31 +518,6 @@ def _parse_sse(body, expected_id):
|
|
|
503
518
|
return None
|
|
504
519
|
|
|
505
520
|
|
|
506
|
-
def http_init(session):
|
|
507
|
-
"""Initialize HTTP MCP server."""
|
|
508
|
-
session.rpc("initialize", {
|
|
509
|
-
"protocolVersion": "2024-11-05",
|
|
510
|
-
"capabilities": {},
|
|
511
|
-
"clientInfo": {"name": "mcp-cli", "version": "1.0"}
|
|
512
|
-
}, msg_id=1)
|
|
513
|
-
session.notify("notifications/initialized")
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
def http_call_tool(url, tool_name, tool_args, extra_headers=None, server_name=None):
|
|
518
|
-
"""Call a tool on HTTP MCP server."""
|
|
519
|
-
session = HttpSession(url, extra_headers, server_name)
|
|
520
|
-
http_init(session)
|
|
521
|
-
resp = session.rpc("tools/call", {"name": tool_name, "arguments": tool_args}, msg_id=3)
|
|
522
|
-
if not resp:
|
|
523
|
-
print("Error: no response", file=sys.stderr)
|
|
524
|
-
sys.exit(1)
|
|
525
|
-
if "error" in resp:
|
|
526
|
-
print(json.dumps(resp["error"], indent=2), file=sys.stderr)
|
|
527
|
-
sys.exit(1)
|
|
528
|
-
_print_result(resp.get("result", {}))
|
|
529
|
-
|
|
530
|
-
|
|
531
521
|
# --- OAuth ---
|
|
532
522
|
# Browser login via discovery + dynamic client registration + PKCE; tokens never printed.
|
|
533
523
|
|
|
@@ -535,9 +525,8 @@ _tokens_warned = False # warn about a corrupt tokens.json once per run
|
|
|
535
525
|
|
|
536
526
|
|
|
537
527
|
def _die(msg, code=1):
|
|
538
|
-
"""
|
|
539
|
-
|
|
540
|
-
sys.exit(code)
|
|
528
|
+
"""Raise McpError; main() prints msg to stderr and exits with code."""
|
|
529
|
+
raise McpError(msg, code)
|
|
541
530
|
|
|
542
531
|
|
|
543
532
|
def _clean(text):
|
|
@@ -885,48 +874,81 @@ def spawn_server(config):
|
|
|
885
874
|
|
|
886
875
|
|
|
887
876
|
def check_alive(proc):
|
|
888
|
-
"""Check if server process is still running
|
|
877
|
+
"""Check if server process is still running; raise McpError with its stderr if dead."""
|
|
889
878
|
if proc.poll() is not None:
|
|
890
879
|
stderr = proc.stderr.read() if proc.stderr else ""
|
|
891
|
-
|
|
880
|
+
msg = f"Error: server exited with code {proc.returncode}"
|
|
892
881
|
if stderr.strip():
|
|
893
|
-
|
|
894
|
-
|
|
882
|
+
msg += "\n" + stderr.strip()
|
|
883
|
+
raise McpError(msg)
|
|
895
884
|
|
|
896
885
|
|
|
897
|
-
|
|
898
|
-
"""
|
|
899
|
-
check_alive(proc)
|
|
900
|
-
try:
|
|
901
|
-
send(proc, "initialize", {
|
|
902
|
-
"protocolVersion": "2024-11-05",
|
|
903
|
-
"capabilities": {},
|
|
904
|
-
"clientInfo": {"name": "mcp-cli", "version": "1.0"}
|
|
905
|
-
}, msg_id=1)
|
|
906
|
-
resp = recv(proc, expected_id=1)
|
|
907
|
-
if not resp:
|
|
908
|
-
check_alive(proc)
|
|
909
|
-
print("Error: no response from server during init", file=sys.stderr)
|
|
910
|
-
sys.exit(1)
|
|
911
|
-
send(proc, "notifications/initialized")
|
|
912
|
-
except BrokenPipeError:
|
|
913
|
-
check_alive(proc)
|
|
914
|
-
print("Error: server crashed during init", file=sys.stderr)
|
|
915
|
-
sys.exit(1)
|
|
886
|
+
class StdioSession:
|
|
887
|
+
"""Stdio MCP server process behind the same rpc/notify interface as HttpSession."""
|
|
916
888
|
|
|
889
|
+
def __init__(self, config):
|
|
890
|
+
self.proc = spawn_server(config)
|
|
917
891
|
|
|
892
|
+
def rpc(self, method, params=None, msg_id=1):
|
|
893
|
+
"""Send JSON-RPC request via stdio and return the matching response (None if none)."""
|
|
894
|
+
send(self.proc, method, params, msg_id=msg_id)
|
|
895
|
+
return recv(self.proc, expected_id=msg_id)
|
|
918
896
|
|
|
919
|
-
def
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
897
|
+
def notify(self, method, params=None):
|
|
898
|
+
"""Send JSON-RPC notification (no id, no response)."""
|
|
899
|
+
send(self.proc, method, params)
|
|
900
|
+
|
|
901
|
+
def close(self):
|
|
902
|
+
"""Stop the server: terminate, then kill if it hasn't exited within 5s."""
|
|
903
|
+
self.proc.terminate()
|
|
904
|
+
try:
|
|
905
|
+
self.proc.wait(timeout=5)
|
|
906
|
+
except subprocess.TimeoutExpired:
|
|
907
|
+
self.proc.kill()
|
|
908
|
+
|
|
909
|
+
def __enter__(self):
|
|
910
|
+
return self
|
|
911
|
+
|
|
912
|
+
def __exit__(self, *exc):
|
|
913
|
+
self.close()
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
@contextlib.contextmanager
|
|
917
|
+
def _open_session(config, server_name=""):
|
|
918
|
+
"""Yield an HTTP or stdio session after the initialize handshake; closes it on exit."""
|
|
919
|
+
stdio = not is_http(config)
|
|
920
|
+
session = StdioSession(config) if stdio else HttpSession(config["url"], config.get("headers"), server_name)
|
|
921
|
+
with session:
|
|
922
|
+
if stdio:
|
|
923
|
+
check_alive(session.proc)
|
|
924
|
+
try:
|
|
925
|
+
resp = session.rpc("initialize", {
|
|
926
|
+
"protocolVersion": "2024-11-05",
|
|
927
|
+
"capabilities": {},
|
|
928
|
+
"clientInfo": {"name": "mcp-cli", "version": "1.0"}
|
|
929
|
+
}, msg_id=1)
|
|
930
|
+
# HTTP ignores the init response; a silent stdio server is dead or not speaking MCP
|
|
931
|
+
if stdio and not resp:
|
|
932
|
+
check_alive(session.proc)
|
|
933
|
+
raise McpError("Error: no response from server during init")
|
|
934
|
+
session.notify("notifications/initialized")
|
|
935
|
+
except BrokenPipeError:
|
|
936
|
+
if not stdio:
|
|
937
|
+
raise
|
|
938
|
+
check_alive(session.proc)
|
|
939
|
+
raise McpError("Error: server crashed during init")
|
|
940
|
+
yield session
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def call_tool(config, tool_name, tool_args, server_name=""):
|
|
944
|
+
"""Call a tool on an HTTP or stdio server and print the result."""
|
|
945
|
+
with _open_session(config, server_name) as session:
|
|
946
|
+
resp = session.rpc("tools/call", {"name": tool_name, "arguments": tool_args}, msg_id=3)
|
|
947
|
+
if not resp:
|
|
948
|
+
raise McpError("Error: no response")
|
|
949
|
+
if "error" in resp:
|
|
950
|
+
raise McpError(json.dumps(resp["error"], indent=2))
|
|
951
|
+
_print_result(resp.get("result", {}))
|
|
930
952
|
|
|
931
953
|
|
|
932
954
|
# --- Tool discovery ---
|
|
@@ -934,9 +956,7 @@ def stdio_call_tool(proc, tool_name, tool_args):
|
|
|
934
956
|
def fetch_tools(config, server_name=""):
|
|
935
957
|
"""Fetch tools list from server (HTTP or stdio), caching for completion."""
|
|
936
958
|
tools = []
|
|
937
|
-
|
|
938
|
-
session = HttpSession(config["url"], config.get("headers"), server_name)
|
|
939
|
-
http_init(session)
|
|
959
|
+
with _open_session(config, server_name) as session:
|
|
940
960
|
cursor, msg_id = None, 2
|
|
941
961
|
while True:
|
|
942
962
|
params = {"cursor": cursor} if cursor else {}
|
|
@@ -948,28 +968,6 @@ def fetch_tools(config, server_name=""):
|
|
|
948
968
|
if not cursor:
|
|
949
969
|
break
|
|
950
970
|
msg_id += 1
|
|
951
|
-
else:
|
|
952
|
-
proc = spawn_server(config)
|
|
953
|
-
try:
|
|
954
|
-
init_server(proc)
|
|
955
|
-
cursor, msg_id = None, 2
|
|
956
|
-
while True:
|
|
957
|
-
params = {"cursor": cursor} if cursor else {}
|
|
958
|
-
send(proc, "tools/list", params, msg_id=msg_id)
|
|
959
|
-
resp = recv(proc, expected_id=msg_id)
|
|
960
|
-
if not resp or "result" not in resp:
|
|
961
|
-
break
|
|
962
|
-
tools += resp["result"].get("tools", [])
|
|
963
|
-
cursor = resp["result"].get("nextCursor")
|
|
964
|
-
if not cursor:
|
|
965
|
-
break
|
|
966
|
-
msg_id += 1
|
|
967
|
-
finally:
|
|
968
|
-
proc.terminate()
|
|
969
|
-
try:
|
|
970
|
-
proc.wait(timeout=5)
|
|
971
|
-
except subprocess.TimeoutExpired:
|
|
972
|
-
proc.kill()
|
|
973
971
|
if server_name and tools:
|
|
974
972
|
_cache_write(server_name, tools)
|
|
975
973
|
return tools
|
|
@@ -1217,8 +1215,10 @@ def refresh_completions():
|
|
|
1217
1215
|
else:
|
|
1218
1216
|
fail += 1
|
|
1219
1217
|
print(f" · {name}: no tools returned", file=sys.stderr)
|
|
1220
|
-
except
|
|
1221
|
-
# fetch_tools
|
|
1218
|
+
except McpError as e:
|
|
1219
|
+
# fetch_tools raises McpError on auth/transport errors; print it and keep going
|
|
1220
|
+
if e.msg:
|
|
1221
|
+
print(e.msg, file=sys.stderr)
|
|
1222
1222
|
fail += 1
|
|
1223
1223
|
print(f" ✗ {name}: failed", file=sys.stderr)
|
|
1224
1224
|
except Exception as e:
|
|
@@ -1338,11 +1338,10 @@ def do_completion():
|
|
|
1338
1338
|
# --- Main ---
|
|
1339
1339
|
|
|
1340
1340
|
def _unknown_tool(server_name, tool_name, names):
|
|
1341
|
-
"""
|
|
1341
|
+
"""Raise McpError (exit 2) with close-match suggestions for a tool name the server doesn't have."""
|
|
1342
1342
|
close = difflib.get_close_matches(tool_name, names, n=3, cutoff=0.4)
|
|
1343
1343
|
hint = f" Did you mean: {', '.join(close)}?" if close else ""
|
|
1344
|
-
|
|
1345
|
-
sys.exit(2)
|
|
1344
|
+
raise McpError(f"Error: unknown tool '{tool_name}' for {server_name}.{hint} List tools with: mcp-call {server_name} --tools", 2)
|
|
1346
1345
|
|
|
1347
1346
|
|
|
1348
1347
|
def run_server(config, tool_name, tool_args, server_name=""):
|
|
@@ -1377,22 +1376,10 @@ def run_server(config, tool_name, tool_args, server_name=""):
|
|
|
1377
1376
|
names = [t.get("name") for t in fetch_tools(config, server_name)]
|
|
1378
1377
|
if names and tool_name not in names: # empty list = server can't list tools; let the call decide
|
|
1379
1378
|
_unknown_tool(server_name, tool_name, names)
|
|
1380
|
-
|
|
1381
|
-
http_call_tool(config["url"], tool_name, tool_args, config.get("headers"), server_name)
|
|
1382
|
-
else:
|
|
1383
|
-
proc = spawn_server(config)
|
|
1384
|
-
try:
|
|
1385
|
-
init_server(proc)
|
|
1386
|
-
stdio_call_tool(proc, tool_name, tool_args)
|
|
1387
|
-
finally:
|
|
1388
|
-
proc.terminate()
|
|
1389
|
-
try:
|
|
1390
|
-
proc.wait(timeout=5)
|
|
1391
|
-
except subprocess.TimeoutExpired:
|
|
1392
|
-
proc.kill()
|
|
1379
|
+
call_tool(config, tool_name, tool_args, server_name)
|
|
1393
1380
|
|
|
1394
1381
|
|
|
1395
|
-
def
|
|
1382
|
+
def _main():
|
|
1396
1383
|
# Shell completion is the fast path — runs on every TAB. Handle it
|
|
1397
1384
|
# before read_config() (which can print "Seeded..." to stderr).
|
|
1398
1385
|
if os.environ.get("_MCP_CALL_COMPLETE"):
|
|
@@ -1457,5 +1444,15 @@ def main():
|
|
|
1457
1444
|
run_server(servers[server_name], tool_name, tool_args, server_name)
|
|
1458
1445
|
|
|
1459
1446
|
|
|
1447
|
+
def main():
|
|
1448
|
+
"""CLI entry point: a McpError from anywhere becomes its stderr message and exit code."""
|
|
1449
|
+
try:
|
|
1450
|
+
_main()
|
|
1451
|
+
except McpError as e:
|
|
1452
|
+
if e.msg:
|
|
1453
|
+
print(e.msg, file=sys.stderr)
|
|
1454
|
+
sys.exit(e.code)
|
|
1455
|
+
|
|
1456
|
+
|
|
1460
1457
|
if __name__ == "__main__":
|
|
1461
1458
|
main()
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""MCP CLI - Call any MCP server tool from the command line."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.9.0"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Call any MCP server tool from CLI with --flag=value args."""
|
|
3
3
|
import base64
|
|
4
|
+
import contextlib
|
|
4
5
|
import difflib
|
|
5
6
|
import hashlib
|
|
6
7
|
import http.server
|
|
@@ -48,6 +49,14 @@ SERVER_FLAGS = ("--tools", "--discover", "--help")
|
|
|
48
49
|
TOOL_FLAGS = ("--help", "--schema", "--input-json")
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
class McpError(Exception):
|
|
53
|
+
"""Fatal error from library code; main() prints msg to stderr and exits with code."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, msg, code=1):
|
|
56
|
+
super().__init__(msg)
|
|
57
|
+
self.msg, self.code = msg, code
|
|
58
|
+
|
|
59
|
+
|
|
51
60
|
def _load_json(path):
|
|
52
61
|
"""Load JSON file if it exists."""
|
|
53
62
|
if os.path.exists(path):
|
|
@@ -57,9 +66,10 @@ def _load_json(path):
|
|
|
57
66
|
|
|
58
67
|
|
|
59
68
|
def _save_config(servers):
|
|
60
|
-
"""Save servers to standalone config."""
|
|
69
|
+
"""Save servers to standalone config (mode 0600: headers may hold tokens)."""
|
|
61
70
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
62
|
-
with open(CONFIG_PATH, "w") as f:
|
|
71
|
+
with os.fdopen(os.open(CONFIG_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f:
|
|
72
|
+
os.chmod(CONFIG_PATH, 0o600) # also tightens a config written before 0600
|
|
63
73
|
json.dump(servers, f, indent=2)
|
|
64
74
|
|
|
65
75
|
|
|
@@ -453,26 +463,22 @@ class HttpSession:
|
|
|
453
463
|
if self._oauth_token:
|
|
454
464
|
return self.rpc(method, params, msg_id, _retried=True)
|
|
455
465
|
if self._oauth_token:
|
|
456
|
-
|
|
457
|
-
sys.exit(EXIT_NEEDS_LOGIN)
|
|
466
|
+
raise McpError(f"Error: {name} rejected a freshly refreshed token (HTTP 401). Ask the user to run: mcp-call --logout {name} && mcp-call --login {name}", EXIT_NEEDS_LOGIN)
|
|
458
467
|
if "resource_metadata=" in e.headers.get("WWW-Authenticate", ""):
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
print(f"Error: HTTP {e.code} from {self.url}", file=sys.stderr)
|
|
468
|
+
raise McpError(f"Error: {name} needs interactive browser login. Ask the user to run: mcp-call --login {name} (do not run it yourself; it opens a browser).", EXIT_NEEDS_LOGIN)
|
|
469
|
+
msg = f"Error: HTTP {e.code} from {self.url}"
|
|
462
470
|
if body.strip():
|
|
463
471
|
# strip HTML, show first 200 chars
|
|
464
472
|
clean = body.strip()
|
|
465
473
|
if "<html" in clean.lower():
|
|
466
474
|
clean = "Server returned HTML error page (auth required?)"
|
|
467
|
-
|
|
468
|
-
|
|
475
|
+
msg += "\n" + clean[:500]
|
|
476
|
+
raise McpError(msg)
|
|
469
477
|
except urllib.error.URLError as e:
|
|
470
|
-
|
|
471
|
-
sys.exit(1)
|
|
478
|
+
raise McpError(f"Error: cannot connect to {self.url}: {e.reason}")
|
|
472
479
|
except TimeoutError:
|
|
473
480
|
# urlopen raises bare TimeoutError in Python 3.10+, not URLError.
|
|
474
|
-
|
|
475
|
-
sys.exit(1)
|
|
481
|
+
raise McpError(f"Error: request to {self.url} timed out. Set MCP_CALL_HTTP_TIMEOUT=<sec> for longer.")
|
|
476
482
|
|
|
477
483
|
def notify(self, method, params=None):
|
|
478
484
|
"""Send JSON-RPC notification (no id, ignore response)."""
|
|
@@ -489,6 +495,15 @@ class HttpSession:
|
|
|
489
495
|
except Exception:
|
|
490
496
|
pass
|
|
491
497
|
|
|
498
|
+
def close(self):
|
|
499
|
+
"""No-op: each request opens and closes its own connection."""
|
|
500
|
+
|
|
501
|
+
def __enter__(self):
|
|
502
|
+
return self
|
|
503
|
+
|
|
504
|
+
def __exit__(self, *exc):
|
|
505
|
+
self.close()
|
|
506
|
+
|
|
492
507
|
|
|
493
508
|
def _parse_sse(body, expected_id):
|
|
494
509
|
"""Parse SSE response and extract JSON-RPC message matching expected_id."""
|
|
@@ -503,31 +518,6 @@ def _parse_sse(body, expected_id):
|
|
|
503
518
|
return None
|
|
504
519
|
|
|
505
520
|
|
|
506
|
-
def http_init(session):
|
|
507
|
-
"""Initialize HTTP MCP server."""
|
|
508
|
-
session.rpc("initialize", {
|
|
509
|
-
"protocolVersion": "2024-11-05",
|
|
510
|
-
"capabilities": {},
|
|
511
|
-
"clientInfo": {"name": "mcp-cli", "version": "1.0"}
|
|
512
|
-
}, msg_id=1)
|
|
513
|
-
session.notify("notifications/initialized")
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
def http_call_tool(url, tool_name, tool_args, extra_headers=None, server_name=None):
|
|
518
|
-
"""Call a tool on HTTP MCP server."""
|
|
519
|
-
session = HttpSession(url, extra_headers, server_name)
|
|
520
|
-
http_init(session)
|
|
521
|
-
resp = session.rpc("tools/call", {"name": tool_name, "arguments": tool_args}, msg_id=3)
|
|
522
|
-
if not resp:
|
|
523
|
-
print("Error: no response", file=sys.stderr)
|
|
524
|
-
sys.exit(1)
|
|
525
|
-
if "error" in resp:
|
|
526
|
-
print(json.dumps(resp["error"], indent=2), file=sys.stderr)
|
|
527
|
-
sys.exit(1)
|
|
528
|
-
_print_result(resp.get("result", {}))
|
|
529
|
-
|
|
530
|
-
|
|
531
521
|
# --- OAuth ---
|
|
532
522
|
# Browser login via discovery + dynamic client registration + PKCE; tokens never printed.
|
|
533
523
|
|
|
@@ -535,9 +525,8 @@ _tokens_warned = False # warn about a corrupt tokens.json once per run
|
|
|
535
525
|
|
|
536
526
|
|
|
537
527
|
def _die(msg, code=1):
|
|
538
|
-
"""
|
|
539
|
-
|
|
540
|
-
sys.exit(code)
|
|
528
|
+
"""Raise McpError; main() prints msg to stderr and exits with code."""
|
|
529
|
+
raise McpError(msg, code)
|
|
541
530
|
|
|
542
531
|
|
|
543
532
|
def _clean(text):
|
|
@@ -885,48 +874,81 @@ def spawn_server(config):
|
|
|
885
874
|
|
|
886
875
|
|
|
887
876
|
def check_alive(proc):
|
|
888
|
-
"""Check if server process is still running
|
|
877
|
+
"""Check if server process is still running; raise McpError with its stderr if dead."""
|
|
889
878
|
if proc.poll() is not None:
|
|
890
879
|
stderr = proc.stderr.read() if proc.stderr else ""
|
|
891
|
-
|
|
880
|
+
msg = f"Error: server exited with code {proc.returncode}"
|
|
892
881
|
if stderr.strip():
|
|
893
|
-
|
|
894
|
-
|
|
882
|
+
msg += "\n" + stderr.strip()
|
|
883
|
+
raise McpError(msg)
|
|
895
884
|
|
|
896
885
|
|
|
897
|
-
|
|
898
|
-
"""
|
|
899
|
-
check_alive(proc)
|
|
900
|
-
try:
|
|
901
|
-
send(proc, "initialize", {
|
|
902
|
-
"protocolVersion": "2024-11-05",
|
|
903
|
-
"capabilities": {},
|
|
904
|
-
"clientInfo": {"name": "mcp-cli", "version": "1.0"}
|
|
905
|
-
}, msg_id=1)
|
|
906
|
-
resp = recv(proc, expected_id=1)
|
|
907
|
-
if not resp:
|
|
908
|
-
check_alive(proc)
|
|
909
|
-
print("Error: no response from server during init", file=sys.stderr)
|
|
910
|
-
sys.exit(1)
|
|
911
|
-
send(proc, "notifications/initialized")
|
|
912
|
-
except BrokenPipeError:
|
|
913
|
-
check_alive(proc)
|
|
914
|
-
print("Error: server crashed during init", file=sys.stderr)
|
|
915
|
-
sys.exit(1)
|
|
886
|
+
class StdioSession:
|
|
887
|
+
"""Stdio MCP server process behind the same rpc/notify interface as HttpSession."""
|
|
916
888
|
|
|
889
|
+
def __init__(self, config):
|
|
890
|
+
self.proc = spawn_server(config)
|
|
917
891
|
|
|
892
|
+
def rpc(self, method, params=None, msg_id=1):
|
|
893
|
+
"""Send JSON-RPC request via stdio and return the matching response (None if none)."""
|
|
894
|
+
send(self.proc, method, params, msg_id=msg_id)
|
|
895
|
+
return recv(self.proc, expected_id=msg_id)
|
|
918
896
|
|
|
919
|
-
def
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
897
|
+
def notify(self, method, params=None):
|
|
898
|
+
"""Send JSON-RPC notification (no id, no response)."""
|
|
899
|
+
send(self.proc, method, params)
|
|
900
|
+
|
|
901
|
+
def close(self):
|
|
902
|
+
"""Stop the server: terminate, then kill if it hasn't exited within 5s."""
|
|
903
|
+
self.proc.terminate()
|
|
904
|
+
try:
|
|
905
|
+
self.proc.wait(timeout=5)
|
|
906
|
+
except subprocess.TimeoutExpired:
|
|
907
|
+
self.proc.kill()
|
|
908
|
+
|
|
909
|
+
def __enter__(self):
|
|
910
|
+
return self
|
|
911
|
+
|
|
912
|
+
def __exit__(self, *exc):
|
|
913
|
+
self.close()
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
@contextlib.contextmanager
|
|
917
|
+
def _open_session(config, server_name=""):
|
|
918
|
+
"""Yield an HTTP or stdio session after the initialize handshake; closes it on exit."""
|
|
919
|
+
stdio = not is_http(config)
|
|
920
|
+
session = StdioSession(config) if stdio else HttpSession(config["url"], config.get("headers"), server_name)
|
|
921
|
+
with session:
|
|
922
|
+
if stdio:
|
|
923
|
+
check_alive(session.proc)
|
|
924
|
+
try:
|
|
925
|
+
resp = session.rpc("initialize", {
|
|
926
|
+
"protocolVersion": "2024-11-05",
|
|
927
|
+
"capabilities": {},
|
|
928
|
+
"clientInfo": {"name": "mcp-cli", "version": "1.0"}
|
|
929
|
+
}, msg_id=1)
|
|
930
|
+
# HTTP ignores the init response; a silent stdio server is dead or not speaking MCP
|
|
931
|
+
if stdio and not resp:
|
|
932
|
+
check_alive(session.proc)
|
|
933
|
+
raise McpError("Error: no response from server during init")
|
|
934
|
+
session.notify("notifications/initialized")
|
|
935
|
+
except BrokenPipeError:
|
|
936
|
+
if not stdio:
|
|
937
|
+
raise
|
|
938
|
+
check_alive(session.proc)
|
|
939
|
+
raise McpError("Error: server crashed during init")
|
|
940
|
+
yield session
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def call_tool(config, tool_name, tool_args, server_name=""):
|
|
944
|
+
"""Call a tool on an HTTP or stdio server and print the result."""
|
|
945
|
+
with _open_session(config, server_name) as session:
|
|
946
|
+
resp = session.rpc("tools/call", {"name": tool_name, "arguments": tool_args}, msg_id=3)
|
|
947
|
+
if not resp:
|
|
948
|
+
raise McpError("Error: no response")
|
|
949
|
+
if "error" in resp:
|
|
950
|
+
raise McpError(json.dumps(resp["error"], indent=2))
|
|
951
|
+
_print_result(resp.get("result", {}))
|
|
930
952
|
|
|
931
953
|
|
|
932
954
|
# --- Tool discovery ---
|
|
@@ -934,9 +956,7 @@ def stdio_call_tool(proc, tool_name, tool_args):
|
|
|
934
956
|
def fetch_tools(config, server_name=""):
|
|
935
957
|
"""Fetch tools list from server (HTTP or stdio), caching for completion."""
|
|
936
958
|
tools = []
|
|
937
|
-
|
|
938
|
-
session = HttpSession(config["url"], config.get("headers"), server_name)
|
|
939
|
-
http_init(session)
|
|
959
|
+
with _open_session(config, server_name) as session:
|
|
940
960
|
cursor, msg_id = None, 2
|
|
941
961
|
while True:
|
|
942
962
|
params = {"cursor": cursor} if cursor else {}
|
|
@@ -948,28 +968,6 @@ def fetch_tools(config, server_name=""):
|
|
|
948
968
|
if not cursor:
|
|
949
969
|
break
|
|
950
970
|
msg_id += 1
|
|
951
|
-
else:
|
|
952
|
-
proc = spawn_server(config)
|
|
953
|
-
try:
|
|
954
|
-
init_server(proc)
|
|
955
|
-
cursor, msg_id = None, 2
|
|
956
|
-
while True:
|
|
957
|
-
params = {"cursor": cursor} if cursor else {}
|
|
958
|
-
send(proc, "tools/list", params, msg_id=msg_id)
|
|
959
|
-
resp = recv(proc, expected_id=msg_id)
|
|
960
|
-
if not resp or "result" not in resp:
|
|
961
|
-
break
|
|
962
|
-
tools += resp["result"].get("tools", [])
|
|
963
|
-
cursor = resp["result"].get("nextCursor")
|
|
964
|
-
if not cursor:
|
|
965
|
-
break
|
|
966
|
-
msg_id += 1
|
|
967
|
-
finally:
|
|
968
|
-
proc.terminate()
|
|
969
|
-
try:
|
|
970
|
-
proc.wait(timeout=5)
|
|
971
|
-
except subprocess.TimeoutExpired:
|
|
972
|
-
proc.kill()
|
|
973
971
|
if server_name and tools:
|
|
974
972
|
_cache_write(server_name, tools)
|
|
975
973
|
return tools
|
|
@@ -1217,8 +1215,10 @@ def refresh_completions():
|
|
|
1217
1215
|
else:
|
|
1218
1216
|
fail += 1
|
|
1219
1217
|
print(f" · {name}: no tools returned", file=sys.stderr)
|
|
1220
|
-
except
|
|
1221
|
-
# fetch_tools
|
|
1218
|
+
except McpError as e:
|
|
1219
|
+
# fetch_tools raises McpError on auth/transport errors; print it and keep going
|
|
1220
|
+
if e.msg:
|
|
1221
|
+
print(e.msg, file=sys.stderr)
|
|
1222
1222
|
fail += 1
|
|
1223
1223
|
print(f" ✗ {name}: failed", file=sys.stderr)
|
|
1224
1224
|
except Exception as e:
|
|
@@ -1338,11 +1338,10 @@ def do_completion():
|
|
|
1338
1338
|
# --- Main ---
|
|
1339
1339
|
|
|
1340
1340
|
def _unknown_tool(server_name, tool_name, names):
|
|
1341
|
-
"""
|
|
1341
|
+
"""Raise McpError (exit 2) with close-match suggestions for a tool name the server doesn't have."""
|
|
1342
1342
|
close = difflib.get_close_matches(tool_name, names, n=3, cutoff=0.4)
|
|
1343
1343
|
hint = f" Did you mean: {', '.join(close)}?" if close else ""
|
|
1344
|
-
|
|
1345
|
-
sys.exit(2)
|
|
1344
|
+
raise McpError(f"Error: unknown tool '{tool_name}' for {server_name}.{hint} List tools with: mcp-call {server_name} --tools", 2)
|
|
1346
1345
|
|
|
1347
1346
|
|
|
1348
1347
|
def run_server(config, tool_name, tool_args, server_name=""):
|
|
@@ -1377,22 +1376,10 @@ def run_server(config, tool_name, tool_args, server_name=""):
|
|
|
1377
1376
|
names = [t.get("name") for t in fetch_tools(config, server_name)]
|
|
1378
1377
|
if names and tool_name not in names: # empty list = server can't list tools; let the call decide
|
|
1379
1378
|
_unknown_tool(server_name, tool_name, names)
|
|
1380
|
-
|
|
1381
|
-
http_call_tool(config["url"], tool_name, tool_args, config.get("headers"), server_name)
|
|
1382
|
-
else:
|
|
1383
|
-
proc = spawn_server(config)
|
|
1384
|
-
try:
|
|
1385
|
-
init_server(proc)
|
|
1386
|
-
stdio_call_tool(proc, tool_name, tool_args)
|
|
1387
|
-
finally:
|
|
1388
|
-
proc.terminate()
|
|
1389
|
-
try:
|
|
1390
|
-
proc.wait(timeout=5)
|
|
1391
|
-
except subprocess.TimeoutExpired:
|
|
1392
|
-
proc.kill()
|
|
1379
|
+
call_tool(config, tool_name, tool_args, server_name)
|
|
1393
1380
|
|
|
1394
1381
|
|
|
1395
|
-
def
|
|
1382
|
+
def _main():
|
|
1396
1383
|
# Shell completion is the fast path — runs on every TAB. Handle it
|
|
1397
1384
|
# before read_config() (which can print "Seeded..." to stderr).
|
|
1398
1385
|
if os.environ.get("_MCP_CALL_COMPLETE"):
|
|
@@ -1457,5 +1444,15 @@ def main():
|
|
|
1457
1444
|
run_server(servers[server_name], tool_name, tool_args, server_name)
|
|
1458
1445
|
|
|
1459
1446
|
|
|
1447
|
+
def main():
|
|
1448
|
+
"""CLI entry point: a McpError from anywhere becomes its stderr message and exit code."""
|
|
1449
|
+
try:
|
|
1450
|
+
_main()
|
|
1451
|
+
except McpError as e:
|
|
1452
|
+
if e.msg:
|
|
1453
|
+
print(e.msg, file=sys.stderr)
|
|
1454
|
+
sys.exit(e.code)
|
|
1455
|
+
|
|
1456
|
+
|
|
1460
1457
|
if __name__ == "__main__":
|
|
1461
1458
|
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|