whichtool
Enables measuring and reporting which tool a model selects from an MCP server's tool list, without ever executing tools. Provides inspection, trial runs, and confusion analysis to benchmark routing accuracy.
README
whichtool
<p align="center"> <img src="assets/whichtool.png" alt="One tool surface, four tools, one selected." width="160"> </p>
Does the model actually pick the right tool from your MCP server?
[!WARNING] Publication is temporarily paused. Automated releases are disabled, and the npm package may be unavailable while the public GitHub repository remains online. The registry and Action instructions below are intentionally retained for a possible future republication. To use the current source now:
git clone https://github.com/mattagame/whichtool.git cd whichtool bun install bun run ./src/cli/main.ts inspect ./tools.json
An MCP server can have valid schemas and still be unreadable to a model. Ship list_users and search_users with similar descriptions and the model guesses. Schema validation still passes. Integration tests pass too, because they call the right tool by construction.
whichtool puts that surface in front of a real model and reports which tool gets picked and which pairs get confused.
whichtool never executes a tool. It reads tools/list, records what the model would have called, and stops.
It is deliberately a single-turn routing benchmark. It measures the model's tool-selection decision on a prepared set of intents; it does not evaluate multi-step agent execution, semantic argument correctness beyond a shallow schema check, tool results, recovery, or the quality of a final answer.
Every call proposed in that turn is retained in the JSON report's trials[].calls; the
first-call fields remain a compatibility view, not a reason to discard additional calls.
It does two jobs:
inspect— token budget, contradictory annotations, near-identical descriptions, invalidx-mcp-headervalues. No model call or model-provider key; a live target may still require its own authorization.run— trials, permuted tool order, confusion matrix, rates with Wilson 95% intervals.
inspect warns when a surface exposes more than 6 tools. Real CLI, MCP, and GitHub Action
runs stop before calling a model above that default. After reviewing the surface, an
operator can raise the limit with --max-tools N, trials.maxTools, the MCP startup flag,
or the Action's max-tools input; 1,000 is the hard maximum. Six is a cautious default, not
a universal rule: more tools can increase ambiguity and prompt size, but the right number
depends on the model, schemas, descriptions, and tasks. Also set --max-context-tokens so a
small number of unusually large tools cannot bypass the context budget.
Those Wilson intervals describe trial-level stability on the tasks in the file. Repeating a task measures whether that same routing decision is stable; it does not estimate how the model will perform on unseen intents.
Install
npx whichtool inspect ./tools.json
# or: bunx whichtool inspect ./tools.json
npm install --save-dev whichtool
Requires Node 20.11+ or Bun 1.3+. Zero runtime dependencies.
Standalone binaries are not published yet. Bun-compiled executables embed third-party runtime components, so distribution stays disabled until their redistribution notices have been reviewed and can ship with every binary. This is separate from the temporary package publication pause above; use the source checkout while that pause is in effect.
Quick start
# 1. Look at the surface (no model-provider key)
whichtool inspect ./tools.json
whichtool inspect https://example.com/mcp
whichtool inspect --transport stdio "bun run ./src/server.ts"
# Capture once, work offline afterwards
whichtool inspect --transport stdio "npx -y @modelcontextprotocol/server-filesystem ." \
--save-snapshot ./tools.json
Snapshots may be { "tools": [ … ] }, a JSON-RPC tools/list envelope, or a bare array.
# 2. Write a task set (whichtool.tasks.yaml)
version: 1
tasks:
- id: users.list.basic
prompt: 'Show me all the users in the workspace'
expected: list_users
- id: users.search.byname
prompt: "Find the user whose name contains 'rossi'"
expected: search_users
- id: distractor.delete
prompt: 'Permanently delete the account belonging to Rossi'
expected: null
expected must be written even when it is null. Full format: docs/task-sets.md.
# Or draft one instead of writing step 2 by hand, then edit and commit the result
# (do not regenerate on every run). It refuses to overwrite without --force.
whichtool tasks generate ./tools.json --provider ollama --model qwen3:4b --out whichtool.tasks.yaml
# Seeded robustness variants, no model
whichtool tasks mutate --out whichtool.tasks.mutated.yaml --seed 0
# 3. Lint before spending anything
whichtool tasks lint ./tools.json --tasks ./whichtool.tasks.yaml
# 4. Preview the workload (no model call)
whichtool run ./tools.json --provider ollama --model qwen3:4b --repeat 5 --dry-run
# 5. Measure
whichtool run ./tools.json --provider ollama --model qwen3:4b --repeat 5
OPENAI_API_KEY=sk-… whichtool run ./tools.json --provider openai --model gpt-4.1-mini
--repeat defaults to 5 per selected task, so total trials are the tasks left after
--only / --skip, multiplied by repeat. A real run refuses more than 50 total trials by
default. After reviewing --dry-run, raise that budget with --max-trials N or
trials.maxTrials; 1,000 is an absolute, non-overridable maximum.
The dry-run prompt-token figure is a lower bound, not a price estimate. Output and reasoning tokens are additional and can be much larger. Automatic retries are disabled by default for the built-in HTTP providers.
During whichtool run, press Ctrl+C to abort in-flight provider requests. The command
exits with code 130 and does not write a partial report. MCP evaluations remain cancellable
through the MCP protocol.
Exit codes: 0 execution was healthy and thresholds held, 1 a quality threshold failed,
2 an execution error (including an incomplete run or too many provider failures). By
default a run needs at least one scored trial and permits at most a 10% provider-error rate;
override these with --min-scored and --max-error-rate.
# 6. Re-render, gate, compare
whichtool run … --format json --out run.json
whichtool report run.json --format markdown
whichtool report run.json --format html --out report.html
whichtool diff base-run.json head-run.json --max-accuracy-drop 0.05
diff refuses to subtract runs that used a different model, endpoint, non-secret provider
request fingerprint, temperature, seed, repeat count, permutation setting, or task set. It
matches outcomes by task and trial index, then uses an exact two-sided paired sign test
(p <= 0.05) to decide whether a movement is distinguishable. A distinguishable increase
in unexpected multi-call behaviour is a regression even when the first picks did not move.
Commands
| Command | What it does |
|---|---|
whichtool inspect <target> |
Surface lint. No model call or model-provider key. |
whichtool mcp |
Expose prepared routing-evaluation operations over MCP. |
whichtool tasks lint [target] |
Validate a task set. |
whichtool tasks generate <target> |
Draft a task set from the tool descriptions. |
whichtool tasks mutate |
Seeded robustness variants. No model. |
whichtool run <target> |
Execute trials and write a report. |
whichtool report <run.json> |
Re-render a saved run. |
whichtool diff <base> <head> |
Compare two saved runs. |
whichtool cache info|clear |
Inspect or clear the trial cache. |
whichtool <command> --help lists flags. Main flags on run:
--tasks --provider --model --repeat --max-trials --max-tools --concurrency --temperature --seed
--min-scored --max-error-rate
--permute / --no-permute --format --out --min-accuracy --max-over-trigger
--max-context-tokens --only --skip --dry-run --seconds-per-trial --reasoning-effort
--cache / --no-cache --cache-dir
Formats: terminal, json, markdown, html, junit, badge.
Environment: an HTTP-target credential needs both WHICHTOOL_HTTP_AUTHORIZATION and the exact allowed origin in WHICHTOOL_HTTP_AUTHORIZATION_ORIGIN (for example https://mcp.example). Remote credentials require HTTPS. Provider keys come from ANTHROPIC_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, TOGETHER_API_KEY, and WHICHTOOL_PROVIDER_API_KEY for an openai-compatible endpoint. NO_COLOR / FORCE_COLOR are honoured.
| Transport | Notes |
|---|---|
snapshot |
Captured tools/list on disk. What CI should use. |
http |
Streamable HTTP (MCP 2026-07-28). |
stdio |
Locally launched server. |
legacy-sse |
Refused. Deprecated since MCP 2025-03-26. |
Providers: anthropic, ollama, openai, openai-chat, openrouter, together, vllm,
any openai-compatible endpoint, and a deterministic mock. openai uses the OpenAI
Responses API. Select openai-chat explicitly for OpenAI Chat Completions; the other
OpenAI-compatible presets continue to use their chat-completions endpoints.
anthropic speaks the Messages API rather than a chat-completions dialect. That provider
does not send temperature or seed and records those capabilities as unsupported, so its
runs lean on --repeat and the trial-level intervals instead.
Configuration
import { defineConfig } from 'whichtool'
export default defineConfig({
target: { transport: 'stdio', command: 'bun run ./src/server.ts' },
tasks: './whichtool.tasks.yaml',
provider: { name: 'ollama', model: 'qwen3:4b' },
trials: {
repeat: 5,
maxTrials: 50,
maxTools: 6,
permute: true,
temperature: 0,
concurrency: 4,
},
thresholds: {
minAccuracy: 0.9,
maxOverTrigger: 0.05,
maxContextTokens: 4000,
maxErrorRate: 0.1,
minScored: 1,
},
report: { formats: ['terminal', 'json'], out: './whichtool-report' },
})
whichtool.config.json works too. API keys are never a config field. The normal CLI can
also discover JavaScript or TypeScript config; the MCP server intentionally does not, as
explained below.
CI
- uses: mattagame/whichtool@v0.1.0
with:
target: ./tools.json
tasks: ./whichtool.tasks.yaml
provider: openai
model: gpt-4.1-mini
max-trials: '50'
max-tools: '6'
min-accuracy: '0.9'
max-over-trigger: '0.05'
Trial caching in the composite action is disabled by default because a cache can contain prompts, tool definitions,
and provider responses. Set cache: 'true' only when that material is non-sensitive and
GitHub-hosted persistence is acceptable.
The Action blocks a measured invocation above 6 tools by default; max-tools can raise the
limit only up to 1,000. Its max-trials budget applies to each measured invocation. A
comparison workflow that measures both the head and base revisions can therefore use the
trial budget once for each run; with the default, that is at most 50 trials for head and 50
for base.
Omit provider to run only the free static pass: inspect, plus tasks lint when a task set is present. A full workflow (including a base-branch comparison written to the job summary) is in examples/github-action.
As an MCP server:
{
"mcpServers": {
"whichtool": {
"command": "npx",
"args": ["-y", "whichtool", "mcp", "--config", "whichtool.config.json"]
}
}
}
The MCP server is deliberately capability-limited by its startup arguments. It does not
auto-discover or execute JavaScript/TypeScript config: pass a reviewed JSON file explicitly
with --config. Tool calls use the configured target and cannot replace it with an
arbitrary path, URL, or subprocess. Agent-selected task/report inputs must stay in the
working directory.
The intended agent workflow starts from evaluation artifacts you have already prepared and
reviewed: inspect_surface, validate_task_file, run_evaluation, then
diff_saved_results on saved runs. The MCP surface does not generate or mutate task sets.
It exposes the same single-turn routing benchmark; it is not an evaluator or executor for a
complete agent workflow.
run_evaluation can always produce a dry-run plan, but cannot contact a provider unless the
operator starts the server with --allow-paid-runs. The operator-owned real-run budget is
50 total trials by default; only the startup --max-trials flag or trials.maxTrials in the
reviewed config can raise it, up to the absolute maximum of 1,000. The agent cannot override
that budget. The same operator-owned rule applies to the 6-tool default through startup
--max-tools or trials.maxTools, with an absolute maximum of 1,000. repeat and
concurrency also have caps. A full run returns a compact summary. Add
--result-file ./latest-run.json to keep the complete report outside model context.
--allow-dynamic-targets exists for isolated development setups and should be treated as
an unsafe opt-in. Provider/model overrides are likewise config-only unless the operator
adds --allow-provider-overrides. Persistent trial caching is off in MCP mode; the operator
must add --cache explicitly after deciding that prompts, calls and responses may be written
to disk.
Examples
| Example | What it shows |
|---|---|
| quickstart | The full loop on a surface you can run locally. |
| ambiguous-server | A deliberately unreadable surface. |
| ollama-qwen3 | A local-model run that disagrees with the static lint. |
| github-action | CI wiring with a base-branch diff. |
On reasoning models such as qwen3, a single trial can take tens of seconds of thinking tokens
whichtool never reads. Measure one trial, then pass --dry-run --seconds-per-trial. Its
prompt-token total remains a lower bound, not a price estimate; output and reasoning tokens
are additional.
Development
Bun is the toolchain; Node is the distribution target. src/core/ is portable TypeScript (no Bun/Node builtins).
bun install
bun test
bun run typecheck
bun run lint
bun run build
docker run --rm -v "$PWD:/work" ghcr.io/mattagame/whichtool inspect ./tools.json
Patches welcome: CONTRIBUTING.md lists the constraints that tests enforce rather than reviewers.
Design record: SPEC.md. Security: SECURITY.md. JSON contract: docs/report-schema.md. Changes: CHANGELOG.md.
Disclaimer
Software is provided as-is, without warranty. See LICENSE.md.
runcosts money on hosted providers. Tool definitions and prompts are sent to the model you configure. Use--dry-runfirst, but treat its prompt-token figure as a lower bound rather than a price estimate. Ollama and other local endpoints stay on your machine.- Tools on the server under test are never invoked.
stdiodoes launch the command you pass, with your privileges — treat that command as code. - Standalone binaries are not distributed yet. Publication stays disabled until the embedded runtime's third-party notices have been reviewed and can ship beside each binary.
- Not a security scanner. A surface can pass
inspectand still be dangerous. Details: SECURITY.md.
Licence
MIT — LICENSE.md.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.