Skip to main content
Glama
thodel

HGB Basel MCP Server

by thodel

Königsfelden — MCP Server

An MCP 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 (FastMCPMCPServer), 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.

Related MCP server: barracuda-mcp

Setup

1. Install dependencies

pip install -r requirements.txt

2. Build the database

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

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.

4. Connect a client

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

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:

{
  "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

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:

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

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)

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.

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:

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:

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, HLS, HBLS, EOS / HGB Basel.

What they share — the nginx routing, the landing pages, and the deploy sequence — lives in 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

pip install -r requirements-dev.txt
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:

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:

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.

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

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.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.

No tool schema history has been recorded yet.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    Enables AI-native access to Swiss cultural heritage data from SIK-ISEA (artists), Nationalmuseum (collections), and Nationalbibliothek (bibliography) via MCP tools, without authentication.
    11
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables searching OpenAleph entities and documents using natural language queries through the MCP protocol.
    19
    1
    MIT
  • F
    license
    Not graded
    quality
    B
    maintenance
    MCP server providing full-text search and structured access to the Historisches Biographisches Lexikon der Schweiz, including biographies, articles, and volume listings.
    -

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/thodel/kf_mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server