使用说明
🤖 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
asyncruntime.
🛡️ 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_healthandosm_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
.envfile from disk anymore — the previousenv_file=".env"behaviour has been removed.Practical consequences:
- No more
cwdrequirement: 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.*.envin the agent's config) keeps them out of version control.- The shipped
.env.exampleis 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_upstreamsset is guarded by anasyncio.Lock, so concurrent tools never double-validate. - Headers always present —
create_headers()short-circuits with aValueError("Headers not set")ifuser_agent/from_headerare 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,@_trackdecorator coverage, sharedhttpx.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_routenow computes the haversine distance between the two endpoints to choose the ORS profile. Previously it always readto_thing.get("distance", 9000), but Nominatim search results never carry adistancefield, so the router always useddriving-careven for foot-walkable hops.osm_get_security_metricsno longer double-counts its own invocation. The@_trackdecorator already bumps the counter, so the inlinerecord_tool_call(...)in the body is gone.create_result_documentonly emits an OSM link when the coordinates are real numbers; broken/unknown/unknownURLs are dropped, andemit_result_citationskips 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-slimbuilder →python:3.12-slim-bookwormruntime, ~249 MB uncompressed). - Hardened: non-root user (uid 10001), launcher-owned init (the image
embeds none),
cap_drop ALL,no-new-privileges,read_only: trueroot FS,tmpfs /tmpwithnoexec,nosuid,nodev,HEALTHCHECKcovering both stdio and HTTP modes. docker-entrypoint.shvalidatesUSER_AGENT+FROM_HEADERand refuses to run as root, exiting withEX_CONFIG(78) on misconfiguration so container logs show a clear, actionable error.docker-compose.ymldefaults to stdio transport (the common case) and exposes the streamable-http service behind anhttpprofile, with 1 CPU / 1 GB resource caps matching the Docker MCP Toolkit defaults.scripts/docker-build.shandscripts/docker-smoke-test.sh— reproducible build (with multi-arch viaPLATFORMS=.../--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 viadocker mcp catalog import. - GitHub Actions workflow (
.github/workflows/docker.yml) runs the multi-arch build + smoke test on every PR and push to main.
- Multi-stage Dockerfile (
- Test coverage:
- 5 new regression tests for the bug fixes (suite was 45 → 50).
- 24 new tests in
tests/test_d6_docker.pylock in the Dockerfile, compose, entrypoint, and catalog artifacts. Suite is now 74 tests, <1s, bandit-clean.
0.4.1
- Security: removed
env_file=".env"fromSettings. The server no longer reads secrets from disk; all configuration comes from the process environment. Eliminates the on-disk secret leak surface and removes thecwd-dependent behaviour that forced callers to pointcwdat the package directory. - Reliability: added
ORS_REQUEST_TIMEOUT(default90) so the OpenRouteService client is constructed with an explicittimeout=, raising it above theopenrouteservicelibrary default of 60s. Public-instance traffic at peak hours no longer trips falseopenrouteservice.exceptions.Timeouton valid routes. - DX:
percival-osm/.envremoved (gitignored template is now documentation only); README updated to drop thecwdrequirement 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) andosm_geocode(text → coords, no full address detail).osm_get_health,osm_get_versiontools; 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). @_trackdecorator applied to all 14 canonical tools for accurate per-tool call counters inosm_get_health.- Bug fixes:
main()was historically invoked 11× in__main__; dead code removal (pygments,pretty_print_thing_json,itertoolsimport); duplicatedatetimeimport. - 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.
- Main Repository: https://github.com/bill-kopp-ai-dev/percival.OS
- License: MIT
Developed with ❤️ by the percival.OS Team
