mcp-cli-skill 0.8.0__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.2/.github/workflows/publish.yml +40 -0
- {mcp_cli_skill-0.8.0 → mcp_cli_skill-0.8.2}/PKG-INFO +8 -1
- {mcp_cli_skill-0.8.0 → mcp_cli_skill-0.8.2}/README.md +7 -0
- {mcp_cli_skill-0.8.0 → mcp_cli_skill-0.8.2}/SKILL.md +4 -2
- {mcp_cli_skill-0.8.0 → mcp_cli_skill-0.8.2}/scripts/mcp_call.py +36 -13
- {mcp_cli_skill-0.8.0 → mcp_cli_skill-0.8.2}/src/mcp_cli_skill/__init__.py +1 -1
- {mcp_cli_skill-0.8.0 → mcp_cli_skill-0.8.2}/src/mcp_cli_skill/cli.py +36 -13
- mcp_cli_skill-0.8.2/tests/test_args.py +101 -0
- {mcp_cli_skill-0.8.0 → mcp_cli_skill-0.8.2}/tests/test_oauth.py +21 -2
- {mcp_cli_skill-0.8.0 → mcp_cli_skill-0.8.2}/.gitignore +0 -0
- {mcp_cli_skill-0.8.0 → mcp_cli_skill-0.8.2}/pyproject.toml +0 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Tests on every push/PR; a `v*` tag also publishes to PyPI via Trusted Publishing (OIDC, no tokens).
|
|
2
|
+
name: ci
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
branches: [master]
|
|
7
|
+
tags: ["v*"]
|
|
8
|
+
pull_request:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
python: ["3.10", "3.13"]
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v5
|
|
18
|
+
- uses: actions/setup-python@v6
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python }}
|
|
21
|
+
- run: python -m unittest discover tests -v
|
|
22
|
+
|
|
23
|
+
publish:
|
|
24
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
25
|
+
needs: test
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
environment: pypi
|
|
28
|
+
permissions:
|
|
29
|
+
id-token: write # PyPI Trusted Publishing
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/checkout@v5
|
|
32
|
+
- uses: actions/setup-python@v6
|
|
33
|
+
with:
|
|
34
|
+
python-version: "3.13"
|
|
35
|
+
- name: Tag must match __version__
|
|
36
|
+
run: |
|
|
37
|
+
v=$(sed -n 's/^__version__ = "\(.*\)"/\1/p' src/mcp_cli_skill/__init__.py)
|
|
38
|
+
test "v$v" = "$GITHUB_REF_NAME" || { echo "tag $GITHUB_REF_NAME != __version__ $v"; exit 1; }
|
|
39
|
+
- run: python -m pip install build && python -m build
|
|
40
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -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
|
|
@@ -65,6 +65,13 @@ mcp-call emergent --tools # bearer token is sent and refreshed automatically
|
|
|
65
65
|
mcp-call --logout emergent # forget the local tokens (no server-side revocation)
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
Multiple accounts on one server: add it under one name per account, and log in with `--no-browser`, which only prints the URL so you can open it in the browser profile signed in to that account (the default browser may silently approve as whoever is already signed in):
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
mcp-call --add-http emergent-work https://mcp.emergent.sh/
|
|
72
|
+
mcp-call --login emergent-work --no-browser
|
|
73
|
+
```
|
|
74
|
+
|
|
68
75
|
Tokens live in `~/.mcp-cli/tokens.json` (mode 0600), bound to the server's URL. A call to a server that needs login exits with code 4 and says which `--login` to run. `--login` waits up to `MCP_CALL_LOGIN_TIMEOUT` seconds (default 300) for the browser; over SSH it prints the URL instead of opening a browser. A static `Authorization` header (`--header`) always wins over OAuth.
|
|
69
76
|
|
|
70
77
|
## Shell completion
|
|
@@ -49,6 +49,13 @@ mcp-call emergent --tools # bearer token is sent and refreshed automatically
|
|
|
49
49
|
mcp-call --logout emergent # forget the local tokens (no server-side revocation)
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
Multiple accounts on one server: add it under one name per account, and log in with `--no-browser`, which only prints the URL so you can open it in the browser profile signed in to that account (the default browser may silently approve as whoever is already signed in):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
mcp-call --add-http emergent-work https://mcp.emergent.sh/
|
|
56
|
+
mcp-call --login emergent-work --no-browser
|
|
57
|
+
```
|
|
58
|
+
|
|
52
59
|
Tokens live in `~/.mcp-cli/tokens.json` (mode 0600), bound to the server's URL. A call to a server that needs login exits with code 4 and says which `--login` to run. `--login` waits up to `MCP_CALL_LOGIN_TIMEOUT` seconds (default 300) for the browser; over SSH it prints the URL instead of opening a browser. A static `Authorization` header (`--header`) always wins over OAuth.
|
|
53
60
|
|
|
54
61
|
## Shell completion
|
|
@@ -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
|
|
@@ -23,7 +23,7 @@ echo '{}' | mcp-call <server> <tool> # call with stdin JSON
|
|
|
23
23
|
mcp-call --add <name> <cmd> [args] [--env K=V ...] # add stdio server
|
|
24
24
|
mcp-call --add-http <name> <url> [--header 'K: V' ...] # add HTTP server (auth via --header/-H)
|
|
25
25
|
mcp-call --remove <name> # remove server
|
|
26
|
-
mcp-call --login <name>
|
|
26
|
+
mcp-call --login <name> [--no-browser] # OAuth browser login (the user runs this, not you)
|
|
27
27
|
mcp-call --logout <name> # forget stored OAuth tokens
|
|
28
28
|
mcp-call --sync # re-sync from Claude configs
|
|
29
29
|
mcp-call --completion <bash|zsh|fish> # print shell completion hook
|
|
@@ -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
|
|
@@ -189,7 +190,7 @@ def parse_args():
|
|
|
189
190
|
print(" mcp-call --add <name> <command> [args...] [--env KEY=VAL ...]", file=sys.stderr)
|
|
190
191
|
print(" mcp-call --add-http <name> <url>", file=sys.stderr)
|
|
191
192
|
print(" mcp-call --remove <name>", file=sys.stderr)
|
|
192
|
-
print(" mcp-call --login <name>
|
|
193
|
+
print(" mcp-call --login <name> [--no-browser] (OAuth browser login; --no-browser only prints the URL)", file=sys.stderr)
|
|
193
194
|
print(" mcp-call --logout <name> (forget stored OAuth tokens)", file=sys.stderr)
|
|
194
195
|
print(" mcp-call --sync", file=sys.stderr)
|
|
195
196
|
print(" mcp-call --completion <bash|zsh|fish>", file=sys.stderr)
|
|
@@ -231,7 +232,7 @@ def parse_args():
|
|
|
231
232
|
if len(args) < 2:
|
|
232
233
|
print(f"Usage: mcp-call {args[0]} <name>", file=sys.stderr)
|
|
233
234
|
sys.exit(1)
|
|
234
|
-
return f"__{args[0][2:]}__", args[1], {}
|
|
235
|
+
return f"__{args[0][2:]}__", args[1], {"no_browser": "--no-browser" in args[2:]}
|
|
235
236
|
if args[0] == "--sync":
|
|
236
237
|
return "__sync__", None, {}
|
|
237
238
|
if args[0] == "--completion":
|
|
@@ -245,11 +246,18 @@ def parse_args():
|
|
|
245
246
|
server = args[0]
|
|
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
|
-
return f"__{args[1][2:]}__", server, {}
|
|
249
|
-
|
|
249
|
+
return f"__{args[1][2:]}__", server, {"no_browser": "--no-browser" in args[2:]}
|
|
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 = {}
|
|
@@ -669,7 +677,7 @@ def oauth_logout(name):
|
|
|
669
677
|
return name in _tokens_read() and _tokens_put(name, None) is not None
|
|
670
678
|
|
|
671
679
|
|
|
672
|
-
def oauth_login(name, url):
|
|
680
|
+
def oauth_login(name, url, open_browser=True):
|
|
673
681
|
"""Interactive browser login: discover, register client (DCR), PKCE authorize, store tokens."""
|
|
674
682
|
# fail fast instead of racing a second login; held until this function returns
|
|
675
683
|
lock = _flock(os.path.join(CONFIG_DIR, "login-" + re.sub(r"[^A-Za-z0-9._-]", "_", name) + ".lock"), 0)
|
|
@@ -773,10 +781,14 @@ def oauth_login(name, url):
|
|
|
773
781
|
params["scope"] = scope
|
|
774
782
|
auth = meta["authorization_endpoint"]
|
|
775
783
|
auth_url = auth + ("&" if urllib.parse.urlsplit(auth).query else "?") + urllib.parse.urlencode(params)
|
|
776
|
-
|
|
784
|
+
if open_browser:
|
|
785
|
+
print(f"Opening the browser to log in to {name}. If it doesn't open, visit:\n{auth_url}", file=sys.stderr)
|
|
786
|
+
else:
|
|
787
|
+
# pick the account: open it in whichever browser profile is signed in to it
|
|
788
|
+
print(f"Open this URL in the browser profile you want to log in to {name} with:\n{auth_url}", file=sys.stderr)
|
|
777
789
|
# over SSH / headless Linux, webbrowser falls back to a blocking text browser
|
|
778
790
|
headless = sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
779
|
-
if not os.environ.get("SSH_CONNECTION") and not headless:
|
|
791
|
+
if open_browser and not os.environ.get("SSH_CONNECTION") and not headless:
|
|
780
792
|
webbrowser.open(auth_url)
|
|
781
793
|
if not done.wait(LOGIN_TIMEOUT):
|
|
782
794
|
_die(f"Error: no callback received in {LOGIN_TIMEOUT}s. If the browser showed an error, run `mcp-call --logout {name}` and retry.")
|
|
@@ -1280,6 +1292,14 @@ def do_completion():
|
|
|
1280
1292
|
|
|
1281
1293
|
# --- Main ---
|
|
1282
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
|
+
|
|
1283
1303
|
def run_server(config, tool_name, tool_args, server_name=""):
|
|
1284
1304
|
"""Route to HTTP or stdio transport."""
|
|
1285
1305
|
# tool discovery commands
|
|
@@ -1297,18 +1317,21 @@ def run_server(config, tool_name, tool_args, server_name=""):
|
|
|
1297
1317
|
if t["name"] == target:
|
|
1298
1318
|
print(json.dumps(t.get("inputSchema", {}), indent=2))
|
|
1299
1319
|
return
|
|
1300
|
-
|
|
1301
|
-
sys.exit(1)
|
|
1320
|
+
_unknown_tool(server_name, target, [t["name"] for t in tools])
|
|
1302
1321
|
elif tool_name == "__help__":
|
|
1303
1322
|
target = tool_args["_tool"]
|
|
1304
1323
|
for t in tools:
|
|
1305
1324
|
if t["name"] == target:
|
|
1306
1325
|
_print_tool_help(server_name or "<server>", t)
|
|
1307
1326
|
return
|
|
1308
|
-
|
|
1309
|
-
sys.exit(1)
|
|
1327
|
+
_unknown_tool(server_name, target, [t["name"] for t in tools])
|
|
1310
1328
|
return
|
|
1311
|
-
# 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)
|
|
1312
1335
|
if is_http(config):
|
|
1313
1336
|
http_call_tool(config["url"], tool_name, tool_args, config.get("headers"), server_name)
|
|
1314
1337
|
else:
|
|
@@ -1356,7 +1379,7 @@ def main():
|
|
|
1356
1379
|
print(f"Error: '{tool_name}' has a static Authorization header; re-add it without one to use --login.", file=sys.stderr)
|
|
1357
1380
|
sys.exit(1)
|
|
1358
1381
|
try:
|
|
1359
|
-
oauth_login(tool_name, _expand_env(cfg["url"]))
|
|
1382
|
+
oauth_login(tool_name, _expand_env(cfg["url"]), open_browser=not tool_args.get("no_browser"))
|
|
1360
1383
|
except KeyboardInterrupt:
|
|
1361
1384
|
print("\nLogin cancelled.", file=sys.stderr)
|
|
1362
1385
|
sys.exit(130)
|
|
@@ -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
|
|
@@ -189,7 +190,7 @@ def parse_args():
|
|
|
189
190
|
print(" mcp-call --add <name> <command> [args...] [--env KEY=VAL ...]", file=sys.stderr)
|
|
190
191
|
print(" mcp-call --add-http <name> <url>", file=sys.stderr)
|
|
191
192
|
print(" mcp-call --remove <name>", file=sys.stderr)
|
|
192
|
-
print(" mcp-call --login <name>
|
|
193
|
+
print(" mcp-call --login <name> [--no-browser] (OAuth browser login; --no-browser only prints the URL)", file=sys.stderr)
|
|
193
194
|
print(" mcp-call --logout <name> (forget stored OAuth tokens)", file=sys.stderr)
|
|
194
195
|
print(" mcp-call --sync", file=sys.stderr)
|
|
195
196
|
print(" mcp-call --completion <bash|zsh|fish>", file=sys.stderr)
|
|
@@ -231,7 +232,7 @@ def parse_args():
|
|
|
231
232
|
if len(args) < 2:
|
|
232
233
|
print(f"Usage: mcp-call {args[0]} <name>", file=sys.stderr)
|
|
233
234
|
sys.exit(1)
|
|
234
|
-
return f"__{args[0][2:]}__", args[1], {}
|
|
235
|
+
return f"__{args[0][2:]}__", args[1], {"no_browser": "--no-browser" in args[2:]}
|
|
235
236
|
if args[0] == "--sync":
|
|
236
237
|
return "__sync__", None, {}
|
|
237
238
|
if args[0] == "--completion":
|
|
@@ -245,11 +246,18 @@ def parse_args():
|
|
|
245
246
|
server = args[0]
|
|
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
|
-
return f"__{args[1][2:]}__", server, {}
|
|
249
|
-
|
|
249
|
+
return f"__{args[1][2:]}__", server, {"no_browser": "--no-browser" in args[2:]}
|
|
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 = {}
|
|
@@ -669,7 +677,7 @@ def oauth_logout(name):
|
|
|
669
677
|
return name in _tokens_read() and _tokens_put(name, None) is not None
|
|
670
678
|
|
|
671
679
|
|
|
672
|
-
def oauth_login(name, url):
|
|
680
|
+
def oauth_login(name, url, open_browser=True):
|
|
673
681
|
"""Interactive browser login: discover, register client (DCR), PKCE authorize, store tokens."""
|
|
674
682
|
# fail fast instead of racing a second login; held until this function returns
|
|
675
683
|
lock = _flock(os.path.join(CONFIG_DIR, "login-" + re.sub(r"[^A-Za-z0-9._-]", "_", name) + ".lock"), 0)
|
|
@@ -773,10 +781,14 @@ def oauth_login(name, url):
|
|
|
773
781
|
params["scope"] = scope
|
|
774
782
|
auth = meta["authorization_endpoint"]
|
|
775
783
|
auth_url = auth + ("&" if urllib.parse.urlsplit(auth).query else "?") + urllib.parse.urlencode(params)
|
|
776
|
-
|
|
784
|
+
if open_browser:
|
|
785
|
+
print(f"Opening the browser to log in to {name}. If it doesn't open, visit:\n{auth_url}", file=sys.stderr)
|
|
786
|
+
else:
|
|
787
|
+
# pick the account: open it in whichever browser profile is signed in to it
|
|
788
|
+
print(f"Open this URL in the browser profile you want to log in to {name} with:\n{auth_url}", file=sys.stderr)
|
|
777
789
|
# over SSH / headless Linux, webbrowser falls back to a blocking text browser
|
|
778
790
|
headless = sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
779
|
-
if not os.environ.get("SSH_CONNECTION") and not headless:
|
|
791
|
+
if open_browser and not os.environ.get("SSH_CONNECTION") and not headless:
|
|
780
792
|
webbrowser.open(auth_url)
|
|
781
793
|
if not done.wait(LOGIN_TIMEOUT):
|
|
782
794
|
_die(f"Error: no callback received in {LOGIN_TIMEOUT}s. If the browser showed an error, run `mcp-call --logout {name}` and retry.")
|
|
@@ -1280,6 +1292,14 @@ def do_completion():
|
|
|
1280
1292
|
|
|
1281
1293
|
# --- Main ---
|
|
1282
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
|
+
|
|
1283
1303
|
def run_server(config, tool_name, tool_args, server_name=""):
|
|
1284
1304
|
"""Route to HTTP or stdio transport."""
|
|
1285
1305
|
# tool discovery commands
|
|
@@ -1297,18 +1317,21 @@ def run_server(config, tool_name, tool_args, server_name=""):
|
|
|
1297
1317
|
if t["name"] == target:
|
|
1298
1318
|
print(json.dumps(t.get("inputSchema", {}), indent=2))
|
|
1299
1319
|
return
|
|
1300
|
-
|
|
1301
|
-
sys.exit(1)
|
|
1320
|
+
_unknown_tool(server_name, target, [t["name"] for t in tools])
|
|
1302
1321
|
elif tool_name == "__help__":
|
|
1303
1322
|
target = tool_args["_tool"]
|
|
1304
1323
|
for t in tools:
|
|
1305
1324
|
if t["name"] == target:
|
|
1306
1325
|
_print_tool_help(server_name or "<server>", t)
|
|
1307
1326
|
return
|
|
1308
|
-
|
|
1309
|
-
sys.exit(1)
|
|
1327
|
+
_unknown_tool(server_name, target, [t["name"] for t in tools])
|
|
1310
1328
|
return
|
|
1311
|
-
# 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)
|
|
1312
1335
|
if is_http(config):
|
|
1313
1336
|
http_call_tool(config["url"], tool_name, tool_args, config.get("headers"), server_name)
|
|
1314
1337
|
else:
|
|
@@ -1356,7 +1379,7 @@ def main():
|
|
|
1356
1379
|
print(f"Error: '{tool_name}' has a static Authorization header; re-add it without one to use --login.", file=sys.stderr)
|
|
1357
1380
|
sys.exit(1)
|
|
1358
1381
|
try:
|
|
1359
|
-
oauth_login(tool_name, _expand_env(cfg["url"]))
|
|
1382
|
+
oauth_login(tool_name, _expand_env(cfg["url"]), open_browser=not tool_args.get("no_browser"))
|
|
1360
1383
|
except KeyboardInterrupt:
|
|
1361
1384
|
print("\nLogin cancelled.", file=sys.stderr)
|
|
1362
1385
|
sys.exit(130)
|
|
@@ -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()
|
|
@@ -201,9 +201,9 @@ class OAuthTest(unittest.TestCase):
|
|
|
201
201
|
threading.Thread(target=go, daemon=True).start()
|
|
202
202
|
return True
|
|
203
203
|
|
|
204
|
-
def run_cli(self, *args):
|
|
204
|
+
def run_cli(self, *args, err=None):
|
|
205
205
|
"""Run mcp-call in-process; return (exit code, stdout, stderr)."""
|
|
206
|
-
out, err, code = io.StringIO(), io.StringIO(), 0
|
|
206
|
+
out, err, code = io.StringIO(), err or io.StringIO(), 0
|
|
207
207
|
with mock.patch.object(sys, "argv", ["mcp-call", *args]), mock.patch.object(sys, "stdin", io.StringIO("")), \
|
|
208
208
|
redirect_stdout(out), redirect_stderr(err):
|
|
209
209
|
try:
|
|
@@ -363,6 +363,25 @@ class OAuthTest(unittest.TestCase):
|
|
|
363
363
|
self.assertEqual(self.fake.registers, 0)
|
|
364
364
|
self.assertFalse(os.path.exists(cli.TOKENS_PATH))
|
|
365
365
|
|
|
366
|
+
def test_14_no_browser_prints_url_only(self):
|
|
367
|
+
opened = []
|
|
368
|
+
self.browser = opened.append # webbrowser.open must not be called
|
|
369
|
+
test = self
|
|
370
|
+
|
|
371
|
+
class User(io.StringIO):
|
|
372
|
+
"""Fake user: opens the printed authorize URL in 'another browser profile'."""
|
|
373
|
+
def write(self, text):
|
|
374
|
+
for word in text.split():
|
|
375
|
+
if "/authorize?" in word:
|
|
376
|
+
test.open_in_thread(word)
|
|
377
|
+
return super().write(text)
|
|
378
|
+
|
|
379
|
+
code, out, err = self.run_cli("fake", "--login", "--no-browser", err=User())
|
|
380
|
+
self.assertEqual(code, 0, err)
|
|
381
|
+
self.assertEqual(opened, [])
|
|
382
|
+
self.assertIn("browser profile", err)
|
|
383
|
+
self.assertIn("Logged in to fake.", out)
|
|
384
|
+
|
|
366
385
|
|
|
367
386
|
if __name__ == "__main__":
|
|
368
387
|
unittest.main()
|
|
File without changes
|
|
File without changes
|