OpenStreetMap MCP 服务

Percival OSM 是一款将 AI 助手接入 OpenStreetMap 的 MCP 服务器,提供实时地理空间查询、兴趣点发现和逐向导航能力,适用于需要地图检索与路线规划的 AI 代理场景。

  • by bill-kopp-ai-dev
  • Python
  • 本地部署
  • 更新于
在 GitHub 查看 bill-kopp-ai-dev/percival-osm

使用说明

🤖 Percival OSM — percival.OS MCP

Version 0.5.0


📋 Description

Percival OSM is an MCP server that connects AI assistants (femtobot, nanobot, claude-code, etc.) to OpenStreetMap, enabling real-time geospatial queries, point-of-interest discovery, and turn-by-turn navigation through a hardened, privacy-respecting interface.

This server is part of the percival.OS ecosystem — a Personal Agentic Operating System designed for autonomy, security, and absolute privacy.

It was originally forked from lowlyocean/mcp-osm and re-engineered for production use inside percival.OS_Dev:

  • 30+ curated geospatial tools (osm_find_*, osm_navigate, osm_directions, …).
  • 3 prompt primitives to teach the agent which tool to call.
  • 2 resource primitives for live policy / category reference.
  • Structured logging to stderr (preserves the MCP stdio stream).
  • Per-tool call telemetry, token-bucket rate limiting for Nominatim (1 req/s).
  • Input sanitization against prompt-injection, HTTPS-only upstreams, URL allow-listing, and an isolated async runtime.

🛡️ percival.OS Principles

Like all components of percival.OS, this MCP server strictly follows our core principles:

  • Privacy & Governance — queries go through open, respectful APIs (Nominatim, Overpass, OpenRouteService) with no commercial tracking.
  • Data Sovereignty — coordinates and location queries stay under the operator's control; the cache file is configurable and bounded to OSM_CACHE_ALLOWED_DIRS.
  • Hardened Security — compact responses reduce token usage, structured output is sanitized against prompt-injection, and all upstream calls are rate-limited + URL-allow-listed.
  • Transparency — operations are observable via osm_get_health and osm_get_security_metrics; every shell output (logging) goes to stderr so the MCP stdio stream is never corrupted.

🚀 Features & Tools

Canonical tools (always available)

Tool Purpose Recommended when…
osm_find_nearby Search POIs by category near a place you know what and where
osm_find_nearby_detailed Same, with full address + tags per result you need deeper context per POI
osm_find_place Find a named location by name + city you have a specific name (e.g. "Eiffel Tower")
osm_find_place_detailed Same, with full address breakdown you need the same with extra detail
osm_find_nearby_store Find a store near coordinates you have GPS coords and a category
osm_find_address Coordinates → address (via Nominatim /reverse) the user has GPS and wants a place name
osm_geocode Address / place name → coordinates only you only need lat/lon
osm_navigate Route summary between A and B distance + ETA only
osm_directions Step-by-step instructions A → B the agent walks the user through each maneuver
osm_get_health Uptime, per-tool call/error counters, last failure on-call triage
osm_get_version Server version, process start time deployment auditing
osm_get_security_metrics Security counter snapshot audit / incident response
osm_get_status Short human-readable status string quick smoke check

Legacy category aliases (default ON, controlled by OSM_EXPOSE_LEGACY_ALIASES)

23 per-category shortcuts — each one is a thin alias for osm_find_nearby with the category parameter pre-filled:

osm_find_groceries, osm_find_bakeries, osm_find_food, osm_find_swimming, osm_find_playgrounds, osm_find_recreation, osm_find_attractions, osm_find_worship, osm_find_accommodation, osm_find_alcohol, osm_find_drugs, osm_find_schools, osm_find_universities, osm_find_libraries, osm_find_transport, osm_find_bikes, osm_find_cars, osm_find_hardware, osm_find_electrical, osm_find_electronics, osm_find_doctors, osm_find_hospitals, osm_find_pharmacy.

Set OSM_EXPOSE_LEGACY_ALIASES=false to expose only the canonical tools (useful for new deployments where the per-category aliases would just bloat the tool list shown to the agent).


🧭 Prompts & Resources

In addition to tools, the server exposes 3 prompt primitives and 2 resource primitives so AI agents can reason about which tool to call and how to render responses, without guessing.

Femtobot surfaces these as mcp_percival_osm_prompt_<name> and mcp_percival_osm_resource_<name> (see femtobot docs/mcp.md).

Prompts — get_prompt(...)

Prompt Purpose
osm_recipe_quick_search Step-by-step recipe for a quick POI search near a place. Includes the canonical osm_find_nearby decision tree and failure modes.
osm_tool_chooser Decision table mapping user intents to the right tool (find_nearby vs find_place vs geocode vs navigate vs directions).
osm_output_format Canonical markdown templates for rendering nearby-search, route, and geocode results.

Resources — read_resource(...)

URI MIME Content
osm://categories text/markdown Canonical list of category keys accepted by osm_find_nearby, with the matching legacy alias for each.
osm://security/nanobot-policy text/markdown The security policy this server enforces (privacy, sanitization, URL allow-listing). Lets the agent self-audit against the policy without re-reading this README.

Prompts are intentionally short. Each one is meant to be referenced once per task. Resources are read on demand and do not consume system-prompt tokens unless the agent explicitly asks for them.


⚙️ Configuration

Configuration model (since 0.4.1)

The server reads all of its configuration from the process environment. It does not load a .env file from disk anymore — the previous env_file=".env" behaviour has been removed.

Practical consequences:

  • No more cwd requirement: the server can be launched from any directory; it no longer matters where you start it from.
  • Secrets never sit on disk under the package: the launcher is the only component that needs to inject them, and the recommended path (mcpServers.*.env in the agent's config) keeps them out of version control.
  • The shipped .env.example is documentation only — it lists every environment variable the server understands, but nothing reads it.

Recommended femtobot / nanobot registration

{
  "tools": {
    "mcpServers": {
      "percival-osm": {
        "command": "percival-osm-mcp",
        "args": ["--mode", "stdio"],
        "env": {
          "USER_AGENT": "percival-osm/0.4.1 (you@example.com)",
          "FROM_HEADER": "you@example.com",
          "ORS_API_KEY": "${OPENROUTESERVICE_API_KEY}",
          "OSM_RESPONSE_MODE": "compact"
        },
        "enabledTools": [
          "osm_find_nearby",
          "osm_find_place",
          "osm_find_address",
          "osm_navigate",
          "osm_directions",
          "osm_geocode",
          "osm_get_health",
          "osm_get_version"
        ]
      }
    }
  }
}

In the snippet above, ${OPENROUTESERVICE_API_KEY} is expanded by the launcher from the agent's own .env (or your shell). For the percival.OS test environment, the wrapper script auto-sources .nanobot-test/.env and verifies every ${VAR} placeholder is set before launching the agent — so a missing key aborts with a clear error instead of a silent failure.

Configuration flags

See .env.example for the canonical list (every variable the server recognises, with its default). Highlights:

# Behavior
OSM_EXPOSE_LEGACY_ALIASES=true          # set false for a slim tool list
OSM_NOMINATIM_RATE_LIMIT_RPS=1.0        # respect Nominatim usage policy
OSM_NOMINATIM_RATE_LIMIT_BURST=2        # small burst above the average
OSM_RESPONSE_MODE=compact               # compact | detailed

# Nominatim usage policy — both must be non-empty
USER_AGENT=percival-osm/0.4.1 (you@example.com)
FROM_HEADER=you@example.com

# OpenRouteService routing (required for route/directions tools; without it,
# those tools return provider_not_configured. Haversine is only used to choose
# the routing profile when ORS is configured.)
ORS_API_KEY=...
ORS_REQUEST_TIMEOUT=90                  # default 90s (lib default is 60s)

# Logging (stderr only — stdout is reserved for MCP JSON-RPC)
OSM_LOG_FORMAT=plain                    # plain | json
OSM_LOG_LEVEL=INFO                      # DEBUG | INFO | WARNING | ERROR

# Security
OSM_REQUIRE_HTTPS_UPSTREAMS=true
OSM_UPSTREAM_ALLOWED_HOSTS=nominatim.openstreetmap.org,overpass-api.de,api.openrouteservice.org
OSM_HTTP_FOLLOW_REDIRECTS=false         # avoids SSRF via redirect
MCP_OSM_AUTH_TOKEN=…                    # required by the authenticated Compose HTTP profile

# Cache
OSM_CACHE_FILE=/tmp/osm-cache.json
OSM_CACHE_TTL_SECONDS=3600
OSM_CACHE_ALLOWED_DIRS=/tmp

📈 Observability

Structured logging

All log records go to stderr (stdout is reserved for the MCP JSON-RPC stream). Switch to one-line JSON via:

OSM_LOG_FORMAT=json OSM_LOG_LEVEL=INFO

Sample JSON record:

{"ts": "2026-07-17T15:00:00-0300", "level": "INFO", "logger": "percival-osm", "msg": "Navigating from [A] to [B]."}

The format is stable: ts (ISO 8601 local), level, logger, msg, plus any structured extra={…} fields attached via logger.info("…", extra=…).

Health & version

  • osm_get_version — package version, server name, process start (ISO 8601).
  • osm_get_health — uptime in seconds, per-tool call/error counters, the most recent upstream failure (kind, message, ts), and the active cache path.
  • osm_get_security_metrics — security counter snapshot (URL policy blocks, auth failures, upstream failures, etc).

All three are read-only and safe to call repeatedly from an orchestrator.

Rate limiting (Nominatim)

Nominatim's usage policy allows at most 1 request per second per agent. The server enforces this with an asyncio.Lock + token-bucket:

OSM_NOMINATIM_RATE_LIMIT_RPS=1.0   # tokens per second
OSM_NOMINATIM_RATE_LIMIT_BURST=2   # initial bucket capacity

Setting OSM_NOMINATIM_RATE_LIMIT_RPS=0 disables the limiter entirely (use only for self-hosted Nominatim). Concurrent calls for the same target collapse through a single-flight future so parallel tools never stampede the upstream API.

Performance / hardening knobs

  • Shared httpx.AsyncClient — reuses the HTTP/2 connection pool across requests instead of opening a new client on every call.
  • Race-free URL validation — the _validated_upstreams set is guarded by an asyncio.Lock, so concurrent tools never double-validate.
  • Headers always present — create_headers() short-circuits with a ValueError("Headers not set") if user_agent / from_header are empty, so missing config is loud, not silent.

🐳 Docker deployment

The project ships first-class Docker support: a hardened multi-stage image, a stdio-friendly docker-compose.yml, and a pre-formatted submission to the Docker MCP Catalog. The image is designed to drop into any MCP-compatible client — nanobot, opencode, Claude Desktop, the Docker MCP Toolkit — without extra glue.

Quick start — stdio (the common case)

# Build the :dev alias with version/revision derived from this checkout
python /home/bill/Projects/nanobot/scripts/percival-docker-build.py osm --dev-alias

# Run interactively as an MCP stdio server. Wire this command into the
# client of your choice under ``mcpServers.percival-osm`` — see the
# per-client snippets below.
docker run --rm -i --init \
  -e USER_AGENT='percival-osm/0.4.1 (you@example.com)' \
  -e FROM_HEADER='you@example.com' \
  percival-osm:dev

Quick start — docker compose

# Copy and customise the env file the compose stack expects
cp .env.example .env
$EDITOR .env   # set USER_AGENT, FROM_HEADER, and optional ORS_API_KEY

# Run stdio as an on-demand MCP process: no TTY, port, healthcheck or restart.
docker compose run --rm -T mcp-osm

# Or start authenticated Streamable HTTP on host loopback (set
# MCP_OSM_AUTH_TOKEN in .env first; use HTTP_BIND_ADDRESS only for an
# intentional external bind).
docker compose --profile http up -d mcp-osm-http

Quick start — Docker MCP Toolkit

Once the project is published to the Docker MCP Catalog (see servers/percival-osm/), the server can be added to any profile from the catalog UI:

# Local catalog import for development
docker mcp catalog import $PWD/servers/percival-osm/catalog.yaml
docker mcp server enable percival-osm

The catalog entry declares the two mandatory env vars (USER_AGENT, FROM_HEADER) and the optional ORS_API_KEY as configurable UI fields, so operators don't need to edit a .env.

Wiring into specific MCP clients

nanobot (~/.nanobot/config.json):

{
  "tools": {
    "mcpServers": {
      "percival-osm": {
        "command": "docker",
        "args": [
          "run", "--rm", "-i",
          "-e", "USER_AGENT=percival-osm/0.4.1 (you@example.com)",
          "-e", "FROM_HEADER=you@example.com",
          "-e", "ORS_API_KEY=${OPENROUTESERVICE_API_KEY}",
          "percival-osm:dev"
        ]
      }
    }
  }
}

opencode (opencode.json or ~/.config/opencode/opencode.json):

{
  "mcp": {
    "percival-osm": {
      "type": "stdio",
      "command": [
        "docker", "run", "--rm", "-i",
        "-e", "USER_AGENT=percival-osm/0.4.1 (you@example.com)",
        "-e", "FROM_HEADER=you@example.com",
        "percival-osm:dev"
      ]
    }
  }
}

Claude Desktop / generic stdio MCP: configure the same docker run --rm -i … percival-osm:dev invocation under mcpServers.percival-osm.

Image security baseline

The shipped image is hardened by construction. The CI workflow at .github/workflows/docker.yml and the static checks in tests/test_d6_docker.py enforce:

Control Value
User percival (uid 10001) — non-root by default
cap_drop ALL (compose + Toolkit both)
security_opt no-new-privileges:true
PID 1 launcher-owned init (docker run --init / compose init: true) — the image embeds none
Read-only root FS enforced in compose
/tmp tmpfs: 64m, mode=1777, noexec, nosuid, nodev
Resources (compose / Toolkit) 1 CPU, 1 GB memory cap
Cache file /cache/osm-cache.json, mode 0600, named volume
Healthcheck Disabled for stdio; HTTP Compose profile probes the local listener without testing upstream APIs

Build, smoke-test, inspect

# Build (defaults to host arch; pass --platform=linux/arm64 for multi-arch)
scripts/docker-build.sh
scripts/docker-build.sh v0.5.0                       # extra tag
PLATFORMS=linux/amd64,linux/arm64 scripts/docker-build.sh  # multi-arch

# Smoke test (stdio MCP, no healthcheck/TTY, HTTP auth/bind/health, shutdown)
scripts/docker-smoke-test.sh

# Print OCI labels, healthcheck, env, resolved site-packages
scripts/docker-inspect.sh

Submitting to the Docker MCP Catalog

The catalog submission artifacts live in servers/percival-osm/:

  • server.yaml — registry-format definition (name, image, meta, env vars)
  • tools.json — exposed tool catalogue (no runtime introspection needed)
  • readme.md — documentation pointer

To submit, follow the upstream contributing guidelines and open a PR against docker/mcp-registry. After approval Docker will rebuild the image under the mcp/percival-osm namespace with signatures, provenance, and SBOMs attached.


🛠️ Development & Testing

Run locally

# Editable install + dev deps
uv sync

# Run the server (stdio transport for direct MCP use)
PYTHONPATH=src .venv/bin/python -m percival_osm_mcp --mode stdio

# Or streamable HTTP for direct API debugging
PYTHONPATH=src .venv/bin/python -m percival_osm_mcp --mode streamable-http --port 8000

Tests

# Run the full suite (74 tests, <1s)
uv run pytest -v

# Targeted suites
uv run pytest tests/test_s5_primitives.py       # prompts + resources + bug regressions
uv run pytest tests/test_d6_docker.py          # Dockerfile / compose / catalog artifacts
uv run pytest tests/test_s3_observability.py    # health, version, structured logging, rate limit
uv run pytest tests/test_runtime_security.py    # URL policy + sanitization

The suite covers:

  • URL allow-listing, redirect validation, private-host blocking.
  • Output sanitization (HTML, markdown, control characters).
  • Prompt-injection regression corpus.
  • Observability primitives (health, version, operational telemetry).
  • Token-bucket rate-limit semantics (burst, throttle-after-burst, disabled).
  • MCP prompt/resource registration and content integrity.
  • Bug-fix regressions for: main() block, @_track decorator coverage, shared httpx.AsyncClient, single-flight ORS, static dead-code removal.

🔒 Security model

This server enforces the following defenses (see osm://security/nanobot-policy resource for the canonical machine-readable form):

Layer Protection
Transport HTTPS-only to upstreams; follow_redirects=false by default
URL policy Allow-list of upstream hosts; inline_credentials rejected
Headers User-Agent + From headers mandatory; requests fail loud if missing
Rate limit 1 req/s + burst 2 against Nominatim via token bucket
Single-flight Concurrent duplicate calls share one upstream request
Sanitization All upstream-rendered text goes through sanitize_external_text / sanitize_external_markdown
Cache File must live under OSM_CACHE_ALLOWED_DIRS; TTL configurable
Auth Compose HTTP profile requires MCP_OSM_AUTH_TOKEN; host bind is loopback by default
Logging stderr only; never touches the MCP stdio stream

Every blocking or failure path increments a counter exposed via osm_get_security_metrics.


📝 Changelog

0.5.0 — current

  • Bug fixes (code review round 1):
    • main() is now called exactly once in the __main__ block (the CHANGELOG for 0.4.0 had claimed the 11× → 1× fix but three copies were still below the guard).
    • OrsRouter.calculate_route now computes the haversine distance between the two endpoints to choose the ORS profile. Previously it always read to_thing.get("distance", 9000), but Nominatim search results never carry a distance field, so the router always used driving-car even for foot-walkable hops.
    • osm_get_security_metrics no longer double-counts its own invocation. The @_track decorator already bumps the counter, so the inline record_tool_call(...) in the body is gone.
    • create_result_document only emits an OSM link when the coordinates are real numbers; broken /unknown/unknown URLs are dropped, and emit_result_citation skips the citation entirely when no link is available.
    • Removed dead code at the bottom of _http_get_json's retry loop (the loop always returns or re-raises), with a clarifying comment.
  • First-class Docker support:
    • Multi-stage Dockerfile (ghcr.io/astral-sh/uv:python3.12-bookworm-slim builder → python:3.12-slim-bookworm runtime, ~249 MB uncompressed).
    • Hardened: non-root user (uid 10001), launcher-owned init (the image embeds none), cap_drop ALL, no-new-privileges, read_only: true root FS, tmpfs /tmp with noexec,nosuid,nodev, HEALTHCHECK covering both stdio and HTTP modes.
    • docker-entrypoint.sh validates USER_AGENT + FROM_HEADER and refuses to run as root, exiting with EX_CONFIG (78) on misconfiguration so container logs show a clear, actionable error.
    • docker-compose.yml defaults to stdio transport (the common case) and exposes the streamable-http service behind an http profile, with 1 CPU / 1 GB resource caps matching the Docker MCP Toolkit defaults.
    • scripts/docker-build.sh and scripts/docker-smoke-test.sh — reproducible build (with multi-arch via PLATFORMS=... / --platform=) and a 4-step smoke test (non-root user, env-var guard, stdio MCP round-trip, full tools/list coverage).
    • scripts/docker-inspect.sh — prints OCI labels, healthcheck JSON, user/entrypoint/cmd, env, and resolved site-packages.
    • Docker MCP Catalog submission artifacts under servers/percival-osm/ (server.yaml, tools.json, readme.md) — drop-in importable via docker mcp catalog import.
    • GitHub Actions workflow (.github/workflows/docker.yml) runs the multi-arch build + smoke test on every PR and push to main.
  • Test coverage:
    • 5 new regression tests for the bug fixes (suite was 45 → 50).
    • 24 new tests in tests/test_d6_docker.py lock in the Dockerfile, compose, entrypoint, and catalog artifacts. Suite is now 74 tests, <1s, bandit-clean.

0.4.1

  • Security: removed env_file=".env" from Settings. The server no longer reads secrets from disk; all configuration comes from the process environment. Eliminates the on-disk secret leak surface and removes the cwd-dependent behaviour that forced callers to point cwd at the package directory.
  • Reliability: added ORS_REQUEST_TIMEOUT (default 90) so the OpenRouteService client is constructed with an explicit timeout=, raising it above the openrouteservice library default of 60s. Public-instance traffic at peak hours no longer trips false openrouteservice.exceptions.Timeout on valid routes.
  • DX: percival-osm/.env removed (gitignored template is now documentation only); README updated to drop the cwd requirement and call out the new env-only model.

0.4.0

  • 3 prompt primitives + 2 resource primitives (osm_primitives.py).
  • osm_directions (structured turn-by-turn, ordered steps with distance/duration) and osm_geocode (text → coords, no full address detail).
  • osm_get_health, osm_get_version tools; structured operational telemetry.
  • Async token-bucket rate limiter for Nominatim (1 req/s + burst 2).
  • Structured logging (JSON) on stderr via OSM_LOG_FORMAT=json.
  • Shared httpx.AsyncClient (lazy + double-checked lock).
  • Single-flight pattern for concurrent ORS distance calls.
  • Race-free URL policy validation (lock around _validated_upstreams).
  • @_track decorator applied to all 14 canonical tools for accurate per-tool call counters in osm_get_health.
  • Bug fixes: main() was historically invoked 11× in __main__; dead code removal (pygments, pretty_print_thing_json, itertools import); duplicate datetime import.
  • Settings additions: OSM_EXPOSE_LEGACY_ALIASES, OSM_LOG_FORMAT, OSM_LOG_LEVEL, OSM_NOMINATIM_RATE_LIMIT_RPS, OSM_NOMINATIM_RATE_LIMIT_BURST.

0.3.0

  • Initial structured observability + capacity flags.

0.0.x

  • Pre-hardening forks of lowlyocean/mcp-osm.

📚 About the Project

This server is an integral module of the percival.OS project. It lets femtobot (and any MCP-compatible client) interact with the physical world through OpenStreetMap's open data.


Developed with ❤️ by the percival.OS Team