Skip to main content
Glama
chris-page-gov

mcp-geo

README.md
# MCP Geo Server

A research Model Context Protocol (MCP) server for geospatial (Ordnance Survey)
and statistical (Office of National Statistics) data.
If you have Docker installed and Internet access, have this running in 3 minutes.

## Public Launch Caveat

This repository is a personal development project. It is not production code and
is not approved by Warwickshire County Council or the Department for Science,
Innovation and Technology.

All opinions expressed, decisions made, and implementation choices documented in
this repository come from a novice learning the opportunities afforded by
emerging AI and MCP technology.

This repository is a diary of that learning journey, not a recommended course of
action and not a formal proposal.

## Start here — run the server and ask it a question

This section is for first-time users.
You do **not** need to understand MCP or the internal architecture — just follow the steps and your AI assistant will gain UK geographic and statistics awareness.

### 1) Get access key (2 minutes)

Create a free Ordnance Survey Data Hub key:

- Ordnance Survey Data Hub: [https://osdatahub.os.uk/](https://osdatahub.os.uk/)
- Step-by-step public-account setup:
  [docs/os_data_hub_public_account_setup.md](docs/os_data_hub_public_account_setup.md)
- Full LandIS spatial warehouse setup:
  [docs/landis_spatial_warehouse_setup.md](docs/landis_spatial_warehouse_setup.md)

Optional:

- `NOMIS_UID` and `NOMIS_SIGNATURE` if you want higher-rate NOMIS access

You only need the OS API key for the default live setup. The ONS live endpoints
used by MCP-Geo do not require a separate ONS API key.

### 2) Run the server in a folder

```bash
git clone https://github.com/chris-page-gov/mcp-geo.git
cd mcp-geo
cp .env.example .env
```

Path portability note:

- Any absolute path shown later in this README is an example, not a required
  location.
- Replace maintainer-specific examples such as `/Users/...` or `/Volumes/...`
  with paths that exist on your machine.
- The main path-bearing settings are `ADDRESSBASE_PREMIUM_XREF_PATH`,
  `LANDIS_LOCAL_DATA_ROOT`, `LANDIS_PORTAL_ARCHIVE_DIR`,
  `LANDIS_FULL_RELEASE_ARCHIVE_DIR`, `BOUNDARY_RUNS_DIR`,
  `BOUNDARY_RUNS_SEARCH_DIRS`, and, for GUI-launched wrappers on macOS,
  `MCP_GEO_DOCKER_BIN`.
- Docker-backed local wrappers (`scripts/claude-mcp-local`,
  `scripts/codex-mcp-local`, `scripts/mcp-docker-local`) now hydrate those
  path-bearing settings from the repo `.env` and mount the configured host
  paths into the container automatically.
- Generated reports, research artifacts, and knowledge-base outputs in this
  repo may embed the maintainer's local paths; they are evidence artifacts, not
  portable setup inputs.

Open `.env` and paste the required key:

```text
OS_API_KEY=your-key-here
OS_API_AUTH_MODE=query
```

Do not add quote marks around `.env` values. Set either `OS_API_KEY` or
`OS_API_KEY_FILE`, not both. For a secret-file setup and first-run OS Data Hub
account details, use
[docs/os_data_hub_public_account_setup.md](docs/os_data_hub_public_account_setup.md).

Optional:

```text
NOMIS_UID=your-nomis-uid
NOMIS_SIGNATURE=your-nomis-signature
```

Now build the MCP server image:

```bash
docker build -t mcp-geo-server .
```

The STDIO server is normally started by your MCP client. A manual
`docker run -i --env-file .env mcp-geo-server` waits for JSON-RPC on stdin and
may print nothing until a request arrives.

Or skip the build and replace `mcp-geo-server` in the smoke test below with
the pre-built image:

```bash
ghcr.io/chris-page-gov/mcp-geo:latest
```

### 3) Verify it works

Send a JSON-RPC request that includes an `id`:

```bash
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
  | docker run --rm -i --env-file .env mcp-geo-server
```

If JSON tool definitions appear, the server is working. Requests without an
`id` are JSON-RPC notifications, so the STDIO adapter correctly sends no
response.

---

### 4) Connect your AI assistant

Example: Claude Desktop configuration

```json
{
  "mcpServers": {
    "geo": {
      "command": "docker",
      "args": ["run", "-i", "--env-file", "/absolute/path/to/mcp-geo/.env", "mcp-geo-server"]
    }
  }
}
```

> Replace `/absolute/path/to/mcp-geo/.env` with the actual path to your `.env`
> file. See [`.env.example`](.env.example) for available settings; at minimum
> you need `OS_API_KEY`.

Or use the pre-built image in the same Claude Desktop config:

```json
{
  "mcpServers": {
    "geo": {
      "command": "docker",
      "args": ["run", "-i", "--env-file", "/absolute/path/to/mcp-geo/.env", "ghcr.io/chris-page-gov/mcp-geo:latest"]
    }
  }
}
```

Restart Claude Desktop.

---

### 5) Ask a real question

Try:

> “Which administrative areas contain postcode SW1A 1AA?”
> “Describe the geography hierarchy around this coordinate 52.4862, -1.8904”
> “Is this location inside an AONB?”

If the assistant answers using real UK geography — everything is working.

---

## What you just did

You connected an AI assistant to live UK spatial data via the Model Context Protocol.

The server runs locally on your machine and calls:

- Ordnance Survey APIs
- ONS statistical geography services
- NOMIS datasets

No data is stored or redistributed.

---

## MCP Specification

See [Latest stable specification target](https://modelcontextprotocol.io/specification/2025-11-25).
The MCP 2026-07-28 release candidate is tracked, but not the default runtime
protocol. Tracking and review cadence live in `docs/spec_tracking.md`; the
current RC alignment ledger is
`Plans/PLAN-MCP-2026-07-28-RC-alignment.md`.
OpenAI's Documentation MCP guide is at
<https://developers.openai.com/resources/docs-mcp>, with the shared server
available at <https://developers.openai.com/mcp> (preview; tracked in
`docs/spec_tracking.md`).

Protocol negotiation behavior:

- Preferred MCP core protocol revision: `2025-11-25`
- Supported MCP core protocol versions: `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`
- Opt-in release-candidate protocol: `2026-07-28` when
  `MCP_2026_RC_ENABLED=1` or `MCP_PROTOCOL_2026_07_28_ENABLED=1`
- Streamable HTTP enforces `MCP-Protocol-Version` when provided and returns negotiated
  `mcp-protocol-version` on responses
- In RC mode, `/mcp` supports `server/discover`, stateless requests without
  `Mcp-Session-Id`, per-request `_meta`, strict `Mcp-Method` / `Mcp-Name`
  validation, cache metadata on list/read results, MRTR-style input-required
  responses for supported elicitation flows, and JSON Schema 2020-12 guardrails
- MCP-Apps extension tracked at `2026-01-26` (`io.modelcontextprotocol/ui`)

## Key Features

- MCP endpoints: `/mcp` (streamable HTTP JSON-RPC), `/tools/list`, `/tools/call`, `/tools/describe`, `/tools/search`, `/resources/list`, `/resources/describe`, `/resources/read`
- Uniform error envelope and pagination (`nextPageToken`)
- Dynamic tool registration with schema introspection
- Tool annotations + defer-loading metadata for tool search integrations
- Agent skills resource (`skills://mcp-geo/getting-started`)
- MCP-Apps UI resources (`ui://mcp-geo/...`) with helper `os_apps.*` tools
- pgRouting-backed route planning surface (`os_route.get`, `os_route.descriptor`)
- Svelte playground UI for MCP tool calls, prompt capture, and auditing
- Routing tool `os_mcp.route_query` for intent classification and workflow guidance
- Structured logging & correlation IDs
- OS API client with retries and explicit upstream error codes
- High coverage test suite exercising success + failure paths
- Evaluation harness with question suite and scoring rubric

## Developer setup

```bash
git clone <repo-url>
cd mcp-geo
pip install -e .[test]
uvicorn server.main:app --reload
```

Then visit:

- `GET /health`
- `GET /tools/list`
- `GET /tools/describe`
- `POST /tools/call` with `{ "tool": "os_places.by_postcode", "postcode": "SW1A1AA" }`
- `POST /mcp` with `{"jsonrpc":"2.0","id":"1","method":"tools/list","params":{}}`

Set `OS_API_KEY` in the environment (or `.env`) for Ordnance Survey API-key calls.
`OS_API_AUTH_MODE=query` is the default and sends the key as the documented `key`
query parameter. `OS_API_AUTH_MODE=header` sends the same project key in the
documented `key` header. `OS_API_AUTH_MODE=bearer` sends `OS_API_ACCESS_TOKEN`
as an OAuth2 bearer token; MCP-Geo does not mint OS OAuth tokens itself, so the
caller must refresh that token out of band. Missing or invalid credentials
return `NO_API_KEY`, `OS_API_KEY_INVALID`, or `OS_API_KEY_EXPIRED`.

If MCP HTTP auth is enabled, only `GET /health` remains public. The raw HTTP
tool, resource, metrics, and playground routes all require the same bearer auth
policy as `POST /mcp`.

## Canonical Map Delivery Baseline

Use this order for reliable cross-host map delivery:

1. `os_maps.render` (static contract baseline; works without widgets)
2. `overlay_bundle` layers for map annotations and features
3. `os_apps.render_*` widgets only when the host advertises MCP-Apps UI support
4. Explicit fallback skeletons (`map_card`, `overlay_bundle`, `export_handoff`)

References:

- `docs/spec_package/06_api_contracts.md`
- `docs/spec_package/06a_map_delivery_fallback_contracts.md`
- `docs/map_delivery_support_matrix.md`
- `docs/map_embedding_best_practices.md`
- `docs/simple_map_lab.md` (minimal auth + PMTiles exploration runbook)

## Getting Started (User Guide)

See `docs/getting_started.md` for a quick way to discover available data, run
MCP Inspector or the playground UI, and explore tools/resources.

## Full Specification Package

For a complete design specification (aims, personas, architecture, scenarios,
diagrams, backlog), see `docs/spec_package/README.md`.

## UK Public Sector AI Community Documentation Set

For the full repository journey narrative (novice-readable chapters, timeline,
evaluation, troubleshooting evidence index, and extension planning), see:

- `docs/public_sector_ai_community/README.md`

For publication output (LaTeX / Prism-ready with ToC and bibliography), see:

- `docs/public_sector_ai_community/prism/main.tex`

## Codex Context (Mac App)

- Use `CONTEXT.md` as the durable project context for Codex across environments.
- If you use the Codex Mac app, open this repo as a project and read `CONTEXT.md`
  at session start.
- The repo MCP configs include `openaiDeveloperDocs`
  (`https://developers.openai.com/mcp`) so OpenAI/Codex/API/App SDK docs can
  be read through MCP instead of the legacy `docs/vendor/openai/` copies.
- Codex app documentation: <https://developers.openai.com/codex/app/>
- Codex app features: <https://developers.openai.com/codex/app/features>

## Docker (STDIO / Claude Desktop)

Build the image:

```bash
docker build -t mcp-geo-server .
```

Or pull the pre-built multi-arch image:

```bash
docker pull ghcr.io/chris-page-gov/mcp-geo:latest
```

Available tags:
- `latest` for the default branch image
- `<sha>` for a specific commit image
- `<version>` for release tags such as `0.8.0`

The published image targets `linux/amd64` and `linux/arm64`.
For the Docker MCP catalog submission draft and validation checklist, see
[`docs/docker_mcp_catalog_submission.md`](docs/docker_mcp_catalog_submission.md).

Claude Desktop config example (STDIO transport):

```json
{
  "mcpServers": {
    "mcp-geo": {
      "command": "/absolute/path/to/mcp-geo/scripts/claude-mcp-local",
      "env": {
        "OS_API_KEY": "${env:OS_API_KEY}",
        "OS_API_KEY_FILE": "${env:OS_API_KEY_FILE}",
        "MCP_STDIO_UI_SUPPORTED": "1",
        "MCP_STDIO_FRAMING": "line",
        "MCP_STDIO_ELICITATION_ENABLED": "1"
      }
    }
  }
}
```

The wrapper script builds and starts the repo's PostGIS+pgRouting sidecar
image locally (Docker), bootstraps the boundary-cache and route-graph schemas
idempotently, builds the app image if needed, and runs STDIO with the
cache/routing DSNs pointed at that sidecar.
Set either `OS_API_KEY` or `OS_API_KEY_FILE` in the host environment (if both
are set, `OS_API_KEY` wins).
Use `MCP_GEO_DOCKER_BUILD=always|missing|never` to control rebuild behavior.
By default it now stores PostGIS data in a Docker named volume
(`mcp-geo-postgis-claude`) and uses the dedicated sidecar container/network
names `mcp-geo-postgis-claude` / `mcp-geo-claude`, so raw database files are
not written into the repo and Claude no longer shares a fallback volume with
other host-side wrappers.
Set `MCP_GEO_POSTGIS_STORAGE_MODE=bind` only if you explicitly want a host path
mount (`MCP_GEO_POSTGIS_DATA_DIR`).
Set `MCP_GEO_POSTGIS_VOLUME` differently per worktree if you want isolated
local PostGIS state for each branch workspace.
Override `MCP_GEO_POSTGIS_IMAGE` only if you need a different pgRouting-capable
tag. The repo-local image currently builds on `postgis/postgis:16-3.4`, which
is upstream-amd64-only for this tag, so Apple Silicon still runs the sidecar as
`linux/amd64` under Docker emulation.
The generic `scripts/mcp-docker-local` fallback now uses its own default
sidecar identity (`mcp-geo-postgis-sidecar` on network `mcp-geo-sidecar`), and
the devcontainer defaults to a separate named volume
`mcp-geo-postgis-devcontainer`, so the normal host wrappers no longer collide
with the devcontainer cache by default. If a sidecar fails to become ready, the
wrapper now inspects the recent Postgres logs and calls out checkpoint-corrupted
volumes explicitly instead of only timing out.
Wrapper-managed PostGIS sidecars no longer publish `5432` to the host by
default; set `MCP_GEO_POSTGIS_PUBLISH_PORT` only when you explicitly need host
access to that sidecar database.
Docker-backed host wrappers now default to isolated PostGIS sidecars per
client. That is the anti-corruption default and should remain the normal
operator assumption. For comparison runs, use
`./scripts/check_shared_benchmark_cache.sh` before launching the clients:
- default `isolated` mode verifies Claude, Codex, and Gemini are each using
  their dedicated sidecar with matching mounted data roots and matching cache
  counts
- opt-in `shared` mode is available only when you explicitly set
  `MCP_GEO_POSTGIS_REUSE_DEVCONTAINER=1` and
  `MCP_GEO_BENCHMARK_CACHE_MODE=shared`, which makes every wrapper reuse the
  same devcontainer PostGIS container

If Docker isn't on the GUI PATH (common on macOS), set `MCP_GEO_DOCKER_BIN` in
Claude Desktop to the absolute Docker path (for example `/opt/homebrew/bin/docker`).

Before demos, run the readiness check:

```bash
./scripts/prepare-for-demo
```

It verifies that the checkout matches `origin/main`, the local
`mcp-geo-server` Docker image was built after that ref, stale app containers
are not still running, the Claude/Codex/Gemini wrappers resolve as expected,
and `.vscode/mcp.json` is present. If the image is stale, either rebuild it
directly or rerun the check with `--rebuild`:

```bash
./scripts/prepare-for-demo --rebuild
```

Optional HTTP transport:

```bash
./scripts/mcp-http-demo-local
```

The HTTP demo launcher uses the same Docker wrapper hydration as the STDIO
client wrappers: it reads `OS_API_KEY` / `OS_API_KEY_FILE`, mounts ONS/OS cache
directories when present, enables the MCP 2026 release-candidate flag by
default, and starts `http://127.0.0.1:8000/mcp` for HTTP-capable clients such as
Codex, VS Code, Inspector, and Claude Code HTTP connections. If you are running
from a clean worktree but want to reuse an existing checkout's `.env` and cache
paths, set `MCP_GEO_ENV_FILE=/absolute/path/to/mcp-geo/.env` before launching.

> Tip: Replace `mcp-geo-server` with `ghcr.io/chris-page-gov/mcp-geo:latest`
> in any Docker command to use the pre-built image instead of a local build.

## Tutorial

See [docs/tutorial.md](docs/tutorial.md) for an evaluation-style walkthrough covering tool discovery, admin lookup, OS tools, ONS tools, resources/ETags, and STDIO.

## Evaluation

See [docs/evaluation.md](docs/evaluation.md) for the question suite, rubric, and harness usage.

## Research (ONS Dataset Selection)

See `research/ons_dataset_selection/report.md` for the ONS dataset selection research pack,
including the taxonomy options, DataPack schema, sample DataPacks, and linking rules used to
improve AI dataset selection and explainability.

## Research (Map Delivery Interoperability)

See `research/map_delivery_research_2026-02/README.md` for the map delivery
research pack covering personas, map delivery option analysis, containerized
Playwright trials, screenshots/log evidence, and final recommendations for
cross-client map reliability.

For validated host/browser behavior by capability mode, see
`docs/map_delivery_support_matrix.md`.
For notebook-to-resource scenario pack workflow, see `docs/map_scenario_packs.md`.
For mixed-host embedding and constrained-style patterns, see
`docs/map_embedding_best_practices.md`.

## Client Tracing

See [docs/client_trace_strategy.md](docs/client_trace_strategy.md) for MCP traffic
and MCP-Apps UI interaction capture using the stdio and HTTP trace proxies.

## Tool Catalog (Epics B–D)

Tools are discoverable via `/tools/list` and rich metadata via `/tools/describe`.
Discovery responses use sanitized tool names (for example `os_places_by_postcode`) for
client compatibility; map back to canonical dotted names via
`annotations.originalName`. Tool calls accept both sanitized and dotted names.
Use `toolset`, `includeToolsets`, and `excludeToolsets` filters to focus discovery
responses by capability groups (for example `ons_selection`, `maps_tiles`, `apps_ui`).
For clients that always request `tools/list` with empty params, set
`MCP_TOOLS_DEFAULT_TOOLSET=starter` (or
`MCP_TOOLS_DEFAULT_INCLUDE_TOOLSETS=<csv>`) to keep initialization payloads small.
For the current local development profile, the checked-in examples use
`MCP_TOOLS_DEFAULT_INCLUDE_TOOLSETS=ons_geo_lookup,property_tax,features_layers,landis_soils`
alongside `starter` so constrained hosts still see the active ONS geo,
AddressBase/council-tax, map export, and LandIS surfaces.
`council_tax.band_lookup` and `council_tax.query` are always loaded by default
so MCP clients do not need a separate property-tax discovery step before using
the council-tax surfaces.

| Tool                                | Purpose                                                                       |
| ----------------------------------- | ----------------------------------------------------------------------------- |
| os_places.search                    | Free text address search                                                      |
| os_places.by_postcode               | UPRNs + addresses for a postcode                                              |
| os_places.by_uprn                   | Single address lookup                                                         |
| os_places.nearest                   | Nearest addresses to a coordinate                                             |
| os_places.within                    | Addresses within bbox                                                         |
| os_names.find                       | OS Names gazetteer search for named places and features                       |
| os_names.nearest                    | Nearest named features                                                        |
| os_features.query                   | NGD features by bbox & collection                                             |
| os_linked_ids.get                   | Relationship lookup between UPRN/USRN/TOID                                    |
| os_maps.render                      | Static map render metadata (proxy URL)                                        |
| os_vector_tiles.descriptor          | Vector tiles style/source descriptor                                          |
| os_offline.descriptor               | Offline PMTiles/MBTiles pack catalog + retrieval contracts                    |
| os_offline.get                      | Offline map handoff payloads (`map_card`, `overlay_bundle`, `export_handoff`) |
| admin_lookup.containing_areas       | Administrative area containment for a point                                   |
| admin_lookup.reverse_hierarchy      | Ancestor chain for an administrative area                                     |
| admin_lookup.area_geometry          | Bounding box geometry for an administrative or statistical area               |
| admin_lookup.find_by_name           | Boundary/admin area name search, including parish/PARNCP areas                |
| council_tax.band_lookup             | Experimental England/Wales Council Tax band lookup                            |
| council_tax.query                   | AddressBase Premium UPRN check for Council Tax and non-domestic rates         |
| landis_catalog.list_products        | LandIS callable product registry, exact thematic IDs, and access tiers        |
| landis_metadata.get                | LandIS product metadata, provenance, and linked resources                     |
| landis_soilscapes.point            | LandIS Soilscapes class lookup for a WGS84 point                              |
| landis_soilscapes.area_summary     | LandIS Soilscapes area composition summary                                    |
| landis_derive.pipe_risk            | LandIS-derived corrosion and shrink-swell pipe risk screening                 |
| ons_data.query                      | Query live ONS observations (dataset/edition/version or term)                 |
| ons_data.dimensions                 | List ONS observation dimensions for a live dataset                            |
| ons_data.get_observation            | Retrieve a single live observation                                            |
| ons_data.create_filter              | Create a live ONS filter                                                      |
| ons_data.get_filter_output          | Retrieve filter output in JSON/CSV/XLSX                                       |
| ons_select.search                   | Rank ONS datasets with explainable scoring                                    |
| ons_search.query                    | Search live ONS datasets (beta API)                                           |
| ons_codes.list                      | List live dimension IDs                                                       |
| ons_codes.options                   | List live dimension options                                                   |
| nomis.datasets                      | List NOMIS datasets or dataset definitions                                    |
| nomis.concepts                      | List NOMIS concepts                                                           |
| nomis.codelists                     | List NOMIS code lists                                                         |
| nomis.query                         | Query NOMIS datasets (JSON-stat/SDMX)                                         |
| os_mcp.descriptor                   | Server capabilities and tool search configuration                             |
| os_mcp.route_query                  | Intent classification and tool/workflow recommendation                        |
| os_route.descriptor                 | Route solver capabilities, supported profiles, and graph readiness            |
| os_route.get                        | Resolve stops and compute a graph-backed route                                |
| os_apps.render_geography_selector   | Open the MCP-Apps geography selector widget                                   |
| os_apps.render_statistics_dashboard | Open the MCP-Apps statistics dashboard widget                                 |
| os_apps.render_feature_inspector    | Open the MCP-Apps feature inspector widget                                    |
| os_apps.render_route_planner        | Open the MCP-Apps route planner widget backed by `os_route.get`               |
| os_apps.render_ui_probe             | Probe MCP-Apps UI rendering support                                           |

Use OS Names for gazetteer-style named-place and named-feature lookup, such as
settlements, hamlets, villages, hills, woods, or other named map features. Use
`admin_lookup.*` when you need an official boundary, hierarchy, containment, or
geometry for a statistical/admin area. `PARISH` is the public normalized level
for civil parishes, Welsh communities, and non-civil-parished areas; source
fields remain `PARNCP25CD`, `PARNCP25NM`, and `PARNCP25NW` where present.

`ons_geo.by_postcode`, `ons_geo.by_uprn`, and `ons_geo.area_summary` expose
normalized `OA`, `LSOA`, `MSOA`, `PARISH`, ward, district, region, and country
fields when the local ONS cache has those source columns. House of Commons
Library 2021 MSOA names are carried only as `displayName` labels with
provenance; they do not replace the official ONS/RGC `currentName`.

## Resources, Filtering & Provenance

The resources API exposes skills, UI widgets, and data resources (boundary
manifest, cache status, and local ONS code cache entries).

- `GET /resources/list` returns skill, UI, and data resource descriptors (with provenance metadata).
- `GET /resources/read?uri=skills://mcp-geo/getting-started` returns skills guidance.
- `GET /resources/read?uri=ui://mcp-geo/geography-selector` returns MCP-Apps UI HTML.
- `GET /resources/read?uri=resource://mcp-geo/landis-products` returns the checked-in LandIS MVP registry.

### LandIS Local Archive And Phase 2 Surface

LandIS now has two layers in this repo:

- a validated MVP screening surface
- an additive phase-2 local-archive surface for NATMAP, NSI, and archive
  discovery

The checked-in registry and prompt resources work offline. The phase-2 archive
resources also work offline from the local mirror. Spatial queries still
require a normalized PostGIS warehouse, but the source of truth for follow-on
LandIS ingestion is now the local archive under `~/Data` rather than a live
portal session.

Use:

- `landis_catalog.list_products` to discover the supported callable products,
  including the exact NATMAP thematic `productId` values accepted by
  `landis_natmap.thematic_area_summary`, plus linked resources and tool
  bindings.
- `landis_metadata.get` to retrieve provenance and limitations for a specific
  LandIS product.
- `landis_soilscapes.point` and `landis_soilscapes.area_summary` for generalized
  Soilscapes lookups.
- `landis_derive.pipe_risk` for caveated corrosion and shrink-swell screening.
- `landis_archive.list_items` and `landis_archive.get_item` to inspect the
  locally mirrored LandIS archive and its surfacing classification, including
  supplementary full-release items such as `HOST`, `wetness`, `Series
  Hydrology`, `Series Leacs`, and matched `data.gov.uk` package metadata.
- `landis_natmap.point`, `landis_natmap.area_summary`, and
  `landis_natmap.thematic_area_summary` for local-archive-backed NATMAP map-unit
  and thematic summaries once loaded into PostGIS.
- `landis_nsi.nearest_sites`, `landis_nsi.within_area`, and
  `landis_nsi.profile_summary` for explicit evidence-first NSI lookups once
  loaded into PostGIS.

Additional LandIS resources:

- `resource://mcp-geo/landis-portal-inventory`
- `resource://mcp-geo/landis-archive-triage`
- `resource://mcp-geo/landis-full-release-manifest`

Reference documentation for the LandIS strategy and dataset surface is also
now checked in as an Obsidian vault under
`Obsidian/LandIS Knowledge Base/`, including the strategy PDF, dataset notes,
use-case summaries, reference pages, and the MCP architecture roadmap in a
form that can be browsed directly in Obsidian or as Markdown in the repo.

The repo now also carries a generated, repo-wide Obsidian knowledge base under
`Obsidian/MCP Geo Knowledge Base/`. Unlike the LandIS example vault, this
surface is built automatically from tracked repo content, excludes `Obsidian/**`
 from source scanning to avoid recursion, records commit-pinned GitHub links and
 source hashes in note frontmatter, and supports an ignored `98 Local Overlay/`
 subtree for machine-local trace/session evidence.

Refresh the canonical vault with:

```bash
python3 scripts/build_obsidian_kb.py \
  --mode canon \
  --git-ref WORKTREE \
  --output-root "Obsidian/MCP Geo Knowledge Base" \
  --manifest-out data/knowledge_base/obsidian_kb_manifest.json
```

Validate it with:

```bash
python3 scripts/validate_obsidian_kb.py \
  --manifest data/knowledge_base/obsidian_kb_manifest.json \
  --fail-on drift coverage recursion orphan
```

Enable the live warehouse with `LANDIS_ENABLED=true`, `LANDIS_LIVE_ENABLED=true`,
and `LANDIS_WAREHOUSE_DSN=...`. Load normalized tables with
`python scripts/landis_ingest.py --dsn ... --soilscapes <file> --pipe-risk <file>`.
To inventory the authenticated LandIS portal itself from a local Atlas sign-in,
run `python scripts/landis_portal_inventory.py`. The generated machine-readable
catalog lands in `research/landis-data-source/landis_portal_inventory_2026-04-04.json`
and the human-readable index lands in
`docs/reports/landis_portal_inventory_2026-04-04.md`.
To mirror the authenticated portal payloads to local storage, run
`python scripts/landis_portal_download.py --destination /absolute/path/to/Data/landis_portal_archive_2026-04-04`.
The downloader reuses the Atlas session, stores per-item metadata plus raw item
payloads, and exports Feature Service layers/tables in chunked GeoJSON/JSON
files under the destination root without storing the session token itself.
To classify the local archive for runtime surfacing, run
`python scripts/landis_archive_triage.py`.
To ingest the local NATMAP and NSI phase-2 slice from `~/Data` into PostGIS,
run `python scripts/landis_phase2_ingest.py --dsn ...`.
The Docker wrapper `scripts/mcp-docker-local` now mounts the configured LandIS
data root into the app container at `/landis-data` and sets
`LANDIS_LOCAL_DATA_ROOT` there automatically. If no explicit root is configured
it still falls back to `~/Data`. It also hydrates and mounts any configured
`LANDIS_PORTAL_ARCHIVE_DIR`, `LANDIS_FULL_RELEASE_ARCHIVE_DIR`,
`ADDRESSBASE_PREMIUM_XREF_PATH`, `BOUNDARY_RUNS_DIR`, and
`BOUNDARY_RUNS_SEARCH_DIRS` paths from the repo `.env`, so the normal
`mcp-geo` + PostGIS container workflow can use repo-local data and external
archives directly without copying raw mirrors into the image or database
volume. On a fresh sidecar, the wrapper still auto-bootstraps the LandIS
warehouse tables from the mounted data before it starts the stdio server: it
runs `scripts/landis_phase2_ingest.py` for the portal-archive NATMAP/NSI slice
and `scripts/landis_ingest.py` for the Warwickshire Soilscapes and pipe-risk
validation layers. Expect the first start to take materially longer than a warm
restart because this load is large. The verified phase-2 warehouse load
currently covers `NationalSoilMap`, eight NATMAP thematic products,
`NSIsite`, and six mirrored NSI observation datasets from the local archive,
plus the existing Soilscapes and pipe-risk validation layers.

For a clean setup path aimed at a full spatial LandIS warehouse, use
[docs/landis_spatial_warehouse_setup.md](docs/landis_spatial_warehouse_setup.md).
The recommended topology is still one MCP-Geo server with a PostGIS LandIS
warehouse behind it; a separate LandIS MCP server is only a later governance,
licensing, or performance-isolation decision.

- `GET /resources/read?uri=resource://mcp-geo/boundary-manifest` returns the boundary manifest.

### Skills and MCP-Apps Resources

In addition to data resources, MCP Geo exposes:

- `skills://mcp-geo/getting-started` (Agent Skills guidance)
- `ui://mcp-geo/geography-selector`
- `ui://mcp-geo/statistics-dashboard`
- `ui://mcp-geo/feature-inspector`
- `ui://mcp-geo/route-planner`

Use `GET /resources/read?uri=...` to fetch these resources. When a host can call
tools but cannot invoke protocol-level `resources/read`, use `os_resources.get`
as the portable fallback bridge. MCP-Apps widgets are HTML documents with
`text/html;profile=mcp-app` MIME types.

## Route Planning

Route planning now has a deterministic tool path as well as a widget path.

- Call `os_mcp.route_query` to classify free-text prompts and extract stop hints.
- Call `os_route.descriptor` to check whether the active PostGIS/pgRouting graph is ready.
- Call `os_route.get` to resolve stops and return distance, duration, geometry, legs,
  steps, mode changes, warnings, and graph provenance.
- Call `os_apps.render_route_planner` when the host can open MCP-Apps UI; the widget
  mirrors the `os_route.get` contract and delegates calculation to that tool.

The intended backend is an OS Multi-modal Routing Network build loaded into PostGIS,
with pgRouting used for shortest-path execution and route warnings enriched from
restriction tables when available.

MCP-Apps support varies by client. If the client does not advertise UI support,
the stdio adapter injects a `fallback` static map payload for
`os_apps.render_geography_selector` (computed via `os_maps.render`).
Set `MCP_STDIO_UI_SUPPORTED=1` to force UI mode, or
`MCP_STDIO_FALLBACK_BBOX_DEG` to control the fallback map span.
Set `MCP_APPS_CONTENT_MODE=embedded` to embed UI HTML as a `resource` content
block, or `MCP_APPS_CONTENT_MODE=resource_link` to emit a `resource_link`
content block. Use `MCP_APPS_CONTENT_MODE=text` to suppress UI content blocks.
Set `MCP_STDIO_ELICITATION_ENABLED=0` to disable form elicitation in STDIO
(`os_mcp.stats_routing`, `ons_select.search`). For Streamable HTTP (`/mcp`), set
`MCP_HTTP_ELICITATION_ENABLED=0`.

### Conditional Requests (ETag)

Clients should cache UI/skills responses and revalidate using `If-None-Match`.
If unchanged, the server returns `304 Not Modified` with the same `ETag` header.

### Dataset Notes

## Compression (GZip)

GZip compression is enabled (minimum payload size 512 bytes). Send:

```text
Accept-Encoding: gzip
```

to receive a compressed response (check `Content-Encoding: gzip`).

## Rate Limiting

Basic per-minute in-memory rate limiting is enabled by default:

- Environment variable: `RATE_LIMIT_PER_MIN` (default 207 per IP per top-level path segment)
- Bypass (tests/dev): `RATE_LIMIT_BYPASS=false` by default; set `true` only for explicit local/dev bypass.
- Path exemptions: `RATE_LIMIT_EXEMPT_PATH_PREFIXES` (defaults to
  `/maps/vector/vts/tile,/maps/raster/osm,/maps/static/osm`) to prevent
  normal map tile fan-out from triggering 429 responses.
Responses over the limit return:
```json
{ "isError": true, "code": "RATE_LIMITED", "message": "Rate limit exceeded" }
```

Note: In-memory approach is not multi-process safe; replace with Redis or a shared store for production.

### Rate-Limit Calibration Helper

You can run an active probe to estimate a suitable `RATE_LIMIT_PER_MIN`:

```bash
python3 scripts/rate_limit_assessor.py \
  --base-url http://127.0.0.1:8000 \
  --path /tools/list \
  --start-rpm 60 \
  --step-rpm 30 \
  --max-rpm 300 \
  --duration-sec 20 \
  --target-429-ratio 0.01 \
  --headroom-percent 15 \
  --output logs/rate-limit-assessment.json
```

Notes:

- Set `RATE_LIMIT_BYPASS=false` before probing, otherwise no limiter behavior will be observed.
- The recommendation is per client IP and top-level path segment, matching middleware behavior.
- Requests under `RATE_LIMIT_EXEMPT_PATH_PREFIXES` are excluded from this probe scope.

## Metrics

Prometheus-style metrics exposed at `GET /metrics` (if `METRICS_ENABLED=true`).
When MCP HTTP auth is enabled, `/metrics` follows the same auth policy as
`/mcp`, `/tools/*`, `/resources/*`, and `/playground/*`.

- `app_requests_total` counter
- `app_rate_limited_total` counter
- `app_request_latency_ms_bucket` / `_count` histogram (client-observed wall time per request)
- `mcp_http_auth_failures_total` counter by auth failure reason
- `mcp_http_session_quota_rejections_total` counter
- `mcp_http_sessions_active` gauge
- `mcp_tool_errors_total` counter by tool and transport

Example scrape output snippet:

```text
# HELP app_requests_total Total HTTP requests
# TYPE app_requests_total counter
app_requests_total 42
# HELP app_request_latency_ms Request latency histogram (ms)
# TYPE app_request_latency_ms histogram
app_request_latency_ms_bucket{le="50"} 40
app_request_latency_ms_bucket{le="100"} 41
app_request_latency_ms_bucket{le="+Inf"} 42
app_request_latency_ms_count 42
```

Admin lookup tools call the live ONS Open Geography services by default. Static
boundary resources are not advertised in the resources API.

### ONS Observations & Discovery (Epic D)

ONS data tools require live mode (`ONS_LIVE_ENABLED=true`). You can supply
`dataset`, `edition`, and `version` directly, or provide a `term` and let
`ons_data.query` auto-resolve the latest version.
`ons_codes.*` supports an optional on-disk cache via `ONS_DATASET_CACHE_ENABLED`.

Tool `ons_data.query` supports:

- `geography` (single code)
- `measure` (single code)
- `timeRange` — either single period (`2024 Q2`) or inclusive range (`2024 Q1-2024 Q4`)
- Pagination: `limit` (1–500, default 100) and `page` (1-based)

### ONS Client & Dataset Caching

`tools/ons_common.py` provides:

- Retry + error mapping
- In-memory TTL cache (short-lived request cache)
- `get_all_pages` helper for full dataset paging

Full dataset cache snapshots are stored on disk when enabled via
`ONS_DATASET_CACHE_ENABLED=true` and `ONS_DATASET_CACHE_DIR`.

### Live ONS Mode & Codes

Enable live mode by setting `ONS_LIVE_ENABLED=true`. If you do not supply
dataset metadata, `ons_data.query` will attempt to resolve the latest edition
and version using `term`.

`ons_data.query` (live):

```text
GET https://api.ons.gov.uk/dataset/{dataset}/edition/{edition}/version/{version}/observations?limit=...&page=...
```

`ons_data.dimensions` (live):

1. Fetch version metadata:

```text
GET https://api.ons.gov.uk/dataset/{dataset}/edition/{edition}/version/{version}
```

1. For each dimension id returned, fetch its codes (paged, currently requesting up
   to 1000):

```text
GET https://api.ons.gov.uk/dataset/{dataset}/edition/{edition}/version/{version}/dimensions/{dimensionId}/options?limit=1000&page=1
```

Provide an optional `dimension` field to retrieve only a single dimension's codes.

`ons_search.query` (live dataset search):

```text
GET https://api.beta.ons.gov.uk/v1/datasets?search=<term>&limit=...&offset=...
```

You can override the base with `ONS_DATASET_API_BASE` or disable live search with `ONS_SEARCH_LIVE_ENABLED=false`.

### NOMIS Labour & Census Statistics

Enable live mode with `NOMIS_LIVE_ENABLED=true` (default). Optional credentials
may be provided via `NOMIS_UID` and `NOMIS_SIGNATURE` if you need higher limits.

Use:

- `nomis.datasets` for dataset discovery
- `nomis.concepts` / `nomis.codelists` for metadata
- `nomis.query` for JSON-stat or SDMX JSON observations

### Council Tax Band Lookup Pilot

`council_tax.band_lookup` is an experimental England/Wales-only pilot backed by
the public GOV.UK Council Tax band service. It currently uses an HTML form flow
rather than a published API, so treat it as a pilot integration with explicit
failure handling rather than a guaranteed stable machine-to-machine contract.

Supported inputs include `postcode`, `propertyName`, `street`, `town`,
`billingAuthorityReference`, and optional filters such as `band` and
`bandStatus`. Set `COUNCIL_TAX_BAND_LIVE_ENABLED=true` to enable the live
lookup surface.

### AddressBase Premium UPRN Tax Status

`council_tax.query` checks a batch of UPRNs against the AddressBase Premium
Application Cross Reference Type 23 table. By default it only counts current
matches with a blank `END_DATE`, so historical cross references do not get
reported as current liabilities.

Configure `ADDRESSBASE_PREMIUM_XREF_PATH` to either an extracted AddressBase
Premium xref CSV/Parquet file or a directory containing one. CSV sources are
stream-scanned; Parquet sources are queried directly with DuckDB so large local
lookup workloads can stay memory-bounded without building a separate indexed
database. The `council_tax.query` tool still classifies `SOURCE=7666VC` as
Council Tax and `SOURCE=7666VN` as non-domestic rates, based on the current OS
documentation.

For local runtime use, the recommended workflow is to keep the licensed source
extract outside git and build a smaller serving Parquet, for example:

```bash
python -m scripts.addressbase_build_xref \
  --input /absolute/path/to/ABP/xref.parquet \
  --output /absolute/path/to/mcp-geo/data/addressbase_premium/2026-03-03/xref_voa_os.parquet
```

The builder keeps the xref columns used by MCP Geo, drops only
`SOURCE=7666OW` and `SOURCE=7666OP`, writes a sorted `xref_voa_os.parquet`, and
leaves the wider VOA/OS-linked cross references available for future
UPRN/TOID-linked workflows. Optional runtime knobs:

- `ADDRESSBASE_PREMIUM_DUCKDB_THREADS`
- `ADDRESSBASE_PREMIUM_DUCKDB_MEMORY_LIMIT`

For local runtime use, the recommended workflow is to keep the licensed source
extract outside git and build a smaller serving Parquet, for example:

```bash
python -m scripts.addressbase_build_xref \
  --input /absolute/path/to/ABP/xref.parquet \
  --output /absolute/path/to/mcp-geo/data/addressbase_premium/2026-03-03/xref_voa_os.parquet
```

The builder keeps the xref columns used by MCP Geo, drops `SOURCE=7666OW` and
`SOURCE=7666OP`, writes a sorted `xref_voa_os.parquet`, and leaves the wider
VOA/OS-linked cross references available for future UPRN/TOID workflows.
Optional runtime knobs:

- `ADDRESSBASE_PREMIUM_DUCKDB_THREADS`
- `ADDRESSBASE_PREMIUM_DUCKDB_MEMORY_LIMIT`

The checked-in repo Docker image now installs the `addressbase` extra, so
Parquet-backed `council_tax.query` runs fully server-side inside the container
rather than depending on a host Python environment.

- Product technical specification:
  [AddressBase Premium Technical Specification](https://docs.os.uk/os-downloads/products/addresses-and-names-portfolio/addressbase-premium/addressbase-premium-technical-specification)
- Type 23 record details and `SOURCE` values:
  [Application Cross Reference - Type 23 Record](https://docs.os.uk/os-downloads/addressing-and-location/addressbase-premium-islands/addressbase-premium-islands-technical-specification/structured-data-types/application-cross-reference-type-23-record)

## Error Model

All errors conform to:

```json
{ "isError": true, "code": "<CODE>", "message": "..." }
```

Primary codes: `INVALID_INPUT`, `UNKNOWN_TOOL`, `NO_API_KEY`, `OS_API_KEY_INVALID`, `OS_API_KEY_EXPIRED`, `LIVE_DISABLED`, `OS_API_ERROR`, `ONS_API_ERROR`, `NOMIS_API_ERROR`, `ADMIN_LOOKUP_API_ERROR`, `COUNCIL_TAX_API_ERROR`, `UPSTREAM_TLS_ERROR`, `UPSTREAM_CONNECT_ERROR`, `INTEGRATION_ERROR`, `RATE_LIMITED`, `UNKNOWN_FILTER`, `NO_OBSERVATION`.

## Project Structure

```text
server/        FastAPI app & routers
tools/         Tool implementations (one module per domain)
resources/     Static datasets (future expansion)
playground/    Svelte + Vite playground UI
tests/         Pytest suite (≥90% coverage)
docs/          Backlog & design notes
.devcontainer/ Dev environment setup
```

Note: The Svelte playground is served by Vite (`npm run dev`). The legacy
`playground/app.py` stub does not serve the UI.

## Dynamic Tool Registration

`server/mcp/tools.py` explicitly imports each `tools.*` module at startup to guarantee registration in environments where implicit side-effect imports are skipped (e.g. selective packaging or lazy loaders). This ensures `/tools/describe` always reflects the full catalog without relying on import order.

## Testing & Coverage

Run tests with:

```bash
./scripts/pytest-local -q
```

Coverage gate (configured) requires ≥90%. Add tests for both success and error branches (retry paths, validation failures, upstream errors). Avoid broad mocks that skip normalization logic.

Host-side wrappers:
- `./scripts/pytest-local`, `./scripts/ruff-local`, and
  `./scripts/mypy-local` run the current repo-supported phased CI slice by
  default.
- `./scripts/ruff-local [paths...]` and `./scripts/mypy-local [paths...]`
  still prefer the running repo devcontainer app container.
- If no devcontainer is running, they fall back to the repo `.venv`.
- If the tool is still unavailable, they fall back to `uv run`.
- Passing explicit paths overrides the default curated slice.
- Override with `MCP_GEO_LOCAL_TOOL_MODE=devcontainer|venv|uv|path`.

Strict OWASP MCP validation:

```bash
./scripts/validate-owasp-mcp-local
```

This writes JSON/Markdown report artifacts plus a remediation backlog under
`output/owasp-mcp-validation/` and fails when any `minimum_bar` or `required`
control is unmet.

## MCP HTTP Hardening

Remote MCP HTTP deployments should enable authenticated access and bounded
session state. When auth is enabled, only `GET /health` remains public; the raw
HTTP routes under `/tools/*`, `/resources/*`, `/playground/*`, and `/metrics`
share the same auth boundary as `/mcp`.

- `MCP_HTTP_AUTH_MODE=hs256_jwt` enables bearer JWT enforcement.
- `MCP_HTTP_AUTH_MODE=static_bearer` enables a fixed bearer token for `/mcp`,
  raw `/tools/*`, raw `/resources/*`, `/metrics`, and `/playground/*`.
- `MCP_HTTP_AUTH_TOKEN` and `MCP_HTTP_JWT_HS256_SECRET` are included in the
  shared log/exception redaction path alongside the OS/NOMIS credentials.
- `MCP_HTTP_JWT_HS256_SECRET_FILE` loads the signing secret from a mounted file.
- `MCP_HTTP_JWT_ISSUER`, `MCP_HTTP_JWT_AUDIENCE`, and
  `MCP_HTTP_JWT_REQUIRED_SCOPES` constrain accepted tokens.
- `MCP_HTTP_SESSION_TTL` and `MCP_HTTP_SESSION_TOOL_CALL_LIMIT` bound session
  lifetime and tool-call volume.
- `MCP_2026_RC_ENABLED=1` or `MCP_PROTOCOL_2026_07_28_ENABLED=1` enables the
  feature-gated MCP `2026-07-28` release-candidate path for interop testing.
  Leave these unset for normal stable clients.
- `OS_API_KEY_FILE`, `OS_API_ACCESS_TOKEN_FILE`, `NOMIS_UID_FILE`, and
  `NOMIS_SIGNATURE_FILE` support secret-file delivery without committing live
  secrets.
- `ops/deployment/docker-compose.prod.yml` is the hardened reference deployment
  used by the OWASP MCP strict evidence set.

## Contributing

- Use Conventional Commits (e.g. `feat(tools): add os_places.within pagination`).
- Every PR: update `CHANGELOG.md`, add/adjust tests, keep coverage ≥90%.
- Include JSON schemas (input/output) when adding a tool.
- Prefer incremental refactors; avoid unrelated changes in feature PRs.

## Security

Report vulnerabilities through GitHub Private Vulnerability Reporting for this
repository. See [SECURITY.md](SECURITY.md) for reporting instructions and scope.

## Enriched Address Data

`os_places.*` currently return raw OS Places fields only. Enrichment via local
code lists is not implemented yet.

## Examples & Golden Tests

See `docs/examples.md` for sample payloads, conversation flows, and guidance on chaining tools. Golden scenario tests (`test_golden_scenarios.py`) ensure transformation stability with deterministic mocked upstream responses.

## Resource Caching & Provenance

All `/resources/read` responses include:

- `etag` (weak) for conditional requests
- `provenance.retrievedAt` timestamp
- `Cache-Control` header
  Clients should respect TTL and still perform ETag revalidation for freshness.

## Troubleshooting

See `docs/troubleshooting.md` for a table of common error codes (`INVALID_INPUT`, `UNKNOWN_TOOL`, `NO_API_KEY`, `OS_API_KEY_INVALID`, etc.) and remediation steps.

## Configuration

Copy `.env.example` → `.env` and set `OS_API_KEY`. Optional flags:

- `DEBUG_ERRORS` (if present / truthy) enables traceback in error responses; otherwise stack traces are suppressed.
- `CIRCUIT_BREAKER_ENABLED`, `CIRCUIT_BREAKER_FAILURE_THRESHOLD`,
  `CIRCUIT_BREAKER_RESET_SECONDS` to control upstream circuit breaker behavior.

## SSL & Certificates

Container and dev setup now use the system CA bundle path
`/etc/ssl/certs/ca-certificates.crt` so local corporate root CAs can be added
without code changes.

- Put local proxy/root CA `.crt` files in `.devcontainer/certs/` before
  rebuilding the devcontainer or Docker image.
- For proxied networks, set `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` via
  `.devcontainer/.env` (or host env exports).
- In this Docker Compose-based devcontainer, container-wide env is sourced from
  `.devcontainer/docker-compose.yml`; keep machine-specific values in
  `.devcontainer/.env` or your host shell rather than `devcontainer.json`.
- Proxy settings are only used at build/runtime injection points and are not
  persisted into the final runtime image metadata.
- `INSTALL_NGROK` is opt-in for the devcontainer build so TLS-inspected
  networks do not fail on the optional tunnel binary fetch.

## License

See [LICENSE](LICENSE).

## MCP STDIO Adapter (Local Dev)

The JSON-RPC 2.0 STDIO adapter lives in `server/stdio_adapter.py` (refactored from the prior `scripts/os_mcp.py`). Legacy entry points remain:

- Console script: `mcp-geo-stdio`
- Wrapper script: `scripts/os-mcp` (delegates to `server/stdio_adapter.py`)

This adapter is referenced by `mcp.json` (`mcp-geo-stdio`).

### Framing

Each request/response:

```text
Content-Length: <bytes>\r\n
\r\n
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
```

### Methods

| Method             | Description                                            |
| ------------------ | ------------------------------------------------------ |
| initialize         | Returns server metadata & capabilities                 |
| tools/list         | Lists tools (name, description, schemas)               |
| tools/call         | Invoke a tool (`params.tool`, optional `params.args`)  |
| resources/list     | Lists resource descriptors (skills + UI resources)     |
| resources/describe | Returns resource metadata (name, description, license) |
| resources/read     | Fetch resource content (ETag supported)                |
| shutdown           | Graceful shutdown (result null)                        |
| exit (notify)      | Process terminates (no response)                       |

Tool call result shape:

```json
{
 "jsonrpc": "2.0",
 "id": 3,
 "result": { "status": 200, "ok": true, "data": { ...tool output... } }
}
```

### Manual Test

```bash
python scripts/os-mcp & PID=$!
printf 'Content-Length: 60\r\n\r\n{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | nc -U /dev/fd/0 # or use a small Python helper
kill $PID
```

Simpler: write a tiny Python snippet to send framed messages (see `tests/test_stdio_adapter.py`).

### VS Code

VS Code reads MCP server configuration from `.vscode/mcp.json` (workspace) or your
user-level `mcp.json`. This repo includes `.vscode/mcp.json` with:

- `mcp-geo` (STDIO, with MCP-Apps UI enabled)
- `mcp-geo-trace` (STDIO + JSON-RPC trace log under `logs/`)
- `mcp-geo-http` (HTTP transport at `http://127.0.0.1:8000/mcp`)
- `.vscode/mcp-geo.toolsets.jsonc` (copy to your VS Code user prompts folder as
  `mcp-geo.toolsets.jsonc` to group tools in Configure Tools)

See `docs/vscode.md` for step-by-step setup, MCP-Apps UI validation, and tracing.

### Notes

- `resources/read` now emits a weak ETag (`etag`) and supports conditional retrieval via `ifNoneMatch` param. If matched, response shape: `{ "jsonrpc":"2.0", "id": <n>, "result": { "notModified": true, "etag": "W/\"...\"" } }`.
- Use the same pagination/filter parameters when revalidating or the variant key changes and a full payload is returned.
- `resources/describe` returns the static metadata list (extend as resources grow).
- Errors follow JSON-RPC error envelope with custom positive codes (1001-1003) for validation and -32603 for internal errors.

### Helper Client Script

For quick one-shot invocations without crafting frames manually, use the helper script added in `scripts/mcp_client.py` (it spawns the adapter, performs `initialize`, your requested method, then `shutdown`/`exit`).

Examples:

```bash
# List tools
python scripts/mcp_client.py tools/list

# Describe available ONS dimensions (live mode)
ONS_LIVE_ENABLED=true python scripts/mcp_client.py tools/call ons_data.dimensions '{"params":{"dataset":"gdp","edition":"time-series","version":"1"}}'

# Query observations (live)
ONS_LIVE_ENABLED=true python scripts/mcp_client.py tools/call ons_data.query '{"params":{"dataset":"gdp","edition":"time-series","version":"1","geography":"K02000001","limit":2}}'

# Fetch resource with ETag then conditional request
R1=$(python scripts/mcp_client.py resources/read '{"uri":"skills://mcp-geo/getting-started"}' | jq -r '.response.result.etag')
python scripts/mcp_client.py resources/read '{"uri":"skills://mcp-geo/getting-started","ifNoneMatch":"'$R1'"}'

# Or using the convenience flag (no JSON escaping needed):
python scripts/mcp_client.py resources/read --if-none-match "$R1" '{"uri":"skills://mcp-geo/getting-started"}'
```

The JSON argument after the tool name is merged into the request `params` object. Include nested objects as required by each tool schema.

### Correct Inline Heredoc Helper (Advanced)

If you prefer piping multiple framed requests to a persistently running adapter instance:

```bash
python scripts/os-mcp & APP_PID=$!
python - <<'PY'
import sys, json
def send(mid, method, params=None):
 msg = {"jsonrpc":"2.0","id":mid,"method":method,"params":params or {}}
 body = json.dumps(msg).encode()
 sys.stdout.buffer.write(b"Content-Length: "+str(len(body)).encode()+b"\r\n\r\n"+body)
 sys.stdout.flush()

# Emit two requests (initialize then list tools)
send(1, "initialize")
send(2, "tools/list")
PY | ./scripts/os-mcp
kill $APP_PID
```

Be careful to avoid duplicating or truncating function definitions when editing inline; each framed JSON-RPC message must be complete and preceded by a correct `Content-Length` header.

### REPL Mode

Interactive session:

```bash
python scripts/mcp_client.py --repl
mcp> resources/describe
mcp> resources/read {"uri":"skills://mcp-geo/getting-started"}
mcp> resources/read {"uri":"skills://mcp-geo/getting-started","ifNoneMatch":"W/\"abc123deadbeef00\""}
mcp> exit
```

`notModified` responses are compacted by the client for readability.