An MCP (Model Context Protocol) server for NYC Council legislative data. Version 2 adds a hybrid mode: a local SQLite index for sub-second search and exploration, plus the live NYC Legistar API for authoritative real-time data.
A free API key is optional — it unlocks the live Legistar API tools, but the server also runs in local-only mode without one.
- Live tools (
get_billcurrent status,get_upcoming_hearings, confirmation queries) require a free Legistar API key. Register at council.nyc.gov/legislation/api — you'll receive a token by email — and set it as theLEGISTAR_TOKENenvironment variable. - Local fast-path tools (search, browse, voting history, aggregation) need no key — they read the local SQLite index at
LEGISTAR_DB_PATH.
You must set at least one of LEGISTAR_TOKEN or LEGISTAR_DB_PATH; set both for full hybrid mode. Example (live tools):
export LEGISTAR_TOKEN="your-legistar-token"See Setup and Environment variables for full details.
| Path | Speed | Data | Use for |
|---|---|---|---|
| Local index (SQLite) | < 1 second | As fresh as your last index run | Search, browse, voting history, aggregation |
| Live API (Legistar) | 1–5 seconds | Real-time | Current status, upcoming hearings, confirmation |
Use both together for the best experience. Search locally to explore; confirm with the live API when accuracy matters.
These require LEGISTAR_DB_PATH and a built index (see Setup).
| Tool | Description |
|---|---|
search_bills |
Full-text search across all bills and resolutions |
search_legislation |
Alias for search_bills |
search_events |
Full-text search across committee hearings |
list_committees |
All committees with bill and event counts |
recent_bills |
Bills introduced in the last N days |
upcoming_events |
Scheduled events in the next N days |
aggregate_bills |
Count bills grouped by status, type, committee, or year |
vote_breakdown |
Every member's vote on a bill (by type-prefixed file number), grouped by roll call — committee and full Council — each with a positions-only tally. Indexed from the archive's History[].Votes |
get_voting_record |
All votes cast by a named council member, newest first |
get_bill_history |
A matter's action trail — hearings, referrals, committee/Council actions, outcomes — in chronological order, by file number. No token required |
co_sponsors |
Members who most often co-sponsor with a given member |
get_bill_hearings |
Events where a bill appeared on the agenda, with each event's date, body, and action |
get_event_bills |
Bills on a specific event's agenda |
These require LEGISTAR_TOKEN.
| Tool | Description |
|---|---|
get_bill |
Current status and record for a bill. A bare NNNN-YYYY number is ambiguous (it can be both an Int and a Res); an ambiguous match is rejected with an error naming every candidate, so re-call with the exact type-prefixed number |
get_bill_history |
Local by default (served from the index, no token; takes a file number). In live-only mode this live variant answers instead — authoritative action trail by numeric matter id |
get_upcoming_hearings |
Real-time upcoming committee hearings and Stated meetings, each with its agenda items (bills/matters) in EventItems |
get_council_member |
Current contact info and active status for a council member |
get_committee |
Current membership count and details for a committee |
get_votes |
Per-item vote breakdown by event item ID |
list_recent_legislation |
Most recently introduced legislation (catches bills since last index) |
search_legislation_live |
Live Legistar search (slower than local, always current). Multi-word queries match all words in any order; quote a "phrase" for adjacency. No relevance ranking upstream — results are ordered by intro date (order='date_asc' for oldest-first). |
Bill/matter records include a legistar_url field: a link a person can click to
open the matter on Legistar. It is populated only for Introductions and is
null for every other type. Here's why — so nobody re-attempts the obvious-looking
fix:
- NYC runs two independent Legistar backends with different ids. The OData
WebAPI this server reads exposes
MatterId/MatterGuid; the publicLegislationDetail.aspxpage keys on a separate ID/GUID that appears nowhere in the OData record. SoLegislationDetail.aspx?ID={MatterId}&GUID={MatterGuid}returns "Invalid parameters!" — there is no formula from the OData id to the website id. (Verified 2026-07-16 for Int 0976-2026: ODataMatterId=78436vs websiteID=8138338; both the number and the GUID differ.) - The one reliable bridge is intro.nyc (Jehiah Czebotar's
redirector, built on the same
nyc_legislationarchive):intro.nyc/{NNNN-YYYY}302-redirects to the correct Legistar page. But it keys on the bare number and assumes type = Introduction —intro.nyc/0052-2026resolves to Int 0052-2026, never the same-numbered M (Land Use Call-Up). There is no type-prefixed slug. - Therefore a link is emitted only when
MatterFilestarts withInt. Resolutions, LU, M, T, Oversight, etc. getnull, because a wrong link is worse than no link.
- Node.js 18 or later
- For live tools: a free Legistar API key — register here
- For local tools: the
jehiah/nyc_legislationarchive (~920 MB, one-time clone)
Step 1 — Get a Legistar API key
Register at council.nyc.gov/legislation/api. You'll receive a token by email.
Step 2 — Clone the legislation archive
git clone --depth 1 https://github.com/jehiah/nyc_legislation.git ~/legistar/nyc_legislationThis is about 920 MB and takes a few minutes. It only needs to be done once.
Step 3 — Build the local index
npx @betanyc/nyc-council-mcp index \
--archive ~/legistar/nyc_legislation \
--db ~/legistar/legistar.db \
--verboseThis takes roughly 15 seconds and produces a legistar.db file of about 900 MB (it indexes ~21,500 matters, ~17,300 events, and ~509,000 roll-call vote rows).
Step 4 — Configure your MCP client
Add both environment variables to your MCP config. See Configuration below.
If you only want the live Legistar API tools and don't need local search, just set LEGISTAR_TOKEN and skip the archive clone.
If you only want fast local search (no real-time data), set LEGISTAR_DB_PATH and skip the API key. Note: get_upcoming_hearings, get_bill (current status), and similar live tools will be unavailable.
The archive is updated most weekdays. Run these two commands to pull the latest data and update your index:
# Pull the latest archive files
cd ~/legistar/nyc_legislation && git pull
# Rebuild incrementally (only processes new/changed files — takes seconds after the first build)
npx @betanyc/nyc-council-mcp index \
--archive ~/legistar/nyc_legislation \
--db ~/legistar/legistar.dbYou never need to wait for a BetaNYC-hosted snapshot. Incremental mode (the default) is fast enough to run manually whenever you want fresh data.
Automated daily updates (cron):
# Add to your crontab: daily at 6am
0 6 * * * cd ~/legistar/nyc_legislation && git pull && npx @betanyc/nyc-council-mcp index --archive ~/legistar/nyc_legislation --db ~/legistar/legistar.dbForce a full rebuild:
npx @betanyc/nyc-council-mcp index \
--archive ~/legistar/nyc_legislation \
--db ~/legistar/legistar.db \
--fullNote: The local index is only as current as your last index run — run the daily cron above and it stays about a day behind live Legistar; leave it for a month and it is a month stale. The upstream archive itself updates most weekdays. For current status and upcoming hearings, use the live API tools (
get_bill,get_upcoming_hearings).
Register at user scope so the server is available from any project:
claude mcp add nyc-council-mcp \
--scope user \
npx -- -y @betanyc/nyc-council-mcp serveThen add your environment variables to ~/.claude.json under the mcpServers entry:
{
"mcpServers": {
"nyc-council-mcp": {
"command": "npx",
"args": ["-y", "@betanyc/nyc-council-mcp", "serve"],
"env": {
"LEGISTAR_TOKEN": "your_token_here",
"LEGISTAR_DB_PATH": "/Users/you/legistar/legistar.db"
}
}
}
}Set only LEGISTAR_TOKEN for live-only mode, or only LEGISTAR_DB_PATH for local-only mode.
Add to claude_desktop_config.json:
{
"mcpServers": {
"nyc-council": {
"command": "npx",
"args": ["-y", "@betanyc/nyc-council-mcp", "serve"],
"env": {
"LEGISTAR_TOKEN": "your_token_here",
"LEGISTAR_DB_PATH": "/Users/you/legistar/legistar.db"
}
}
}
}git clone https://github.com/BetaNYC/nyc-council-mcp.git
cd nyc-council-mcp
npm install && npm run build
LEGISTAR_TOKEN=your_token LEGISTAR_DB_PATH=./legistar.db node dist/index.js servenyc-council-mcp index [options]
Options:
--archive <path> Path to jehiah/nyc_legislation clone (required)
--db <path> SQLite output path (default: $LEGISTAR_DB_PATH or ./legistar.db)
--full Force a full rebuild (default: incremental)
--verbose Print progress to stderr
--help Show help
Incremental mode upgrades itself to a full rebuild when it has to. Even without
--full, the indexer rebuilds from scratch if either has changed since your last run:
- the schema version — the corpus was built by an older release whose tables differ, or
- the set of indexed sources — the indexer now walks matter trees it did not before.
This matters because an incremental run skips files by modification time, so widening
coverage would otherwise leave older files permanently unindexed. When a trigger fires,
--verbose names it and the run drops and recreates the data tables before indexing.
Upgrading to 2.6.0 fires the schema trigger once, so your first index after upgrading
takes a full rebuild (~15 seconds) and picks up roll-call votes and matter history.
Full-text search across the local index, which covers Introductions,
Resolutions, and Land Use Applications. Multi-word queries match matters
containing all the words in any order; quote a "phrase" to require adjacency.
Upgrading from ≤ 2.1.1: Resolutions and Land Use matters were previously absent from the index. Re-run
npx @betanyc/nyc-council-mcp index …after updating to pick them up.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
query |
string | yes | — | Search terms |
limit |
number | no | 25 | Max results (max 100) |
agency |
string | no | — | Agency key or name for role-context snippets (e.g. DEP, NYPD) |
status |
string | no | — | Filter by status (e.g. Enacted, Laid Over) |
committee |
string | no | — | Filter by committee name |
search_bills("open data")
search_bills("bicycle lane", agency="DOT", status="Enacted")
search_bills("tenant protection", committee="Housing")
Count bills grouped by a dimension.
| Parameter | Type | Required | Options |
|---|---|---|---|
group_by |
string | yes | status, type, committee, year |
aggregate_bills(group_by="status")
aggregate_bills(group_by="year")
Roll calls are indexed locally (#30). Each matter's
History[].Votesis indexed into avotestable, sovote_breakdown,get_voting_record, andget_bill_historyanswer from the corpus with no token. A member's entry is a position (Affirmative / Negative / Abstain) or a non-position reason for not casting one (Absent, Excused, Medical …); both are stored, but a tally counts positions only — an Absent member is never scored as a vote. If thevotestable is empty the tools still raise a named error rather than return[](which reads as "cast no votes"); that means the corpus predates roll-call indexing and needs anyc-council-mcp indexrebuild.get_votesremains for live per-item positions.
Every member's vote on a matter, grouped by roll call (committee and full Council
appear separately), each with a positions-only tally. Res 0540-2026 returns its
Council roll call (45–6, 51 recorded) and its Committee on Finance roll call (14–1,
17 recorded — the tally is 14–1, not 17).
| Parameter | Type | Required | Description |
|---|---|---|---|
file_number |
string | yes | Type-prefixed file number, e.g. Res 0540-2026. A bare NNNN-YYYY that matches both an Int and a Res is rejected, naming each |
A matter's action trail in chronological order — introduced, referred, heard, amended,
approved, enacted — one row per action, with the acting body and date. Served from the
local matter_history table, so it needs no token. Each action also carries the
event_item_id of the meeting item it happened at, which is the id get_votes takes if
you want to confirm a roll call against the live API.
| Parameter | Type | Required | Description |
|---|---|---|---|
file_number |
string | yes | Type-prefixed file number, e.g. Int 1122-2024 |
All votes cast by a council member.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
member_name |
string | yes | — | Full or partial name |
limit |
number | no | 50 | Max results |
Members who most frequently co-sponsor bills with a given member.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
member_name |
string | yes | — | Full or partial name |
limit |
number | no | 20 | Top N co-sponsors |
1. search_bills("e-bike") → sub-second results from local index
2. get_bill("Int 0042-2024") → authoritative current status from live API
3. get_bill_history("Int 0042-2024") → full action trail from the local index (no token)
4. vote_breakdown("Int 0042-2024") → how each member voted, per roll call, from the local index
1. get_voting_record("Nurse") → all votes cast
2. co_sponsors("Nurse") → frequent co-sponsors
3. search_bills("bicycle", committee="Transportation") → bills in their area
1. upcoming_events(days=14) → from local index (as fresh as your last index run)
2. get_upcoming_hearings() → from live API (real-time)
get_upcoming_hearings populates each event's EventItems array with the agenda
items (the bills/matters on that hearing's agenda), fetched per event from the
Legistar /events/{EventId}/eventitems endpoint. The /events list endpoint
itself always returns EventItems empty and ignores $expand, so the follow-up
call is required. Pass include_agenda=false to skip it and return the hearing
schedule faster with EventItems empty.
When you pass agency to search_bills, results include a role-context snippet showing HOW the agency appears in the bill — whether it is directed, consulted, or reporting. This helps distinguish bills that merely mention an agency from those that grant or restrict its authority.
Supported agency keys: DEP, DOT, NYPD, FDNY, DOB, HPD, HRA, DSS, ACS, DOHMH, DHS, DCAS, DSNY, DPR, DCP, FINANCE, LAW, MAYOR, COMPTROLLER, MTA, EDC, SBS, DCWP, DYCD, DFTA, DOE, CUNY, and more.
You can also pass a full name: agency="department of transportation" or agency="sanitation".
| Variable | Required for | Notes |
|---|---|---|
LEGISTAR_TOKEN |
Live API tools | Register at council.nyc.gov/legislation/api |
LEGISTAR_DB_PATH |
Local SQLite tools | Path to your built legistar.db |
| Do not commit tokens to version control. |
- Live tools: NYC Council Legistar API, provided by Granicus
- Local index: jehiah/nyc_legislation archive — has been mirroring Legistar since 2018, updated most weekdays
This project builds on the foundational work of @jehiah, whose nyc_legislation project has been mirroring NYC Council legislative data since 2018 and powers intro.nyc.
The local SQLite index and agency snippet approach are adapted from WillHsiaoNYC/legistar-mcp, which introduced the two-speed architecture and role-context snippet design that v2 implements in TypeScript.
Thank you to Nathan Storey for including this project in the Civic AI Tools Directory.
Publishing is automated. To cut a release:
- Bump
versioninpackage.jsonin a PR (with a matching CHANGELOG.md entry). - Merge the PR.
- Push the matching tag:
git tag v<version> && git push origin v<version>. - The release workflow runs tests, verifies the tag matches
package.json, publishes to npm with provenance, and creates a GitHub Release.
Prerequisite: the NPM_TOKEN org secret (an npm automation token with publish rights on @betanyc) must be configured. Do not run npm publish by hand.
This project is built and maintained by BetaNYC, New York's civic technology and open-data community. We work to improve lives in New York through civic design, technology, data, and public-interest technology.
Come do civic tech with us. We run public events, meetups, and hands-on data classes throughout the year — including NYC School of Data and CityCamp NYC, and we host frequent civic-tech gatherings. See what's coming up on our events calendar.
Sustain this work. These MCP servers are free and open source. To help keep this work going and find BetaNYC's tools, please consider donating and becoming a Beta Builder.
If you build something with this project, we'd love to hear about it. We can help other New Yorkers find it. BetaNYC publishes a weekly newsletter, This Week in NYC's Civic Technology and Open Data.
- Subscribe to the newsletter to keep up with NYC civic tech, open data, and public-interest technology.
- Built something, or found a story worth sharing? Submit a link for the newsletter and we'll consider it for an upcoming issue.
BetaNYC maintains a suite of open-source MCP servers for NYC and NYS civic data. See the full directory, with install details for each, at beta.nyc/ai-tools.
This server pairs directly with:
- nyc-charter-laws-rules: trace a bill through to the Charter, Administrative Code, and Rules text it amends.
- nyc-budget-mcp: connect Council members and legislation to the discretionary funding (Schedule C) they direct.
Issues and pull requests welcome at github.com/BetaNYC/nyc-council-mcp.
Before working on the code, read CONTEXT.md — the project's glossary. It
pins down the distinctions this domain punishes you for blurring: a matter versus a
bill versus a file number (which is not unique without its type prefix), an event
versus an event item (different id spaces), and a position versus a non-position in a
roll call (both live in the same field, so a tally must filter rather than count rows).
Freedom isn't free. Support BetaNYC.
MIT License — Copyright (c) 2026 BetaNYC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.