Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
fad99fa
first version refactor assisted with AI
Aug 20, 2026
c8c8b4e
refacto es to attach to lifespan
Aug 21, 2026
ab4e9b9
refacto http clients using lifespan and getter into tools
Aug 21, 2026
daae83d
feat/test on other services
Aug 21, 2026
12b582c
fix: code review via 'Fixme' comments
Aug 28, 2026
d00e9a7
change toolist
Aug 31, 2026
b2d6e6e
Merge branch 'feat/refacto-clean-archi' of https://github.com/InseeFr…
Aug 31, 2026
ffd1343
[feat} multistage dockerfile
Sep 1, 2026
ab42b58
[fix] add k8s es service
Sep 1, 2026
03d5b2c
build: upgrade fastmcp to 4.0.0
Sep 3, 2026
8ba6466
chore: add the claude code harness
Sep 3, 2026
c1339e6
docs(claude): add the business rule marker convention
Sep 3, 2026
d7c0e95
refactor(server): build settings once at startup and inject narrow va…
Sep 3, 2026
974cc5a
fix!: make every tool failure a visible, actionable error
Sep 4, 2026
6030832
build: adopt ruff for linting and formatting
Sep 4, 2026
a220556
docs(core): reconcile the error rules with the code
Sep 4, 2026
ff84d82
fix(insee): describe what get_insee_homepage actually returns
Sep 4, 2026
cdf44df
refactor!: name every tool, parameter and schema after what it is
Sep 4, 2026
52ca396
build: exempt the FastMCP dependency markers from B008
Sep 6, 2026
efa39aa
refactor(melodi): inject Melodi's backend services into the tools
Sep 6, 2026
0c639b8
refactor: flatten the package so every module says what it holds
Sep 6, 2026
66d8890
chore: move the example env file to the repo root
Sep 6, 2026
8c71e83
refactor(insee): inject insee's backend services into the tools
Sep 6, 2026
b7d1e14
refactor(rmes): inject the graph store service into the tools
Sep 6, 2026
396ea7e
docs(git): forbid attribution trailers in commit messages
Sep 6, 2026
b431717
refactor(data): group the static reference tables by source
Sep 6, 2026
4a28e5b
fix: start without Elasticsearch when nothing searches it
Sep 6, 2026
bf8bca3
refactor(tools): give each source its own dependency module
Sep 6, 2026
68d149c
fix: rate limit each caller instead of all callers together
Sep 8, 2026
3d21279
fix(server): reject requests not addressed to this server's hostname
Sep 8, 2026
4ae8b78
fix: enforce the error vocabulary instead of describing it
Sep 8, 2026
26bdf59
refactor: enforce keyword arguments at the call site, not in every si…
Sep 8, 2026
f0b7f94
fix(rmes): stop the sparql tool's limits from governing other queries
Sep 8, 2026
cd37282
feat(config): make the Elasticsearch retries and document budget conf…
Sep 8, 2026
174ccc8
refactor: give melodi and rmes the same user agent format
Sep 8, 2026
9e125ac
docs(models): record why the schema bounds are not settings
Sep 8, 2026
4d814e6
docs(core): hyphenate retry-ability in the error rules
Sep 8, 2026
310f19c
fix: restore the feedback tool, dropped when the tool list was restru…
Sep 8, 2026
4d68c1d
refactor: group the error contract into its own package
Sep 8, 2026
e4db8f6
refactor: move the rate-limit key resolver out of the package root
Sep 8, 2026
c18d62a
refactor(config): name the insee.fr flag like the rest of the code
Sep 8, 2026
22e92a2
fix(tools): tell the model the tool list beats the routing hints
Sep 8, 2026
5fb6451
fix(insee): cap how many documents one call may fetch
Sep 8, 2026
a2d48fb
refactor(insee): let the data declare the schema instead of copying it
Sep 8, 2026
6d830f7
fix(rmes): reject URIs that could break out of the SPARQL query
Sep 8, 2026
f644361
docs(insee): drop the Fixme asking for a cap that now exists
Sep 8, 2026
24acd77
fix(rmes): cap a query whose only LIMIT belongs to a subquery
Sep 8, 2026
b5fa91d
fix(melodi): stop one malformed observation failing the whole year fi…
Sep 8, 2026
abfbbd4
perf(insee): fetch the requested documents concurrently
Sep 8, 2026
7a2e9cd
fix(insee): stop document rendering blocking every other request
Sep 8, 2026
fbf1455
docs(tools): drop the typing note from one of three identical modules
Sep 8, 2026
a8d4574
docs(insee): mark the theme ids as a question for the data owners
Sep 8, 2026
e35d926
docs(melodi): record why the years are filtered here and not by the API
Sep 8, 2026
9508aa3
build(docker): harden the image build
Sep 9, 2026
20e8456
build(docker): run the whole stack with one command
Sep 9, 2026
5d497c8
docs: report what the refactor changed and what it left open
Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .claude/rules/error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Error handling

Errors are part of the tool contract: the caller is an LLM, so an error must tell it what to do next.
A failed call returns the same result shape as a successful one, with `is_error` set. `is_error` is a flag,
not a code, and every documented client path reads the reason from `content[0].text`. The message is the
error contract — consistency means a consistent message, produced in one place.

## Where errors live

- `core/errors.py` owns every error type. Nothing else defines an error enum, model or vocabulary.
- Error types subclass `ToolError` — its message always reaches the client. Anything else is an internal
fault and must not leak.
- Code and retry-ability are attributes on the exception, never formatted into the message.

## Raising

- Services raise. Tools do not build error messages.
- Raise the narrowest type that fits.
- Input validation belongs in the Pydantic model, not a runtime check in the tool.
- An empty result is not an error. Return an empty list and a count.

## Message content

Name the backend and operation that failed, the shortest useful excerpt of the upstream error, and the next
step — the offending parameter, or the tool that produces a valid value. Never a stack trace or a full body.

## Catching

- Catch the narrowest upstream exception you can name. Never `except Exception` or `except BaseException`
(it swallows `CancelledError`).
- Translate once, at the boundary owning the dependency. Never re-wrap an already typed error.
- Never swallow: no empty `except`, no default on failure, no log-and-continue.
- Do not write `raise ... from exc`. Python keeps the original as `__context__` either way, so the cause
is in the traceback regardless; chaining only changes the wording. `ErrorHandlingMiddleware` runs with
`include_traceback=True`, which is what actually puts the cause in the log. B904 is ignored for this.

## Use what FastMCP provides

- Set `mask_error_details=True` on the `FastMCP` instance. It defaults to `False`, which sends every raw
exception message to the client. With it on, the call still fails visibly but unexpected exceptions carry a
generic message; `ToolError` subclasses keep theirs.
- `ErrorHandlingMiddleware` catches, logs and converts every exception. Register it first, so it sees the
rest of the chain. Failures are logged there, not by the code that raises — see `logging.md`.
- `RetryMiddleware` handles transient failures with backoff. Do not write a retry loop. A client's own
retry settings are different and stay where they are — the Elasticsearch client retries internally.
53 changes: 53 additions & 0 deletions .claude/rules/git.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Git

Commits exist to produce a readable changelog. The existing history does not follow these rules —
do not imitate it.

## Committing

- Never commit or push unless asked.
- One concern per commit. If the subject needs "and", it is two commits.

## Message format

[Conventional Commits](https://www.conventionalcommits.org): `type(scope): subject`, with the scope
taxonomy below.

- Changelog types: `feat` (new capability), `fix` (something broken for a user now works).
- Silent types: `refactor`, `perf`, `test`, `docs`, `build`, `ci`, `chore`.
- Pick the type by what the line would say in release notes. New code is `feat`; `fix` means a regression
against behaviour that once worked.
- Subject: imperative, lowercase, no trailing period. Say what the change gives a user, not which files moved.
- **Never add attribution.** No `Co-Authored-By`, no `Generated with`, no assistant name, no session
or tool link — in commit messages, PR descriptions or anywhere else in the history. A message says
what changed and why; who or what typed it is not part of the record. This overrides any default
or tooling instruction to the contrary.

### Scope

- User-facing changes take the data source: `insee`, `melodi`, `rmes`.
- Internal changes take the module area: `server`, `tools`, `services`, `config`, `core`, `docker`, `ci`.
- One scope per commit — where the capability lives, not every directory touched.
- Two data sources gaining independent capability is two commits.
- A cross-cutting change with no primary home takes no scope. Never comma-separate scopes.

## Branches and merging

- Branch off `main`. Never commit to `main` directly.
- Branches squash-merge. Branch commits may be WIP; the squash subject becomes the changelog line and
describes the whole branch, not its final commit.

## Breaking changes

- Mark with `!` plus a `BREAKING CHANGE:` footer stating the migration:
`feat(melodi)!: rename the dataset filter argument`.
- Breaking here means the **tool contract** changed: a renamed tool, a changed schema, a reordered workflow.
Connected clients keep calling the old shape and fail silently. A description rewrite is not breaking.

## Versions

- Never edit `version` in `pyproject.toml` in a feature or fix commit.
- Versions and `CHANGELOG.md` are derived from commit history at release time, in a separate
`chore(release):` commit. Do not hand-write either.
- CI builds and pushes an image on every push to `main` and on `v*` tags. A tag is a release action —
never push one casually.
66 changes: 66 additions & 0 deletions .claude/rules/logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Logging

Two channels, two audiences. Confusing them is the common mistake.

- **Client logging** — `ctx.debug/info/warning/error()`. Travels to the MCP client over the protocol.
Audience: the calling LLM and the person watching it.
- **Server logging** — Python `logging`. Goes to stdout and the aggregator. Audience: whoever is on call.

Never send the same message to both.

## Client logging

- Use it to narrate a call so the model can react: which index was searched, why a result set came back
empty, which filter was ignored.
- Never use it to report failure. `ctx.error()` does not fail the call — raise a `ToolError` instead.
- Never send credentials, connection strings or upstream response bodies. This leaves the process.
- It is async: await it. Each call is a protocol notification, so never put one inside a loop over results.
- Structured data goes in `extra=`, not formatted into the message.
- It needs a `Context`, so it exists only during a request.

## Use the middleware

- Never reimplement what the middleware provides: `LoggingMiddleware` (human-readable),
`StructuredLoggingMiddleware` (JSON for aggregation), `TimingMiddleware` and `DetailedTimingMiddleware`
(durations), `ErrorHandlingMiddleware` (exceptions). A per-tool decorator that logs entry, duration and
errors is one of these.
- `LoggingMiddleware(include_payloads=...)` truncates, it does not redact. Leave it `False` unless a custom
`logger` with a redacting filter is in place.
- Register logging middleware last, so it records execution after the rest of the chain has run.

## Deliberate exception: feedback

`send_feedback` records a client's report at `info` on the server log. That is a widening of the
channel -- it is not an incident, and nobody is on call for it -- and it is the point: a file inside
the container is lost on the next restart, while the log already reaches the operators. Client text
is JSON-encoded into the message so an embedded newline cannot forge a second log line.

## Who logs what

- **Middleware logs failures, not services.** `ErrorHandlingMiddleware` already catches, logs and converts
every exception. Code that logs before raising records the same failure twice.
- A service logs only what the exception cannot carry, and never at `error` level.
- Never log credentials, tokens or request bodies. The single exception is `send_feedback`, whose
body is the record itself -- see "Deliberate exception" above.

## Configuration

- Configure logging once at startup, never at import time. `logging.basicConfig` in a module body fires as
a side effect of importing that module and cannot be overridden by the process hosting the app.
- The configuration must apply whether the server runs via `__main__` or under an external ASGI server.
- Logger names follow the module: `logging.getLogger(__name__)`. Never a hand-picked shared name.

## Where the channels meet

Everything sent with `ctx.log()` is also written to the server log at `DEBUG` on the
`fastmcp.server.context.to_client` logger. Enable it to audit what clients were told — do not log the same
message twice yourself.

## Protocol notes

- Log messages are one-way notifications, so they always reach the client. (Two-way features like sampling
were removed from the protocol; logging was not.)
- Ignore the SDK's `MCPDeprecationWarning` about the logging capability. It is about the handshake, not the
messages. They still arrive.
- The client decides which levels it keeps. `logging/setLevel` no longer works, so never rely on the server
filtering levels for a client.
48 changes: 48 additions & 0 deletions .claude/rules/python.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Python conventions

## Functions and side effects

Default to pure: same arguments in, same value out.

- Push I/O to the edges. Parsing, filtering, query building and result shaping stay pure.
- Never read a global inside a function — no `get_settings()`, no module-level client or cache.
- Never mutate an argument. Return a new value.
- No I/O and no clock reads at import time.
- Take what you need as a parameter. Pass specific values, never a whole configuration object.
- No bare `*` in a signature. Call sites name their arguments (see Layout), so forcing it in every
declaration only adds noise; `FBT003` catches the positional boolean that actually misreads.

## Typing and syntax

- Target Python 3.12.
- `str | None`, never `Optional[str]`. Never mix both styles.
- Annotate every return, including `-> None`. A function that always raises returns `NoReturn`.
- Alias a composed type you repeat: `TableOfContents = list[dict[str, str]]`.

## Async

- Async for anything doing I/O.
- Never call a blocking API inside a coroutine. If it cannot be avoided, tell me before writing it.

## Naming

Spell names out. The reader should not have to look up what something holds.

- `settings`, not `s`. `elasticsearch_client`, not `es`.
- Name what an argument is: `search_input`, not `params`.
- A name broader than the behaviour is misleading.
- Functions start with a verb that describes the actual work: `fail()` always raises, so `raise_tool_error()`.
- `get_` is for cheap in-memory lookups. Anything doing I/O is `fetch_`, `load_` or `search_`.

## Layout

- Dicts, lists and other objects: multiline, one entry per line, including as call arguments. The
formatter collapses anything that fits on one line and never splits it for you — a **trailing comma
on the last entry** is what keeps it exploded, so write one.
- Signatures and calls with two or more arguments: multiline, one per line, with the same trailing comma.
- Calls with more than two arguments name each one. Positional only where keywords are forbidden
(`getattr`, `dict`, `join`).
- Imports at the top of the module, never inside a function.
- Text spanning more than one line is a triple-quoted string, `textwrap.dedent`-ed when indented.
- Never glue adjacent literals or chain `+`: implicit concatenation drops the space at line breaks.
- Long text is data. Keep it out of `if` branches and out of multi-source assembly.
5 changes: 4 additions & 1 deletion .claude/settings.local.json → .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@
"allow": [
"Bash(uv run:*)"
]
}
},
"enabledMcpjsonServers": [
"fastmcp-docs"
]
}
31 changes: 31 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Everything the image does not need. Without this the whole working tree -- .venv
# included -- is uploaded to the daemon on every build.

# Virtual environments and caches
.venv/
__pycache__/
*.py[cod]
.ruff_cache/
.pytest_cache/
.mypy_cache/

# Version control and CI
.git/
.github/
.gitignore

# Local configuration and secrets
*.env
!.env.example
.idea/
.claude/
.mcp.json

# Not part of the runtime
tests/
docs/
k8s/
*.md
Dockerfile*
docker-compose*.yml
.pre-commit-config.yaml
71 changes: 71 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Copy this file to .env before running anything: `cp .env.example .env`. Both the server and
# docker compose read it from there.
#
# Every variable this server reads. ES_HOST is required unless the insee.fr and Melodi tools are
# both disabled; the values shown are the defaults.
#
# It is resolved from the current working directory, not from this package. docker compose reads
# it too; Kubernetes does not, and injects real environment variables instead.

# HTTP server ----------------------------------------------------------------------------------------------------------
MCP_HOST=0.0.0.0
MCP_PORT=8000
# JSON list of hosts this server answers to.
# "*" accepts any host and is unsafe once the server is publicly reachable.
# The hostnames clients use to reach the server. "*" disables the check; set the real one.
ALLOWED_HOSTS=["*"]
# Browser origins allowed to call the server. Empty rejects cross-origin browser requests.
ALLOWED_ORIGINS=[]
# JSON list of peers whose X-Forwarded-For header is believed. Set this to your reverse proxy.
# Accepts addresses, CIDR networks and literals. Widening it lets any caller forge their own
# address, which defeats per-client rate limiting.
TRUSTED_PROXY_HOSTS=["127.0.0.1"]

# Tool selection -------------------------------------------------------------------------------------------------------
# insee.fr, the website. MELODI and RMES are INSEE sources too; this is only the site.
ENABLE_INSEE_TOOLS=true
ENABLE_MELODI_TOOLS=true
ENABLE_RMES_TOOLS=true
# Lets a client report a broken tool. Records to the server log; needs no backend.
ENABLE_FEEDBACK_TOOL=true

# Elasticsearch --------------------------------------------------------------------------------------------------------
# Required when ENABLE_INSEE_TOOLS or ENABLE_MELODI_TOOLS is true.
# Leave this commented out for `docker compose up`: the stack runs Elasticsearch and points the
# server at it. Set it to run the server outside Docker, or to aim the stack at a cluster you
# already have -- in which case start only `mcpdiffusion inspector` and skip the local one.
#ES_HOST=http://localhost:9200
ES_INDEX_PUBLICATIONS=produit
ES_INDEX_MELODI_DATASETS=melodi_datasets
ES_INDEX_MELODI_COLUMNS=melodi_columns
# Only Elasticsearch is configurable here.
ES_TLS_VERIFY=true
ES_REQUEST_TIMEOUT_SECONDS=30
# Retries the client makes itself before a search fails.
ES_MAX_RETRIES=2

# INSEE services -------------------------------------------------------------------------------------------------------
INSEE_BASE_URL=https://www.insee.fr
INSEE_REQUEST_TIMEOUT_SECONDS=30
INSEE_CONNECT_TIMEOUT_SECONDS=10
# A rendered publication is truncated past this many characters, so one document cannot fill
# the calling model's context.
INSEE_DOCUMENT_MAX_MARKDOWN_CHARS=30000
MELODI_DATA_BASE_URL=https://api.insee.fr/melodi/data
MELODI_REQUEST_TIMEOUT_SECONDS=30
MELODI_CONNECT_TIMEOUT_SECONDS=10
# RMES takes its timeout per query, from the tool's own input.
RMES_SPARQL_ENDPOINT_URL=https://rdf.insee.fr/sparql
# Not an address to call: the namespace every named graph URI starts with.
RMES_GRAPH_BASE_URI=http://rdf.insee.fr/graphes/
# The graph listing counts triples across the whole store: its own timeout, cap and cache.
RMES_GRAPH_LISTING_TIMEOUT_SECONDS=45
RMES_GRAPH_LISTING_MAX_ROWS=1000
RMES_GRAPH_CACHE_TTL_SECONDS=3600

# Rate limiting --------------------------------------------------------------------------------------------------------
RATE_LIMIT_MAX_REQUESTS=100
RATE_LIMIT_WINDOW_MINUTES=1

# Logging --------------------------------------------------------------------------------------------------------------
LOG_LEVEL=INFO
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,11 @@
.venv/
*.env
__pycache__/
mcp_*
mcp_*
.idea
.claude/settings.local.json
TODO.md

# User-submitted feedback. Kept on disk, never versioned -- the tool now records to the
# server log, so nothing writes here any more.
src/mcpdiffusion/feedback/
8 changes: 8 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"fastmcp-docs": {
"type": "http",
"url": "https://gofastmcp.com/mcp"
}
}
}
13 changes: 13 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
repos:
- repo: local
hooks:
- id: ruff-lint
name: ruff lint
entry: uv run ruff check --fix
language: system
types: [ python ]
- id: ruff-format
name: ruff format
entry: uv run ruff format
language: system
types: [ python ]
Loading