Skip to content

refactor!: make the server production-ready on FastMCP 4 - #9

Open
devmadou wants to merge 56 commits into
mainfrom
feat/refacto-clean-archi-2
Open

refactor!: make the server production-ready on FastMCP 4#9
devmadou wants to merge 56 commits into
mainfrom
feat/refacto-clean-archi-2

Conversation

@devmadou

@devmadou devmadou commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Restructuring the whole server, adopting the FastMCP 4 APIs, and fixing what the code
review turned up. Full breakdown in docs/report.md.

Breaking

Three RMES tools renamed — connected clients calling the old names will fail:

before after
RMES_describe_resource describe_rmes_resource
RMES_list_graphs search_rmes_graphs
RMES_run_sparql run_rmes_sparql

Three environment variables renamed:

before after
ES_INDEX_PRODUITS ES_INDEX_PUBLICATIONS
RMES_ENDPOINT RMES_SPARQL_ENDPOINT_URL
ENABLE_INSEEFR_TOOLS ENABLE_INSEE_TOOLS

The Kubernetes env: block needs the first two.

What changed

Architecture. 23 mostly-flat modules became 58 grouped by responsibility. The helpers/,
core/, infra/ and config/ drawers are gone.

FastMCP 4. Dependency injection replaces service-locator lookups, built-in rate limiting and
host protection replace hand-rolled versions, and mask_error_details is on.

About 20 bugs, each reproduced before being fixed. Elasticsearch ApiError escaped the
failure boundary entirely. A SPARQL injection was possible through a resource URI. Every caller
shared one rate-limit bucket behind the ingress. A LIMIT inside a subquery left the outer query
unbounded.

Configuration. 7 variables became 31, all typed and documented.

Performance. Documents now fetch concurrently, and rendering moved off the event loop — it
previously blocked every other request for the length of a batch.

Build. ruff adopted. docker compose up works from a clean clone, which it did not before.

Verification

The auto-generated test suite does not collect and was not relied on. Instead: the tool contract
was diffed at every step, error messages were diffed byte-for-byte, live calls were made to all
three upstreams, and the full stack was verified from an empty volume — 51 seconds cold start,
three indexes restored, twelve tools answering.

Left open

Eight # Business rule: markers record questions only the data owners can answer.

Known gaps: no readiness or liveness probe on the MCP Kubernetes deployment, no Elasticsearch
credentials support, and README.md and SKILL.md await regeneration.

ESDH3T and others added 30 commits August 20, 2026 15:36
Drops the unused tasks extra. No source changes were required: the
lifespan decorator, tool registration and http_app all survive the
major bump. Verified with the full suite plus a live tool call
through an in-memory client.
Project instructions in CLAUDE.md, topic rules under .claude/rules/
(python, git, error handling, logging), shared permissions in
.claude/settings.json, and the FastMCP documentation MCP server in
.mcp.json so contributors get it without local setup.

Personal and reference files stay out of git: settings.local.json,
TODO.md and CLAUDE-example.md are ignored.
`# Fixme:` is ours to fix; `# Business rule:` marks a question only the owner
of the search and data semantics can answer. Preserve current behaviour and
flag it rather than deciding.
…lues

Settings are constructed in server.py and passed down. `get_settings()` is
gone: no service or client reaches for configuration, each takes the specific
value it needs. Elasticsearch queries no longer block the event loop.

Replaces hand-rolled infrastructure with what FastMCP already ships:
- core/middleware.py -> SlidingWindowRateLimitingMiddleware, keyed per client
- @log_tool -> LoggingMiddleware + TimingMiddleware + ErrorHandlingMiddleware
- TrustedHostMiddleware -> http_app(host_origin_protection=...)

Fixes found on the way:
- the title relevance boost was discarded by every INSEE search tool, which
  overwrote its `should` clauses with the collection filters
- _truncate could compute a negative tail, and a zero-length tail appended the
  whole document instead of nothing
- the server crashed on shutdown, awaiting a synchronous close()
- `must_not` was threaded through three tools while always empty

Configuration is not backward compatible. Renamed: TLS_VERIFY -> ES_TLS_VERIFY,
GLOBAL_REQUEST_MIN -> RATE_LIMIT_MAX_REQUESTS, FORWARDED_ALLOW_IPS ->
TRUSTED_PROXY_HOSTS, ENABLE_* -> ENABLE_*_TOOLS. ALLOWED_HOSTS and
TRUSTED_PROXY_HOSTS now take JSON lists. TZ and ES_HOST_LOCAL are removed,
nothing read them. TRUSTED_PROXY_HOSTS defaults to 127.0.0.1 rather than "*",
so a deployment behind a proxy must name it or every client shares one
rate-limit bucket.
A failed call now always reaches the caller as is_error with a message it can
act on. Three mechanisms coexisted before: raising, returning an error inside a
successful payload, and per-item statuses. Only the last one was right, and it
stays where it belongs.

RMES failures were the worst case. A timeout, a syntax error or an unreachable
endpoint returned is_error=False with the reason tucked into an `error` field,
so a failed query looked like a success to the caller.

- AppToolError replaces fail(). Being a class, `raise` is visible at the call
  site, so the eight dead `raise` statements that followed fail() are gone and
  cannot come back.
- mask_error_details is on. Deliberate messages pass through untouched;
  anything else is a bug and is replaced by a generic message.
- ErrorHandlingMiddleware no longer transforms. Its default promoted our errors
  to JSON-RPC protocol errors labelled "Internal error", losing is_error.
- One vocabulary. RMES had six codes of its own; they map onto the shared set.
- An empty result is no longer an error: search_melodi_modalities returns an
  empty list and a count.
- get_insee_document no longer copies raw exception text into its results. That
  text is a returned value, so masking could never redact it.

Also fixes a crash: _fetch_html referenced a variable removed when the client
gained a base_url, so every insee.fr timeout, 404 and network error raised
NameError instead of the intended message.

Function names now lead with a verb: _build_failed_document, _match_exact,
_match_prefix, _strip_graph_base, compute_current_date_iso.

BREAKING CHANGE: the `error` field is removed from RMES_list_graphs,
RMES_describe_resource and RMES_run_sparql. A client reading `output.error`
must instead check is_error on the call result and read the message.
120 findings across the package, most of them rules we had written down but had
no way to enforce.

- UP045 x26: `Optional[X]` where python.md mandates `X | None`
- I001 x11: unsorted imports, including one I introduced by hand
- E501 x3, plus 59 in data/indicators.py which is literal statistics awaiting a
  live source and is ignored per-file until then
- F401 x1: register_extras_send_feedback is imported and never registered, so
  send_feedback is not exposed at all. Marked with an explicit noqa and a note,
  rather than deleted, until we decide to wire it up or drop it.

B904 flagged 20 raises inside except clauses without `from exc`. Adding it
turned out to buy nothing: Python preserves the original as __context__ either
way, so the cause is in the traceback regardless and `from exc` only changes
the wording. What was actually missing is that ErrorHandlingMiddleware was
discarding tracebacks, so no cause reached the log at all. include_traceback is
now on, `from exc` is dropped, and B904 is ignored with the reason recorded.

python.md now explains that the formatter collapses anything fitting on one
line and never splits it, so a trailing comma is what keeps a literal exploded.
Without that the multiline rule reads as broken. Quotes need no rule: the
formatter normalises them.

error.md's chaining rule now states its real weight - server-side hygiene,
nothing crossing the wire - instead of implying it preserves lost information.
error.md still told the reader to write `raise ... from exc` after we removed
every one of them and ignored B904. It now says not to, and why, so nobody adds
them back believing the ignore was an oversight.

It also read as forbidding the Elasticsearch client's own max_retries and
retry_on_timeout, which RetryMiddleware does not replace: one retries the HTTP
connection, the other the MCP request. The rule now separates them.

uvicorn moves to module scope. It is a declared dependency, so importing it
inside __main__ bought no isolation and was the only import in a function body,
against python.md.
The description promised `mainIndicators` with a per-indicator link to pass to
`get_insee_document`, plus `lastArticles` and `keyGraphics`. The tool returns
`indicators` and `count` and no links at all, so the model was instructed to
perform a chain it could not perform, on every call. get_insee_document
pointed at those same links, and now points at the search tools instead.

Cleanup around it, behaviour unchanged at 59 indicators:

- DICT_KV becomes KEY_INDICATORS, which is python.md's own example of the
  distinction
- the header row {"cle": "clé", ...} is deleted along with the runtime filter
  that removed it by comparing three literals on every call
- trailing whitespace lives in the data rather than in a .strip() per read
- the mapping moves to services/insee_indicators.py, so it is testable without
  an MCP server
- the tool is sync: it reads a literal and performs no I/O

The figures themselves are untouched. They are frozen literals that assert
their own dates, which is a decision for the data owners, so it is recorded as
a `# Business rule:` along with the promised-but-never-built output shape - the
best evidence of what the tool was originally meant to be.
Tool descriptions came from a separate `config/tool_metadata.py`, so they drifted
from the code they described: five routing hints pointed at tools that never
existed (`query_insee_rmes`, `search_chiffres_clefs_insee`, `get_MELODI_datasets`)
and `search_insee_documents` documented a `chiffre_clef` parameter its signature
had lost. Descriptions now live in the docstring beside the function, so a rename
cannot leave the prose behind.

Cross-tool routing moved to the server `instructions`, assembled from the enabled
families -- a deployment with `ENABLE_RMES_TOOLS=false` no longer advertises RMES
workflows. Per-tool guidance keeps its WHEN TO USE / WHEN NOT TO USE shape.

Tool parameters are flat: `params: XxxInput` nested every argument one level deep
for no gain. The Input models became reusable `Annotated` aliases, which also
removed the duplication four `# Fixme:` markers asked about.

Names now say what a value is, not what the caller does with it -- `ResultCount`
became `NumberOfResults`, `PublicationYearFilter` became `YearOfReference`, and
enums took the `Choice` suffix so the plain name was free for the alias.

Every parameter description, default, bound, example and enum is byte-identical
to before; only names and nesting changed.

BREAKING CHANGE: three tools are renamed and several parameters with them.
Connected clients calling the old names fail.

  RMES_list_graphs        -> search_rmes_graphs
    contains / category / expand -> graph_uri_substring / graph_category / expand_graphs
  RMES_describe_resource  -> describe_rmes_resource
    uri / graph                  -> resource_uri / graph_uri
  RMES_run_sparql         -> run_rmes_sparql
    full_sparql_query / timeout  -> sparql_query / timeout_seconds

  search_insee_*          geo_niveau        -> geo_level
  get_insee_document      list_of_url       -> document_urls
                          include_sommaire  -> include_table_of_contents
  get_melodi_observations list_of_year      -> years
                          dict_of_columns_and_values -> column_filters
                          number_of_results -> number_of_observations
  search_melodi_datasets  french_query      -> query
                          number_of_results -> number_of_datasets
  search_melodi_modalities columns_id       -> column_ids
                          french_query      -> query
                          number_of_results -> number_of_modalities
`Depends()` and `CurrentContext()` appear as argument defaults, which B008
flags because a shared mutable default would leak between calls. These build
a dependency marker rather than a value: FastMCP resolves them per request,
so the default is never what the function receives.

Configured once here rather than repeating a `noqa` at every injection site.
Melodi's data access lived in one module that the tools wired by hand: each
tool pulled a raw client out of the lifespan context, passed an index name
down on every call, and translated backend failures itself.

Each backend now sits behind a service, built once at startup with its client
and index already bound:

  services/melodi/index_service.py     MelodiIndexService -- Elasticsearch
  services/melodi/api_service.py       MelodiApiService   -- Melodi REST API
  services/elasticsearch_failures.py   one failure boundary for both searches

Tools declare the service they need and FastMCP injects it, so no Melodi tool
imports `elasticsearch` or `httpx` any more, and none carries an index name.
Query construction moves onto the `elasticsearch.dsl` builders insee already
uses; every generated body is identical to the raw dict it replaces, except
that the DSL omits an empty `filter` clause, which is a no-op in Elasticsearch.

The tool contract -- names, input schemas, descriptions, output schemas -- is
byte-identical to the previous commit.

Also corrects four failures the caller could not act on:

- Elasticsearch `ApiError` escaped the boundary entirely, because it does not
  subclass `TransportError`. A missing index surfaced as a generic error. It
  is now mapped by status and marked non-retryable, so the model reports it
  instead of rephrasing the query.
- The Elasticsearch host and port travelled to the client inside the
  exception message. Only the exception type is sent now; the full cause
  stays in the server log.
- Melodi answers HTTP 400 for an unknown dataset, column and modality alike,
  yet the message only suggested checking modality codes. It now names every
  remedy and quotes the upstream detail that distinguishes them.
- Values were forced through `str()`, so a field arriving as a list became
  the literal "['GEO', 'SEX']". Malformed data now fails validation rather
  than reaching the caller as text.
`core/` and `infra/` were junk drawers: neither name states a membership
rule, so unrelated files accumulated. `core/` held an error type, a 186-line
instructions document, logging setup and a rate-limit helper. `infra/` held
the lifespan, three client accessors and the dependency providers -- and
imported `services/`, inverting the hierarchy its own name claimed.

Both are gone, along with `config/`, whose single module now names itself:

  settings.py  errors.py  instructions.py  lifespan.py
  logging.py   rate_limiting.py  dependencies.py

The three client accessors fold into `dependencies.py`, so one module holds
everything a tool can inject. They keep their explicit-`ctx` signatures and
sit under their own heading until insee and rmes move to `Depends`.

`data/`, `models/`, `services/` and `tools/` stay: each one can state what
belongs in it.

Pure relocation. The tool contract -- names, input schemas, descriptions,
output schemas -- is byte-identical, as are the generated Elasticsearch
queries and every `# Fixme:` and `# Business rule:` marker. The Fixme about
untyped dependency functions moves from `infra/__init__.py` into
`dependencies.py`, where it now names the cause: the lifespan context is an
untyped mapping, so the return annotations are asserted, never checked.
It sat inside the package at `src/mcpdiffusion/.env.example`, which shipped
it in the wheel and put the documented configuration surface where nobody
looks for it. Nothing referenced that path except README.

`docker-compose-dev.yaml` reads `mcp-diffusion.env` and is unaffected, and
`.gitignore` ignores `*.env` but not `*.env.example`, so the example stays
tracked.
insee's data access lived in three modules the tools wired by hand: each
search tool assembled its own query, threaded an index name down on every
call, and translated Elasticsearch failures itself.

Each backend now sits behind a service, built once at startup with its client
and index already bound:

  services/insee/index_service.py     InseeIndexService    -- Elasticsearch
  services/insee/document_service.py  InseeDocumentService -- insee.fr pages

Tools declare the service they need and FastMCP injects it, so no insee tool
imports `elasticsearch` or `httpx` any more, and none carries an index name.
`build_key_indicators` moves into the homepage tool, its only caller, and
`services/insee_indicators.py` goes with it.

The one query builder driven by three booleans -- `must_not_rapides`,
`must_only_rapides`, `chiffre_clef` -- becomes one builder per search. Those
flags encoded which tool was calling and allowed combinations that mean
nothing. The clause lists they passed around as a bare tuple are now a frozen
`QueryClauses` with named `must`, `filter` and `should`.

Every generated Elasticsearch body is identical to the one it replaces,
checked through the registered tools rather than in isolation.

Also corrects three failures the caller could not act on:

- `search_insee_chiffrecle` reported "INSEE documents search backend
  unreachable", copy-pasted from the documents tool. Each search now names
  itself.
- Elasticsearch `ApiError` escaped the boundary entirely, because it does not
  subclass `TransportError`. A missing index surfaced as a generic error. All
  three searches now share the boundary that maps it by status.
- `document_urls` entries were typed `object` and coerced with `str()`, though
  the model has always declared `list[str]`.

`es_index_produits` becomes `es_index_publications`, so the setting and the
parameter reading it finally share one name. The value stays "produit": that
is the real index. Deployments setting ES_INDEX_PRODUITS must rename it to
ES_INDEX_PUBLICATIONS -- an unknown variable is ignored, not rejected.

The only change to the tool contract is `DocumentResult.status`, now
`Literal["success", "error"]` rather than `str`. That adds an enum to the
output schema without changing any value a caller receives.
rmes was one 502-line module holding the graph taxonomy, SPARQL transport, a
module-level cache and the three tool operations, with every tool passing the
endpoint down on each call.

It splits along the seam it actually has -- domain versus transport -- rather
than the index/api seam the other two sources use:

  data/rmes_graph_categories.py        which families exist, as static data
  services/rmes/graph_taxonomy.py      what matching a family means
  services/rmes/graph_store_service.py RmesGraphStoreService: queries and cache

Tools declare the service through `Depends` and build their own output, so no
rmes tool imports `httpx` any more and none carries the endpoint. Every tool in
the server now takes its service the same way, and the last explicit-`ctx`
accessor in `dependencies.py` goes with it.

The category families were declared twice: `GraphCategoryChoice` listed the
keys and `CATEGORY_DEFS` defined them, free to drift apart. The families are
now static data -- no classes, no lambdas, just the label, description and the
test each one declares -- the enum is derived from their keys, and the taxonomy
turns the declarations into matchers. Adding a family makes it selectable.

`_execute_sparql` returned a dict whose meaning depended on magic keys --
`format`, `data`, `_meta.limit_added`, `_meta.hint`. It returns a frozen
`SparqlResponse` with those as fields, so the turtle and JSON shapes are
visible in the type rather than discovered at the call site.

Five `# Fixme:` markers are resolved by code, not deletion:

- The graph cache was a module-level dict, and a second request arriving during
  the long listing re-ran it. It is now instance state behind an `asyncio.Lock`
  with a second freshness check inside, so ten concurrent callers run the query
  once.
- The summary categorised every row, then the expansion pass categorised them
  all again. Rows are grouped once and the expansion only decides whether the
  grouping is reported.
- `CategoryMatcher` was `Any`; it is `Callable[[str], bool]`.
- `_CategoryRule` was a hand-written `__slots__` class; it is a frozen
  dataclass.
- The logger asking for a naming convention was never called by any code, in
  this module or the one it replaces. Both are gone.

Four values move out of the code and into the documented configuration: the
graph namespace and the listing's timeout, row cap and cache lifetime. The two
RMES URLs now say which is which: `RMES_SPARQL_ENDPOINT_URL` is posted to,
`RMES_GRAPH_BASE_URI` is only ever a prefix. Deployments setting RMES_ENDPOINT
must rename it -- an unknown variable is ignored, not rejected.

Fixes a defect in the Turtle branch: `result.get("limit_added") and max_rows`
yields `False` when the caller supplied their own LIMIT, which Pydantic then
coerced to `0` on an `int | None` field. A CONSTRUCT or DESCRIBE query reported
`limit_added: 0` where the JSON branch reports `null`, and no limit of zero was
ever added. Both branches now report `null` -- the only change to a value a
caller receives.

Verified against the live endpoint: eleven of twelve recorded cases match
exactly, the twelfth being that fix. They cover 703 graphs across 12 families,
filtering by substring and by family, the expanded listing, a 43-property
resource description, SELECT, ASK, CONSTRUCT, an added LIMIT, an empty query
and a non-SPARQL one.
Commit messages carried `Co-Authored-By` and a session link. They say who
typed the change rather than what it does, so they do not belong in the
history the changelog is read from.

The rule states that it overrides tooling defaults, because the assistant
harness instructs the opposite.
Every table in `data/` serves exactly one source, but they sat flat in the
package and only the rmes one carried a prefix. They now group the way
`services/`, `tools/` and `models/` already do -- folder is the source,
filename is what it holds:

  data/insee/  geography.py  themes.py  indicators.py
  data/rmes/   graph_categories.py

The `rmes_` prefix goes with the move, since the folder says it.

`pyproject.toml` carried a per-file ruff ignore for the long literal tables in
`data/indicators.py`. Moving the file orphaned it and surfaced 52 line-length
errors in a file that had been exempt, so the path moves with it. Two comments
naming the old paths are corrected, including the one inside the
`# Business rule:` marker on `get_insee_homepage` -- the path only, not the
rule.

Pure relocation: the tool contract is byte-identical, and the homepage tool
still returns all 59 indicators.
`ES_HOST` had no default, so the server refused to start without it even when
the insee.fr and Melodi tools were both disabled. CLAUDE.md says only rmes
works without Elasticsearch; the code disagreed, and a rmes-only deployment
was impossible.

The host is now genuinely optional, and a settings validator demands it only
when a family that searches is enabled, naming the flags that would relax the
requirement instead of failing on a bare field name.

The lifespan built every client and service unconditionally, so it could not
express that. It splits into one module per source, each an async context
manager that yields its own fragment of the lifespan context and closes its
own clients:

  lifespan/elasticsearch.py  the client insee.fr and Melodi share
  lifespan/insee.py          scraper and services
  lifespan/melodi.py         api client and services
  lifespan/rmes.py           sparql client and service

`AsyncExitStack` replaces the unconditional `finally`, so a family that was
never built is never torn down, and the two families that need Elasticsearch
nest under it -- the dependency is structural rather than a comment, and no
optional client is handed to a parameter that requires one.

`build_lifespan` went from seventeen parameters to one. It takes the whole
`Settings` because it is the composition root, the one place whose job is to
read configuration; the per-source builders still take narrow values, which is
where `python.md` means it. That resolves the `# Fixme:` asking for exactly
this.

In rmes-only mode no Elasticsearch client is constructed at all, checked by
counting constructor calls rather than reading the code.
`dependencies.py` held the providers for all three sources, so adding a source
meant editing a file every other source already depends on. It becomes a
package with one module per source, matching how `data/`, `services/`,
`tools/` and `lifespan/` already group:

  dependencies/insee.py  dependencies/melodi.py  dependencies/rmes.py

Nothing is re-exported from the package `__init__`: a tool imports from its
own source's module, so a new source adds a file rather than editing a shared
one.
The deployment passed no TRUSTED_PROXY_HOSTS, so uvicorn kept its default of
trusting only 127.0.0.1. Behind the Ingress the peer is a cluster IP, never
that, so uvicorn ignored the forwarded caller address and reported the Ingress
as the client of every request.

The rate limiter keys on that address. Every caller therefore shared one
bucket: a hundred requests a minute for the whole world combined, and one busy
client could lock out the rest, while the code read as though the limit were
per caller.

Trusting any peer is safe only while nothing but the Ingress can reach the
pod. A NetworkPolicy restricting traffic to the Ingress namespace is what
makes that true; without one, a pod inside the cluster could reach the server
directly and claim any caller address.
The Host and Origin guard was configured in a way that enforced the opposite
of what it looked like.

`host_origin_protection="auto"` leaves the decision to a heuristic that defers
to existing handling for reverse-proxy deployments, while `allowed_hosts` was
left at its `["*"]` default, which accepts any Host. The check ran and
approved everyone: a request claiming `Host: evil.example.com` reached the
session manager untouched.

Meanwhile `allowed_origins` was never passed and had no setting, so the Origin
half ran on a default nobody chose and could not adjust. A browser client on
another origin was refused with no way to permit it.

So the rule that was written did nothing and the rule that was not written did
the refusing. Protection is now enforced rather than inferred, the Ingress
hostname is declared, and the browser origins are a setting: empty today,
because no browser client calls this server.

A request claiming another hostname is now answered 421, and a cross-origin
browser request 403. Local development is unaffected: with nothing set,
`ALLOWED_HOSTS` stays `["*"]`.
Mamadou Diallo-Ext added 26 commits September 8, 2026 12:10
`ErrorCode` was a `Literal`, which Python never checks. A typo was accepted in
silence and reached the caller as an invented code: `AppToolError("TOTALLY_MADE_UP",
...)` produced `[TOTALLY_MADE_UP] oops` with nothing to catch it.

It is a `StrEnum` now, and all twenty-four raises pass a member. A typo is an
`AttributeError` at the line that wrote it, and an editor marks it before the
code runs. No runtime coercion is added: validating the code inside `__init__`
would raise while an `except` block was already handling a failure, replacing
the real error with a confusing one. Referencing a member fails earlier and
more clearly.

`UNKNOWN` becomes `INTERNAL_ERROR`. Every other code says where the fault is
-- the caller's input, a backend, the network -- while `UNKNOWN` described our
ignorance, which reads as an admission rather than information. The caller
learns something useful from `INTERNAL_ERROR`: the fault is in this server, so
rephrasing will not help.

That code had never been used: the one place needing it wrote `"[UNKNOWN] ..."`
as a literal, because it reports a per-URL failure in the result rather than
raising. It now builds the prefix from the same vocabulary, so the two cannot
drift apart, and that branch is exercised by forcing an unexpected failure
rather than assumed to work.

The module is `error.py`, matching the rule file that governs it.

Message text is unchanged throughout, which matters because the message is the
error contract: every insee and Melodi failure message, both live rmes error
paths, and the tool contract are identical.
…gnature

The bare * marker was in only three signatures, and every caller already named
its arguments, so it duplicated a convention the layout rules already carry.

Ruff FBT003 replaces the one case it genuinely guarded: a bare boolean passed
positionally to AppToolError. FBT001 and FBT002 stay off, because a tool's
boolean parameter is a named field of its JSON schema.
run_rmes_sparql's schema bounds were applied inside the shared execute path, so
the graph listing was silently capped at 60s no matter what
RMES_GRAPH_LISTING_TIMEOUT_SECONDS was set to, and describe_rmes_resource drew
its own budget from a tool it does not expose.

The ceiling now sits in run_rmes_sparql, next to the max_rows clamp, and
describe_rmes_resource carries its own timeout and row limit.
…igurable

ES_MAX_RETRIES and INSEE_DOCUMENT_MAX_MARKDOWN_CHARS were hardcoded constants,
though both depend on the deployment: how reliable that Elasticsearch is, and
how much context the calling model has. Both reach their clients through the
lifespan rather than being read where they are used.
The two clients announced themselves as McpDiffusion/0.1 and MCP-RMeS/2.0: two
product names and two versions, neither matching pyproject.toml. Both now send
McpDiffusion/0.1.0. insee.fr keeps its browser string, which it needs to be
served the real markup.
Both files carried a Fixme claiming these values belong in settings. They do
not: they bound the published tool schema, so an env-driven value would
advertise a different contract per deployment, and they are read at import
time, before any Settings instance exists. The banner said "Constants", which
explained nothing about why they sit beside the schema they shape.
…ctured

send_feedback was registered until d00e9a7 removed the else branch that carried
it. It was the only tool registered exclusively there, so it went unnoticed
while SKILL.md kept telling clients to call it.

It now records to the server log instead of appending to a file inside the
container, which was lost on every restart, blocked the event loop, and was
tracked by git despite holding user-submitted content. Client text is
JSON-encoded into the message so an embedded newline cannot forge a second log
line, and both fields are length-bounded in the schema.

Registration is gated on ENABLE_FEEDBACK_TOOL, matching the three data sources,
and the handshake instructions name the tool only when it is enabled.
feedback.md stays on disk but is no longer tracked.
error.py and the Elasticsearch failure translator sat at the package root and in
services/ respectively, though both belong to one concern and are used by every
layer. errors/ now holds them, with a membership rule the root never had: does
it define or produce an AppToolError.

The translator also gains a home it lacked. It was never a service -- it
orchestrates nothing and holds no client -- so services/ only ever housed it for
want of anywhere better.
rate_limiting.py was named after the feature that consumes the function rather
than what the function does: it resolves the caller's address and knows nothing
about windows or buckets. It is now utils/client_host.py, named for its subject,
and the docstring says why the middleware context argument goes unused.

The package root is left holding only what server.py needs to boot.
enable_inseefr_tools was the only identifier using "inseefr". Five directories,
all five tool names and every other setting already say "insee", so the flag was
the outlier rather than the convention.

A comment now carries what the longer name was reaching for: INSEE is the
institution and owns all three sources, while this flag is only the website.

ENABLE_INSEEFR_TOOLS becomes ENABLE_INSEE_TOOLS. No manifest sets it, so nothing
breaks silently, but it belongs in the release notes.
The guidance sections point at each other -- the Melodi entries route to
search_insee_documents, the insee.fr ones to run_rmes_sparql -- so a deployment
with a family disabled was telling the model to call tools that were never
registered.

A global rule now says the tool list is what exists, rather than trying to keep
prose and configuration in step. The insee.fr bullet that routed to two sources
in one sentence is split, one per source.
get_insee_document took an unbounded list, and each entry costs a fetch of
insee.fr plus a full extraction. A caller could ask for hundreds in one call and
hold the request open while insee.fr absorbed the load.

The bound sits in the schema rather than in a runtime check, so the model is
told the limit instead of discovering it through an error.
ThemeChoice and ThemeConjonctureChoice listed by hand the same labels that the
tables in data/insee/themes.py already hold, so a theme added to one was invisible
to the other. Both are now derived from those tables, following what rmes already
does with GraphCategoryChoice. The enum values are unchanged; only their order
follows the tables now.

The curated indicator entries gain a TypedDict naming their three keys. They stay
plain dicts, because data/ holds plain data and the consuming layer is what turns
it into typed objects.
describe_rmes_resource interpolates the resource and graph URIs into `<...>`, so
a URI carrying `>` closed the brackets and the rest of the string ran as query
text. Both parameters are now checked against the IRI grammar, which forbids
those characters anyway, so nothing legitimate is refused.

Not an escalation -- run_rmes_sparql already accepts arbitrary SPARQL from the
same caller. What it fixes is the diagnosis: a malformed URI now fails naming
the parameter, instead of coming back as a syntax error from RMES.
MAX_DOCUMENT_URLS bounds the list in the schema, so the note asking for it was
stale. The one below it opened with "on top of that" and lost its antecedent, and
claimed the sequential fetching blocks the event loop -- awaiting in a loop is
serial, not blocking. Reworded to say what is actually wrong with it.
The check looked for LIMIT anywhere in the query, so one inside a subquery -- or
the word inside a string literal -- counted as the caller's own and no cap was
added. The outer query then returned as many rows as the endpoint would give.

A LIMIT that bounds the whole query is the last thing in it, with only OFFSET
allowed to follow, so the check is anchored to the tail.

This bounds what comes back, not what RMES computes: a subquery with no limit of
its own is still evaluated in full upstream. The per-query timeout stays the only
guard on that.
…lter

An observation whose `dimensions` was null crashed the filter: the `{}` fallback
only applies when the key is absent, not when its value is null. That raised an
AttributeError, which is not a ToolError, so the caller lost the entire batch to
a generic internal error over a single row.

Reading the year moves into its own function, total for every shape of missing
period. The open question -- whether TIME_PERIOD is always a string, which we do
not control -- stays marked, with a note that coercing it would hide a change in
the upstream format rather than surface it.
A batch took the sum of its URLs: each was fetched only once the previous one
had been rendered. They now go out together, bounded by MAX_DOCUMENT_URLS on the
schema rather than a second limit of its own.

Rendering one URL moves into its own method, which returns a failure as a result
instead of raising, so one bad URL still costs the caller nothing but that entry
and the order still matches the URLs given.
Turning a page into markdown costs 80-1000 ms of CPU, and it ran on the event
loop. During a batch the server served nothing else: a probe scheduled every
10 ms got zero turns until the whole batch finished.

Rendering and table-of-contents parsing now run in a worker thread. The batch
itself costs about 7% more wall-clock; in exchange the loop stays responsive,
with a worst observed stall of 56 ms.

Sharing TRAFILATURA_OPTIONS across threads was checked first: extract() never
writes to it, and concurrent runs return byte-identical output to serial ones.
The return annotations are correct; what the note described -- that
`ctx.lifespan_context` is an untyped mapping, so they are asserted rather than
checked -- follows from the FastMCP lifespan contract and holds for the Melodi
and RMES modules just the same. Only insee.py said so, which made the three read
as though one of them were different.
Two notes sat on this table. Whether static reference data belongs in the source
tree was settled when data/ was reorganised by source, so that one goes.

The other is real but not ours to answer: the ids are insee.fr's own, transcribed
by hand, and nothing in this repo can check them. A wrong id searches the wrong
theme without failing, which makes it a Business rule -- preserved, flagged, left
to whoever owns the site's taxonomy.
"I wonder whether the API supports filtering" is now answered by testing it. It
does filter, but `TIME_PERIOD=2025` matches only periods starting on that date:
on DS_DECES_MORTALITE_SERIES, which holds annual and monthly rows together, it
returns the yearly row and January while skipping August entirely. Moving the
filter upstream would look like an obvious speed-up and would silently drop most
of a monthly dataset, so fetching everything is deliberate rather than pending.

The note on the value's type shrinks to the one thing that is not obvious from
the code: str() is absent on purpose, because a coerced value would match nothing
instead of failing.
The build context carried the whole working tree, .venv and .git included, to the
daemon on every build; a .dockerignore now excludes it. uv was pulled from the
:latest tag, so the tool could change between two builds of the same commit.

`uv sync` used --frozen, which installs a stale lock without complaint: a
dependency added to pyproject.toml but never locked would be missing from the
image and surface as an ImportError at runtime. --locked fails the build instead.

The image also gains a healthcheck, so compose can wait for the server rather
than guess, OCI labels tracing it back to this repository, and the venv on PATH
so `python` means the right one when exec-ing into a container. Cache mounts keep
uv's downloads between builds without storing them in the image.
Compose could not start the project. It referenced an image nobody built, ran no
Elasticsearch though the insee.fr and Melodi tools require one, expected a network
created by hand, and read an env file that does not exist. Anyone cloning the repo
had no way to run it.

It now builds the server, starts Elasticsearch with the INSEE indexes, restores
the snapshot and waits for each step to be healthy before the next. The restore
skips itself when the data is already there, so a restart costs seconds rather
than a minute.

Elasticsearch is built from the published amd64 image rather than run from it:
the snapshot is copied into the official multi-arch base, so it runs native on
arm64 and amd64 with no emulation. Setting path.repo there also removes the
dependency on configuration baked into an image we do not own.

ES_HOST defaults to the bundled service but yields to one set in .env, which is
what lets an existing cluster be used with `up mcpdiffusion inspector`. The
example env file no longer ships a value that would be wrong inside a container.
Fifty-five commits are hard to review as a list. This groups them by what they
address -- architecture, FastMCP adoption, bugs, security, configuration,
performance, build -- and states the before and after for each.

It also records what was deliberately not done: the eight questions left for
whoever owns the data semantics, and the four known gaps, so neither reads later
as something that was missed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant