<?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: Martin Havel</title>
    <description>The latest articles on DEV Community by Martin Havel (@martinhavel).</description>
    <link>https://dev.to/martinhavel</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%2F3882767%2F8eda1091-a117-4681-a0d1-328c35b1d3eb.png</url>
      <title>DEV Community: Martin Havel</title>
      <link>https://dev.to/martinhavel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/martinhavel"/>
    <language>en</language>
    <item>
      <title>Your MCP Server Passes Every Test — and Claude Still Rejects the Tool</title>
      <dc:creator>Martin Havel</dc:creator>
      <pubDate>Wed, 10 Jun 2026 16:20:37 +0000</pubDate>
      <link>https://dev.to/martinhavel/your-mcp-server-passes-every-test-and-claude-still-rejects-the-tool-2oaa</link>
      <guid>https://dev.to/martinhavel/your-mcp-server-passes-every-test-and-claude-still-rejects-the-tool-2oaa</guid>
      <description>&lt;p&gt;We shipped what looked like a routine improvement to one of our MCP tools: a declared &lt;code&gt;outputSchema&lt;/code&gt;, generated from our existing Zod types. Server-side smoke tests passed. The structured output validated cleanly against JSON Schema 2020-12 with an independent validator. We deployed.&lt;/p&gt;

&lt;p&gt;Then Claude Desktop refused to call the tool at all.&lt;/p&gt;

&lt;p&gt;This post is a write-up of that incident—what failed, why all our tests missed it, and what an actual end-to-end test for an MCP server needs to look like. One caveat up front: this is n=1, observed on our specific setup (&lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; ^1.10, &lt;code&gt;zodToJsonSchema&lt;/code&gt;, Claude Desktop as the client, June 2026). Treat it as a mechanism worth knowing about, not a universal law.&lt;/p&gt;

&lt;h2&gt;
  
  
  "MCP tool not showing up": outputSchema rejected by the client ingest layer
&lt;/h2&gt;

&lt;p&gt;That heading is the literal symptom, because it's what you'll be searching for at 11 p.m. The variants we tried ourselves: &lt;em&gt;MCP tool not showing up&lt;/em&gt;, &lt;em&gt;outputSchema rejected&lt;/em&gt;, &lt;em&gt;MCP tool ingest failed&lt;/em&gt;, &lt;em&gt;Claude Desktop tool error request_id&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Here's what we saw, concretely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Calling the tool from Claude Desktop produced an error carrying a &lt;code&gt;request_id&lt;/code&gt;—the request reached Anthropic's side and was rejected there.&lt;/li&gt;
&lt;li&gt;Our server logs showed a &lt;code&gt;new session&lt;/code&gt; line and then... nothing. No &lt;code&gt;[tool]&lt;/code&gt; invocation line. The call never arrived at our handler.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;curl&lt;/code&gt; against our own server with the same payload? Worked perfectly. Valid response, valid &lt;code&gt;structuredContent&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the tool definition itself was being rejected somewhere between the client and our server—at what I'll call the &lt;strong&gt;tool-ingest layer&lt;/strong&gt;: the validation Anthropic's infrastructure runs on tool definitions before the model is ever allowed to use them. Our server was never consulted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: adding outputSchema via zodToJsonSchema
&lt;/h2&gt;

&lt;p&gt;The tool in question was &lt;code&gt;watch_entity&lt;/code&gt; from our open-source Czech company due-diligence MCP server (&lt;a href="https://github.com/cz-agents" rel="noopener noreferrer"&gt;cz-agents on GitHub&lt;/a&gt;). We already returned &lt;code&gt;structuredContent&lt;/code&gt; and wanted to declare its shape, which the MCP spec supports via &lt;code&gt;outputSchema&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;zodToJsonSchema&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod-to-json-schema&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;watchEntityOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;ico&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;watching&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;  &lt;span class="c1"&gt;// &amp;lt;- this matters later&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;watch_entity&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Watch a Czech company for changes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;watchEntityInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;outputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;zodToJsonSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;watchEntityOutput&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;- the change&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;handler&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks innocent. But look at what &lt;code&gt;zodToJsonSchema&lt;/code&gt; actually emits by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://json-schema.org/draft-07/schema#"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ico"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"const"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"watching"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"anyOf"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ico"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three constructs in this schema are worth noticing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;$schema&lt;/code&gt; URI is draft-07&lt;/strong&gt;, not the JSON Schema 2020-12 dialect the MCP spec gravitates toward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;z.null()&lt;/code&gt; / &lt;code&gt;.nullable()&lt;/code&gt; produces &lt;code&gt;"type": "null"&lt;/code&gt;&lt;/strong&gt; branches.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;z.literal()&lt;/code&gt; produces &lt;code&gt;const&lt;/code&gt;.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three are completely standard JSON Schema. Every off-the-shelf validator we threw at this—including an independent &lt;code&gt;jsonschema&lt;/code&gt; check of our &lt;code&gt;structuredContent&lt;/code&gt; against the schema—passed without complaint.&lt;/p&gt;

&lt;p&gt;But the ingest layer that vets tool definitions on Anthropic's side was, at the time we hit this, stricter than a generic validator. It didn't accept this combination, and the failure mode wasn't "schema ignored"—it was &lt;strong&gt;the entire tool being rejected&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I want to be careful with framing here: this isn't a story about anyone doing something wrong. The SDK emitted valid draft-07. Our validator correctly validated it. The ingest layer enforced a tighter profile than "any valid JSON Schema." &lt;strong&gt;Validation layers differ&lt;/strong&gt;, and the only place all of them meet is a live round-trip. That lesson survives even if the ingest layer accepts these constructs tomorrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnostic key: asymmetry
&lt;/h2&gt;

&lt;p&gt;What saved us from days of wrong theories was one observation: &lt;strong&gt;only the tool with &lt;code&gt;outputSchema&lt;/code&gt; failed&lt;/strong&gt;. Our text-only tool on the same server, same session, same deploy—&lt;code&gt;get_dd_report&lt;/code&gt;—kept working the whole time.&lt;/p&gt;

&lt;p&gt;That asymmetry rules out almost everything else you'd suspect first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A network or transport issue would hit both tools.&lt;/li&gt;
&lt;li&gt;A server crash or bad deploy would hit both tools.&lt;/li&gt;
&lt;li&gt;A client-side transient would not reproduce selectively, every time, on exactly one tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A generic outage doesn't aim. When one tool fails deterministically and its siblings don't, diff the tool &lt;em&gt;definitions&lt;/em&gt;, not the infrastructure. In our case, the diff was one field: &lt;code&gt;outputSchema&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second diagnostic key was the log shape. A &lt;code&gt;new session&lt;/code&gt; line with no &lt;code&gt;[tool]&lt;/code&gt; line means the handshake happened but the tool call was never dispatched to us. Combined with an error that carries a &lt;code&gt;request_id&lt;/code&gt;, that places the rejection firmly on the ingest side—not in our process, not in the user's network.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: drop outputSchema, keep structuredContent
&lt;/h2&gt;

&lt;p&gt;Here's the part that surprised me: &lt;strong&gt;&lt;code&gt;structuredContent&lt;/code&gt; works fine without a declared &lt;code&gt;outputSchema&lt;/code&gt;&lt;/strong&gt;. The schema declaration is metadata; the structured payload travels regardless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;watch_entity&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Watch a Czech company for changes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;watchEntityInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// outputSchema removed — structuredContent still flows&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;watchEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="na"&gt;structuredContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;- still delivered to the client&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;We removed the &lt;code&gt;outputSchema&lt;/code&gt; declaration, redeployed, and the end-to-end flow worked perfectly: Claude Desktop called the tool, the &lt;code&gt;[tool]&lt;/code&gt; line showed up in &lt;code&gt;docker logs&lt;/code&gt;, and structured content arrived at the client. You lose client-side schema validation of your output, which is a real (if modest) loss—but you keep the structured data, and you keep a working tool.&lt;/p&gt;

&lt;p&gt;If you do want to keep &lt;code&gt;outputSchema&lt;/code&gt;, the cautious path based on what we observed is to post-process the generated schema—strip the draft-07 &lt;code&gt;$schema&lt;/code&gt; URI, replace &lt;code&gt;"type": "null"&lt;/code&gt; branches with a non-null type plus optionality, replace &lt;code&gt;const&lt;/code&gt; with a single-value &lt;code&gt;enum&lt;/code&gt;—and then &lt;em&gt;test it through a real client&lt;/em&gt; before trusting it. Which brings us to the actual point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your curl smoke test will never catch this
&lt;/h2&gt;

&lt;p&gt;Our smoke test did what most MCP smoke tests do: hit the server over HTTP, list tools, call each one, and validate the response. It's a fine test of &lt;em&gt;our&lt;/em&gt; code. It exercises exactly zero of the validation that happens on the client/platform side.&lt;/p&gt;

&lt;p&gt;The chain for a hosted MCP tool call looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude (model) → Anthropic tool-ingest/validation → your MCP server → back
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A curl test starts at step 3. Everything that can reject your tool in steps 1–2—schema dialect restrictions, definition size limits, naming rules, whatever else the platform enforces—is invisible to it. Server-side green means "my half works," and nothing more.&lt;/p&gt;

&lt;p&gt;So our deploy checklist gained one non-negotiable step. &lt;strong&gt;The real E2E test for an MCP server is a live client round-trip:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deploy to a staging endpoint.&lt;/li&gt;
&lt;li&gt;Connect a real Claude client (Claude Desktop, claude.ai, or Claude Code) to it.&lt;/li&gt;
&lt;li&gt;Ask it to invoke &lt;em&gt;every tool&lt;/em&gt;—especially any tool whose definition changed, not just its handler.&lt;/li&gt;
&lt;li&gt;Watch your server logs for the invocation marker. For us: &lt;code&gt;docker logs -f mcp-server | grep '\[tool\]'&lt;/code&gt;. A session line without a tool line indicates a rejection upstream of your server.&lt;/li&gt;
&lt;li&gt;Confirm the result rendered correctly in the client.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It's manual, it's slightly annoying, and it takes five minutes. It is also the only test in our suite that would have caught this—and the only one that exercises the same path your users do.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A tool &lt;em&gt;definition&lt;/em&gt; change is riskier than a handler change: it gets re-validated by every layer between you and the model, and the failure mode is the whole tool disappearing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;zodToJsonSchema&lt;/code&gt; defaults to draft-07 with &lt;code&gt;type: "null"&lt;/code&gt; and &lt;code&gt;const&lt;/code&gt;—valid JSON Schema that stricter ingest profiles may not accept. Inspect the generated schema; don't assume.&lt;/li&gt;
&lt;li&gt;Selective failure is information. One tool down, siblings up → diff the definitions.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;new session&lt;/code&gt; log line without a &lt;code&gt;[tool]&lt;/code&gt; log line localizes the rejection upstream of your server.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;structuredContent&lt;/code&gt; does not require &lt;code&gt;outputSchema&lt;/code&gt;. When in doubt, ship the data without the declaration.&lt;/li&gt;
&lt;li&gt;Server-side smoke tests validate your half of the contract. Only a live Claude client round-trip validates the whole thing. Put one in your deploy checklist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;*Observed June 2026 on &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; ^1.10 with Claude Desktop. If the ingest behavior has changed since, the specific constructs may pass—the testing lesson stands either way. The server involved is open source: &lt;a href="https://github.com/cz-agents" rel="noopener noreferrer"&gt;cz-agents MCP servers&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>testing</category>
      <category>typescript</category>
      <category>ai</category>
    </item>
    <item>
      <title>I built a free, browser-only European payment QR generator (and an MCP server)</title>
      <dc:creator>Martin Havel</dc:creator>
      <pubDate>Mon, 01 Jun 2026 13:41:27 +0000</pubDate>
      <link>https://dev.to/martinhavel/i-built-a-free-browser-only-european-payment-qr-generator-and-an-mcp-server-11k</link>
      <guid>https://dev.to/martinhavel/i-built-a-free-browser-only-european-payment-qr-generator-and-an-mcp-server-11k</guid>
      <description>&lt;p&gt;Every time I get an invoice or a broker deposit instruction, I do the same dumb, error-prone thing: copy the IBAN, the amount, and the variable symbol into my banking app &lt;strong&gt;by hand&lt;/strong&gt;. One transposed digit and the money goes to a stranger.&lt;/p&gt;

&lt;p&gt;Czech invoices increasingly carry a &lt;strong&gt;QR Platba&lt;/strong&gt; code you just scan — but a lot of them still don't (anyone invoicing from Word/Excel, foreign senders, smaller shops). So I built a small tool that turns those payment details into a scannable QR, and it grew into something I think is worth sharing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://qr.cz-agents.dev" rel="noopener noreferrer"&gt;https://qr.cz-agents.dev&lt;/a&gt; · &lt;strong&gt;Code (MIT):&lt;/strong&gt; &lt;a href="https://github.com/martinhavel/cz-agents-mcp" rel="noopener noreferrer"&gt;https://github.com/martinhavel/cz-agents-mcp&lt;/a&gt; · &lt;strong&gt;npm:&lt;/strong&gt; &lt;code&gt;@czagents/payqr&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Generates &lt;strong&gt;SPAYD&lt;/strong&gt; (Czech/Slovak "QR Platba") and &lt;strong&gt;EPC / GiroCode&lt;/strong&gt; (the SEPA standard, huge in Germany/Austria) payment QR codes from an IBAN + amount + reference.&lt;/li&gt;
&lt;li&gt;Also does plain-text, Wi-Fi and vCard QR, and &lt;strong&gt;reads a QR back&lt;/strong&gt; (decode + classify).&lt;/li&gt;
&lt;li&gt;You can type the details &lt;strong&gt;or drop a payment screenshot&lt;/strong&gt; — OCR runs in your browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The lazy path (in an AI client / MCP):&lt;/strong&gt; hand it a &lt;em&gt;whole invoice&lt;/em&gt; — PDF or photo — and the model reads the IBAN, amount and reference and generates the payment QR for you. No retyping at all, with a verify step before anything is shown.&lt;/li&gt;
&lt;li&gt;It's &lt;strong&gt;100% client-side&lt;/strong&gt;: nothing is uploaded, no account, no tracking, no AI, MIT.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flcg9udhnsd05jq2wqxci.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flcg9udhnsd05jq2wqxci.gif" alt="payqr demo" width="800" height="543"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bits that were actually interesting to build
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The payment formats are just strings.&lt;/strong&gt; SPAYD is a delimited string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SPD*1.0*ACC:CZ6508000000192000145399*AM:1250.00*CC:CZK*X-VS:1234567890
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EPC/GiroCode is 12 newline-separated lines (service tag, BIC, recipient, IBAN, amount, remittance…). EUR-only, recipient name required. Once you know the spec, generating them is deterministic — no API, no key, no rounding surprises. A lot of "QR code generator" tools just wrap a remote API; this one computes everything locally, which is what makes the privacy claim real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. IBAN validation is a tiny party trick.&lt;/strong&gt; mod-97: move the first 4 chars to the end, turn letters into numbers, and the whole thing mod 97 must equal 1. ~10 lines, catches most typos before a QR is ever drawn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Privacy is an architecture, not a checkbox.&lt;/strong&gt; OCR uses &lt;code&gt;tesseract.js&lt;/code&gt; and QR decoding uses &lt;code&gt;jsQR&lt;/code&gt;, both lazy-loaded in the browser. The image never leaves the device. I could only honestly write "your image never leaves your device" because there is literally no backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP part (and a payment-safety gotcha)
&lt;/h2&gt;

&lt;p&gt;It's also an &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; server, so in an AI client you can hand it an &lt;strong&gt;invoice (PDF or photo)&lt;/strong&gt; and the model reads the IBAN/amount/reference and calls &lt;code&gt;qr_payment&lt;/code&gt;. The instructions force a &lt;strong&gt;read-back step&lt;/strong&gt;: the model echoes the extracted fields and asks you to verify before showing the QR — because a misread digit is real money gone.&lt;/p&gt;

&lt;p&gt;Here's the non-obvious part I burned an afternoon on. The tool returns the QR as an MCP image content block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;content&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;base64png&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&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;In Claude Desktop, that image is given to the &lt;strong&gt;model as rendered pixels&lt;/strong&gt; — which the model &lt;strong&gt;cannot re-export&lt;/strong&gt;. So when I told it "show the user the QR as a file," it couldn't access the bytes… and helpfully &lt;strong&gt;regenerated the QR from the payload using its own library&lt;/strong&gt;. For a payment QR that's dangerous: the model just became the source of truth, and a single mistyped character would silently corrupt the transfer.&lt;/p&gt;

&lt;p&gt;Two fixes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never regenerate&lt;/strong&gt; — and if the model has no choice, make it &lt;code&gt;qr_read&lt;/code&gt; its own image and diff the payload character-for-character before showing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return the PNG as base64 &lt;em&gt;text&lt;/em&gt; too&lt;/strong&gt; (&lt;code&gt;qr_png_base64&lt;/code&gt;), so the model can write the &lt;em&gt;exact&lt;/em&gt; bytes to a file with zero re-encoding.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Lesson for anyone building image-returning MCP tools: the rendered image block is for the human; if you also want the model to &lt;em&gt;do&lt;/em&gt; something with the bytes, hand them over as text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://qr.cz-agents.dev" rel="noopener noreferrer"&gt;https://qr.cz-agents.dev&lt;/a&gt; — type an IBAN, or drop a screenshot. It's free and there's nothing to sign up for. I'd love feedback on EPC/GiroCode edge cases (BICs, structured vs unstructured remittance) — that's where the spec gets fiddly.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>mcp</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building MCP servers for a country that isn't in the dataset (Czech gov APIs)</title>
      <dc:creator>Martin Havel</dc:creator>
      <pubDate>Thu, 16 Apr 2026 15:57:32 +0000</pubDate>
      <link>https://dev.to/martinhavel/building-mcp-servers-for-a-country-that-isnt-in-the-dataset-czech-gov-apis-1lo8</link>
      <guid>https://dev.to/martinhavel/building-mcp-servers-for-a-country-that-isnt-in-the-dataset-czech-gov-apis-1lo8</guid>
      <description>&lt;h2&gt;
  
  
  Abstract
&lt;/h2&gt;

&lt;p&gt;In December 2025, Anthropic donated the Model Context Protocol to the Linux Foundation. MCP is now a vendor-neutral standard — OpenAI, Block, and dozens of IDEs ship with it. The registry has ~300 servers. Most of them are for English-first ecosystems: GitHub, Slack, Linear, Notion.&lt;/p&gt;

&lt;p&gt;Nobody had written an MCP server for Czech government or business data. So I spent an evening doing it. Two servers live, published to npm and the official registry. This is what I learned about adapting MCP to a locale the tooling wasn't designed for.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/martinhavel/cz-agents-mcp" rel="noopener noreferrer"&gt;github.com/martinhavel/cz-agents-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Landing: &lt;a href="https://cz-agents.dev" rel="noopener noreferrer"&gt;cz-agents.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;code&gt;@czagents/ares&lt;/code&gt;, &lt;code&gt;@czagents/cnb&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Registry: &lt;code&gt;dev.cz-agents/ares&lt;/code&gt;, &lt;code&gt;dev.cz-agents/cnb&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  1. The context problem
&lt;/h2&gt;

&lt;p&gt;If you ask Claude about GitHub, it knows what GitHub is. It ships with an MCP server for it. Same for Postgres, Puppeteer, Stripe, Slack.&lt;/p&gt;

&lt;p&gt;If you ask Claude about &lt;strong&gt;ARES&lt;/strong&gt; (the Czech Business Register), it doesn't know. It's gov data for 10M people. The API is public and documented. But there's no MCP adapter. So every time you want your Czech tax assistant to pre-fill a company address from an IČO, you have to either:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write a one-off REST wrapper in your agent code.&lt;/li&gt;
&lt;li&gt;Tell Claude "call GET &lt;a href="https://ares.gov.cz/" rel="noopener noreferrer"&gt;https://ares.gov.cz/&lt;/a&gt;... with these params, then parse the JSON."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Neither scales past one agent. MCP solves this for English-speaking SaaS — it should solve it for everyone else too.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Czech Business ID gotcha (MOD11)
&lt;/h2&gt;

&lt;p&gt;Every Czech company has an &lt;strong&gt;IČO&lt;/strong&gt; — 8-digit identifier. But not every 8-digit number is valid. The last digit is a MOD11 checksum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isValidIco&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ico&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ico&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rem&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rem&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;7&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;This is the kind of knowledge that's obvious if you've built Czech software and invisible otherwise. An MCP server that accepts IČO input &lt;strong&gt;must&lt;/strong&gt; validate before hitting the upstream API — otherwise every typo wastes a round-trip and risks getting your origin IP blacklisted.&lt;/p&gt;

&lt;p&gt;This is reason #1 an MCP wrapper has real value over "tell the LLM the REST path": it encodes domain knowledge the LLM won't know.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The ARES API that lies
&lt;/h2&gt;

&lt;p&gt;ARES has an OpenAPI spec at &lt;code&gt;https://ares.gov.cz/ekonomicke-subjekty-v-be/rest/v3/api-docs&lt;/code&gt;. If you follow it to build a client, your requests will return 404.&lt;/p&gt;

&lt;p&gt;The actual endpoints are at a &lt;strong&gt;different path&lt;/strong&gt; than the spec claims. For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docs say: &lt;code&gt;GET /v3/ekonomicky-subjekt/{ico}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Reality: &lt;code&gt;GET /ekonomicke-subjekty/{ico}&lt;/code&gt; (no &lt;code&gt;/v3/&lt;/code&gt;, plural noun)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It took an hour of DevTools staring to figure out. I put the correct paths in the MCP server and cached the gotcha in repo docs. &lt;strong&gt;That's reason #2&lt;/strong&gt; — downstream consumers don't re-discover the mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. DNS authentication for the MCP registry
&lt;/h2&gt;

&lt;p&gt;The MCP registry (&lt;code&gt;registry.modelcontextprotocol.io&lt;/code&gt;) supports four auth methods: GitHub interactive, GitHub OIDC, DNS proof, HTTP proof. For a custom namespace like &lt;code&gt;dev.cz-agents/*&lt;/code&gt; (reverse-DNS, so the domain name claims ownership), DNS is cleanest.&lt;/p&gt;

&lt;p&gt;How it works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate Ed25519 keypair: &lt;code&gt;openssl genpkey -algorithm ED25519 -out private.pem&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add TXT record at the &lt;strong&gt;root&lt;/strong&gt; domain (not &lt;code&gt;_mcp.&lt;/code&gt; as some docs suggest):
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   cz-agents.dev.  TXT  "v=MCPv1; k=ed25519; p=&amp;lt;base64 public key&amp;gt;"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;mcp-publisher login dns --domain cz-agents.dev --private-key &amp;lt;hex&amp;gt;&lt;/code&gt; — the CLI signs a challenge, the registry fetches the TXT and verifies.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mcp-publisher publish packages/ares/server.json&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two things caught me off-guard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The public key must be &lt;strong&gt;base64&lt;/strong&gt;, not hex (documentation was ambiguous).&lt;/li&gt;
&lt;li&gt;The registry's DNS resolver has a &lt;strong&gt;negative cache&lt;/strong&gt; of ~60 seconds. If you tried to log in before adding the TXT, you'll get &lt;code&gt;no such host&lt;/code&gt; for a minute even after the record propagates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Rate limiting against yourself
&lt;/h2&gt;

&lt;p&gt;The ARES servers run on infrastructure paid for by Czech taxpayers. Hammering them from an AI agent is rude and eventually gets your IP blacklisted.&lt;/p&gt;

&lt;p&gt;I built two layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Application rate limit&lt;/strong&gt; per client IP (60 req/min, token bucket in memory), reading &lt;code&gt;CF-Connecting-IP&lt;/code&gt; when behind Cloudflare. Returns 429 + &lt;code&gt;Retry-After&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response cache&lt;/strong&gt; — ARES company data changes maybe monthly. I cache by IČO for 1 hour. For a batch of 100 agents asking about the same invoice, that's 1 upstream call instead of 100.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building an MCP server over a public API, &lt;strong&gt;these aren't optional&lt;/strong&gt;. Your origin IP is a shared resource.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The scope problem
&lt;/h2&gt;

&lt;p&gt;ARES is well-documented. But once you step beyond it, Czech gov APIs range from "poorly documented but functional" to "only SOAP, service has been down for 18 months" to "the data is behind an Angular SPA with no backend API".&lt;/p&gt;

&lt;p&gt;I spent 15 minutes probing ISIR (insolvency register) and found: REST endpoints return 404, SOAP is unreachable, the "open data portal" is a React app that fetches its own JSON via undocumented URLs. I dropped it from v1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson for other locales:&lt;/strong&gt; start with the APIs that have OpenAPI specs and no auth. Business registries, currency rates, postal codes. Save the complicated ones for v2 when you have community pressure and can involve users in testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. What's live
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;@czagents/ares&lt;/strong&gt; — 9 tools: &lt;code&gt;lookup_by_ico&lt;/code&gt;, &lt;code&gt;search_companies&lt;/code&gt;, &lt;code&gt;search_by_address&lt;/code&gt;, &lt;code&gt;search_by_nace&lt;/code&gt;, &lt;code&gt;get_statutaries&lt;/code&gt;, &lt;code&gt;validate_dic&lt;/code&gt;, &lt;code&gt;check_vat_payer&lt;/code&gt;, &lt;code&gt;get_bank_accounts&lt;/code&gt;, &lt;code&gt;get_history&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;@czagents/cnb&lt;/strong&gt; — 3 tools: &lt;code&gt;get_rates&lt;/code&gt;, &lt;code&gt;convert&lt;/code&gt;, &lt;code&gt;get_rate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Streamable HTTP at &lt;code&gt;https://ares.cz-agents.dev/mcp&lt;/code&gt; and &lt;code&gt;https://cnb.cz-agents.dev/mcp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Rate-limited, cached, MIT licensed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. Why bother — the real answer
&lt;/h2&gt;

&lt;p&gt;I build two Czech SaaS products: &lt;a href="https://knihajizd.mhai.app" rel="noopener noreferrer"&gt;Kniha Jízd&lt;/a&gt; (mileage logbook) and Šiml (tax assistant). Both need ARES lookups daily — "fill in the address of this company by its IČO" was hundreds of lines of custom integration code per product.&lt;/p&gt;

&lt;p&gt;Now it's one config line in &lt;code&gt;claude_desktop_config.json&lt;/code&gt; or a three-line fetch from my own SDK. The open-source version is a side-effect of building my own tooling for my own products. That's the best reason to open source: you were going to write it anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're building software for a non-English-first locale&lt;/strong&gt;, I'd love to see a /fr-agents, /de-agents, /jp-agents drop in the coming months. The pattern is portable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update — 2026-04-27, eleven days later
&lt;/h2&gt;

&lt;p&gt;Quick technical update on what shipped since this article, in case anyone&lt;br&gt;
is looking for the same scaffolding for their own locale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Five packages live&lt;/strong&gt;, not two: &lt;code&gt;@czagents/{ares, cnb, sanctions, isir, dd}&lt;/code&gt;.
The new ones cover EU FSF + OFAC SDN sanctions screening, an ISIR
insolvency-register client built on the Czech Justice Department's SOAP
service, and a due-diligence aggregator that cross-references all three
plus walks the statutory chain (UBO-style).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Glama scored it A/A/A&lt;/strong&gt; (quality / security / license). Free third-party
validation that costs nothing if you ship clean code with a real LICENSE,
pinned deps, and proper tool annotations. Worth doing for any MCP server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Submitted to Anthropic Connectors Directory.&lt;/strong&gt; Pending review. The
submission process itself was a useful audit — they ask for tool
annotations (readOnlyHint, openWorldHint, titles), privacy policy, GDPR
legal basis, public terms. All things any production MCP server should
have anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One subtle infra gotcha worth flagging for other MCP server authors:&lt;/strong&gt;
Anthropic's reachability prober uses &lt;code&gt;python-httpx&lt;/code&gt; with &lt;code&gt;Accept: */*&lt;/code&gt;.
The MCP TypeScript SDK does a strict string-match on the Accept header
and returns 406 Not Acceptable, which Claude.ai surfaces as the cryptic
&lt;em&gt;"Couldn't reach the MCP server"&lt;/em&gt;. Fix: pre-handler that rewrites
&lt;code&gt;Accept: */*&lt;/code&gt; → &lt;code&gt;Accept: application/json, text/event-stream&lt;/code&gt; on both
&lt;code&gt;req.headers&lt;/code&gt; AND &lt;code&gt;req.rawHeaders&lt;/code&gt; (the SDK uses hono/node-server which
reads from rawHeaders). Cost me three hours of debugging — sharing so
nobody else does the same.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern from the original post — &lt;em&gt;adapt MCP to a non-English locale,&lt;br&gt;
ship small, let the registry do the discovery&lt;/em&gt; — held up. Whether anyone&lt;br&gt;
finds it useful in production is a separate question, and one I don't&lt;br&gt;
have data on yet.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Questions, feedback, PRs welcome at &lt;a href="https://github.com/martinhavel/cz-agents-mcp" rel="noopener noreferrer"&gt;github.com/martinhavel/cz-agents-mcp&lt;/a&gt;. If your country has similar public registries and you want a hand designing the namespace, ping me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
