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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-cli-skill
3
- Version: 0.8.4
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
- print(f"Error: {name} rejected a freshly refreshed token (HTTP 401). Ask the user to run: mcp-call --logout {name} && mcp-call --login {name}", file=sys.stderr)
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
- print(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).", file=sys.stderr)
460
- sys.exit(EXIT_NEEDS_LOGIN)
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
- print(clean[:500], file=sys.stderr)
468
- sys.exit(1)
475
+ msg += "\n" + clean[:500]
476
+ raise McpError(msg)
469
477
  except urllib.error.URLError as e:
470
- print(f"Error: cannot connect to {self.url}: {e.reason}", file=sys.stderr)
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
- print(f"Error: request to {self.url} timed out. Set MCP_CALL_HTTP_TIMEOUT=<sec> for longer.", file=sys.stderr)
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
- """Print msg to stderr and exit."""
539
- print(msg, file=sys.stderr)
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, print stderr if dead."""
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
- print(f"Error: server exited with code {proc.returncode}", file=sys.stderr)
880
+ msg = f"Error: server exited with code {proc.returncode}"
892
881
  if stderr.strip():
893
- print(stderr.strip(), file=sys.stderr)
894
- sys.exit(1)
882
+ msg += "\n" + stderr.strip()
883
+ raise McpError(msg)
895
884
 
896
885
 
897
- def init_server(proc):
898
- """Initialize stdio MCP handshake."""
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 stdio_call_tool(proc, tool_name, tool_args):
920
- """Call a tool on stdio server."""
921
- send(proc, "tools/call", {"name": tool_name, "arguments": tool_args}, msg_id=3)
922
- resp = recv(proc, expected_id=3)
923
- if not resp:
924
- print("Error: no response", file=sys.stderr)
925
- sys.exit(1)
926
- if "error" in resp:
927
- print(json.dumps(resp["error"], indent=2), file=sys.stderr)
928
- sys.exit(1)
929
- _print_result(resp.get("result", {}))
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
- if is_http(config):
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 SystemExit:
1221
- # fetch_tools may sys.exit on auth errors; catch to keep going
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
- """Exit 2 with close-match suggestions for a tool name the server doesn't have."""
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
- print(f"Error: unknown tool '{tool_name}' for {server_name}.{hint} List tools with: mcp-call {server_name} --tools", file=sys.stderr)
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
- if is_http(config):
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 main():
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.8.4"
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
- print(f"Error: {name} rejected a freshly refreshed token (HTTP 401). Ask the user to run: mcp-call --logout {name} && mcp-call --login {name}", file=sys.stderr)
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
- print(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).", file=sys.stderr)
460
- sys.exit(EXIT_NEEDS_LOGIN)
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
- print(clean[:500], file=sys.stderr)
468
- sys.exit(1)
475
+ msg += "\n" + clean[:500]
476
+ raise McpError(msg)
469
477
  except urllib.error.URLError as e:
470
- print(f"Error: cannot connect to {self.url}: {e.reason}", file=sys.stderr)
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
- print(f"Error: request to {self.url} timed out. Set MCP_CALL_HTTP_TIMEOUT=<sec> for longer.", file=sys.stderr)
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
- """Print msg to stderr and exit."""
539
- print(msg, file=sys.stderr)
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, print stderr if dead."""
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
- print(f"Error: server exited with code {proc.returncode}", file=sys.stderr)
880
+ msg = f"Error: server exited with code {proc.returncode}"
892
881
  if stderr.strip():
893
- print(stderr.strip(), file=sys.stderr)
894
- sys.exit(1)
882
+ msg += "\n" + stderr.strip()
883
+ raise McpError(msg)
895
884
 
896
885
 
897
- def init_server(proc):
898
- """Initialize stdio MCP handshake."""
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 stdio_call_tool(proc, tool_name, tool_args):
920
- """Call a tool on stdio server."""
921
- send(proc, "tools/call", {"name": tool_name, "arguments": tool_args}, msg_id=3)
922
- resp = recv(proc, expected_id=3)
923
- if not resp:
924
- print("Error: no response", file=sys.stderr)
925
- sys.exit(1)
926
- if "error" in resp:
927
- print(json.dumps(resp["error"], indent=2), file=sys.stderr)
928
- sys.exit(1)
929
- _print_result(resp.get("result", {}))
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
- if is_http(config):
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 SystemExit:
1221
- # fetch_tools may sys.exit on auth errors; catch to keep going
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
- """Exit 2 with close-match suggestions for a tool name the server doesn't have."""
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
- print(f"Error: unknown tool '{tool_name}' for {server_name}.{hint} List tools with: mcp-call {server_name} --tools", file=sys.stderr)
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
- if is_http(config):
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 main():
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