<?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>x402 processed 169M payments. On my eight MCP servers: zero. Only the scouts arrived.</title>
      <dc:creator>Martin Havel</dc:creator>
      <pubDate>Sun, 19 Jul 2026 12:34:21 +0000</pubDate>
      <link>https://dev.to/martinhavel/x402-processed-169m-payments-on-my-eight-mcp-servers-zero-only-the-scouts-arrived-2ljp</link>
      <guid>https://dev.to/martinhavel/x402-processed-169m-payments-on-my-eight-mcp-servers-zero-only-the-scouts-arrived-2ljp</guid>
      <description>&lt;p&gt;&lt;em&gt;Part of a series on building cz-agents → under the hood.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we left off
&lt;/h2&gt;

&lt;p&gt;In June, I compared the three camps of agentic payments here—x402, card tokens, and banks—and argued that x402 will take machine-to-machine micropayments, while cards and banks split the rest. I won't repeat the basics of the protocol, how the 402 status code works, or why cards don't add up economically on tiny amounts; anyone who needs a refresher will find it in that article.&lt;/p&gt;

&lt;p&gt;This piece is about something else. A few weeks have passed; the numbers and the big-player backing have both jumped by an order of magnitude, and for the first time, I can compare those figures against what I actually see in my own logs. The gap between the two is the entire point of what follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers are hard to miss
&lt;/h2&gt;

&lt;p&gt;I'll start with what speaks &lt;em&gt;for&lt;/em&gt; x402, because that's the more honest approach. According to aggregate Chainalysis data, the protocol has processed over 169 million payments so far, between roughly 590,000 buyers and 100,000 sellers. That's no longer a conference demo.&lt;/p&gt;

&lt;p&gt;More interesting than the volume is the structural shift. The share of transactions above one dollar rose from 49% in early 2025 to roughly 95% in early 2026. In other words: x402 is ceasing to be a toy for micro-cents and is starting to handle amounts that actually show up on the books. Anyone who wrote the protocol off as a curiosity for paying fractions of a cent per API query is looking at an old snapshot.&lt;/p&gt;

&lt;p&gt;Above all, a lineup has assembled that is hard to dismiss as crypto-bubble enthusiasts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe&lt;/strong&gt; launched x402 support on February 10, 2026, in the preview of its Machine Payments product (USDC on the Base network).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ripple&lt;/strong&gt; added native x402 to the XRP Ledger on June 21, 2026.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS&lt;/strong&gt; built x402 into Bedrock AgentCore Payments—together with Coinbase and Stripe—and also lets you monetize agent traffic on CloudFront and WAF. Sites behind Amazon's edge can charge agents right at the edge, without touching their core application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google&lt;/strong&gt; is a founding participant in the x402 Foundation and built the protocol into its Agent Payments Protocol (AP2).&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;x402 Foundation&lt;/strong&gt; itself, founded in September 2025 by Coinbase and Cloudflare, moved to formal governance under the Linux Foundation on April 2, 2026. With this move, the protocol stopped being the property of a single exchange.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When Stripe, Amazon, Google, and Ripple all adopt the same standard in a single year, and the foundation hands it over to neutral governance, that's usually a signal that infrastructure is being built—not that theater is being staged. At least, that's how it looks from the outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I see on my own servers: nothing
&lt;/h2&gt;

&lt;p&gt;I run eight public MCP servers—the Czech business register, due diligence, sanctions lists, insolvency, central-bank FX rates, VAT, European company registers, and real-estate data—plus one paid server. They're exactly the kind of services discussed in the context of the agentic economy: machine-readable data, a small query, a clearly bounded answer, and no reason a human should have to fill out a form to pay for it.&lt;/p&gt;

&lt;p&gt;Total agentic payments processed across all of them: zero.&lt;/p&gt;

&lt;p&gt;Before anyone turns that into a headline, I should state the uncomfortable truth upfront—otherwise, you'd figure it out yourself and be right: my servers don't accept x402. They have no wallets, they don't return a 402 status with payment terms, and they can't verify signed stablecoin payments. Money reaches me via Stripe subscriptions and API keys with tier limits. An agent wanting to pay me for a single query has no way to do so.&lt;/p&gt;

&lt;p&gt;So zero payments isn't evidence against x402. It's evidence about me. Concluding "see, nobody uses this protocol" would be a cheap trick: building a straw man out of my own inaction just to knock it down. The absence of payments on a server that can't accept them says nothing about demand.&lt;/p&gt;

&lt;p&gt;The interesting part is something else—something that &lt;em&gt;is&lt;/em&gt; in the logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only things arriving from the x402 economy are the scouts
&lt;/h2&gt;

&lt;p&gt;My access logs regularly show a probe identifying itself as &lt;code&gt;402.ad-mcp-probe&lt;/code&gt;. It belongs to the 402.ad catalog, which describes itself as a search engine for the agentic economy. It didn't just hit one server; it systematically went through all nine. And it's not alone—my logs show a whole fleet of similar indexing services mapping out who can do what.&lt;/p&gt;

&lt;p&gt;The logic behind that catalog is the substantive part. It indexes two categories: "MCP server" and "x402 API." When its probe hits an endpoint that returns a free response, it files it under the first bucket. If it hits an endpoint returning a 402 or 401, it marks it as paid, and potentially x402-compatible. Its own programmatic interface for registering a new service costs 0.10 USDC, payable via x402. The catalog of the agentic economy charges for a listing using the protocol of the agentic economy. It's a service that doesn't just index the protocol; it uses it.&lt;/p&gt;

&lt;p&gt;Boiled down to the single sentence this piece exists for: the only things that have actually reached me from the entire x402 economy are probes checking whether I can accept a payment. Not agents with wallets. Not demand. Just mappers.&lt;/p&gt;

&lt;p&gt;Infrastructure gets built first. Catalogs take inventory of who can accept payments before there is any paying traffic. That's an observation, not a complaint—but it can be read two ways, and honesty demands looking at both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two readings of the same thing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First reading: rails before the train.&lt;/strong&gt; That's how every payment network comes to be. Before anyone can pay by card, a terminal has to exist; before a terminal exists, someone has to walk from shop to shop to find out who wants one. The mapping phase always precedes demand, and the numbers above suggest the train is already running somewhere—just not on my rails. If this were purely hype, Stripe wouldn't have shipped a product, AWS wouldn't have put it in the CloudFront billing layer, and the Linux Foundation wouldn't have taken it under its wing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second reading: rails for a train that will never arrive.&lt;/strong&gt; That's also how every tech bubble starts. Enthusiasts build infrastructure, catalogs index it, the ecosystem congratulates itself on its growth—and the end customer never arrives, because their problem was solvable in another, cheaper way. Probes from a catalog aren't demand. They are someone else's sunk cost on a bet that demand will materialize.&lt;/p&gt;

&lt;p&gt;I don't know which of these two readings is correct, and I won't pretend to. But I do know what signals to look for, which is more useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Will a probe arrive asking about price, rather than just existence?&lt;/strong&gt; The difference between "I have you in the catalog" and "how much does this query cost?" is the difference between a map and a market.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Will an agent show up in the logs that receives a 401 and actually tries to pay?&lt;/strong&gt; Today, agents just leave upon refusal. When one of them finally tries to resolve a refusal with a payment, the nature of the traffic will have changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Will a human customer ask?&lt;/strong&gt; The strongest signal isn't machine-made. When someone building an agent writes to me wanting to pay per unit—because a subscription doesn't fit their needs—the debate is over.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I haven't seen a single one of those three signals yet. That's a fact, not an opinion—and it's the only thing I can honestly claim about x402 demand based on my own data.&lt;/p&gt;

&lt;h2&gt;
  
  
  So should I turn x402 on?
&lt;/h2&gt;

&lt;p&gt;This is the question every API operator asks themselves after reading the numbers above. I'll work through it using my own case, because there is no universal answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For.&lt;/strong&gt; The barrier for an agent is zero: no registration, no key, no waiting for a human to approve access. My data is exactly the kind of small query worth paying for per unit—one company check, one FX rate, one sanctions scan. With the ecosystem now being pushed by Stripe, AWS, and Google, the integration path will keep getting shorter. What takes a week of work today could be a library and three lines of config a year from now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Against.&lt;/strong&gt; My paying customers are people with subscriptions, not agents with wallets. Billing in USDC means, in the Czech context, an entirely new chapter of tax and accounting work—overhead I'd be taking on for demand that no one has actually expressed yet. Furthermore, every additional payment path is one more thing that can break: more code, a larger attack surface, and more things to monitor in a system where I'm already tracking tiers, quotas, and per-query limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decision: I'm not turning it on—but I'll set up a gauge.&lt;/strong&gt; This isn't because I don't believe in x402. I believe in it just as much as I did in June: it will eventually take over machine-to-machine micropayments. I'm not turning it on because between "the protocol is growing" and "someone will buy my specific data per unit" lies a massive assumption I have no data to back up. Turning x402 on today would mean building a terminal based on someone else's numbers, hoping the mappers who visit me will one day bring a buyer with them.&lt;/p&gt;

&lt;p&gt;What I'll do instead is cheaper and more informative: I'll measure the three signals from the previous section. I already have traffic classification on my servers. I can tell a catalog probe from a real tool call—which, incidentally, is the distinction where a lot of confident adoption numbers in this field fall apart. When the first agent wanting to pay lands in my logs, or the first human asks to pay per unit, it'll be a bet backed by evidence, not just a trend. Until then, waiting is my cheapest option. It's also reversible, which is more than you can say for a half-built payment layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take from this
&lt;/h2&gt;

&lt;p&gt;The answer to "is x402 exploding?" is, by every available metric, yes. My logs don't contradict that, because my logs know nothing about it. That's the honest version. Zero payments on a server with no payment gateway is not a data point about the protocol; it's a data point about my prioritization.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;is&lt;/em&gt; a data point about the protocol is the probe. Someone is spending money to map out who can accept machine payments—and doing so before they have anyone to sell the map to. That's either the harbinger of something massive, or an expensive inventory of a market that will never form. Anyone who tells you today, with absolute certainty, which of the two it is, is selling you something.&lt;/p&gt;

&lt;p&gt;Oddly, that uncertainty doesn't make me nervous. It means I don't have to decide right now—I just need to know what to watch. When demand appears, it will show up in my logs before I read about it in a press release. That's the only edge I have over the big players: I don't have to place my bets in advance.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Source for x402 adoption figures: Chainalysis — x402 agentic payments adoption. Traffic data on my own servers comes from access log classification across my portfolio of MCP servers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correction, 26 July 2026.&lt;/strong&gt; A reader here pointed out that the second signal above is mis-specified, and they are right. A 401 carries no price and no inline way to pay, so an agent that receives one has nothing to answer with money — it can only guess. The signal that means something is a &lt;strong&gt;402 carrying terms, followed by an actual payment&lt;/strong&gt;. I have left the original wording rather than quietly rewriting it, because the mistake is the interesting part: I was watching the thermometer that was easiest to read instead of the one attached to the question.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>api</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I gave myself an AI advisory board — three models argue, I decide</title>
      <dc:creator>Martin Havel</dc:creator>
      <pubDate>Mon, 22 Jun 2026 13:16:36 +0000</pubDate>
      <link>https://dev.to/martinhavel/i-gave-myself-an-ai-advisory-board-three-models-argue-i-decide-538f</link>
      <guid>https://dev.to/martinhavel/i-gave-myself-an-ai-advisory-board-three-models-argue-i-decide-538f</guid>
      <description>&lt;p&gt;Most "use AI for your work" advice stops at &lt;em&gt;ask one model, take the answer&lt;/em&gt;. That's a single perspective delivered in a confident voice — and confidence is exactly what you shouldn't trust on a decision that matters. So I stopped asking one model anything important. I built a small advisory board instead: three models, three roles, run in parallel, and I'm the one who decides at the end.&lt;/p&gt;

&lt;p&gt;Here's the setup and what it actually buys you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The harness
&lt;/h2&gt;

&lt;p&gt;I drive everything from Claude Code as the orchestrator. A single skill — I call it &lt;code&gt;triad&lt;/code&gt; — takes a brief and fans it out to three workers, each with a fixed role:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The business analyst&lt;/strong&gt; (Gemini, via the gemini-cli bridge) — market, demand, positioning, "would anyone care."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The architect&lt;/strong&gt; (Opus) — structure, trade-offs, failure modes, "what breaks at scale."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The builder&lt;/strong&gt; (Codex, in the background) — the concrete implementation pass, "what would this actually cost to ship."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They don't see each other's output. That's the whole point: I want three &lt;em&gt;independent&lt;/em&gt; reads, not a consensus that formed because one model anchored the others. The orchestrator collects all three, then does a synthesis pass that lays their reasoning side by side and flags where they &lt;strong&gt;disagree&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why parallel and blind matters
&lt;/h2&gt;

&lt;p&gt;The value isn't in the agreement. When all three land in the same place, fine — low-information. The signal is in the &lt;em&gt;disagreement&lt;/em&gt;: the architect says "this couples two systems you'll regret," the analyst says "nobody asked for this," the builder says "three days, not three hours." Three different failure modes I'd never have surfaced by iterating with one model in a single thread, where each follow-up just reinforces the first frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  A board meeting that saved me six months
&lt;/h2&gt;

&lt;p&gt;A real one, lightly abstracted: an automated energy-arbitrage bot I was genuinely excited about — the kind of project you can already picture running. I briefed the panel half-expecting a green light. Instead they took it apart from three sides: the analyst found the demand wasn't really there, the architect showed the edge evaporated once you priced in the moving parts, and the builder put a number on the maintenance that would have quietly eaten my weekends for a year. None of them &lt;em&gt;forbade&lt;/em&gt; anything — they just laid the decision out so clearly it made itself. I shelved it, and never spent the six months of development I'd otherwise have sunk into learning that the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What lands on my desk
&lt;/h2&gt;

&lt;p&gt;The synthesis isn't a verdict — it's the three reads stacked up with the seams showing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Project X — synthesis&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Analyst:&lt;/strong&gt; market is thinner than I assumed — maybe a few hundred users, not thousands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architect:&lt;/strong&gt; turns a stateless tool into a service; you now own uptime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Builder:&lt;/strong&gt; ~10 days, not the weekend I'd pictured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where they split:&lt;/strong&gt; analyst sees low value, builder says it's cheap to try — so it's a small experiment, not a bet.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;I read that and decide. The disagreement line is the part I'm actually paying for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;False consensus.&lt;/strong&gt; Models trained on overlapping data agree for the wrong reasons. If you only count votes, you'll mistake correlation for confirmation. Read the &lt;em&gt;reasoning&lt;/em&gt;, not the verdict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sycophancy leaks in through the brief.&lt;/strong&gt; If your prompt smuggles the answer you want, all three will hand it back. Keep the brief neutral; let them fight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's expensive and slow.&lt;/strong&gt; Minutes and three model calls, not a one-second answer. This is for decisions, not lookups — I don't run a triad to rename a variable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synthesis is where you can cheat yourself.&lt;/strong&gt; The merge step is tempting to skim. The disagreements are the product; if you collapse them too early you've just paid three times for one opinion.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The one rule that makes it safe
&lt;/h2&gt;

&lt;p&gt;The board advises. It researches, stress-tests, and prepares the ground from several angles. It does not decide. The decision — and the accountability for it — is always mine, a human's. That's not a disclaimer; it's the operating principle. An LLM ensemble is a fantastic way to &lt;em&gt;see a problem from more sides than you could alone&lt;/em&gt;. It is a terrible thing to outsource judgment to. Keep the human as the synthesizer and the decider, and the whole pattern gets stronger, not weaker.&lt;/p&gt;

&lt;p&gt;I've been running this for about six months now, and often — it's become my default for anything that would cost real time to get wrong.&lt;/p&gt;

&lt;p&gt;If you take one thing: don't ask one model and trust the confident voice. Make a few of them argue, read where they break apart, and then &lt;em&gt;you&lt;/em&gt; decide.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>productivity</category>
      <category>tooling</category>
    </item>
    <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>
