mcp-cli-skill 0.5.3__tar.gz → 0.6.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.
- {mcp_cli_skill-0.5.3 → mcp_cli_skill-0.6.0}/PKG-INFO +22 -1
- {mcp_cli_skill-0.5.3 → mcp_cli_skill-0.6.0}/README.md +21 -0
- {mcp_cli_skill-0.5.3 → mcp_cli_skill-0.6.0}/SKILL.md +3 -0
- {mcp_cli_skill-0.5.3 → mcp_cli_skill-0.6.0}/scripts/mcp_call.py +235 -19
- {mcp_cli_skill-0.5.3 → mcp_cli_skill-0.6.0}/src/mcp_cli_skill/__init__.py +1 -1
- {mcp_cli_skill-0.5.3 → mcp_cli_skill-0.6.0}/src/mcp_cli_skill/cli.py +235 -19
- {mcp_cli_skill-0.5.3 → mcp_cli_skill-0.6.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-cli-skill
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -54,6 +54,27 @@ mcp-call --remove myserver
|
|
|
54
54
|
mcp-call --sync # re-sync from Claude configs
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
## Shell completion
|
|
58
|
+
|
|
59
|
+
Tab completion suggests server names, tool names, and flag names (no MCP roundtrip — reads a local cache).
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# bash — add to ~/.bashrc
|
|
63
|
+
eval "$(mcp-call --completion bash)"
|
|
64
|
+
|
|
65
|
+
# zsh — add to ~/.zshrc
|
|
66
|
+
eval "$(mcp-call --completion zsh)"
|
|
67
|
+
|
|
68
|
+
# fish — write once
|
|
69
|
+
mcp-call --completion fish > ~/.config/fish/completions/mcp-call.fish
|
|
70
|
+
|
|
71
|
+
# Populate the tool cache once (or re-run after server changes):
|
|
72
|
+
mcp-call --refresh-completions
|
|
73
|
+
mcp-call --clear-cache [server]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The cache lives at `~/.mcp-cli/cache/tools-<server>.json` and is also refreshed whenever you run `mcp-call <server> --tools`.
|
|
77
|
+
|
|
57
78
|
### Environment variables
|
|
58
79
|
|
|
59
80
|
`${VAR}` patterns in URLs, headers, command args, and env values are expanded at runtime:
|
|
@@ -38,6 +38,27 @@ mcp-call --remove myserver
|
|
|
38
38
|
mcp-call --sync # re-sync from Claude configs
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
## Shell completion
|
|
42
|
+
|
|
43
|
+
Tab completion suggests server names, tool names, and flag names (no MCP roundtrip — reads a local cache).
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# bash — add to ~/.bashrc
|
|
47
|
+
eval "$(mcp-call --completion bash)"
|
|
48
|
+
|
|
49
|
+
# zsh — add to ~/.zshrc
|
|
50
|
+
eval "$(mcp-call --completion zsh)"
|
|
51
|
+
|
|
52
|
+
# fish — write once
|
|
53
|
+
mcp-call --completion fish > ~/.config/fish/completions/mcp-call.fish
|
|
54
|
+
|
|
55
|
+
# Populate the tool cache once (or re-run after server changes):
|
|
56
|
+
mcp-call --refresh-completions
|
|
57
|
+
mcp-call --clear-cache [server]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The cache lives at `~/.mcp-cli/cache/tools-<server>.json` and is also refreshed whenever you run `mcp-call <server> --tools`.
|
|
61
|
+
|
|
41
62
|
### Environment variables
|
|
42
63
|
|
|
43
64
|
`${VAR}` patterns in URLs, headers, command args, and env values are expanded at runtime:
|
|
@@ -24,6 +24,9 @@ mcp-call --add <name> <cmd> [args] [--env K=V ...] # add stdio server
|
|
|
24
24
|
mcp-call --add-http <name> <url> # add HTTP server
|
|
25
25
|
mcp-call --remove <name> # remove server
|
|
26
26
|
mcp-call --sync # re-sync from Claude configs
|
|
27
|
+
mcp-call --completion <bash|zsh|fish> # print shell completion hook
|
|
28
|
+
mcp-call --refresh-completions # cache tool lists for tab completion
|
|
29
|
+
mcp-call --clear-cache [server] # bust the completion cache
|
|
27
30
|
```
|
|
28
31
|
|
|
29
32
|
## Server Management
|
|
@@ -8,6 +8,7 @@ import sys
|
|
|
8
8
|
import tempfile
|
|
9
9
|
import re
|
|
10
10
|
import shutil
|
|
11
|
+
import time
|
|
11
12
|
import urllib.parse
|
|
12
13
|
import urllib.request
|
|
13
14
|
import urllib.error
|
|
@@ -15,9 +16,21 @@ import urllib.error
|
|
|
15
16
|
MCP_CALL_TMPDIR = os.path.join(tempfile.gettempdir(), "mcp-call")
|
|
16
17
|
CONFIG_DIR = os.path.expanduser("~/.mcp-cli")
|
|
17
18
|
CONFIG_PATH = os.path.join(CONFIG_DIR, "servers.json")
|
|
19
|
+
CACHE_DIR = os.path.join(CONFIG_DIR, "cache")
|
|
18
20
|
CLAUDE_SETTINGS = os.path.expanduser("~/.claude/settings.json")
|
|
19
21
|
CLAUDE_JSON = os.path.expanduser("~/.claude.json")
|
|
20
22
|
|
|
23
|
+
# CLI flags that don't take a positional server/tool — used for completion.
|
|
24
|
+
META_FLAGS = (
|
|
25
|
+
"--servers", "--sync", "--add", "--add-http", "--remove",
|
|
26
|
+
"--completion", "--refresh-completions", "--clear-cache",
|
|
27
|
+
"--version", "--help",
|
|
28
|
+
)
|
|
29
|
+
# Flags valid after a server name (no tool yet).
|
|
30
|
+
SERVER_FLAGS = ("--tools", "--discover", "--help")
|
|
31
|
+
# Flags valid after a server + tool.
|
|
32
|
+
TOOL_FLAGS = ("--help", "--schema", "--input-json")
|
|
33
|
+
|
|
21
34
|
|
|
22
35
|
def _load_json(path):
|
|
23
36
|
"""Load JSON file if it exists."""
|
|
@@ -83,6 +96,54 @@ def read_config():
|
|
|
83
96
|
return servers
|
|
84
97
|
|
|
85
98
|
|
|
99
|
+
# --- Tools cache (powers shell completion) ---
|
|
100
|
+
|
|
101
|
+
def _cache_path(server):
|
|
102
|
+
"""Disk path for a server's cached tool list."""
|
|
103
|
+
safe = re.sub(r"[^A-Za-z0-9._-]", "_", server)
|
|
104
|
+
return os.path.join(CACHE_DIR, f"tools-{safe}.json")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cache_write(server, tools):
|
|
108
|
+
"""Atomically persist a server's tool list. Never raises."""
|
|
109
|
+
try:
|
|
110
|
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
111
|
+
path = _cache_path(server)
|
|
112
|
+
tmp = path + ".tmp"
|
|
113
|
+
with open(tmp, "w") as f:
|
|
114
|
+
json.dump({"ts": int(time.time()), "tools": tools}, f)
|
|
115
|
+
os.replace(tmp, path)
|
|
116
|
+
except OSError:
|
|
117
|
+
pass # cache writes are best-effort; never break the main flow
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cache_read(server):
|
|
121
|
+
"""Return cached tools for a server (possibly stale), or empty list."""
|
|
122
|
+
try:
|
|
123
|
+
with open(_cache_path(server)) as f:
|
|
124
|
+
return json.load(f).get("tools", [])
|
|
125
|
+
except (OSError, json.JSONDecodeError):
|
|
126
|
+
return []
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _cache_clear(server=None):
|
|
130
|
+
"""Remove cache for one server, or all if server is None."""
|
|
131
|
+
if not os.path.isdir(CACHE_DIR):
|
|
132
|
+
return
|
|
133
|
+
if server:
|
|
134
|
+
try:
|
|
135
|
+
os.remove(_cache_path(server))
|
|
136
|
+
except FileNotFoundError:
|
|
137
|
+
pass
|
|
138
|
+
return
|
|
139
|
+
for name in os.listdir(CACHE_DIR):
|
|
140
|
+
if name.startswith("tools-") and name.endswith(".json"):
|
|
141
|
+
try:
|
|
142
|
+
os.remove(os.path.join(CACHE_DIR, name))
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
|
|
86
147
|
def parse_value(val):
|
|
87
148
|
"""Parse string value to appropriate type."""
|
|
88
149
|
try:
|
|
@@ -114,6 +175,9 @@ def parse_args():
|
|
|
114
175
|
print(" mcp-call --add-http <name> <url>", file=sys.stderr)
|
|
115
176
|
print(" mcp-call --remove <name>", file=sys.stderr)
|
|
116
177
|
print(" mcp-call --sync", file=sys.stderr)
|
|
178
|
+
print(" mcp-call --completion <bash|zsh|fish>", file=sys.stderr)
|
|
179
|
+
print(" mcp-call --refresh-completions (cache tool lists for tab completion)", file=sys.stderr)
|
|
180
|
+
print(" mcp-call --clear-cache [server]", file=sys.stderr)
|
|
117
181
|
sys.exit(0 if args else 1)
|
|
118
182
|
|
|
119
183
|
if args[0] == "--servers":
|
|
@@ -141,6 +205,13 @@ def parse_args():
|
|
|
141
205
|
return "__remove__", args[1], {}
|
|
142
206
|
if args[0] == "--sync":
|
|
143
207
|
return "__sync__", None, {}
|
|
208
|
+
if args[0] == "--completion":
|
|
209
|
+
shell = args[1] if len(args) > 1 else "bash"
|
|
210
|
+
return "__completion__", shell, {}
|
|
211
|
+
if args[0] == "--refresh-completions":
|
|
212
|
+
return "__refresh_completions__", None, {}
|
|
213
|
+
if args[0] == "--clear-cache":
|
|
214
|
+
return "__clear_cache__", args[1] if len(args) > 1 else None, {}
|
|
144
215
|
|
|
145
216
|
server = args[0]
|
|
146
217
|
if len(args) < 2 or args[1] == "--tools":
|
|
@@ -421,29 +492,32 @@ def stdio_call_tool(proc, tool_name, tool_args):
|
|
|
421
492
|
|
|
422
493
|
# --- Tool discovery ---
|
|
423
494
|
|
|
424
|
-
def fetch_tools(config):
|
|
425
|
-
"""Fetch tools list from server (HTTP or stdio)."""
|
|
495
|
+
def fetch_tools(config, server_name=""):
|
|
496
|
+
"""Fetch tools list from server (HTTP or stdio), caching for completion."""
|
|
497
|
+
tools = []
|
|
426
498
|
if is_http(config):
|
|
427
499
|
session = HttpSession(config["url"], config.get("headers"))
|
|
428
500
|
http_init(session)
|
|
429
501
|
resp = session.rpc("tools/list", {}, msg_id=2)
|
|
430
|
-
if
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
try:
|
|
435
|
-
init_server(proc)
|
|
436
|
-
send(proc, "tools/list", {}, msg_id=2)
|
|
437
|
-
resp = recv(proc, expected_id=2)
|
|
438
|
-
if not resp or "result" not in resp:
|
|
439
|
-
return []
|
|
440
|
-
return resp["result"].get("tools", [])
|
|
441
|
-
finally:
|
|
442
|
-
proc.terminate()
|
|
502
|
+
if resp and "result" in resp:
|
|
503
|
+
tools = resp["result"].get("tools", [])
|
|
504
|
+
else:
|
|
505
|
+
proc = spawn_server(config)
|
|
443
506
|
try:
|
|
444
|
-
proc
|
|
445
|
-
|
|
446
|
-
proc
|
|
507
|
+
init_server(proc)
|
|
508
|
+
send(proc, "tools/list", {}, msg_id=2)
|
|
509
|
+
resp = recv(proc, expected_id=2)
|
|
510
|
+
if resp and "result" in resp:
|
|
511
|
+
tools = resp["result"].get("tools", [])
|
|
512
|
+
finally:
|
|
513
|
+
proc.terminate()
|
|
514
|
+
try:
|
|
515
|
+
proc.wait(timeout=5)
|
|
516
|
+
except subprocess.TimeoutExpired:
|
|
517
|
+
proc.kill()
|
|
518
|
+
if server_name and tools:
|
|
519
|
+
_cache_write(server_name, tools)
|
|
520
|
+
return tools
|
|
447
521
|
|
|
448
522
|
|
|
449
523
|
def _colors():
|
|
@@ -658,13 +732,139 @@ def sync_from_claude():
|
|
|
658
732
|
print(f"Synced: {added} new servers added, {len(current)} total")
|
|
659
733
|
|
|
660
734
|
|
|
735
|
+
def refresh_completions():
|
|
736
|
+
"""Fetch tools/list from every configured server and cache results."""
|
|
737
|
+
servers = read_config()
|
|
738
|
+
ok, fail = 0, 0
|
|
739
|
+
for name, cfg in servers.items():
|
|
740
|
+
try:
|
|
741
|
+
tools = fetch_tools(cfg, name)
|
|
742
|
+
if tools:
|
|
743
|
+
ok += 1
|
|
744
|
+
print(f" ✓ {name}: {len(tools)} tools cached")
|
|
745
|
+
else:
|
|
746
|
+
fail += 1
|
|
747
|
+
print(f" · {name}: no tools returned", file=sys.stderr)
|
|
748
|
+
except SystemExit:
|
|
749
|
+
# fetch_tools may sys.exit on auth errors; catch to keep going
|
|
750
|
+
fail += 1
|
|
751
|
+
print(f" ✗ {name}: failed", file=sys.stderr)
|
|
752
|
+
except Exception as e:
|
|
753
|
+
fail += 1
|
|
754
|
+
print(f" ✗ {name}: {e}", file=sys.stderr)
|
|
755
|
+
print(f"\nCached {ok} servers ({fail} failed)")
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
# --- Shell completion ---
|
|
759
|
+
|
|
760
|
+
# Bash hook — passes all prior words + the current word as the last arg.
|
|
761
|
+
_BASH_COMPLETION = r"""
|
|
762
|
+
_mcp_call_complete() {
|
|
763
|
+
local cur="${COMP_WORDS[COMP_CWORD]}"
|
|
764
|
+
local prior=("${COMP_WORDS[@]:1:COMP_CWORD-1}")
|
|
765
|
+
local IFS=$'\n'
|
|
766
|
+
COMPREPLY=( $(_MCP_CALL_COMPLETE=1 mcp-call "${prior[@]}" "$cur" 2>/dev/null) )
|
|
767
|
+
}
|
|
768
|
+
complete -F _mcp_call_complete mcp-call
|
|
769
|
+
complete -F _mcp_call_complete mcp-cli-skill
|
|
770
|
+
""".strip()
|
|
771
|
+
|
|
772
|
+
_ZSH_COMPLETION = r"""
|
|
773
|
+
#compdef mcp-call mcp-cli-skill
|
|
774
|
+
_mcp_call_complete() {
|
|
775
|
+
local -a completions
|
|
776
|
+
local IFS=$'\n'
|
|
777
|
+
completions=( $(_MCP_CALL_COMPLETE=1 mcp-call "${words[@]:1}" 2>/dev/null) )
|
|
778
|
+
compadd -a completions
|
|
779
|
+
}
|
|
780
|
+
compdef _mcp_call_complete mcp-call
|
|
781
|
+
compdef _mcp_call_complete mcp-cli-skill
|
|
782
|
+
""".strip()
|
|
783
|
+
|
|
784
|
+
_FISH_COMPLETION = r"""
|
|
785
|
+
function __mcp_call_complete
|
|
786
|
+
set -l cmd (commandline -opc) (commandline -ct)
|
|
787
|
+
_MCP_CALL_COMPLETE=1 mcp-call $cmd[2..-1] 2>/dev/null
|
|
788
|
+
end
|
|
789
|
+
complete -c mcp-call -f -a "(__mcp_call_complete)"
|
|
790
|
+
complete -c mcp-cli-skill -f -a "(__mcp_call_complete)"
|
|
791
|
+
""".strip()
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def print_completion_script(shell):
|
|
795
|
+
"""Print the shell hook the user should source/eval."""
|
|
796
|
+
scripts = {"bash": _BASH_COMPLETION, "zsh": _ZSH_COMPLETION, "fish": _FISH_COMPLETION}
|
|
797
|
+
if shell not in scripts:
|
|
798
|
+
print(f"Error: unsupported shell '{shell}'. Use bash, zsh, or fish.", file=sys.stderr)
|
|
799
|
+
sys.exit(1)
|
|
800
|
+
print(scripts[shell])
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _completion_candidates(prior, partial):
|
|
804
|
+
"""Return candidate completions given the prior args and the partial word.
|
|
805
|
+
|
|
806
|
+
Position is determined by *non-flag* prior args:
|
|
807
|
+
- 0 positionals -> server names + top-level meta flags
|
|
808
|
+
- 1 positional -> tool names for that server + server-level flags
|
|
809
|
+
- >=2 positionals -> flag names for that tool from cached schema
|
|
810
|
+
|
|
811
|
+
Special-cased meta flags that expect a specific kind of next arg
|
|
812
|
+
short-circuit the positional logic.
|
|
813
|
+
"""
|
|
814
|
+
# Meta flags whose immediate next arg has a fixed shape — suggest
|
|
815
|
+
# only those, not random server/tool names.
|
|
816
|
+
if prior == ["--remove"] or prior == ["--clear-cache"]:
|
|
817
|
+
return list(_load_json(CONFIG_PATH).keys())
|
|
818
|
+
if prior == ["--completion"]:
|
|
819
|
+
return ["bash", "zsh", "fish"]
|
|
820
|
+
# --add / --add-http take free-form command/URL/headers — nothing useful to suggest.
|
|
821
|
+
if prior and prior[0] in ("--add", "--add-http"):
|
|
822
|
+
return []
|
|
823
|
+
|
|
824
|
+
positional = [a for a in prior if not a.startswith("-")]
|
|
825
|
+
|
|
826
|
+
if not positional:
|
|
827
|
+
servers = _load_json(CONFIG_PATH)
|
|
828
|
+
return list(servers.keys()) + list(META_FLAGS)
|
|
829
|
+
|
|
830
|
+
server = positional[0]
|
|
831
|
+
if len(positional) == 1:
|
|
832
|
+
tools = _cache_read(server)
|
|
833
|
+
return [t["name"] for t in tools if t.get("name")] + list(SERVER_FLAGS)
|
|
834
|
+
|
|
835
|
+
tool_name = positional[1]
|
|
836
|
+
tools = _cache_read(server)
|
|
837
|
+
for t in tools:
|
|
838
|
+
if t.get("name") == tool_name:
|
|
839
|
+
props = (t.get("inputSchema") or {}).get("properties") or {}
|
|
840
|
+
return [f"--{k}" for k in props] + list(TOOL_FLAGS)
|
|
841
|
+
return list(TOOL_FLAGS)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def do_completion():
|
|
845
|
+
"""Print newline-separated completion candidates matching the partial word."""
|
|
846
|
+
# The shell hook appends the partial (possibly empty) as the last arg.
|
|
847
|
+
args = sys.argv[1:]
|
|
848
|
+
if not args:
|
|
849
|
+
partial, prior = "", []
|
|
850
|
+
else:
|
|
851
|
+
partial, prior = args[-1], args[:-1]
|
|
852
|
+
try:
|
|
853
|
+
for cand in _completion_candidates(prior, partial):
|
|
854
|
+
if cand.startswith(partial):
|
|
855
|
+
print(cand)
|
|
856
|
+
except Exception:
|
|
857
|
+
# Never let completion errors leak into the user's terminal.
|
|
858
|
+
pass
|
|
859
|
+
|
|
860
|
+
|
|
661
861
|
# --- Main ---
|
|
662
862
|
|
|
663
863
|
def run_server(config, tool_name, tool_args, server_name=""):
|
|
664
864
|
"""Route to HTTP or stdio transport."""
|
|
665
865
|
# tool discovery commands
|
|
666
866
|
if tool_name in ("__tools__", "__discover__", "__schema__", "__help__"):
|
|
667
|
-
tools = fetch_tools(config)
|
|
867
|
+
tools = fetch_tools(config, server_name)
|
|
668
868
|
if tool_name == "__tools__":
|
|
669
869
|
_print_tools(tools)
|
|
670
870
|
elif tool_name == "__discover__":
|
|
@@ -705,6 +905,12 @@ def run_server(config, tool_name, tool_args, server_name=""):
|
|
|
705
905
|
|
|
706
906
|
|
|
707
907
|
def main():
|
|
908
|
+
# Shell completion is the fast path — runs on every TAB. Handle it
|
|
909
|
+
# before read_config() (which can print "Seeded..." to stderr).
|
|
910
|
+
if os.environ.get("_MCP_CALL_COMPLETE"):
|
|
911
|
+
do_completion()
|
|
912
|
+
return
|
|
913
|
+
|
|
708
914
|
servers = read_config()
|
|
709
915
|
server_name, tool_name, tool_args = parse_args()
|
|
710
916
|
|
|
@@ -723,6 +929,16 @@ def main():
|
|
|
723
929
|
if server_name == "__sync__":
|
|
724
930
|
sync_from_claude()
|
|
725
931
|
return
|
|
932
|
+
if server_name == "__completion__":
|
|
933
|
+
print_completion_script(tool_name)
|
|
934
|
+
return
|
|
935
|
+
if server_name == "__refresh_completions__":
|
|
936
|
+
refresh_completions()
|
|
937
|
+
return
|
|
938
|
+
if server_name == "__clear_cache__":
|
|
939
|
+
_cache_clear(tool_name)
|
|
940
|
+
print(f"Cleared cache{' for ' + tool_name if tool_name else ''}.")
|
|
941
|
+
return
|
|
726
942
|
|
|
727
943
|
if server_name not in servers:
|
|
728
944
|
print(f"Error: '{server_name}' not found. Available:", file=sys.stderr)
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""MCP CLI - Call any MCP server tool from the command line."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.6.0"
|
|
@@ -8,6 +8,7 @@ import sys
|
|
|
8
8
|
import tempfile
|
|
9
9
|
import re
|
|
10
10
|
import shutil
|
|
11
|
+
import time
|
|
11
12
|
import urllib.parse
|
|
12
13
|
import urllib.request
|
|
13
14
|
import urllib.error
|
|
@@ -15,9 +16,21 @@ import urllib.error
|
|
|
15
16
|
MCP_CALL_TMPDIR = os.path.join(tempfile.gettempdir(), "mcp-call")
|
|
16
17
|
CONFIG_DIR = os.path.expanduser("~/.mcp-cli")
|
|
17
18
|
CONFIG_PATH = os.path.join(CONFIG_DIR, "servers.json")
|
|
19
|
+
CACHE_DIR = os.path.join(CONFIG_DIR, "cache")
|
|
18
20
|
CLAUDE_SETTINGS = os.path.expanduser("~/.claude/settings.json")
|
|
19
21
|
CLAUDE_JSON = os.path.expanduser("~/.claude.json")
|
|
20
22
|
|
|
23
|
+
# CLI flags that don't take a positional server/tool — used for completion.
|
|
24
|
+
META_FLAGS = (
|
|
25
|
+
"--servers", "--sync", "--add", "--add-http", "--remove",
|
|
26
|
+
"--completion", "--refresh-completions", "--clear-cache",
|
|
27
|
+
"--version", "--help",
|
|
28
|
+
)
|
|
29
|
+
# Flags valid after a server name (no tool yet).
|
|
30
|
+
SERVER_FLAGS = ("--tools", "--discover", "--help")
|
|
31
|
+
# Flags valid after a server + tool.
|
|
32
|
+
TOOL_FLAGS = ("--help", "--schema", "--input-json")
|
|
33
|
+
|
|
21
34
|
|
|
22
35
|
def _load_json(path):
|
|
23
36
|
"""Load JSON file if it exists."""
|
|
@@ -83,6 +96,54 @@ def read_config():
|
|
|
83
96
|
return servers
|
|
84
97
|
|
|
85
98
|
|
|
99
|
+
# --- Tools cache (powers shell completion) ---
|
|
100
|
+
|
|
101
|
+
def _cache_path(server):
|
|
102
|
+
"""Disk path for a server's cached tool list."""
|
|
103
|
+
safe = re.sub(r"[^A-Za-z0-9._-]", "_", server)
|
|
104
|
+
return os.path.join(CACHE_DIR, f"tools-{safe}.json")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cache_write(server, tools):
|
|
108
|
+
"""Atomically persist a server's tool list. Never raises."""
|
|
109
|
+
try:
|
|
110
|
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
111
|
+
path = _cache_path(server)
|
|
112
|
+
tmp = path + ".tmp"
|
|
113
|
+
with open(tmp, "w") as f:
|
|
114
|
+
json.dump({"ts": int(time.time()), "tools": tools}, f)
|
|
115
|
+
os.replace(tmp, path)
|
|
116
|
+
except OSError:
|
|
117
|
+
pass # cache writes are best-effort; never break the main flow
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cache_read(server):
|
|
121
|
+
"""Return cached tools for a server (possibly stale), or empty list."""
|
|
122
|
+
try:
|
|
123
|
+
with open(_cache_path(server)) as f:
|
|
124
|
+
return json.load(f).get("tools", [])
|
|
125
|
+
except (OSError, json.JSONDecodeError):
|
|
126
|
+
return []
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _cache_clear(server=None):
|
|
130
|
+
"""Remove cache for one server, or all if server is None."""
|
|
131
|
+
if not os.path.isdir(CACHE_DIR):
|
|
132
|
+
return
|
|
133
|
+
if server:
|
|
134
|
+
try:
|
|
135
|
+
os.remove(_cache_path(server))
|
|
136
|
+
except FileNotFoundError:
|
|
137
|
+
pass
|
|
138
|
+
return
|
|
139
|
+
for name in os.listdir(CACHE_DIR):
|
|
140
|
+
if name.startswith("tools-") and name.endswith(".json"):
|
|
141
|
+
try:
|
|
142
|
+
os.remove(os.path.join(CACHE_DIR, name))
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
|
|
86
147
|
def parse_value(val):
|
|
87
148
|
"""Parse string value to appropriate type."""
|
|
88
149
|
try:
|
|
@@ -114,6 +175,9 @@ def parse_args():
|
|
|
114
175
|
print(" mcp-call --add-http <name> <url>", file=sys.stderr)
|
|
115
176
|
print(" mcp-call --remove <name>", file=sys.stderr)
|
|
116
177
|
print(" mcp-call --sync", file=sys.stderr)
|
|
178
|
+
print(" mcp-call --completion <bash|zsh|fish>", file=sys.stderr)
|
|
179
|
+
print(" mcp-call --refresh-completions (cache tool lists for tab completion)", file=sys.stderr)
|
|
180
|
+
print(" mcp-call --clear-cache [server]", file=sys.stderr)
|
|
117
181
|
sys.exit(0 if args else 1)
|
|
118
182
|
|
|
119
183
|
if args[0] == "--servers":
|
|
@@ -141,6 +205,13 @@ def parse_args():
|
|
|
141
205
|
return "__remove__", args[1], {}
|
|
142
206
|
if args[0] == "--sync":
|
|
143
207
|
return "__sync__", None, {}
|
|
208
|
+
if args[0] == "--completion":
|
|
209
|
+
shell = args[1] if len(args) > 1 else "bash"
|
|
210
|
+
return "__completion__", shell, {}
|
|
211
|
+
if args[0] == "--refresh-completions":
|
|
212
|
+
return "__refresh_completions__", None, {}
|
|
213
|
+
if args[0] == "--clear-cache":
|
|
214
|
+
return "__clear_cache__", args[1] if len(args) > 1 else None, {}
|
|
144
215
|
|
|
145
216
|
server = args[0]
|
|
146
217
|
if len(args) < 2 or args[1] == "--tools":
|
|
@@ -421,29 +492,32 @@ def stdio_call_tool(proc, tool_name, tool_args):
|
|
|
421
492
|
|
|
422
493
|
# --- Tool discovery ---
|
|
423
494
|
|
|
424
|
-
def fetch_tools(config):
|
|
425
|
-
"""Fetch tools list from server (HTTP or stdio)."""
|
|
495
|
+
def fetch_tools(config, server_name=""):
|
|
496
|
+
"""Fetch tools list from server (HTTP or stdio), caching for completion."""
|
|
497
|
+
tools = []
|
|
426
498
|
if is_http(config):
|
|
427
499
|
session = HttpSession(config["url"], config.get("headers"))
|
|
428
500
|
http_init(session)
|
|
429
501
|
resp = session.rpc("tools/list", {}, msg_id=2)
|
|
430
|
-
if
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
try:
|
|
435
|
-
init_server(proc)
|
|
436
|
-
send(proc, "tools/list", {}, msg_id=2)
|
|
437
|
-
resp = recv(proc, expected_id=2)
|
|
438
|
-
if not resp or "result" not in resp:
|
|
439
|
-
return []
|
|
440
|
-
return resp["result"].get("tools", [])
|
|
441
|
-
finally:
|
|
442
|
-
proc.terminate()
|
|
502
|
+
if resp and "result" in resp:
|
|
503
|
+
tools = resp["result"].get("tools", [])
|
|
504
|
+
else:
|
|
505
|
+
proc = spawn_server(config)
|
|
443
506
|
try:
|
|
444
|
-
proc
|
|
445
|
-
|
|
446
|
-
proc
|
|
507
|
+
init_server(proc)
|
|
508
|
+
send(proc, "tools/list", {}, msg_id=2)
|
|
509
|
+
resp = recv(proc, expected_id=2)
|
|
510
|
+
if resp and "result" in resp:
|
|
511
|
+
tools = resp["result"].get("tools", [])
|
|
512
|
+
finally:
|
|
513
|
+
proc.terminate()
|
|
514
|
+
try:
|
|
515
|
+
proc.wait(timeout=5)
|
|
516
|
+
except subprocess.TimeoutExpired:
|
|
517
|
+
proc.kill()
|
|
518
|
+
if server_name and tools:
|
|
519
|
+
_cache_write(server_name, tools)
|
|
520
|
+
return tools
|
|
447
521
|
|
|
448
522
|
|
|
449
523
|
def _colors():
|
|
@@ -658,13 +732,139 @@ def sync_from_claude():
|
|
|
658
732
|
print(f"Synced: {added} new servers added, {len(current)} total")
|
|
659
733
|
|
|
660
734
|
|
|
735
|
+
def refresh_completions():
|
|
736
|
+
"""Fetch tools/list from every configured server and cache results."""
|
|
737
|
+
servers = read_config()
|
|
738
|
+
ok, fail = 0, 0
|
|
739
|
+
for name, cfg in servers.items():
|
|
740
|
+
try:
|
|
741
|
+
tools = fetch_tools(cfg, name)
|
|
742
|
+
if tools:
|
|
743
|
+
ok += 1
|
|
744
|
+
print(f" ✓ {name}: {len(tools)} tools cached")
|
|
745
|
+
else:
|
|
746
|
+
fail += 1
|
|
747
|
+
print(f" · {name}: no tools returned", file=sys.stderr)
|
|
748
|
+
except SystemExit:
|
|
749
|
+
# fetch_tools may sys.exit on auth errors; catch to keep going
|
|
750
|
+
fail += 1
|
|
751
|
+
print(f" ✗ {name}: failed", file=sys.stderr)
|
|
752
|
+
except Exception as e:
|
|
753
|
+
fail += 1
|
|
754
|
+
print(f" ✗ {name}: {e}", file=sys.stderr)
|
|
755
|
+
print(f"\nCached {ok} servers ({fail} failed)")
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
# --- Shell completion ---
|
|
759
|
+
|
|
760
|
+
# Bash hook — passes all prior words + the current word as the last arg.
|
|
761
|
+
_BASH_COMPLETION = r"""
|
|
762
|
+
_mcp_call_complete() {
|
|
763
|
+
local cur="${COMP_WORDS[COMP_CWORD]}"
|
|
764
|
+
local prior=("${COMP_WORDS[@]:1:COMP_CWORD-1}")
|
|
765
|
+
local IFS=$'\n'
|
|
766
|
+
COMPREPLY=( $(_MCP_CALL_COMPLETE=1 mcp-call "${prior[@]}" "$cur" 2>/dev/null) )
|
|
767
|
+
}
|
|
768
|
+
complete -F _mcp_call_complete mcp-call
|
|
769
|
+
complete -F _mcp_call_complete mcp-cli-skill
|
|
770
|
+
""".strip()
|
|
771
|
+
|
|
772
|
+
_ZSH_COMPLETION = r"""
|
|
773
|
+
#compdef mcp-call mcp-cli-skill
|
|
774
|
+
_mcp_call_complete() {
|
|
775
|
+
local -a completions
|
|
776
|
+
local IFS=$'\n'
|
|
777
|
+
completions=( $(_MCP_CALL_COMPLETE=1 mcp-call "${words[@]:1}" 2>/dev/null) )
|
|
778
|
+
compadd -a completions
|
|
779
|
+
}
|
|
780
|
+
compdef _mcp_call_complete mcp-call
|
|
781
|
+
compdef _mcp_call_complete mcp-cli-skill
|
|
782
|
+
""".strip()
|
|
783
|
+
|
|
784
|
+
_FISH_COMPLETION = r"""
|
|
785
|
+
function __mcp_call_complete
|
|
786
|
+
set -l cmd (commandline -opc) (commandline -ct)
|
|
787
|
+
_MCP_CALL_COMPLETE=1 mcp-call $cmd[2..-1] 2>/dev/null
|
|
788
|
+
end
|
|
789
|
+
complete -c mcp-call -f -a "(__mcp_call_complete)"
|
|
790
|
+
complete -c mcp-cli-skill -f -a "(__mcp_call_complete)"
|
|
791
|
+
""".strip()
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def print_completion_script(shell):
|
|
795
|
+
"""Print the shell hook the user should source/eval."""
|
|
796
|
+
scripts = {"bash": _BASH_COMPLETION, "zsh": _ZSH_COMPLETION, "fish": _FISH_COMPLETION}
|
|
797
|
+
if shell not in scripts:
|
|
798
|
+
print(f"Error: unsupported shell '{shell}'. Use bash, zsh, or fish.", file=sys.stderr)
|
|
799
|
+
sys.exit(1)
|
|
800
|
+
print(scripts[shell])
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _completion_candidates(prior, partial):
|
|
804
|
+
"""Return candidate completions given the prior args and the partial word.
|
|
805
|
+
|
|
806
|
+
Position is determined by *non-flag* prior args:
|
|
807
|
+
- 0 positionals -> server names + top-level meta flags
|
|
808
|
+
- 1 positional -> tool names for that server + server-level flags
|
|
809
|
+
- >=2 positionals -> flag names for that tool from cached schema
|
|
810
|
+
|
|
811
|
+
Special-cased meta flags that expect a specific kind of next arg
|
|
812
|
+
short-circuit the positional logic.
|
|
813
|
+
"""
|
|
814
|
+
# Meta flags whose immediate next arg has a fixed shape — suggest
|
|
815
|
+
# only those, not random server/tool names.
|
|
816
|
+
if prior == ["--remove"] or prior == ["--clear-cache"]:
|
|
817
|
+
return list(_load_json(CONFIG_PATH).keys())
|
|
818
|
+
if prior == ["--completion"]:
|
|
819
|
+
return ["bash", "zsh", "fish"]
|
|
820
|
+
# --add / --add-http take free-form command/URL/headers — nothing useful to suggest.
|
|
821
|
+
if prior and prior[0] in ("--add", "--add-http"):
|
|
822
|
+
return []
|
|
823
|
+
|
|
824
|
+
positional = [a for a in prior if not a.startswith("-")]
|
|
825
|
+
|
|
826
|
+
if not positional:
|
|
827
|
+
servers = _load_json(CONFIG_PATH)
|
|
828
|
+
return list(servers.keys()) + list(META_FLAGS)
|
|
829
|
+
|
|
830
|
+
server = positional[0]
|
|
831
|
+
if len(positional) == 1:
|
|
832
|
+
tools = _cache_read(server)
|
|
833
|
+
return [t["name"] for t in tools if t.get("name")] + list(SERVER_FLAGS)
|
|
834
|
+
|
|
835
|
+
tool_name = positional[1]
|
|
836
|
+
tools = _cache_read(server)
|
|
837
|
+
for t in tools:
|
|
838
|
+
if t.get("name") == tool_name:
|
|
839
|
+
props = (t.get("inputSchema") or {}).get("properties") or {}
|
|
840
|
+
return [f"--{k}" for k in props] + list(TOOL_FLAGS)
|
|
841
|
+
return list(TOOL_FLAGS)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def do_completion():
|
|
845
|
+
"""Print newline-separated completion candidates matching the partial word."""
|
|
846
|
+
# The shell hook appends the partial (possibly empty) as the last arg.
|
|
847
|
+
args = sys.argv[1:]
|
|
848
|
+
if not args:
|
|
849
|
+
partial, prior = "", []
|
|
850
|
+
else:
|
|
851
|
+
partial, prior = args[-1], args[:-1]
|
|
852
|
+
try:
|
|
853
|
+
for cand in _completion_candidates(prior, partial):
|
|
854
|
+
if cand.startswith(partial):
|
|
855
|
+
print(cand)
|
|
856
|
+
except Exception:
|
|
857
|
+
# Never let completion errors leak into the user's terminal.
|
|
858
|
+
pass
|
|
859
|
+
|
|
860
|
+
|
|
661
861
|
# --- Main ---
|
|
662
862
|
|
|
663
863
|
def run_server(config, tool_name, tool_args, server_name=""):
|
|
664
864
|
"""Route to HTTP or stdio transport."""
|
|
665
865
|
# tool discovery commands
|
|
666
866
|
if tool_name in ("__tools__", "__discover__", "__schema__", "__help__"):
|
|
667
|
-
tools = fetch_tools(config)
|
|
867
|
+
tools = fetch_tools(config, server_name)
|
|
668
868
|
if tool_name == "__tools__":
|
|
669
869
|
_print_tools(tools)
|
|
670
870
|
elif tool_name == "__discover__":
|
|
@@ -705,6 +905,12 @@ def run_server(config, tool_name, tool_args, server_name=""):
|
|
|
705
905
|
|
|
706
906
|
|
|
707
907
|
def main():
|
|
908
|
+
# Shell completion is the fast path — runs on every TAB. Handle it
|
|
909
|
+
# before read_config() (which can print "Seeded..." to stderr).
|
|
910
|
+
if os.environ.get("_MCP_CALL_COMPLETE"):
|
|
911
|
+
do_completion()
|
|
912
|
+
return
|
|
913
|
+
|
|
708
914
|
servers = read_config()
|
|
709
915
|
server_name, tool_name, tool_args = parse_args()
|
|
710
916
|
|
|
@@ -723,6 +929,16 @@ def main():
|
|
|
723
929
|
if server_name == "__sync__":
|
|
724
930
|
sync_from_claude()
|
|
725
931
|
return
|
|
932
|
+
if server_name == "__completion__":
|
|
933
|
+
print_completion_script(tool_name)
|
|
934
|
+
return
|
|
935
|
+
if server_name == "__refresh_completions__":
|
|
936
|
+
refresh_completions()
|
|
937
|
+
return
|
|
938
|
+
if server_name == "__clear_cache__":
|
|
939
|
+
_cache_clear(tool_name)
|
|
940
|
+
print(f"Cleared cache{' for ' + tool_name if tool_name else ''}.")
|
|
941
|
+
return
|
|
726
942
|
|
|
727
943
|
if server_name not in servers:
|
|
728
944
|
print(f"Error: '{server_name}' not found. Available:", file=sys.stderr)
|
|
File without changes
|