mcptoon 0.1.0__py3-none-any.whl

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.
mcptoon/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright 2025 cxh
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """mcptoon — Token-efficient MCP CLI client."""
17
+
18
+ __version__ = "0.1.0"
mcptoon/cache.py ADDED
@@ -0,0 +1,92 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright 2025 cxh
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """
17
+ mcptoon cache — Schema cache for tool discovery
18
+
19
+ Caches tool lists per server to avoid repeated initialize/list_tools round-trips.
20
+ TTL: 5 minutes (configurable via MCPTOON_CACHE_TTL env var)
21
+ """
22
+ import json
23
+ import os
24
+ import time
25
+ from pathlib import Path
26
+
27
+ from .config import CACHE_DIR
28
+
29
+ _DEFAULT_TTL = 300 # 5 minutes
30
+ _CACHE_FILE = CACHE_DIR / "schema_cache.json"
31
+
32
+
33
+ def _get_ttl() -> int:
34
+ try:
35
+ return int(os.environ.get("MCPTOON_CACHE_TTL", _DEFAULT_TTL))
36
+ except ValueError:
37
+ return _DEFAULT_TTL
38
+
39
+
40
+ def _load_cache() -> dict:
41
+ """Load the cache file."""
42
+ if not _CACHE_FILE.exists():
43
+ return {}
44
+ try:
45
+ return json.loads(_CACHE_FILE.read_text(encoding="utf-8"))
46
+ except (json.JSONDecodeError, OSError):
47
+ return {}
48
+
49
+
50
+ def _save_cache(data: dict):
51
+ """Save the cache file."""
52
+ try:
53
+ _CACHE_FILE.write_text(
54
+ json.dumps(data, ensure_ascii=False),
55
+ encoding="utf-8",
56
+ )
57
+ except OSError:
58
+ pass
59
+
60
+
61
+ def get_cached_tools(server: str) -> list[dict] | None:
62
+ """Get cached tools for a server, or None if cache miss/expired."""
63
+ cache = _load_cache()
64
+ entry = cache.get(server)
65
+ if not entry:
66
+ return None
67
+ if time.time() - entry.get("ts", 0) > _get_ttl():
68
+ return None
69
+ return entry.get("tools", [])
70
+
71
+
72
+ def set_cached_tools(server: str, tools: list[dict]):
73
+ """Cache tools for a server."""
74
+ cache = _load_cache()
75
+ cache[server] = {"tools": tools, "ts": time.time()}
76
+ _save_cache(cache)
77
+
78
+
79
+ def clear_cache():
80
+ """Clear all cached tools."""
81
+ try:
82
+ _CACHE_FILE.unlink()
83
+ except FileNotFoundError:
84
+ pass
85
+
86
+
87
+ def clear_server_cache(server: str):
88
+ """Clear cached tools for a specific server."""
89
+ cache = _load_cache()
90
+ if server in cache:
91
+ del cache[server]
92
+ _save_cache(cache)
mcptoon/cli.py ADDED
@@ -0,0 +1,414 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright 2025 cxh
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """
17
+ mcptoon cli — Command-line entry point
18
+
19
+ Usage:
20
+ mcptoon list List configured servers
21
+ mcptoon manifest List all tools (compact)
22
+ mcptoon manifest --full List all tools with params
23
+ mcptoon inspect <server> <tool> Show tool schema
24
+ mcptoon call <server> <tool> [ARGS] Call a tool
25
+ mcptoon init Create sample config
26
+ mcptoon add <name> [options] Add a server
27
+ mcptoon remove <name> Remove a server
28
+ mcptoon usage Show usage stats
29
+
30
+ Output flags (global):
31
+ --toon Token-efficient output (default for claude)
32
+ --json JSON output
33
+ --compact Names only
34
+ --raw Raw output
35
+ --head N Limit to N items
36
+ --max-chars N Truncate to N chars
37
+ --full No truncation
38
+ """
39
+ import json
40
+ import sys
41
+ import os
42
+
43
+ from . import config as cfg
44
+ from . import manifest as manifest_mod
45
+ from . import output
46
+ from . import usage as usage_mod
47
+ from .router import call_tool
48
+ from .errors import is_error, get_error_message
49
+
50
+
51
+ def main():
52
+ args = sys.argv[1:]
53
+
54
+ if not args:
55
+ _print_help()
56
+ sys.exit(0)
57
+
58
+ # ─── Parse global output flags ───
59
+ fmt = "auto"
60
+ head_n = 0
61
+ max_chars = 0
62
+ full = False
63
+ cmd_args = []
64
+
65
+ i = 0
66
+ while i < len(args):
67
+ a = args[i]
68
+ if a == "--json":
69
+ fmt = "json"
70
+ elif a == "--compact":
71
+ fmt = "compact"
72
+ elif a == "--toon":
73
+ fmt = "toon"
74
+ elif a == "--raw":
75
+ fmt = "raw"
76
+ elif a == "--full":
77
+ full = True
78
+ elif a == "--head" and i + 1 < len(args):
79
+ try:
80
+ head_n = int(args[i + 1])
81
+ except ValueError:
82
+ pass
83
+ i += 1
84
+ elif a == "--max-chars" and i + 1 < len(args):
85
+ try:
86
+ max_chars = int(args[i + 1])
87
+ except ValueError:
88
+ pass
89
+ i += 1
90
+ elif a.startswith("--head="):
91
+ try:
92
+ head_n = int(a.split("=", 1)[1])
93
+ except ValueError:
94
+ pass
95
+ elif a.startswith("--max-chars="):
96
+ try:
97
+ max_chars = int(a.split("=", 1)[1])
98
+ except ValueError:
99
+ pass
100
+ else:
101
+ cmd_args.append(a)
102
+ i += 1
103
+
104
+ if not cmd_args:
105
+ _print_help()
106
+ sys.exit(0)
107
+
108
+ command = cmd_args[0]
109
+ rest = cmd_args[1:]
110
+
111
+ # ─── Dispatch ───
112
+ if command in ("list", "servers"):
113
+ _cmd_list(rest)
114
+ elif command in ("manifest", "tools"):
115
+ _cmd_manifest(rest, fmt, head_n, max_chars, full)
116
+ elif command == "inspect":
117
+ _cmd_inspect(rest, fmt, max_chars, full)
118
+ elif command == "call":
119
+ _cmd_call(rest, fmt, head_n, max_chars, full)
120
+ elif command == "init":
121
+ _cmd_init(rest)
122
+ elif command == "add":
123
+ _cmd_add(rest)
124
+ elif command == "remove":
125
+ _cmd_remove(rest)
126
+ elif command == "usage":
127
+ _cmd_usage(rest, fmt)
128
+ elif command in ("help", "-h", "--help"):
129
+ _print_help()
130
+ else:
131
+ # Try natural language: "mcptoon 有什么工具"
132
+ _try_natural(command, rest, fmt, head_n, max_chars, full)
133
+
134
+
135
+ # ═══════════════════════════════════════════════════
136
+ # Commands
137
+ # ═══════════════════════════════════════════════════
138
+
139
+ def _cmd_list(_rest):
140
+ """List configured servers."""
141
+ servers = cfg.list_servers()
142
+ if not servers:
143
+ print("No servers configured. Run: mcptoon init")
144
+ return
145
+ print(f"Configured servers ({len(servers)}):")
146
+ for name in servers:
147
+ s_cfg = cfg.get_server_config(name)
148
+ transport = s_cfg.get("transport", "stdio") if s_cfg else "?"
149
+ if transport == "stdio":
150
+ cmd_str = " ".join(
151
+ (s_cfg.get("command", []) if isinstance(s_cfg.get("command"), list) else [s_cfg.get("command", [])])
152
+ + s_cfg.get("args", [])
153
+ )
154
+ print(f" {name:20s} [stdio] {cmd_str}")
155
+ else:
156
+ url = s_cfg.get("url", "?") if s_cfg else "?"
157
+ print(f" {name:20s} [http] {url}")
158
+
159
+
160
+ def _cmd_manifest(rest, fmt, head_n, max_chars, full):
161
+ """List all tools."""
162
+ full_mode = "--full" in rest or full
163
+
164
+ manifest = manifest_mod.get_manifest(use_cache=True)
165
+ if not manifest:
166
+ print("No tools found. Run: mcptoon init")
167
+ return
168
+
169
+ if fmt in ("toon", "compact"):
170
+ # For toon/compact, output just the tool names
171
+ result = {}
172
+ for server, tools in manifest.items():
173
+ names = [t.get("name", "?") for t in tools if "error" not in t]
174
+ if names:
175
+ result[server] = names
176
+ print(output.render(result, fmt=fmt, head_n=head_n, max_chars=max_chars, full=full))
177
+ else:
178
+ # Human-readable or JSON
179
+ if fmt == "json":
180
+ print(output.render(manifest, fmt="json", head_n=head_n, max_chars=max_chars, full=full))
181
+ else:
182
+ text = manifest_mod.format_manifest(manifest, full=full_mode)
183
+ if max_chars > 0:
184
+ text = output._truncate(text, max_chars)
185
+ print(text)
186
+
187
+
188
+ def _cmd_inspect(rest, fmt, max_chars, full):
189
+ """Show tool schema."""
190
+ if len(rest) < 2:
191
+ print("Usage: mcptoon inspect <server> <tool>")
192
+ sys.exit(1)
193
+
194
+ server = rest[0]
195
+ tool = rest[1]
196
+
197
+ info = manifest_mod.inspect_tool(server, tool)
198
+ if not info:
199
+ print(f"Tool not found: {server}:{tool}")
200
+ sys.exit(1)
201
+
202
+ print(output.render(info, fmt=fmt if fmt != "auto" else "json", max_chars=max_chars, full=full))
203
+
204
+
205
+ def _cmd_call(rest, fmt, head_n, max_chars, full):
206
+ """Call a tool."""
207
+ if len(rest) < 2:
208
+ print("Usage: mcptoon call <server> <tool> [JSON_ARGS] [--destructive]")
209
+ print("")
210
+ print("Examples:")
211
+ print(' mcptoon call fetch fetch \'{"url":"https://example.com"}\' --toon')
212
+ print(' mcptoon call exa search \'{"query":"AI"}\' --json')
213
+ sys.exit(1)
214
+
215
+ server = rest[0]
216
+ tool = rest[1]
217
+ is_destructive = "--destructive" in rest
218
+
219
+ # Parse args (JSON string or key=value pairs)
220
+ args = {}
221
+ for item in rest[2:]:
222
+ if item == "--destructive":
223
+ continue
224
+ # Try JSON
225
+ if item.startswith("{"):
226
+ try:
227
+ args = json.loads(item)
228
+ break
229
+ except json.JSONDecodeError as e:
230
+ print(f"Error parsing JSON args: {e}")
231
+ sys.exit(1)
232
+ # key=value
233
+ if "=" in item:
234
+ k, v = item.split("=", 1)
235
+ # Try to parse value as JSON
236
+ try:
237
+ v = json.loads(v)
238
+ except json.JSONDecodeError:
239
+ pass
240
+ args[k] = v
241
+
242
+ result = call_tool(server, tool, args, is_destructive=is_destructive)
243
+
244
+ if is_error(result):
245
+ err = result["_error"]
246
+ print(f"Error [{err['code']}]: {err['message']}", file=sys.stderr)
247
+ if err.get("retry"):
248
+ print(" (retryable)", file=sys.stderr)
249
+ sys.exit(1)
250
+
251
+ print(output.render(result, fmt=fmt, head_n=head_n, max_chars=max_chars, full=full))
252
+
253
+
254
+ def _cmd_init(_rest):
255
+ """Create sample config."""
256
+ if cfg.init_sample_config():
257
+ print(f"Sample config created: {cfg.CONFIG_FILE}")
258
+ print("Edit it to add your MCP servers, then run: mcptoon manifest")
259
+ else:
260
+ print(f"Config already exists: {cfg.CONFIG_FILE}")
261
+
262
+
263
+ def _cmd_add(rest):
264
+ """Add a server to config."""
265
+ if not rest:
266
+ print("Usage: mcptoon add <name> --stdio <command> [args...]")
267
+ print(" mcptoon add <name> --http <url> [--header 'Key: Value']")
268
+ sys.exit(1)
269
+
270
+ name = rest[0]
271
+ flags = rest[1:]
272
+
273
+ if "--stdio" in flags:
274
+ idx = flags.index("--stdio")
275
+ cmd_parts = flags[idx + 1:]
276
+ if not cmd_parts:
277
+ print("Error: --stdio requires a command")
278
+ sys.exit(1)
279
+ # First part is command, rest are args
280
+ server_cfg = {
281
+ "transport": "stdio",
282
+ "command": cmd_parts[0:1] if isinstance(cmd_parts[0], str) else cmd_parts,
283
+ "args": cmd_parts[1:] if len(cmd_parts) > 1 else [],
284
+ }
285
+ # Actually, command should be a list for npx-style
286
+ # Support: mcptoon add fetch --stdio npx -y @mcp/server-fetch
287
+ server_cfg = {
288
+ "transport": "stdio",
289
+ "command": [cmd_parts[0]] + (cmd_parts[1:2] if len(cmd_parts) > 1 else []),
290
+ "args": cmd_parts[2:] if len(cmd_parts) > 2 else [],
291
+ }
292
+ cfg.add_server(name, server_cfg)
293
+ print(f"Added server '{name}' [stdio]: {' '.join(cmd_parts)}")
294
+
295
+ elif "--http" in flags:
296
+ idx = flags.index("--http")
297
+ if idx + 1 >= len(flags):
298
+ print("Error: --http requires a URL")
299
+ sys.exit(1)
300
+ url = flags[idx + 1]
301
+ server_cfg = {"transport": "http", "url": url}
302
+
303
+ # Parse headers
304
+ headers = {}
305
+ for j, f in enumerate(flags):
306
+ if f == "--header" and j + 1 < len(flags):
307
+ h = flags[j + 1]
308
+ if ":" in h:
309
+ k, v = h.split(":", 1)
310
+ headers[k.strip()] = v.strip()
311
+ if headers:
312
+ server_cfg["headers"] = headers
313
+
314
+ cfg.add_server(name, server_cfg)
315
+ print(f"Added server '{name}' [http]: {url}")
316
+
317
+ else:
318
+ print("Error: must specify --stdio or --http")
319
+ sys.exit(1)
320
+
321
+
322
+ def _cmd_remove(rest):
323
+ """Remove a server."""
324
+ if not rest:
325
+ print("Usage: mcptoon remove <name>")
326
+ sys.exit(1)
327
+ name = rest[0]
328
+ if cfg.remove_server(name):
329
+ print(f"Removed server: {name}")
330
+ else:
331
+ print(f"Server not found: {name}")
332
+
333
+
334
+ def _cmd_usage(_rest, fmt):
335
+ """Show usage stats."""
336
+ stats = usage_mod.get_usage_stats()
337
+ if fmt in ("toon", "compact"):
338
+ print(output.render(stats, fmt=fmt))
339
+ else:
340
+ print(f"Total calls: {stats['total_calls']}")
341
+ print(f"Success rate: {stats['success_rate']}")
342
+ print(f"Tokens (est): {stats['total_tokens_est']}")
343
+ if stats["by_server"]:
344
+ print("\nBy server:")
345
+ for s, c in stats["by_server"].items():
346
+ print(f" {s:20s} {c}")
347
+ if stats["top_tools"]:
348
+ print("\nTop tools:")
349
+ for t, c in stats["top_tools"].items():
350
+ print(f" {t:30s} {c}")
351
+
352
+
353
+ # ═══════════════════════════════════════════════════
354
+ # Natural language fallback
355
+ # ═══════════════════════════════════════════════════
356
+
357
+ def _try_natural(command, rest, fmt, head_n, max_chars, full):
358
+ """Try to interpret natural language input."""
359
+ text = " ".join([command] + rest).lower()
360
+
361
+ if any(kw in text for kw in ["什么", "有哪些", "工具", "tools", "list", "manifest"]):
362
+ _cmd_manifest(rest, fmt, head_n, max_chars, full)
363
+ return
364
+
365
+ if any(kw in text for kw in ["服务器", "server", "list server"]):
366
+ _cmd_list(rest)
367
+ return
368
+
369
+ print(f"Unknown command: {command}")
370
+ print("Run: mcptoon help")
371
+
372
+
373
+ # ═══════════════════════════════════════════════════
374
+ # Help
375
+ # ═══════════════════════════════════════════════════
376
+
377
+ def _print_help():
378
+ print("""mcptoon — Token-efficient MCP CLI client
379
+
380
+ Usage:
381
+ mcptoon list List configured servers
382
+ mcptoon manifest List all tools (compact)
383
+ mcptoon manifest --full List all tools with params
384
+ mcptoon inspect <server> <tool> Show tool schema
385
+ mcptoon call <server> <tool> [ARGS] Call a tool
386
+ mcptoon init Create sample config
387
+ mcptoon add <name> [options] Add a server
388
+ mcptoon remove <name> Remove a server
389
+ mcptoon usage Show usage stats
390
+
391
+ Output flags:
392
+ --toon Token-efficient output (saves 40-60% tokens)
393
+ --json JSON output
394
+ --compact Names only
395
+ --head N Limit to N items
396
+ --max-chars N Truncate to N chars
397
+ --full No truncation
398
+
399
+ Examples:
400
+ mcptoon init
401
+ mcptoon manifest --toon
402
+ mcptoon call fetch fetch '{"url":"https://example.com"}' --toon
403
+ mcptoon add myserver --stdio npx -y @mcp/server-fetch
404
+
405
+ Environment:
406
+ MCPTOON_AGENT_TYPE=claude Auto-select --toon
407
+ MCPTOON_AGENT_TYPE=openai Auto-select --json
408
+
409
+ Config: ~/.mcptoon/config.json
410
+ """)
411
+
412
+
413
+ if __name__ == "__main__":
414
+ main()