La Legislative
FreeNot checkedA read-only MCP server for querying Los Angeles City legislative data - Council Files, votes, member activity, and Neighborhood Council engagement - through par
About
A read-only MCP server for querying Los Angeles City legislative data - Council Files, votes, member activity, and Neighborhood Council engagement - through parameterized tools without raw SQL.
README
A read-only Model Context Protocol server (server name:
la-legislative) that exposes Los Angeles City legislative records — Council Files, votes, council-member activity, and Neighborhood Council engagement — to an MCP client (Claude Code / Claude Desktop) as a small set of fixed, typed, parameterized query tools.
You point Claude at your local database of LA City legislative data, and it can answer questions like "how did each council member vote on Council File 24-0764?" or "what housing files touched Council District 5 last year?" without ever writing SQL itself.
Why this design (Tier 1, not Tier 0)
This server does not let the model write raw SQL. That is a deliberate choice.
The underlying schema needs 5+ table joins to answer even basic questions (a single Council File's "dossier" pulls from projects, file_activities, file_activities_url, online_documents, project_movers, votes, council_members, and project_address). Letting an LLM improvise joins across that surface is exactly where you get silent wrong-join errors — answers that look right and are quietly wrong.
So this is a Tier 1 server: every capability is a hand-written, parameterized query behind a typed tool. The model chooses which tool and supplies typed parameters; it never composes the query. There is no raw-SQL tool.
Scope is intentionally narrow: personal, single-user, local only. No public access, no auth, no write operations, no hosting. It talks to a local PostgreSQL database (citywise_full) over stdio — there is no remote URL or HTTP endpoint. The MCP client launches it as a subprocess.
Project structure
legi-mcp/
├── .env # SQL_CONNECTION (gitignored — never commit)
├── .gitignore
├── requirements.txt
├── server.py # FastMCP instance + tool registration
├── db.py # connection pool + run_query helper
├── test_tools.py # standalone verification harness (not an MCP tool)
└── tools/
├── get_project.py
├── search_projects.py
├── get_votes.py
├── get_member_activity.py
└── get_nc_engagement.py
The tools
The server exposes five tools. All are read-only and take typed parameters.
get_project(project_id)
The full "dossier" for one Council File. Bundles the base record (including the aggregate vote tally vote_given), the activity timeline, attached documents, online documents, movers/seconders, individual per-member votes, and location/topic metadata (address, lat/long, topics, places).
project_id— the Council File number, e.g."24-0764".
Returns a dict keyed project, timeline, documents, online_documents, movers, votes, location. Returns {"error": ...} if the file is not found.
search_projects(keyword, council_district, status, date_from, date_to, topic, address, limit=25)
Advanced search for when you don't have a Council File number. All filters are optional and combined with AND. Returns lightweight rows.
keyword— matched (case-insensitive) against project name OR about text.council_district— exact council district integer.status— exact status value, e.g."passed","planned".date_from/date_to— inclusive ISO-date range onstart_date.topic— matched against topic metadata.address— matched against address metadata.limit— default 25, hard-capped server-side at 50.
Returns a list of {id, name, status, council_district, start_date, about}.
get_votes(project_id)
Just the per-member roll-call vote for one file.
project_id— the Council File number, e.g."24-0764".
Returns a list of {member_name, district, vote}.
get_member_activity(council_member, date_from, date_to)
What a council member moved/seconded and how they voted. The free-text name is resolved via case-insensitive match against name / first name / last name. If it matches 0 members it returns an error; if it matches 2 or more it returns a disambiguation list of candidates instead of guessing.
council_member— full or partial member name.date_from/date_to— optional inclusive ISO-date range on projectstart_date.
Returns {member, moved_or_seconded, votes} on a unique match, or a disambiguation/error dict otherwise.
get_nc_engagement(council_district, nc_name)
Neighborhood Council engagement (Community Impact Statement counts, date ranges) by district or by NC name. At least one parameter is required.
council_district— matches NCs whose territory overlaps the district; results carryoverlap_fractionandis_primary.nc_name— matched against NC name / normalized name;overlap_fractionandis_primaryare null in this mode.
Returns a list of {nc_name, council_member_name, cis_count, first_date, last_date, overlap_fraction, is_primary}. Capped at 200 rows.
Architecture
Built on the official MCP SDK's mcp.server.fastmcp.FastMCP. Each tool lives in its own module under tools/, and server.py imports and registers them on the FastMCP("la-legislative") instance. All database access is centralized in db.py.
db.py maintains one module-level psycopg2.pool.SimpleConnectionPool (min 1 / max 5) and exposes a single run_query(sql, params) helper that runs a parameterized query with a RealDictCursor and returns rows as dicts.
Security posture
This is a first-class part of the design, not an afterthought:
- Read-only DB role. The server connects only as a read-only Postgres role (
mcp_readonly). Write attempts are denied at the database level, not just by convention in the app code. - No string-built SQL. Every query uses
%splaceholders with a params tuple. There is zero f-string / string-formatted SQL anywhere in the codebase. Where filters are dynamic (e.g.search_projects), only the values are parameters — column names and clause fragments are hardcoded literals. - Server-enforced row caps. Every list-returning tool has a hard cap (
search_projects≤ 50,get_nc_engagement≤ 200) applied server-side regardless of what the caller requests. - No leaking internals. Errors surfaced to the model are always generic (
"Database query failed."). The real exception is logged to stderr only, so the raw error and the connection string — which contains the password — can never reach the caller.
How to replicate
Prerequisites: Python 3.10+ (built and tested on 3.13), PostgreSQL, and access to a populated citywise_full database. The database itself (the legislative data) is a prerequisite this repo does not create — this project is a query layer over an existing DB, not an ingestion pipeline.
1. Create the read-only DB role
Run once as a DB admin (in psql or pgAdmin). Choose a real password in place of the placeholder:
CREATE ROLE mcp_readonly WITH LOGIN PASSWORD '<choose-a-password>';
GRANT CONNECT ON DATABASE citywise_full TO mcp_readonly;
GRANT USAGE ON SCHEMA public TO mcp_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO mcp_readonly;
Then verify it is genuinely read-only — an INSERT or UPDATE as mcp_readonly must fail with a permission error:
-- Connect as mcp_readonly and try:
INSERT INTO projects (id) VALUES ('test');
-- Expected: ERROR: permission denied for table projects
2. Create .env
In the project root, create a .env file (already gitignored — never commit it) with one line:
SQL_CONNECTION="postgresql://mcp_readonly:<choose-a-password>@localhost:15432/citywise_full"
Adjust host / port / database to match your setup. The reference deployment uses port 15432.
3. Set up the virtualenv and install dependencies
python3 -m venv .venv
./.venv/bin/pip install -r requirements.txt
Dependencies (see requirements.txt for exact pins): the official mcp[cli] (>=1.2,<2), psycopg2-binary, and python-dotenv.
4. Verify against the live DB
Before wiring into any client, run the included harness. It exercises each tool function directly against your database and prints sample output (including proof that the search_projects limit cap holds and that member disambiguation triggers):
./.venv/bin/python test_tools.py
5. Register with the MCP client
Claude Code (CLI):
claude mcp add --transport stdio la-legislative -- /full/path/to/.venv/bin/python /full/path/to/server.py
claude mcp list # should show la-legislative ✔ Connected
Claude Desktop: add an entry under mcpServers in claude_desktop_config.json:
{
"mcpServers": {
"la-legislative": {
"command": "/full/path/to/legi-mcp/.venv/bin/python",
"args": ["/full/path/to/legi-mcp/server.py"]
}
}
}
Gotcha:
commandmust point at the Python binary inside the venv (.venv/bin/python), not at the.venvdirectory. Pointing at the directory causes a "Permission denied" spawn failure.
Gotchas / notes
.envis loaded by absolute path, not cwd.db.pyresolves.envrelative to the source file (os.path.dirname(os.path.abspath(__file__))), because the MCP client launches the server from its own working directory. A plain cwd-relativeload_dotenv()would silently fail to find.env. This is already handled — don't "simplify" it back to a bareload_dotenv().- Always use the venv's Python in the launch command, so
mcpandpsycopg2resolve. A systempython3will not have the dependencies. - Restart / reconnect the MCP client after any config change before expecting it to take effect.
Out of scope
By explicit design, this server does not include:
- Any write / insert / update tools.
- Hosting, auth, or multi-user access.
- A raw-SQL tool.
- Tools against
project_graphs/graph_typesorfile_activities_url.activity_file_summary— those are currently unpopulated in the source data, so nothing is built on them yet.
Installing La Legislative
This server has no published package — it is built from source. Open the repository and follow its README.
▸ github.com/Rehaan12345/legi-mcpFAQ
Is La Legislative MCP free?
Yes, La Legislative MCP is free — one-click install via Unyly at no cost.
Does La Legislative need an API key?
No, La Legislative runs without API keys or environment variables.
Is La Legislative hosted or self-hosted?
Self-hosted: the server runs locally on your machine via the install command above.
How do I install La Legislative in Claude Desktop, Claude Code or Cursor?
Open La Legislative on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.
Related MCPs
wenb1n-dev/SmartDB_MCP
A universal database MCP server supporting simultaneous connections to multiple databases. It provides tools for database operations, health analysis, SQL optim
by wenb1n-devPostgres Server
This server enables interaction with PostgreSQL databases through the Model Context Protocol, optimized for the AWS Bedrock AgentCore Runtime. It provides tools
by madhurprashPostgres
Query your database in natural language
by AnthropicPostgreSQL
Read-only database access with schema inspection.
by modelcontextprotocolCompare La Legislative with
Not sure what to pick?
Find your stack in 60 seconds
Author?
Embed badge for your README
Browse similar
All data MCPs
