Skip to main content
Glama
nyc-council-mcp

nyc-council-mcp

README.md
# nyc-council-mcp

An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) 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](https://council.nyc.gov/legislation/api/) for authoritative real-time data.

Built by [BetaNYC](https://beta.nyc) with [Claude](https://claude.ai).

---

## API key

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_bill` current status, `get_upcoming_hearings`, confirmation queries) require a free **Legistar API key**. Register at [council.nyc.gov/legislation/api](https://council.nyc.gov/legislation/api/) — you'll receive a token by email — and set it as the `LEGISTAR_TOKEN` environment 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):

```bash
export LEGISTAR_TOKEN="your-legistar-token"
```

See [Setup](#setup) and [Environment variables](#environment-variables) for full details.

---

## Two-speed design

| 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.

---

## Tools

### Fast-path tools (local SQLite index)

These require `LEGISTAR_DB_PATH` and a built index (see [Setup](#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 |

### Confirm-path tools (live Legistar API)

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. `PersonWWW2` (the Legistar profile page) is checked: a known-bad upstream link is corrected from a small dated override list (`PERSON_WWW2_OVERRIDES` in `src/legistar.ts`, with the original kept in `PersonWWW2_upstream`), and a link shared by two people in one result gets a `PersonWWW2_warning` |
| `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). |

### `legistar_url` — the human-openable link, and why it's Introduction-only

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 public
  `LegislationDetail.aspx` page keys on a **separate** ID/GUID that appears
  **nowhere** in the OData record. So `LegislationDetail.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: OData `MatterId=78436` vs
  website `ID=8138338`; both the number and the GUID differ.)
- The one reliable bridge is **[intro.nyc](https://intro.nyc)** (Jehiah Czebotar's
  redirector, built on the same `nyc_legislation` archive): `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-2026` resolves 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 `MatterFile` starts with `Int `. Resolutions,
  LU, M, T, Oversight, etc. get `null`, because a wrong link is worse than no link.

---

## Prerequisites

- Node.js 18 or later
- **For live tools:** a free Legistar API key — [register here](https://council.nyc.gov/legislation/api/)
- **For local tools:** the `jehiah/nyc_legislation` archive (~920 MB, one-time clone)

---

## Setup

### Recommended: hybrid mode (both data sources)

**Step 1 — Get a Legistar API key**

Register at [council.nyc.gov/legislation/api](https://council.nyc.gov/legislation/api/). You'll receive a token by email.

**Step 2 — Clone the legislation archive**

```bash
git clone --depth 1 https://github.com/jehiah/nyc_legislation.git ~/legistar/nyc_legislation
```

This is about 920 MB and takes a few minutes. It only needs to be done once.

**Step 3 — Build the local index**

```bash
npx @betanyc/nyc-council-mcp index \
  --archive ~/legistar/nyc_legislation \
  --db ~/legistar/legistar.db \
  --verbose
```

This 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](#configuration) below.

---

### Live-only mode (no local index)

If you only want the live Legistar API tools and don't need local search, just set `LEGISTAR_TOKEN` and skip the archive clone.

---

### Local-only mode (no API key)

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.

---

## Keeping your index fresh

The archive is updated most weekdays. Run these two commands to pull the latest data and update your index:

```bash
# 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.db
```

You 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):**

```bash
# 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.db
```

**Force a full rebuild:**

```bash
npx @betanyc/nyc-council-mcp index \
  --archive ~/legistar/nyc_legislation \
  --db ~/legistar/legistar.db \
  --full
```

> **Note:** 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`).

---

## Configuration

### Claude Code (recommended)

Register at user scope so the server is available from any project:

```bash
claude mcp add nyc-council-mcp \
  --scope user \
  npx -- -y @betanyc/nyc-council-mcp serve
```

Then add your environment variables to `~/.claude.json` under the `mcpServers` entry:

```json
{
  "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.

### Claude Desktop

Add to `claude_desktop_config.json`:

```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"
      }
    }
  }
}
```

### Build from source

```bash
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 serve
```

---

## Index CLI reference

```
nyc-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.

**An archive with no matching files is an error, not an empty index.** If `--archive`
points at a directory where none of `people/*.json`, `introduction/**/*.json`,
`resolution/**/*.json`, `land_use/**/*.json` or `events/**/*.json` match, `index` exits
non-zero and leaves any existing database untouched. A source directory that exists but
matches nothing is reported on stderr. The build uses portable paths (including archive
paths with spaces, parentheses or brackets) and is tested in CI on Windows as well as Linux.

---

## Tool reference

### `search_bills` / `search_legislation`

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")
```

### `aggregate_bills`

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](https://github.com/BetaNYC/nyc-council-mcp/issues/30)).
> Each matter's `History[].Votes` is indexed into a `votes` table, so `vote_breakdown`,
> `get_voting_record`, and `get_bill_history` answer 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 the `votes` table 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 a `nyc-council-mcp index` rebuild. `get_votes` remains for live per-item positions.

### `vote_breakdown`

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 |

### `get_bill_history`

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` |

### `get_voting_record`

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 |

### `co_sponsors`

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 |

---

## Common workflows

### Explore a topic, then confirm

```
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
```

### Analyze a council member's record

```
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
```

### Track upcoming hearings

```
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.

---

## Agency snippets

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"`.

---

## Environment variables

| Variable | Required for | Notes |
|---|---|---|
| `LEGISTAR_TOKEN` | Live API tools | Register at [council.nyc.gov/legislation/api](https://council.nyc.gov/legislation/api/) |
| `LEGISTAR_DB_PATH` | Local SQLite tools | Path to your built `legistar.db` |
Do not commit tokens to version control.

---

## Data sources

- **Live tools:** [NYC Council Legistar API](https://council.nyc.gov/legislation/api/), provided by [Granicus](https://granicus.com/)
- **Local index:** [jehiah/nyc_legislation](https://github.com/jehiah/nyc_legislation) archive — has been mirroring Legistar since 2018, updated most weekdays

---

## Acknowledgments

This project builds on the foundational work of [@jehiah](https://github.com/jehiah), whose [nyc_legislation](https://github.com/jehiah/nyc_legislation) project has been mirroring NYC Council legislative data since 2018 and powers [intro.nyc](https://intro.nyc/).

The local SQLite index and agency snippet approach are adapted from [WillHsiaoNYC/legistar-mcp](https://github.com/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](https://www.civicaitools.org/directory).

---

## Releases

Publishing is automated. To cut a release:

1. Bump `version` in `package.json` in a PR (with a matching [CHANGELOG.md](CHANGELOG.md) entry).
2. Merge the PR.
3. Push the matching tag: `git tag v<version> && git push origin v<version>`.
4. The [release workflow](.github/workflows/release.yml) runs tests, verifies the tag matches `package.json` and `server.json`, publishes to npm with provenance, and creates a GitHub Release.
5. When it succeeds, [publish-mcp.yml](.github/workflows/publish-mcp.yml) lists that version in the [official MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.BetaNYC/nyc-council-mcp`. Bump `version` in `server.json` (both places) together with `package.json`.

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.

## About BetaNYC

This project is built and maintained by [BetaNYC](https://beta.nyc), 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](https://www.schoolofdata.nyc/)
and [CityCamp NYC](https://citycamp.nyc), and we host frequent civic-tech gatherings. See what's coming up on our
[events calendar](https://www.beta.nyc/events/).

**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](https://beta.nyc/donate).

## Building on this? Tell us!

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](https://beta.nyc/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](https://www.beta.nyc/newsletter-inbox/) and we'll consider it for
  an upcoming issue.

## Related BetaNYC MCP servers

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](https://beta.nyc/ai-tools)**.

This server pairs directly with:

- **[nyc-charter-laws-rules](https://github.com/BetaNYC/nyc-charter-laws-rules)**: trace a bill through to the Charter, Administrative Code, and Rules text it amends.
- **[nyc-budget-mcp](https://github.com/BetaNYC/New-York-City-Budget)**: connect Council members and legislation to the discretionary funding (Schedule C) they direct.

---

## Contributing

Issues and pull requests welcome at [github.com/BetaNYC/nyc-council-mcp](https://github.com/BetaNYC/nyc-council-mcp).

Before working on the code, read [`CONTEXT.md`](./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).

---

## Support our work

Freedom isn't free. [Support BetaNYC](https://beta.nyc/donate/).

## License

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.

TDQS

A3.8/5.0

Scored across 8 tools

Disambiguation3/5

The tools mostly target distinct resources (bill, history, hearings, member, committee, votes, recent legislation, search), but get_bill_history and get_votes both cover vote/roll-call information and their boundary is muddied by references to absent tools like vote_breakdown and 'local get_bill_history.' Inputs sometimes distinguish them (file number vs EventItemId), but the repeated local/live framing confuses a set that contains only live tools.

Naming Consistency5/5

All names use predictable snake_case with a leading verb: get_, list_, search_. Minor compound forms like get_bill_history and search_legislation_live are still readable and consistent with the pattern.

Tool Count5/5

Eight tools is well within the appropriate range for a read-only legislative lookup server. Each tool maps to a distinct resource or action, so the count itself is well-scoped.

Completeness2/5

The descriptions repeatedly direct agents to search_bills, vote_breakdown, and a local index that are not in this server, creating significant dead ends. In particular, get_votes requires an EventItemId, but the only in-set source is upcoming hearing agenda items; the description explicitly says live get_bill_history returns an event ID rather than an event item ID, leaving no reliable path to past roll-call votes.

Maintenance

ActivityMaintained
ResponsivenessOutdated data