mcp-cli-skill 0.8.2__tar.gz → 0.8.4__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.8.2 → mcp_cli_skill-0.8.4}/PKG-INFO +1 -1
- {mcp_cli_skill-0.8.2 → mcp_cli_skill-0.8.4}/scripts/mcp_call.py +57 -12
- {mcp_cli_skill-0.8.2 → mcp_cli_skill-0.8.4}/src/mcp_cli_skill/__init__.py +1 -1
- {mcp_cli_skill-0.8.2 → mcp_cli_skill-0.8.4}/src/mcp_cli_skill/cli.py +57 -12
- {mcp_cli_skill-0.8.2 → mcp_cli_skill-0.8.4}/tests/test_args.py +37 -3
- {mcp_cli_skill-0.8.2 → mcp_cli_skill-0.8.4}/tests/test_oauth.py +15 -0
- {mcp_cli_skill-0.8.2 → mcp_cli_skill-0.8.4}/.github/workflows/publish.yml +0 -0
- {mcp_cli_skill-0.8.2 → mcp_cli_skill-0.8.4}/.gitignore +0 -0
- {mcp_cli_skill-0.8.2 → mcp_cli_skill-0.8.4}/README.md +0 -0
- {mcp_cli_skill-0.8.2 → mcp_cli_skill-0.8.4}/SKILL.md +0 -0
- {mcp_cli_skill-0.8.2 → mcp_cli_skill-0.8.4}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mcp-cli-skill
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.4
|
|
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
|
|
@@ -296,6 +296,40 @@ def parse_args():
|
|
|
296
296
|
return server, tool, tool_args
|
|
297
297
|
|
|
298
298
|
|
|
299
|
+
def _named_file_with(items, data):
|
|
300
|
+
"""Path named in a text block whose file holds exactly these bytes, else None."""
|
|
301
|
+
for it in items:
|
|
302
|
+
text = it.get("text", "") if it.get("type") == "text" else ""
|
|
303
|
+
for p in re.findall(r"[~/][^\s\"'`,;()<>\[\]{}]+", text):
|
|
304
|
+
p = os.path.expanduser(p.rstrip(".:"))
|
|
305
|
+
try:
|
|
306
|
+
if os.path.getsize(p) == len(data):
|
|
307
|
+
with open(p, "rb") as f:
|
|
308
|
+
if f.read() == data:
|
|
309
|
+
return p
|
|
310
|
+
except OSError:
|
|
311
|
+
continue
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _save_image_copy(item, data):
|
|
316
|
+
"""Write an image block to MCP_CALL_TMPDIR, pruning copies older than 24h; return its path."""
|
|
317
|
+
os.makedirs(MCP_CALL_TMPDIR, exist_ok=True)
|
|
318
|
+
cutoff = time.time() - 86400
|
|
319
|
+
for name in os.listdir(MCP_CALL_TMPDIR):
|
|
320
|
+
old = os.path.join(MCP_CALL_TMPDIR, name)
|
|
321
|
+
try:
|
|
322
|
+
if name.startswith("mcp-") and os.path.getmtime(old) < cutoff:
|
|
323
|
+
os.remove(old)
|
|
324
|
+
except OSError:
|
|
325
|
+
pass
|
|
326
|
+
ext = item.get("mimeType", "image/png").split("/")[-1]
|
|
327
|
+
fd, path = tempfile.mkstemp(suffix=f".{ext}", prefix="mcp-", dir=MCP_CALL_TMPDIR)
|
|
328
|
+
with os.fdopen(fd, "wb") as f:
|
|
329
|
+
f.write(data)
|
|
330
|
+
return path
|
|
331
|
+
|
|
332
|
+
|
|
299
333
|
def _print_content(items):
|
|
300
334
|
"""Print MCP content blocks (text, image, etc.)."""
|
|
301
335
|
for item in items:
|
|
@@ -305,12 +339,9 @@ def _print_content(items):
|
|
|
305
339
|
except json.JSONDecodeError:
|
|
306
340
|
print(item["text"])
|
|
307
341
|
elif item.get("type") == "image":
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
os.write(fd, base64.b64decode(item["data"]))
|
|
312
|
-
os.close(fd)
|
|
313
|
-
print(path)
|
|
342
|
+
data = base64.b64decode(item["data"])
|
|
343
|
+
# the server may already have saved this exact image (e.g. a --path arg): point at it, don't copy
|
|
344
|
+
print(_named_file_with(items, data) or _save_image_copy(item, data))
|
|
314
345
|
elif item.get("type") == "resource_link":
|
|
315
346
|
line = item.get("uri", "")
|
|
316
347
|
if item.get("name"):
|
|
@@ -1036,10 +1067,11 @@ def is_http(config):
|
|
|
1036
1067
|
return config.get("type") == "http" or "url" in config
|
|
1037
1068
|
|
|
1038
1069
|
|
|
1039
|
-
def _config_key(cfg):
|
|
1040
|
-
"""Hashable identity for a server config — used to collapse
|
|
1070
|
+
def _config_key(name, cfg):
|
|
1071
|
+
"""Hashable identity for a server config — used to collapse duplicate stdio servers."""
|
|
1041
1072
|
if is_http(cfg):
|
|
1042
|
-
|
|
1073
|
+
# same URL under another name is a different account or key, never an alias
|
|
1074
|
+
return ("http", cfg["url"], name)
|
|
1043
1075
|
return ("stdio", cfg.get("command", "?"), tuple(cfg.get("args", [])))
|
|
1044
1076
|
|
|
1045
1077
|
|
|
@@ -1049,15 +1081,25 @@ def _truncate(text, width):
|
|
|
1049
1081
|
|
|
1050
1082
|
|
|
1051
1083
|
def list_servers(servers):
|
|
1052
|
-
"""Print configured servers grouped by transport, collapsing
|
|
1084
|
+
"""Print configured servers grouped by transport, collapsing duplicate stdio servers."""
|
|
1053
1085
|
c = _colors()
|
|
1054
1086
|
term_w = shutil.get_terminal_size((100, 24)).columns
|
|
1055
1087
|
# split + group by config identity
|
|
1056
1088
|
http_groups, stdio_groups = {}, {}
|
|
1057
1089
|
for name, cfg in servers.items():
|
|
1058
1090
|
bucket = http_groups if is_http(cfg) else stdio_groups
|
|
1059
|
-
bucket.setdefault(_config_key(cfg), []).append(name)
|
|
1091
|
+
bucket.setdefault(_config_key(name, cfg), []).append(name)
|
|
1060
1092
|
max_name = min(max((len(n) for n in servers), default=20), 28)
|
|
1093
|
+
tokens = _tokens_read() # local file only; no network
|
|
1094
|
+
|
|
1095
|
+
def login_mark(name):
|
|
1096
|
+
"""'🔑 logged in' / '🔑 login needed' for servers with an OAuth entry, else ''."""
|
|
1097
|
+
entry, cfg = tokens.get(name), servers[name]
|
|
1098
|
+
if entry is None or not is_http(cfg):
|
|
1099
|
+
return "", ""
|
|
1100
|
+
if entry.get("access_token") and _norm_url(entry.get("url", "")) == _norm_url(_expand_env(cfg["url"])):
|
|
1101
|
+
return "🔑 logged in", c["green"]
|
|
1102
|
+
return "🔑 login needed", c["yellow"]
|
|
1061
1103
|
|
|
1062
1104
|
def print_group(label, color, groups, target_fn):
|
|
1063
1105
|
total = sum(len(v) for v in groups.values())
|
|
@@ -1069,12 +1111,15 @@ def list_servers(servers):
|
|
|
1069
1111
|
names_sorted = sorted(names)
|
|
1070
1112
|
primary = names_sorted[0]
|
|
1071
1113
|
target = target_fn(key)
|
|
1114
|
+
mark, mark_color = login_mark(primary)
|
|
1072
1115
|
# compute remaining width for the target
|
|
1073
1116
|
base = f" ● {primary:<{max_name}} "
|
|
1074
1117
|
visible_len = len(base)
|
|
1075
|
-
avail = max(20, term_w - visible_len - 4)
|
|
1118
|
+
avail = max(20, term_w - visible_len - 4 - (len(mark) + 2 if mark else 0))
|
|
1076
1119
|
target_disp = _truncate(target, avail)
|
|
1077
1120
|
line = f" {c['green']}●{c['reset']} {c['bold']}{primary:<{max_name}}{c['reset']} {c['dim']}{target_disp}{c['reset']}"
|
|
1121
|
+
if mark:
|
|
1122
|
+
line += f" {mark_color}{mark}{c['reset']}"
|
|
1078
1123
|
if len(names_sorted) > 1:
|
|
1079
1124
|
line += f" {c['yellow']}×{len(names_sorted)}{c['reset']}"
|
|
1080
1125
|
print(line)
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""MCP CLI - Call any MCP server tool from the command line."""
|
|
2
|
-
__version__ = "0.8.
|
|
2
|
+
__version__ = "0.8.4"
|
|
@@ -296,6 +296,40 @@ def parse_args():
|
|
|
296
296
|
return server, tool, tool_args
|
|
297
297
|
|
|
298
298
|
|
|
299
|
+
def _named_file_with(items, data):
|
|
300
|
+
"""Path named in a text block whose file holds exactly these bytes, else None."""
|
|
301
|
+
for it in items:
|
|
302
|
+
text = it.get("text", "") if it.get("type") == "text" else ""
|
|
303
|
+
for p in re.findall(r"[~/][^\s\"'`,;()<>\[\]{}]+", text):
|
|
304
|
+
p = os.path.expanduser(p.rstrip(".:"))
|
|
305
|
+
try:
|
|
306
|
+
if os.path.getsize(p) == len(data):
|
|
307
|
+
with open(p, "rb") as f:
|
|
308
|
+
if f.read() == data:
|
|
309
|
+
return p
|
|
310
|
+
except OSError:
|
|
311
|
+
continue
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _save_image_copy(item, data):
|
|
316
|
+
"""Write an image block to MCP_CALL_TMPDIR, pruning copies older than 24h; return its path."""
|
|
317
|
+
os.makedirs(MCP_CALL_TMPDIR, exist_ok=True)
|
|
318
|
+
cutoff = time.time() - 86400
|
|
319
|
+
for name in os.listdir(MCP_CALL_TMPDIR):
|
|
320
|
+
old = os.path.join(MCP_CALL_TMPDIR, name)
|
|
321
|
+
try:
|
|
322
|
+
if name.startswith("mcp-") and os.path.getmtime(old) < cutoff:
|
|
323
|
+
os.remove(old)
|
|
324
|
+
except OSError:
|
|
325
|
+
pass
|
|
326
|
+
ext = item.get("mimeType", "image/png").split("/")[-1]
|
|
327
|
+
fd, path = tempfile.mkstemp(suffix=f".{ext}", prefix="mcp-", dir=MCP_CALL_TMPDIR)
|
|
328
|
+
with os.fdopen(fd, "wb") as f:
|
|
329
|
+
f.write(data)
|
|
330
|
+
return path
|
|
331
|
+
|
|
332
|
+
|
|
299
333
|
def _print_content(items):
|
|
300
334
|
"""Print MCP content blocks (text, image, etc.)."""
|
|
301
335
|
for item in items:
|
|
@@ -305,12 +339,9 @@ def _print_content(items):
|
|
|
305
339
|
except json.JSONDecodeError:
|
|
306
340
|
print(item["text"])
|
|
307
341
|
elif item.get("type") == "image":
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
os.write(fd, base64.b64decode(item["data"]))
|
|
312
|
-
os.close(fd)
|
|
313
|
-
print(path)
|
|
342
|
+
data = base64.b64decode(item["data"])
|
|
343
|
+
# the server may already have saved this exact image (e.g. a --path arg): point at it, don't copy
|
|
344
|
+
print(_named_file_with(items, data) or _save_image_copy(item, data))
|
|
314
345
|
elif item.get("type") == "resource_link":
|
|
315
346
|
line = item.get("uri", "")
|
|
316
347
|
if item.get("name"):
|
|
@@ -1036,10 +1067,11 @@ def is_http(config):
|
|
|
1036
1067
|
return config.get("type") == "http" or "url" in config
|
|
1037
1068
|
|
|
1038
1069
|
|
|
1039
|
-
def _config_key(cfg):
|
|
1040
|
-
"""Hashable identity for a server config — used to collapse
|
|
1070
|
+
def _config_key(name, cfg):
|
|
1071
|
+
"""Hashable identity for a server config — used to collapse duplicate stdio servers."""
|
|
1041
1072
|
if is_http(cfg):
|
|
1042
|
-
|
|
1073
|
+
# same URL under another name is a different account or key, never an alias
|
|
1074
|
+
return ("http", cfg["url"], name)
|
|
1043
1075
|
return ("stdio", cfg.get("command", "?"), tuple(cfg.get("args", [])))
|
|
1044
1076
|
|
|
1045
1077
|
|
|
@@ -1049,15 +1081,25 @@ def _truncate(text, width):
|
|
|
1049
1081
|
|
|
1050
1082
|
|
|
1051
1083
|
def list_servers(servers):
|
|
1052
|
-
"""Print configured servers grouped by transport, collapsing
|
|
1084
|
+
"""Print configured servers grouped by transport, collapsing duplicate stdio servers."""
|
|
1053
1085
|
c = _colors()
|
|
1054
1086
|
term_w = shutil.get_terminal_size((100, 24)).columns
|
|
1055
1087
|
# split + group by config identity
|
|
1056
1088
|
http_groups, stdio_groups = {}, {}
|
|
1057
1089
|
for name, cfg in servers.items():
|
|
1058
1090
|
bucket = http_groups if is_http(cfg) else stdio_groups
|
|
1059
|
-
bucket.setdefault(_config_key(cfg), []).append(name)
|
|
1091
|
+
bucket.setdefault(_config_key(name, cfg), []).append(name)
|
|
1060
1092
|
max_name = min(max((len(n) for n in servers), default=20), 28)
|
|
1093
|
+
tokens = _tokens_read() # local file only; no network
|
|
1094
|
+
|
|
1095
|
+
def login_mark(name):
|
|
1096
|
+
"""'🔑 logged in' / '🔑 login needed' for servers with an OAuth entry, else ''."""
|
|
1097
|
+
entry, cfg = tokens.get(name), servers[name]
|
|
1098
|
+
if entry is None or not is_http(cfg):
|
|
1099
|
+
return "", ""
|
|
1100
|
+
if entry.get("access_token") and _norm_url(entry.get("url", "")) == _norm_url(_expand_env(cfg["url"])):
|
|
1101
|
+
return "🔑 logged in", c["green"]
|
|
1102
|
+
return "🔑 login needed", c["yellow"]
|
|
1061
1103
|
|
|
1062
1104
|
def print_group(label, color, groups, target_fn):
|
|
1063
1105
|
total = sum(len(v) for v in groups.values())
|
|
@@ -1069,12 +1111,15 @@ def list_servers(servers):
|
|
|
1069
1111
|
names_sorted = sorted(names)
|
|
1070
1112
|
primary = names_sorted[0]
|
|
1071
1113
|
target = target_fn(key)
|
|
1114
|
+
mark, mark_color = login_mark(primary)
|
|
1072
1115
|
# compute remaining width for the target
|
|
1073
1116
|
base = f" ● {primary:<{max_name}} "
|
|
1074
1117
|
visible_len = len(base)
|
|
1075
|
-
avail = max(20, term_w - visible_len - 4)
|
|
1118
|
+
avail = max(20, term_w - visible_len - 4 - (len(mark) + 2 if mark else 0))
|
|
1076
1119
|
target_disp = _truncate(target, avail)
|
|
1077
1120
|
line = f" {c['green']}●{c['reset']} {c['bold']}{primary:<{max_name}}{c['reset']} {c['dim']}{target_disp}{c['reset']}"
|
|
1121
|
+
if mark:
|
|
1122
|
+
line += f" {mark_color}{mark}{c['reset']}"
|
|
1078
1123
|
if len(names_sorted) > 1:
|
|
1079
1124
|
line += f" {c['yellow']}×{len(names_sorted)}{c['reset']}"
|
|
1080
1125
|
print(line)
|
|
@@ -5,13 +5,15 @@ import subprocess
|
|
|
5
5
|
import sys
|
|
6
6
|
import tempfile
|
|
7
7
|
import textwrap
|
|
8
|
+
import time
|
|
8
9
|
import unittest
|
|
9
10
|
|
|
10
11
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
11
12
|
|
|
12
13
|
FAKE_SERVER = textwrap.dedent("""
|
|
13
|
-
import json, os, sys
|
|
14
|
-
tools = os.environ.get("FAKE_TOOLS", "echo,slack_channel").split(",")
|
|
14
|
+
import base64, json, os, sys
|
|
15
|
+
tools = os.environ.get("FAKE_TOOLS", "echo,slack_channel,shot").split(",")
|
|
16
|
+
IMG = b"\\x89PNG fake image bytes"
|
|
15
17
|
for line in sys.stdin:
|
|
16
18
|
msg = json.loads(line)
|
|
17
19
|
if msg.get("id") is None:
|
|
@@ -25,6 +27,14 @@ FAKE_SERVER = textwrap.dedent("""
|
|
|
25
27
|
with open(os.environ["FAKE_LOG"], "a") as f:
|
|
26
28
|
f.write(msg["params"]["name"] + "\\n")
|
|
27
29
|
res = {"content": [{"type": "text", "text": "ok " + msg["params"]["name"]}]}
|
|
30
|
+
if msg["params"]["name"] == "shot": # like browser_screenshot: image block (+ saved file if path given)
|
|
31
|
+
img = {"type": "image", "data": base64.b64encode(IMG).decode(), "mimeType": "image/png"}
|
|
32
|
+
path = msg["params"].get("arguments", {}).get("path")
|
|
33
|
+
res = {"content": [img]}
|
|
34
|
+
if path:
|
|
35
|
+
with open(path, "wb") as f:
|
|
36
|
+
f.write(IMG)
|
|
37
|
+
res["content"].append({"type": "text", "text": f"Saved {path} (1 KB)"})
|
|
28
38
|
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": res}) + "\\n")
|
|
29
39
|
sys.stdout.flush()
|
|
30
40
|
""")
|
|
@@ -43,7 +53,7 @@ class ArgsTest(unittest.TestCase):
|
|
|
43
53
|
|
|
44
54
|
def run_cli(self, *args, tools=None):
|
|
45
55
|
"""Run mcp-call in a subprocess with an isolated HOME; return (exit code, stdout, stderr)."""
|
|
46
|
-
env = {**os.environ, "HOME": self.home, "PYTHONPATH": os.path.join(REPO, "src"), "NO_COLOR": "1"}
|
|
56
|
+
env = {**os.environ, "HOME": self.home, "TMPDIR": self.home, "PYTHONPATH": os.path.join(REPO, "src"), "NO_COLOR": "1"}
|
|
47
57
|
if tools:
|
|
48
58
|
env["FAKE_TOOLS"] = tools
|
|
49
59
|
p = subprocess.run([sys.executable, "-m", "mcp_cli_skill.cli", *args], env=env, cwd=REPO,
|
|
@@ -91,6 +101,30 @@ class ArgsTest(unittest.TestCase):
|
|
|
91
101
|
self.assertIn("ok brand_new", out)
|
|
92
102
|
self.assertEqual(self.calls(), ["echo", "brand_new"])
|
|
93
103
|
|
|
104
|
+
def copies(self):
|
|
105
|
+
d = os.path.join(self.home, "mcp-call")
|
|
106
|
+
return sorted(os.listdir(d)) if os.path.isdir(d) else []
|
|
107
|
+
|
|
108
|
+
def test_image_already_saved_by_server_is_not_copied(self):
|
|
109
|
+
path = os.path.join(self.home, "shot.png")
|
|
110
|
+
code, out, err = self.run_cli("fake", "shot", f"--path={path}")
|
|
111
|
+
self.assertEqual(code, 0, err)
|
|
112
|
+
self.assertEqual(out.splitlines()[0], path) # image block points at the server's file
|
|
113
|
+
self.assertEqual(self.copies(), [])
|
|
114
|
+
|
|
115
|
+
def test_image_without_saved_file_is_copied_and_old_copies_pruned(self):
|
|
116
|
+
os.makedirs(os.path.join(self.home, "mcp-call"))
|
|
117
|
+
stale, fresh = (os.path.join(self.home, "mcp-call", n) for n in ("mcp-stale.png", "mcp-fresh.png"))
|
|
118
|
+
for p in (stale, fresh):
|
|
119
|
+
open(p, "w").close()
|
|
120
|
+
os.utime(stale, (time.time() - 2 * 86400,) * 2)
|
|
121
|
+
code, out, err = self.run_cli("fake", "shot")
|
|
122
|
+
self.assertEqual(code, 0, err)
|
|
123
|
+
copy = out.strip()
|
|
124
|
+
with open(copy, "rb") as f:
|
|
125
|
+
self.assertEqual(f.read(), b"\x89PNG fake image bytes")
|
|
126
|
+
self.assertEqual(self.copies(), sorted(["mcp-fresh.png", os.path.basename(copy)]))
|
|
127
|
+
|
|
94
128
|
def test_schema_unknown_tool_suggests(self):
|
|
95
129
|
code, _, err = self.run_cli("fake", "ech", "--schema")
|
|
96
130
|
self.assertEqual(code, 2)
|
|
@@ -382,6 +382,21 @@ class OAuthTest(unittest.TestCase):
|
|
|
382
382
|
self.assertIn("browser profile", err)
|
|
383
383
|
self.assertIn("Logged in to fake.", out)
|
|
384
384
|
|
|
385
|
+
def test_15_servers_lists_accounts_separately_with_login_state(self):
|
|
386
|
+
self.servers["fake-2"] = dict(self.servers["fake"]) # second account, same URL
|
|
387
|
+
cli._save_config(self.servers)
|
|
388
|
+
entry = self.seed()
|
|
389
|
+
store = {"fake": entry, "fake-2": {k: v for k, v in entry.items() if k != "access_token"}}
|
|
390
|
+
with open(cli.TOKENS_PATH, "w") as f:
|
|
391
|
+
json.dump(store, f)
|
|
392
|
+
code, out, err = self.run_cli("--servers")
|
|
393
|
+
self.assertEqual(code, 0, err)
|
|
394
|
+
self.assertNotIn("aliases", out)
|
|
395
|
+
rows = {line.split()[1]: line for line in out.splitlines() if line.strip().startswith("●")}
|
|
396
|
+
self.assertIn("logged in", rows["fake"])
|
|
397
|
+
self.assertIn("login needed", rows["fake-2"])
|
|
398
|
+
self.assertNotIn("🔑", rows["plain"]) # no OAuth entry, no marker
|
|
399
|
+
|
|
385
400
|
|
|
386
401
|
if __name__ == "__main__":
|
|
387
402
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|