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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-cli-skill
3
- Version: 0.8.2
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"):
@@ -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 duplicates."""
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
- return ("http", cfg["url"])
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 duplicates."""
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"
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"):
@@ -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 duplicates."""
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
- return ("http", cfg["url"])
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 duplicates."""
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