mcp-geocoder
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-geocoderCheck if Heinrichweg 24 is a real address in Berlin."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
MCP-Geocoder
Fault-tolerant German address capture for phone agents. Matches what the speech recognizer thought it heard against the official street directory, returns confidence instead of guesswork, and tells the agent what to say next — as an MCP server, a REST API, and a demo web UI.
Runtime: Bun · Package manager: pnpm · Lint: Biome · Container: Docker · Code: English, comments: German
Why
No speech recognition system understands German street names without errors. The difference between a good and a bad phone agent is not whether it makes mistakes, but whether it notices them.
An agent that silently accepts what it heard writes "Henrichweg 24" into the database although the caller said "Heinrichstraße 24". The downstream lookup fails, and the end customer does not experience "recognition is poor" but "the agent can't find my address".
This server turns that around: a hard failure becomes an ordinary follow-up question.
Typical weakness | Where it is addressed here |
Names, streets, places are misrecognized | Matching against the official directory of the postal code; Cologne phonetics + Jaro-Winkler; keyterm generator for the transcriber |
Agent stumbles over numbers and abbreviations | Every response is TTS-ready ( |
Similar streets get mixed up | Conflicting street type (Weg ≠ Straße) caps confidence — never an automatic accept |
Wrong data slips through silently |
|
Related MCP server: HongKongAddressMCP
Quick start
pnpm install
cp .env.example .env # set service area and thresholds
pnpm check # typecheck + lint + test (offline, fixtures only)
pnpm serve # http://localhost:8080No local Bun? Everything runs in Docker as well.
docker run --rm -p 8080:8080 ghcr.io/ai-workflow-automations/mcp-geocoder # demo at http://localhost:8080URL | What |
| Demo UI: matching, hard cases, API docs |
| REST API, see API |
| OpenAPI 3.1 — the single source of API documentation |
| MCP Streamable HTTP — this is where the phone agent connects |
| Data sources, thresholds, service area |
| llms.txt overview for AI crawlers, links absolute |
The demo page ships with favicon, meta description, Open Graph, geo.* tags for the service
area and a Schema.org WebApplication JSON-LD. Canonical URL, geo position and JSON-LD are
rendered per deployment from the configuration — see PUBLIC_URL under Configuration.
Claude Code in 30 seconds
Prebuilt image on GHCR, nothing to clone or install besides Docker:
claude mcp add mcp-geocoder -- docker run -i --rm ghcr.io/ai-workflow-automations/mcp-geocoder stdioRestrict to your service area with -e SERVICE_AREA_POSTAL_CODES=10115,10117,10119 before the image name.
If docker pull answers unauthorized, the package is still private: log in with a token that has
read:packages (echo $TOKEN | docker login ghcr.io -u <user> --password-stdin), or make the package
public under the organization's package settings.
Or run it as a service once and connect over HTTP (also works for Vapi, n8n, etc.):
docker run -d --name geocoder -p 8080:8080 ghcr.io/ai-workflow-automations/mcp-geocoder
claude mcp add --transport http mcp-geocoder http://localhost:8080/mcpWith MCP_AUTH_TOKEN set, add --header "Authorization: Bearer <token>" to the second command.
Use --scope user to make the server available in every project.
Without Docker: clone, install, start Claude Code in the folder. .mcp.json
registers the server for that project, no claude mcp add needed. Requires
Bun and pnpm on the machine — .mcp.json launches bun run src/index.ts.
curl -fsSL https://bun.sh/install | bash # once, if `which bun` is empty
git clone https://github.com/AI-Workflow-Automations/MCP-Geocoder && cd MCP-Geocoder && pnpm install
claude # asks once, then the server starts on demand
claude mcp add --scope user mcp-geocoder -- bun run "$PWD/src/index.ts" # optional: every project/mcp shows Failed to reconnect to mcp-geocoder: CONNECTION_CLOSED? The launch command exited
before speaking MCP. Run it by hand — the reason is in the first line of output:
bun run src/index.ts # .mcp.json path: "command not found" → install Bun
docker run -i --rm ghcr.io/ai-workflow-automations/mcp-geocoder stdio # Docker path: "unauthorized" → see aboveA healthy start prints [mcp-geocoder] stdio-Transport bereit on stderr and then waits for input.
Claude Desktop (claude_desktop_config.json):
{
"mcpServers": {
"mcp-geocoder": {
"command": "docker",
"args": ["run", "-i", "--rm", "ghcr.io/ai-workflow-automations/mcp-geocoder", "stdio"]
}
}
}Architecture
Source file: docs/diagrams/01-architecture.puml
Layered by dependency direction — the domain knows nothing about HTTP, MCP, or any data source:
src/
├── domain/ pure business logic, no I/O
│ ├── types.ts MatchStatus, StreetCandidate, AddressResolution, ...
│ ├── ports.ts StreetDirectory, StreetSearch, EscalationSink (interfaces)
│ ├── normalization.ts abbreviations, umlauts, filler words, stem/street type
│ ├── phonetics.ts Cologne phonetics (Kölner Phonetik)
│ ├── similarity.ts Jaro-Winkler, Damerau-Levenshtein, token similarity
│ ├── scoring.ts weights, street-type cap, ranking
│ └── decision.ts confirmed / ambiguous / unresolved
├── application/ use cases, depend on ports only
│ ├── address-resolver.ts
│ ├── postal-code-resolver.ts
│ ├── keyterm-builder.ts
│ └── geocoder-service.ts facade - the single entry point for MCP and REST
├── infrastructure/ adapters for the ports
│ ├── openplz-directory.ts official directory
│ ├── photon-search.ts fault-tolerant search (OSM)
│ ├── ttl-cache.ts, http-client.ts, escalation-log.ts
├── speech/ speech-formatter.ts - TTS-ready sentences, central phrases
├── mcp/ server.ts (tools + resources), api-guide.ts
├── api/ app.ts (Express: REST + MCP transport + demo), openapi.ts
├── config.ts the only place that reads process.env
├── composition.ts composition root - concrete implementations are wired here
├── index.ts entry point stdio
└── serve.ts entry point HTTPWhy this way: The hard-case tests run against a fixture directory instead of OpenPLZ —
possible because AddressResolver only knows the StreetDirectory port (dependency
inversion). MCP tools and REST routes call the same facade; there is no second code path
for the demo (DRY). The domain depends on nothing but itself.
Conversation flow
Source file: docs/diagrams/02-conversation.puml
Postal code first. Digits survive narrowband telephony far better than place names, and a confirmed postal code shrinks the search space from hundreds of thousands of streets to a few hundred.
Then the street — against the directory of that postal code.
React to
status:confirmed→ read back ·ambiguous→ read out the choices ·unresolved→ hand over.Never guess. If the caller rejects everything:
flag_for_human.
Decision logic
Source file: docs/diagrams/03-decision.puml
Absolute confidence alone is not enough. Three rules on top:
Margin to the runner-up. Two almost equally good hits are never
confirmed, even if both are above the threshold.Street-type cap. "Henrichweg" and "Heinrichstraße" share practically the same stem and the same sound. Weighted additively, that would clear the auto-accept threshold. But a different street type is a hard signal — in the directory these are two streets. Such a candidate is capped at 0.84 and at most read out as an option.
Suggestion band. Only candidates close to the best one are read out. Three options of which two are obviously wrong tempt the caller to confirm anything.
Hard cases
test/hard-cases.test.ts is the acceptance criterion for the
matcher. Whoever changes weights or thresholds has to pass it. Excerpt:
Heard | Why it is hard | Expected |
| stem sounds like Heinrichstraße |
|
| STT dropped the i |
|
| street type missing, three candidates share the stem |
|
| Bahnhofstraße has almost the same stem |
|
| Straße, Allee, Ring — undecidable |
|
| detached, abbreviated street type, suffix |
|
| kk instead of ck |
|
| hyphens missing |
|
| umlaut as ue, missing h |
|
| Am Markt / Neuer Weg sound almost the same |
|
| filler words |
|
| does not exist |
|
| does not exist, sounds like nothing |
|
The same cases are available in the demo UI under Hard cases — there against the real directory of a postal code.
API
REST and MCP are equivalent. Full description: /openapi.json, MCP tool describe_api,
MCP resource geocoder://docs/api.
MCP tool | REST | When |
|
| Always first |
|
| For every street name |
|
| After a choice question — only then the address counts as captured |
|
| Caller rejects everything, third failed attempt. Writes a structured log line |
|
| Once during setup: Deepgram keyterm list from the service area |
|
| This documentation, with the active thresholds |
— |
| Data sources, thresholds |
Response format
{
"status": "ambiguous",
"needsHuman": false,
"candidates": [
{ "street": "Heinrichstraße", "postalCode": "10115", "locality": "Berlin", "confidence": 0.95, "source": "openplz",
"breakdown": { "lexical": 0.96, "phonetic": 1, "token": 0.96, "streetType": 1, "total": 0.95 } },
{ "street": "Henrichweg", "postalCode": "10115", "locality": "Berlin", "confidence": 0.84, "source": "openplz",
"breakdown": { "total": 0.84, "cappedBy": "Grundwort widerspricht sich (strasse vs. weg)" } }
],
"speech": "Da habe ich mehrere Möglichkeiten. Erstens: Heinrichstraße in 1 0 1 1 5 Berlin. Zweitens: Henrichweg in 1 0 1 1 5 Berlin. Welche davon ist richtig?",
"heard": { "postalCode": "10115", "street": "Heinrich 24", "houseNumber": "24" },
"reason": "\"Heinrichstraße\" bei 0.95 - unter der Auto-Schwelle 0.85."
}speech is read out verbatim — it is German, because the agent talks to German
callers. Postal codes and house numbers are already speakable, regardless of whether the
agent's TTS normalization works correctly. reason and cappedBy are German log text.
Connecting to Vapi
Three steps, in this order:
Apply
vapi/assistant-patch.jsonblock by block — transcriber tonova-3/de, barge-in threshold, GermanformatPlan. Does not need the server.Create
vapi/mcp-tool.jsonas an MCP tool, URL pointing at your instance.Add
vapi/system-prompt-address.mdto the system prompt. Without this block the agent will not call the tools in the right order.
Generate the keyterm list via generate_keyterms and paste it into step 1.
Configuration
All values in .env.example. The important ones:
Variable | Default | Effect |
| — | service area for Photon bias and keyterms |
| 0.85 |
|
| 0.70 | choices from here, hand-over below |
| 0.08 | minimum distance to the runner-up |
| 0.08 | suggestion band |
| true |
|
| — | bearer token for HTTP; |
| true |
|
| from request | base URL for canonical, JSON-LD and |
| — | Photon bias point; also emitted as |
The thresholds are starting values, not validated numbers. Procedure: run 30 real
calls, evaluate reason and confidence from the logs, adjust. The log lines from
flag_for_human (event: "address_escalation") are the data basis. Suggested acceptance
metric: 95 % correctly captured addresses with 100 % read-back.
Docker
docker compose pull && docker compose up # prebuilt image from GHCR
docker compose up --build # build locally instead
docker compose --profile photon up # plus a self-hosted Photon instanceCompose does not bind host ports — the services only expose 8080 (geocoder) and 2322
(Photon) inside the Compose network, meant for a reverse proxy or another stack on the same
network. For a host port, run the image directly (docker run -p 8080:8080 …) or add a
ports: entry in a docker-compose.override.yml.
Image
ghcr.io/ai-workflow-automations/mcp-geocoder, built for linux/amd64 and linux/arm64.
Tag | Meaning |
| current |
| release tags |
| exact commit |
The entrypoint takes one argument: serve (default, HTTP on port 8080) or stdio (MCP over
stdin/stdout for Claude Code and Claude Desktop). Configuration is entirely via environment
variables, see Configuration.
The image builds in two stages: node:22-alpine resolves dependencies with pnpm
(node-linker=hoisted, so node_modules is flat), oven/bun:1-alpine runs it. No build
step — Bun runs TypeScript directly. Runs as user bun, health check on /health.
The public Photon instance does not carry telephony load. For production enable the
photon profile — the first start downloads the Germany extract (several GB) — and set
PHOTON_BASE_URL=http://photon:2322.
Quality gates
pnpm check # typecheck + lint + test, in that orderStep | Tool | What it catches |
| tsc | type errors |
| unused code, | |
| bun test | behaviour, including the hard cases |
pnpm lint:fix applies Biome's safe fixes.
CI
.github/workflows/ci.yml runs the three gates above (pnpm + Bun),
then a Docker build with a smoke test against /health. Runs on push to main, on release tags
v*, on every pull request, and manually. On push (not on pull requests) the publish job then
pushes the multi-arch image to GHCR — see Image for the tag scheme.
Diagrams
SVGs in docs/diagrams/, sources next to them as .puml. After changes:
./docs/diagrams/render.sh # requires: brew install plantuml graphvizWithout Graphviz the script falls back to smetana. The committed SVGs were rendered with
PlantUML 1.2024.8.
Limitations
No threshold reliably separates a non-existent street from a badly transcribed one. Hence the exit via
flag_for_human, not ever-stricter thresholds.Keyterm prompting for German is unresolved. The Vapi docs say for Deepgram "keywords work with English models"; Deepgram advertises keyterm prompting for all supported languages including German. Test before rollout.
Narrowband stays narrowband. The frequencies that distinguish similar-sounding names are gone on the phone. The consequence is dialogue design, not model choice: force short answers, postal code instead of place name, spell surnames, always read back.
Streets without a street type ("Am Markt", "Bernauer") are harder to cap — there only the margin to the runner-up applies.
Data sources: OpenPLZ API · Photon (komoot) · Deepgram Keyterm Prompting · Vapi MCP
This server cannot be deployed
Maintenance
Related MCP Connectors
Address validation & geocoding for AI agents: 240+ countries, UK PAF, free US/CA enrichment
Normalize and verify Japanese postal addresses into structured fields for agents.
Returns the postal code (CEP) and standardized address from the Brazilian Post from a given address.
Verify addresses, email addresses, and phone numbers with confidence scores.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceProvides Japanese postal code to address lookup functionality that can be integrated with AI assistants and other MCP clients.3MIT
- FlicenseNot gradedqualityDmaintenanceEnables Hong Kong address lookup and correction using government data, with fuzzy matching and LLM-powered correction for both Chinese and English addresses.3-
- FlicenseAqualityDmaintenanceEnables AI assistants to validate and standardize US addresses using the official USPS Address Validation API with secure OAuth2 authentication.1-
- FlicenseAqualityAmaintenanceAn MCP server that exposes ISO 20022 postal-address classification, assessment, and remediation tools for AI agents, enabling them to prepare debtor/creditor addresses for the November 2026 structured-address cutover.131-