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.

v1v2
default responseJSON-LD structured contentGerman markdown, ready to answer from
total number of matchesnot reportedtotal in every search response
keyword logicone keywords field, OR only, undocumentedkeywords_any (OR, as before) and keywords_all (AND) — keywords is rejected with a pointer
output formatfixedchosen per request via x-output-format (markdown | jsonld | both)
duplicatesthe same entity could repeat within a pageone entity per result, guaranteed
paginationnone10 per page, page parameter, hasMore flag
multi-value inputscomma-separated stringsarrays
place filterslocality, region, postal_code separatelyone location covering all of them — the three old names are rejected with an error, not ignored
type filtercoarse classes onlyfull ODTA taxonomy, incl. subtypes
keyword tolerancetwo edits for every term, regardless of lengthno 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 filtermax_length_kmtemporarily unavailable — see Known limitations
JSON-LD vocabularysome invented predicatesthe graph's own predicates
ODTA specificationsv1.4 and v2.1v2.2 and v1.4
event date filterdateRangeStart and keywords both requireddateRangeStart still required, keywords now optional
event result orderrelevancechronological, earliest first
a call with no filtersreturned a nationwide dump ranked by relevancerejected 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-formatbody contains
(omitted) or markdownmarkdown — the default
jsonldjsonld@graph in expanded JSON-LD
bothmarkdown and jsonld
anything elsefalls 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

parametersearched in the graphmatching
nameschema:nameword-beginning (prefix), every word of a multi-word name required — see Keyword matching
keywords_any, keywords_allschema:name, schema:keywords, schema:description, schema:description/schema:textword-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
typerdf:typeexact IRI
locationdiffers per tool, see belowexact 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.

toolsearched innot searched
get_pois_by_criteriaschema:address/addressLocality, …/addressRegion, …/postalCode, schema:containedInPlace/schema:name
get_trails_by_criteriaodta: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_criteriaschema:location/schema:address/addressLocality, schema:location/schema:containedInPlace/schema:namethe venue's addressRegion and postalCode
get_touristic_regions_by_criteriano 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 → deen → 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

Places that exist permanently: museums, restaurants, castles, viewpoints, pools, wineries.

parametertypenotes
namestringa specific entity, e.g. Kölner Dom
keywords_anyarraytopical terms, at least one must match — influences ranking
keywords_allarraytopical terms, every one must match — filters instead of ranking
typearraysee Types below
locationarraycity, tourism region, federal state, postal code or containing place — see Places
location_prefix_matchbooleansee Places
near_pointstring"<radius>km,<lat>,<lon>", e.g. "10km,48.137,11.576"
cuisinearrayfree text, mostly German: italienisch, vegetarisch, regional
amenitiesarraya label (Sauna) or an ODTA IRI (odta:ParkingLots)
accessible_for_freebooleanfree entry only
parametersearched in the graphmatching
cuisineschema:servesCuisinephrase match, analyzed
amenitiesschema:amenityFeature/schema:name (labels) or schema:amenityFeature/schema:valueReference (IRIs)phrase match on labels; exact, case-insensitive on IRIs
accessible_for_freeschema:isAccessibleForFreeexact boolean
near_pointgeo:hasGeometry/geo:asWKTgeo radius

get_trails_by_criteria

Hiking, cycling and walking routes.

parametertypenotes
difficultyarrayexactly leicht, mittel or schwer — the input schema rejects any other value, English included
max_duration_minnumbermatched to the hour; the source stores ISO-8601 durations
is_circularbooleancircular routes only
near_pointstringas above
near_modestringstart (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.

parametersearched in the graphmatching
difficultyodta:difficulty (vocabulary IRI) and odta:difficulty/schema:name (German label)exact IRI, or analyzed match on the label
max_duration_minodta:estimatedDuration/schema:namematched to the hour; the source stores ISO-8601 (PT4H13M)
is_circularodta:circularTrailexact boolean
near_point + near_modeodta: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

Things happening on dates: markets, festivals, guided tours.

parametertypenotes
dateRangeStartstringrequiredYYYY-MM-DD; any other format is rejected
dateRangeEndstringYYYY-MM-DD; omit for an open-ended future
attendance_modestringoffline, online, mixed
near_pointstringmatches 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.

parametersearched in the graphmatching
dateRangeStart / dateRangeEndschema:startDate and schema:eventSchedule/schema:startDatedate range; a recurring event matches on any occurrence in the window
attendance_modeschema:eventAttendanceModeexact, case-insensitive
near_pointschema:location/geo:hasGeometry/geo:asWKT — the venue's geometrygeo 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

The 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.

parametersearched in the graphmatching
name, keywords_any, keywords_allas aboveword-beginning (prefix), as above

The tool is scoped to the 143 entities whose schema:identifier/schema:value is
Reisegebiete (RG250).

get_entity_details

Everything known about one entity.

parametertypenotes
uristringrequired — take it from a previous result
languagestringoptional, 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 → deen → 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:

queryreturns
{"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
neitherthe 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.

inputresponse
page: -3 or page: 1.5page 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: -5max_duration_min must be greater than 0, got: -5
max_length_km: 10, or any other parameter the tool does not declareUnknown 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 dateRangeStartdata 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 dateAt 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/list rather 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 with Unknown parameter: <name>. This tool accepts: …. Send only what tools/list
    declares, and read that list at runtime rather than hard-coding it.

Did this page help you?