mcp-cli-skill 0.1.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.
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-cli-skill
3
+ Version: 0.1.0
4
+ Summary: Call any MCP server tool from the command line with shell composition support
5
+ Project-URL: Homepage, https://github.com/wise-toddler/mcp-cli-skill
6
+ Project-URL: Repository, https://github.com/wise-toddler/mcp-cli-skill
7
+ Author-email: Shivansh Singh <wiseeldrich2004@gmail.com>
8
+ License-Expression: MIT
9
+ Keywords: agent,cli,mcp,model-context-protocol,skills
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+
17
+ # mcp-cli
18
+
19
+ Call any MCP server tool from the command line with shell composition support.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ # As a CLI tool (recommended)
25
+ pipx install mcp-cli-skill
26
+
27
+ # Or run directly without installing
28
+ uvx mcp-cli-skill --servers
29
+
30
+ # As a Claude Code skill
31
+ npx skills add wise-toddler/mcp-cli-skill -g
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```bash
37
+ mcp-call --servers # list configured servers
38
+ mcp-call <server> --tools # discover tools
39
+ mcp-call <server> <tool> --key=value ... # call a tool
40
+ ```
41
+
42
+ ## Server Management
43
+
44
+ Config stored at `~/.mcp-cli/servers.json`. On first run, auto-seeds from `~/.claude/settings.json`.
45
+
46
+ ```bash
47
+ mcp-call --add myserver uvx some-mcp --env API_KEY=abc123
48
+ mcp-call --remove myserver
49
+ mcp-call --sync # re-sync from ~/.claude/settings.json
50
+ ```
51
+
52
+ ## Why?
53
+
54
+ MCP tool calls can't use shell composition. This CLI lets agents (or you) use:
55
+
56
+ - File content as args: `--query="$(cat /tmp/query.sql)"`
57
+ - Pipe output: `| jq '.results'`
58
+ - Shell variables: `--name="$VAR"`
59
+ - Chaining: `cmd1 && cmd2`
60
+
61
+ ## Examples
62
+
63
+ ```bash
64
+ mcp-call redash redash_query \
65
+ --action=adhoc --query="$(cat /tmp/q.sql)" --data_source_id=1
66
+
67
+ mcp-call slack slack_chat \
68
+ --action=post --channel=C123 --text="$(cat /tmp/msg.txt)"
69
+
70
+ mcp-call redash redash_query \
71
+ --action=list --page_size=5 | jq '.results[].name'
72
+ ```
73
+
74
+ ## Requirements
75
+
76
+ - Python 3.10+
77
+
78
+ ## How it works
79
+
80
+ Reads MCP server config from `~/.mcp-cli/servers.json` (standalone, agent-agnostic). On first run, seeds from `~/.claude/settings.json`. Spawns the server as a subprocess, speaks JSON-RPC over stdio, prints the result. Zero dependencies — pure Python stdlib.
@@ -0,0 +1,64 @@
1
+ # mcp-cli
2
+
3
+ Call any MCP server tool from the command line with shell composition support.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # As a CLI tool (recommended)
9
+ pipx install mcp-cli-skill
10
+
11
+ # Or run directly without installing
12
+ uvx mcp-cli-skill --servers
13
+
14
+ # As a Claude Code skill
15
+ npx skills add wise-toddler/mcp-cli-skill -g
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ mcp-call --servers # list configured servers
22
+ mcp-call <server> --tools # discover tools
23
+ mcp-call <server> <tool> --key=value ... # call a tool
24
+ ```
25
+
26
+ ## Server Management
27
+
28
+ Config stored at `~/.mcp-cli/servers.json`. On first run, auto-seeds from `~/.claude/settings.json`.
29
+
30
+ ```bash
31
+ mcp-call --add myserver uvx some-mcp --env API_KEY=abc123
32
+ mcp-call --remove myserver
33
+ mcp-call --sync # re-sync from ~/.claude/settings.json
34
+ ```
35
+
36
+ ## Why?
37
+
38
+ MCP tool calls can't use shell composition. This CLI lets agents (or you) use:
39
+
40
+ - File content as args: `--query="$(cat /tmp/query.sql)"`
41
+ - Pipe output: `| jq '.results'`
42
+ - Shell variables: `--name="$VAR"`
43
+ - Chaining: `cmd1 && cmd2`
44
+
45
+ ## Examples
46
+
47
+ ```bash
48
+ mcp-call redash redash_query \
49
+ --action=adhoc --query="$(cat /tmp/q.sql)" --data_source_id=1
50
+
51
+ mcp-call slack slack_chat \
52
+ --action=post --channel=C123 --text="$(cat /tmp/msg.txt)"
53
+
54
+ mcp-call redash redash_query \
55
+ --action=list --page_size=5 | jq '.results[].name'
56
+ ```
57
+
58
+ ## Requirements
59
+
60
+ - Python 3.10+
61
+
62
+ ## How it works
63
+
64
+ Reads MCP server config from `~/.mcp-cli/servers.json` (standalone, agent-agnostic). On first run, seeds from `~/.claude/settings.json`. Spawns the server as a subprocess, speaks JSON-RPC over stdio, prints the result. Zero dependencies — pure Python stdlib.
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: mcp-cli
3
+ description: Call any MCP server tool as a CLI command with shell composition. Use when tool args come from files, pipes, or shell commands. Trigger on "mcp-call", "call mcp from cli", or when MCP tool content is in a file.
4
+ ---
5
+
6
+ # MCP CLI
7
+
8
+ Call any configured MCP server tool from the command line with `--flag=value` style args and full shell composition support.
9
+
10
+ If `mcp-call` is not found, install it: `pipx install mcp-cli-skill` or `uvx mcp-cli-skill`
11
+
12
+ ## Commands
13
+
14
+ ```bash
15
+ mcp-call --servers # list configured servers
16
+ mcp-call <server> --tools # list tools for a server
17
+ mcp-call <server> <tool> --key=value ... # call a tool
18
+ mcp-call --add <name> <cmd> [args] [--env K=V ...] # add server
19
+ mcp-call --remove <name> # remove server
20
+ mcp-call --sync # re-sync from ~/.claude/settings.json
21
+ ```
22
+
23
+ ## Server Management
24
+
25
+ Config stored at `~/.mcp-cli/servers.json`. On first run, seeds from `~/.claude/settings.json`.
26
+
27
+ ```bash
28
+ mcp-call --add myredash uvx redash-mcp --env REDASH_URL=http://localhost --env REDASH_API_KEY=abc123
29
+ mcp-call --add github npx @modelcontextprotocol/server-github --env GITHUB_TOKEN=ghp_xxx
30
+ mcp-call --remove myredash
31
+ mcp-call --sync
32
+ ```
33
+
34
+ ## Examples
35
+
36
+ ```bash
37
+ mcp-call redash redash_query --action=adhoc --query="$(cat /tmp/query.sql)" --data_source_id=1
38
+ mcp-call redash redash_query --action=list --page_size=5 | jq '.results[].name'
39
+ mcp-call slack slack_chat --action=post --channel=C123 --text="$(cat /tmp/msg.txt)"
40
+ mcp-call redash redash_query --action=run --id=42 | jq '.query_result.data.rows' > /tmp/result.json
41
+ ```
42
+
43
+ ## When to Use
44
+
45
+ Prefer this over MCP tool calls when:
46
+ - Content comes from a file: `--arg="$(cat file)"`
47
+ - Output needs piping: `| jq`, `> file`, `| grep`
48
+ - Shell variable expansion needed: `--arg="$VAR"`
49
+ - Chaining multiple calls in one command
50
+
51
+ ## Arg Types
52
+
53
+ Values auto-parse: `--id=42` → int, `--flag=true` → bool, `--name=hello` → string.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-cli-skill"
7
+ version = "0.1.0"
8
+ description = "Call any MCP server tool from the command line with shell composition support"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Shivansh Singh", email = "wiseeldrich2004@gmail.com" }
14
+ ]
15
+ keywords = ["mcp", "model-context-protocol", "cli", "agent", "skills"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/wise-toddler/mcp-cli-skill"
25
+ Repository = "https://github.com/wise-toddler/mcp-cli-skill"
26
+
27
+ [project.scripts]
28
+ mcp-call = "mcp_cli_skill.cli:main"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/mcp_cli_skill"]
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env python3
2
+ """Call any MCP server tool from CLI with --flag=value args."""
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+
8
+ CONFIG_DIR = os.path.expanduser("~/.mcp-cli")
9
+ CONFIG_PATH = os.path.join(CONFIG_DIR, "servers.json")
10
+ CLAUDE_SETTINGS = os.path.expanduser("~/.claude/settings.json")
11
+
12
+
13
+ def _load_json(path):
14
+ """Load JSON file if it exists."""
15
+ if os.path.exists(path):
16
+ with open(path) as f:
17
+ return json.load(f)
18
+ return {}
19
+
20
+
21
+ def _save_config(servers):
22
+ """Save servers to standalone config."""
23
+ os.makedirs(CONFIG_DIR, exist_ok=True)
24
+ with open(CONFIG_PATH, "w") as f:
25
+ json.dump(servers, f, indent=2)
26
+
27
+
28
+ def read_config():
29
+ """Read MCP servers, seeding from Claude settings on first run."""
30
+ if os.path.exists(CONFIG_PATH):
31
+ return _load_json(CONFIG_PATH)
32
+ # first run: seed from ~/.claude/settings.json
33
+ servers = _load_json(CLAUDE_SETTINGS).get("mcpServers", {})
34
+ if servers:
35
+ _save_config(servers)
36
+ print(f"Seeded {len(servers)} servers from {CLAUDE_SETTINGS}", file=sys.stderr)
37
+ return servers
38
+
39
+
40
+ def parse_value(val):
41
+ """Parse string value to appropriate type."""
42
+ try:
43
+ return json.loads(val)
44
+ except (json.JSONDecodeError, ValueError):
45
+ return val
46
+
47
+
48
+ def parse_args():
49
+ """Parse CLI arguments into server, tool, and args dict."""
50
+ args = sys.argv[1:]
51
+ if not args or args[0] in ("-h", "--help"):
52
+ print("Usage: mcp-call <server> <tool> [--key=value ...]", file=sys.stderr)
53
+ print(" mcp-call --servers", file=sys.stderr)
54
+ print(" mcp-call <server> --tools", file=sys.stderr)
55
+ print(" mcp-call --add <name> <command> [args...] [--env KEY=VAL ...]", file=sys.stderr)
56
+ print(" mcp-call --remove <name>", file=sys.stderr)
57
+ print(" mcp-call --sync", file=sys.stderr)
58
+ sys.exit(0 if args else 1)
59
+
60
+ if args[0] == "--servers":
61
+ return "__servers__", None, {}
62
+ if args[0] == "--add":
63
+ return "__add__", None, {"_raw": args[1:]}
64
+ if args[0] == "--remove":
65
+ if len(args) < 2:
66
+ print("Usage: mcp_call.py --remove <name>", file=sys.stderr)
67
+ sys.exit(1)
68
+ return "__remove__", args[1], {}
69
+ if args[0] == "--sync":
70
+ return "__sync__", None, {}
71
+
72
+ server = args[0]
73
+ if len(args) < 2 or args[1] == "--tools":
74
+ return server, "__tools__", {}
75
+
76
+ tool = args[1]
77
+ tool_args = {}
78
+ for arg in args[2:]:
79
+ if arg.startswith("--") and "=" in arg:
80
+ key, val = arg[2:].split("=", 1)
81
+ tool_args[key] = parse_value(val)
82
+ elif arg.startswith("--"):
83
+ tool_args[arg[2:]] = True
84
+ return server, tool, tool_args
85
+
86
+
87
+ def send(proc, method, params=None, msg_id=None):
88
+ """Send JSON-RPC message."""
89
+ msg = {"jsonrpc": "2.0", "method": method}
90
+ if params:
91
+ msg["params"] = params
92
+ if msg_id is not None:
93
+ msg["id"] = msg_id
94
+ proc.stdin.write(json.dumps(msg) + "\n")
95
+ proc.stdin.flush()
96
+
97
+
98
+ def recv(proc, expected_id=None):
99
+ """Read JSON-RPC response, optionally matching by id."""
100
+ for _ in range(10): # skip spurious responses (e.g. notification acks)
101
+ line = proc.stdout.readline()
102
+ if not line:
103
+ return None
104
+ resp = json.loads(line)
105
+ if expected_id is None or resp.get("id") == expected_id:
106
+ return resp
107
+ return None
108
+
109
+
110
+ def spawn_server(config):
111
+ """Spawn MCP server subprocess."""
112
+ cmd = [config["command"]] + config.get("args", [])
113
+ env = {**os.environ, **config.get("env", {})}
114
+ return subprocess.Popen(
115
+ cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
116
+ stderr=subprocess.PIPE, text=True, env=env
117
+ )
118
+
119
+
120
+ def check_alive(proc):
121
+ """Check if server process is still running, print stderr if dead."""
122
+ if proc.poll() is not None:
123
+ stderr = proc.stderr.read() if proc.stderr else ""
124
+ print(f"Error: server exited with code {proc.returncode}", file=sys.stderr)
125
+ if stderr.strip():
126
+ print(stderr.strip(), file=sys.stderr)
127
+ sys.exit(1)
128
+
129
+
130
+ def init_server(proc):
131
+ """Initialize MCP handshake."""
132
+ check_alive(proc)
133
+ try:
134
+ send(proc, "initialize", {
135
+ "protocolVersion": "2024-11-05",
136
+ "capabilities": {},
137
+ "clientInfo": {"name": "mcp-cli", "version": "1.0"}
138
+ }, msg_id=1)
139
+ resp = recv(proc, expected_id=1)
140
+ if not resp:
141
+ check_alive(proc)
142
+ print("Error: no response from server during init", file=sys.stderr)
143
+ sys.exit(1)
144
+ send(proc, "notifications/initialized")
145
+ except BrokenPipeError:
146
+ check_alive(proc)
147
+ print("Error: server crashed during init", file=sys.stderr)
148
+ sys.exit(1)
149
+
150
+
151
+ def list_servers(servers):
152
+ """Print configured servers."""
153
+ for name, cfg in servers.items():
154
+ cmd = " ".join([cfg["command"]] + cfg.get("args", []))
155
+ print(f" {name:20s} → {cmd}")
156
+
157
+
158
+ def list_tools(proc):
159
+ """List tools from server."""
160
+ send(proc, "tools/list", {}, msg_id=2)
161
+ resp = recv(proc, expected_id=2)
162
+ if not resp or "result" not in resp:
163
+ return
164
+ for tool in resp["result"].get("tools", []):
165
+ schema = tool.get("inputSchema", {})
166
+ props = schema.get("properties", {})
167
+ flags = " ".join(f"--{k}" for k in props)
168
+ print(f" {tool['name']:30s} {flags}")
169
+ if tool.get("description"):
170
+ print(f" {tool['description']}")
171
+
172
+
173
+ def call_tool(proc, tool_name, tool_args):
174
+ """Call a tool and print result."""
175
+ send(proc, "tools/call", {"name": tool_name, "arguments": tool_args}, msg_id=3)
176
+ resp = recv(proc, expected_id=3)
177
+ if not resp:
178
+ print("Error: no response", file=sys.stderr)
179
+ sys.exit(1)
180
+ if "error" in resp:
181
+ print(json.dumps(resp["error"], indent=2), file=sys.stderr)
182
+ sys.exit(1)
183
+ for item in resp.get("result", {}).get("content", []):
184
+ if item.get("type") == "text":
185
+ try:
186
+ print(json.dumps(json.loads(item["text"]), indent=2, default=str))
187
+ except json.JSONDecodeError:
188
+ print(item["text"])
189
+
190
+
191
+ def add_server(raw_args):
192
+ """Add a new MCP server."""
193
+ if len(raw_args) < 2:
194
+ print("Usage: --add <name> <command> [args...] [--env KEY=VAL ...]", file=sys.stderr)
195
+ sys.exit(1)
196
+ name = raw_args[0]
197
+ command = raw_args[1]
198
+ cmd_args = []
199
+ env = {}
200
+ i = 2
201
+ while i < len(raw_args):
202
+ if raw_args[i] == "--env" and i + 1 < len(raw_args):
203
+ k, v = raw_args[i + 1].split("=", 1)
204
+ env[k] = v
205
+ i += 2
206
+ else:
207
+ cmd_args.append(raw_args[i])
208
+ i += 1
209
+ servers = read_config()
210
+ entry = {"command": command}
211
+ if cmd_args:
212
+ entry["args"] = cmd_args
213
+ if env:
214
+ entry["env"] = env
215
+ servers[name] = entry
216
+ _save_config(servers)
217
+ print(f"Added server '{name}': {command} {' '.join(cmd_args)}")
218
+
219
+
220
+ def remove_server(name):
221
+ """Remove an MCP server."""
222
+ servers = read_config()
223
+ if name not in servers:
224
+ print(f"Error: '{name}' not found.", file=sys.stderr)
225
+ sys.exit(1)
226
+ del servers[name]
227
+ _save_config(servers)
228
+ print(f"Removed server '{name}'")
229
+
230
+
231
+ def sync_from_claude():
232
+ """Re-sync servers from ~/.claude/settings.json (merges, doesn't overwrite)."""
233
+ claude_servers = _load_json(CLAUDE_SETTINGS).get("mcpServers", {})
234
+ current = read_config()
235
+ added = 0
236
+ for name, cfg in claude_servers.items():
237
+ if name not in current:
238
+ current[name] = cfg
239
+ added += 1
240
+ _save_config(current)
241
+ print(f"Synced: {added} new servers added, {len(current)} total")
242
+
243
+
244
+ def main():
245
+ servers = read_config()
246
+ server_name, tool_name, tool_args = parse_args()
247
+
248
+ if server_name == "__servers__":
249
+ list_servers(servers)
250
+ return
251
+ if server_name == "__add__":
252
+ add_server(tool_args["_raw"])
253
+ return
254
+ if server_name == "__remove__":
255
+ remove_server(tool_name)
256
+ return
257
+ if server_name == "__sync__":
258
+ sync_from_claude()
259
+ return
260
+
261
+ if server_name not in servers:
262
+ print(f"Error: '{server_name}' not found. Available:", file=sys.stderr)
263
+ list_servers(servers)
264
+ sys.exit(1)
265
+
266
+ proc = spawn_server(servers[server_name])
267
+ try:
268
+ init_server(proc)
269
+ if tool_name == "__tools__":
270
+ list_tools(proc)
271
+ else:
272
+ call_tool(proc, tool_name, tool_args)
273
+ finally:
274
+ proc.terminate()
275
+ try:
276
+ proc.wait(timeout=5)
277
+ except subprocess.TimeoutExpired:
278
+ proc.kill()
279
+
280
+
281
+ if __name__ == "__main__":
282
+ main()
@@ -0,0 +1 @@
1
+ """MCP CLI - Call any MCP server tool from the command line."""
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env python3
2
+ """Call any MCP server tool from CLI with --flag=value args."""
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+
8
+ CONFIG_DIR = os.path.expanduser("~/.mcp-cli")
9
+ CONFIG_PATH = os.path.join(CONFIG_DIR, "servers.json")
10
+ CLAUDE_SETTINGS = os.path.expanduser("~/.claude/settings.json")
11
+
12
+
13
+ def _load_json(path):
14
+ """Load JSON file if it exists."""
15
+ if os.path.exists(path):
16
+ with open(path) as f:
17
+ return json.load(f)
18
+ return {}
19
+
20
+
21
+ def _save_config(servers):
22
+ """Save servers to standalone config."""
23
+ os.makedirs(CONFIG_DIR, exist_ok=True)
24
+ with open(CONFIG_PATH, "w") as f:
25
+ json.dump(servers, f, indent=2)
26
+
27
+
28
+ def read_config():
29
+ """Read MCP servers, seeding from Claude settings on first run."""
30
+ if os.path.exists(CONFIG_PATH):
31
+ return _load_json(CONFIG_PATH)
32
+ # first run: seed from ~/.claude/settings.json
33
+ servers = _load_json(CLAUDE_SETTINGS).get("mcpServers", {})
34
+ if servers:
35
+ _save_config(servers)
36
+ print(f"Seeded {len(servers)} servers from {CLAUDE_SETTINGS}", file=sys.stderr)
37
+ return servers
38
+
39
+
40
+ def parse_value(val):
41
+ """Parse string value to appropriate type."""
42
+ try:
43
+ return json.loads(val)
44
+ except (json.JSONDecodeError, ValueError):
45
+ return val
46
+
47
+
48
+ def parse_args():
49
+ """Parse CLI arguments into server, tool, and args dict."""
50
+ args = sys.argv[1:]
51
+ if not args or args[0] in ("-h", "--help"):
52
+ print("Usage: mcp-call <server> <tool> [--key=value ...]", file=sys.stderr)
53
+ print(" mcp-call --servers", file=sys.stderr)
54
+ print(" mcp-call <server> --tools", file=sys.stderr)
55
+ print(" mcp-call --add <name> <command> [args...] [--env KEY=VAL ...]", file=sys.stderr)
56
+ print(" mcp-call --remove <name>", file=sys.stderr)
57
+ print(" mcp-call --sync", file=sys.stderr)
58
+ sys.exit(0 if args else 1)
59
+
60
+ if args[0] == "--servers":
61
+ return "__servers__", None, {}
62
+ if args[0] == "--add":
63
+ return "__add__", None, {"_raw": args[1:]}
64
+ if args[0] == "--remove":
65
+ if len(args) < 2:
66
+ print("Usage: mcp_call.py --remove <name>", file=sys.stderr)
67
+ sys.exit(1)
68
+ return "__remove__", args[1], {}
69
+ if args[0] == "--sync":
70
+ return "__sync__", None, {}
71
+
72
+ server = args[0]
73
+ if len(args) < 2 or args[1] == "--tools":
74
+ return server, "__tools__", {}
75
+
76
+ tool = args[1]
77
+ tool_args = {}
78
+ for arg in args[2:]:
79
+ if arg.startswith("--") and "=" in arg:
80
+ key, val = arg[2:].split("=", 1)
81
+ tool_args[key] = parse_value(val)
82
+ elif arg.startswith("--"):
83
+ tool_args[arg[2:]] = True
84
+ return server, tool, tool_args
85
+
86
+
87
+ def send(proc, method, params=None, msg_id=None):
88
+ """Send JSON-RPC message."""
89
+ msg = {"jsonrpc": "2.0", "method": method}
90
+ if params:
91
+ msg["params"] = params
92
+ if msg_id is not None:
93
+ msg["id"] = msg_id
94
+ proc.stdin.write(json.dumps(msg) + "\n")
95
+ proc.stdin.flush()
96
+
97
+
98
+ def recv(proc, expected_id=None):
99
+ """Read JSON-RPC response, optionally matching by id."""
100
+ for _ in range(10): # skip spurious responses (e.g. notification acks)
101
+ line = proc.stdout.readline()
102
+ if not line:
103
+ return None
104
+ resp = json.loads(line)
105
+ if expected_id is None or resp.get("id") == expected_id:
106
+ return resp
107
+ return None
108
+
109
+
110
+ def spawn_server(config):
111
+ """Spawn MCP server subprocess."""
112
+ cmd = [config["command"]] + config.get("args", [])
113
+ env = {**os.environ, **config.get("env", {})}
114
+ return subprocess.Popen(
115
+ cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
116
+ stderr=subprocess.PIPE, text=True, env=env
117
+ )
118
+
119
+
120
+ def check_alive(proc):
121
+ """Check if server process is still running, print stderr if dead."""
122
+ if proc.poll() is not None:
123
+ stderr = proc.stderr.read() if proc.stderr else ""
124
+ print(f"Error: server exited with code {proc.returncode}", file=sys.stderr)
125
+ if stderr.strip():
126
+ print(stderr.strip(), file=sys.stderr)
127
+ sys.exit(1)
128
+
129
+
130
+ def init_server(proc):
131
+ """Initialize MCP handshake."""
132
+ check_alive(proc)
133
+ try:
134
+ send(proc, "initialize", {
135
+ "protocolVersion": "2024-11-05",
136
+ "capabilities": {},
137
+ "clientInfo": {"name": "mcp-cli", "version": "1.0"}
138
+ }, msg_id=1)
139
+ resp = recv(proc, expected_id=1)
140
+ if not resp:
141
+ check_alive(proc)
142
+ print("Error: no response from server during init", file=sys.stderr)
143
+ sys.exit(1)
144
+ send(proc, "notifications/initialized")
145
+ except BrokenPipeError:
146
+ check_alive(proc)
147
+ print("Error: server crashed during init", file=sys.stderr)
148
+ sys.exit(1)
149
+
150
+
151
+ def list_servers(servers):
152
+ """Print configured servers."""
153
+ for name, cfg in servers.items():
154
+ cmd = " ".join([cfg["command"]] + cfg.get("args", []))
155
+ print(f" {name:20s} → {cmd}")
156
+
157
+
158
+ def list_tools(proc):
159
+ """List tools from server."""
160
+ send(proc, "tools/list", {}, msg_id=2)
161
+ resp = recv(proc, expected_id=2)
162
+ if not resp or "result" not in resp:
163
+ return
164
+ for tool in resp["result"].get("tools", []):
165
+ schema = tool.get("inputSchema", {})
166
+ props = schema.get("properties", {})
167
+ flags = " ".join(f"--{k}" for k in props)
168
+ print(f" {tool['name']:30s} {flags}")
169
+ if tool.get("description"):
170
+ print(f" {tool['description']}")
171
+
172
+
173
+ def call_tool(proc, tool_name, tool_args):
174
+ """Call a tool and print result."""
175
+ send(proc, "tools/call", {"name": tool_name, "arguments": tool_args}, msg_id=3)
176
+ resp = recv(proc, expected_id=3)
177
+ if not resp:
178
+ print("Error: no response", file=sys.stderr)
179
+ sys.exit(1)
180
+ if "error" in resp:
181
+ print(json.dumps(resp["error"], indent=2), file=sys.stderr)
182
+ sys.exit(1)
183
+ for item in resp.get("result", {}).get("content", []):
184
+ if item.get("type") == "text":
185
+ try:
186
+ print(json.dumps(json.loads(item["text"]), indent=2, default=str))
187
+ except json.JSONDecodeError:
188
+ print(item["text"])
189
+
190
+
191
+ def add_server(raw_args):
192
+ """Add a new MCP server."""
193
+ if len(raw_args) < 2:
194
+ print("Usage: --add <name> <command> [args...] [--env KEY=VAL ...]", file=sys.stderr)
195
+ sys.exit(1)
196
+ name = raw_args[0]
197
+ command = raw_args[1]
198
+ cmd_args = []
199
+ env = {}
200
+ i = 2
201
+ while i < len(raw_args):
202
+ if raw_args[i] == "--env" and i + 1 < len(raw_args):
203
+ k, v = raw_args[i + 1].split("=", 1)
204
+ env[k] = v
205
+ i += 2
206
+ else:
207
+ cmd_args.append(raw_args[i])
208
+ i += 1
209
+ servers = read_config()
210
+ entry = {"command": command}
211
+ if cmd_args:
212
+ entry["args"] = cmd_args
213
+ if env:
214
+ entry["env"] = env
215
+ servers[name] = entry
216
+ _save_config(servers)
217
+ print(f"Added server '{name}': {command} {' '.join(cmd_args)}")
218
+
219
+
220
+ def remove_server(name):
221
+ """Remove an MCP server."""
222
+ servers = read_config()
223
+ if name not in servers:
224
+ print(f"Error: '{name}' not found.", file=sys.stderr)
225
+ sys.exit(1)
226
+ del servers[name]
227
+ _save_config(servers)
228
+ print(f"Removed server '{name}'")
229
+
230
+
231
+ def sync_from_claude():
232
+ """Re-sync servers from ~/.claude/settings.json (merges, doesn't overwrite)."""
233
+ claude_servers = _load_json(CLAUDE_SETTINGS).get("mcpServers", {})
234
+ current = read_config()
235
+ added = 0
236
+ for name, cfg in claude_servers.items():
237
+ if name not in current:
238
+ current[name] = cfg
239
+ added += 1
240
+ _save_config(current)
241
+ print(f"Synced: {added} new servers added, {len(current)} total")
242
+
243
+
244
+ def main():
245
+ servers = read_config()
246
+ server_name, tool_name, tool_args = parse_args()
247
+
248
+ if server_name == "__servers__":
249
+ list_servers(servers)
250
+ return
251
+ if server_name == "__add__":
252
+ add_server(tool_args["_raw"])
253
+ return
254
+ if server_name == "__remove__":
255
+ remove_server(tool_name)
256
+ return
257
+ if server_name == "__sync__":
258
+ sync_from_claude()
259
+ return
260
+
261
+ if server_name not in servers:
262
+ print(f"Error: '{server_name}' not found. Available:", file=sys.stderr)
263
+ list_servers(servers)
264
+ sys.exit(1)
265
+
266
+ proc = spawn_server(servers[server_name])
267
+ try:
268
+ init_server(proc)
269
+ if tool_name == "__tools__":
270
+ list_tools(proc)
271
+ else:
272
+ call_tool(proc, tool_name, tool_args)
273
+ finally:
274
+ proc.terminate()
275
+ try:
276
+ proc.wait(timeout=5)
277
+ except subprocess.TimeoutExpired:
278
+ proc.kill()
279
+
280
+
281
+ if __name__ == "__main__":
282
+ main()