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 +3 -0
- toolbloat/__main__.py +3 -0
- toolbloat/cli.py +439 -0
- toolbloat/config.py +175 -0
- toolbloat/data/demo/results.json +3677 -0
- toolbloat/discover.py +100 -0
- toolbloat/errors.py +25 -0
- toolbloat/evaluate.py +245 -0
- toolbloat/intents.py +167 -0
- toolbloat/llm.py +261 -0
- toolbloat/metrics.py +197 -0
- toolbloat/py.typed +0 -0
- toolbloat/report.py +67 -0
- toolbloat/templates/report.html.j2 +376 -0
- toolbloat/terminal.py +132 -0
- toolbloat/tokens.py +55 -0
- toolbloat-0.2.0.dist-info/METADATA +296 -0
- toolbloat-0.2.0.dist-info/RECORD +22 -0
- toolbloat-0.2.0.dist-info/WHEEL +4 -0
- toolbloat-0.2.0.dist-info/entry_points.txt +2 -0
- toolbloat-0.2.0.dist-info/licenses/LICENSE +21 -0
- toolbloat-0.2.0.dist-info/licenses/NOTICE +232 -0
toolbloat/__init__.py
ADDED
toolbloat/__main__.py
ADDED
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
|