i14y-mcp
> **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide/swiss-public-data-mcp)** β a collection of open-source MCP servers connecting AI agents to Swiss public and open data.
> This is a private project. It is not affiliated with, endorsed by, or operated on behalf of any employer or public authority.
# i14y-mcp
[](LICENSE)
[](https://www.python.org/)
[](https://modelcontextprotocol.io/)
[](https://www.i14y.admin.ch)
**MCP server for the I14Y interoperability platform β Switzerland's national metadata catalogue.**
π©πͺ [Deutsche Version](README.de.md)
---
## Why this server exists
The other servers in this portfolio answer *Β«what does the data say?Β»*.
This one answers the question that comes first: **Β«who publishes data on this
topic, through which interface, under which licence?Β»**
I14Y is the national data catalogue maintained by the Federal Statistical
Office. It describes datasets, registered APIs, public services and harmonised
concepts from the Confederation, cantons and communes, using the DCAT-AP-CH
profile (eCH-0200).
> **Mnemonic: Β«Catalogue before shelf.Β»** Without a catalogue, an agent has to
> already know a data source exists. With one, it can find it.
---
## π― Anchor Demo Query
> *Β«Which authority publishes data on special needs education, through which
> interface is it available, and under which licence?Β»*
```
search_catalog(query="SonderpΓ€dagogik")
β Β«Statistik der SonderpΓ€dagogikΒ» β Federal Statistical Office (BFS), theme: Bildung
get_dataset(dataset_id=...)
β 2 distributions, licence: Β«Opendata BY ASK β attribution required,
commercial use only with permission from the data supplierΒ»
β contact: auskunftsdienst@bfs.admin.ch
```
Two tool calls turn a vague topic into a named authority, a download URL and a
licence you can act on β `get_dataset` aggregates the distributions, licences
and contact point into one record.
### Demo

---
## Architecture
```
ββββββββββββββββββββββββββββββββ
β MCP Host (Claude) β
βββββββββββββββββ¬βββββββββββββββ
β stdio | streamable-http
βββββββββββββββββΌβββββββββββββββ
β i14y-mcp β
β ββββββββββββββββββββββββββ β
β β server.py (13 tools) β β
β ββββββββββββββββββββββββββ€ β
β β mappers.py β β DCAT β flat, one language
β ββββββββββββββββββββββββββ€ β
β β models.py (Pydantic) β β source + provenance envelope
β ββββββββββββββββββββββββββ€ β
β β client.py β β retry 2s/4s/8s, no-retry 4xx
β ββββββββββββββββββββββββββ β
βββββββββββββββββ¬βββββββββββββββ
β HTTPS, no auth
βββββββββββββββββΌβββββββββββββββ
β api.i14y.admin.ch/api β
β datasets Β· dataservices Β· β
β concepts Β· publicservices Β· β
β catalogs Β· agents Β· search β
ββββββββββββββββββββββββββββββββ
```
### Architecture decision
This server uses **Architecture A (live API only)**.
Rationale (verified live on 2026-07-21):
- All read endpoints respond without authentication and paginate correctly.
- No bulk download of catalogue metadata is offered, and none is needed.
- Error responses follow RFC 7807, so failure modes are distinguishable.
Consequences:
- Every HTTP call retries transient failures with 2 s / 4 s / 8 s backoff.
- `search_catalog` caps results client-side because the upstream ignores paging.
- `api_status` always returns an evaluable state instead of empty records.
Full probe report: [`docs/probe-i14y.md`](docs/probe-i14y.md).
### Project phase
This server is in **Phase 1 (read-only)** of the portfolio's Β«Read-only FirstΒ»
phase architecture: all tools are read-only, there is no authentication and no
personal data. See [`docs/roadmap.md`](docs/roadmap.md) for the phase model and
the prerequisites for any future write capability.
---
## Tools
| Tool | Purpose |
|---|---|
| `search_catalog` | Free-text search across the catalogue. Entry point. |
| `list_datasets` | Paginated dataset register (complete, unlike search). |
| `get_dataset` | Full metadata record for one dataset. |
| `get_dataset_distributions` | Download URLs, formats and **licences**. |
| `list_data_services` | Register of official Swiss APIs with endpoint URLs. |
| `get_data_service` | Full record for one registered interface. |
| `list_public_services` | Administrative services for citizens. |
| `list_concepts` | Harmonised concepts and code lists. |
| `get_concept` | One concept definition. |
| `search_codelist_entries` | Individual codes of a code list. |
| `list_publishers` | Publishing bodies, with Swiss UID. |
| `list_catalogs` | Contributing catalogues. |
| `api_status` | Reachability check with graceful degradation. |
All tools are annotated `readOnlyHint: true`. Write operations exist in the
upstream API but are deliberately not exposed.
### MCP primitives
This server exposes **Tools only** β no Resources, no Prompts. That is a
deliberate choice, not an omission: I14Y is queried by free-text search and by
opaque UUIDs, so there is no small, stable set of addressable URIs that would map
cleanly onto MCP Resources, and the server ships no opinionated prompt templates.
Every tool is read-only and idempotent; if a future stable entry point emerges
(e.g. a fixed theme list) it is a candidate for a Resource.
---
## MCP Protocol Version
This server speaks **two protocol eras** over the same endpoint. The client's
first request on a connection decides which one applies; a later claim from the
other era is refused.
| Era | Revision | Who reaches it |
|---|---|---|
| `initialize` handshake | `2024-11-05` β¦ **`2025-11-25`** | What today's clients speak. The server answers with the revision asked for, or with the `2025-11-25` ceiling when the request asks for something newer. |
| Per-request envelope | **`2026-07-28`** | A request carrying the `2026-07-28` `_meta` envelope opens a modern connection. |
Both revisions are pinned in
[`tests/test_protocol_version.py`](tests/test_protocol_version.py) and asserted
against the installed SDK, so a Dependabot bump of `mcp` cannot move either one
silently. The handshake ceiling is measured against a live `initialize` through
the assembled ASGI stack, not read off a constant name.
Note that the SDK's `LATEST_PROTOCOL_VERSION` is an alias for the **modern**
era, not for the handshake era β pinning against it alone would leave the era
that current clients actually negotiate free to drift.
### What the server carries on the modern revision
`2026-07-28` moves discovery off the `initialize` handshake onto
`server/discover` plus a per-request `_meta` envelope, and gives every cacheable
result a freshness hint. What this server puts on those surfaces is measured in
[`tests/test_spec_2026_07_28.py`](tests/test_spec_2026_07_28.py) through the
assembled stack β and, for stdio, through a real subprocess β rather than read
back off the configuration:
| Surface | What this server answers |
|---|---|
| `serverInfo`, stamped under **every** modern response | name, display title, description, website, and the installed distribution version β the same version the outbound `User-Agent` carries |
| `server/discover` β `instructions` | how to sequence the tools, plus the two properties of I14Y that no single tool description shows |
| `ttlMs` / `cacheScope` | 300 s, `public`, on all five cacheable methods this server answers |
| `tools[].title` | a display name per tool, so a client shows Β«Search a concept's code listΒ» rather than `search_codelist_entries` |
The freshness hint is not cosmetic. With none set, the SDK answers `ttlMs: 0,
cacheScope: private` β Β«already stale, never shareΒ» β for directories that are
fixed at import and cannot change while the process runs.
Both transports serve the modern revision: HTTP through the streamable-HTTP
session manager, and stdio β the default, and what `uvx i14y-mcp` starts β
through the same dual-era loop. Neither is evidence for the other, so both are
measured.
**One thing this server advertises but does not use.** On `2026-07-28` the
`listChanged` flags and `resources.subscribe` derive solely from whether
`subscriptions/listen` is served, and the SDK wires that handler
unconditionally. `server/discover` therefore reports `listChanged: true` for a
tool list that is fixed at import, and a subscribe capability over an empty
resource list; no change notification is ever sent. It is not switchable
through the public API β only a wholesale replacement of the `server/discover`
handler could do it, which would be a second truth about our own capabilities β
so it is pinned by a test instead of papered over.
**Update policy.** When the gate fails, do not edit the constant blindly: read
the spec changelog between the two revisions, verify the server still behaves,
then move the constant, this section, `README.de.md` and
[`CHANGELOG.md`](CHANGELOG.md) together.
---
## Installation
```bash
uvx i14y-mcp
```
Or from source:
```bash
git clone https://github.com/malkreide/i14y-mcp
cd i14y-mcp
pip install -e ".[dev]"
```
### Claude Desktop
```json
{
"mcpServers": {
"i14y": {
"command": "uvx",
"args": ["i14y-mcp"]
}
}
}
```
### Remote deployment (Render, Railway)
```bash
I14Y_MCP_TRANSPORT=streamable-http HOST=0.0.0.0 PORT=8000 i14y-mcp
```
`I14Y_MCP_TRANSPORT` accepts `stdio` (the default of the entry point itself),
`streamable-http` (`http` is a synonym) or `sse`. The HTTP transports bind to
`HOST`, which defaults to `127.0.0.1` (loopback); set `HOST=0.0.0.0` to expose
the port on a PaaS (the container image already does both).
#### Connector URL: `https://<host>/mcp`
A Claude.ai custom connector speaks Streamable HTTP, and that transport serves
exactly one path: `/mcp`. `sse` is the older transport and serves `/sse` and
`/messages` instead β measured through the assembled app, `POST /mcp` answers
**404** there and **200** under Streamable HTTP. The container image therefore
defaults to `streamable-http`; a connector pointed at an SSE deployment gets a
404 and never completes a handshake.
#### `I14Y_MCP_ALLOWED_HOSTS` β required for a public deployment
A comma-separated list of the hostnames the server is reached under β
**hostnames only, no scheme and no port**:
```bash
I14Y_MCP_ALLOWED_HOSTS=i14y-mcp.up.railway.app,mcp.example.ch
```
The value is compared literally against the incoming `Host` header, so
`https://i14y-mcp.up.railway.app` or a trailing `:443` matches nothing and every
request fails with **HTTP 421 Invalid Host header**. Behind TLS the browser
sends the bare hostname; a non-standard port needs the SDK's only wildcard form,
`host:*`. Loopback stays reachable either way, so container health checks are
unaffected.
Leaving it unset on a non-loopback bind does not fall back to something safe.
The server cannot guess the name it will be addressed by, and a guessed list
would reject every real request, so it switches the Host check off entirely and
says so in the log:
```
dns_rebinding_protection_off
```
#### `railway.json`
[`railway.json`](railway.json) pins the one build setting the deployment cannot
get wrong on its own:
```json
{ "build": { "builder": "DOCKERFILE", "dockerfilePath": "Dockerfile" } }
```
With any other builder Railway never looks at the Dockerfile. It derives a start
command itself, `I14Y_MCP_TRANSPORT` is then set nowhere, and `main()` falls into
the stdio branch β the container runs, never opens a port, and the only trace is
a failing health check. `tests/test_entrypoint.py` fails the day that setting
changes.
Two things the file deliberately does **not** carry:
- **No environment variables.** Railway's schema has no key for them at any
level, so `I14Y_MCP_ALLOWED_HOSTS` and `I14Y_MCP_TRANSPORT` belong in the
service variables. The trap is that the schema validates *values* but waves
*invented keys* through: a `"variables": { β¦ }` block passes validation, your
editor stays quiet, and Railway ignores it. A test rejects such a key.
- **No `healthcheckPath`.** Measured through the assembled app, no path answers
a GET with 2xx: `/` and `/health` are 404, `/mcp` is 400 without a session and
421 under a foreign `Host`. A health check pointed at any of them would mark
the deployment unhealthy and roll it back. The Dockerfile's TCP check does the
right thing instead. Should the server ever grow a real health route, the key
may be set β a test then requires that the path actually answers.
CORS exposes the `Mcp-Session-Id` header so browser MCP clients keep their session.
Which browser origins may call the server comes from `I14Y_MCP_CORS_ORIGINS`, a
comma-separated list β **unset means no browser client is permitted at all**,
which is the default. `*` is still accepted and logs a warning. stdio and other
non-browser clients are unaffected either way.
### Docker
```bash
docker compose up --build # Streamable HTTP on http://localhost:8000/mcp
```
The image is a hardened multi-stage build: it runs as a non-root user, ships no
build tools, and needs no secrets (the API is unauthenticated). See
[`Dockerfile`](Dockerfile) and [`compose.yaml`](compose.yaml).
---
## Join keys
I14Y is a connector layer. Two identifiers make it composable with the rest of
the portfolio:
| Key | Field | Joins to |
|---|---|---|
| Swiss UID | `Publisher.uid` | [`register-mcp`](https://github.com/malkreide/register-mcp) (Zefix) |
| Endpoint URL | `DataServiceSummary.endpoint_urls` | any portfolio server wrapping that API |
---
## Known limitations
Verified live on 2026-07-21.
1. **The search index covers roughly half the register.** `search_catalog`
returns at most 1013 records; `list_datasets` reaches about 2003. Use
`list_datasets` when completeness matters.
2. **Search returns Datasets only.** Filtering by `types=["Concept"]` or
`types=["DataService"]` yields zero results even though those entities
exist. Use `list_concepts` and `list_data_services` instead.
3. **The upstream ignores paging on search.** The full result set is always
returned; this server caps it at 200 records and sets `truncated: true`.
4. **Licences vary per distribution**, not per dataset. Most carry
Β«Opendata BY ASKΒ», which requires attribution and restricts commercial use.
Always read the `licence` field before reuse.
5. **Some metadata fields are simply empty.** Frequency, temporal coverage and
distribution format are optional and frequently unset by publishers. This is
a data-quality property of the catalogue, not a bug in this server.
6. **Not every entry with an endpoint has a URL.** Entries labelled only
Β«OpenAPI SpezifikationΒ» without a URI are surfaced as `(no URI) <label>`
rather than dropped.
---
## Testing
```bash
PYTHONPATH=src pytest tests/ -m "not live" # offline, used in CI
PYTHONPATH=src pytest tests/ -m "live" # hits the real API
PYTHONPATH=src pytest tests/ # everything
python -m ruff check src tests
```
The live tests are not decoration: fundstΓΌck 4 in the probe report β keywords
nesting their language object under `label` β was caught by a live test after
the unit tests were already green.
---
## Contributing
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the ground rules (read-only, one
egress host, no secrets) and the local dev loop. Maintainers: [`PUBLISHING.md`](PUBLISHING.md)
covers the PyPI / MCP Registry release process.
---
## Security
See [`SECURITY.md`](SECURITY.md) for the security posture and how to report a
vulnerability.
---
## License
MIT License β see [LICENSE](LICENSE). The catalogue data remains subject to the
terms declared by each publisher.
---
## Author
**Hayal Oezkan** Β· [github.com/malkreide](https://github.com/malkreide)
---
## Credits & related projects
- Data: [I14Y Interoperability Platform](https://www.i14y.admin.ch), Federal Statistical Office (BFS)
- Standard: [eCH-0200 / DCAT-AP-CH](https://www.ech.ch/de/ech/ech-0200/1.0)
- Source discovery inspired by [rnckp/awesome-ogd-switzerland](https://github.com/rnckp/awesome-ogd-switzerland)
- Portfolio: [swiss-public-data-mcp](https://github.com/malkreide/swiss-public-data-mcp)
- Protocol: [Model Context Protocol](https://modelcontextprotocol.io/)
Licence: MIT. The catalogue data remains subject to the terms declared by each
publisher.
---
## MCP Registry
Ownership marker used by the [MCP Registry](https://registry.modelcontextprotocol.io)
to link this PyPI package to the GitHub namespace:
```
mcp-name: io.github.malkreide/i14y-mcp
```
TDQS
Scored across 13 tools
Each tool targets a distinct resource or action: listing vs. fetching individual entities, plus search, status, and code-list lookup. Even the overlapping get_dataset and get_dataset_distributions are clearly differentiated by the description noting get_dataset returns distributions alongside the full record, making get_dataset_distributions a focused convenience tool. No two tools appear to do the same thing.
Tool names follow a consistent verb_noun pattern: list_* for collections, get_* for single entities, search_* for search operations, and api_status for health. The pattern is uniform across all 13 tools with no mixed conventions or vague verbs.
13 tools is well within the ideal 3-15 range for a domain-specific server. Each tool covers a distinct aspect of the I14Y metadata catalog (datasets, data services, public services, concepts, publishers, catalogs, status), and none feel redundant or superfluous.
The tool surface covers the core read-only workflows for a catalog: listing and retrieving datasets, data services, concepts, and code-list entries, plus search, publishers, catalogs, and status. Minor gaps existβthere is no get_public_service or get_catalog, so those entities can only be listed, not fetched individually. This is a minor gap that agents can work around, but it does not break the primary use cases.