State-level campaign finance, as an MCP server. Donors, expenditures, committees, and candidates from state disclosure systems — the data the FEC doesn't cover and that no MCP server currently exposes — through one common schema. Oklahoma first; more states via adapters.
Federal campaign finance (FEC) is wrapped to death. State campaign finance — where most money-in-politics questions actually live — is fragmented across ~46 portals with no unified API, and the one historical normalizer (FollowTheMoney) is winding down. The hard, valuable part isn't the MCP plumbing; it's the normalization layer that maps a messy state portal into a clean, sourced schema. That's the product.
The v1 demo runs on a committed synthetic Oklahoma extract (see the data note below), so a fresh checkout works with no network and no keys.
uv sync
uv run statefinance ingest ok # load the OK 2024 sample into the store
uv run statefinance top-donors "Tallchief for Oklahoma"
# Redbud Ranch LLC: $10,000
# Acme Energy, LLC: $5,000 (spelling variants combined)
# Patterson, John Q.: $1,500
uv run statefinance summary cycle 2024
# raised $30,650, spent $76,200, top recipient: Tallchief for Oklahoma
uv run statefinance donor-history "Patterson, John Q." # all of a donor's giving
uv run statefinance serve # run the MCP server (stdio){
"mcpServers": {
"statefinance": {
"command": "uv",
"args": ["run", "statefinance", "serve"],
"cwd": "/path/to/statefinance-mcp"
}
}
}| Tool | What it does |
|---|---|
search_contributions |
Filter contributions by donor, recipient, candidate, amount/date range, state, cycle. Returns matches + full-match count and total. |
search_expenditures |
Filter expenditures by committee, payee, purpose, date, state, cycle. |
get_committee |
A committee plus its contribution and expenditure totals. |
get_candidate |
A candidate (by id or name), their committees, and money raised/spent. |
top_donors |
Top donors to a committee, grouped by a light-normalized donor key. |
donor_history |
Every contribution by a donor, with a per-recipient breakdown. |
summary |
Aggregate totals for a committee, candidate, or cycle. |
Every result carries each record's source_url + as_of — this is accountability
data, so a wrong number is worse than no number.
state portal ─▶ StateAdapter.fetch() ─▶ raw snapshot (data/raw/<state>/)
StateAdapter.normalize() ─▶ common schema ─▶ DuckDB store
│
MCP server (reads the store only)
- Ingestion and serving are decoupled. Ingest populates a local normalized store; the MCP server only reads it, so tool calls are fast and portals stay un-hammered.
- Adding a state is implement
fetch+normalize, ship a fixture, register — core, store, and tools untouched. See docs/adding-a-state.md (enforced by a test that adds a second state through the adapter contract alone). - Donor normalization is light and honest (trim/case/whitespace, org suffixes). Fuzzy entity resolution is explicitly deferred — see docs/donor-normalization.md.
The committed Oklahoma sample is synthetic (fictional committees, candidates,
donors — clearly labeled), so the offline demo never asserts fabricated facts
about real people. Acquisition of real Oklahoma data and the ToS posture are
documented in docs/sources/oklahoma.md; live
ingestion is intentionally gated — enable ingest ok --live only after
confirming the portal's current export method and terms of service.
uv run pytest # offline suite (committed synthetic extract)
uv run ruff check . # lintIf uv run statefinance ever reports No module named 'statefinance' (a known
editable-install quirk on some setups, e.g. paths with spaces), run with
PYTHONPATH=src uv run statefinance ....
See SPEC.md and BUILD_PLAN.md for the design.
MIT.