va-lis-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jonny Fuller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,285 @@
1
+ Metadata-Version: 2.4
2
+ Name: va-lis-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for the Virginia Legislative Information System (LIS) API
5
+ Author-email: Jonny Fuller <mr.waffles@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/JonnyWaffles/va-lis-mcp
8
+ Project-URL: Repository, https://github.com/JonnyWaffles/va-lis-mcp
9
+ Project-URL: Bug Tracker, https://github.com/JonnyWaffles/va-lis-mcp/issues
10
+ Keywords: virginia,legislature,LIS,legislation,API,MCP,model context protocol
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: mcp<3,>=2
23
+ Requires-Dist: va-lis-client<0.4,>=0.3.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8.2; extra == "dev"
26
+ Requires-Dist: pytest-asyncio>=1; extra == "dev"
27
+ Requires-Dist: ruff>=0.14; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # va-lis-mcp
31
+
32
+ An MCP server for the **Virginia Legislative Information System (LIS) REST
33
+ API**. It exposes the Virginia General Assembly's sessions, bills, summaries,
34
+ full bill text, action history, votes and roll calls, members, committees,
35
+ and meetings to any MCP client (Claude Code, Claude Desktop, and others).
36
+ Every tool is read only. You bring your own LIS API key.
37
+
38
+ Built on [va-lis-client](https://pypi.org/project/va-lis-client/), a Python
39
+ client for the LIS API.
40
+
41
+ > **Status: not yet on PyPI.** The `uvx va-lis-mcp` snippets below are written
42
+ > for the first release. Until then, install from a source checkout (see
43
+ > [Installing from source](#installing-from-source)).
44
+
45
+ ## Requirements
46
+
47
+ - Python 3.10 or newer
48
+ - [uv](https://docs.astral.sh/uv/), which provides the `uvx` launcher used
49
+ below. Install it with one line:
50
+ `curl -LsSf https://astral.sh/uv/install.sh | sh` (macOS/Linux) or
51
+ `powershell -c "irm https://astral.sh/uv/install.ps1 | iex"` (Windows)
52
+ - A LIS API key (next section)
53
+
54
+ ## Getting an API key
55
+
56
+ Register for a free partner key at
57
+ <https://lis.virginia.gov/apiregistration>. The key is a GUID. The server
58
+ reads it from the `LIS_API_KEY` environment variable.
59
+
60
+ LIS heartbeat endpoints accept any key, even garbage, so a successful ping
61
+ proves nothing. Use the `check_api_key` tool to confirm the key is registered
62
+ and active.
63
+
64
+ ## Install and configure
65
+
66
+ ### Claude Code
67
+
68
+ ```bash
69
+ claude mcp add va-lis -e LIS_API_KEY=your-guid-here -- uvx va-lis-mcp
70
+ ```
71
+
72
+ ### Claude Desktop and other MCP clients
73
+
74
+ Add this to the client's MCP configuration (for Claude Desktop that is
75
+ `claude_desktop_config.json`):
76
+
77
+ ```json
78
+ {
79
+ "mcpServers": {
80
+ "va-lis": {
81
+ "command": "uvx",
82
+ "args": ["va-lis-mcp"],
83
+ "env": { "LIS_API_KEY": "your-guid-here" }
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ ### Installing from source
90
+
91
+ Until the first PyPI release, point the client at a checkout instead:
92
+
93
+ ```bash
94
+ git clone https://github.com/JonnyWaffles/va-lis-mcp
95
+ claude mcp add va-lis -e LIS_API_KEY=your-guid-here -- uv run --directory /path/to/va-lis-mcp va-lis-mcp
96
+ ```
97
+
98
+ The same works in a JSON config with `"command": "uv"` and
99
+ `"args": ["run", "--directory", "/path/to/va-lis-mcp", "va-lis-mcp"]`.
100
+
101
+ ## Tools
102
+
103
+ Every tool returns compact JSON. Bills are addressed by bill number plus
104
+ session code, members by `member_id`, and committees by number (`H08`) or
105
+ name.
106
+
107
+ ### Sessions and bills
108
+
109
+ | Tool | Inputs | Returns |
110
+ |---|---|---|
111
+ | `get_current_session` | none | The GA's working session, with a note on what "current" means during the interim |
112
+ | `list_sessions` | `year` | Sessions for a calendar year with their codes (reference data reaches back to 1994) |
113
+ | `list_bills` | `session_code`, `query?`, `limit?`, `offset?` | Bill rows filtered by a substring match on number, description, and title; paginated, with a total match count |
114
+ | `get_bill` | `bill_number`, `session_code` | Full bill detail: title, status, dates, patrons grouped by role, and the session lineage list |
115
+ | `get_bill_patrons` | `bill_number`, `session_code` | Every patron with member id, role, and chamber, in LIS display order |
116
+ | `get_bill_summaries` | `bill_number`, `session_code` | Plain text summaries (as introduced, as passed); the active entry is the current one |
117
+ | `get_bill_text` | `bill_number`, `session_code`, `document_code?` | Plain text of one version (default: most recent), truncated at 15,000 characters, plus the list of available versions |
118
+ | `get_bill_history` | `bill_number`, `session_code` | Chronological events with date, description, actor, committee, vote tally, `vote_id`, status, and passage flags |
119
+
120
+ ### Votes
121
+
122
+ | Tool | Inputs | Returns |
123
+ |---|---|---|
124
+ | `get_bill_votes` | `bill_number`, `session_code` | Every vote the bill saw in either chamber (floor, committee, subcommittee) with tally, outcome, and the voice, block, and roll call flags |
125
+ | `get_roll_call` | `bill_number`, `session_code`, `vote_id?` | Members grouped as yea, nay, abstain, and not voting, each with party and district, plus any vote statements; voice and block votes are left out |
126
+ | `get_member_votes` | `member_id`, `session_code`, `bill_number?`, `exclude_block_votes?`, `limit?`, `offset?` | One member's votes across a session or on one bill, with block votes flagged; paginated |
127
+
128
+ ### Members
129
+
130
+ | Tool | Inputs | Returns |
131
+ |---|---|---|
132
+ | `list_members` | `session_code`, `query?`, `chamber?` | The session roster, searchable by any part of a name, departed members included |
133
+ | `get_member` | `member_id`, `session_code` | One roster record with party, district, status, and contact details |
134
+ | `get_member_bills` | `member_id`, `session_code`, `role?`, `query?`, `limit?`, `offset?` | Every bill the member patrons, with chief patronage flagged; paginated |
135
+ | `get_member_committees` | `member_id`, `session_code`, `include_subcommittees?` | The member's committee seats and roles |
136
+
137
+ ### Committees
138
+
139
+ | Tool | Inputs | Returns |
140
+ |---|---|---|
141
+ | `list_committees` | `chamber?`, `include_subcommittees?` | The standing committees, with subcommittees on request |
142
+ | `get_committee` | `committee`, `session_code`, `chamber?` | The committee's roster for the session with each member's role, party, and district, its regular meeting note, and its subcommittees |
143
+
144
+ ### Meetings
145
+
146
+ | Tool | Inputs | Returns |
147
+ |---|---|---|
148
+ | `list_meetings` | `start_date`, `end_date`, `committee?`, `chamber?`, `meeting_type?`, `limit?`, `offset?` | The master schedule for a date range: date, the clerk's time text, room, owner, and cancellation flag; paginated |
149
+ | `list_dockets` | `committee`, `session_code` | A Senate committee's dockets with the bills on each (the House keeps none) |
150
+ | `list_floor_calendars` | `chamber`, `session_code` | A chamber's floor calendars for the session |
151
+ | `get_floor_calendar` | `calendar_id` | The bills on one floor calendar by section, with the actions and vote ids recorded against each |
152
+
153
+ ### Diagnostics
154
+
155
+ | Tool | Inputs | Returns |
156
+ |---|---|---|
157
+ | `check_api_key` | none | Whether the configured key is registered and active |
158
+
159
+ Errors come back as readable tool errors. A bill lookup that misses names the
160
+ bill and session it searched; an ambiguous committee name lists the matches;
161
+ an unknown member id points at `list_members`; a missing key tells you to set
162
+ `LIS_API_KEY` and where to register.
163
+
164
+ ## Domain notes
165
+
166
+ Distilled from the [va-lis-client
167
+ documentation](https://github.com/JonnyWaffles/va-lis-client), which is the
168
+ full reference for LIS behavior.
169
+
170
+ - **Session codes** encode year plus sequence: `20261` is the 2026 Regular
171
+ Session, `20262` is 2026 Special Session I. Most tools require one.
172
+ - **Bill numbers are unpadded.** LIS returns nothing for `HB0001`; the
173
+ correct form is `HB1`. The tools normalize input (`hb0001` becomes `HB1`),
174
+ so you can pass either.
175
+ - **The default session tracks the GA's working session.** Once a session
176
+ wraps up, the default advances to the upcoming session during the interim.
177
+ By August 2026 it already pointed at `20271`. For retrospective work on a
178
+ session that just ended, pass its explicit session code.
179
+ - **Carry over reuses bill IDs.** A bill from an even year session may be
180
+ carried over into the following odd year session (never the reverse, at
181
+ most once). The carried bill keeps its numeric `LegislationID` in both
182
+ sessions, so a bill is identified by session code plus bill number. The
183
+ `sessions` list on `get_bill` is the lineage record: two entries mean the
184
+ bill was carried over.
185
+ - **Passage dates live in the history.** The bill detail's passage date
186
+ fields are always null in LIS; `get_bill_history` flags passage events and
187
+ carries the vote tallies.
188
+ - **Votes reach a bill through its history.** No LIS vote endpoint accepts a
189
+ bill id, so `get_bill_votes` walks the bill's events and fetches the vote
190
+ behind each one. `get_bill_history` carries the `vote_id` on each event.
191
+ - **A block vote is not a position on one bill.** A chamber bundles its
192
+ uncontroversial bills and passes them in one motion; one 2026 House vote
193
+ disposed of 105 bills at 97 to 0. A voice vote records no members at all.
194
+ `get_roll_call` leaves both out, `get_bill_votes` flags them, and
195
+ `get_member_votes` marks each row with `is_block`.
196
+ - **Not voting is not in the tally.** Members recorded as `not_voting` (LIS
197
+ code `X`) are omitted from the tally string, so the member groups never add
198
+ up to it. Vote statements are corrections a member filed afterwards; the
199
+ recorded roll call is never amended.
200
+ - **The roster is larger than the chamber, on purpose.** A session roster
201
+ includes members who left or arrived mid-session, because they cast the
202
+ votes recorded before they left. `list_members` keeps them and marks the
203
+ departure date and reason.
204
+ - **Committees are not session scoped; their seats are.** The committee
205
+ list describes the committees that exist now, and `get_committee` reads the
206
+ seat list for the session you name. LIS has no "bills referred to a
207
+ committee" endpoint; `get_bill_history` names the committee that holds a
208
+ bill.
209
+ - **Dockets are Senate only.** House committee agendas are not in the API:
210
+ `list_meetings` gives the meeting, the bill's history gives the referral,
211
+ and the agenda itself lives on the committee's web page. Schedule times are
212
+ free text as the clerk entered them and may be blank.
213
+
214
+ ## Embedding in an ASGI app
215
+
216
+ Besides stdio, the server can be served over streamable HTTP. The packaged
217
+ ASGI app is stateless and returns plain JSON, so it works behind multiple
218
+ workers without sticky sessions:
219
+
220
+ ```bash
221
+ uvicorn va_lis_mcp.asgi:app --workers 4
222
+ ```
223
+
224
+ The MCP endpoint is at `/mcp`. To mount it inside a larger Starlette (or any
225
+ ASGI) application, the host app's lifespan must run the MCP session manager:
226
+
227
+ ```python
228
+ from contextlib import asynccontextmanager
229
+
230
+ from starlette.applications import Starlette
231
+ from starlette.routing import Mount
232
+
233
+ from va_lis_mcp.server import mcp
234
+
235
+
236
+ @asynccontextmanager
237
+ async def lifespan(app):
238
+ async with mcp.session_manager.run():
239
+ yield
240
+
241
+
242
+ app = Starlette(
243
+ routes=[
244
+ Mount("/lis", app=mcp.streamable_http_app(stateless_http=True, json_response=True)),
245
+ ],
246
+ lifespan=lifespan,
247
+ )
248
+ ```
249
+
250
+ That serves the endpoint at `/lis/mcp`. Keep `stateless_http=True` whenever
251
+ more than one worker handles requests: the default stateful transport needs
252
+ every request of a session to reach the same worker, which a plain worker
253
+ pool does not guarantee.
254
+
255
+ This is ASGI only; a WSGI stack cannot host it. Django projects can still
256
+ serve it, but through their ASGI entry point (for example gunicorn with
257
+ uvicorn workers), not through WSGI.
258
+
259
+ ## Development
260
+
261
+ ```bash
262
+ uv venv
263
+ uv pip install -e ".[dev]"
264
+ uv run pytest
265
+ uv run ruff check .
266
+ ```
267
+
268
+ Poke at the tools interactively with the MCP Inspector:
269
+
270
+ ```bash
271
+ npx @modelcontextprotocol/inspector uv run va-lis-mcp
272
+ ```
273
+
274
+ ## Upgrading
275
+
276
+ `uvx` caches resolved environments, so after a new release force resolution
277
+ of the latest version:
278
+
279
+ ```bash
280
+ uvx va-lis-mcp@latest
281
+ ```
282
+
283
+ ## License
284
+
285
+ MIT
@@ -0,0 +1,256 @@
1
+ # va-lis-mcp
2
+
3
+ An MCP server for the **Virginia Legislative Information System (LIS) REST
4
+ API**. It exposes the Virginia General Assembly's sessions, bills, summaries,
5
+ full bill text, action history, votes and roll calls, members, committees,
6
+ and meetings to any MCP client (Claude Code, Claude Desktop, and others).
7
+ Every tool is read only. You bring your own LIS API key.
8
+
9
+ Built on [va-lis-client](https://pypi.org/project/va-lis-client/), a Python
10
+ client for the LIS API.
11
+
12
+ > **Status: not yet on PyPI.** The `uvx va-lis-mcp` snippets below are written
13
+ > for the first release. Until then, install from a source checkout (see
14
+ > [Installing from source](#installing-from-source)).
15
+
16
+ ## Requirements
17
+
18
+ - Python 3.10 or newer
19
+ - [uv](https://docs.astral.sh/uv/), which provides the `uvx` launcher used
20
+ below. Install it with one line:
21
+ `curl -LsSf https://astral.sh/uv/install.sh | sh` (macOS/Linux) or
22
+ `powershell -c "irm https://astral.sh/uv/install.ps1 | iex"` (Windows)
23
+ - A LIS API key (next section)
24
+
25
+ ## Getting an API key
26
+
27
+ Register for a free partner key at
28
+ <https://lis.virginia.gov/apiregistration>. The key is a GUID. The server
29
+ reads it from the `LIS_API_KEY` environment variable.
30
+
31
+ LIS heartbeat endpoints accept any key, even garbage, so a successful ping
32
+ proves nothing. Use the `check_api_key` tool to confirm the key is registered
33
+ and active.
34
+
35
+ ## Install and configure
36
+
37
+ ### Claude Code
38
+
39
+ ```bash
40
+ claude mcp add va-lis -e LIS_API_KEY=your-guid-here -- uvx va-lis-mcp
41
+ ```
42
+
43
+ ### Claude Desktop and other MCP clients
44
+
45
+ Add this to the client's MCP configuration (for Claude Desktop that is
46
+ `claude_desktop_config.json`):
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "va-lis": {
52
+ "command": "uvx",
53
+ "args": ["va-lis-mcp"],
54
+ "env": { "LIS_API_KEY": "your-guid-here" }
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ### Installing from source
61
+
62
+ Until the first PyPI release, point the client at a checkout instead:
63
+
64
+ ```bash
65
+ git clone https://github.com/JonnyWaffles/va-lis-mcp
66
+ claude mcp add va-lis -e LIS_API_KEY=your-guid-here -- uv run --directory /path/to/va-lis-mcp va-lis-mcp
67
+ ```
68
+
69
+ The same works in a JSON config with `"command": "uv"` and
70
+ `"args": ["run", "--directory", "/path/to/va-lis-mcp", "va-lis-mcp"]`.
71
+
72
+ ## Tools
73
+
74
+ Every tool returns compact JSON. Bills are addressed by bill number plus
75
+ session code, members by `member_id`, and committees by number (`H08`) or
76
+ name.
77
+
78
+ ### Sessions and bills
79
+
80
+ | Tool | Inputs | Returns |
81
+ |---|---|---|
82
+ | `get_current_session` | none | The GA's working session, with a note on what "current" means during the interim |
83
+ | `list_sessions` | `year` | Sessions for a calendar year with their codes (reference data reaches back to 1994) |
84
+ | `list_bills` | `session_code`, `query?`, `limit?`, `offset?` | Bill rows filtered by a substring match on number, description, and title; paginated, with a total match count |
85
+ | `get_bill` | `bill_number`, `session_code` | Full bill detail: title, status, dates, patrons grouped by role, and the session lineage list |
86
+ | `get_bill_patrons` | `bill_number`, `session_code` | Every patron with member id, role, and chamber, in LIS display order |
87
+ | `get_bill_summaries` | `bill_number`, `session_code` | Plain text summaries (as introduced, as passed); the active entry is the current one |
88
+ | `get_bill_text` | `bill_number`, `session_code`, `document_code?` | Plain text of one version (default: most recent), truncated at 15,000 characters, plus the list of available versions |
89
+ | `get_bill_history` | `bill_number`, `session_code` | Chronological events with date, description, actor, committee, vote tally, `vote_id`, status, and passage flags |
90
+
91
+ ### Votes
92
+
93
+ | Tool | Inputs | Returns |
94
+ |---|---|---|
95
+ | `get_bill_votes` | `bill_number`, `session_code` | Every vote the bill saw in either chamber (floor, committee, subcommittee) with tally, outcome, and the voice, block, and roll call flags |
96
+ | `get_roll_call` | `bill_number`, `session_code`, `vote_id?` | Members grouped as yea, nay, abstain, and not voting, each with party and district, plus any vote statements; voice and block votes are left out |
97
+ | `get_member_votes` | `member_id`, `session_code`, `bill_number?`, `exclude_block_votes?`, `limit?`, `offset?` | One member's votes across a session or on one bill, with block votes flagged; paginated |
98
+
99
+ ### Members
100
+
101
+ | Tool | Inputs | Returns |
102
+ |---|---|---|
103
+ | `list_members` | `session_code`, `query?`, `chamber?` | The session roster, searchable by any part of a name, departed members included |
104
+ | `get_member` | `member_id`, `session_code` | One roster record with party, district, status, and contact details |
105
+ | `get_member_bills` | `member_id`, `session_code`, `role?`, `query?`, `limit?`, `offset?` | Every bill the member patrons, with chief patronage flagged; paginated |
106
+ | `get_member_committees` | `member_id`, `session_code`, `include_subcommittees?` | The member's committee seats and roles |
107
+
108
+ ### Committees
109
+
110
+ | Tool | Inputs | Returns |
111
+ |---|---|---|
112
+ | `list_committees` | `chamber?`, `include_subcommittees?` | The standing committees, with subcommittees on request |
113
+ | `get_committee` | `committee`, `session_code`, `chamber?` | The committee's roster for the session with each member's role, party, and district, its regular meeting note, and its subcommittees |
114
+
115
+ ### Meetings
116
+
117
+ | Tool | Inputs | Returns |
118
+ |---|---|---|
119
+ | `list_meetings` | `start_date`, `end_date`, `committee?`, `chamber?`, `meeting_type?`, `limit?`, `offset?` | The master schedule for a date range: date, the clerk's time text, room, owner, and cancellation flag; paginated |
120
+ | `list_dockets` | `committee`, `session_code` | A Senate committee's dockets with the bills on each (the House keeps none) |
121
+ | `list_floor_calendars` | `chamber`, `session_code` | A chamber's floor calendars for the session |
122
+ | `get_floor_calendar` | `calendar_id` | The bills on one floor calendar by section, with the actions and vote ids recorded against each |
123
+
124
+ ### Diagnostics
125
+
126
+ | Tool | Inputs | Returns |
127
+ |---|---|---|
128
+ | `check_api_key` | none | Whether the configured key is registered and active |
129
+
130
+ Errors come back as readable tool errors. A bill lookup that misses names the
131
+ bill and session it searched; an ambiguous committee name lists the matches;
132
+ an unknown member id points at `list_members`; a missing key tells you to set
133
+ `LIS_API_KEY` and where to register.
134
+
135
+ ## Domain notes
136
+
137
+ Distilled from the [va-lis-client
138
+ documentation](https://github.com/JonnyWaffles/va-lis-client), which is the
139
+ full reference for LIS behavior.
140
+
141
+ - **Session codes** encode year plus sequence: `20261` is the 2026 Regular
142
+ Session, `20262` is 2026 Special Session I. Most tools require one.
143
+ - **Bill numbers are unpadded.** LIS returns nothing for `HB0001`; the
144
+ correct form is `HB1`. The tools normalize input (`hb0001` becomes `HB1`),
145
+ so you can pass either.
146
+ - **The default session tracks the GA's working session.** Once a session
147
+ wraps up, the default advances to the upcoming session during the interim.
148
+ By August 2026 it already pointed at `20271`. For retrospective work on a
149
+ session that just ended, pass its explicit session code.
150
+ - **Carry over reuses bill IDs.** A bill from an even year session may be
151
+ carried over into the following odd year session (never the reverse, at
152
+ most once). The carried bill keeps its numeric `LegislationID` in both
153
+ sessions, so a bill is identified by session code plus bill number. The
154
+ `sessions` list on `get_bill` is the lineage record: two entries mean the
155
+ bill was carried over.
156
+ - **Passage dates live in the history.** The bill detail's passage date
157
+ fields are always null in LIS; `get_bill_history` flags passage events and
158
+ carries the vote tallies.
159
+ - **Votes reach a bill through its history.** No LIS vote endpoint accepts a
160
+ bill id, so `get_bill_votes` walks the bill's events and fetches the vote
161
+ behind each one. `get_bill_history` carries the `vote_id` on each event.
162
+ - **A block vote is not a position on one bill.** A chamber bundles its
163
+ uncontroversial bills and passes them in one motion; one 2026 House vote
164
+ disposed of 105 bills at 97 to 0. A voice vote records no members at all.
165
+ `get_roll_call` leaves both out, `get_bill_votes` flags them, and
166
+ `get_member_votes` marks each row with `is_block`.
167
+ - **Not voting is not in the tally.** Members recorded as `not_voting` (LIS
168
+ code `X`) are omitted from the tally string, so the member groups never add
169
+ up to it. Vote statements are corrections a member filed afterwards; the
170
+ recorded roll call is never amended.
171
+ - **The roster is larger than the chamber, on purpose.** A session roster
172
+ includes members who left or arrived mid-session, because they cast the
173
+ votes recorded before they left. `list_members` keeps them and marks the
174
+ departure date and reason.
175
+ - **Committees are not session scoped; their seats are.** The committee
176
+ list describes the committees that exist now, and `get_committee` reads the
177
+ seat list for the session you name. LIS has no "bills referred to a
178
+ committee" endpoint; `get_bill_history` names the committee that holds a
179
+ bill.
180
+ - **Dockets are Senate only.** House committee agendas are not in the API:
181
+ `list_meetings` gives the meeting, the bill's history gives the referral,
182
+ and the agenda itself lives on the committee's web page. Schedule times are
183
+ free text as the clerk entered them and may be blank.
184
+
185
+ ## Embedding in an ASGI app
186
+
187
+ Besides stdio, the server can be served over streamable HTTP. The packaged
188
+ ASGI app is stateless and returns plain JSON, so it works behind multiple
189
+ workers without sticky sessions:
190
+
191
+ ```bash
192
+ uvicorn va_lis_mcp.asgi:app --workers 4
193
+ ```
194
+
195
+ The MCP endpoint is at `/mcp`. To mount it inside a larger Starlette (or any
196
+ ASGI) application, the host app's lifespan must run the MCP session manager:
197
+
198
+ ```python
199
+ from contextlib import asynccontextmanager
200
+
201
+ from starlette.applications import Starlette
202
+ from starlette.routing import Mount
203
+
204
+ from va_lis_mcp.server import mcp
205
+
206
+
207
+ @asynccontextmanager
208
+ async def lifespan(app):
209
+ async with mcp.session_manager.run():
210
+ yield
211
+
212
+
213
+ app = Starlette(
214
+ routes=[
215
+ Mount("/lis", app=mcp.streamable_http_app(stateless_http=True, json_response=True)),
216
+ ],
217
+ lifespan=lifespan,
218
+ )
219
+ ```
220
+
221
+ That serves the endpoint at `/lis/mcp`. Keep `stateless_http=True` whenever
222
+ more than one worker handles requests: the default stateful transport needs
223
+ every request of a session to reach the same worker, which a plain worker
224
+ pool does not guarantee.
225
+
226
+ This is ASGI only; a WSGI stack cannot host it. Django projects can still
227
+ serve it, but through their ASGI entry point (for example gunicorn with
228
+ uvicorn workers), not through WSGI.
229
+
230
+ ## Development
231
+
232
+ ```bash
233
+ uv venv
234
+ uv pip install -e ".[dev]"
235
+ uv run pytest
236
+ uv run ruff check .
237
+ ```
238
+
239
+ Poke at the tools interactively with the MCP Inspector:
240
+
241
+ ```bash
242
+ npx @modelcontextprotocol/inspector uv run va-lis-mcp
243
+ ```
244
+
245
+ ## Upgrading
246
+
247
+ `uvx` caches resolved environments, so after a new release force resolution
248
+ of the latest version:
249
+
250
+ ```bash
251
+ uvx va-lis-mcp@latest
252
+ ```
253
+
254
+ ## License
255
+
256
+ MIT
@@ -0,0 +1,60 @@
1
+ [project]
2
+ name = "va-lis-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for the Virginia Legislative Information System (LIS) API"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "Jonny Fuller", email = "mr.waffles@gmail.com" }]
9
+ keywords = ["virginia", "legislature", "LIS", "legislation", "API", "MCP", "model context protocol"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Topic :: Software Development :: Libraries",
19
+ ]
20
+ dependencies = [
21
+ "mcp>=2,<3",
22
+ "va-lis-client>=0.3.0,<0.4",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ # Floors are real claims: CI runs a --resolution lowest-direct job against
27
+ # them. pytest-asyncio 1.x needs pytest >= 8.2, and asyncio_mode = "auto"
28
+ # below needs pytest-asyncio >= 1.
29
+ dev = [
30
+ "pytest>=8.2",
31
+ "pytest-asyncio>=1",
32
+ "ruff>=0.14",
33
+ ]
34
+
35
+ [project.scripts]
36
+ va-lis-mcp = "va_lis_mcp.server:main"
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/JonnyWaffles/va-lis-mcp"
40
+ Repository = "https://github.com/JonnyWaffles/va-lis-mcp"
41
+ "Bug Tracker" = "https://github.com/JonnyWaffles/va-lis-mcp/issues"
42
+
43
+ [build-system]
44
+ requires = ["setuptools>=68"]
45
+ build-backend = "setuptools.build_meta"
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["src"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ asyncio_mode = "auto"
53
+ # pytest-asyncio warns when this is unset and will change its default later.
54
+ asyncio_default_fixture_loop_scope = "function"
55
+
56
+ [tool.ruff]
57
+ line-length = 100
58
+ lint.select = ["E", "F", "B", "I"]
59
+ lint.extend-select = ["I"]
60
+ fix = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """MCP server for the Virginia Legislative Information System (LIS) API."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ # pyproject.toml holds the number. Reading it back keeps the two in step.
6
+ __version__ = version("va-lis-mcp")