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.
@@ -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.0
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> # OAuth browser login (the user runs this, not you)
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> (OAuth browser login for an HTTP server)", file=sys.stderr)
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
- if len(args) < 2 or args[1] == "--tools":
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
- print(f"Opening the browser to log in to {name}. If it doesn't open, visit:\n{auth_url}", file=sys.stderr)
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
- print(f"Error: tool '{target}' not found", file=sys.stderr)
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
- print(f"Error: tool '{target}' not found", file=sys.stderr)
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.0"
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> (OAuth browser login for an HTTP server)", file=sys.stderr)
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
- if len(args) < 2 or args[1] == "--tools":
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
- print(f"Opening the browser to log in to {name}. If it doesn't open, visit:\n{auth_url}", file=sys.stderr)
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
- print(f"Error: tool '{target}' not found", file=sys.stderr)
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
- print(f"Error: tool '{target}' not found", file=sys.stderr)
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