Skip to main content
Glama
Romumrn

ViromeChat MCP server

by Romumrn
README.md
# ViromeChat MCP server

[![CI](https://github.com/Romumrn/viromeatlas_mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Romumrn/viromeatlas_mcp/actions/workflows/ci.yml)

A [FastMCP](https://gofastmcp.com/) server that owns **all** dataset access, external API calls,
and business logic for [Viromech@t](https://github.com/Romumrn/viromechat). The client (the
FastAPI backend / React front, in the separate `viromechat` repo) never touches a dataframe, an S3
credential, or a column name directly. It only talks to this server over MCP/HTTP, generically,
by reading whatever tools and resources it currently publishes.

This repo is the standalone home of that server. It has **no dependency on the app repo**; the only
contract between them is the set of MCP tools/resources documented below, consumed by the backend
via its `MCP_SERVER_URL` env var.

## C'est quoi un MCP, et pourquoi ?

Un MCP (Model Context Protocol), c'est une façon standard de donner accès à des données à une IA.
Plutôt que de recoder une intégration à chaque fois, on expose des « outils » une bonne fois, et n'importe quel assistant qui parle MCP sait les utiliser tout seul, en langage naturel.

Dans ce projet, c'est ce serveur qui fait le vrai boulot : il a les accès S3, il charge la taxonomie, il écrit le SQL, il pose les garde-fous. L'IA, elle, ne voit jamais les données, ni les acces S3, ni les mots de passe, elle demande juste « les hôtes de tel virus » et le serveur lui renvois lme resultats.

L'intérêt est surtout là : les données et l'IA ne se touchent plus directement. On peut changer de modèle ou de front sans rien casser ici, toute la logique reste au même endroit, et comme c'est un standard, le même serveur pourrait être branché sur un autre assistant sans le réécrire. Ce qui peut etre tres pratique pour ouvir l'acces a un plus grand nombre. 

La techno du MCp n'est absolument pas un truc niche, de geek de l'IA, c'est en pleine expension : **data.gouv.fr a fait pareil** avec un serveur MCP par-dessus
ses ~74 000 jeux de données publiques, pour qu'on puisse les explorer en langage naturel au lieu de passer par des API. Ils le résument bien, ça n'ouvre aucune donnée nouvelle, ça rend juste
l'existant beaucoup plus simple à atteindre. C'est exactement l'idée ici, voir plus fino dans leur 
[article](https://www.numerique.gouv.fr/actualites/serveur-mcp-datagouv-retex-clarifications-donnees-publiques-ia/)


## Running it

**Prerequisites:** the taxonomy dataset (`data/TAXONOMY.csv`, ~327 MB) is stored via
[Git LFS](https://git-lfs.com/). Run `git lfs install` once per machine before cloning, or
`git lfs pull` after cloning, to materialize it.

### Local (Python)

```bash
git lfs pull                      # fetch data/TAXONOMY.csv
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env              # fill in your S3 credentials
python server_mcp.py
```

### Docker

```bash
cp .env.example .env              # fill in your S3 credentials
docker compose up --build
```

Either way it starts an HTTP server on `0.0.0.0:8000`, MCP endpoint at `/mcp`
(`http://localhost:8000/mcp`, this is what the backend points `MCP_SERVER_URL` at). On startup it:

1. Loads `data/TAXONOMY.csv` fully into memory as `df_taxo`.
2. Loads the two column-description files (`data/v@_columns_description.csv` and
   `data/TAXONOMY_columns_description.json`) that back the two MCP resources below.
3. Opens an in-memory DuckDB connection, installs the `httpfs` and `spatial` extensions, and
   registers a `host` view over the S3 Parquet dataset, **the Parquet file is never loaded into
   memory**; every `query_host_sql` call is pushed down to S3 by DuckDB (column/row-group pruning).

### Tests

```bash
pip install pytest
pytest
```

The helper tests exercise the pure functions (`_ok`/`_fail`, figure/table builders, SQL guards) and
need no live S3 connection.

---

## Integrating a client

Any MCP client can consume this server. The Viromech@t backend does it with a `fastmcp.Client`:

```python
from fastmcp import Client

async with Client("http://localhost:8000/mcp") as mcp:
    tools = await mcp.list_tools()
    result = await mcp.call_tool("wikipedia_search", {"search_term": "Lentivirus"})
```

The client should discover tools and resources dynamically (`list_tools()` / `list_resources()`)
and dispatch on `artifact["type"]`, **never** hard-code tool names or column knowledge. That is
what keeps the two repos decoupled: adding a tool here that reuses an existing artifact type needs
no client change.

---

## Resources

Resources are static, read-once knowledge, not something the LLM "calls" like a tool. The client
reads them once per conversation and folds their content into the system prompt.

| URI | Content | Source |
|---|---|---|
| `resource://datasets/host/schema` | JSON map `{column_name: {description, Type}}` for every column of the `host` table | `data/v@_columns_description.csv` |
| `resource://datasets/taxonomy/schema` | Full JSON schema (name, description, columns, primary key, row definition) of `df_taxo` | `data/TAXONOMY_columns_description.json` |

Adding a new resource (e.g. a third dataset) requires no client-side change: the client discovers
resources via `list_resources()` and reads each one generically.

---

## The response contract

**Every tool returns exactly this shape**, regardless of what it does:

```jsonc
{
  "success": true,           // or false
  "content": "human-readable text, what the LLM reads back as the tool result",
  "artifacts": [ ... ]        // structured extras the client can render; [] if none
}
```

On failure, `content` holds the error message (with retry guidance where possible) and
`artifacts` is empty. The two helpers `_ok(content, artifacts)` / `_fail(content)` at the top of
`server_mcp.py` build this shape. Always use them instead of hand-rolling a dict.

### Artifact types

| `type` | Emitted by | Shape | Consumed by the client as |
|---|---|---|---|
| `url` | `wikipedia_search` | `{"type": "url", "url": "..."}` | Wikipedia link in the "Sources" panel |
| `pubmed` | `pubmed_search` | `{"type": "pubmed", "pmids": [123, 456]}` | PubMed links + PMID whitelist for the hallucination guard |
| `ncbi_taxonomy` | `ncbi_taxonomy_search` | `{"type": "ncbi_taxonomy", "url": "...", "tax_id": "..."}` | NCBI Taxonomy link in the "Sources" panel |
| `table` | `query_host_sql`, `query_dataframe` | `{"type": "table", "rows": [...], "columns": [...], "total_rows": N}` | Tracked as executed SQL/code in "Sources"; `rows` capped to `preview_rows` |
| `plotly` | `create_visualization`, `create_map` | `{"type": "plotly", "figure": {...}}` (from `fig.to_json()`, parsed back to a dict) | Rendered plotly chart |

The client dispatches purely on `artifact["type"]`, never on the tool's name. Adding a
tool that reuses an existing artifact type (e.g. another `"table"`-returning tool) requires **no
client change at all**.

---

## Tools

### `wikipedia_search(search_term: str, wikipedia_limit: int = 4000) -> dict`

Looks up a page on Wikipedia; falls back to the closest full-text search match if there's no exact
title match (flagged as a "fuzzy match" note in the content). Returns a `url` artifact.

### `pubmed_search(query: str, max_results: int = 5) -> dict`

Searches PubMed (NCBI E-utilities `esearch` + `efetch`, db=`pubmed`) and returns title, authors,
journal, year, abstract, DOI, and PMID for each hit. Returns a `pubmed` artifact with every real
PMID found. This is the sole source of truth for the client's PMID hallucination guard.

### `ncbi_taxonomy_search(name: str) -> dict`

Resolves any organism name (acronym, common name, or scientific name) against the **NCBI
Taxonomy** database (E-utilities, db=`taxonomy`). Returns, for every match: scientific name, rank
(species/genus/family, and so on), division, full lineage, and known synonyms/acronyms. This is the
authoritative way to turn `HIV` into `Human immunodeficiency virus 1` / genus `Lentivirus`, or to
check whether a name is a genus or a family, without depending on Wikipedia's phrasing. Returns an
`ncbi_taxonomy` artifact for the top match.

> Implementation note: NCBI's `efetch` XML nests one `<Taxon>` per ancestor rank inside each
> result's `<LineageEx>`. The parser only iterates `root.findall("Taxon")` (direct children);
> using `.//Taxon` would also pick up every ancestor as if it were a separate match.

### `query_host_sql(sql: str, preview_rows: int = 50) -> dict`

Runs a read-only `SELECT` against the `host` view (the S3 Parquet dataset), returning a `table`
artifact. This is the **required first step** before `query_dataframe`, `create_visualization`, or
`create_map` can use `df_host`. Those tools operate on the result of the *last* `query_host_sql`
call (`ctx.last_host_result`), never on the full dataset.

Guardrails enforced before execution:
* Only a single `SELECT` statement; `INSERT/UPDATE/DELETE/DDL/PRAGMA/...` are rejected by
  `_FORBIDDEN_SQL_KEYWORDS`.
* **Bare `SELECT *` is rejected outright.** `host` has ~65 columns including a heavy `geometry`
  blob; pulling every column for every matching row over S3 is what caused multi-minute timeouts
  before this guard existed. Callers must project only the columns they need.
* Coordinates live in a native `GEOMETRY` point column, not plain `lat`/`lon`. Extract them with
  `ST_X(geometry) AS lon, ST_Y(geometry) AS lat` (the `spatial` extension is loaded at startup).

### `query_dataframe(code: str, preview_rows: int = 50) -> dict`

Executes pandas code with `df_taxo`, `df_host` (= `ctx.last_host_result`, or a clear error if
`query_host_sql` hasn't been called yet), `pd`, and `np` in scope. Must assign a DataFrame to
`result`. Returns a `table` artifact.

### `create_visualization(code: str) -> dict`

Same execution environment as `query_dataframe`, plus `px`/`go`. Must assign a Plotly figure to
`fig`. Rejects empty figures (0 data points) with a guidance message rather than silently returning
a blank chart. Returns a `plotly` artifact.

### `create_map(code: str) -> dict`

Same as `create_visualization`, but enforces `px.scatter_mapbox(...)` (never `scatter_map`) and
that the preceding `query_host_sql` call already extracted `lon`/`lat` from `geometry`. Returns a
`plotly` artifact.

**Mandatory sample identifier**: the resulting figure is rejected unless `primary_id` (the
BioSample accession) appears in `hover_data`. Every plotted point must be traceable back to its
exact sample. Enforced in code (`_check_hover_has_column(fig, "primary_id")`), not just requested
in the docstring. A map missing it is a hard `_fail(...)`.

---

## Extending the server

To add a new tool:

1. Write it as a plain function decorated with `@mcp.tool`, returning `_ok(content, artifacts)` or
   `_fail(content)`, never a hand-built dict.
2. If it produces something the client should render specially (a link, a table, a figure), reuse
   an existing artifact `type` from the table above whenever the shape fits. This means zero
   client changes. Only invent a new `type` (and wire it into the client's dispatch loop) if the
   shape is genuinely new.
3. Put every usage rule, caveat, and example **in the tool's docstring**. It is sent verbatim to
   the LLM as the tool's description. This is the only place dataset-specific guidance should live.
4. If the tool needs a UI-configurable default (like `preview_rows` or `wikipedia_limit`), just
   name the parameter that; the client applies the matching expert setting to any tool whose JSON
   schema declares a parameter with that name.

---

## Configuration

`server_mcp.py` reads `.env` (see [`.env.example`](.env.example)) at import time, via
`load_env_file()` from `mcp_config.py`:

| Variable | Required | Default | Meaning |
|---|---|---|---|
| `ENDPOINT` | yes | (none) | S3-compatible endpoint hostname |
| `ACCESS_KEY` | yes | (none) | S3 access key |
| `SECRET_KEY` | yes | (none) | S3 secret key |
| `BUCKET` | yes | (none) | S3 bucket name |
| `VIRAL_HOST_DATASET` | yes | `*.parquet` | Object key of the Parquet dataset inside the bucket |
| `REGION` | no | `fr` | S3 region |
| `S3_URL_STYLE` | no | `path` | DuckDB `s3_url_style` setting |
| `TAXO_DB_PATH` | no | `data/TAXONOMY.csv` | Local path to the taxonomy CSV |

Non-secret settings live in `mcp_config.py`.