<?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>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, the whole pitch. Neither thing was true.&lt;/p&gt;

&lt;p&gt;There was no Bloom filter. There never had been one. The policy engine has one, a real one, with tests. The blocklist never did. Somewhere along the way "we should use a Bloom filter" became "we use a Bloom filter" in a document, and then the claim got copied into another document, and after it existed in three places it read like an established fact. Three docs, zero source files.&lt;/p&gt;

&lt;p&gt;Nobody lied on purpose. A plausible sentence got written once and nothing ever checked it against the code. I only caught it because the stress test forced me to look at what the hot path actually did, and what it actually 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;Verify advertised internals against the code. Our "Bloom filter" lived in three documents and zero files, and it survived because every reader checked it against another document instead of the source.&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 fifteen 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;&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>
