census-mcp-server
by zenlyticgreg
README.md
# census-mcp-server
Production-grade [Model Context Protocol](https://modelcontextprotocol.io) server for the
[U.S. Census Bureau Data API](https://www.census.gov/data/developers/data-sets.html), backed by a
PostgreSQL cache layer for FIPS geography mappings and variable metadata.
## Tools
| Tool | Purpose |
| --- | --- |
| `get_census_data` | Fetch ACS 5-Year / Decennial data with multi-variable chunked batching and geographic nesting (e.g. all tracts in a county). |
| `search_census_variables` | Full-text search over cached variable metadata (labels, concepts, table codes). |
| `resolve_geography` | Fuzzy-resolve place names → FIPS codes via a lazily hydrated `pg_trgm` cache. |
**Live deployment**: `https://census-mcp-server-production-4b30.up.railway.app/mcp` (streamable HTTP,
Railway, bearer-token protected — see [Deployed on Railway](#deployed-on-railway) below).
All tools support `response_format: 'markdown' | 'json'`, return structured content alongside text,
and are annotated read-only/idempotent.
### Supported datasets
| id | Dataset | Vintages |
| --- | --- | --- |
| `acs5` | ACS 5-Year Detailed Tables | 2009–2023 |
| `acs5_profile` | ACS 5-Year Data Profiles | 2010–2023 |
| `acs5_subject` | ACS 5-Year Subject Tables | 2010–2023 |
| `dec_pl` | Decennial Redistricting (P.L. 94-171) | 2000, 2010, 2020 |
| `dec_dhc` | Decennial DHC | 2020 |
| `dec_sf1` | Decennial Summary File 1 | 2000, 2010 |
## Architecture
```
src/
├── index.ts Bootstrap + transports (stdio / streamable HTTP)
├── server.ts McpServer factory (composition root)
├── config/env.ts Zod-validated environment config (fail-fast)
├── constants.ts Limits, sentinels, state abbreviations
├── errors/census-error.ts CensusError taxonomy + remediation hints
├── http/census-http-client.ts fetch wrapper: retries, backoff+jitter, timeouts
├── logging/logger.ts pino → stderr (stdout reserved for MCP frames)
├── schemas/tool-schemas.ts Zod input/output schemas for every tool
├── tools/ Controllers: schema ↔ service ↔ response shaping
├── services/ CensusApi / CensusData / Geography / VariableSearch / Database
├── repositories/ SQL access: geographies, variables, ingestions
├── db/ Embedded migrations + advisory-locked runner + CLI
└── utils/ Bounded concurrency, name normalization, Markdown
```
Design notes:
- **Resilience** — upstream 429/5xx/timeouts retry with exponential backoff + full jitter,
honoring `Retry-After`. Errors map onto a typed `CensusError` taxonomy (rate limits, invalid
FIPS, invalid variables, missing API key, ...) with agent-actionable hints.
- **Chunked batching** — variable lists split into API-sized chunks (48/request), fetched with
bounded concurrency, and merged on geography keys. Cache hydration writes in 1,000-row
batched `unnest()` upserts. Responses are row-capped (`max_rows`) and character-capped with
explicit truncation flags.
- **Cache layer** — geography names (~32k places) and variable catalogs (~28k entries) hydrate
lazily on first use, then serve locally: trigram-indexed fuzzy search for names, GIN-indexed
full-text search for variables. Migrations are embedded, idempotent, and serialized with a
PostgreSQL advisory lock.
## Quickstart (local, stdio)
Requires Node 20+ and a reachable PostgreSQL 14+ instance.
```bash
npm install # also generates package-lock.json (needed for Docker builds)
cp .env.example .env # set CENSUS_API_KEY and DATABASE_URL
npm run build
npm run db:migrate # optional; the server also migrates at startup
npm run start:local # loads .env via node --env-file
```
(`npm start` runs without loading `.env` — it expects the environment to be
provided externally, as in Docker or an MCP client config.)
Get a free API key at <https://api.census.gov/data/key_signup.html>.
### Claude Desktop / Claude Code registration
```json
{
"mcpServers": {
"census": {
"command": "node",
"args": ["/absolute/path/to/census-mcp-server/dist/index.js"],
"env": {
"CENSUS_API_KEY": "your-key",
"DATABASE_URL": "postgres://census:change_me@localhost:5432/census_cache"
}
}
}
}
```
## Docker deployment (streamable HTTP)
```bash
npm install # generate package-lock.json before the first image build
cp .env.example .env # set CENSUS_API_KEY and POSTGRES_PASSWORD
docker compose up --build -d
curl http://localhost:3000/healthz
```
- Multi-stage build: TypeScript compiles in a build stage; the runtime stage ships only
production `node_modules` + `dist`, running as a non-root `mcp` user.
- Healthchecks: `pg_isready` for PostgreSQL; a Node probe against `/healthz` (which verifies
database connectivity) for the server. The server only starts after PostgreSQL is healthy.
- Network isolation: PostgreSQL lives on an internal-only network with no published ports;
data persists in the `census_pgdata` volume.
- MCP endpoint: `POST http://localhost:3000/mcp` (stateless JSON mode).
## Scripts
| Command | Description |
| --- | --- |
| `npm run build` | Compile TypeScript to `dist/` |
| `npm start` | Run the compiled server (env provided externally) |
| `npm run start:local` | Run the compiled server, loading `.env` |
| `npm run dev` | Watch-mode development server (tsx) |
| `npm run lint` | ESLint (strict: no `any`, explicit return types) |
| `npm run typecheck` | `tsc --noEmit` |
| `npm run db:migrate` | Apply pending schema migrations and exit |
## Example workflow
1. `resolve_geography` — `{ "name": "San Francisco", "level": "county", "state": "CA" }`
→ `state_fips: "06"`, `geo_code: "075"`.
2. `search_census_variables` — `{ "query": "median household income" }` → `B19013_001E`.
3. `get_census_data` —
```json
{
"variables": ["B19013_001E", "B01001_001E"],
"geography": {
"level": "tract",
"codes": ["*"],
"within": [
{ "level": "state", "code": "06" },
{ "level": "county", "code": "075" }
]
}
}
```
## Deployed on Railway
The server is deployed at project `census-mcp-server` in Railway (workspace: Greg Peters's
Projects), built directly from this repo's `Dockerfile` on every push to `master`:
- **App service** `census-mcp-server` — env vars `TRANSPORT=http`, `PORT=3000`, `LOG_LEVEL=info`,
`CENSUS_API_KEY`, `MCP_AUTH_TOKEN`, and `DATABASE_URL` set as a Railway reference variable
(`${{Postgres.DATABASE_URL}}`) pointing at the sibling Postgres service — so the cache survives
redeploys and the connection string is never duplicated.
- **Postgres service** — managed Railway PostgreSQL; migrations run automatically at container
startup (see `src/db/migration-runner.ts`).
- **Public domain** — a generated `*.up.railway.app` domain targets container port 3000.
`/mcp` requires `Authorization: Bearer <MCP_AUTH_TOKEN>`; `/healthz` is open for Railway's
health probes.
To redeploy: push to `master` (Railway auto-builds), or run `railway redeploy --service
census-mcp-server --yes --from-source` to force a fresh pull. To rotate secrets: `railway
variable set CENSUS_API_KEY=<new-key> --service census-mcp-server` (a set triggers a redeploy
unless `--skip-deploys` is passed).
## Security notes
- The API key is only read from the environment, appended per-request, and redacted from all
log output.
- Every tool input is validated by Zod schemas (strict bounds and patterns) before any I/O.
- Internal errors are logged server-side and never leaked to MCP clients verbatim.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing