toolbloat 0.2.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.
toolbloat/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """toolbloat: token cost and tool-selection accuracy of your MCP stack."""
2
+
3
+ __version__ = "0.2.0"
toolbloat/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import entry
2
+
3
+ entry()
toolbloat/cli.py ADDED
@@ -0,0 +1,439 @@
1
+ """toolbloat command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import subprocess
8
+ import sys
9
+ import webbrowser
10
+ from dataclasses import asdict
11
+ from datetime import UTC, datetime
12
+ from importlib import resources
13
+ from pathlib import Path
14
+ from typing import Annotated
15
+
16
+ import typer
17
+ from rich.console import Console
18
+
19
+ from . import __version__
20
+ from .errors import ToolbloatError
21
+
22
+ app = typer.Typer(
23
+ add_completion=False,
24
+ no_args_is_help=True,
25
+ rich_markup_mode="rich",
26
+ help="See what your MCP servers cost in tokens on every turn, and whether stacking them"
27
+ " makes your model pick the wrong tool.",
28
+ )
29
+ console = Console()
30
+ err = Console(stderr=True)
31
+
32
+ DEFAULT_MODELS = ["ollama:qwen3:4b"]
33
+
34
+
35
+ def _fail(e: Exception) -> None:
36
+ err.print(f"[bold red]error:[/] {e}")
37
+ raise typer.Exit(1)
38
+
39
+
40
+ def _version(value: bool) -> None:
41
+ if value:
42
+ console.print(f"toolbloat {__version__}")
43
+ raise typer.Exit()
44
+
45
+
46
+ @app.callback()
47
+ def main(
48
+ version: Annotated[
49
+ bool, typer.Option("--version", callback=_version, is_eager=True, help="Show the version.")
50
+ ] = False,
51
+ ) -> None:
52
+ pass
53
+
54
+
55
+ ConfigOpt = Annotated[
56
+ list[Path] | None,
57
+ typer.Option("--config", "-c", help="MCP config file (.mcp.json, Claude Desktop, Cursor). Repeatable."),
58
+ ]
59
+ ServerOpt = Annotated[
60
+ list[str] | None,
61
+ typer.Option("--server", "-s", help='A server to start: "cmd args" or "name=cmd args". Repeatable.'),
62
+ ]
63
+
64
+
65
+ def _collect_servers(configs: list[Path] | None, servers: list[str] | None):
66
+ from .config import dedupe, default_config_paths, load_config_file, parse_server_flag, skipped_remote
67
+
68
+ specs = []
69
+ if servers:
70
+ specs += [parse_server_flag(v, i) for i, v in enumerate(servers)]
71
+ paths = list(configs or [])
72
+ if not specs and not paths:
73
+ paths = [p for p in default_config_paths() if p.exists()]
74
+ if not paths:
75
+ raise ToolbloatError(
76
+ "No MCP config found (.mcp.json, .cursor/mcp.json, Claude Desktop). Pass --config PATH"
77
+ ' or --server "uvx mcp-server-time". Want to see a result first? Run `toolbloat demo`.'
78
+ )
79
+ for p in paths:
80
+ if not p.exists():
81
+ raise ToolbloatError(f"Config file not found: {p}")
82
+ found = load_config_file(p)
83
+ console.print(f"[dim]config[/] {p} [dim]({len(found)} stdio server(s))[/]")
84
+ remote = skipped_remote(p)
85
+ if remote:
86
+ console.print(f"[yellow]skipped[/] {', '.join(remote)}: HTTP/SSE transports are not supported yet")
87
+ specs += found
88
+ specs = dedupe(specs)
89
+ if not specs:
90
+ raise ToolbloatError("The config files list no stdio MCP servers.")
91
+ return specs
92
+
93
+
94
+ def _discover(configs, servers, timeout):
95
+ from .discover import list_tools
96
+ from .tokens import annotate
97
+
98
+ specs = _collect_servers(configs, servers)
99
+ with console.status(f"Starting {len(specs)} MCP server(s) and listing tools (tools are never called)..."):
100
+ results = list_tools(specs, timeout=timeout)
101
+ for r in results:
102
+ annotate(r.tools)
103
+ if r.error:
104
+ console.print(f"[red]failed[/] {r.name} ({r.command}): {r.error}")
105
+ return results
106
+
107
+
108
+ def _confirm_cloud(specs, servers, yes: bool) -> None:
109
+ """Before anything leaves the machine, say which servers go to which provider and ask."""
110
+ cloud = sorted({s.provider for s in specs if s.provider != "ollama"})
111
+ if not cloud:
112
+ return
113
+ sent = [r for r in servers if not r.error and r.tools]
114
+ console.print(
115
+ f"[yellow]privacy:[/] the tool definitions (names, descriptions, JSON Schemas) of these"
116
+ f" {len(sent)} server(s) and the test requests will be sent to [bold]{', '.join(cloud)}[/]:"
117
+ )
118
+ for r in sent:
119
+ console.print(f" - {r.name} ({len(r.tools)} tools) from {r.source}: {r.command}")
120
+ console.print("[dim] Nothing else leaves this machine: no env values, no files, and no tool is ever executed.[/]")
121
+ if yes:
122
+ return
123
+ if not sys.stdin.isatty():
124
+ raise ToolbloatError(
125
+ "Refusing to send tool definitions to a cloud provider without confirmation."
126
+ " Re-run with --yes to accept, or use a local model (ollama:...)."
127
+ )
128
+ if not typer.confirm("Send them?", default=False):
129
+ raise ToolbloatError("Cancelled: nothing was sent.")
130
+
131
+
132
+ def _hardware() -> str:
133
+ parts = [platform.system(), platform.machine()]
134
+ if sys.platform == "darwin":
135
+ try:
136
+ chip = subprocess.run(
137
+ ["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True, timeout=5
138
+ ).stdout.strip()
139
+ mem = int(subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5).stdout)
140
+ parts = [chip or parts[1], f"{mem // 2**30} GB RAM", f"macOS {platform.mac_ver()[0]}"]
141
+ except (OSError, ValueError, subprocess.SubprocessError):
142
+ pass
143
+ return ", ".join(p for p in parts if p)
144
+
145
+
146
+ def _server_dict(r) -> dict:
147
+ return {
148
+ "name": r.name,
149
+ "command": r.command,
150
+ "server_version": r.server_version,
151
+ "error": r.error,
152
+ "tools": [
153
+ {"name": t.name, "description": t.description, "input_schema": t.input_schema, "tokens": t.tokens}
154
+ for t in r.tools
155
+ ],
156
+ }
157
+
158
+
159
+ @app.command()
160
+ def tokens(
161
+ config: ConfigOpt = None,
162
+ server: ServerOpt = None,
163
+ turns_per_day: Annotated[int, typer.Option(help="Turns per day for the monthly projection.")] = 100,
164
+ json_out: Annotated[bool, typer.Option("--json", help="Print JSON instead of tables.")] = False,
165
+ timeout: Annotated[float, typer.Option(help="Seconds to wait for each server.")] = 90,
166
+ max_tokens: Annotated[
167
+ int | None,
168
+ typer.Option(
169
+ "--max-tokens",
170
+ min=1,
171
+ help="Token budget for all tool definitions. Exit with code 2 if the total is over it,"
172
+ " or 1 if a server could not be measured. For CI.",
173
+ ),
174
+ ] = None,
175
+ markdown: Annotated[
176
+ Path | None,
177
+ typer.Option("--markdown", help="Also append a Markdown summary to this file (e.g. $GITHUB_STEP_SUMMARY)."),
178
+ ] = None,
179
+ ) -> None:
180
+ """Count the tokens your MCP tool definitions add to every turn. No model calls."""
181
+ from .metrics import compute
182
+ from .terminal import markdown_tokens, print_tokens
183
+
184
+ try:
185
+ results = _discover(config, server, timeout)
186
+ except ToolbloatError as e:
187
+ _fail(e)
188
+ data = {
189
+ "servers": [_server_dict(r) for r in results],
190
+ "settings": {"turns_per_day": turns_per_day},
191
+ "intents": [],
192
+ "models": [],
193
+ "calls": [],
194
+ }
195
+ total = compute(data)["total_tokens"]
196
+ failed = [r.name for r in results if r.error]
197
+ if markdown:
198
+ with markdown.open("a", encoding="utf-8") as f:
199
+ f.write(markdown_tokens(data, max_tokens))
200
+ if json_out:
201
+ console.print_json(json.dumps({"tokenizer": "o200k_base (approximate)", **data}))
202
+ else:
203
+ print_tokens(console, data)
204
+ if max_tokens is None:
205
+ return
206
+ if failed:
207
+ err.print(
208
+ f"[bold red]error:[/] could not measure {', '.join(failed)}, so the budget check cannot pass."
209
+ " Fix the server command or remove it from the config."
210
+ )
211
+ raise typer.Exit(1)
212
+ if total > max_tokens:
213
+ err.print(
214
+ f"[bold red]over budget:[/] tool definitions take {total:,} tokens, the budget is {max_tokens:,}"
215
+ f" (+{total - max_tokens:,})."
216
+ )
217
+ raise typer.Exit(2)
218
+ console.print(f"[green]within budget:[/] {total:,} of {max_tokens:,} tokens.")
219
+
220
+
221
+ @app.command()
222
+ def run(
223
+ model: Annotated[
224
+ list[str] | None,
225
+ typer.Option(
226
+ "--model", "-m", help="provider:model, e.g. ollama:qwen3:4b, mistral:mistral-small-latest. Repeatable."
227
+ ),
228
+ ] = None,
229
+ config: ConfigOpt = None,
230
+ server: ServerOpt = None,
231
+ intents: Annotated[Path, typer.Option(help="Intents YAML (created if missing, then reused).")] = Path(
232
+ ".toolbloat/intents.yaml"
233
+ ),
234
+ intent_model: Annotated[
235
+ str | None, typer.Option(help="Model that writes missing intents (default: first --model).")
236
+ ] = None,
237
+ intents_per_tool: Annotated[int, typer.Option(min=1, max=10, help="Intents to generate per tool.")] = 1,
238
+ out: Annotated[Path, typer.Option(help="Output directory for results.json and report.html.")] = Path(
239
+ "toolbloat-report"
240
+ ),
241
+ turns_per_day: Annotated[int, typer.Option(help="Turns per day for the monthly projection.")] = 100,
242
+ max_cost: Annotated[float, typer.Option(help="Stop before spending more than this many USD.")] = 1.0,
243
+ price: Annotated[
244
+ list[str] | None,
245
+ typer.Option(help="Price for a model without a built-in one: provider:model=IN,OUT (USD per 1M tokens)."),
246
+ ] = None,
247
+ timeout: Annotated[float, typer.Option(help="Seconds to wait for each server.")] = 90,
248
+ cached_only: Annotated[
249
+ bool, typer.Option("--cached-only", help="Make no model calls: build the report from cached answers only.")
250
+ ] = False,
251
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask before sending data to a cloud API.")] = False,
252
+ open_report: Annotated[bool, typer.Option("--open/--no-open", help="Open the HTML report.")] = False,
253
+ ) -> None:
254
+ """Measure tokens and tool-selection accuracy per model, alone and stacked."""
255
+ from . import evaluate
256
+ from . import intents as intents_mod
257
+ from .llm import LLM, PRICES, Budget, ModelSpec
258
+ from .report import write_report
259
+ from .terminal import print_summary
260
+
261
+ try:
262
+ for p in price or []:
263
+ mid, _, vals = p.partition("=")
264
+ pin, _, pout = vals.partition(",")
265
+ try:
266
+ PRICES[ModelSpec.parse(mid).id] = (float(pin), float(pout))
267
+ except ValueError as e:
268
+ raise ToolbloatError(f"--price '{p}' must look like provider:model=IN,OUT") from e
269
+ specs = [ModelSpec.parse(m) for m in (model or DEFAULT_MODELS)]
270
+ for s in specs:
271
+ if not s.has_known_price():
272
+ raise ToolbloatError(
273
+ f"No price known for {s.id}, so --max-cost cannot protect you. Add"
274
+ f" --price {s.id}=IN,OUT (USD per million tokens)."
275
+ )
276
+ budget = Budget(max_cost=max_cost)
277
+ llms = [LLM(s, budget) for s in specs]
278
+ if not cached_only:
279
+ for llm in llms:
280
+ llm.preflight()
281
+ results_servers = _discover(config, server, timeout)
282
+ tools = [t for r in results_servers for t in r.tools]
283
+ if not tools:
284
+ raise ToolbloatError("The servers started but exposed no tools.")
285
+
286
+ gen = LLM(ModelSpec.parse(intent_model), budget) if intent_model else llms[0]
287
+ if not cached_only:
288
+ _confirm_cloud([*specs, gen.spec], results_servers, yes)
289
+ with console.status("Preparing intents...") as status:
290
+ items = intents_mod.ensure(
291
+ intents,
292
+ tools,
293
+ gen,
294
+ intents_per_tool,
295
+ on_progress=lambda n, total, key: status.update(f"Writing intents {n}/{total}: {key}"),
296
+ )
297
+ console.print(f"[dim]intents[/] {intents} ({len(items)} total, edit it and rerun to change them)")
298
+
299
+ cache = evaluate.Cache(out / "calls.jsonl")
300
+ total = evaluate.total_calls(llms, tools, items)
301
+ done = {"n": 0}
302
+ with console.status(f"Asking models which tool they would call (0/{total})...") as status:
303
+
304
+ def tick(c):
305
+ done["n"] += 1
306
+ status.update(
307
+ f"Asking models which tool they would call ({done['n']}/{total}) · spent ${budget.total:.4f}"
308
+ )
309
+
310
+ calls, stopped = evaluate.run(llms, tools, items, cache=cache, on_call=tick, cached_only=cached_only)
311
+ if stopped and not calls:
312
+ raise ToolbloatError(stopped)
313
+ except ToolbloatError as e:
314
+ _fail(e)
315
+
316
+ results = {
317
+ "schema_version": 1,
318
+ "tool": "toolbloat",
319
+ "version": __version__,
320
+ "created_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
321
+ "environment": {"hardware": _hardware(), "python": platform.python_version()},
322
+ "tokenizer": "o200k_base",
323
+ "servers": [_server_dict(r) for r in results_servers],
324
+ "intents": [asdict(i) for i in items],
325
+ "intents_meta": intents_mod.load(intents)[1],
326
+ "models": [{"id": s.id, "price_in": s.price()[0], "price_out": s.price()[1]} for s in specs],
327
+ "calls": [asdict(c) for c in calls],
328
+ "settings": {"turns_per_day": turns_per_day, "max_cost": max_cost},
329
+ "spend": {k: round(v, 6) for k, v in budget.spent.items()},
330
+ "stopped": stopped,
331
+ }
332
+ out.mkdir(parents=True, exist_ok=True)
333
+ (out / "results.json").write_text(json.dumps(results, indent=1, ensure_ascii=False), encoding="utf-8")
334
+ html = write_report(results, out / "report.html")
335
+ print_summary(console, results)
336
+ if stopped:
337
+ err.print(f"[bold yellow]stopped early:[/] {stopped}")
338
+ console.print(f"\n[bold]Report:[/] {html}\n[dim]Data:[/] {out / 'results.json'}")
339
+ if open_report:
340
+ webbrowser.open(html.resolve().as_uri())
341
+ if stopped and not calls:
342
+ raise typer.Exit(1)
343
+
344
+
345
+ @app.command(name="intents")
346
+ def intents_cmd(
347
+ model: Annotated[str, typer.Option("--model", "-m", help="Model that writes the requests.")] = DEFAULT_MODELS[0],
348
+ config: ConfigOpt = None,
349
+ server: ServerOpt = None,
350
+ intents: Annotated[Path, typer.Option(help="Intents YAML to create or complete.")] = Path(
351
+ ".toolbloat/intents.yaml"
352
+ ),
353
+ intents_per_tool: Annotated[int, typer.Option(min=1, max=10)] = 1,
354
+ max_cost: Annotated[float, typer.Option(help="Stop before spending more than this many USD.")] = 1.0,
355
+ timeout: Annotated[float, typer.Option(help="Seconds to wait for each server.")] = 90,
356
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask before sending data to a cloud API.")] = False,
357
+ ) -> None:
358
+ """Write (or complete) the intents YAML without running the evaluation, so you can review it first."""
359
+ from . import intents as intents_mod
360
+ from .llm import LLM, Budget, ModelSpec
361
+
362
+ try:
363
+ budget = Budget(max_cost=max_cost)
364
+ gen = LLM(ModelSpec.parse(model), budget)
365
+ found = _discover(config, server, timeout)
366
+ tools = [t for r in found for t in r.tools]
367
+ _confirm_cloud([gen.spec], found, yes)
368
+ with console.status("Writing intents...") as status:
369
+ items = intents_mod.ensure(
370
+ intents,
371
+ tools,
372
+ gen,
373
+ intents_per_tool,
374
+ on_progress=lambda n, total, key: status.update(f"Writing intents {n}/{total}: {key}"),
375
+ )
376
+ except ToolbloatError as e:
377
+ _fail(e)
378
+ missing = sorted({t.key for t in tools} - {i.tool for i in items})
379
+ console.print(f"{len(items)} intents in {intents} · spent ${budget.total:.4f}")
380
+ if missing:
381
+ console.print(f"[yellow]no intent yet for:[/] {', '.join(missing)} (rerun to retry)")
382
+
383
+
384
+ @app.command()
385
+ def report(
386
+ results_file: Annotated[Path, typer.Argument(help="results.json written by `toolbloat run`.")],
387
+ out: Annotated[Path | None, typer.Option(help="HTML file to write (default: next to results).")] = None,
388
+ open_report: Annotated[bool, typer.Option("--open/--no-open")] = False,
389
+ ) -> None:
390
+ """Rebuild the HTML report and terminal summary from a results.json."""
391
+ from .report import write_report
392
+ from .terminal import print_summary
393
+
394
+ try:
395
+ results = json.loads(results_file.read_text(encoding="utf-8"))
396
+ except (OSError, json.JSONDecodeError) as e:
397
+ _fail(ToolbloatError(f"Cannot read {results_file}: {e}"))
398
+ html = write_report(results, out or results_file.with_name("report.html"))
399
+ print_summary(console, results)
400
+ console.print(f"\n[bold]Report:[/] {html}")
401
+ if open_report:
402
+ webbrowser.open(html.resolve().as_uri())
403
+
404
+
405
+ @app.command()
406
+ def demo(
407
+ out: Annotated[Path | None, typer.Option(help="Where to write the demo report HTML.")] = None,
408
+ open_report: Annotated[bool, typer.Option("--open/--no-open", help="Open the HTML report in a browser.")] = False,
409
+ ) -> None:
410
+ """Show a RECORDED real run on five MCP reference servers. No keys, no setup."""
411
+ from .report import write_report
412
+ from .terminal import print_summary
413
+
414
+ data = resources.files("toolbloat").joinpath("data/demo/results.json").read_text(encoding="utf-8")
415
+ results = json.loads(data)
416
+ results["recorded"] = True
417
+ console.print(
418
+ f"[bold magenta]Recorded run[/] from {results['created_at'][:10]} · toolbloat {results['version']} ·"
419
+ f" {results['environment']['hardware']}\n[dim]This replays a real `toolbloat run`; nothing is executed"
420
+ " now. Measure your own stack with `toolbloat run`.[/]"
421
+ )
422
+ print_summary(console, results)
423
+ target = out or Path("toolbloat-demo") / "report.html"
424
+ html = write_report(results, target)
425
+ console.print(f"\n[bold]HTML report:[/] {html}")
426
+ if open_report:
427
+ webbrowser.open(html.resolve().as_uri())
428
+
429
+
430
+ def entry() -> None:
431
+ try:
432
+ app()
433
+ except KeyboardInterrupt:
434
+ err.print("interrupted")
435
+ sys.exit(130)
436
+
437
+
438
+ if __name__ == "__main__":
439
+ entry()
toolbloat/config.py ADDED
@@ -0,0 +1,175 @@
1
+ """Find MCP servers in agent configs. Only command and args are ever shown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ import sys
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from .errors import ToolbloatError
14
+
15
+
16
+ @dataclass
17
+ class ServerSpec:
18
+ name: str
19
+ command: str
20
+ args: list[str] = field(default_factory=list)
21
+ # Passed to the server process, never printed or written to a report.
22
+ env: dict[str, str] = field(default_factory=dict, repr=False)
23
+ cwd: str | None = None
24
+ source: str = "--server"
25
+
26
+ def display(self) -> str:
27
+ return " ".join([self.command, *redact_args(self.args)])
28
+
29
+
30
+ _SECRET_FLAG = re.compile(r"(key|token|secret|password|passwd|auth|bearer|credential)", re.I)
31
+ _SECRET_VALUE = re.compile(r"^(sk-|ghp_|gho_|github_pat_|xox[abp]-|AKIA|AIza|eyJ)")
32
+
33
+
34
+ def redact_args(args: list[str]) -> list[str]:
35
+ """Hide values that look like secrets (e.g. --api-key X, token=X, sk-...)."""
36
+ out: list[str] = []
37
+ hide_next = False
38
+ for a in args:
39
+ if hide_next:
40
+ out.append("***")
41
+ hide_next = False
42
+ continue
43
+ if a.startswith("-") and "=" in a and _SECRET_FLAG.search(a.split("=", 1)[0]):
44
+ out.append(a.split("=", 1)[0] + "=***")
45
+ elif a.startswith("-") and _SECRET_FLAG.search(a):
46
+ out.append(a)
47
+ hide_next = True
48
+ elif _SECRET_VALUE.search(a) or ("=" in a and _SECRET_FLAG.search(a.split("=", 1)[0])):
49
+ out.append("***" if "=" not in a else a.split("=", 1)[0] + "=***")
50
+ else:
51
+ out.append(a)
52
+ return out
53
+
54
+
55
+ def default_config_paths(cwd: Path | None = None) -> list[Path]:
56
+ cwd = cwd or Path.cwd()
57
+ home = Path.home()
58
+ paths = [cwd / ".mcp.json", cwd / ".cursor" / "mcp.json", home / ".cursor" / "mcp.json"]
59
+ if sys.platform == "darwin":
60
+ paths.append(home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json")
61
+ elif sys.platform == "win32":
62
+ appdata = os.environ.get("APPDATA")
63
+ if appdata:
64
+ paths.append(Path(appdata) / "Claude" / "claude_desktop_config.json")
65
+ else:
66
+ paths.append(home / ".config" / "Claude" / "claude_desktop_config.json")
67
+ return paths
68
+
69
+
70
+ def load_config_file(path: Path) -> list[ServerSpec]:
71
+ """Read the mcpServers block of .mcp.json, Claude Desktop or Cursor config."""
72
+ try:
73
+ data = json.loads(path.read_text(encoding="utf-8"))
74
+ except json.JSONDecodeError as e:
75
+ raise ToolbloatError(f"{path} is not valid JSON (line {e.lineno}): {e.msg}") from e
76
+ except OSError as e:
77
+ raise ToolbloatError(f"Cannot read {path}: {e.strerror}") from e
78
+ servers = data.get("mcpServers") if isinstance(data, dict) else None
79
+ if not isinstance(servers, dict):
80
+ return []
81
+ out: list[ServerSpec] = []
82
+ for name, cfg in servers.items():
83
+ if not isinstance(cfg, dict):
84
+ continue
85
+ if cfg.get("disabled") is True:
86
+ continue
87
+ command = cfg.get("command")
88
+ transport = cfg.get("type") or cfg.get("transport") or "stdio"
89
+ if not command or transport not in ("stdio",):
90
+ # HTTP/SSE servers are on the roadmap; skip them for now.
91
+ continue
92
+ args = [str(a) for a in cfg.get("args") or []]
93
+ env = {str(k): str(v) for k, v in (cfg.get("env") or {}).items()}
94
+ out.append(
95
+ ServerSpec(
96
+ name=safe_name(name),
97
+ command=str(command),
98
+ args=args,
99
+ env=env,
100
+ cwd=cfg.get("cwd"),
101
+ source=str(path),
102
+ )
103
+ )
104
+ return out
105
+
106
+
107
+ def skipped_remote(path: Path) -> list[str]:
108
+ """Names of servers in a config that use a transport we do not support yet."""
109
+ try:
110
+ data = json.loads(path.read_text(encoding="utf-8"))
111
+ except (OSError, json.JSONDecodeError):
112
+ return []
113
+ servers = data.get("mcpServers") if isinstance(data, dict) else None
114
+ if not isinstance(servers, dict):
115
+ return []
116
+ return [
117
+ n
118
+ for n, c in servers.items()
119
+ if isinstance(c, dict) and not c.get("command") and (c.get("url") or c.get("type") in ("http", "sse"))
120
+ ]
121
+
122
+
123
+ def parse_server_flag(value: str, index: int) -> ServerSpec:
124
+ """--server "name=cmd args" or --server "cmd args"."""
125
+ name = None
126
+ m = re.match(r"^([A-Za-z0-9_.-]+)=(.+)$", value.strip())
127
+ if m and " " not in m.group(1):
128
+ name, value = m.group(1), m.group(2)
129
+ try:
130
+ parts = shlex.split(value)
131
+ except ValueError as e:
132
+ raise ToolbloatError(f"Cannot parse --server '{value}': {e}") from e
133
+ if not parts:
134
+ raise ToolbloatError('--server needs a command, e.g. --server "uvx mcp-server-time"')
135
+ if name is None:
136
+ name = guess_name(parts) or f"server{index + 1}"
137
+ return ServerSpec(name=safe_name(name), command=parts[0], args=parts[1:])
138
+
139
+
140
+ RUNNERS = {"npx", "uvx", "node", "python", "python3", "uv", "run", "bunx", "pnpm", "dlx", "deno", "docker"}
141
+
142
+
143
+ def guess_name(parts: list[str]) -> str | None:
144
+ """Pick a short server name from a command line, e.g. "uvx mcp-server-time" -> "time"."""
145
+ cands = [p for p in parts[1:] if not p.startswith("-") and p not in RUNNERS]
146
+ preferred = [p for p in cands if "mcp" in p.lower() or "server" in p.lower()]
147
+ for p in preferred + cands:
148
+ base = re.split(r"[@=<>~!]", p.rstrip("/").split("/")[-1])[0] or p.split("/")[-1]
149
+ if p.startswith("@") and "/" in p:
150
+ base = re.split(r"[@=<>~!]", p.split("/", 1)[1])[0]
151
+ base = re.sub(r"\.(py|js|mjs|ts)$", "", base)
152
+ base = re.sub(r"^(mcp[-_]server[-_]|server[-_]|mcp[-_])", "", base)
153
+ base = re.sub(r"([-_]mcp|[-_]server)$", "", base)
154
+ if base:
155
+ return base
156
+ return None
157
+
158
+
159
+ def safe_name(name: str) -> str:
160
+ s = re.sub(r"[^A-Za-z0-9_-]+", "-", name).strip("-_")
161
+ return s[:24] or "server"
162
+
163
+
164
+ def dedupe(specs: list[ServerSpec]) -> list[ServerSpec]:
165
+ seen: dict[str, int] = {}
166
+ out = []
167
+ for s in specs:
168
+ key = s.name
169
+ if key in seen:
170
+ seen[key] += 1
171
+ s.name = f"{key}{seen[key]}"
172
+ else:
173
+ seen[key] = 1
174
+ out.append(s)
175
+ return out