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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-cli-skill
3
- Version: 0.5.3
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 not resp or "result" not in resp:
431
- return []
432
- return resp["result"].get("tools", [])
433
- proc = spawn_server(config)
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.wait(timeout=5)
445
- except subprocess.TimeoutExpired:
446
- proc.kill()
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.5.3"
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 not resp or "result" not in resp:
431
- return []
432
- return resp["result"].get("tools", [])
433
- proc = spawn_server(config)
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.wait(timeout=5)
445
- except subprocess.TimeoutExpired:
446
- proc.kill()
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)