<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Roshan Singh</title>
    <description>The latest articles on DEV Community by Roshan Singh (@lopster568).</description>
    <link>https://dev.to/lopster568</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4049860%2Fbb7899fa-ab84-4cc4-808e-1dba2ac3537c.jpeg</url>
      <title>DEV Community: Roshan Singh</title>
      <link>https://dev.to/lopster568</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lopster568"/>
    <language>en</language>
    <item>
      <title>I measured what 14 MCP servers cost a context window. Claude counts them 64% higher than tiktoken</title>
      <dc:creator>Roshan Singh</dc:creator>
      <pubDate>Tue, 18 Aug 2026 17:11:54 +0000</pubDate>
      <link>https://dev.to/lopster568/i-measured-what-14-mcp-servers-cost-a-context-window-claude-counts-them-64-higher-than-tiktoken-10pj</link>
      <guid>https://dev.to/lopster568/i-measured-what-14-mcp-servers-cost-a-context-window-claude-counts-them-64-higher-than-tiktoken-10pj</guid>
      <description>&lt;p&gt;Last month I &lt;a href="https://dev.to/lopster568/what-should-an-mcp-tool-return-i-ran-72-trials-instead-of-arguing-43b4"&gt;ran 72 trials&lt;/a&gt; to settle what an MCP tool should return, because a maintainer would not take opinion for an answer. That left the other half open: before an agent does any work, how much of its context window have the attached servers eaten?&lt;/p&gt;

&lt;p&gt;Vendors published numbers for this in 2026. I checked the six that get cited: exactly one is a real measurement study, StackOne's from 2026-03-31, which measured GitHub and Atlassian and worked through Cloudflare's code-mode case. Of the other five, one uses hypothetical unnamed servers, three contain no server-specific token study at all, and one I could not locate at any domain. So the published state of the art is a single snapshot, and it still shows March's numbers. A number that is not re-measured is a screenshot, and MCP servers change every few weeks. So I built a standing measurement, called loadline: 14 servers, methodology 0.2.0, one run dated 2026-08-18, monthly from here, every row reproducible from the repo with your own credentials. The front door is a stack calculator, not a table: the useful question is what my stack costs in the client I use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude counts the same schema bytes around 64 percent higher than tiktoken
&lt;/h2&gt;

&lt;p&gt;All three count one fixed string, the canonical serialization from methodology section 1.5. Same bytes, three counters.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;server&lt;/th&gt;
&lt;th&gt;o200k_base&lt;/th&gt;
&lt;th&gt;claude-opus-5&lt;/th&gt;
&lt;th&gt;premium&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;filesystem (14 tools)&lt;/td&gt;
&lt;td&gt;2,697&lt;/td&gt;
&lt;td&gt;4,432&lt;/td&gt;
&lt;td&gt;+64.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;linear (53 tools)&lt;/td&gt;
&lt;td&gt;17,780&lt;/td&gt;
&lt;td&gt;29,026&lt;/td&gt;
&lt;td&gt;+63.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kubernetes (20 tools)&lt;/td&gt;
&lt;td&gt;4,430&lt;/td&gt;
&lt;td&gt;7,552&lt;/td&gt;
&lt;td&gt;+70.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;github (47 tools)&lt;/td&gt;
&lt;td&gt;59,084&lt;/td&gt;
&lt;td&gt;86,843&lt;/td&gt;
&lt;td&gt;+47.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across the 12 rows that produced counts the premium runs from 47.0 percent (github) to 70.5 percent (kubernetes), median 64.1, with 8 of the 12 between 60 and 66. Gemini stays within 11 percent of o200k on every row, so this is not a bigger-models-count-more effect. It is specific to Claude's tokenizer on schema text.&lt;/p&gt;

&lt;p&gt;Every MCP cost study I have seen counts with tiktoken, the only one of the three that runs offline and free. If your agent runs on Claude, those studies describe a context load roughly 60 percent smaller than the one you pay for on the same schemas. They are not wrong about the bytes. They count them for a different model than the one in your loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub MCP, loaded naively, is 43 percent of a 200k window
&lt;/h2&gt;

&lt;p&gt;The github row measures 47 tools at 59,084 tokens under o200k_base and 86,843 under claude-opus-5. That second figure is 43 percent of a 200k window, spent before the first user message.&lt;/p&gt;

&lt;p&gt;Two qualifiers matter more than the number. First, 47 tools is what the server exposed to the classic personal access token this run used, and the spec lets a surface vary with the authorization presented, so the real GitHub surface is larger. Auth scope publishes per row, since that is the largest source of legitimate disagreement here.&lt;/p&gt;

&lt;p&gt;Second, nobody on a tool-search client pays it. Claude Code has defaulted to progressive disclosure since January 2026, and on this surface that models out to roughly 4,300 to 6,800 tokens: a 500-token stub plus 3 to 5 tools at the measured 1,257-token average. A swing that size, driven entirely by the client, is why no row publishes one cost number. Three modes, always. And "modeled" is load-bearing there, which I get to below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three tools can cover a product line
&lt;/h2&gt;

&lt;p&gt;Cloudflare's aggregate endpoint answers &lt;code&gt;tools/list&lt;/code&gt; with three tools: &lt;code&gt;docs&lt;/code&gt; (362 tokens), &lt;code&gt;search&lt;/code&gt; (572), &lt;code&gt;execute&lt;/code&gt; (660). Total 1,596 under o200k, 37 times smaller than github's naive load, and hygiene holds at B (85.19), so the compression did not come from deleting descriptions.&lt;/p&gt;

&lt;p&gt;The qualifier: that is the aggregate endpoint measured as one server. Cloudflare also ships 16 product-scoped endpoints this run did not enumerate or sum.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chrome DevTools ships 52 tools without the token bill you would expect
&lt;/h2&gt;

&lt;p&gt;Chrome DevTools MCP, official from Google and newly measured this run, exposes 52 tools, second only to linear's 53, at 7,984 tokens under o200k: 153 tokens per tool, the leanest average in the corpus. Playwright's 24 tools cost 167 each, filesystem's 14 cost 192, linear's 53 cost 335. Hygiene holds at B (80.28), so, as with Cloudflare, the leanness is not from deleting descriptions. A server can carry more surface than all but one other in the corpus and still land near the cheap end of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two of the 14 servers could not start, and one of them healed an hour later
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fetch&lt;/code&gt;, the reference implementation, and &lt;code&gt;postgres&lt;/code&gt; both failed to launch from a clean install when the run executed, 2026-08-18 at 15:38 UTC. Each declares an unbounded dependency on the MCP Python SDK, &lt;code&gt;mcp&amp;gt;=1.1.3&lt;/code&gt; for fetch and &lt;code&gt;mcp[cli]&amp;gt;=1.5.0&lt;/code&gt; for postgres, so both take whatever the resolver hands them. A resolve that lands on &lt;code&gt;mcp&lt;/code&gt; 2.0.0 breaks both, in two different places.&lt;/p&gt;

&lt;p&gt;fetch imports a symbol that 2.0.0 renamed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ImportError: cannot import name 'McpError' from 'mcp.shared.exceptions'.
Did you mean: 'MCPError'?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;postgres dies one import earlier, on a module 2.0.0 no longer ships at that path:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ModuleNotFoundError: No module named 'mcp.server.fastmcp'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then, re-checking both rows about an hour later on the same machine with the resolver cache forced fresh, fetch came back. A clean resolve handed it &lt;code&gt;mcp&lt;/code&gt; 1.29.0 instead of 2.0.0, and it started. Nothing upstream was withdrawn: 2.0.0 is still the latest release and is not yanked. The same command, on the same machine, on the same afternoon, resolved differently. postgres still fails.&lt;/p&gt;

&lt;p&gt;So the &lt;code&gt;fetch: unreachable&lt;/code&gt; row this post is built on went stale about an hour after it was written, and I am publishing it that way, labelled, with an entry in the corrections log, rather than quietly regenerating the dataset until it agrees with the prose. Next month's run will say whatever is true next month.&lt;/p&gt;

&lt;p&gt;The more useful outcome is that it found a hole in my own instrument. The harness records whether the server package was pinned, but it never records which SDK version the resolve actually produced, so the artifact cannot explain why 15:38 and 16:45 disagree. That gets fixed before the next run.&lt;/p&gt;

&lt;p&gt;I could have pinned around the breakage in a minute and decided not to. Pinning changes what "the fetch server" means here, publishing a constrained old version while implying it describes what you would install today. Server rot is part of the subject matter, so it is part of the dataset.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;figma&lt;/code&gt; does not publish as a row at all this run: it failed gate 2 of the published selection rule, full surface enumerable under a free-tier credential, because its auth is OAuth-only and a reproducible harness has no credential to exercise that flow. It was excluded from the corpus rather than published as an auth row, and chrome-devtools was promoted to the freed slot.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cheap and findable are different axes
&lt;/h2&gt;

&lt;p&gt;Notion's redesigned server is cheap: 24 tools, 5,180 tokens under o200k. It also scores hygiene D (54.79) and a top-3 retrievability fraction of 0.5417, the lowest measured here, with an MRR of 0.4527. What sinks it: &lt;code&gt;when_to_use_signal&lt;/code&gt; 0, &lt;code&gt;disambiguation&lt;/code&gt; 0, &lt;code&gt;parameter_descriptions&lt;/code&gt; 44.03. Context7, at 2 tools and 1,052 tokens, scores hygiene A (97.22), top-3 1.0, MRR 1.0.&lt;/p&gt;

&lt;p&gt;Under a naive client that difference is invisible: both surfaces are just text in the prompt. Under a tool-search client it is the whole game, because a tool the search does not surface is a tool the agent does not have, however cheap it is. Rank on schema size alone and the winning move is gutting descriptions.&lt;/p&gt;
&lt;h2&gt;
  
  
  What is measured and what is not
&lt;/h2&gt;

&lt;p&gt;Every cell carries a MEASURED or MODELED label. Naive full-load is measured: the token count of the canonical serialization. The per-tool costs in the tool-search column are measured, but its total is modeled, because &lt;code&gt;k&lt;/code&gt;, the number of tools a session pulls, is an assumption, which is why it publishes as a 3-to-5 range and not a point. Code mode is modeled end to end and keeps that label until a Tier 2 run validates it.&lt;/p&gt;

&lt;p&gt;Retrievability queries are derived from each tool's own description, so the metric measures within-server disambiguation, whether sibling tools shadow each other, not whether real phrasing finds a tool. It shares input text with the hygiene grade, so the two are not independent evidence.&lt;/p&gt;

&lt;p&gt;Dollar figures are estimates, computed from a price table pending verification and shown only as a cold-write and cache-read pair. Tokens stay the primary unit: a single dollar figure is the cold-start number presented as though it recurs, overstating steady state by roughly 10x, since tool definitions are a near-perfect cache prefix. The rest is in &lt;code&gt;docs/methodology-v0.md&lt;/code&gt; 0.2.0, with the rejected alternative for each judgment call.&lt;/p&gt;
&lt;h2&gt;
  
  
  The governance, as mechanism
&lt;/h2&gt;

&lt;p&gt;Neutrality is not something to take on my word, so here are the checkable parts.&lt;/p&gt;

&lt;p&gt;Selection runs on a published rule: five binary gates (protocol compliance, full surface enumerable under a free-tier credential, verifiable adoption evidence, a release in the trailing six months, no official successor), plus a category spread and a 15-slot ceiling. Coverage is not a claim this project makes. Anyone can submit a server through a public issue and gets the gate result back, pass or fail, with the failing gate named.&lt;/p&gt;

&lt;p&gt;The corrections log is public and append-only, and it has two entries before the first ranking: a mismatch between what the methodology said the harness did and what it did, caught in review before any release, and the &lt;code&gt;fetch&lt;/code&gt; row above. Before a server's numbers first publish, its maintainer gets 14 days with the rows, the artifacts, and the methodology version; a reply publishes verbatim beside the row, and non-response is recorded as "no reply received", not as agreement.&lt;/p&gt;

&lt;p&gt;Every row ships three SHA-256 hashes of the tool surface plus the raw wire artifacts, so a disputed count is checked against the bytes it came from, not against my word. Recusal is published before the first ranking, and my own project is excluded from rankings by rule.&lt;/p&gt;
&lt;h2&gt;
  
  
  What lands next
&lt;/h2&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/lopster568/loadline" rel="noopener noreferrer"&gt;github.com/lopster568/loadline&lt;/a&gt;. Calculator: &lt;a href="https://loadline-dev.netlify.app" rel="noopener noreferrer"&gt;loadline-dev.netlify.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The next run adds &lt;code&gt;slack&lt;/code&gt;, once its operator credential lands, and starts Tier 2: scripted task runs through a call-logging proxy, measuring real call and response flows. Tier 2 is the capability axis, and if it stops running the leaderboard pauses, because a cost-only ranking is worse than none.&lt;/p&gt;

&lt;p&gt;If a number here is wrong, tell me and bring the row. The harness, corpus file, artifacts, and derived query sets are in the repo, so disagreement can be specific, and corrections go in the log with your name on them.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/lopster568" rel="noopener noreferrer"&gt;
        lopster568
      &lt;/a&gt; / &lt;a href="https://github.com/lopster568/loadline" rel="noopener noreferrer"&gt;
        loadline
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;loadline&lt;/h1&gt;
&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;STATUS: pre-release draft. Not published. All text pending owner review.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;loadline is a standing, versioned measurement of what MCP servers cost an
agent's context window. It measures on a monthly cadence, run manually today
per &lt;code&gt;docs/pc-sweep-runbook.md&lt;/code&gt;, across a curated set of servers, counts
tool-schema tokens with three tokenizer adapters, and reports the result
through a stack calculator: pick servers, a client mode, and a model, and
get total context footprint, window share, per-server attribution, and a
cold-write/cache-read dollar pair. Published rows carry counts from all three
tokenizers (OpenAI o200k locally, Claude and Gemini via their token-counting
APIs); a cell whose count could not be obtained publishes as
&lt;code&gt;available: false&lt;/code&gt;, never as an estimate. Costs
are always reported per client mode (naive full-load, tool search /
progressive disclosure, code mode), never collapsed into one number
because the same stack costs a different amount depending on how the
client…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/lopster568/loadline" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;





&lt;p&gt;I do this work for hire: auditing what your agent's tool surface costs before it does any work, and cutting it down without gutting what the search can still find. Scope and pricing at &lt;a href="https://roshansingh.systems/#hire" rel="noopener noreferrer"&gt;roshansingh.systems/#hire&lt;/a&gt;, or write to &lt;a href="mailto:inbox@roshansingh.systems"&gt;inbox@roshansingh.systems&lt;/a&gt; and tell me what your agents are loading.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Does progressive disclosure actually work? I benchmarked Jaeger's MCP server against the naive alternative</title>
      <dc:creator>Roshan Singh</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:25:41 +0000</pubDate>
      <link>https://dev.to/lopster568/does-progressive-disclosure-actually-work-i-benchmarked-jaegers-mcp-server-against-the-naive-21fj</link>
      <guid>https://dev.to/lopster568/does-progressive-disclosure-actually-work-i-benchmarked-jaegers-mcp-server-against-the-naive-21fj</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/lopster568/building-jaegers-mcp-server-connecting-llms-to-distributed-traces-2jj6"&gt;my first post about Jaeger's MCP server&lt;/a&gt; I made a claim I couldn't back up. The server tiers its nine tools by cost, cheap discovery first, verbose span data last, so that an agent drills down instead of dumping everything into its context. I measured the byte sizes and called it progressive disclosure. What I never measured was whether agents actually behave that way, or whether the whole tiered design beats the thing most teams build first: one tool that returns everything.&lt;/p&gt;

&lt;p&gt;Meanwhile the internet is still running the MCP-token-cost argument I wrote about last time: strong claims, no controlled comparison.&lt;/p&gt;

&lt;p&gt;So I ran the comparison. 72 trials, two tool designs, two model families, six trace troubleshooting tasks, pre-registered hypotheses. This is the second arm of &lt;a href="https://github.com/lopster568/jaeger-mcp-bench" rel="noopener noreferrer"&gt;jaeger-mcp-bench&lt;/a&gt;; the first arm, which varied a single tool's output format, is written up &lt;a href="https://dev.to/lopster568/what-should-an-mcp-tool-return-i-ran-72-trials-instead-of-arguing-43b4"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two arms
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tiered&lt;/strong&gt; is the real thing: stock &lt;code&gt;jaegertracing/jaeger:2.20.0&lt;/code&gt; with &lt;code&gt;ai.enable_mcp: true&lt;/code&gt;. That registers the nine shipped tools (&lt;code&gt;get_services&lt;/code&gt;, &lt;code&gt;search_traces&lt;/code&gt;, &lt;code&gt;get_trace_topology&lt;/code&gt;, &lt;code&gt;get_critical_path&lt;/code&gt;, &lt;code&gt;get_span_details&lt;/code&gt;, &lt;code&gt;get_trace_errors&lt;/code&gt;, and friends) plus the instructions file from post #1, nine lines at this release. Not a fork, not a patch. Anyone can pull the image and get this arm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flat&lt;/strong&gt; is the naive integration, built for this benchmark: a small Go MCP server exposing exactly one tool, &lt;code&gt;get_trace_data(service, lookback_minutes, limit, errors_only)&lt;/code&gt;, which calls jaeger-query's &lt;code&gt;/api/traces&lt;/code&gt; and returns every matching trace as a complete span dump. Every attribute, every log, every reference, verbatim, no summarization. It is the wrapper most people write on day one: expose the search endpoint, return the JSON, let the model figure it out.&lt;/p&gt;

&lt;p&gt;Everything else is held constant. Same Jaeger backend, same frozen trace store, same streamable HTTP transport with identical session options, same per-trial budget, and deliberately neutral wording everywhere the model can see. The flat server's instructions open with the same one-line domain framing as the tiered server's and then stop. No "naive", no "baseline", no experiment vocabulary in anything model-visible, because a model that knows it's the control arm stops being a control arm.&lt;/p&gt;

&lt;p&gt;The tasks are the kind of thing you'd actually ask: find the root cause of the errors, name the operation eating the critical path of the slowest trace, count the spans and services in the slowest trace, pull an attribute off a failing span, map who calls the database, explain where a slow request's extra time went. Ground truth is computed from the same HTTP API both arms read, using a Python port of Jaeger's own critical path algorithm, against a store frozen before the first trial. The fixture is volume-capped at 100 traces per service, which happens to be the tiered arm's server-side search cap, so both arms can enumerate the full candidate set. Whether they bother to is part of what's being tested.&lt;/p&gt;

&lt;p&gt;One more thing before any trial ran: the design doc with hypotheses, task table, and exactly four statistical comparisons went into git first. The bar for "significant" was Bonferroni-corrected to 0.0125. Everything else in this post is labeled what it is: observation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correctness: the inventory decides, not the model
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparison (registered)&lt;/th&gt;
&lt;th&gt;Correct&lt;/th&gt;
&lt;th&gt;p&lt;/th&gt;
&lt;th&gt;Significant?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude: tiered vs flat&lt;/td&gt;
&lt;td&gt;16/18 vs 6/18&lt;/td&gt;
&lt;td&gt;0.0006&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini: tiered vs flat&lt;/td&gt;
&lt;td&gt;14/18 vs 8/18&lt;/td&gt;
&lt;td&gt;0.04&lt;/td&gt;
&lt;td&gt;no (0.04 &amp;gt; 0.0125 corrected)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tiered: Claude vs Gemini&lt;/td&gt;
&lt;td&gt;16/18 vs 14/18&lt;/td&gt;
&lt;td&gt;0.37&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flat: Claude vs Gemini&lt;/td&gt;
&lt;td&gt;6/18 vs 8/18&lt;/td&gt;
&lt;td&gt;0.49&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pooled across both models, tiered solved 30 of 36 tasks and flat solved 14 of 36.&lt;/p&gt;

&lt;p&gt;Correct here means the trial produced the right answer within its budget. A trial that exhausted budget or wall clock without answering counts as incorrect, because that is what it is in production. If you instead condition on trials that finished, flat Claude looks fine, which is exactly the accounting a fixed budget exists to prevent, and uncapped Gemini still got only 8 of 18 with genuinely wrong answers. The p values are two-proportion z-tests, per the registered plan; if you distrust the normal approximation at n=18, Fisher exact gives 0.0016 and 0.086 for the two tiered-vs-flat rows and changes no verdict.&lt;/p&gt;

&lt;p&gt;Tiered did not sweep, either. On the trace-shape task Claude's tiered arm went 1 of 3 while flat went 2 of 3, and both of the tiered arm's budget deaths landed there. Six tasks, honest variance.&lt;/p&gt;

&lt;p&gt;The result I keep looking at is the bottom half of the table: neither model cleared a significance bar against the other inside either arm, and at n=18 the test had little power to find one. What the observed numbers say is that swapping the model barely moved them and swapping the tool design doubled them. If you have a fixed budget to spend on making agents useful against your infrastructure, spend it on the tool design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost: cap the naive arm and it fails, uncap it and it's 29x
&lt;/h2&gt;

&lt;p&gt;The two CLIs disagreed about money in a useful way.&lt;/p&gt;

&lt;p&gt;Claude ran with a $0.50 budget per trial, same as arm 1. Nine of its eighteen flat trials burned the entire budget without producing an answer, and two more hit the ten minute wall clock. The tiered arm lost two trials to budget in the same 18. Under a cap, the flat design doesn't get to be expensive. It just fails.&lt;/p&gt;

&lt;p&gt;Gemini ran uncapped, and that shows you the bill the cap was hiding: the flat arm averaged 289,000 input tokens per trial against the tiered arm's 9,900. Twenty-nine times. The single worst trial pushed 2.0 million raw input tokens through the model across seven calls to answer one question about one error (the arm means above are cache-normalized; raw cumulative is the bill your provider sends). The tiered arm answered the same question correctly on about 13,000.&lt;/p&gt;

&lt;p&gt;Same problem, two ways to pay for it. Give the everything-tool a budget and it exhausts it. Remove the budget and it exhausts you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Behavior: they drill down, and mostly don't need the depths
&lt;/h2&gt;

&lt;p&gt;The claim at the center of the server's design is behavioral: given tiered tools, an agent will fetch structure before detail. I captured full tool-call trajectories for six supplementary tiered trials, one per task, run after the scored matrix against the same frozen store, and classified each: did a discovery call precede the first verbose call?&lt;/p&gt;

&lt;p&gt;Zero violations. Two trajectories drilled down exactly as designed, structure first, &lt;code&gt;get_trace_errors&lt;/code&gt; only after. The other four never made a verbose call at all. Topology, trace summaries, and span names were enough to answer the question. That's the strongest version of the result: for most of these tasks the expensive tier isn't just deferred, it's unnecessary. Two of the six also called &lt;code&gt;read_skill&lt;/code&gt; unprompted and pulled the embedded troubleshooting playbook before touching trace data, which I did not expect.&lt;/p&gt;

&lt;p&gt;Six trajectories is a small sample and I report it as one. But zero-for-six violations, on the exact behavior the instructions file asks for, is not nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two traps the losers fell into
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The error filter doesn't mean what the schema says it means.&lt;/strong&gt; Jaeger's &lt;code&gt;error=true&lt;/code&gt; search matches error spans belonging to the queried service only. Ask for failing traces of the &lt;code&gt;driver&lt;/code&gt; service and you get nothing, because the actual error status lives on &lt;code&gt;redis-manual&lt;/code&gt; spans inside driver's traces. I have exactly one clean measurement of this, from a captured trajectory: the same query with &lt;code&gt;with_errors: true&lt;/code&gt; returned an empty result, and without it returned 2,882 bytes including a trace that did contain error spans. That agent recovered by dropping the filter. Five of six Gemini trials on this task also answered "no failing traces", but that harness logged tool names without arguments, so I can't show the filter caused any of them, and three of the five never called search at all. One measurement, reported as one.&lt;/p&gt;

&lt;p&gt;The upstream part is worth more than the trap. Jaeger's MCP schema documents the flag as "If true only return traces containing error spans", which is trace-scoped language for span-scoped behavior. My own flat server describes it correctly, because I hit the confusion while building it and wrote the caveat into the tool description. That's the fixable thing, and it's one line of documentation, not a design argument.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defaults silently truncate.&lt;/strong&gt; The flat tool's &lt;code&gt;limit&lt;/code&gt; defaults to 20, a choice I made building it; jaeger-query's own API defaults to 100, which would have covered this entire fixture. The schema discloses the default. Gemini fetched it anyway, took the slowest trace of that sample, and analyzed the wrong trace with total confidence, twice. The complete set was one parameter away. Nothing in a raw dump tells you it's incomplete; a tiered summary tool that returns &lt;code&gt;total_count&lt;/code&gt; and &lt;code&gt;truncated&lt;/code&gt; fields does. That's post #1's schema lesson wearing a different hat.&lt;/p&gt;

&lt;h2&gt;
  
  
  My harness lied to me before the models could
&lt;/h2&gt;

&lt;p&gt;Confession section, as usual. The first full Claude run came back looking plausible and was garbage: a network blip during the run made the CLI return "API Error: Unable to connect" as if it were the model's answer, exit code zero, eighteen times. Every one of those would have been scored as the model getting the question wrong. I caught it because a 10-minute checkpoint probe showed a suspiciously fast, suspiciously uniform failure pattern. That run is quarantined in the repo with a note, the runner now detects and retries transport failures, and infrastructure errors are excluded from every denominator.&lt;/p&gt;

&lt;p&gt;The scorer needed three rounds of the same medicine. Reading real answers against verdicts caught it penalizing exemplary responses, ones that named non-callers precisely to exclude them ("no other service calls mysql directly"), and being fooled by the word "route" being both a service and a verb. Each false verdict is now a pinned regression test; the suite is at 116.&lt;/p&gt;

&lt;p&gt;This is the same lesson as &lt;a href="https://dev.to/lopster568/two-ceilings-taking-a-go-dns-server-from-500-to-9500-qps-2poj"&gt;the soak test that almost passed on an idle box&lt;/a&gt;: the instrument fails silently, the failure reads as a result, and the only defense is auditing the instrument against reality before believing it. A benchmark's biggest threat isn't the model gaming it. It's your own harness being politely wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not show
&lt;/h2&gt;

&lt;p&gt;The comparison bundles tool inventory with serialization format. The flat arm returns jaeger-query's classic JSON, which is chattier per attribute than the tiered tools' output structs, so this measures "the shipped tiered design versus a naive wrapper as people actually build one", not tool-count in isolation. Arm 1 isolated format on its own; I didn't re-isolate it here, and the design doc says so.&lt;/p&gt;

&lt;p&gt;Hotrod traces are small, tens of spans. At production sizes the flat arm's context pressure gets worse, not better, but that's extrapolation and I'm not claiming it. Six trajectories is descriptive, not inferential. Gemini's failure detection is heuristic because its CLI has no budget cap analogue. And n=18 per cell is honest for proportions and thin for everything subtler; the 18 are 6 tasks times 3 repeats, so they cluster by task rather than being 18 independent draws.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Tool inventory design moved correctness more than model choice did. 83% vs 39% between designs, no detectable model effect inside either arm at this n.&lt;/li&gt;
&lt;li&gt;The everything-tool makes you pick your failure mode: budget-capped it fails to answer, uncapped it costs 29x.&lt;/li&gt;
&lt;li&gt;Agents given tiered tools used them as tiered. Most tasks never needed the verbose tier at all.&lt;/li&gt;
&lt;li&gt;Empty results are ambiguous to agents. If your filter has surprising scoping, the agent will trust the empty set and report a confident wrong answer. Say what the filter means in the schema.&lt;/li&gt;
&lt;li&gt;Audit your harness like it's hostile. Mine dressed a network outage as eighteen model failures and had exit code zero while doing it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything is in &lt;a href="https://github.com/lopster568/jaeger-mcp-bench" rel="noopener noreferrer"&gt;the repo&lt;/a&gt;: pre-registered design with its amendment history in git, the flat server, the harness, raw trials including the quarantined run, and the tables. The tiered arm is a stock Jaeger image; reproducing this costs a docker pull, a seed, and API credits.&lt;/p&gt;

&lt;p&gt;The design I couldn't back up in my first post is now the measured one. That was the point of writing the claim down before testing it.&lt;/p&gt;




&lt;p&gt;I do this work for hire: benchmarking what your MCP tools actually cost agents, and building tool surfaces that hold up under measurement. Scope and pricing at &lt;a href="https://roshansingh.systems/#hire" rel="noopener noreferrer"&gt;roshansingh.systems/#hire&lt;/a&gt;, or write to &lt;a href="mailto:inbox@roshansingh.systems"&gt;inbox@roshansingh.systems&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>go</category>
      <category>observability</category>
    </item>
    <item>
      <title>What should an MCP tool return? I ran 72 trials instead of arguing</title>
      <dc:creator>Roshan Singh</dc:creator>
      <pubDate>Fri, 07 Aug 2026 17:41:39 +0000</pubDate>
      <link>https://dev.to/lopster568/what-should-an-mcp-tool-return-i-ran-72-trials-instead-of-arguing-43b4</link>
      <guid>https://dev.to/lopster568/what-should-an-mcp-tool-return-i-ran-72-trials-instead-of-arguing-43b4</guid>
      <description>&lt;p&gt;There's an argument running about MCP right now. You've probably seen it: a 400-point thread called "MCP is dead?" with real token numbers in it, four connected servers eating 21,077 tokens of context before anyone asks a question. The argument is about what MCP costs. Almost nobody in it has measured what agents actually do with the data a tool returns.&lt;/p&gt;

&lt;p&gt;I ended up measuring that, not because I planned to, but because a maintainer refused to let me guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question nobody wanted to answer with opinions
&lt;/h2&gt;

&lt;p&gt;I contribute to CNCF Jaeger's MCP server. Last April I proposed exposing service performance metrics (latencies, call rates, error rates) as an MCP tool, and hit an immediate design fork: what shape should the output be?&lt;/p&gt;

&lt;p&gt;Option one, summary rows: pre-aggregated stats per service, compact, cheap. Option two, per-bucket time series: the raw points, roughly 720 of them per service at default resolution, expensive but complete.&lt;/p&gt;

&lt;p&gt;I asked which the maintainer preferred. The answer, verbatim, from the issue thread:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This type of decision should not be based on opinion, but on benchmarks with a real agent troubleshooting some issues and using this MCP tool to access metrics, where you could do A/B testing of different output formats.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fair. So I built the A/B.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Everything below is public in &lt;a href="https://github.com/lopster568/jaeger-mcp-bench" rel="noopener noreferrer"&gt;jaeger-mcp-bench&lt;/a&gt;, including the harness, the tasks, the scorer, and a research log of everything that went wrong.&lt;/p&gt;

&lt;p&gt;The fixture is Jaeger v2 with the spanmetrics connector, hotrod generating traffic, and Prometheus behind it, snapshotted so every run sees identical metric state. In front of the metrics API sits a thin bench server with exactly one switch: &lt;code&gt;--format=summary|series&lt;/code&gt;. No new semantics, just the shape of what comes back.&lt;/p&gt;

&lt;p&gt;Six troubleshooting tasks, and this part matters: three were chosen because I predicted summary would win them (point questions: current latency, ranking, threshold checks) and three because I predicted series would win (temporal questions: spike detection, correlation, trend). Designing the tasks to favor both arms keeps the deck unstacked against your own hypothesis.&lt;/p&gt;

&lt;p&gt;Two agents, and not stripped-down tool loops: Claude Sonnet through the Claude Code CLI and Gemini 2.5 Pro through the gemini CLI, real system prompts and all, because that's what production agents actually are. Three trials per cell, 72 trials total, cells run in randomized order (seed 42) so fixture drift can't correlate with either arm. Scoring is programmatic against ground truth, not vibes.&lt;/p&gt;

&lt;p&gt;Note: Every trial ran headless: claude -p and the gemini CLI invoked one shot per trial, no session and no one to rephrase, so a decline ended the trial rather than starting a retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 72 trials said
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;correct&lt;/th&gt;
&lt;th&gt;wrong&lt;/th&gt;
&lt;th&gt;declined&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;claude / series&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini / series&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude / summary&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini / summary&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The result I expected was wrong answers. Bad format, wrong conclusions, embarrassing agent behavior, good story.&lt;/p&gt;

&lt;p&gt;That is not what happens. Across 72 trials there was exactly one wrong commitment, and it traced to a bug in my own bench server, not to the format (disclosed in RESULTS.md; it biased against summary, and I'd rather report my own bug than a fake finding).&lt;/p&gt;

&lt;p&gt;The real difference lives in the decline rate. Given summary rows, agents said some version of "I cannot determine this from the available data" &lt;strong&gt;seven times more often&lt;/strong&gt; than with series, almost entirely on the temporal questions. And they were right to decline: aggregation had destroyed the time axis the question needed. You cannot localize a spike in an average.&lt;/p&gt;

&lt;p&gt;Agents don't get it wrong when you underfeed them. They give up, politely and correctly.&lt;/p&gt;

&lt;p&gt;The stats hold up under correction: Claude's summary-versus-series gap is significant after Bonferroni (p=0.001 against a 0.0125 alpha). Gemini's, at p=0.016, sits right on the boundary, and I'd rather say that than round it in my favor. Point queries were a wash, exactly as predicted: when the question needs one number, format doesn't matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  So series won. But that's not the useful lesson
&lt;/h2&gt;

&lt;p&gt;The tool shipped with per-bucket series as the decision, backed by data instead of my taste. Fine.&lt;/p&gt;

&lt;p&gt;The useful lesson is about what "expensive output" actually buys. The whole MCP cost argument treats tokens as waste: big responses bad, small responses good. The benchmark says the relationship is more specific than that. A compact format is cheap right up until it deletes the axis a question depends on, and then its cost is not tokens, it's the agent declining to answer at all. A "cannot determine" costs you the entire investigation loop, plus the retry, plus whatever the human does when the agent shrugs.&lt;/p&gt;

&lt;p&gt;Put differently: the token bill of series output is visible and easy to complain about. The failure bill of summary output is invisible until you measure decline rates, and nobody measures decline rates.&lt;/p&gt;

&lt;p&gt;If you're designing an MCP tool right now, the transferable version is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Match the format to the question class, not to the token budget. Point questions tolerate aggregation. Temporal and causal questions do not.&lt;/li&gt;
&lt;li&gt;Watch declines, not just errors. Models fail politely. Your error dashboards won't see it.&lt;/li&gt;
&lt;li&gt;Balance your benchmark against your own prediction, or you'll build a deck stacked toward whatever you already believed.&lt;/li&gt;
&lt;li&gt;When your bench produces a result you like, hunt for the bug first. Mine had one, and it flattered the winning arm.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;Seventy-two trials is three per cell. Two model families, one fixture, six tasks, all in the observability domain. This settles which format a metrics tool in a tracing backend should return; it does not settle MCP philosophy. If you run the harness against other domains I'd genuinely like to see the numbers, and everything needed to do that is in the repo.&lt;/p&gt;

&lt;p&gt;The maintainer was right, is the short version. The format decision took a weekend to benchmark and would have taken zero minutes to argue about forever.&lt;/p&gt;




&lt;p&gt;I do this work for hire, mostly making LLM agents safe and observable against production infrastructure. Scope and pricing are at &lt;a href="https://roshansingh.systems/#hire" rel="noopener noreferrer"&gt;roshansingh.systems/#hire&lt;/a&gt;, or write to &lt;a href="mailto:inbox@roshansingh.systems"&gt;inbox@roshansingh.systems&lt;/a&gt; and tell me what your agents touch.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>llm</category>
      <category>benchmark</category>
      <category>ai</category>
    </item>
    <item>
      <title>The Bloom filter that never existed, and the two ceilings it was hiding</title>
      <dc:creator>Roshan Singh</dc:creator>
      <pubDate>Fri, 31 Jul 2026 09:41:30 +0000</pubDate>
      <link>https://dev.to/lopster568/the-bloom-filter-that-never-existed-and-the-two-ceilings-it-was-hiding-2opo</link>
      <guid>https://dev.to/lopster568/the-bloom-filter-that-never-existed-and-the-two-ceilings-it-was-hiding-2opo</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The most expensive bug I fixed this year was not in the code. It was in the documentation, and it had been shaping what everyone believed the code did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;HydraDNS is an open-source DNS security gateway I build in Go. Router points at it, it filters every DNS query on the network against a 92k-domain blocklist, blocks the bad ones, forwards the rest. Before putting it on anyone else's network I wanted a real number for what one box could take, so I sat down with dnspyre and a rule I had written for myself: every number becomes a sales claim or a fix ticket. No number, no claim.&lt;/p&gt;

&lt;p&gt;Our feature sheet said the blocklist was backed by a Bloom filter, sub-millisecond lookups. Here is the uncomfortable part: at every load this system had ever run, that claim was indistinguishable from the truth. Normal-traffic latency sat at one or two milliseconds. There was nothing to doubt, because nothing observable disagreed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first ceiling
&lt;/h2&gt;

&lt;p&gt;The redline test capped at about 500 queries per second. Odd, but fine, until I noticed the cap would not move. Blocked queries capped at ~500. Cached queries that never touch upstream also capped at ~500. Two paths doing completely different work, same wall, CPU sitting under 30% on a 22-core dev machine.&lt;/p&gt;

&lt;p&gt;That combination is worth memorizing: when two very different code paths hit the same ceiling and the CPU is bored, the bottleneck is not in either path. It is in something they share.&lt;/p&gt;

&lt;p&gt;Ours was the blocklist check. &lt;code&gt;IsBlocked&lt;/code&gt; ran a SQL &lt;code&gt;COUNT&lt;/code&gt; against the 92k-row table on every single query, because the check sits in front of the cache, so even cache hits paid for it. Every one of those reads was serialized through a single SQLite connection, &lt;code&gt;MaxOpenConns=1&lt;/code&gt;, which was also absorbing the async write traffic from query logging. Engine self-latency under load: p50 of 50ms, p99 of five full seconds. For DNS.&lt;/p&gt;

&lt;p&gt;And the Bloom filter? I went looking for it so I could tune it, and found a misattribution rather than a fabrication. The codebase does have a Bloom filter, a real one with tests, in the policy engine next door. Somewhere across three documents, "the policy engine uses a Bloom filter" drifted into "the blocklist uses a Bloom filter" and started reading like established fact. Every sanity check, mine included, recognized a component that genuinely exists and moved on.&lt;/p&gt;

&lt;p&gt;Here is what makes this class of bug nasty: reading the blocklist code would not have flagged it either. The SQL path was correct code doing exactly what it claimed. Code review asks whether an implementation is right, and it was. No review asks whether an implementation matches an adjective in a feature sheet. The only reviewer that can ask that question is a measurement at redline, and that is precisely why the stress plan existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, which is embarrassingly boring
&lt;/h2&gt;

&lt;p&gt;I built the thing the docs had been imagining, minus the cleverness: &lt;code&gt;MemoryChecker&lt;/code&gt;, an atomic in-memory domain set, loaded at startup, swapped wholesale on the existing 6-hour blocklist refresh. The parent-domain walk (block &lt;code&gt;example.com&lt;/code&gt;, catch &lt;code&gt;ads.example.com&lt;/code&gt;) reproduces the exact candidate set the SQL version checked, so behavior stayed identical. Only the cost changed.&lt;/p&gt;

&lt;p&gt;Throughput: ~500 to ~9,500 QPS. Nineteen times, from taking one membership check off the database. Not a probabilistic data structure. A map.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second ceiling was waiting behind the first
&lt;/h2&gt;

&lt;p&gt;Re-ran the redline. The box got fast, then started eating memory: RSS climbed from 98MiB to a little over 2GB as load went to 10,000 QPS. It drained back when load stopped, so not a leak, a backlog. But the target hardware for this thing has 2 to 4GB of RAM total. That transient is an OOM kill in the middle of a traffic burst, which is the exact moment a DNS server must not die.&lt;/p&gt;

&lt;p&gt;Query logging spawned a goroutine per query, each doing an INSERT plus a stats UPDATE through that same single connection. At 500 QPS the queue drained and nobody noticed. At 10,000, goroutines piled up faster than one connection could ever clear them. The first bottleneck had been rationing the second. Lift one ceiling and the next bomb arms itself.&lt;/p&gt;

&lt;p&gt;The replacement: one writer goroutine, a bounded channel of 4,096 entries, batches flushed at 256 entries or 500ms, bulk INSERT plus one aggregated stats UPDATE per batch. Enqueue is non-blocking, and when the buffer is full the entry is dropped and the drop is counted, because losing a log line is acceptable for a DNS server and stalling resolution is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Throughput ceiling:   ~500  -&amp;gt; ~9,500 QPS   (19x)
Engine p99:           5000ms -&amp;gt; 20ms
RSS at 10k QPS:       2,063MiB -&amp;gt; ~85MiB
CPU at 10k QPS:       56% -&amp;gt; 14%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirmed with a soak: 248,166 queries at 1,379 QPS, zero engine errors, memory a bounded sawtooth instead of a climb. All numbers from a 22-core dev machine with load generated in-container, and I label them that way everywhere, because the other lesson of this story is what happens to unverified claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Same ceiling on two different paths plus an idle CPU means stop optimizing the paths and find what they share.&lt;/li&gt;
&lt;li&gt;Fixing one bottleneck unmasks the next. The box's real limit is wherever you stop looking.&lt;/li&gt;
&lt;li&gt;Unbounded "async" is not async, it is an OOM with a delay on it. Bound every queue and count every drop.&lt;/li&gt;
&lt;li&gt;The one I keep thinking about: some claims are empirically true at every load you have run and false at the load you have not. They survive code review, doc review, and months of correct operation, because nothing observable disagrees with them. The only experiment that can falsify them is a redline test, which is what a stress plan is actually for. Not big numbers for a landing page. Falsification.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The longer write-up, with the full latency data and the test harness bug that almost let the soak pass on an idle server, is here: &lt;a href="https://dev.to/lopster568/two-ceilings-taking-a-go-dns-server-from-500-to-9500-qps-2poj"&gt;https://dev.to/lopster568/two-ceilings-taking-a-go-dns-server-from-500-to-9500-qps-2poj&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>go</category>
      <category>devbugsmash</category>
      <category>bugsmash</category>
    </item>
    <item>
      <title>Two ceilings: taking a Go DNS server from 500 to 9,500 QPS</title>
      <dc:creator>Roshan Singh</dc:creator>
      <pubDate>Wed, 29 Jul 2026 06:13:42 +0000</pubDate>
      <link>https://dev.to/lopster568/two-ceilings-taking-a-go-dns-server-from-500-to-9500-qps-2poj</link>
      <guid>https://dev.to/lopster568/two-ceilings-taking-a-go-dns-server-from-500-to-9500-qps-2poj</guid>
      <description>&lt;p&gt;I run HydraDNS, an open-source DNS security gateway in Go. Last month I sat down to find out what one box could actually handle before I put it on anyone else's network. The plan had a rule I'd written for myself: every number we discover becomes either a sales claim or a fix ticket. No number, no claim.&lt;/p&gt;

&lt;p&gt;I expected to find one bottleneck. I found two, stacked on top of each other, and a third thing I wasn't looking for: a data structure in our own documentation that had never existed in the code.&lt;/p&gt;

&lt;p&gt;Everything below was measured on a 22-core dev machine with load generated inside the container, using dnspyre, so docker-proxy and host networking stay out of the numbers. It's not appliance hardware and I'm not making appliance claims. The shapes are what matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first ceiling: ~500 QPS, and it didn't care what I threw at it
&lt;/h2&gt;

&lt;p&gt;The first redline run capped at roughly 500 queries per second. Fine, servers have limits. What made it interesting was that the cap didn't move. Blocked queries: ~500. Cached queries that never touch upstream: ~500. Two code paths that do completely different work, hitting the same wall, with the CPU sitting under 30% of 22 cores.&lt;/p&gt;

&lt;p&gt;That signature is worth memorizing. When two very different paths hit the &lt;em&gt;same&lt;/em&gt; ceiling and the CPU is bored, the bottleneck isn't in either path. It's in something they share, or something upstream of both.&lt;/p&gt;

&lt;p&gt;Ours was in the blocklist check. &lt;code&gt;IsBlocked&lt;/code&gt; ran a SQL &lt;code&gt;COUNT&lt;/code&gt; against a 92k-row &lt;code&gt;blocklist_entries&lt;/code&gt; table on &lt;strong&gt;every query&lt;/strong&gt;. Not just candidate blocks, every query, because the check sits in front of the cache, so even cache hits paid for it. And all of those reads were serialized through a single SQLite connection, &lt;code&gt;MaxOpenConns=1&lt;/code&gt;, which was also absorbing the async write traffic from query logging.&lt;/p&gt;

&lt;p&gt;The engine's self-measured latency under load: p50 of 50ms, p99 of 5000ms. Five full seconds at the tail, for DNS, which is supposed to be the fast part of the internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where I found out our docs were lying
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable bit. Our feature sheet, and two other internal docs, said the blocklist was backed by a Bloom filter, sub-millisecond membership checks. And at every load this system had ever run, that claim was indistinguishable from the truth: normal-traffic latency sat at one or two milliseconds. Nothing observable disagreed with it.&lt;/p&gt;

&lt;p&gt;What I found was a misattribution, not a fabrication. The codebase does have a Bloom filter, a real one with tests, in the policy engine next door. Somewhere across three documents, "the policy engine uses a Bloom filter" drifted into "the blocklist uses a Bloom filter," and a claim about the wrong component started reading like established fact. Every sanity check, mine included, recognized a component that genuinely exists and moved on.&lt;/p&gt;

&lt;p&gt;The nasty part is that reading the blocklist code would not have caught it either. The SQL path was correct code doing exactly what it claimed, and code review asks whether an implementation is right, not whether it matches an adjective in a feature sheet. The only reviewer that can ask that question is a measurement at redline, which is exactly what this stress test was for. It forced me to look at what the hot path actually did, and what it did was a table scan's worth of SQL per packet.&lt;/p&gt;

&lt;p&gt;So the fix wasn't "tune the Bloom filter." The fix was to build the in-memory layer the docs had been imagining. &lt;code&gt;MemoryChecker&lt;/code&gt;: an atomic in-memory domain set, loaded at startup, swapped wholesale on the existing 6-hour blocklist refresh. The parent-domain walk (block &lt;code&gt;example.com&lt;/code&gt;, and &lt;code&gt;ads.example.com&lt;/code&gt; matches too) reproduces exactly the candidate set the SQL version checked, so behavior didn't change, just the cost.&lt;/p&gt;

&lt;p&gt;Throughput went from ~500 to ~9,500 QPS. Nineteen times, from moving one membership check off the database. Not a clever data structure. A map, held in memory, reloaded every six hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second ceiling was hiding behind the first
&lt;/h2&gt;

&lt;p&gt;With reads fixed, I re-ran the redline, and the box got fast and then started eating memory. RSS climbed from 98MiB to a bit over 2GB as offered load went from 500 to 10,000 QPS.&lt;/p&gt;

&lt;p&gt;It drained back to normal when the load stopped, so it wasn't a leak, it was a backlog. But a 2GB transient spike is academic only until you remember the target hardware has 2 to 4GB of RAM total. On the appliance this is an OOM kill in the middle of a traffic burst, which is the exact moment you don't want your DNS to die.&lt;/p&gt;

&lt;p&gt;The cause: query logging spawned a goroutine per query, and each one did an INSERT plus a stats UPDATE through that same single SQLite connection. At 500 QPS the queue drained fine and nobody noticed. At 10,000 QPS, goroutines piled up faster than one connection could ever clear them. The first bottleneck had been rationing the second one. Lift the read ceiling and the write bomb goes off.&lt;/p&gt;

&lt;p&gt;"Async logging" is only safe if it's bounded. Unbounded goroutine-per-event isn't async, it's a memory spike with extra steps, and it just moves the failure from slow to OOM.&lt;/p&gt;

&lt;p&gt;The replacement is &lt;code&gt;querylog_writer.go&lt;/code&gt;: one writer goroutine, a bounded channel of 4,096 entries, batches flushed at 256 entries or 500ms, bulk INSERT plus a single aggregated stats UPDATE per batch. Enqueue is non-blocking. If the buffer is full, the entry is dropped and the drop is counted, because losing a log line is an acceptable failure for a DNS server and stalling resolution is not.&lt;/p&gt;

&lt;p&gt;After: RSS flat at ~85MiB from 500 to 10,000 QPS. Engine latency p50 5ms, p99 20ms. CPU at 10k QPS dropped from 56% to 14%, because it turns out scheduling tens of thousands of goroutines was itself a workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, before and after
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Throughput ceiling:   ~500  -&amp;gt; ~9,500 QPS   (19x)
Engine p99:           5000ms -&amp;gt; 20ms
RSS at 10k QPS:       2,063MiB -&amp;gt; ~85MiB
CPU at 10k QPS:       56% -&amp;gt; 14%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a 3-minute soak to confirm the shape holds: 248,166 queries at 1,379 QPS, zero engine errors, RSS a bounded sawtooth (119 to 139 to 114MiB as GC does its thing) instead of the old monotonic climb.&lt;/p&gt;

&lt;h2&gt;
  
  
  The soak that almost passed on an idle box
&lt;/h2&gt;

&lt;p&gt;One more confession, because it's the most useful lesson in here. My first soak attempt reported beautifully. Flat memory, no errors, perfect.&lt;/p&gt;

&lt;p&gt;It had also sent zero queries. Deploying the retention change had recreated the container, which wiped the dnspyre binary inside it, and the &lt;code&gt;docker exec&lt;/code&gt; failures were being swallowed by a &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; I'd added earlier. The harness "ran," measured an idle server, and called it healthy. I caught it only because the sent-count looked suspiciously round: zero.&lt;/p&gt;

&lt;p&gt;If your load generator's failure mode is silence, your benchmark's failure mode is a false pass. Now the harness asserts the sent count before it's allowed to report anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take to your codebase
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Same ceiling on two different paths plus idle CPU means the bottleneck is shared or upstream. Stop optimizing the paths.&lt;/li&gt;
&lt;li&gt;Fixing one bottleneck unmasks the next. The box's real limit is wherever you stop looking.&lt;/li&gt;
&lt;li&gt;Bound every async path. A queue without a cap is an OOM with a delay on it.&lt;/li&gt;
&lt;li&gt;Dropping data beats stalling the hot path, if you count the drops.&lt;/li&gt;
&lt;li&gt;Some claims are true at every load you have run and false at the load you have not. They survive code review and months of correct operation because nothing observable disagrees with them. The only experiment that falsifies them is a redline test. That is what a stress plan is for.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last one is the one I keep thinking about. The expensive bug wasn't in the code. It was in the docs, quietly shaping what everyone believed the code did.&lt;/p&gt;




&lt;p&gt;I do this work for hire, mostly making LLM agents safe and observable against production infrastructure, and occasionally chasing a database off a hot path. Scope and pricing are at &lt;a href="https://roshansingh.systems/#hire" rel="noopener noreferrer"&gt;roshansingh.systems/#hire&lt;/a&gt;, or write to &lt;a href="mailto:inbox@roshansingh.systems"&gt;inbox@roshansingh.systems&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>go</category>
      <category>performance</category>
      <category>dns</category>
      <category>programming</category>
    </item>
    <item>
      <title>Building Jaeger's MCP server: connecting LLMs to distributed traces</title>
      <dc:creator>Roshan Singh</dc:creator>
      <pubDate>Tue, 28 Jul 2026 13:40:00 +0000</pubDate>
      <link>https://dev.to/lopster568/building-jaegers-mcp-server-connecting-llms-to-distributed-traces-2jj6</link>
      <guid>https://dev.to/lopster568/building-jaegers-mcp-server-connecting-llms-to-distributed-traces-2jj6</guid>
      <description>&lt;p&gt;A distributed trace is one of the few places where a system tells you the truth about itself. It records what actually called what, in what order, and how long each hop took. It is also, for a human being at 2am, a wall of a thousand spans.&lt;/p&gt;

&lt;p&gt;That combination makes tracing data an unusually good target for an LLM. The data is structured, causal, and too large to read by hand. Over the last few months I shipped seven merged PRs into &lt;a href="https://github.com/jaegertracing/jaeger" rel="noopener noreferrer"&gt;CNCF Jaeger's&lt;/a&gt; MCP server, the component that lets an LLM query traces as a first-class tool rather than by scraping the UI.&lt;/p&gt;

&lt;p&gt;This post is about how that server is built in Go, and about the one design lesson that took me three PRs and a maintainer's review to actually internalize.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server is
&lt;/h2&gt;

&lt;p&gt;The Model Context Protocol (MCP) is a way to hand an LLM a typed set of tools with schemas, instead of an API and hope. Jaeger's implementation lives in the main Go binary as a package that wraps the existing &lt;code&gt;QueryService&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Package mcptools provides the Jaeger telemetry MCP tools as a reusable&lt;/span&gt;
&lt;span class="c"&gt;// library. The tools wrap a *querysvc.QueryService.&lt;/span&gt;
&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;mcptools&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;NewServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;telset&lt;/span&gt; &lt;span class="n"&gt;telemetry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queryAPI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;querysvc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Implementation&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServerVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServerOptions&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Instructions&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;serverInstructions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;registerTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queryAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddReceivingMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mw&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are worth pulling out of that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It takes a &lt;code&gt;*querysvc.QueryService&lt;/code&gt; directly, not a storage handle.&lt;/strong&gt; The MCP tools do not reach into Cassandra or Elasticsearch. They go through the same query layer the Jaeger UI uses, which means tenancy, storage backends, and query semantics are all inherited for free. This was a maintainer correction early on: the original ADR had the extension depending on &lt;code&gt;jaegerstorage&lt;/code&gt;, and it was changed to depend on the query service instead. Wrapping the layer that already has the business logic is almost always the right call for an MCP server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instructions are embedded, not configured.&lt;/strong&gt; &lt;code&gt;serverInstructions&lt;/code&gt; is a &lt;code&gt;//go:embed INSTRUCTIONS.md&lt;/code&gt; string handed to &lt;code&gt;ServerOptions.Instructions&lt;/code&gt;, which the SDK returns during the MCP &lt;code&gt;initialize&lt;/code&gt; handshake. The LLM client receives the server's usage guidance automatically. Nobody has to paste anything into a config file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Middleware is the observability seam.&lt;/strong&gt; The server registers receiving middleware for tracing and metrics. There is a pleasing recursion in a tracing backend emitting traces about the tool calls an agent makes against it, and it turns out to be genuinely useful for debugging agent behavior.&lt;/p&gt;

&lt;p&gt;The transport is streamable HTTP, wrapped with tenancy extraction and &lt;code&gt;otelhttp&lt;/code&gt;, and mounted on an existing mux rather than binding its own listener:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;WrapHTTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenancyMgr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;tenancy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Manager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;telset&lt;/span&gt; &lt;span class="n"&gt;telemetry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;mcpHandler&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewStreamableHTTPHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StreamableHTTPOptions&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;JSONResponse&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// Use SSE for streamed events&lt;/span&gt;
            &lt;span class="n"&gt;Stateless&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// Session state management&lt;/span&gt;
            &lt;span class="n"&gt;SessionTimeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mcpSessionTimeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tenantHandler&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tenancy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExtractTenantHTTPHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenancyMgr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mcpHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;otelhttp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;tenantHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"jaeger_mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;otelhttp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTracerProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;telset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TracerProvider&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One server instance is reused across sessions. With &lt;code&gt;Stateless: false&lt;/code&gt; the SDK builds one &lt;code&gt;ServerSession&lt;/code&gt; per MCP session and reuses it for that session's requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tools, and why they are shaped that way
&lt;/h2&gt;

&lt;p&gt;The server currently registers nine tools. The interesting part is not the list, it is that the list is deliberately tiered by cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;get_services&lt;/code&gt;, &lt;code&gt;get_span_names&lt;/code&gt; - cheap discovery&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;search_traces&lt;/code&gt; - lightweight summaries, no spans or attributes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_trace_topology&lt;/code&gt; - trace structure as a flat depth-first list, no attributes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_critical_path&lt;/code&gt; - the chain of spans that determined end-to-end duration&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_trace_errors&lt;/code&gt;, &lt;code&gt;get_span_details&lt;/code&gt; - verbose, full OTLP data&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_service_dependencies&lt;/code&gt; - the service graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;read_skill&lt;/code&gt; - built-in analysis playbooks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is progressive disclosure, and it exists for one reason: context window economy. A topology span is roughly 185 bytes. A full span detail is roughly 1KB. When I measured that during the instructions work, the ratio was about 6x. An agent that calls &lt;code&gt;get_span_details&lt;/code&gt; on every span in a 500-span trace burns its entire context before it has formed a hypothesis. An agent that starts with topology, finds the critical path, and then fetches details for four spans, answers the question.&lt;/p&gt;

&lt;p&gt;So the whole design problem becomes: &lt;strong&gt;how do you get the LLM to drill down instead of dumping?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My first answer was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson: the schema is the interface, not the prompt
&lt;/h2&gt;

&lt;p&gt;I opened &lt;a href="https://github.com/jaegertracing/jaeger/pull/8253" rel="noopener noreferrer"&gt;PR #8253&lt;/a&gt; to add an &lt;code&gt;INSTRUCTIONS.md&lt;/code&gt; system prompt. My draft was thorough in the way that draft documentation is thorough: a tool reference table, investigation patterns, a constraints section, a system limits section. It explained the drill-down order carefully. It was, I thought, the fix.&lt;/p&gt;

&lt;p&gt;The maintainer review pushed back on the size, and I trimmed it. Then came the comment that actually mattered:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;we could improve some tool descriptions to be more explicit about the limits and truncation. For example [...] The &lt;code&gt;ErrorCount&lt;/code&gt; could be renamed to &lt;code&gt;TotalErrorCount&lt;/code&gt; and the &lt;code&gt;Spans&lt;/code&gt; list could say (possibly truncated to MaxXyz size)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That reframed the problem. I had been writing a document &lt;em&gt;about&lt;/em&gt; the tools, sitting outside them, that an agent may or may not have in context by the time it decides what to call. The information belonged &lt;em&gt;in the tools&lt;/em&gt;, in the schema, at the exact point of decision.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;INSTRUCTIONS.md&lt;/code&gt; shipped at five lines. Here is essentially all of it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Jaeger is a distributed tracing backend. A trace is a tree of spans representing a request or workflow [...] These tools support progressive disclosure to manage context density. While they can be called in any order based on available data, prefer starting with broad discovery (&lt;code&gt;get_services&lt;/code&gt; or &lt;code&gt;search_traces&lt;/code&gt;) or structural overviews (&lt;code&gt;get_trace_topology&lt;/code&gt;) before requesting verbose OTLP details for specific spans.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Everything else moved into the schemas. The trace errors output went from a bare &lt;code&gt;ErrorCount&lt;/code&gt; to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;GetTraceErrorsOutput&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;TraceID&lt;/span&gt;         &lt;span class="kt"&gt;string&lt;/span&gt;       &lt;span class="s"&gt;`json:"trace_id" jsonschema:"Unique identifier for the trace"`&lt;/span&gt;
    &lt;span class="n"&gt;TotalErrorCount&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;          &lt;span class="s"&gt;`json:"total_error_count" jsonschema:"Total number of error spans in the trace (may exceed the size of the spans list due to per-request limits)"`&lt;/span&gt;
    &lt;span class="n"&gt;Spans&lt;/span&gt;           &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;SpanDetail&lt;/span&gt; &lt;span class="s"&gt;`json:"spans,omitempty" jsonschema:"Error span details (possibly truncated to server-configured limit)"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that as an agent. &lt;code&gt;total_error_count&lt;/code&gt; tells you there were 40 errors. The &lt;code&gt;spans&lt;/code&gt; schema tells you the list you are holding may be truncated. The agent can now &lt;em&gt;detect its own truncation&lt;/em&gt; by comparing the two numbers, without being told to in a prompt it might not be attending to.&lt;/p&gt;

&lt;p&gt;I followed up in &lt;a href="https://github.com/jaegertracing/jaeger/pull/8314" rel="noopener noreferrer"&gt;PR #8314&lt;/a&gt; by rewriting all the tool descriptions on the same principle. Each one now states what the tool does, what distinguishes its output, and the runtime behavior the schema cannot express:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mcpServer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"get_trace_topology"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Get the structural overview of a trace as a flat, depth-first span list. "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s"&gt;"Each span includes a 'path' field encoding ancestry as slash-delimited span IDs "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s"&gt;"(e.g. 'rootID/parentID/spanID'). "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s"&gt;"Does NOT include attributes, events, or links."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;handlers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewGetTraceTopologyHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queryAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxSpanDetailsPerRequest&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what is &lt;em&gt;not&lt;/em&gt; there: no workflow ordering, no "call this before that." The input and output schemas already encode the dependencies. Sequencing instructions in a description are a smell that your schema is underspecified.&lt;/p&gt;

&lt;p&gt;The generalizable rule: &lt;strong&gt;a tool description is read at the moment of the decision it governs. A system prompt is read once, at the start, and competes with everything since.&lt;/strong&gt; Put the guidance where the decision happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a tool, end to end
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;get_service_dependencies&lt;/code&gt; (&lt;a href="https://github.com/jaegertracing/jaeger/pull/8403" rel="noopener noreferrer"&gt;PR #8403&lt;/a&gt;) is a clean example of the whole shape. &lt;code&gt;QueryService.GetDependencies()&lt;/code&gt; was the only query method with no MCP tool, so an agent asking "what does payment-service call?" had to fetch traces and rebuild the graph by hand, even though Jaeger already computes and stores it.&lt;/p&gt;

&lt;p&gt;The types come first, because in MCP the types &lt;em&gt;are&lt;/em&gt; the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;GetDependenciesInput&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;StartTime&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"start_time,omitempty" jsonschema:"Start of time range (RFC3339 or relative like -24h). Default: -24h"`&lt;/span&gt;
    &lt;span class="n"&gt;EndTime&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"end_time,omitempty" jsonschema:"End of time range (RFC3339 or relative like now). Default: now"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;DependencyLink&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Caller&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"caller" jsonschema:"Calling service name"`&lt;/span&gt;
    &lt;span class="n"&gt;Callee&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"callee" jsonschema:"Called service name"`&lt;/span&gt;
    &lt;span class="n"&gt;CallCount&lt;/span&gt; &lt;span class="kt"&gt;uint64&lt;/span&gt; &lt;span class="s"&gt;`json:"call_count" jsonschema:"Number of calls from caller to callee in the time window"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that matter more than they look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accept relative time.&lt;/strong&gt; &lt;code&gt;-24h&lt;/code&gt; and &lt;code&gt;now&lt;/code&gt; are what an LLM naturally produces when a user says "the last day." Forcing RFC3339 only means the model has to compute timestamps, which it does badly, and now you own a class of bugs where the agent silently queries the wrong window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sort the output.&lt;/strong&gt; The handler sorts by caller then callee:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;slices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SortFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;links&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DependencyLink&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;cmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Caller&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Callee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Callee&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deterministic output is not cosmetic when the consumer is a language model. Map iteration order in Go is randomized, so an unsorted response gives a different token sequence for identical data on every call. That defeats prompt caching and makes agent behavior non-reproducible when you are trying to debug it. I hit the same issue in &lt;a href="https://github.com/jaegertracing/jaeger/pull/8339" rel="noopener noreferrer"&gt;PR #8339&lt;/a&gt;, where &lt;code&gt;search_traces&lt;/code&gt; was computing a set of service names to derive &lt;code&gt;service_count&lt;/code&gt; and then throwing the names away. Surfacing them (sorted) removed an entire round of follow-up tool calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing that it actually speaks MCP
&lt;/h2&gt;

&lt;p&gt;The existing tests posted hand-rolled JSON-RPC payloads over HTTP. That verifies your handlers. It does not verify that you implemented the protocol, because you are also the one writing the request.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/jaegertracing/jaeger/pull/8230" rel="noopener noreferrer"&gt;PR #8230&lt;/a&gt; added integration tests that drive the server through the real MCP Go SDK client (&lt;code&gt;mcp.NewClient&lt;/code&gt; with &lt;code&gt;StreamableClientTransport&lt;/code&gt;) against mock storage: protocol negotiation, &lt;code&gt;tools/list&lt;/code&gt; discovery, typed invocation of every tool, concurrent session independence, and error paths.&lt;/p&gt;

&lt;p&gt;The most valuable test is the one that walks the intended workflow end to end: services, then search, then topology, then critical path, then details. It is the only test that would fail if progressive disclosure quietly broke.&lt;/p&gt;

&lt;p&gt;Two things I would tell anyone writing these:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert on the specific error, not just that one occurred.&lt;/strong&gt; A test that only checks &lt;code&gt;err != nil&lt;/code&gt; passes when your transport dies for unrelated reasons. Review caught exactly this in my missing-required-field test, which would have gone green on a session timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Empty results must serialize as &lt;code&gt;[]&lt;/code&gt;, not &lt;code&gt;null&lt;/code&gt;.&lt;/strong&gt; A &lt;code&gt;null&lt;/code&gt; where an agent expects a list is a parse error or, worse, a hallucinated recovery. It is worth a dedicated test.&lt;/p&gt;

&lt;p&gt;Later, &lt;a href="https://github.com/jaegertracing/jaeger/pull/8383" rel="noopener noreferrer"&gt;PR #8383&lt;/a&gt; added end-to-end tracing tests: create a root span, format it as a W3C traceparent, send it through &lt;code&gt;CallToolParams.Meta&lt;/code&gt;, and assert the middleware span has a matching TraceID and parent SpanID. That verifies trace context actually propagates from an MCP client into the server's own spans, which is what makes agent tool calls debuggable in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take to your own MCP server
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wrap your service layer, not your storage.&lt;/strong&gt; You inherit auth, tenancy, and semantics rather than reimplementing them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier your tools by output size&lt;/strong&gt; and say so in the descriptions. Context window is the scarce resource.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put guidance in schemas, not system prompts.&lt;/strong&gt; The description is read at the decision point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make truncation self-evident.&lt;/strong&gt; Return a total alongside a possibly-truncated list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sort everything.&lt;/strong&gt; Deterministic output is a correctness property when the consumer is a model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accept the time formats an LLM actually emits.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test through a real SDK client&lt;/strong&gt;, and test the intended workflow as a single path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code is all public, in &lt;code&gt;cmd/jaeger/internal/extension/jaegerquery/internal/mcptools/&lt;/code&gt;. It has moved since I started (it was its own &lt;code&gt;jaegermcp&lt;/code&gt; extension before being folded into the query extension), which is its own small lesson about contributing to an active CNCF project: write the tests well and the code survives the refactor.&lt;/p&gt;

&lt;p&gt;Additionally,&lt;br&gt;
I later benchmarked this question properly: &lt;a href="https://github.com/lopster568/jaeger-mcp-bench" rel="noopener noreferrer"&gt;jaeger-mcp-bench&lt;/a&gt;, 72 randomized trials across Claude and Gemini.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;I do this work for hire.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I make LLM agents safe and observable when they touch production infrastructure. Two things I sell, both fixed-scope:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCP server for your Go service&lt;/strong&gt;: tool design, schemas models actually call correctly, OAuth, tracing, and integration tests that prove it works. ~2 weeks, from $3,000.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent tool-call audit&lt;/strong&gt;: what your agents can actually reach, where the write paths are, what is untraced, and the blast radius if a model gets it wrong. Written report in 7 business days.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://roshansingh.systems/#hire" rel="noopener noreferrer"&gt;roshansingh.systems/#hire&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>opentelemetry</category>
      <category>observability</category>
      <category>mcp</category>
    </item>
  </channel>
</rss>
