mispick 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.
mispick/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """mispick - find out which of your MCP tools the model mixes up.
2
+
3
+ mispick never calls a tool. It runs `initialize` and `tools/list`, asks a model to
4
+ *choose*, and records the choice. See SPEC section 3.
5
+ """
6
+
7
+ from mispick.types import Choice, Query, ServerInfo, Tool, ToolSet
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = ["Choice", "Query", "ServerInfo", "Tool", "ToolSet", "__version__"]
mispick/cli.py ADDED
@@ -0,0 +1,564 @@
1
+ """The `mispick` command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from mispick import __version__
15
+ from mispick.fix import as_patch, run_fix_mode
16
+ from mispick.generate import DEFAULT_N, DEFAULT_NO_TOOL, QueryCache, build_query_set
17
+ from mispick.loader import Target, TargetError, load, write_snapshot
18
+ from mispick.metrics import Metrics, compute
19
+ from mispick.models.base import DEFAULT_MAX_TOKENS, BackendError
20
+ from mispick.models.registry import DEFAULT_MODEL, get_backend
21
+ from mispick.report import badge as badge_report
22
+ from mispick.report import fixes as fixes_report
23
+ from mispick.report import html as html_report
24
+ from mispick.report import json as json_report
25
+ from mispick.report import markdown as markdown_report
26
+ from mispick.report import terminal as terminal_report
27
+ from mispick.select import DEFAULT_CONCURRENCY, DEFAULT_K, RunConfig, RunResult, run_selection
28
+ from mispick.sources.config import ConfigError, collisions
29
+ from mispick.sources.server import ServerError
30
+ from mispick.sources.snapshot import SnapshotError
31
+ from mispick.types import ToolSet
32
+
33
+ app = typer.Typer(
34
+ name="mispick",
35
+ help="Find out which of your MCP tools the model mixes up - measured, not guessed.",
36
+ no_args_is_help=True,
37
+ add_completion=False,
38
+ )
39
+ console = Console()
40
+ err_console = Console(stderr=True)
41
+
42
+ #: Exit codes, per SPEC section 6.6.
43
+ EXIT_OK = 0
44
+ EXIT_BELOW_THRESHOLD = 1
45
+ EXIT_ERROR = 2
46
+
47
+ CmdOpt = Annotated[str | None, typer.Option("--cmd", help="Local stdio server command.")]
48
+ UrlOpt = Annotated[str | None, typer.Option("--url", help="Streamable HTTP server URL.")]
49
+ ConfigOpt = Annotated[
50
+ str | None,
51
+ typer.Option("--config", help="A claude_desktop_config.json / .mcp.json; loads every server."),
52
+ ]
53
+ SnapshotOpt = Annotated[
54
+ str | None, typer.Option("--snapshot", help="A captured tools/list JSON file (offline).")
55
+ ]
56
+ TimeoutOpt = Annotated[float, typer.Option("--timeout", help="Seconds to wait per server.")]
57
+ OnlyOpt = Annotated[
58
+ list[str] | None, typer.Option("--only", help="With --config, limit to these server labels.")
59
+ ]
60
+
61
+
62
+ def _target(
63
+ cmd: str | None,
64
+ url: str | None,
65
+ config: str | None,
66
+ snapshot: str | None,
67
+ timeout: float,
68
+ only: list[str] | None = None,
69
+ ) -> Target:
70
+ return Target(
71
+ cmd=cmd, url=url, config=config, snapshot=snapshot, timeout=timeout, only=only or []
72
+ )
73
+
74
+
75
+ def _load_or_exit(target: Target) -> tuple[ToolSet, list[str]]:
76
+ """Load tools, turning our exceptions into a clear message and exit code 2."""
77
+ try:
78
+ tool_set, failures = asyncio.run(load(target))
79
+ except (TargetError, ConfigError, SnapshotError, ServerError) as exc:
80
+ err_console.print(f"[red]error:[/red] {exc}")
81
+ raise typer.Exit(EXIT_ERROR) from exc
82
+ for failure in failures:
83
+ err_console.print(f"[yellow]warning:[/yellow] could not load {failure}")
84
+ if not tool_set.tools:
85
+ err_console.print("[red]error:[/red] that target exposed no tools; nothing to measure.")
86
+ raise typer.Exit(EXIT_ERROR)
87
+ return tool_set, failures
88
+
89
+
90
+ def _servers_line(tool_set: ToolSet) -> str:
91
+ parts = []
92
+ for info in tool_set.servers:
93
+ name = info.name or info.label
94
+ version = f" {info.version}" if info.version else ""
95
+ parts.append(f"{name}{version}")
96
+ return ", ".join(parts) if parts else "unknown"
97
+
98
+
99
+ @app.command()
100
+ def tools(
101
+ cmd: CmdOpt = None,
102
+ url: UrlOpt = None,
103
+ config: ConfigOpt = None,
104
+ snapshot: SnapshotOpt = None,
105
+ timeout: TimeoutOpt = 20.0,
106
+ only: OnlyOpt = None,
107
+ ) -> None:
108
+ """List the tools a target exposes, and any cross-server name collisions."""
109
+ target = _target(cmd, url, config, snapshot, timeout, only)
110
+ tool_set, _ = _load_or_exit(target)
111
+
112
+ table = Table(title=f"{len(tool_set.tools)} tools from {_servers_line(tool_set)}")
113
+ if target.is_multi_server:
114
+ table.add_column("server", style="cyan")
115
+ table.add_column("tool", style="bold")
116
+ table.add_column("description")
117
+ for tool in tool_set.sorted_tools():
118
+ desc = (tool.description or "[dim](none)[/dim]").replace("\n", " ")
119
+ if len(desc) > 70:
120
+ desc = desc[:69] + "…"
121
+ row = [tool.name, desc]
122
+ if target.is_multi_server:
123
+ row.insert(0, tool.server or "-")
124
+ table.add_row(*row)
125
+ console.print(table)
126
+
127
+ found = collisions(tool_set)
128
+ if found:
129
+ console.print()
130
+ console.print("[bold yellow]Cross-server name collisions[/bold yellow]")
131
+ for name, servers in found.items():
132
+ where = ", ".join(str(s) for s in servers)
133
+ console.print(f" [bold]{name}[/bold] is exposed by {where}")
134
+ console.print(
135
+ "[dim]The MCP spec warns clients to disambiguate these. Run "
136
+ "`mispick run --config …` to measure whether the model mixes them up.[/dim]"
137
+ )
138
+
139
+
140
+ # Registered as `snapshot` rather than by its function name: `snapshot` is also an option
141
+ # name on the other commands, so the function cannot be called that without shadowing it.
142
+ @app.command(name="snapshot")
143
+ def snapshot_cmd(
144
+ output: Annotated[str, typer.Option("--output", "-o", help="Where to write the JSON.")],
145
+ cmd: CmdOpt = None,
146
+ url: UrlOpt = None,
147
+ config: ConfigOpt = None,
148
+ timeout: TimeoutOpt = 20.0,
149
+ ) -> None:
150
+ """Capture a server's tools/list to a file, so later runs work offline."""
151
+ target = _target(cmd, url, config, None, timeout)
152
+ tool_set, _ = _load_or_exit(target)
153
+ path = write_snapshot(tool_set, output)
154
+ console.print(
155
+ f"Wrote {len(tool_set.tools)} tools to [bold]{path}[/bold]. "
156
+ f"Re-run offline with: [dim]mispick tools --snapshot {path}[/dim]"
157
+ )
158
+
159
+
160
+
161
+ class Format(StrEnum):
162
+ terminal = "terminal"
163
+ json = "json"
164
+ md = "md"
165
+ html = "html"
166
+
167
+
168
+ FormatOpt = Annotated[Format, typer.Option("--format", help="Report format.")]
169
+ OutOpt = Annotated[
170
+ str | None, typer.Option("--out", "-o", help="Write the report here instead of stdout.")
171
+ ]
172
+ BadgeOpt = Annotated[
173
+ str | None, typer.Option("--badge", help="Also write an SVG badge to this path.")
174
+ ]
175
+
176
+ ModelOpt = Annotated[
177
+ str, typer.Option("--model", help="provider/model. Default is a local Ollama model.")
178
+ ]
179
+ NOpt = Annotated[int, typer.Option("--queries", "-n", help="Queries generated per tool (N).")]
180
+ KOpt = Annotated[int, typer.Option("--runs", "-k", help="Times each query is re-run (K).")]
181
+ TempOpt = Annotated[float, typer.Option("--temperature", help="0 for a deterministic run.")]
182
+ SeedOpt = Annotated[int | None, typer.Option("--seed", help="Seed, if the backend honours one.")]
183
+ FailUnderOpt = Annotated[
184
+ int | None,
185
+ typer.Option("--fail-under", help="Exit 1 if the score is below this. For CI."),
186
+ ]
187
+ RegenOpt = Annotated[
188
+ bool, typer.Option("--regenerate", help="Ignore the query cache and generate afresh.")
189
+ ]
190
+ CacheDirOpt = Annotated[str, typer.Option("--cache-dir", help="Where queries.yaml lives.")]
191
+ ConcurrencyOpt = Annotated[int, typer.Option("--concurrency", help="Selection calls in flight.")]
192
+ ThinkOpt = Annotated[
193
+ bool,
194
+ typer.Option(
195
+ "--think/--no-think",
196
+ help="Let a reasoning model think first. Off by default: much slower, same answers.",
197
+ ),
198
+ ]
199
+ MaxTokensOpt = Annotated[
200
+ int,
201
+ typer.Option(
202
+ "--max-tokens",
203
+ help="Output budget for query generation. Small reasoning models need a lot of it.",
204
+ ),
205
+ ]
206
+
207
+
208
+ async def _measure(
209
+ target: Target,
210
+ tool_set: ToolSet,
211
+ *,
212
+ model: str,
213
+ n: int,
214
+ k: int,
215
+ temperature: float,
216
+ seed: int | None,
217
+ regenerate: bool,
218
+ cache_dir: str,
219
+ concurrency: int,
220
+ max_tokens: int = DEFAULT_MAX_TOKENS,
221
+ think: bool = False,
222
+ no_tool: int = DEFAULT_NO_TOOL,
223
+ ) -> tuple[RunResult, Metrics]:
224
+ """Generate queries if needed, run selection, compute metrics."""
225
+ backend = get_backend(model, think=think)
226
+ cache = QueryCache.open(cache_dir)
227
+ try:
228
+ with console.status("[dim]generating test queries…[/dim]") as status:
229
+
230
+ def on_tool(name: str) -> None:
231
+ status.update(f"[dim]generating test queries for {name}…[/dim]")
232
+
233
+ queries = await build_query_set(
234
+ backend,
235
+ tool_set,
236
+ n=n,
237
+ no_tool_count=no_tool,
238
+ cache=cache,
239
+ regenerate=regenerate,
240
+ seed=seed,
241
+ max_tokens=max_tokens,
242
+ on_progress=on_tool,
243
+ )
244
+ path = cache.save(generator=backend.name)
245
+ console.print(
246
+ f"[dim]{len(queries)} queries · cached in {path} · edit it and they stick[/dim]"
247
+ )
248
+
249
+ total = len(queries) * k
250
+ done = 0
251
+ with console.status(f"[dim]0/{total} selections…[/dim]") as status:
252
+
253
+ def on_choice(_: object) -> None:
254
+ nonlocal done
255
+ done += 1
256
+ status.update(f"[dim]{done}/{total} selections…[/dim]")
257
+
258
+ result = await run_selection(
259
+ backend,
260
+ tool_set,
261
+ queries,
262
+ config=RunConfig(
263
+ n=n, k=k, temperature=temperature, seed=seed, concurrency=concurrency
264
+ ),
265
+ on_progress=on_choice,
266
+ )
267
+ finally:
268
+ await backend.aclose()
269
+ return result, compute(result)
270
+
271
+
272
+ def _emit(
273
+ result: RunResult,
274
+ metrics: Metrics,
275
+ fmt: Format,
276
+ out: str | None,
277
+ badge: str | None,
278
+ ) -> None:
279
+ """Render in the requested format, to a file or to stdout."""
280
+ if fmt is Format.terminal and out is None:
281
+ terminal_report.render(result, metrics, console)
282
+ else:
283
+ if fmt is Format.json:
284
+ text = json_report.render(result, metrics)
285
+ elif fmt is Format.md:
286
+ text = markdown_report.render(result, metrics)
287
+ elif fmt is Format.html:
288
+ text = html_report.render(result, metrics)
289
+ else:
290
+ # terminal format with --out: render without colour codes.
291
+ file_console = Console(width=100, no_color=True, record=True)
292
+ terminal_report.render(result, metrics, file_console)
293
+ text = file_console.export_text()
294
+ if out:
295
+ path = Path(out)
296
+ path.parent.mkdir(parents=True, exist_ok=True)
297
+ path.write_text(text)
298
+ err_console.print(f"[dim]wrote {path}[/dim]")
299
+ else:
300
+ # stdout must stay clean so it can be piped.
301
+ print(text, end="")
302
+
303
+ if badge:
304
+ svg = badge_report.render(metrics, model=result.config.model)
305
+ badge_path = Path(badge)
306
+ badge_path.parent.mkdir(parents=True, exist_ok=True)
307
+ badge_path.write_text(svg)
308
+ err_console.print(f"[dim]wrote {badge_path}[/dim]")
309
+
310
+
311
+ @app.command()
312
+ def run(
313
+ cmd: CmdOpt = None,
314
+ url: UrlOpt = None,
315
+ config: ConfigOpt = None,
316
+ snapshot: SnapshotOpt = None,
317
+ timeout: TimeoutOpt = 20.0,
318
+ only: OnlyOpt = None,
319
+ model: ModelOpt = DEFAULT_MODEL,
320
+ queries: NOpt = DEFAULT_N,
321
+ runs: KOpt = DEFAULT_K,
322
+ temperature: TempOpt = 0.7,
323
+ seed: SeedOpt = None,
324
+ regenerate: RegenOpt = False,
325
+ cache_dir: CacheDirOpt = ".mispick",
326
+ concurrency: ConcurrencyOpt = DEFAULT_CONCURRENCY,
327
+ max_tokens: MaxTokensOpt = DEFAULT_MAX_TOKENS,
328
+ think: ThinkOpt = False,
329
+ fail_under: FailUnderOpt = None,
330
+ fmt: FormatOpt = Format.terminal,
331
+ out: OutOpt = None,
332
+ badge: BadgeOpt = None,
333
+ ) -> None:
334
+ """Measure which tools the model mixes up."""
335
+ target = _target(cmd, url, config, snapshot, timeout, only)
336
+ tool_set, _ = _load_or_exit(target)
337
+
338
+ try:
339
+ result, metrics = asyncio.run(
340
+ _measure(
341
+ target,
342
+ tool_set,
343
+ model=model,
344
+ n=queries,
345
+ k=runs,
346
+ temperature=temperature,
347
+ seed=seed,
348
+ regenerate=regenerate,
349
+ cache_dir=cache_dir,
350
+ concurrency=concurrency,
351
+ max_tokens=max_tokens,
352
+ think=think,
353
+ )
354
+ )
355
+ except BackendError as exc:
356
+ err_console.print(f"[red]error:[/red] {exc}")
357
+ raise typer.Exit(EXIT_ERROR) from exc
358
+
359
+ if not [c for c in result.choices if not c.error]:
360
+ err_console.print(
361
+ "[red]error:[/red] every selection call failed, so there is nothing to report."
362
+ )
363
+ for error in result.errors[:3]:
364
+ err_console.print(f" {error}")
365
+ raise typer.Exit(EXIT_ERROR)
366
+
367
+ _emit(result, metrics, fmt, out, badge)
368
+
369
+ if fail_under is not None and metrics.score < fail_under:
370
+ err_console.print(
371
+ f"\n[red]score {metrics.score} is below --fail-under {fail_under}[/red]"
372
+ )
373
+ raise typer.Exit(EXIT_BELOW_THRESHOLD)
374
+
375
+
376
+ PatchOpt = Annotated[
377
+ str | None, typer.Option("--patch", help="Write the accepted rewrites here as a diff.")
378
+ ]
379
+ PairsOpt = Annotated[int, typer.Option("--pairs", help="How many confused pairs to try to fix.")]
380
+
381
+
382
+ @app.command()
383
+ def fix(
384
+ cmd: CmdOpt = None,
385
+ url: UrlOpt = None,
386
+ config: ConfigOpt = None,
387
+ snapshot: SnapshotOpt = None,
388
+ timeout: TimeoutOpt = 20.0,
389
+ only: OnlyOpt = None,
390
+ model: ModelOpt = DEFAULT_MODEL,
391
+ queries: NOpt = DEFAULT_N,
392
+ runs: KOpt = DEFAULT_K,
393
+ temperature: TempOpt = 0.7,
394
+ seed: SeedOpt = None,
395
+ regenerate: RegenOpt = False,
396
+ cache_dir: CacheDirOpt = ".mispick",
397
+ concurrency: ConcurrencyOpt = DEFAULT_CONCURRENCY,
398
+ max_tokens: MaxTokensOpt = DEFAULT_MAX_TOKENS,
399
+ think: ThinkOpt = False,
400
+ pairs: PairsOpt = 3,
401
+ patch: PatchOpt = None,
402
+ fmt: FormatOpt = Format.terminal,
403
+ out: OutOpt = None,
404
+ ) -> None:
405
+ """Propose description rewrites for the worst confused pairs, and prove they help.
406
+
407
+ Every rewrite is re-tested against the same queries. Rewrites that do not measurably
408
+ improve selection are reported as rejected rather than hidden. Your source is never
409
+ modified.
410
+ """
411
+ target = _target(cmd, url, config, snapshot, timeout, only)
412
+ tool_set, _ = _load_or_exit(target)
413
+
414
+ async def go() -> tuple[RunResult, Metrics, object]:
415
+ backend = get_backend(model, think=think)
416
+ cache = QueryCache.open(cache_dir)
417
+ try:
418
+ with console.status("[dim]generating test queries…[/dim]"):
419
+ query_list = await build_query_set(
420
+ backend,
421
+ tool_set,
422
+ n=queries,
423
+ cache=cache,
424
+ regenerate=regenerate,
425
+ seed=seed,
426
+ max_tokens=max_tokens,
427
+ )
428
+ cache.save(generator=backend.name)
429
+
430
+ with console.status("[dim]measuring the baseline…[/dim]"):
431
+ baseline = await run_selection(
432
+ backend,
433
+ tool_set,
434
+ query_list,
435
+ config=RunConfig(
436
+ n=queries,
437
+ k=runs,
438
+ temperature=temperature,
439
+ seed=seed,
440
+ concurrency=concurrency,
441
+ ),
442
+ )
443
+ base_metrics = compute(baseline)
444
+
445
+ with console.status("[dim]proposing and re-testing rewrites…[/dim]") as status:
446
+
447
+ def on_pair(pair: object) -> None:
448
+ status.update(f"[dim]re-testing a rewrite for {pair}…[/dim]")
449
+
450
+ report = await run_fix_mode(
451
+ backend,
452
+ tool_set,
453
+ baseline,
454
+ base_metrics,
455
+ limit=pairs,
456
+ on_progress=on_pair,
457
+ )
458
+ finally:
459
+ await backend.aclose()
460
+ return baseline, base_metrics, report
461
+
462
+ try:
463
+ baseline, _base_metrics, report = asyncio.run(go())
464
+ except BackendError as exc:
465
+ err_console.print(f"[red]error:[/red] {exc}")
466
+ raise typer.Exit(EXIT_ERROR) from exc
467
+
468
+ if not [c for c in baseline.choices if not c.error]:
469
+ err_console.print("[red]error:[/red] every selection call failed; nothing to fix.")
470
+ for error in baseline.errors[:3]:
471
+ err_console.print(f" {error}")
472
+ raise typer.Exit(EXIT_ERROR)
473
+
474
+ from mispick.fix import FixReport
475
+
476
+ assert isinstance(report, FixReport)
477
+
478
+ if fmt is Format.md or out:
479
+ text = fixes_report.render_markdown(report, tool_set)
480
+ if out:
481
+ path = Path(out)
482
+ path.parent.mkdir(parents=True, exist_ok=True)
483
+ path.write_text(text)
484
+ err_console.print(f"[dim]wrote {path}[/dim]")
485
+ else:
486
+ print(text, end="")
487
+ else:
488
+ fixes_report.render_terminal(report, tool_set, console)
489
+
490
+ if patch:
491
+ patch_path = Path(patch)
492
+ patch_path.parent.mkdir(parents=True, exist_ok=True)
493
+ patch_path.write_text(as_patch(report, tool_set))
494
+ err_console.print(f"[dim]wrote {patch_path}[/dim]")
495
+
496
+
497
+ @app.command(name="report")
498
+ def report_cmd(
499
+ path: Annotated[str, typer.Argument(help="A JSON report from `mispick run --format json`.")],
500
+ fmt: FormatOpt = Format.terminal,
501
+ out: OutOpt = None,
502
+ badge: BadgeOpt = None,
503
+ ) -> None:
504
+ """Re-render a saved run in another format, without measuring again."""
505
+ from mispick.report.load import ReportError
506
+ from mispick.report.load import load as load_report
507
+
508
+ try:
509
+ result, metrics = load_report(path)
510
+ except ReportError as exc:
511
+ err_console.print(f"[red]error:[/red] {exc}")
512
+ raise typer.Exit(EXIT_ERROR) from exc
513
+ _emit(result, metrics, fmt, out, badge)
514
+
515
+
516
+ @app.command(name="compare")
517
+ def compare_cmd(
518
+ base: Annotated[str, typer.Argument(help="The base branch's JSON report.")],
519
+ head: Annotated[str, typer.Argument(help="This branch's JSON report.")],
520
+ out: OutOpt = None,
521
+ max_drop: Annotated[
522
+ int | None,
523
+ typer.Option("--max-drop", help="Exit 1 if the score fell by more than this."),
524
+ ] = None,
525
+ ) -> None:
526
+ """Compare two saved runs and report the delta. For CI."""
527
+ from mispick.report.load import ReportError
528
+ from mispick.report.load import load as load_report
529
+
530
+ try:
531
+ base_result, base_metrics = load_report(base)
532
+ head_result, head_metrics = load_report(head)
533
+ except ReportError as exc:
534
+ err_console.print(f"[red]error:[/red] {exc}")
535
+ raise typer.Exit(EXIT_ERROR) from exc
536
+
537
+ text = markdown_report.render_comparison(
538
+ (head_result, head_metrics), (base_result, base_metrics)
539
+ )
540
+ if out:
541
+ path = Path(out)
542
+ path.parent.mkdir(parents=True, exist_ok=True)
543
+ path.write_text(text)
544
+ err_console.print(f"[dim]wrote {path}[/dim]")
545
+ else:
546
+ print(text, end="")
547
+
548
+ drop = base_metrics.score - head_metrics.score
549
+ if max_drop is not None and drop > max_drop:
550
+ err_console.print(
551
+ f"[red]score fell by {drop} points "
552
+ f"({base_metrics.score} → {head_metrics.score}), above --max-drop {max_drop}[/red]"
553
+ )
554
+ raise typer.Exit(EXIT_BELOW_THRESHOLD)
555
+
556
+
557
+ @app.command()
558
+ def version() -> None:
559
+ """Print the mispick version."""
560
+ console.print(f"mispick {__version__}")
561
+
562
+
563
+ if __name__ == "__main__": # pragma: no cover
564
+ app()