Charlotte-Mecklenburg MCP Server
# Charlotte-Mecklenburg MCP Server
An MCP (Model Context Protocol) server that exposes Charlotte, NC and Mecklenburg
County open data as tools for an LLM agent. Ask about trash pickup, parcels and
zoning, crime and 311 requests, planning areas and rezonings, bike lanes and ADA
infrastructure, or environmental data (watersheds, tree canopy, brownfields) at any
address in the city/county, and the server geocodes the address and queries the
relevant live ArcGIS layer for you.
Core motion behind almost every tool: **geocode an address → spatial or attribute
query against an ArcGIS REST layer → structured result + a human-readable summary.**
## Data sources
Everything is queried live, at call time, from public ArcGIS REST endpoints — there
is no local copy of the underlying data (a small local registry of *endpoint
metadata*, not the data itself, lives in `data/registry.json`; see "Endpoint
discovery" below). Four backends are in play:
| Source | What it is | Base URL |
|---|---|---|
| City of Charlotte on-prem ArcGIS Server | City departmental data (CMPD, sanitation, 311, zoning) | `https://gis.charlottenc.gov/arcgis/rest/services` |
| Mecklenburg County on-prem ArcGIS Server | County data (parcels, tax foreclosures) | `https://meckags.mecklenburgcountync.gov/server/rest/services` |
| City of Charlotte ArcGIS Online (AGOL) org | Solid waste routes, bike lanes, ADA infrastructure, CATS transit, watersheds, tree canopy | `https://services.arcgis.com/9Nl857LBlQVyzq54/arcgis/rest/services` |
| Mecklenburg County ArcGIS Online (AGOL) org | Capital Improvement Projects, brownfields, battery/HHW recycling sites | `https://services.arcgis.com/BWD3gDuaqc7SQmy7/arcgis/rest/services` |
**All endpoints are public and require no authentication/token.** Geocoding uses
Esri's public World geocoder (`https://geocode.arcgis.com/...`), also token-free for
non-stored results.
An offline registry of ~1,875 discovered service/layer entries across all four
sources (crawled via `scripts/build_registry.py`) backs the `list_datasets` tool, so
an agent can search for "what layer has X" before calling a specific tool.
## Install
```bash
git clone <this repo> charlotte-mcp
cd charlotte-mcp
uv sync
```
Register with Claude Code:
```bash
claude mcp add charlotte -- uv --directory /Users/dwlavoie/charlotte-mcp run charlotte-mcp
```
(Substitute your actual clone path for `/Users/dwlavoie/charlotte-mcp`.) The server
runs over stdio — no ports, no API keys, no config needed.
You can also run it directly for local testing:
```bash
uv run charlotte-mcp
```
## Use from ChatGPT (remote connector)
ChatGPT cannot run local MCP servers — it only supports **remote** custom connectors
(a public HTTPS endpoint, added in Developer Mode). A hosted instance of this server
runs at:
```
https://pcygic2am3.us-east-1.awsapprunner.com/mcp
```
To connect it (requires ChatGPT Plus/Pro; custom connectors are read-only on those
plans, which is all this server needs):
1. On chatgpt.com, open **Settings → Apps & Connectors → Advanced settings** and
enable **Developer mode**.
2. Add a custom connector: name **Charlotte City Data**, MCP server URL
`https://pcygic2am3.us-east-1.awsapprunner.com/mcp`, authentication **none**.
3. Start a new chat, enable the connector from the tools menu, and test with
*"When is trash pickup at 2437 Remount Rd?"* (expected answer: Thursday).
Non-technical users: paste the block below into a new ChatGPT chat and it will walk
you through the steps above interactively.
> You're going to help me connect a custom connector called "Charlotte City Data" —
> a read-only tool that answers questions about Charlotte, NC using the city's
> official open data. Source: https://github.com/Lavoiedavidw/Charlotte-City-MCP
> Please walk me through, one step at a time, waiting for my confirmation between
> each: (1) confirm I'm signed in to ChatGPT Plus or Pro on chatgpt.com in a web
> browser; (2) guide me to Settings → Apps & Connectors → Advanced settings and help
> me turn on Developer mode; (3) guide me to add a custom connector named "Charlotte
> City Data" with MCP server URL https://pcygic2am3.us-east-1.awsapprunner.com/mcp
> and no authentication; (4) have me start a new chat, enable the connector from the
> tools menu, and test it with "When is trash pickup at 2437 Remount Rd?" — the
> answer should be Thursday. Afterward, give me five examples of useful things I can
> ask it about any Charlotte address.
## Remote hosting (AWS)
The same server serves streamable HTTP for remote MCP clients:
```bash
uv run charlotte-mcp --transport http --host 0.0.0.0 --port 8080
```
The hosted instance is defined as two CloudFormation stacks in `infra/`:
- `infra/build.yaml` — ECR repository + a CodeBuild project that builds the
`Dockerfile` directly from this public GitHub repo (no GitHub↔AWS OAuth
connection needed) and pushes `:latest`.
- `infra/service.yaml` — an App Runner service (0.25 vCPU / 0.5 GB, ≈$3–5/mo at
light usage) serving the image with auto-deploy on new pushes. Deploy it *after*
the first CodeBuild run, since App Runner requires the image to exist.
One transport gotcha, fixed in `server.py`: FastMCP enables localhost-only DNS
rebinding protection at construction time, which makes a public deployment reject
its own hostname with HTTP 421. The entrypoint disables that protection when
binding to a non-loopback `--host`.
## Tools
21 tools total: 1 geocoder, 1 dataset-discovery tool, and 19 domain tools across six
tool packs. Every location-based tool accepts either `address: str` or
`lat: float, lon: float` and returns a dict with a `summary` field.
| Tool | Pack | Description |
|---|---|---|
| `geocode` | core | Geocode a free-form address to lat/lon via the Esri World geocoder |
| `list_datasets` | datasets | Search the ~1,875-entry endpoint registry by keyword/source |
| `lookup_parcel` | property | Look up a parcel by address, point, PID, or NC-PIN (`ParcelStatus`) |
| `get_zoning` | property | Get zoning code and rezone date for a parcel (`Parcel_Zoning_Lookup`) |
| `search_tax_foreclosures` | property | Search county tax foreclosures by neighborhood/zip/status (`TaxForeclosures`) |
| `get_trash_schedule` | sanitation | Garbage/recycling/yard-waste day, provider, route, and GREEN/ORANGE recycling week |
| `crime_near` | safety | CMPD patrol + domestic-violence calls-for-service near a point (NPA-aggregate) |
| `violent_crime_near` | safety | CMPD violent-offense counts (homicide/rape/robbery/assault) near a point (NPA-aggregate) |
| `homicides_near` | safety | Point-level homicide incidents near a point, with weapon/clearance detail |
| `service_requests_near` | safety | 311 service requests near a point |
| `get_planning_area` | planning | Named community planning area containing a location |
| `rezonings_near` | planning | Approved rezonings (since 2016) near a point |
| `area_plans_at` | planning | Area plan(s) covering a location |
| `bike_lanes_near` | transport | Bike lane segments near a point (street, lane type, width, year built) |
| `ada_features_near` | transport | ADA curb ramps, accessible parking, and pedestrian signals near a point |
| `cip_projects_near` | transport | County Capital Improvement (stormwater) projects near a point |
| `cats_projects_near` | transport | CATS transit capital projects (stations + corridors) near a point |
| `brownfields_near` | environment | Recorded NC Brownfields Program sites near a point |
| `watershed_at` | environment | Named watershed/basin containing a location |
| `tree_canopy_at` | environment | Tree canopy / vegetation / impervious-surface stats for a location |
| `battery_recycling_sites_near` | environment | Household hazardous waste / battery recycling drop-off sites near a point |
## Example prompts
- **"When is trash pickup at 2437 Remount Rd?"**
- "What's the zoning for 2437 Remount Rd, and has it been rezoned recently?"
- "Are there any tax foreclosures in zip code 28208?"
- "What watershed is 2437 Remount Rd in, and what's the tree canopy like there?"
- "Any 311 requests or CMPD calls for service near 600 E 4th St in the last 90 days?"
- "Find bike lanes and ADA curb ramps within 400 meters of 600 E 4th St."
- "What capital improvement or CATS transit projects are planned near uptown?"
- "Search the dataset registry for anything related to 'waste'."
See `docs/demo.md` for a full worked transcript of the trash-pickup question.
## Known data quirks
- **City `SWS/AddressLocator` geocoder is broken** — it returns roughly `(0, 0)` in
its native spatial reference (wkid 2264) instead of a real match. The server does
not use it; `geocode` and every tool's address resolution go through Esri's World
geocoder instead.
- **`ParcelStatus` (county on-prem) rejects `outFields=*`** — the layer's `query`
operation returns an HTTP 200 with an ArcGIS `{"error": {"code": 400, ...}}` body
whenever `outFields=*` or `resultRecordCount` is present, regardless of the
where/point/envelope filter. `lookup_parcel` always passes an explicit field list
and never a page-size hint to work around it. This is isolated to this layer.
- **`TaxForeclosures.status` is null on every row** in the current dataset (629
total rows checked). The `status` filter is still exposed by `search_tax_foreclosures`
as documented — it just won't match anything until the county populates that
column. `cde_symbology_val` and `bpo_status` are the actually-populated
status-like fields.
- **`CMPD_Calls_for_Service` and `ViolentCrimeData` are aggregate-only tables**, not
incident-level data — no geometry, no per-incident rows, just monthly counts by
Neighborhood Profile Area (NPA). `crime_near` and `violent_crime_near` proxy "near
this point" by sampling nearby NPAs from the spatial `ServiceRequests311` layer,
then filtering the aggregate tables to those NPAs — a best-effort approximation,
not a true point-in-polygon match. `homicides_near` and `service_requests_near`,
by contrast, query real point-level layers directly.
- **ArcGIS `distance` + `units=esriSRUnit_Meter` buffer overshoot, on some AGOL
layers.** During development the environment tool pack found `Brownfields` and
`Battery_Recycling_Sites` (both hosted on layers with a native NC State Plane
*feet* spatial reference) returning features far outside the requested meter
radius — consistent with the server silently treating the requested distance as
the layer's native unit (feet) instead of converting from meters, a roughly 3.28x
overshoot. `environment.py`'s `brownfields_near` and `battery_recycling_sites_near`
work around this by fetching the full (small: 170 and 5 records respectively) layer
once and filtering with a client-side haversine calculation instead of relying on
the server-side buffer. A follow-up audit of every other radius-based tool (all of
`safety.py` and `transport.py`, plus a re-probe of the two originally-flagged
layers) found the meter buffer behaving correctly everywhere else, including on
a fresh re-test of `Brownfields`/`Battery_Recycling_Sites` — see the "Task 8
distance-buffer regression check" note in `PLAN.md` for the full methodology and
a caution for future maintainers about re-verifying before trusting (or removing
a workaround for) the buffer on any new layer.
## Development
```bash
uv run pytest -m live # live smoke tests against real endpoints, one file per tool pack
```
Tests are marked `live` because they hit real network services; expect occasional
transient failures from upstream ArcGIS endpoints rather than code bugs.
TDQS
Scored across 21 tools
Most tools target distinct datasets (parcels, zoning, crime, planning, transportation), but the crime tools (crime_near, violent_crime_near, homicides_near) and planning tools (area_plans_at, get_planning_area) overlap in purpose and could cause misselection. Descriptions are detailed enough to resolve ambiguity.
Names mostly follow a predictable pattern: [subject]_near for radius searches, [subject]_at for point-in-polygon lookups, and verb_noun (get_, lookup_, search_) for direct lookups. Minor deviations exist (geocode, get_planning_area vs area_plans_at) but the mixed conventions are still readable and consistent within their categories.
21 tools is slightly heavy but justified by the wide scope of municipal data (geocoding, parcels, zoning, crime, transit, environment). Each tool covers a distinct dataset, so the count feels appropriate for the server's purpose.
The tool surface covers a broad range of civic data with read-only queries, and list_datasets helps discover additional layers. Minor gaps exist (e.g., no generic query tool for arbitrary layers, no parks or building permits) but agents can work around these for most core workflows.