portlandmaps-mcp
v0.2.5
Published
Unofficial Model Context Protocol server for the public PortlandMaps API (Portland, OR property, permit, zoning, sewer and parcel data). Not affiliated with or endorsed by the City of Portland.
Maintainers
Readme
PortlandMaps MCP Server
Standalone Model Context Protocol server for querying PortlandMaps /api/* endpoints from MCP clients such as Claude Desktop, Cursor, or Codex.
Unofficial / not affiliated. This is a community project that wraps the public PortlandMaps API. It is not affiliated with, authored by, or endorsed by the City of Portland or the PortlandMaps service. "PortlandMaps" is used here only to describe the upstream API this server connects to. Data is provided as-is; see the Important Caveat below.
- Registry:
io.github.GPTNowLLC/portlandmaps-mcpon the MCP Registry - npm:
portlandmaps-mcp
Install
Run the published server directly with npx (no clone/build needed):
PORTLANDMAPS_API_KEY="your-key" npx -y portlandmaps-mcpAdd it to an MCP client (Claude Desktop, Cursor, Codex, …):
{
"mcpServers": {
"portlandmaps": {
"command": "npx",
"args": ["-y", "portlandmaps-mcp"],
"env": {
"PORTLANDMAPS_API_KEY": "your-key"
}
}
}
}PORTLANDMAPS_API_KEY is the only required setting (see Setup for the
optional ones). For a from-source / HTTP / Vercel run instead, see the sections
further down.
Tools
suggest_portlandmaps_address- 1:1 wrapper for/api/suggest/.get_portlandmaps_detail- 1:1 wrapper for/api/detail/.get_assessor_data- 1:1 wrapper for/api/assessor/.get_permit_data- 1:1 wrapper for/api/permit/.search_landuse_cases- 1:1 wrapper for/api/landuse/.search_sewer_records- 1:1 wrapper for/api/sewer/.query_intersects- 1:1 wrapper for/api/intersects/.convert_geometry- 1:1 wrapper for/api/geometry/.lookup_agol- 1:1 wrapper for/api/agol/.list_detail_types- static catalog ofdetail_typevalues and expected identifier formats for/api/detail/.render_parcel_shape- returns an interactive UI component (mcp-uiUIResource) drawing a property's lot as inline SVG over a City of Portland aerial photo (showing the true roofline): the parcel outline, the building footprint(s) sitting on the lot, approximate setback dimensions, a north arrow, and faint neighbouring parcels for context. AcceptsdetailType+detailId(fetches geometry) or rawrings. Setbasemap: "none"to drop the photo (dark vector render) andincludeContext: falsefor just the bare outline.
The server intentionally avoids composite tools. Agents should compose endpoint calls themselves. render_parcel_shape is the one deliberate exception (see UI Components below).
All tools are read-only and carry MCP annotations (readOnlyHint, idempotentHint, openWorldHint) so hosts can treat them as safe reads. The server also advertises usage instructions in its initialize result, teaching clients the address → Taxlots Property ID → detail lookup chain.
Prompts
The server registers three prompts (surfaced as slash commands in capable hosts). Each takes a single address argument and emits a guidance message that steers the model through the resolve-then-look-up workflow — the model still drives the tools, so the prompts stay host-agnostic.
property-report— resolve an address, then compile assessment, zoning, and recent permits into a single report.show-parcel— resolve an address, then render its parcel viarender_parcel_shape.zoning— resolve an address, then explain its base zone, overlays, and key development standards.
UI Components (MCP Apps + mcp-ui)
render_parcel_shape renders a property's lot outline as an interactive UI
component. It targets two host families at once:
- MCP Apps hosts (Claude): the tool declares
_meta.ui.resourceUripointing at a registeredui://portlandmaps/parcel-view.htmltemplate. The host renders the template in a sandboxed iframe and forwards the tool result; the bundled client (@modelcontextprotocol/ext-apps) readsstructuredContent.ringsand draws the SVG client-side. - Legacy mcp-ui hosts: the same tool result also embeds a
ui://resource with the baked HTML (built via@mcp-ui/server), which those hosts render directly. MCP Apps hosts ignore this embedded resource.
The lot outline is drawn from geometry.rings (Web Mercator / EPSG:3857) with a
uniform scale (no distortion), Y-flip, and a cos²(latitude) area correction. The
render math (src/parcelSvg.ts) is shared between the server and the iframe: an
esbuild step (npm run build:template) bundles the client entry
(src/client/parcelApp.ts) into a self-contained template emitted to
src/parcelTemplate.generated.ts (regenerated by npm run build).
Context layers (footprints, neighbours, setbacks)
When includeContext is true (the default), the tool enriches the bare outline
with data from two public Portland ArcGIS REST layers on the same host — no
API key required — queried in the same EPSG:3857 frame so everything aligns
without reprojection (src/contextLayers.ts):
- Building Footprints —
od/rest/services/COP_OpenData_Property/MapServer/48. Footprints intersecting a window around the lot are fetched; each is classified on- or off-lot by a centroid-in-polygon test, and the on-lot ones are highlighted. The largest on-lot footprint is the "primary" structure and carriesBLDG_SQFT/NUM_STORY/YEAR_BUILTinto the caption. - Regional Taxlots —
arcgis/rest/services/Public/Taxlots/MapServer/0. Neighbouring lots are drawn faintly behind the subject for context.
Setbacks are the per-side gap (ground feet) between the primary building's bounding box and the lot's bounding box — a deliberately simple, honest approximation for the rectangular, street-aligned lots that dominate Portland; they are labelled "approx." and omitted when no on-lot building is found. The view frames on the subject lot (expanded ~45%) so a single oversized neighbour can't shrink it; geometry extending past the frame is clipped to the card.
Context fetching is best-effort: any failure (network, layer change, no
footprint on the lot) degrades gracefully to the plain parcel outline, so the
core render never breaks. Pass includeContext: false to skip the extra queries.
Aerial basemap (true roofline)
When basemap is "aerial" (the default), the tool draws a City of Portland
aerial photo behind the vectors so the property's real roofline is visible.
This matters because every available vector building-footprint layer returns a
generalized rectangle (the assessor outline), not the true (often L-shaped)
roof — the photo is the only honest way to show the actual structure.
- Source —
Public/Aerial_Photos_Multi_Year(the most-recent imagery available per location, citywide; no baked cadastral lines or labels), fetched via the MapServerexportoperation. Public, same host, no API key. - Self-contained & aligned — the tool fetches the photo server-side for
the exact EPSG:3857 projection window the vectors use (
fetchAerialBasemapinsrc/aerial.tsrequestsexportat the same expanded-lot bbox), then base64-encodes it and passes it throughstructuredContentas an inlinedata:URI. The sandboxed component renders<image href="data:image/jpeg…">; no externalhttp(s)ref ever reaches the iframe (safeBasemaprejects any non-data:URI as a hard security boundary). A faint dark scrim keeps the light vector strokes legible, and a "Aerial: City of Portland" caption credits the basemap. - Footprints over the photo — because the assessor footprint is coarser than
the photographed roof (and orthophotos lean elevated rooflines outward by a
metre or two), footprints over the aerial are drawn as thin dashed
schematics (no solid fill), so the real roof in the photo reads as the truth
while the dashes still mark the subject structure and anchor the setback
dimensions. In
basemap: "none"mode they remain solid filled shapes. - Best-effort — a failed or empty (no-coverage) export degrades silently to
the vector-only render. Pass
basemap: "none"to skip the fetch entirely.
To verify the live MCP Apps render locally, build (npm run build) and connect
the MCP Inspector to dist/index.js, then open the Apps tab and run
render_parcel_shape. Note: the Inspector's Apps sandbox uses an OAuth handshake
that 401s against a local stdio server, so launch it with auth disabled —
DANGEROUSLY_OMIT_AUTH=true npx @modelcontextprotocol/inspector node dist/index.js
— and the component renders as a live iframe. Pass rings directly to render
without an API call, or a detailId (e.g. detailType: "zoning",
detailId: "R337949") to fetch geometry.
ChatGPT (OpenAI Apps SDK) support is planned as a follow-on phase.
Setup
npm install
npm run buildOptional environment variables:
PORTLANDMAPS_API_KEY=...
PORTLANDMAPS_API_KEY_PARAM=api_key
PORTLANDMAPS_CACHE_TTL_MS=3600000PORTLANDMAPS_API_KEY_PARAM defaults to api_key. Change it if PortlandMaps expects a different query parameter for the key you are using.
PortlandMaps documents api_key as required for API access. Without a key, some endpoints may return HTTP 200 with an error payload; this server converts those payloads into MCP tool errors.
The issued PortlandMaps key is rate limited to 200 requests per 15 minutes. PortlandMaps returns:
X-Rate-Limit-LimitX-Rate-Limit-RemainingX-Rate-Limit-Reset
If the limit is exceeded, PortlandMaps returns HTTP 429. This server includes those rate-limit headers in HTTP error messages.
Local Stdio Run
npm run devFor MCP clients, use the built server over stdio:
{
"mcpServers": {
"portlandmaps": {
"command": "node",
"args": ["/absolute/path/to/portlandmaps-mcp/dist/index.js"],
"env": {
"PORTLANDMAPS_API_KEY": ""
}
}
}
}Versioned HTTP Run
Use this when you need a remote MCP URL for Claude's custom connector flow. The HTTP server exposes a versioned endpoint so breaking changes can be registered as a fresh connector without uninstalling an old one.
npm run build
PORTLANDMAPS_API_KEY="..." npm run start:httpDefaults:
- Health check:
http://localhost:8787/health - Versioned MCP endpoint:
http://localhost:8787/mcp/v2 - Compatibility MCP endpoint:
http://localhost:8787/mcp
Optional HTTP environment variables:
PORT=8787
MCP_VERSION=v2
MCP_MAX_BODY_BYTES=1048576 # cap on the request body buffered per call (default 1 MiB)
MCP_RATE_LIMIT_RPM=60 # per-client-IP request cap per window (default 60; 0 disables)
MCP_RATE_LIMIT_WINDOW_MS=60000 # sliding window length in ms (default 60s)
MCP_ICON_BASE_URL=https://portlandmaps-mcp.vercel.app # origin serving the icon assets (empty disables icons)
MCP_WIDGET_DOMAIN=https://portlandmaps-mcp.vercel.app # unique sandbox origin advertised for the parcel-view widgetRendered widget metadata (CSP + domain)
The one rendered tool (render_parcel_shape) ships its UI as the
ui://portlandmaps/parcel-view.html resource. App platforms expect that resource
to declare a Content Security Policy and a unique sandbox domain; ChatGPT,
for instance, shows "Widget CSP is not set" / "Widget domain is not set"
submission warnings until both are present. src/widgetMeta.ts builds this
_meta, advertised on both the resources/list entry and the resources/read
content item, in two key conventions at once: the MCP Apps standard
(_meta.ui.csp / _meta.ui.domain, consumed by Claude) and the OpenAI legacy
aliases (openai/widgetCSP / openai/widgetDomain, consumed by ChatGPT).
The CSP is a deliberately empty allowlist (no connect/resource origins):
the widget is fully self-contained — esbuild bakes the client into the template,
the aerial basemap is an inline data: URI (never an external ref), and it talks
to the host over postMessage — so it opens zero external connections. The domain
has no functional job (no cross-origin requests need a CORS/OAuth allowlist); it
exists only to give the app a unique, stable sandbox origin, which submission
requires. Override it for a self-hosted deployment with MCP_WIDGET_DOMAIN
(defaults to the Vercel origin).
Server icon / branding
The server advertises an icon set (serverInfo.icons) so capable clients — e.g.
Claude custom connectors — and the MCP Registry listing can display a logo. The
images live in public/ and are served at the site root (/favicon.svg,
/favicon.png, /favicon-32x32.png, /favicon-16x16.png,
/apple-touch-icon.png, plus a generated /favicon.ico and an index.html
landing page). Per the MCP spec, each icon src must be an absolute HTTPS URL
hosted on the deployment (a data: URI is not allowed), so MCP_ICON_BASE_URL
must point at wherever public/ is served — it defaults to the Vercel domain. On
Vercel the assets are served automatically from the output directory; the local
dev:http / start:http server serves the same files from public/. The
registry copy of this list lives in server.json under icons.
To rebrand: drop replacement images into public/ (keep the filenames, or update
both src/icons.ts and server.json).
Public endpoint hardening
The HTTP endpoints are unauthenticated — anyone with the URL can call the
read-only PortlandMaps tools using the server's PORTLANDMAPS_API_KEY. If you
publish the URL (e.g. as a remotes entry in the MCP Registry or a public Claude
connector), treat it as a free public proxy on your key and add guardrails:
- Rate limiting (built in): every POST is throttled per client IP before
any upstream PortlandMaps call, so a throttled request never burns your key
quota. Tune with
MCP_RATE_LIMIT_RPM/MCP_RATE_LIMIT_WINDOW_MS; setMCP_RATE_LIMIT_RPM=0to disable. Blocked callers get HTTP429+Retry-After. Note: on serverless the limiter is per warm instance, so it bounds — but does not globally cap — traffic. Pair it with platform rate limiting (below). - On Vercel: enable a Spend Management cap, turn on Vercel Firewall rate-limit rules and/or BotID, and use a dedicated, rotatable PortlandMaps key so abuse is recoverable by rotating one credential.
For Claude custom connectors, deploy this HTTP server behind HTTPS and use:
https://your-host.example/mcp/v2Do not put the PortlandMaps API key in Claude's connector UI. Keep it as a server-side environment variable on the host running this server.
Vercel Deploy
The repo ships with Vercel serverless functions in api/ so the same MCP server can be deployed without running a long-lived process. Each request creates a transport (sessionless mode), so it maps cleanly to a function invocation.
npm i -g vercel # one-time
vercel link # link to a project
vercel env add PORTLANDMAPS_API_KEY # paste your PortlandMaps key
vercel deploy --prodAfter deploy, the endpoints are:
https://<project>.vercel.app/healthhttps://<project>.vercel.app/mcp/v2(versioned, use this for new connectors)https://<project>.vercel.app/mcp(compatibility alias)
Optional env vars: MCP_VERSION, PORTLANDMAPS_API_KEY_PARAM, PORTLANDMAPS_CACHE_TTL_MS, MCP_RATE_LIMIT_RPM, MCP_RATE_LIMIT_WINDOW_MS, MCP_ICON_BASE_URL, MCP_WIDGET_DOMAIN. Note that both the in-memory cache (PORTLANDMAPS_CACHE_TTL_MS) and the in-memory rate limiter do not persist/share state across function instances on Vercel — see Public endpoint hardening and pair the built-in limiter with Vercel Firewall + a Spend Management cap when exposing the URL publicly.
Example Prompts
Ask your MCP client:
Use suggest_portlandmaps_address for 4521 SE Belmont St, then use get_portlandmaps_detail with the returned detail_type/detail_id and sections='list'.Use query_intersects with include=['row','describe','taxlot'] for this Web Mercator point and explain what right-of-way data came back.Development Notes
- The server uses stdio transport, which is the normal local MCP integration path.
- Responses include
structuredContentwhen supported by the MCP client and a JSON text fallback for all clients. - HTTP responses are cached in memory for one hour by default.
- Avoid broad spatial queries. Prefer address/property ID lookups and bounded geometry queries.
Publishing to the MCP Registry
This repo is set up to publish to the official MCP Registry.
The registry entry is described by server.json; its name
(io.github.GPTNowLLC/portlandmaps-mcp) matches the mcpName field in
package.json, which is how the registry verifies npm ownership.
To cut a release (maintainers):
# 1. Bump the version in BOTH package.json and server.json (keep them in sync),
# then publish the artifact to npm. prepublishOnly runs the build for you.
npm publish
# 2. Install the publisher CLI once (Homebrew shown; see registry docs for others)
brew install mcp-publisher
# 3. Authenticate as someone with access to the GPTNowLLC GitHub org, then publish
mcp-publisher login github
mcp-publisher publish
# 4. Verify it landed
curl "https://registry.modelcontextprotocol.io/v0/servers?search=io.github.GPTNowLLC/portlandmaps-mcp"The three versions — package.json, server.json (top level), and
server.json's packages[0].version — must all match the version you published
to npm, or the registry rejects the publish.
Important Caveat
This project gathers public data and computes rough planning inputs. It does not determine code compliance, replace Portland BDS review, or verify current ADU/tiny house rules.
Troubleshooting
Claude Desktop MCP is located here: ~/Library/Application Support/Claude/claude_desktop_config.json
