mcp-cli-skill 0.8.3__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.3 → mcp_cli_skill-0.8.4}/PKG-INFO +1 -1
- {mcp_cli_skill-0.8.3 → mcp_cli_skill-0.8.4}/scripts/mcp_call.py +37 -6
- {mcp_cli_skill-0.8.3 → mcp_cli_skill-0.8.4}/src/mcp_cli_skill/__init__.py +1 -1
- {mcp_cli_skill-0.8.3 → mcp_cli_skill-0.8.4}/src/mcp_cli_skill/cli.py +37 -6
- {mcp_cli_skill-0.8.3 → mcp_cli_skill-0.8.4}/tests/test_args.py +37 -3
- {mcp_cli_skill-0.8.3 → mcp_cli_skill-0.8.4}/.github/workflows/publish.yml +0 -0
- {mcp_cli_skill-0.8.3 → mcp_cli_skill-0.8.4}/.gitignore +0 -0
- {mcp_cli_skill-0.8.3 → mcp_cli_skill-0.8.4}/README.md +0 -0
- {mcp_cli_skill-0.8.3 → mcp_cli_skill-0.8.4}/SKILL.md +0 -0
- {mcp_cli_skill-0.8.3 → mcp_cli_skill-0.8.4}/pyproject.toml +0 -0
- {mcp_cli_skill-0.8.3 → mcp_cli_skill-0.8.4}/tests/test_oauth.py +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"):
|
|
@@ -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"):
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|