Part of a series on building cz-agents → under the hood

Short context

Model Context Protocol is an open protocol for connecting external tools and data sources to LLM applications. Instead of giving an agent a long prompt with manually pasted data, the agent can call a server that exposes specific tools, schemas, and resources. In my case, that meant giving the model access to Czech compliance sources: ARES for company identity, ISIR for insolvency, sanctions lists, and an aggregated due-diligence view. The goal was not to build a chatbot. The goal was a small, auditable data boundary that an agent can use without making up answers about a company like Acme Imports s.r.o. or a test identifier such as IČO 00000000.

The source-data challenge

Czech public data looks simple from a distance: there is a register, there is an endpoint, there is documentation. After six months of work, my summary is less optimistic. These systems were designed primarily for human lookup, not for high-frequency programmatic queries that need consistent latency, predictable errors, and stable contracts.

ARES has XML and JSON APIs, but practical operation means respecting rate limits, backoff, and crawl etiquette. There is no convenient bulk export that covers the common need: download everything relevant, validate locally, and maintain your own index. If a product needs to check one specific entity, that is fine. If an agent starts composing dozens of requests in one analysis, the server has to be conservative, cache aggressively, and know when to say no.

ISIR is a different kind of problem. The insolvency register combines structured fields and documents, but important information often ends up in PDFs. The parser then deals with text-layer quality, scanned attachments, legal phrasing, and differences between court districts. Some fields are present for one event type and missing for another. Sometimes the expected combination of identifiers exists; sometimes only a name, a date, or a case-file link remains. This is not one broken API. It is the shape of a system that served people in browsers for a long time.

The important part is that this is not just format dirt. In compliance workflows, every missing or ambiguous field has operational meaning. If ARES returns an entity without an expected detail, the agent must not fill the gap by inference. If an ISIR document cannot be read reliably, the result has to carry uncertainty. If a sanctions hit is only a name similarity, the user needs to see which attributes matched and which ones are absent.

Sanctions add another layer. EU Financial Sanctions Files are available as token-authenticated JSON and provide the European base for Czech entities. If there is USD transaction exposure, I had to add OFAC SDN as well. Even there, the work is not just downloading a list. The real work is normalising names, aliases, countries, birth dates, legal entities, and the final explanation of why the system marked a possible hit. A compliance output without an explanation is almost worse than no output.

Two engineering gotchas

1. AsyncLocalStorage and Streamable HTTP transport

The most concrete bug that surprised me was lost request context. In Node.js I used AsyncLocalStorage to store the request IP address, so rate limiting and basic analytics did not have to pass metadata through every function. It worked in normal Express handlers. Then I added StreamableHTTPServerTransport.handleRequest() from the MCP SDK and part of the metrics started returning undefined.

The cause was that the transport created a new async chain internally. The code ran, responses came back, no exception was thrown. The context that the rate limiter and analytics layer relied on was simply unavailable where the tool was actually executed. This is exactly the kind of bug that looks like a minor observability anomaly until you realise the limiter cannot see the client.

The workaround was pragmatic: next to AsyncLocalStorage, I added a module-level fallback variable and small setRequestIp() / clearRequestIp() functions. The wrapper sets the IP immediately before calling the handler and clears it in a finally block. It is not a general pattern for business logic, but for a narrow slice of request metadata around the MCP transport, it was better than dragging the IP address through every tool call.

2. SQLite instead of Postgres

The second decision was less dramatic but more important for operations: I used SQLite through better-sqlite3. For a compliance product, Postgres sounds like the default reflex. But I was building read-heavy servers with local caches of registers, sanctions lists, and normalised results. The primary requirement was not multi-writer concurrency. It was simple single-container deployment, fast local queries, and fewer operational dependencies.

The topology therefore uses a shared read-only volume mount between services. One process updates the data and the other containers read it without inter-service database connections. That removed a full layer of connection strings, credentials, network policy, and networked migrations from deployment. The trade-off is explicit: this is not for many writer services or transactional workload. For compliance queries that are mostly reads with periodic dataset refreshes, it fit better than a small Postgres cluster.

The architecture after six months

The result is seven open-source npm packages and one closed-source premium layer called ddplus. The open-source part covers individual MCP servers and shared utilities: ARES, ISIR, sanctions, the due-diligence aggregator, input normalisation, transport helpers, and common validation schema. Each server runs as a Docker container because that keeps local development and deployment understandable. An Apache reverse proxy sits in front and routes public endpoints by host and path.

The multi-server topology worked better than one large process. ARES has different cache behaviour than sanctions. ISIR has different retry rules and document edge cases. The due-diligence aggregator is an orchestrator that combines results from the other servers and returns a smaller, explained output to the model. The premium tier is separated intentionally; I do not want to mix paid-feature details into open-source packages, and I do not want the self-hosted variant to have hidden runtime dependencies.

The most useful boundary between services turned out to be boring: small JSON objects with source version, refresh time, and explicit status. When a source is unavailable, the aggregator returns a partial result with a reason instead of silently producing an empty answer. That matters with LLM consumers because absence otherwise gets too easily rewritten as certainty.

Why SaaS on top of open source

My original assumption was that open-source MCP servers would be enough. Technically, they are enough. With compliance users, I ran into a different need: they do not want to take responsibility for self-hosting, monitoring, and data refresh. They want to know that the sanctions list is current, that the ARES fallback works, that the ISIR parser did not break on a new combination of documents, and that somebody handles incidents when a public source changes behaviour.

That is why I added a hosted layer with a trial and then a subscription model. The tiers are 29, 99, and 199 EUR depending on volume and operational limits. This is not an attempt to hide open source behind a paywall. It separates two kinds of value: code that can be read, run, and modified, and a service that carries operational responsibility. The GDPR approach is conservative: minimise stored input data, log mostly technical metadata, and keep the retention window as short as possible for sensitive queries.

What I take from it

After six months, the main lesson is simple: MCP is a useful shape for the boundary between an agent and a system, but most of the work is still in the data. You need to know rate limits, outage behaviour, legal meaning of fields, false-positive matches, and the operational cost of every external endpoint. A demo needs one tool call. A compliance product needs to explain why the result appeared and when not to trust it.

The open-source packages are at github.com/martinhavel/cz-agents-mcp. The hosted trial runs at cz-agents.dev. Both variants use the same base: small MCP servers, conservative caching, explicit limits, and outputs intended to be readable by both machines and humans.