v2 (Currently Only Available for Closed testing)
Getting started with the DZT MCP server (v2)
The DZT knowledge graph is reachable as a Model Context Protocol server: five search
tools over German tourism data — points of interest, trails, events and official tourism
regions.
Endpoint https://proxy.opendatagermany.io/api/its/mcp
Auth x-api-key: <your key> — request credentials from [email protected]
JSON-RPC 2.0 over POST. tools/list to discover, tools/call to invoke. Any MCP client
works without configuration; the API key is the only requirement.
What changed since v1
If you integrated against the first version, read this section before anything else.
| v1 | v2 | |
|---|---|---|
| default response | JSON-LD structured content | German markdown, ready to answer from |
| output format | fixed | chosen per request via x-output-format (markdown | jsonld | both) |
| duplicates | the same entity could repeat within a page | one entity per result, guaranteed |
| pagination | none | 10 per page, page parameter, hasMore flag |
| multi-value inputs | comma-separated strings | arrays |
| place filters | locality, region, postal_code separately | one location covering all of them — the three old names are rejected with an error, not ignored |
| type filter | coarse classes only | full ODTA taxonomy, incl. subtypes |
| JSON-LD vocabulary | some invented predicates | the graph's own predicates |
Breaking: locality and postal_code are gone — pass those values in location.
Multi-value parameters no longer accept comma-separated strings. The default response body no
longer contains JSON-LD; request it with x-output-format: jsonld
Your first call
curl -X POST https://proxy.opendatagermany.io/api/its/mcp \
-H 'x-api-key: YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"get_pois_by_criteria",
"arguments":{"type":["Museum"],"location":["Köln"]}}}'The tool result is a JSON object inside result.content[0].text (and repeated as
result.structuredContent):
{
"markdown": "Seite 1 – 10 Treffer. Weitere Treffer mit page=2.\n\n## 1. Domschatzkammer\n…",
"page": 1,
"pageSize": 10,
"count": 10,
"hasMore": true
}markdown is written to be read: a heading per result, then its type, description and
fields. If you are feeding an LLM, pass it through unchanged — it needs no post-processing.
Choosing the response format
One header decides the body. Accept is not used for this; it belongs to the MCP
transport, and most clients will not let you override it per call.
x-output-format | body contains |
|---|---|
(omitted) or markdown | markdown — the default |
jsonld | jsonld — @graph in expanded JSON-LD |
both | markdown and jsonld |
| anything else | falls back to markdown |
JSON-LD is expanded form — full IRI keys (https://schema.org/name), no @context, and
values as [{"@language":"de","@value":"…"}]. Predicates come straight from the graph, so
they are real: schema:servesCuisine, schema:amenityFeature, odta:length,
odta:uphillElevation. Compact it client-side with any JSON-LD processor if you prefer
CURIEs.
The five tools
Every tool takes language (two-letter code, default de) and page (1-based, 10 results
per page). All multi-value parameters are arrays.
Where these are searched, and how
| parameter | searched in the graph | matching |
|---|---|---|
name | schema:name | fuzzy (edit distance 2) and prefix |
keywords | schema:name, schema:keywords, schema:description, schema:description/schema:text | fuzzy on name and keywords; prefix only on description |
type | rdf:type | exact IRI |
location | schema:address/schema:addressLocality, …/addressRegion, …/postalCode, schema:containedInPlace/schema:name | exact whole value, case-insensitive; prefix if location_prefix_match. City, tourism region, federal state and postal code are all this one field — matching is by NAME, never geographic containment |
language | selects among language-tagged literals | requested → de → en → untagged |
Keyword relevance is weighted: a hit in name outranks one in keywords, which outranks one
in the description.
get_pois_by_criteria
get_pois_by_criteriaPlaces that exist permanently: museums, restaurants, castles, viewpoints, pools, wineries.
| parameter | type | notes |
|---|---|---|
name | string | a specific entity, e.g. Kölner Dom |
keywords | array | topical terms, matched across name, keywords and description |
type | array | see Types below |
location | array | city, tourism region, federal state, postal code or containing place — see Places |
location_prefix_match | boolean | see Places |
near_point | string | "<radius>km,<lat>,<lon>", e.g. "10km,48.137,11.576" |
cuisine | array | free text, mostly German: italienisch, vegetarisch, regional |
amenities | array | a label (Sauna) or an ODTA IRI (odta:ParkingLots) |
accessible_for_free | boolean | free entry only |
| parameter | searched in the graph | matching |
|---|---|---|
cuisine | schema:servesCuisine | phrase match, analyzed |
amenities | schema:amenityFeature/schema:name (labels) or schema:amenityFeature/schema:valueReference (IRIs) | phrase match on labels; exact, case-insensitive on IRIs |
accessible_for_free | schema:isAccessibleForFree | exact boolean |
near_point | geo:hasGeometry/geo:asWKT | geo radius |
get_trails_by_criteria
get_trails_by_criteriaHiking, cycling and walking routes.
| parameter | type | notes |
|---|---|---|
difficulty | array | leicht, mittel, schwer (English also accepted) |
max_length_km | number | see Known limitations |
max_ascent_m | number | see Known limitations |
max_duration_min | number | matched to the hour; the source stores ISO-8601 durations |
is_circular | boolean | circular routes only |
near_point | string | as above |
near_mode | string | start (default) — starts near the point; passes — passes near it (relevant for trails) |
Plus name, keywords, type, location, location_prefix_match.
| parameter | searched in the graph | matching |
|---|---|---|
difficulty | odta:difficulty (vocabulary IRI) and odta:difficulty/schema:name (German label) | exact IRI, or analyzed match on the label |
max_length_km | odta:length/schema:value | numeric range — see Known limitations |
max_ascent_m | odta:uphillElevation/schema:value | numeric range — see Known limitations |
max_duration_min | odta:estimatedDuration/schema:name | matched to the hour; the source stores ISO-8601 (PT4H13M) |
is_circular | odta:circularTrail | exact boolean |
near_point + near_mode | odta:startLocation/geo:hasGeometry/geo:asWKT (start) or geo:hasGeometry/geo:asWKT (passes) | geo radius |
For trails, location additionally covers the start and end points —
odta:startLocation/schema:address/schema:addressLocality, the same via odta:endLocation,
and the schema:containedInPlace/schema:name of either. So a town name finds trails that
start there, not only ones whose own address is there.
get_events_by_criteria
get_events_by_criteriaThings happening on dates: markets, festivals, guided tours.
| parameter | type | notes |
|---|---|---|
dateRangeStart | string | YYYY-MM-DD; any other format is rejected |
dateRangeEnd | string | YYYY-MM-DD |
attendance_mode | string | offline, online, mixed |
near_point | string | matches the venue's location |
Plus name, keywords, type, location, location_prefix_match.
A recurring event has many occurrences. When you pass a date range, the startDate returned
is the next occurrence inside that range — not the event's earliest date ever.
| parameter | searched in the graph | matching |
|---|---|---|
dateRangeStart / dateRangeEnd | schema:startDate and schema:eventSchedule/schema:startDate | date range; a recurring event matches on any occurrence in the window |
attendance_mode | schema:eventAttendanceMode | exact, case-insensitive |
near_point | schema:location/geo:hasGeometry/geo:asWKT — the venue's geometry | geo radius |
For events, location resolves through the venue:
schema:location/schema:address/schema:addressLocality and
schema:location/schema:containedInPlace/schema:name. An event has no address of its own.
get_touristic_regions_by_criteria
get_touristic_regions_by_criteriaThe 143 official German tourism regions (Reisegebiete). Takes name, keywords.
Use it to resolve a region's canonical spelling before filtering other tools by location.
| parameter | searched in the graph | matching |
|---|---|---|
name, keywords | as above | fuzzy on the name, prefix on descriptions |
The tool is scoped to the 143 entities whose schema:identifier/schema:value is
Reisegebiete (RG250).
get_entity_details
get_entity_detailsEverything known about one entity.
| parameter | type | notes |
|---|---|---|
uri | string | required — take it from a previous result |
language | string | optional, defaults to de |
Returns the full record rather than a search summary, including every image with its
copyright and licence. x-output-format applies here exactly as it does to the search
tools: markdown by default, JSON-LD only on request. Until 2026-08-18 this one tool ignored
the header and always returned both — the default body is now ~87 % smaller (4.2 KB instead
of 32.6 KB), so a client that read structuredContent["@graph"] without sending a header
must now send x-output-format: jsonld and read structuredContent.jsonld["@graph"].
uri is resolved directly — no search, no matching. language selects among language-tagged
literals with the same fallback as elsewhere (requested → de → en → untagged).
Places: matching is exact
location matches the whole stored value, case-insensitively. Hyphen and space variants
are tried automatically, so Chiemsee-Alpenland also finds Chiemsee Alpenland.
Exactness is deliberate: it stops a search for one town returning a similarly-named one —
Erfurt will not return results from Querfurt.
The cost is that a short form misses a longer official name. Frankfurt does not match
Frankfurt am Main, and Erfurt does not match Erfurt - Molsdorf. Two options:
- pass the full official name, or
- set
location_prefix_match: true, which also matches values beginning with your term
Prefix matching finds longer names, not neighbouring places. For "somewhere near here",
use near_point.
A worked example, on get_events_by_criteria — 6 events contain "erfurt" as a substring;
5 are genuinely in Erfurt and one is Querfurt:
| query | returns |
|---|---|
{"location":["Erfurt"]} | 3 — those recorded exactly as Erfurt |
{"location":["Erfurt"],"location_prefix_match":true} | 5 — adds two in Erfurt - Molsdorf |
| neither | the Querfurt event, correctly excluded |
The counts are tool- and data-specific: the same two queries against get_pois_by_criteria
both fill a page (count: 10, hasMore: true), because Erfurt has far more POIs than events.
What carries over is the behaviour — exact by default, prefix only when you ask, and never
a neighbouring town.
Types
type accepts a bare name (Museum), a prefixed name (odta:HikingTrail) or a full IRI
(https://odta.io/voc/HikingTrail) — always as an array. A bare name is tried against both
schema.org and ODTA.
The full ODTA taxonomy is filterable, including subtypes: odta:HikingTrail,
odta:BikeTourTrail, odta:ChristmasMarket, odta:FarmersMarket. (In v1 subtypes silently
returned nothing, because types resolved through rdfs:label, which ODTA classes lack.)
If a very specific type returns nothing, retry with a broader one — Restaurant →
FoodEstablishment.
Pagination
Ten results per page. hasMore tells you whether another page exists; to fetch it, repeat
the identical call with page incremented. Pages are disjoint and stable — ordering is by
relevance with a deterministic tiebreak, so an entity cannot appear on two pages.
count is the number of results on this page, not the total number of matches.
Invalid input is rejected, not ignored
A malformed filter fails the call with a message naming both what was expected and what
arrived — it is never silently dropped. Answering a question while quietly discarding the
user's constraint produces a confidently wrong result, which is worse than an error.
| input | response |
|---|---|
page: -3 or page: 1.5 | page must be a whole number >= 1, got: -3 |
near_point: "nahe München" | near_point must be <radius>km,<lat>,<lon> — for example 10km,48.137,11.576 |
near_point: "10km,999,11.5" | near_point latitude must be between -90 and 90 |
near_point: "0km,48.1,11.5" | near_point radius must be greater than 0 km |
dateRangeStart: "Dezember" | dateRangeStart must be a date as YYYY-MM-DD, for example 2026-12-24 |
difficulty: ["ganz einfach"] | difficulty must be leicht, mittel or schwer (or easy/medium/hard) |
max_length_km: -5 | max_length_km must be greater than 0 |
near_mode: "irgendwas" | near_mode must be start or passes |
Type errors are caught by the input schema itself — passing type: "Museum" instead of
["Museum"] returns data/type must be array, and get_entity_details without a uri
returns data must have required property 'uri'. A uri that does not exist returns 404.
Errors arrive as an MCP tool error: HTTP 200 with result.isError: true, and the message in
result.structuredContent. The message is prefixed by an execution id, so read the tail.
Two values are deliberately not validated: attendance_mode and free-text fields like
cuisine and amenities. An unrecognised value there simply returns no results, which is an
honest answer rather than an error.
Known limitations
Trail length and ascent filters are unreliable. max_length_km and max_ascent_m
currently compare as text rather than numbers, so they return incorrect sets. Read the length
and ascent values from the results instead of relying on the filter. A fix is in progress.
Coverage is uneven across Germany. Some regions are densely described, others barely.
An empty result frequently means missing data rather than a missing attraction
Keyword search is fuzzy on names, exact-prefix on descriptions. A term is matched with
tolerance for typos against names and keywords, and as a prefix against descriptions. This
keeps long prose from matching almost anything.
Practical notes
- Data follows ODTA Domain Specifications v2.2. Each entity carries
compliesWith,
naming the specification it was validated against. - Call
tools/listrather than hard-coding schemas; parameters are extended over time.
Disclaimer
This documentation was created by AI and audited by a person.
Updated 5 days ago
