Skip to main content
Glama
zenlyticgreg

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.