Skip to main content
Glama
thodel

HGB Basel MCP Server

by thodel
README.md
# Königsfelden — MCP Server

An [MCP](https://modelcontextprotocol.io) server that exposes the Königsfelden corpus —
the records of the Cistercian convent and the Hofmeisterei Königsfelden (1300–1658),
edited by *Die Urkunden und Akten des Klosters und der Hofmeisterei Königsfelden* —
to Claude and other MCP-compatible clients.

## Architecture

```
data/docs/*.xml       (TEI, one file per register entry)  ─┐
data/registers/                                            ├─► build_db.py ──► kf.db
  people.xml  places.xml  organizations.xml               ─┘                  (SQLite + FTS5)
                                                                                   │
                                                                              server.py
                                                              (mcp 2.0 MCPServer,
                                                               streamable HTTP)
                                                                                   │
                                                                    http://<host>:8001/mcp
```

The TEI sources are parsed once into a SQLite database with two FTS5 indexes. The server
then runs stateless read-only queries against it (`PRAGMA query_only`).

The server targets **mcp 2.0**, which renamed the high-level server class
(`FastMCP` → `MCPServer`), removed `mcp.server.fastmcp`, and moved the bind address from
the constructor into `run()`; `requirements.txt` pins the major version accordingly.

**The transport is streamable HTTP** (`/mcp`), not the legacy HTTP+SSE (`/sse`) this
server used previously. SSE is deprecated, and — more practically — its handshake hands
the client an absolute `/messages/` path computed from the app's own mount point, which
a client cannot reach when the server sits under a reverse-proxy sub-path. Streamable
HTTP has one endpoint and no such handshake. **Existing clients pointed at `/sse` must
be repointed at the new endpoint;** it is a deliberate cutover, not a compatible change.

Entity identifiers come from the TEI `xml:id` attributes — persons `perXXXXXX`, places
`locXXXXXX`, organisations `orgXXXX`. Person and place records additionally carry HLS
identifiers (and GND, for places) where the edition supplies them.

## Setup

### 1. Install dependencies

```bash
pip install -r requirements.txt
```

### 2. Build the database

```bash
python build_db.py --docs ../data/docs --registers ../data/registers --db kf.db
```

`--docs` is a directory of per-entry TEI files; the entry id is the **filename** without
its extension. `--registers` must contain `people.xml`, `places.xml`, and
`organizations.xml`. Both default to `../data/docs` and `../data/registers`; `--batch`
(default 200) controls the commit batch size.

Run it once, and again whenever the TEI changes. **Rebuilding is destructive**: the five
tables the script owns (`entries`, `spans`, `persons`, `places`, `orgs`) and both FTS
indexes are cleared and repopulated, so a rebuild always mirrors the current sources
rather than accumulating duplicates. It prints `Existing database: clearing N entries`
when it does this. Nothing else in the file is touched.

Malformed records are skipped individually and reported on stderr as
`WARNING: N record(s) skipped` — check for that line, since the build otherwise
completes normally.

### 3. Start the server

```bash
python server.py --db kf.db --host 0.0.0.0 --port 8001
```

Each flag also has an environment variable — `KF_DB`, `KF_HOST`, `KF_PORT`,
`KF_HTTP_PATH` — which the flags override. Importing `server.py` never reads `sys.argv`,
so it is safe to import from tests or an ASGI loader.

`--http-path` (default `/mcp`) is the path the MCP endpoint is served at. **Behind a
reverse proxy, set it to the public path** — see [Reverse proxy](#reverse-proxy-nginx).

### 4. Connect a client

**Claude Code** — the name and URL are positional; there is no `--url` flag:

```bash
claude mcp add --transport http kf http://<server-ip>:8001/mcp -s user
```

`-s user` makes the server available in every project; `-s project` writes it to
`.mcp.json` to share with a repository; the default `local` scope is just you, in the
current project. `claude mcp list` then reports the connection status.

**Claude Desktop, Cowork, claude.ai** — Customize → Connectors → **+** → *Add custom
connector*, and paste the same URL. These clients connect from Anthropic's cloud rather
than from your machine, so the server has to be reachable over the public internet;
`claude_desktop_config.json` only configures local stdio servers, not remote URLs.

**Project-scoped `.mcp.json`:**

```json
{
  "mcpServers": {
    "kf": {
      "type": "http",
      "url": "http://<server-ip>:8001/mcp"
    }
  }
}
```

`type` is required, and `streamable-http` is accepted as an alias for `http`. An entry
with a `url` but no `type` is read as a stdio server and skipped with an error.

---

## Docker deployment

### Build image

```bash
docker compose build
```

### First-time: build the database

Copy the TEI sources onto the server (the compose file mounts `/home/dh/kf_data` as
`/data`), then:

```bash
docker run --rm -v /home/dh/kf_data:/data kf-mcp python build_db.py --docs /data/kf_raw/docs --registers /data/kf_raw/registers --db /data/kf.db
```

### Run

```bash
docker compose up -d
```

The container serves on port 8001 and expects `kf.db` at `/data/kf.db`. Adjust the volume
path in `docker-compose.yml` if your data lives elsewhere.

### Reverse proxy (nginx)

<a id="reverse-proxy-nginx"></a>

Serving under a sub-path (`https://tei.example.ch/mcp/kf/mcp`) has exactly one rule:
**the app's `--http-path` and the nginx `location` must be the same string.** The
endpoint is one path that answers `POST` (requests), `GET` (the server→client stream),
and `DELETE` (session teardown); it builds no URLs of its own, so all nginx has to do is
forward the path unchanged.

```nginx
server {
    listen 443 ssl;
    server_name tei.example.ch;

    # KF_HTTP_PATH=/mcp/kf/mcp — same string, no trailing slash on proxy_pass,
    # so the path reaches the app unrewritten.
    location /mcp/kf/mcp {
        proxy_pass         http://127.0.0.1:8001;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        # The GET stream must not be buffered or timed out mid-session.
        proxy_set_header   Connection '';
        proxy_buffering    off;
        proxy_cache        off;
        proxy_read_timeout 3600s;
        chunked_transfer_encoding on;
    }
}
```

Two failure modes worth knowing, both of which return a bare `Not Found` or `405`:

- **A trailing slash on `proxy_pass`** (`http://127.0.0.1:8001/`) strips the location
  prefix, so the app sees `/` and no route matches.
- **`location` and `--http-path` disagree** — the app 404s every request. Check the
  startup line, which prints the exact path being served:
  `Starting KF MCP server on 0.0.0.0:8001/mcp/kf/mcp`.

Verify from outside before wiring up a client:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://tei.example.ch/mcp/kf/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
```

`200` means the endpoint is live. `404` is a path mismatch, `405` means nginx is not
passing `POST` to the app (a static `location` or a `limit_except` is shadowing it).

> **Note:** the server has no authentication. By default `docker-compose.yml` publishes
> port 8001 on all interfaces; if a proxy fronts it, bind it to loopback instead so the
> corpus is not reachable directly:
>
> ```bash
> KF_BIND=127.0.0.1 docker compose up -d
> ```
>
> Otherwise restrict access at the firewall.

---

## Available tools

| Tool | Description |
|------|-------------|
| `corpus_stats()` | Entry/span/person/place/org counts and year range |
| `list_entries(limit=50, offset=0)` | Paginated list of register entries, ordered by year |
| `get_entry(entry_id)` | Full entry: title, year, source, pages, transcription, all spans |
| `search_persons(query, limit=50)` | Person authority file by name (substring match) |
| `get_person(pid)` | Person record with HLS id, occupation, life dates, and mentions |
| `search_places(query, limit=50)` | Place authority file by name (German or French) |
| `get_place(pid)` | Place record with geo, HLS id, GND id, and mentions |
| `search_orgs(query, limit=50)` | Organisation authority file by name or description |
| `search_fulltext(query, limit=20)` | Full-text search over transcriptions, with snippets |
| `get_entries_for_person(pid, limit=50)` | Entries mentioning a person, by authority id |
| `get_entries_for_place(pid, limit=50)` | Entries mentioning a place, by authority id |
| `get_entries_by_year(year_from, year_to, limit=100)` | Entries in a year range (max span 300 years) |

## Available resources

| URI | Description |
|-----|-------------|
| `kf://stats` | Corpus statistics (JSON) |
| `kf://persons` | Person index — `{total, returned, truncated, persons: [...]}`, capped at 1000 rows and flagged when truncated |
| `kf://entry/{entry_id}` | Single entry (JSON) |

## Query behaviour

**Limits.** Every `limit` is clamped to at most 500; a negative, zero, or non-numeric
value falls back to that tool's own default rather than returning the whole table. Use
`list_entries(limit, offset)` to page through the full corpus.

**Result size.** Claude.ai and Claude Desktop truncate a tool or resource result at
roughly 150,000 characters. `kf://persons` is capped at 1000 rows (about 100 KB) for
that reason and reports its own truncation; the 500-row tool ceiling stays comfortably
under the limit too.

**Full-text search.** `search_fulltext` passes the query to FTS5, so operators work —
`Brugg OR Königsfelden`, `Heinr*`, `NEAR(...)`. If the query isn't valid FTS5 syntax
(a stray quote, a dangling `AND`), it silently falls back to a literal word search
instead of erroring. Only a query with no usable words returns `{"error": ...}`.

**Name search.** `search_persons`, `search_places`, and `search_orgs` do a plain
case-insensitive substring match. SQL wildcards in the query are escaped, so searching
for `100%` finds a literal "100%" rather than matching every record.

**Missing records.** `get_entry`, `get_person`, and `get_place` return
`{"error": "... not found."}` rather than raising.

**Spans.** `get_entry` returns every span in the entry — `persName`, `placeName`,
`orgName`, `date`, `measure`. The `ref` field holds the authority id and is empty for
unlinked mentions and for dates/measures; `norm` holds the normalised `@when` or
`@quantity` value.

**How entry years are assigned.** The year comes from the first `<date when="...">` in the
document `<body>`. If the body has no date, the `<sourceDesc>` in the header is used as a
fallback. Dates in `publicationStmt` or `revisionDesc` are never used — they describe the
edition, not the charter. Years outside 1000–1800 are ignored, and entries with no usable
date have `year = NULL`.

## Database schema

| Table | Contents |
|-------|----------|
| `entries` | id, title, short_id, year, source, pages, text_raw |
| `spans` | entry_id, span_id, class, ref, text, norm |
| `persons` | id, forename, surname, full_name, main_name, occupation, birth, death, org_ref, hls_id, note |
| `places` | id, name_de, name_fr, country, region, geo, hls_id, gnd_id, place_type |
| `orgs` | id, name, desc_de, desc_fr |
| `fts_entries`, `fts_spans` | FTS5 indexes (external content, populated by AFTER INSERT triggers at build time — there are no update/delete triggers, which is why a rebuild clears and repopulates) |

## Deployment

This server runs on `tei.dh.unibe.ch` at
**`https://tei.dh.unibe.ch/mcp/kf/mcp`**, alongside four sibling MCP servers:
[SSRQ](https://github.com/thodel/ssrq_mcp), [HLS](https://github.com/thodel/hls_mcp), [HBLS](https://github.com/thodel/hbls_mcp), [EOS / HGB Basel](https://github.com/thodel/eos_mcp).

What they share — the nginx routing, the landing pages, and the deploy sequence —
lives in **[tei_mcp_ops](https://github.com/thodel/tei_mcp_ops)**. Start there for
anything that spans the fleet; in particular, the app's `--http-path` and the nginx
`location` have to be the same string, which is the rule a sub-path deployment turns
on.

## Tests

```bash
pip install -r requirements-dev.txt
```

```bash
pytest test_kf_mcp.py
```

Unit tests (TEI parsing, authority registers, rebuild idempotency) run with no setup.
The DB and server tests skip unless you point them at a built database and a running
server:

```bash
KF_DB=/home/dh/kf_data/kf.db KF_SERVER=http://localhost:8001 pytest test_kf_mcp.py
```

The suite also runs standalone, with grouped output and a non-zero exit on failure:

```bash
python test_kf_mcp.py --unit --db /home/dh/kf_data/kf.db --server http://localhost:8001
```

Note that the DB tests assert corpus-size floors (≥1550 entries, ≥5000 persons, ≥1300
places, ≥2000 orgs) — they will fail against a small sample database.

## Semantic search

`search_fulltext` finds entries containing the words you typed. `search_semantic`
finds passages that *mean* what you asked — which matters more here than in a modern
corpus. The transcriptions are 14th–17th century Alemannic and Latin, so a question
like *"wer hat den Hof Lind gepachtet?"* shares almost no surface forms with
*"wellchen der hof Lind soll hingelichen werden"*. Keyword search reaches this
material only if you already know how it was spelled.

### The expanded reading

The edition transcribes diplomatically: segment boundaries are marked `✳` (82,062 of
them), and every abbreviated word appears as the raw manuscript form immediately
followed by the editor's expansion.

| transcription | reading |
|---|---|
| `un̄ und` | und — the raw form is a whole word |
| `Hein r₎ rich` | Heinrich — the raw form continues a fragment |
| `stif tˀin terin` | stifterin |
| `Diz ist dˀ der` | Diz ist der |

Embedded as written, this is close to unusable: names arrive split in half, every
abbreviated word is doubled, and the segment markers punctuate the text at random.
**Passages therefore hold the expanded reading**, not the raw transcription.

Whether an expansion joins the token before it depends on whether that token is
already a word — "ist" is, "Hein" and "stif" are not. Counting occurrences does not
separate them, because "Hein" recurs constantly (the name is abbreviated the same way
every time), and neither does a rare/common threshold, because "Hein" is also a name.
What does separate them is the *ratio* of fragment to standalone occurrences, which
the corpus supplies itself — better than a word list for a language with no settled
orthography.

`search_fulltext` still searches the raw transcription, so an exact historical string
remains findable.

### Building the index

```bash
GPUSTACK_API_KEY=... python embed_db.py             # whole corpus
GPUSTACK_API_KEY=... python embed_db.py --limit 100 # trial run on a sample
GPUSTACK_API_KEY=... python embed_db.py --recompute # after changing the model
```

Entries are windowed into ~1000-character passages (150 overlap), each prefixed with
its title and year — a charter passage otherwise carries no trace of which document
or century it belongs to, and "the farm at Lind" reads the same in 1360 as in 1644.
Embedding uses `qwen3-embedding-0.6b` on GPUStack (1024 dimensions); vectors are
stored L2-normalised as float32 BLOBs, so search is one exact matrix multiply.

Runs are resumable — chunks already embedded with the same model are skipped, each
batch is committed — and recorded in `embedding_runs` with model, dimensions and
window settings.

### Query-time requirements

The server embeds the incoming query, so it needs `GPUSTACK_API_KEY` at runtime even
though passage vectors are already in the database. GPUStack is reachable only from
inside the UniBE network; from outside it returns **403 before checking the key**, so
a 403 means the wrong network, not a bad credential. Without a key the other tools
work normally and `search_semantic` returns an explanatory error.