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.
- mcp_cli_skill-0.1.0/PKG-INFO +80 -0
- mcp_cli_skill-0.1.0/README.md +64 -0
- mcp_cli_skill-0.1.0/SKILL.md +53 -0
- mcp_cli_skill-0.1.0/pyproject.toml +31 -0
- mcp_cli_skill-0.1.0/scripts/mcp_call.py +282 -0
- mcp_cli_skill-0.1.0/src/mcp_cli_skill/__init__.py +1 -0
- mcp_cli_skill-0.1.0/src/mcp_cli_skill/cli.py +282 -0
|
@@ -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()
|