I wanted a small, real MCP server to learn the protocol properly: not a "hello world" tool, but something an assistant could use for an actual task. French public data is a good playground. Two official APIs answer most "who is this company?" questions, need no key, and are published under the Licence Ouverte 2.0 (Etalab's open licence, which allows reuse, including commercial, as long as you credit the source):
- API Recherche d'entreprises (DINUM, behind annuaire-entreprises.data.gouv.fr), built on INSEE Sirene and the national business register.
- BODACC, the official gazette of commercial notices (insolvency judgments, deregistrations, accounts filings), published by DILA on OpenDataSoft.
The result is mcp-french-company-data: about 460 lines of Python across two modules, three read-only tools, stdio transport, MIT licence. This post covers the choices that mattered: which tools to expose, how the schemas are built, how errors reach the model, how it's tested, and what it can't do.
Which tools, and why only three
The tempting design is one tool per API endpoint. I went the other way and started from the questions a user actually asks:
- "What's the SIREN of X?" →
search_companies - "Tell me everything public about this company" →
get_company - "Is it in trouble?" →
get_bodacc_announcements
Three tools with clear, non-overlapping names are easier for a model to choose between than ten thin wrappers. The server also ships an instructions string that tells the model how they chain together:
mcp = MCPServer(
name="french-company-data",
version=__version__,
instructions=(
"Look up French companies in official open data. Use search_companies to find a "
"SIREN from a name, get_company for the full profile, and get_bodacc_announcements "
"for legal notices (insolvency, deregistration, accounts filing, changes). "
"Data is public and may lag the registries by a few days; always cite the source URL."
),
)
The other big choice was what not to return. The search API can include company officers (names, birth month and year). The server never asks for them:
params: dict[str, Any] = {
"q": q,
"per_page": per_page,
"page": 1,
# Leave out `dirigeants` (names and birth months of individuals).
"minimal": "true",
"include": "siege,complements,finances,tva",
}
Responses are also reshaped. The raw API uses French field names and INSEE codes (5599, "21" for a headcount bracket). The server maps the most common ones to readable labels and keeps the code next to them, e.g. "SA with board of directors (5599)". Unknown codes are returned as-is rather than guessed.
Every tool is annotated read-only, so clients can treat it accordingly:
READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False,
idempotent_hint=True, open_world_hint=True)
Schemas: let the type hints do the work
This uses the v2 Python SDK (mcp 2.3.0 when I ran the tests). Most tutorials you'll find use the v1 FastMCP class; the decorator pattern is the same. Each parameter is an Annotated type with a Pydantic Field, and the SDK turns that into the JSON Schema the client sees:
@mcp.tool(annotations=READ_ONLY)
async def get_bodacc_announcements(
siren: Annotated[str, Field(description="SIREN (9 digits) or SIRET (14 digits)")],
limit: Annotated[int, Field(ge=1, le=20, description="Max announcements, newest first (1-20)")] = 10,
category: Annotated[BodaccCategory | None, Field(
description="Optional filter: collective (insolvency), radiation, dpc (accounts), "
"modification, creation, immatriculation, vente, conciliation, "
"retablissement_professionnel, divers")] = None,
) -> dict[str, Any]:
BodaccCategory is a Literal[...] of the ten BODACC families, so the generated schema contains a real enum, and the ge/le bounds become minimum: 1 / maximum: 20. The model sees the allowed values instead of having to guess French category codes. On search_companies, query has min_length=2 and the optional postal code has pattern=r"^\d{5}$".
The docstring becomes the tool description, so I wrote those for the model, not for humans reading the code: what it returns, and what it deliberately doesn't ("Personal data of company officers is intentionally not returned").
Because the functions return a dict, the SDK also publishes an output schema and returns structured content, which is what the tests assert on.
Error handling: three layers
A tool that raises a random exception gives the model nothing to work with. Here every failure ends up as a tool result with isError: true and a sentence the model can act on.
1. Schema validation, done by the SDK before my code runs. Calling search_companies with query: "d" returns (real output):
Error executing tool search_companies: 1 validation error for search_companiesArguments
query
String should have at least 2 characters [type=string_too_short, ...]
2. Domain validation, before any network call. A SIREN has a Luhn check digit, so a typo can be caught locally:
def _siren_or_fail(raw: str) -> str:
siren = normalize_siren(raw)
if not siren:
raise ToolError(f"'{raw}' is not a valid SIREN (9 digits) or SIRET (14 digits).")
return siren
normalize_siren strips spaces, truncates a 14-digit SIRET to its SIREN, and has one hard-coded exception: La Poste (356000000), whose SIREN doesn't pass the Luhn key.
3. Upstream failures. The HTTP layer only raises one exception type, SourceError, with a message meant to be shown as-is. Tools convert it to ToolError:
try:
raw = await data.bodacc(s, limit=limit, famille=category)
except SourceError as exc:
raise ToolError(str(exc)) from exc
A "not found" is also an explicit error, with the most likely reason. Some companies opt out of public listing ("non-diffusible"), and the API simply doesn't return them:
raise ToolError(f"No public record for SIREN {s}. It may not exist, or be "
"'non-diffusible' (opted out of public listing).")
Being a polite API client
Recherche d'entreprises documents a limit of 7 requests per second per IP. BODACC on OpenDataSoft exposes an anonymous daily quota in its X-RateLimit-* headers (20,000 calls per day when I looked). An assistant looping over a list of suppliers can hit those fast, so the client:
- spaces requests (at most 4/s for the search API, 2/s for BODACC) with a small async limiter;
- caches identical requests in memory for 5 minutes;
- sends a descriptive
User-Agentwith the repo URL; - on HTTP 429, waits once if
Retry-Afteris 10 seconds or less, otherwise gives up with a clear message instead of blocking the conversation:
if resp.status_code == 429 and attempt == 0:
delay = _retry_after(resp.headers.get("Retry-After"))
if delay is not None and delay <= MAX_RETRY_AFTER:
await self._sleep(delay)
continue
raise SourceError(f"{source}: rate limit reached, retry later "
f"(Retry-After: {resp.headers.get('Retry-After', 'n/a')})")
The limiter takes clock and sleep as parameters, which makes it testable without waiting.
Tests: three levels, no network by default
The suite has 27 offline tests plus one live test, opt-in. I re-ran them while writing this (Windows, Python 3.13.15, mcp 2.3.0, pytest 9.1.1):
$ pytest
collected 28 items
tests\test_live.py s
tests\test_server.py ............
tests\test_sources.py ...............
======================== 27 passed, 1 skipped in 4.58s ========================
$ LIVE=1 pytest -m live
====================== 1 passed, 27 deselected in 2.80s =======================
HTTP is simulated with httpx.MockTransport, serving JSON fixtures recorded from the real APIs and logging every request. That lets tests assert on what is sent, not only on what comes back:
async def test_get_company_invalid_siren_is_tool_error(server, fake):
async with Client(server) as c:
res = await c.call_tool("get_company", {"siren": "123"})
assert res.is_error
assert "not a valid SIREN" in res.content[0].text
assert fake.requests == [] # rejected before any HTTP call
The MCP layer is tested through a real client. Client(server) connects in-process, so list_tools and call_tool go through the SDK exactly as a host would: schema generation, validation, structured output, error wrapping.
The stdio launch is tested twice: once by spawning python -m french_company_data with the SDK's stdio client, and once with a hand-written JSON-RPC exchange (initialize, notifications/initialized, tools/list) over the process pipes. That second test catches a classic stdio bug: anything printed to stdout that isn't protocol. It's also why main() turns down httpx logging before starting.
One fixture needed care: the insolvency example comes from a real BODACC notice about a small company. The recording script keeps the structure and wording but replaces the identity with a fictitious one (SAMPLE FLOORING SARL, SIREN 732829320) and redacts the name of the court-appointed person. The search fixtures also drop sole proprietorships, which are named after private individuals.
Plugging it into Claude Desktop or another client
It's a stdio server, so any MCP host that can launch a command works. For Claude Desktop, add this to claude_desktop_config.json (Settings > Developer > Edit Config) and restart:
{
"mcpServers": {
"french-company-data": {
"command": "uvx",
"args": ["--from", "git+https://github.com/Kamelyoul/mcp-french-company-data", "mcp-french-company-data"]
}
}
}
The same block works in ~/.cursor/mcp.json for Cursor; the README has the Claude Code one-liner.
To be precise about what's verified: the tests launch the server over stdio the way these hosts do, and examples/demo_client.py runs all three tools against the live APIs. No GUI client is part of the test suite.
A prompt like "Check the BODACC for SIREN 552032534: any insolvency proceedings?" gives the model get_bodacc_announcements, which returns the notices newest first, an insolvency_in_results flag, and each notice's bodacc.fr URL.
Limits (the real ones)
- Not the full Sirene database. Companies that opted out of public listing are not returned by the API.
- Freshness. Data can lag the official registries by a few days. BODACC notices for sole proprietors are sometimes not linked to a SIREN and can be missed.
- Per-process state. The rate limiter and the cache live in memory, in one process. Two clients running two copies of the server each get their own budget.
- Stdio only. No HTTP transport, no authentication: it runs locally, next to the assistant.
- Partial code tables. Only 10 legal forms get a readable label; the rest come back as raw INSEE codes.
- Endpoint risk. The BODACC OpenDataSoft URL is the one documented on data.gouv.fr today. If DILA moves it, it's one constant to change.
- Not legal or financial advice. For a credit decision or a claim deadline, open the official notice; every result links to it.
Attribution
Data: API Recherche d'entreprises (DINUM, INSEE Sirene, RNE) and BODACC (DILA), both under Licence Ouverte 2.0. Every tool response carries that attribution in a source field. The project isn't affiliated with these administrations.
Code: github.com/Kamelyoul/mcp-french-company-data. If you need a custom MCP server for your own API or data, I take on small projects on Fiverr.
Top comments (0)