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 |
| total number of matches | not reported | total in every search response |
| keyword logic | one keywords field, OR only, undocumented | keywords_any (OR, as before) and keywords_all (AND) — keywords is rejected with a pointer |
| 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 |
| keyword tolerance | two edits for every term, regardless of length | no tolerance — word-beginning (prefix) matching, plus the umlaut word form for short terms; the edit distance was removed on 20.08.2026, see Keyword matching |
| trail length filter | max_length_km | temporarily unavailable — see Known limitations |
| JSON-LD vocabulary | some invented predicates | the graph's own predicates |
| ODTA specifications | v1.4 and v2.1 | v2.2 and v1.4 |
| event date filter | dateRangeStart and keywords both required | dateRangeStart still required, keywords now optional |
| event result order | relevance | chronological, earliest first |
| a call with no filters | returned a nationwide dump ranked by relevance | rejected with an error naming the filters to choose from |
Breaking: keywords is gone — use keywords_any for the previous behaviour, or
keywords_all when every term must match. locality and postal_code are gone — pass those
values in location. max_length_km is currently not accepted either; see Known
limitations.
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 von 167 Treffern insgesamt. Weitere Treffer mit page=2.\n\n## 1. Domschatzkammer\n…",
"page": 1,
"pageSize": 10,
"count": 10,
"hasMore": true,
"total": 167
}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 | word-beginning (prefix), every word of a multi-word name required — see Keyword matching |
keywords_any, keywords_all | schema:name, schema:keywords, schema:description, schema:description/schema:text | word-beginning (prefix) in every field, no typo tolerance; a hit in name outranks one in keywords, which outranks one in the description. See Keyword matching |
type | rdf:type | exact IRI |
location | differs per tool, see below | exact whole value, case-insensitive; prefix if location_prefix_match. Matching is by NAME, never geographic containment |
Where location looks, per tool. All place slots are merged into a single index field, which is
why they cannot be queried apart from one another.
| tool | searched in | not searched |
|---|---|---|
get_pois_by_criteria | schema:address/addressLocality, …/addressRegion, …/postalCode, schema:containedInPlace/schema:name | — |
get_trails_by_criteria | odta:startLocation and odta:endLocation, each via schema:address/addressLocality and …/addressRegion and schema:containedInPlace/schema:name; plus the route's own schema:containedInPlace/schema:name (tourism region, federal state, nature park) | postal code — a route has no address of its own |
get_events_by_criteria | schema:location/schema:address/addressLocality, schema:location/schema:containedInPlace/schema:name | the venue's addressRegion and postalCode |
get_touristic_regions_by_criteria | no location parameter — filter by name | — |
Two consequences worth planning for. A trail cannot be restricted to where it starts: start
point, end point and containing area share one field, so a route that merely ends in or runs
through your place matches as well. near_point with near_mode: "start" is the only true
start-point restriction. A postal code only works for POIs; for events and trails it silently
matches nothing, so use the city or region name there.
| language | selects among language-tagged literals | requested → de → en → untagged. Applies per field AND per set: where a value is a list of translated sub-records (amenities, difficulty labels, containing places), the whole list is answered in one language. Values carrying no language tag — codes, payment methods, place names without a tag — are always included. The selection applies to the markdown and to the JSON-LD alike |
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_any | array | topical terms, at least one must match — influences ranking |
keywords_all | array | topical terms, every one must match — filters instead of ranking |
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 | exactly leicht, mittel or schwer — the input schema rejects any other value, English included |
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) — the route's start point is inside the radius, distance measured to that point; passes — the route's line runs through the radius anywhere, distance measured to the nearest point of the route |
Plus name, keywords_any, keywords_all, 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_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 the route's whole geo:hasGeometry/geo:asWKT (passes) | geo radius |
passes measures to the nearest point of the route. The radius applies to the route's whole
line, and since 20.08.2026 the reported Distance is the shortest distance from your point to that
line — computed with geof:distance in the graph. Before that it was the distance to the route's
start point, which put long-distance routes at the end of the list with absurd values: a 20 km
search around Dresden reported the Elbe cycle route at 465 km, because it begins at the river mouth,
while its line passes 390 m from the point. Routes without start coordinates used to carry no
distance at all although they lay 12–17 km away; they are now measured like everything else.
In both modes every hit is inside the radius, ordered nearest first, and carries a distance.
The nearest-point computation costs time on wide radii: measured 4.7 s at 100 km and 6.5 s at
200 km, against 3.2 s and 4.8 s in start mode. Narrow the radius when that matters.
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 | required — YYYY-MM-DD; any other format is rejected |
dateRangeEnd | string | YYYY-MM-DD; omit for an open-ended future |
attendance_mode | string | offline, online, mixed |
near_point | string | matches the venue's location. The list stays chronological — events are not distance-ordered and carry no distance |
Plus name, keywords_any, keywords_all, type, location, location_prefix_match.
dateRangeStart is required. For "what is on" or "what is coming up", pass today's date;
for a specific weekend or month, pass both ends of it. Without a date the index would happily
return events from past years ranked by keyword relevance, so the parameter is enforced rather
than defaulted — the caller knows which day "today" is, the server does not know which day the
question was asked. To search the past, or all time, pass an early dateRangeStart such as
2000-01-01 — but keep another filter narrow when you do, because a wide date range plus a
wide place filter is one of the query shapes that can time out (see Known limitations).
Results come back chronologically, earliest first (relevance is only the tiebreaker). This
holds across pages, so page 2 continues where page 1 ended — within the sorted window of 2000
matches described under Pagination.
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. Ordering
uses an event's earliest occurrence overall, so for a long-running series with past dates the
position in the list can sit slightly ahead of the startDate shown.
| 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.
The result carries those containing places back, restricted to the official area records
(gdz.bkg.bund.de, germany.travel). They are what explains a hit: a venue in Potsdam answers
location: ["Berlin"] because the chain says Berlin, and without the containing places the
response would give no reason for it. Duplicate venue records from the import are filtered out
this way — one event carried seventeen Place records of the same house.
get_touristic_regions_by_criteria
get_touristic_regions_by_criteriaThe 143 official German tourism regions (Reisegebiete). Takes name, keywords_any, keywords_all.
Use it to resolve a region's canonical spelling before filtering other tools by location.
This is the one tool that also answers with no parameters at all — the set is small and
enumerable, so paging through it to see which regions exist is a legitimate call.
| parameter | searched in the graph | matching |
|---|---|---|
name, keywords_any, keywords_all | as above | word-beginning (prefix), as above |
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"],"dateRangeStart":"2000-01-01"} | 3 — those recorded exactly as Erfurt |
{"location":["Erfurt"],"location_prefix_match":true,"dateRangeStart":"2000-01-01"} | 5 — adds two in Erfurt - Molsdorf |
| neither | the Querfurt event, correctly excluded |
(The early dateRangeStart is only there because events require one; it makes the example
cover all dates so the counts are about place matching, nothing else.)
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, so an entity cannot
appear on two pages.
Ordering is by relevance with a deterministic tiebreak for POIs, trails and regions,
chronological for events (earliest start date first), and by distance for a
near_point search on POIs and trails. Events are the exception: a near_point filter does
not reorder them and reports no distance — they stay chronological.
count is the number of results on this page. total is the number of matches overall —
so "count": 10, "total": 35 means "ten of thirty-five", and "total": 2, "count": 2 means
"that is all of them". The markdown header states the same thing in words.
total is the number of matches in the index, which can be larger than the set that was
actually sorted and paged. Sorting runs over a window of 2000 matches: for POI and trail
searches that window applies to a near_point search, and event searches always use it,
because their chronological order has to be computed after the index query. Whenever a result
set exceeds the window, the page is a sample of it rather than the true top of the list, and
the markdown header says so:
Seite 1 – 10 Treffer von 3776 Treffern im Index. Weitere Treffer mit page=2. Hinweis: es gibt
3776 Treffer; sortiert wurden 2000 davon, die Liste ist also ein Ausschnitt.
Narrow the search — a tighter radius, a smaller date range, a location or a type — to bring
the match count under 2000 and get a complete ordering. (The notice ends with a sentence about
a smaller radius even when the query has no near_point; read it as "narrow the query".)
A near_point search returns the nearest entries first. A city-sized radius fits inside the
window (50 km around Dresden holds 1,209 POIs), so the list really is the nearest ones; 200 km
around Munich holds 30,308 and does not.
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,<latitude>,<longitude> — three comma-separated parts, the radius with the unit km. Got: nahe München |
near_point: "10km,999,11.5" | near_point latitude must be between -90 and 90. Got: 999 |
near_point: "0km,48.1,11.5" | near_point radius must be greater than 0 km. Got: 0km,48.1,11.5 |
dateRangeStart: "Dezember" | dateRangeStart must be a date as YYYY-MM-DD (four digits, two, two). Got: Dezember |
difficulty: ["ganz einfach"] or ["easy"] | data/difficulty/0 must be equal to one of the allowed values — the schema enum allows only the three German values |
max_duration_min: -5 | max_duration_min must be greater than 0, got: -5 |
max_length_km: 10, or any other parameter the tool does not declare | Unknown parameter: max_length_km. This tool accepts: … (the accepted names follow) |
near_mode: "irgendwas" | data/near_mode must be equal to one of the allowed values (start, passes) |
attendance_mode: "quatsch" | data/attendance_mode must be equal to one of the allowed values (offline, online, mixed) |
events without dateRangeStart | data must have required property 'dateRangeStart' |
no filter at all, e.g. {} | At least one search filter is required: pass one of name, keywords_any, keywords_all, type, location, near_point, cuisine, amenities, accessible_for_free — for example location: ["Koeln"] or type: ["Museum"] (the list names that tool's own facets) |
| events with only a date | At least one search filter is required besides the date: pass one of name, keywords_any, keywords_all, type, location, near_point or attendance_mode — for example location: ["Berlin"] or keywords_any: ["Weihnachtsmarkt"] |
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.
A call has to say what it is looking for. get_pois_by_criteria, get_trails_by_criteria
and get_events_by_criteria need at least one filter that narrows the search — one of name,
keywords_any, keywords_all, type, location, near_point or a facet of that tool. language and page do
not count, and for events neither does the date on its own: a nationwide list of everything
happening in December, ranked by nothing in particular, answers no question anybody asked.
get_touristic_regions_by_criteria is exempt, see above.
Free-text fields are the exception: cuisine and amenities are not validated, because
their values are free text in the source data. An unrecognised value there simply returns no
results, which is an honest answer rather than an error. Everything with a fixed set of values —
difficulty, near_mode, attendance_mode — is checked against the input schema and rejected.
Known limitations
There is currently no filter for trail length. max_length_km was removed on 20.08.2026
because of a technical problem in the indexing of that value; we are working on a fix and will
restore the field afterwards. Until then the name returns an Unknown parameter error rather
than a plausible but wrong set. The length is part of every result, so you can filter on your
side. max_duration_min is unaffected and works.
Markdown and JSON-LD differ in depth, not in language. Both answer in the same language.
The markdown collapses a referenced record to its name — one line per property of the result — so
a sub-record's own fields (a venue's address, its containing places) appear in the JSON-LD only.
For trails the protected areas hang off the trail itself and therefore show in both.
Coverage is uneven across Germany. Some regions are densely described, others barely.
An empty result frequently means missing data rather than a missing attraction
A very broad query can time out in the graph. Broad searches — no location with a common
keyword, or a very wide date range — can come back as The knowledge graph returned an EMPTY response for this query (no JSON-LD at all, not zero hits), or as a bare 504. Both mean the
query did not complete; neither is the same as zero results. Narrow the search (add a
location, a type, a tighter date range or a more specific keyword) and retry. Under load
this can also hit queries that normally answer in seconds, so a retry is worth one attempt
before you conclude anything about the data.
Keyword matching is by word beginning, with no tolerance for typos. A term matches where a
word in the name, the keywords or the description starts with it: Kunst finds Kunsthalle and
Kunstverein. Fuzzy matching was removed — it was meant to absorb typos, but the
caller is a language model rather than someone typing, and the price was wrong hits: Obst
matched Ost, Markt matched Macht. Send the term you mean.
Hyphens count as word separators, in name as well: Mosel-Saar is searched as Mosel plus
Saar, which is why it finds the region whose name carries the hyphen.
keywords_any ranks, keywords_all filters. With keywords_any, at least one term has to
match and the terms mainly decide the order — a term that matches nothing is dropped rather than
narrowing the result. keywords_all requires every term and therefore filters, which cuts a large
result set down sharply and empties a small one. Coverage is uneven across Germany, so the same
pair of terms can be productive in one region and empty in another; read total from the previous
response before reaching for it.
Practical notes
- Call
tools/listrather than hard-coding schemas; parameters are extended over time. - Unknown arguments are rejected, not ignored. A parameter the tool does not declare fails
the call withUnknown parameter: <name>. This tool accepts: …. Send only whattools/list
declares, and read that list at runtime rather than hard-coding it.
Updated 18 days ago
