<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://avneeshk.me/feed/by_tag/debugging.xml" rel="self" type="application/atom+xml" /><link href="https://avneeshk.me/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-17T11:48:31+00:00</updated><id>https://avneeshk.me/feed/by_tag/debugging.xml</id><title type="html">Avneesh Kasture</title><subtitle>Building secure MCP servers for clients — hardening the agentic layer against prompt injection, tool misuse, and privilege abuse. Field notes on MCP security, agentic safety, and shipping AI platforms.</subtitle><author><name>Avneesh Kasture</name><email>apkasture02@gmail.com</email></author><entry><title type="html">Why does my MCP server run fine in a terminal but refuse to connect?</title><link href="https://avneeshk.me/blog/stdout-is-not-yours/" rel="alternate" type="text/html" title="Why does my MCP server run fine in a terminal but refuse to connect?" /><published>2026-07-02T14:00:00+00:00</published><updated>2026-07-02T14:00:00+00:00</updated><id>https://avneeshk.me/blog/stdout-is-not-yours</id><content type="html" xml:base="https://avneeshk.me/blog/stdout-is-not-yours/"><![CDATA[<p>Suppose someone reports the following. They built an MCP server. They run
<code class="language-plaintext highlighter-rouge">uv run my-mcp</code> in a terminal, and it prints its startup banner. No traceback,
exit code 0. Then they point Claude Desktop at it, and the client produces a wall
of schema errors and marks the server as failed.</p>

<p>Nothing crashed. So why won’t it connect?</p>

<p>Let’s look at what the client is actually doing.</p>

<h2 id="who-is-reading-stdout">Who is reading stdout?</h2>

<p>A stdio MCP server talks to its client over two pipes. It reads requests from
stdin, and it writes responses to stdout. That is the whole transport. The client
sits on the other end of stdout and parses every line that comes out as a
JSON-RPC message.</p>

<p>You might think of stdout as the place where a program prints things for a human
to read. In a terminal, that’s what it is. But when the client launches your
server, nobody is looking at stdout except a parser with very firm opinions about
what a line should contain.</p>

<figure class="post-figure">
  <img src="/assets/blog/stdout-is-not-yours/teletype-asr-33.jpg" alt="A cream-colored Teletype ASR-33 terminal with a roll of paper and a punched-tape reader, on display at the Computer History Museum" loading="lazy" />
  <figcaption>The human stdout was built for: a Teletype ASR-33. Photo: <a href="https://commons.wikimedia.org/wiki/File:ASR-33_at_CHM.agr.jpg">ASR-33 at CHM</a> by ArnoldReinhold, <a href="https://creativecommons.org/licenses/by-sa/3.0/">CC BY-SA 3.0</a>.</figcaption>
</figure>

<p>Now trace what happens when your server logs something. Maybe it’s a <code class="language-plaintext highlighter-rouge">structlog</code>
line, or a stray <code class="language-plaintext highlighter-rouge">print()</code>, or a dependency announcing “loaded model
successfully.” The line goes to stdout. The client receives it, tries to parse it
as protocol, and rejects the exchange:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>unrecognized_keys: ["warning", "event", "timestamp", "level"]
invalid_value ... path: ["jsonrpc"]
</code></pre></div></div>

<p>Look at those key names. <code class="language-plaintext highlighter-rouge">event</code>, <code class="language-plaintext highlighter-rouge">timestamp</code> and <code class="language-plaintext highlighter-rouge">level</code> are not JSON-RPC
fields. They are your log fields. The client got your log record where it
expected a response, and it has no way to tell a debug message apart from a
malformed reply. So if you see a burst of <code class="language-plaintext highlighter-rouge">invalid_union</code> and <code class="language-plaintext highlighter-rouge">unrecognized_keys</code>
errors from a server that starts perfectly in a shell, this is the first thing to
check.</p>

<figure class="post-figure">
  <img src="/assets/blog/stdout-is-not-yours/stdio-pipes.svg" alt="Diagram: the client writes a tools/list request to the server&#39;s stdin and parses every line of the server&#39;s stdout as JSON-RPC. A JSON-RPC result frame is accepted; a structlog line with event and level keys is rejected. The same log line sent to stderr lands in the client log or your terminal, where nobody parses it." loading="lazy" />
  <figcaption>Three pipes, one parser. Only stdout is read as protocol, so a log line there is a malformed reply.</figcaption>
</figure>

<p>This is one of those cases where running the program by hand actively misleads
you. In the terminal, the log line lands on your screen and looks harmless. Under
the client, the same bytes land in the protocol stream and break it. Same
program and the same output, but a different reader.</p>

<h2 id="so-where-should-the-logs-go">So where should the logs go?</h2>

<p>Stderr. The client doesn’t parse it, which makes it the right destination for
every diagnostic byte your server produces. If you use <code class="language-plaintext highlighter-rouge">structlog</code>, point its
logger factory at <code class="language-plaintext highlighter-rouge">sys.stderr</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">sys</span>
<span class="kn">import</span> <span class="nn">structlog</span>

<span class="n">structlog</span><span class="p">.</span><span class="n">configure</span><span class="p">(</span>
    <span class="n">processors</span><span class="o">=</span><span class="p">[</span>
        <span class="n">structlog</span><span class="p">.</span><span class="n">processors</span><span class="p">.</span><span class="n">add_log_level</span><span class="p">,</span>
        <span class="n">structlog</span><span class="p">.</span><span class="n">processors</span><span class="p">.</span><span class="n">TimeStamper</span><span class="p">(</span><span class="n">fmt</span><span class="o">=</span><span class="s">"iso"</span><span class="p">),</span>
        <span class="n">structlog</span><span class="p">.</span><span class="n">processors</span><span class="p">.</span><span class="n">JSONRenderer</span><span class="p">(),</span>
    <span class="p">],</span>
    <span class="n">logger_factory</span><span class="o">=</span><span class="n">structlog</span><span class="p">.</span><span class="n">PrintLoggerFactory</span><span class="p">(</span><span class="nb">file</span><span class="o">=</span><span class="n">sys</span><span class="p">.</span><span class="n">stderr</span><span class="p">),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Two rules follow, and there’s no wiggle room in either of them.</p>

<ol>
  <li>Don’t call <code class="language-plaintext highlighter-rouge">print()</code> in any runtime path. A bare <code class="language-plaintext highlighter-rouge">print()</code> writes to stdout by
definition. The one you left in an error branch you rarely hit will corrupt
the protocol on the day that branch finally runs. If you really need it, write
<code class="language-plaintext highlighter-rouge">print(..., file=sys.stderr)</code>, though the logger is the better choice.</li>
  <li>Your dependencies count too. A library that prints a banner or a progress bar
to stdout poisons the channel just as well as your own code does. Import such
libraries with that in mind, and silence the chatty ones.</li>
</ol>

<h2 id="why-didnt-the-tests-catch-this">Why didn’t the tests catch this?</h2>

<p>Because the tests never looked. A typical unit test imports a function and checks
its return value. It doesn’t launch the server as a subprocess and read stdout
the way a real client does. The bug lives entirely in the integration, and that’s
the one place the suite doesn’t go.</p>

<p>The remedy is a small stdio smoke test. Spawn the server as a subprocess, send an
<code class="language-plaintext highlighter-rouge">initialize</code> request followed by a <code class="language-plaintext highlighter-rouge">tools/list</code> request, and assert that what
comes back on stdout is clean, parseable JSON-RPC listing the tools you expect.
If a log line is leaking, the test fails right away. As a bonus, it also catches
a couple of related failures in the same run, such as import-time crashes and
startup failures on an empty config. Run it before you tell anyone the server
works.</p>

<h2 id="bonus-chatter">Bonus chatter</h2>

<p>The <code class="language-plaintext highlighter-rouge">JSONRenderer</code> in the configuration above has a pleasant side effect: it
escapes non-ASCII characters. That keeps your stderr logs readable even on a
Windows <code class="language-plaintext highlighter-rouge">cp1252</code> console, which would otherwise choke on a stray emoji or
box-drawing character. It’s one less platform surprise to debug.</p>

<h2 id="exercise">Exercise</h2>

<p>When you run the server by hand, <code class="language-plaintext highlighter-rouge">print("hello")</code> and
<code class="language-plaintext highlighter-rouge">print("hello", file=sys.stderr)</code> both show up in your terminal, and they look
identical. Explain why only one of them breaks the connection when the client
launches the server.</p>

<details class="post-answer">
  <summary>Show an answer</summary>
  
<p>In your terminal, file descriptors 1 and 2 both point at the same tty. Both lines land on your screen, and the only thing reading them is you.</p>

<p>When the client launches the server, it replaces fd 1 with a pipe and reads every line on that pipe as a JSON-RPC message. <code class="language-plaintext highlighter-rouge">hello</code> isn’t JSON, so the parse fails. fd 2 is inherited or routed to the client’s log, and nobody parses it.</p>

<p>The two <code class="language-plaintext highlighter-rouge">print</code> calls didn’t change. What changed is who is on the other end of fd 1.</p>

</details>]]></content><author><name>Avneesh Kasture</name><email>apkasture02@gmail.com</email></author><category term="mcp" /><category term="debugging" /><summary type="html"><![CDATA[A stdio MCP server that starts cleanly in a terminal but fails under the client is almost always writing logs to stdout. Stdout carries the JSON-RPC messages, so a single log line corrupts the conversation. The fix is to send structlog output to stderr.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://avneeshk.me/assets/og/stdout-is-not-yours.png" /><media:content medium="image" url="https://avneeshk.me/assets/og/stdout-is-not-yours.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>