SSRQ MCP Server
by thodel
README.md
# SSRQ — MCP Server
An [MCP](https://modelcontextprotocol.io) server that exposes the person and organisation
authority file of the **Sammlung Schweizerischer Rechtsquellen** — *Les sources du droit
suisse* / *Le fonti del diritto svizzero*, in English the **Collection of Swiss Law
Sources** (SSRQ · SDS · FDS) — to Claude and other MCP-compatible clients.
The collection is published by the Rechtsquellenstiftung of the Swiss Law Society and
comprises over 140 editions of legal-historical documents from the Middle Ages to 1798
(<https://ssrq-sds-fds.ch>). This server serves the authority file behind those editions:
27,996 persons, 9,134 organisations, and 81486 name variants
(rebuilt from the register TTL by `ssrq_parse_ttl.py`, in this repo since #11 —
the earlier build dropped ~4,300 persons and carried editorial timestamps as
attestation years).
## Architecture
```
ssrq__fuseki_*.ttl ──► SSRQ ETL ──► ssrq.db (SQLite)
persons ────┐
orgs ───────┼──► server.py
name_index ─┘ (mcp 2.0 MCPServer,
streamable HTTP)
│
http://<host>:8002/mcp
```
The server targets **mcp 2.0**, which renamed the high-level server class
(`FastMCP` → `MCPServer`) and removed `mcp.server.fastmcp`; `requirements.txt` pins the
major version accordingly.
The database is built by the SSRQ project's ETL pipeline from the RDF-TTL source dump;
this repository only serves it. Every connection is opened `mode=ro` with
`PRAGMA query_only`, so the server cannot write to the corpus.
## Setup
### 1. Build the database
The database lives at `/data/ssrq.db` in the container. To rebuild it from the RDF-TTL
source (in the SSRQ project repository):
```bash
python ssrq_parse_ttl.py --input /path/to/ssrq__fuseki_*.ttl --db ssrq.db
```
`db.SCHEMA_SQL` holds the schema this server expects — it is the contract between the
ETL and the server, and the tests build their fixtures from it.
### 2. Install dependencies
```bash
pip install -r requirements.txt
```
### 3. Start the server
```bash
python server.py --db ssrq.db --host 0.0.0.0 --port 8002
```
Each flag also has an environment variable — `SSRQ_DB`, `SSRQ_HOST`, `SSRQ_PORT`,
`SSRQ_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 ssrq http://<server-ip>:8002/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": {
"ssrq": {
"type": "http",
"url": "http://<server-ip>:8002/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
```
### Run
```bash
docker compose up -d
```
The container serves on port 8002 and expects `ssrq.db` at `/data/ssrq.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/ssrq/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;
# SSRQ_HTTP_PATH=/mcp/ssrq/mcp — same string, no trailing slash on proxy_pass,
# so the path reaches the app unrewritten.
location /mcp/ssrq/mcp {
proxy_pass http://127.0.0.1:8002;
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:8002/`) 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 SSRQ MCP server on 0.0.0.0:8002/mcp/ssrq/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/ssrq/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 8002 on all interfaces; if a proxy fronts it, bind it to loopback instead so the
> authority file is not reachable directly:
>
> ```bash
> SSRQ_BIND=127.0.0.1 docker compose up -d
> ```
>
> Otherwise restrict access at the firewall.
---
## Available tools
| Tool | Description |
|------|-------------|
| `corpus_stats()` | Person/org/name-variant counts and the attested year range |
| `list_persons(limit=50, offset=0)` | Paginated list of the person authority file, by id |
| `search_persons(query, limit=50)` | Persons by standardised name, label, or spelling variant |
| `get_person(pid)` | Full person record by SSRQ id (e.g. `per000001`), with name variants |
| `get_persons_by_year(year_from, year_to, limit=100)` | Persons whose attested years overlap a range (max span 500 years) |
| `search_orgs(query, limit=50)` | Organisation authority by name |
| `get_org(oid)` | Full org record by SSRQ id (e.g. `org000001`), with name variants |
| `search_name_index(query, type_filter="", limit=50)` | Search all 138k name variants; `type_filter` is `person`, `org`, or empty for both |
| `get_name_variants(id)` | All name variants for a given person or org id |
| `related_persons(pid)` | Spouses, mothers, fathers, organisations, and places, resolved to records |
## Available resources
| URI | Description |
|-----|-------------|
| `ssrq://stats` | Corpus statistics (JSON) |
| `ssrq://orgs` | Organisation index — `{total, returned, truncated, orgs: [...]}`, capped at 1000 rows and flagged when truncated |
| `ssrq://person/{pid}` | Single person record (JSON) |
| `ssrq://org/{oid}` | Single organisation record (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_persons(limit, offset)` to page through the register.
**Result size.** Claude.ai and Claude Desktop truncate a tool or resource result at
roughly 150,000 characters. `ssrq://orgs` 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.
**Name search.** `search_persons`, `search_orgs`, and `search_name_index` 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. `search_persons` looks at
`std_name`, `label`, and both spelling-variant columns; historical spellings that differ
from the modern form are best reached through `search_name_index`.
**Name index shape.** Every row carries `kind` (`person` or `org`), so the result shape is
the same whether or not `type_filter` is set.
**Missing records.** `get_person`, `get_org`, and `related_persons` return
`{"error": "... not found."}` rather than raising.
**Year ranges.** `get_persons_by_year` matches on *overlap*: a person is returned when
`first_year <= year_to` and `last_year >= year_from`. Persons with no attested years are
never returned. An inverted range, or one spanning more than 500 years, comes back as an
error object.
**Places.** `related_persons` resolves `spouse_ids`, `mother_ids`, `father_ids`, and
`org_ids` against the `persons` and `orgs` tables. `loc_ids` point at the SSRQ place
authority, which this database does not currently carry: those ids are returned as bare
`{"id": ...}` entries together with a `places_note`. If a `places` (or `locations`) table
is added to the database later, they are resolved to full records automatically.
## Database schema
| Table | Contents |
|-------|----------|
| `persons` | id, uri, etype, label, label_lang, std_name, forename, surname, sex, first_year, last_year, years, org_ids, spouse_ids, mother_ids, father_ids, loc_ids, orig_names, std_names |
| `orgs` | id, uri, etype, label, std_name, surname, alias_of, org_type |
| `name_index` | name_text, ssrq_id, is_orig (138k variant → canonical mappings) |
### Key notes
- **Person IDs:** `per000001`–`per999999` (27,996 total)
- **Org IDs:** `org000001`–`org999999` (9,134 total)
- `orig_names` / `std_names` — original and normalised spelling variants (comma-joined)
- `is_orig=1` in `name_index` means the name is the original spelling; `is_orig=0` is a
normalised variant. Original spellings sort first in every variant listing.
- The relation columns (`org_ids`, `spouse_ids`, `mother_ids`, `father_ids`, `loc_ids`)
are comma-joined id lists; `related_persons` resolves them all in one call.
## Deployment
This server runs on `tei.dh.unibe.ch` at
**`https://tei.dh.unibe.ch/mcp/ssrq/mcp`**, alongside four sibling MCP servers:
[Königsfelden](https://github.com/thodel/kf_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_ssrq_mcp.py
```
Unit tests build their own throwaway database and run with no setup. The DB and server
tests skip unless you point them at the real database and a running server:
```bash
SSRQ_DB=/data/ssrq.db SSRQ_SERVER=http://localhost:8002 pytest test_ssrq_mcp.py
```
The suite also runs standalone, with grouped output and a non-zero exit on failure:
```bash
python test_ssrq_mcp.py --unit --db /data/ssrq.db --server http://localhost:8002
```
Note that the DB tests assert corpus-size floors (≥20,000 persons, ≥6,000 organisations,
≥100,000 name variants) — they will fail against a small sample database.
This server cannot be deployed
Maintenance
ActivityNo data
ResponsivenessUnresponsive