Skip to main content
Glama
thodel
by thodel

SSRQ — MCP Server

An MCP server that exposes the person and organisation authority file of the Sammlung Schweizerischer RechtsquellenLes 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 (FastMCPMCPServer) 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.

Related MCP server: swiss-courts-mcp

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):

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

pip install -r requirements.txt

3. Start the server

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.

4. Connect a client

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

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:

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

docker compose build

Run

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)

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.

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:

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:

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: per000001per999999 (27,996 total)

  • Org IDs: org000001org999999 (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, 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_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:

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:

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.

Related MCP Connectors

Related MCP Servers