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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-cli-skill
3
- Version: 0.8.3
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
- os.makedirs(MCP_CALL_TMPDIR, exist_ok=True)
309
- ext = item.get("mimeType", "image/png").split("/")[-1]
310
- fd, path = tempfile.mkstemp(suffix=f".{ext}", prefix="mcp-", dir=MCP_CALL_TMPDIR)
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.3"
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
- os.makedirs(MCP_CALL_TMPDIR, exist_ok=True)
309
- ext = item.get("mimeType", "image/png").split("/")[-1]
310
- fd, path = tempfile.mkstemp(suffix=f".{ext}", prefix="mcp-", dir=MCP_CALL_TMPDIR)
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