mcp-cli-skill 0.8.1__tar.gz → 0.8.2__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.
@@ -14,8 +14,8 @@ jobs:
14
14
  matrix:
15
15
  python: ["3.10", "3.13"]
16
16
  steps:
17
- - uses: actions/checkout@v4
18
- - uses: actions/setup-python@v5
17
+ - uses: actions/checkout@v5
18
+ - uses: actions/setup-python@v6
19
19
  with:
20
20
  python-version: ${{ matrix.python }}
21
21
  - run: python -m unittest discover tests -v
@@ -28,8 +28,8 @@ jobs:
28
28
  permissions:
29
29
  id-token: write # PyPI Trusted Publishing
30
30
  steps:
31
- - uses: actions/checkout@v4
32
- - uses: actions/setup-python@v5
31
+ - uses: actions/checkout@v5
32
+ - uses: actions/setup-python@v6
33
33
  with:
34
34
  python-version: "3.13"
35
35
  - name: Tag must match __version__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-cli-skill
3
- Version: 0.8.1
3
+ Version: 0.8.2
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
@@ -14,7 +14,7 @@ Always use `mcp-call` as the command, never `uvx mcp-cli-skill`.
14
14
 
15
15
  ```bash
16
16
  mcp-call --servers # list configured servers
17
- mcp-call <server> --tools # list tools (human-readable)
17
+ mcp-call <server> --tools # list tools (human-readable; --list / --help work too)
18
18
  mcp-call <server> --discover # list tools as JSON with schemas
19
19
  mcp-call <server> <tool> --schema # show tool's input schema as JSON
20
20
  mcp-call <server> <tool> --key=value ... # call a tool
@@ -42,6 +42,8 @@ mcp-call --remove myredash
42
42
  mcp-call --sync
43
43
  ```
44
44
 
45
+ An unknown option or tool name exits 2 with a "Did you mean" hint and is never sent to the server — use the hint instead of guessing another name.
46
+
45
47
  If a call exits 4 with "needs interactive browser login", ask the user to run the `mcp-call --login <name>` command it prints; don't run it yourself.
46
48
 
47
49
  ## Examples
@@ -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 difflib
4
5
  import hashlib
5
6
  import http.server
6
7
  import json
@@ -246,10 +247,17 @@ def parse_args():
246
247
  # `mcp-call <server> --login|--logout` alias the top-level flags
247
248
  if len(args) > 1 and args[1] in ("--login", "--logout"):
248
249
  return f"__{args[1][2:]}__", server, {"no_browser": "--no-browser" in args[2:]}
249
- if len(args) < 2 or args[1] == "--tools":
250
+ # --list / --help / -h are what people (and agents) guess for "list tools"
251
+ if len(args) < 2 or args[1] in ("--tools", "--list", "--help", "-h"):
250
252
  return server, "__tools__", {}
251
253
  if args[1] == "--discover":
252
254
  return server, "__discover__", {}
255
+ if args[1].startswith("-"):
256
+ # MCP tool names never start with "-": a mistyped option, so never send it to the server
257
+ close = difflib.get_close_matches(args[1], ["--tools", "--discover", "--login", "--logout"], n=1)
258
+ hint = f" Did you mean {close[0]}?" if close else ""
259
+ print(f"Error: unknown option '{args[1]}'.{hint} List tools with: mcp-call {server} --tools", file=sys.stderr)
260
+ sys.exit(2)
253
261
 
254
262
  tool = args[1]
255
263
  tool_args = {}
@@ -1284,6 +1292,14 @@ def do_completion():
1284
1292
 
1285
1293
  # --- Main ---
1286
1294
 
1295
+ def _unknown_tool(server_name, tool_name, names):
1296
+ """Exit 2 with close-match suggestions for a tool name the server doesn't have."""
1297
+ close = difflib.get_close_matches(tool_name, names, n=3, cutoff=0.4)
1298
+ hint = f" Did you mean: {', '.join(close)}?" if close else ""
1299
+ print(f"Error: unknown tool '{tool_name}' for {server_name}.{hint} List tools with: mcp-call {server_name} --tools", file=sys.stderr)
1300
+ sys.exit(2)
1301
+
1302
+
1287
1303
  def run_server(config, tool_name, tool_args, server_name=""):
1288
1304
  """Route to HTTP or stdio transport."""
1289
1305
  # tool discovery commands
@@ -1301,18 +1317,21 @@ def run_server(config, tool_name, tool_args, server_name=""):
1301
1317
  if t["name"] == target:
1302
1318
  print(json.dumps(t.get("inputSchema", {}), indent=2))
1303
1319
  return
1304
- print(f"Error: tool '{target}' not found", file=sys.stderr)
1305
- sys.exit(1)
1320
+ _unknown_tool(server_name, target, [t["name"] for t in tools])
1306
1321
  elif tool_name == "__help__":
1307
1322
  target = tool_args["_tool"]
1308
1323
  for t in tools:
1309
1324
  if t["name"] == target:
1310
1325
  _print_tool_help(server_name or "<server>", t)
1311
1326
  return
1312
- print(f"Error: tool '{target}' not found", file=sys.stderr)
1313
- sys.exit(1)
1327
+ _unknown_tool(server_name, target, [t["name"] for t in tools])
1314
1328
  return
1315
- # tool calls
1329
+ # tool calls: a name missing from the cached list is a typo or a newly added tool,
1330
+ # so re-fetch once (refreshes the cache) and only refuse if the server lacks it too
1331
+ if server_name and tool_name not in [t.get("name") for t in _cache_read(server_name)]:
1332
+ names = [t.get("name") for t in fetch_tools(config, server_name)]
1333
+ if names and tool_name not in names: # empty list = server can't list tools; let the call decide
1334
+ _unknown_tool(server_name, tool_name, names)
1316
1335
  if is_http(config):
1317
1336
  http_call_tool(config["url"], tool_name, tool_args, config.get("headers"), server_name)
1318
1337
  else:
@@ -1,2 +1,2 @@
1
1
  """MCP CLI - Call any MCP server tool from the command line."""
2
- __version__ = "0.8.1"
2
+ __version__ = "0.8.2"
@@ -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 difflib
4
5
  import hashlib
5
6
  import http.server
6
7
  import json
@@ -246,10 +247,17 @@ def parse_args():
246
247
  # `mcp-call <server> --login|--logout` alias the top-level flags
247
248
  if len(args) > 1 and args[1] in ("--login", "--logout"):
248
249
  return f"__{args[1][2:]}__", server, {"no_browser": "--no-browser" in args[2:]}
249
- if len(args) < 2 or args[1] == "--tools":
250
+ # --list / --help / -h are what people (and agents) guess for "list tools"
251
+ if len(args) < 2 or args[1] in ("--tools", "--list", "--help", "-h"):
250
252
  return server, "__tools__", {}
251
253
  if args[1] == "--discover":
252
254
  return server, "__discover__", {}
255
+ if args[1].startswith("-"):
256
+ # MCP tool names never start with "-": a mistyped option, so never send it to the server
257
+ close = difflib.get_close_matches(args[1], ["--tools", "--discover", "--login", "--logout"], n=1)
258
+ hint = f" Did you mean {close[0]}?" if close else ""
259
+ print(f"Error: unknown option '{args[1]}'.{hint} List tools with: mcp-call {server} --tools", file=sys.stderr)
260
+ sys.exit(2)
253
261
 
254
262
  tool = args[1]
255
263
  tool_args = {}
@@ -1284,6 +1292,14 @@ def do_completion():
1284
1292
 
1285
1293
  # --- Main ---
1286
1294
 
1295
+ def _unknown_tool(server_name, tool_name, names):
1296
+ """Exit 2 with close-match suggestions for a tool name the server doesn't have."""
1297
+ close = difflib.get_close_matches(tool_name, names, n=3, cutoff=0.4)
1298
+ hint = f" Did you mean: {', '.join(close)}?" if close else ""
1299
+ print(f"Error: unknown tool '{tool_name}' for {server_name}.{hint} List tools with: mcp-call {server_name} --tools", file=sys.stderr)
1300
+ sys.exit(2)
1301
+
1302
+
1287
1303
  def run_server(config, tool_name, tool_args, server_name=""):
1288
1304
  """Route to HTTP or stdio transport."""
1289
1305
  # tool discovery commands
@@ -1301,18 +1317,21 @@ def run_server(config, tool_name, tool_args, server_name=""):
1301
1317
  if t["name"] == target:
1302
1318
  print(json.dumps(t.get("inputSchema", {}), indent=2))
1303
1319
  return
1304
- print(f"Error: tool '{target}' not found", file=sys.stderr)
1305
- sys.exit(1)
1320
+ _unknown_tool(server_name, target, [t["name"] for t in tools])
1306
1321
  elif tool_name == "__help__":
1307
1322
  target = tool_args["_tool"]
1308
1323
  for t in tools:
1309
1324
  if t["name"] == target:
1310
1325
  _print_tool_help(server_name or "<server>", t)
1311
1326
  return
1312
- print(f"Error: tool '{target}' not found", file=sys.stderr)
1313
- sys.exit(1)
1327
+ _unknown_tool(server_name, target, [t["name"] for t in tools])
1314
1328
  return
1315
- # tool calls
1329
+ # tool calls: a name missing from the cached list is a typo or a newly added tool,
1330
+ # so re-fetch once (refreshes the cache) and only refuse if the server lacks it too
1331
+ if server_name and tool_name not in [t.get("name") for t in _cache_read(server_name)]:
1332
+ names = [t.get("name") for t in fetch_tools(config, server_name)]
1333
+ if names and tool_name not in names: # empty list = server can't list tools; let the call decide
1334
+ _unknown_tool(server_name, tool_name, names)
1316
1335
  if is_http(config):
1317
1336
  http_call_tool(config["url"], tool_name, tool_args, config.get("headers"), server_name)
1318
1337
  else:
@@ -0,0 +1,101 @@
1
+ """Mistyped options / tool names fail fast with a hint and never reach the server."""
2
+ import json
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ import textwrap
8
+ import unittest
9
+
10
+ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
11
+
12
+ FAKE_SERVER = textwrap.dedent("""
13
+ import json, os, sys
14
+ tools = os.environ.get("FAKE_TOOLS", "echo,slack_channel").split(",")
15
+ for line in sys.stdin:
16
+ msg = json.loads(line)
17
+ if msg.get("id") is None:
18
+ continue
19
+ method = msg["method"]
20
+ if method == "initialize":
21
+ res = {"protocolVersion": "2024-11-05", "capabilities": {}, "serverInfo": {"name": "fake", "version": "1"}}
22
+ elif method == "tools/list":
23
+ res = {"tools": [{"name": n, "inputSchema": {"type": "object", "properties": {}}} for n in tools]}
24
+ else: # tools/call: log every name that actually reached the server
25
+ with open(os.environ["FAKE_LOG"], "a") as f:
26
+ f.write(msg["params"]["name"] + "\\n")
27
+ res = {"content": [{"type": "text", "text": "ok " + msg["params"]["name"]}]}
28
+ sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": res}) + "\\n")
29
+ sys.stdout.flush()
30
+ """)
31
+
32
+
33
+ class ArgsTest(unittest.TestCase):
34
+ def setUp(self):
35
+ self.home = tempfile.mkdtemp()
36
+ self.log = os.path.join(self.home, "calls.log")
37
+ script = os.path.join(self.home, "fake_server.py")
38
+ with open(script, "w") as f:
39
+ f.write(FAKE_SERVER)
40
+ os.makedirs(os.path.join(self.home, ".mcp-cli"))
41
+ with open(os.path.join(self.home, ".mcp-cli", "servers.json"), "w") as f:
42
+ json.dump({"fake": {"command": sys.executable, "args": [script], "env": {"FAKE_LOG": self.log}}}, f)
43
+
44
+ def run_cli(self, *args, tools=None):
45
+ """Run mcp-call in a subprocess with an isolated HOME; return (exit code, stdout, stderr)."""
46
+ env = {**os.environ, "HOME": self.home, "PYTHONPATH": os.path.join(REPO, "src"), "NO_COLOR": "1"}
47
+ if tools:
48
+ env["FAKE_TOOLS"] = tools
49
+ p = subprocess.run([sys.executable, "-m", "mcp_cli_skill.cli", *args], env=env, cwd=REPO,
50
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30)
51
+ return p.returncode, p.stdout, p.stderr
52
+
53
+ def calls(self):
54
+ if not os.path.exists(self.log):
55
+ return []
56
+ with open(self.log) as f:
57
+ return f.read().split()
58
+
59
+ def test_list_help_aliases_show_tools(self):
60
+ for flag in ("--tools", "--list", "--help", "-h"):
61
+ code, out, err = self.run_cli("fake", flag)
62
+ self.assertEqual(code, 0, f"{flag}: {err}")
63
+ self.assertIn("echo", out, flag)
64
+ self.assertEqual(self.calls(), [])
65
+
66
+ def test_unknown_option_fails_fast(self):
67
+ code, _, err = self.run_cli("fake", "--list-tools")
68
+ self.assertEqual(code, 2)
69
+ self.assertIn("unknown option '--list-tools'", err)
70
+ self.assertIn("Did you mean --tools?", err)
71
+ self.assertIn("mcp-call fake --tools", err)
72
+ self.assertEqual(self.calls(), [])
73
+
74
+ def test_unknown_tool_suggests_and_never_calls(self):
75
+ code, _, err = self.run_cli("fake", "ehco")
76
+ self.assertEqual(code, 2)
77
+ self.assertIn("unknown tool 'ehco'", err)
78
+ self.assertIn("Did you mean: echo", err)
79
+ self.assertEqual(self.calls(), [])
80
+
81
+ def test_known_tool_still_called(self):
82
+ code, out, err = self.run_cli("fake", "echo")
83
+ self.assertEqual(code, 0, err)
84
+ self.assertIn("ok echo", out)
85
+ self.assertEqual(self.calls(), ["echo"])
86
+
87
+ def test_stale_cache_new_tool_still_callable(self):
88
+ self.assertEqual(self.run_cli("fake", "echo")[0], 0) # caches echo,slack_channel
89
+ code, out, err = self.run_cli("fake", "brand_new", tools="echo,slack_channel,brand_new")
90
+ self.assertEqual(code, 0, err)
91
+ self.assertIn("ok brand_new", out)
92
+ self.assertEqual(self.calls(), ["echo", "brand_new"])
93
+
94
+ def test_schema_unknown_tool_suggests(self):
95
+ code, _, err = self.run_cli("fake", "ech", "--schema")
96
+ self.assertEqual(code, 2)
97
+ self.assertIn("Did you mean: echo", err)
98
+
99
+
100
+ if __name__ == "__main__":
101
+ unittest.main()
File without changes
File without changes