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.
- {mcp_cli_skill-0.8.1 → mcp_cli_skill-0.8.2}/.github/workflows/publish.yml +4 -4
- {mcp_cli_skill-0.8.1 → mcp_cli_skill-0.8.2}/PKG-INFO +1 -1
- {mcp_cli_skill-0.8.1 → mcp_cli_skill-0.8.2}/SKILL.md +3 -1
- {mcp_cli_skill-0.8.1 → mcp_cli_skill-0.8.2}/scripts/mcp_call.py +25 -6
- {mcp_cli_skill-0.8.1 → mcp_cli_skill-0.8.2}/src/mcp_cli_skill/__init__.py +1 -1
- {mcp_cli_skill-0.8.1 → mcp_cli_skill-0.8.2}/src/mcp_cli_skill/cli.py +25 -6
- mcp_cli_skill-0.8.2/tests/test_args.py +101 -0
- {mcp_cli_skill-0.8.1 → mcp_cli_skill-0.8.2}/.gitignore +0 -0
- {mcp_cli_skill-0.8.1 → mcp_cli_skill-0.8.2}/README.md +0 -0
- {mcp_cli_skill-0.8.1 → mcp_cli_skill-0.8.2}/pyproject.toml +0 -0
- {mcp_cli_skill-0.8.1 → mcp_cli_skill-0.8.2}/tests/test_oauth.py +0 -0
|
@@ -14,8 +14,8 @@ jobs:
|
|
|
14
14
|
matrix:
|
|
15
15
|
python: ["3.10", "3.13"]
|
|
16
16
|
steps:
|
|
17
|
-
- uses: actions/checkout@
|
|
18
|
-
- uses: actions/setup-python@
|
|
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@
|
|
32
|
-
- uses: actions/setup-python@
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|