[trace-mcp]

Tool response token cost

Measured 2026-09-05 on darwin 25.5.0 / arm64, trace-mcp 3.18.0 (94dbaf70) — the build stamp travels with the figure to every surface that quotes it, and the preregistration states the bar and the verdict (this run publishes as a miss, on the reduction half of the bar). Against trace-mcp’s own repo (2 159 files, 11 134 symbols) over a real stdio tools/call round-trip. TRA-880, extended to the tail by TRA-945. Reproduce with:

pnpm run build && npx tsx scripts/bench-response-tokens.ts [repoPath]

Token column is the median of three runs, a real o200k_base count of the response text, not an estimate. Call volume is this machine’s ~/.trace/savings.json (20359 calls since the store was created) — real usage, one machine, never an average user.

What was wrong

The advertised surface side of the token story has been measured and guarded for weeks (preset-surface-budget.test.ts). The response side never was.

src/savings.ts scored a call before the tool ran: recordCall(name) took a hand-written RAW_COST_ESTIMATES[name], multiplied it by a flat COMPRESSION_RATIO = 0.15, and booked the difference as saved. The gate (src/server/tool-gate-helpers.ts) was the only caller and never passed a real count. So tokens_saved was calls x constant — arithmetically confirmable in the store: 5 123 search_text calls, 13 063 650 saved, exactly 2 550 each.

That number is not internal. It is the counter on the homepage and in the README (docs/_data/savings.yml), and calls/tokens_saved ride the usage ping.

The measurement

24 tools, covering 97.2% of recorded call volume. Ratio is measured response ÷ the raw Read/Grep the tool is credited with replacing; above 1.00 means the tool costs more than what it stands in for.

tool calls (real) raw baseline measured response measured/baseline
search_text 5,125 3,000 1,722 0.57
get_outline 4,461 1,200 1,427 1.19
search 4,441 600 924 1.54
get_symbol 2,687 800 294 0.37
find_usages 440 1,000 975 0.97
get_project_map 351 1,500 568 0.38
get_index_health 211 500 297 0.59
get_tests_for 96 800 90 0.11
get_complexity_report 80 800 1,820 2.27
get_feature_context 73 4,000 7,443 1.86
get_env_vars 66 500 13 0.03
get_dead_code 53 1,200 2,725 2.27
get_context_bundle 39 6,000 127 0.02
get_changed_symbols 38 500 1,224 2.45
get_task_context 32 8,000 5,383 0.67
check_quality_gates 24 500 121 0.24
get_circular_imports 21 500 76 0.15
check_duplication 19 500 272 0.54
check_claudemd_drift 17 500 1,099 2.20
scan_security 16 500 38 0.08
list_projects 15 500 901 1.80
get_call_graph 14 1,500 1,421 0.95

Those 18319 calls cost 21904545 measured tokens against a 27729800-token baseline — 21% fewer, or 31.5% if you floor the losing tools at zero the way the corrected counter does. That is the figure the homepage and the README quote in place of the old “~40–50% on average” (TRA-904). It is generated into docs/_data/response_tokens.json by npx tsx scripts/gen-response-tokens-data.ts from this table’s two inputs, so no surface can retype it. The baseline half is still an estimate — see the last section.

Three things the table says:

  1. 0.15 is wrong on every tool that matters. The four busiest (88% of all calls) measure 0.37–1.54. The assumption is off by 2.5x on the best of them.
  2. 8 of the 22 cost more than the baseline they replace — and, before the counter was corrected, were still booking a positive number on every call. It was ten of twenty-two until TRA-952 reshaped the three worst (below); the ones left are led by get_complexity_report and get_dead_code at 2.27x. Each is a response-shaping defect: a default depth/limit too generous for what the caller asked.
  3. A few are far better than claimedget_context_bundle at 0.02 and get_env_vars at 0.03 were being under-credited by an order of magnitude.

The tail, and the tools with nothing to compare against

TRA-880 measured twelve tools (88.4% of calls) and published as a miss on coverage. Measuring the remaining twelve found something the head could not show: some tools have no baseline at all.

A savings figure is “what a Read/Grep would have cost, minus what we returned”. register_edit is a notification that a file changed; reindex rebuilds an index. There is no file read an agent could have run instead, so that subtraction has no left-hand side. DEFAULT_RAW_COST = 500 was supplying one anyway — and register_edit is the fourth busiest tool on this machine, 1 289 calls. Across the whole store, 1 731 calls to mutating tools had booked ~736 000 tokens of savings that never existed, 3.0% of everything the counter had ever claimed.

Fixed in src/savings.ts: NO_BASELINE_TOOLS credits zero. The response is still counted on the spend side, because the agent still paid for it:

tool calls baseline measured response tokens spent, credited zero
register_edit 1,289 345 444,705
reindex 184 59 10,856

That is 1473 calls and 455561 tokens of pure overhead — real cost with no counterfactual. Counting it on the spend side and nothing on the baseline side gives the all-in number: 19.4%. That is what a session costs; the 21% above is what a lookup costs. Neither is wrong; they answer different questions, and the lower one is the one to plan a budget against.

src/tools/register/__tests__/no-baseline-tools.test.ts fails CI if a tool that describes itself as mutating is left out of the set, so the next one cannot quietly start booking savings again.

What closing the tail did to the headline

The two left-hand columns are frozen literals: they record what those runs measured, so a later re-measurement cannot rewrite them. Only the TRA-952 column is live.

  TRA-880 (12 tools) TRA-945 (24 tools) TRA-952 (shaped)
coverage of recorded calls 88.4% 97.2% 97.2%
net reduction_pct 29.3% 21.1% 21%
credited 35.2% 32.6% 31.5%
all-in, incl. no-baseline overhead not computed 19.5% 19.4%
tools costing more than their baseline 4 of 12 10 of 22 8 of 22

The tail was more expensive than the head, in both directions: it contained the worst per-call ratios in the product and the calls that should never have been scored. Fixing the coverage miss produced a reduction miss.

The fix

SavingsTracker.recordActualTokens(tool, tokens) corrects the pre-call guess once the response exists. recordCall stays where it is, before the tool runs, because budget clamping and dedup both read the session totals first — this is a two-phase estimate-then-reconcile, not a move.

Four things the correction has to get right, all found in review and guarded in tests/tools/savings.test.ts:

How the numbers are collected

Three runs per tool, median published, min and max printed. That is not ceremony: a single sample recorded get_task_context at 5 383 tokens and then at 8 357 minutes later on the same commit. The spread turned out not to be variance but a degraded surface — when a daemon is already running, the stdio session proxies to it and the session’s own --preset is ignored, so twelve of the twenty-four tools answer Tool X disabled and the bench was about to publish those error strings as measurements. The harness now aborts on any errored call rather than writing it to the artifact.

Within one healthy session the responses are near-deterministic: every tool above has a min–max spread of 0–3 tokens.

What is still an estimate, and what to do next

RAW_COST_ESTIMATES — “what a Read/Grep would have cost instead” — is still hand-written and unvalidated, so the savings baseline remains a guess even though the response side is now measured. That is the next measurement, not this one: it needs a real counterfactual (the same question answered with Read/Grep, tokens counted), which is what benchmarks/pr-context-benchmark does for PR context and nothing does for tool calls.

One caveat on get_outline: it read 1 427 tokens against 1 056 in TRA-880, on the same target file, because the change that added NO_BASELINE_TOOLS grew src/savings.ts by ~70 lines. The bench measures a live repository, so its own commits move its numbers. That is a property of the corpus, not noise, and it is why the corpus size is stated at the top.

What shaping the three worst tools did (TRA-952)

The first three tools on that follow-up list have been reshaped, and the table above is the after. What each one was returning that nobody asked for:

tool before after what came out
list_projects 5,240 (10.48x) 901 (1.80x) 98 subprojects, three absolute paths each. call_project_tool only accepts registered roots, so a subproject was never a valid next call. Now include_subprojects, default off.
get_call_graph 5,263 (3.51x) 1,421 (0.95x) Both directions expanded at every level, so depth 2 answered “what else does my caller call” — 64 of 75 nodes. Each branch now keeps its own direction.
get_dead_code 4,819 (4.02x) 2,725 (2.27x) 50 of 341 candidates in one page, for a list the caller verifies entry by entry. Default is 25; total_dead is unchanged, so nothing is hidden.

No response field was dropped and no schema changed: the subprojects list is still available on request, the call graph still reaches the same depth, and a deeper dead-code page is still one limit away.

And it moved the headline by 0.1 points. Those three tools are 82 of 18,319 recorded calls. The weighted figure is decided by search_text, get_outline and search, which are 78% of call volume between them — search at 1.54x and get_outline at 1.19x are now the whole of the negative block that matters. Worst-ratio-first was the right order for finding defects and the wrong one for moving the number; the next response-shaping issue should be search, on volume.

get_dead_code is left at 2.27x on purpose. Its baseline is 1,200 tokens — “what a Read/Grep would have cost instead” for a whole-repo dead-code sweep over 3,412 exports, which is not a credible 1,200 tokens. Cutting the tool further would buy the ratio by answering less; the honest correction there is on the baseline half, which is still an estimate.

Three rows moved for reasons that are not this change

get_changed_symbols (521 → 1,224), find_usages (1,122 → 975) and search_text (1,659 → 1,722) were not touched. get_changed_symbols reports the diff of whatever working tree it runs on, so the two runs asked it different questions and its row is not comparable between them at all. The other two track the corpus: the repo gained files and symbols between the two measurements. Same caveat as get_outline above, and the reason the per-tool ratios are the durable part of this page and the aggregate is not.

Last updated: September 5, 2026