@pipeworx/mcp-openstates 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pipeworx
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.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # mcp-openstates
2
+
3
+ OpenStates MCP — bills, legislators, votes in all 50 US states
4
+
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 250+ live data sources.
6
+
7
+ ## Tools
8
+
9
+ | Tool | Description |
10
+ |------|-------------|
11
+ | `get_legislator` | Fetch a single legislator by OpenStates person ID. Returns biographical info, current roles, prior offices, contact methods, sources. |
12
+
13
+ ## Quick Start
14
+
15
+ Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "openstates": {
21
+ "url": "https://gateway.pipeworx.io/openstates/mcp"
22
+ }
23
+ }
24
+ }
25
+ ```
26
+
27
+ Or connect to the full Pipeworx gateway for access to all 250+ data sources:
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "pipeworx": {
33
+ "url": "https://gateway.pipeworx.io/mcp"
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ ## Using with ask_pipeworx
40
+
41
+ Instead of calling tools directly, you can ask questions in plain English:
42
+
43
+ ```
44
+ ask_pipeworx({ question: "your question about Openstates data" })
45
+ ```
46
+
47
+ The gateway picks the right tool and fills the arguments automatically.
48
+
49
+ ## More
50
+
51
+ - [All tools and guides](https://github.com/pipeworx-io/examples)
52
+ - [pipeworx.io](https://pipeworx.io)
53
+
54
+ ## License
55
+
56
+ MIT
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@pipeworx/mcp-openstates",
3
+ "version": "0.1.0",
4
+ "description": "OpenStates MCP — bills, legislators, votes in all 50 US states",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "openstates"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/pipeworx-io/mcp-openstates"
13
+ },
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5.7.0"
19
+ }
20
+ }
package/server.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.pipeworx-io/openstates",
4
+ "title": "Openstates",
5
+ "description": "OpenStates MCP — bills, legislators, votes in all 50 US states",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/openstates",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-openstates",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/openstates/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,346 @@
1
+ interface McpToolDefinition {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: 'object';
6
+ properties: Record<string, unknown>;
7
+ required?: string[];
8
+ };
9
+ }
10
+
11
+ interface McpToolExport {
12
+ tools: McpToolDefinition[];
13
+ callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
14
+ meter?: { credits: number };
15
+ cost?: Record<string, unknown>;
16
+ provider?: string;
17
+ }
18
+
19
+ /**
20
+ * OpenStates MCP — bills, legislators, votes in all 50 US states
21
+ *
22
+ * Federal congress packs cover Washington; OpenStates covers the other 50
23
+ * statehouses + DC + 5 territories. Indexed at the bill, person, vote, and
24
+ * committee level.
25
+ *
26
+ * API: https://docs.openstates.org/api-v3/
27
+ * Auth: header `X-API-KEY`. Free tier ~5,000 req/day.
28
+ *
29
+ * Tools:
30
+ * - search_bills: text + jurisdiction + session search
31
+ * - get_bill: single bill with versions, sponsors, votes
32
+ * - search_legislators: filter by state, name, chamber, party
33
+ * - get_legislator: full record by OpenStates person ID
34
+ */
35
+
36
+
37
+ const BASE_URL = 'https://v3.openstates.org';
38
+
39
+ const tools: McpToolExport['tools'] = [
40
+ {
41
+ name: 'search_bills',
42
+ description:
43
+ 'Search bills in any US statehouse. Pass jurisdiction as a 2-letter state code (e.g., "CA", "NY", "TX") or full name. Returns bill identifiers, titles, classifications, last action, sponsors, and OpenStates IDs for use with get_bill.',
44
+ inputSchema: {
45
+ type: 'object',
46
+ properties: {
47
+ jurisdiction: { type: 'string', description: '2-letter state code or jurisdiction name' },
48
+ query: { type: 'string', description: 'Free-text search across title/summary' },
49
+ session: { type: 'string', description: 'Session identifier (e.g., "20232024")' },
50
+ classification: { type: 'string', description: 'bill | resolution | constitutional amendment | etc.' },
51
+ sponsor: { type: 'string', description: 'Legislator name filter' },
52
+ sort: {
53
+ type: 'string',
54
+ description: 'updated_desc | updated_asc | first_action_desc | first_action_asc | latest_action_desc | latest_action_asc',
55
+ },
56
+ per_page: { type: 'number', description: '1-50 (default 20)' },
57
+ page: { type: 'number', description: '1-based page (default 1)' },
58
+ },
59
+ required: ['jurisdiction'],
60
+ },
61
+ },
62
+ {
63
+ name: 'get_bill',
64
+ description:
65
+ 'Fetch a single bill with full detail: versions, sponsorships, related/companion bills, actions, and votes. Pass the OpenStates ID (e.g., "ocd-bill/abc...") or a state/session/identifier triple.',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {
69
+ openstates_id: { type: 'string', description: 'OpenStates bill ID (preferred, e.g., "ocd-bill/...")' },
70
+ jurisdiction: { type: 'string', description: 'State code (use with session + identifier)' },
71
+ session: { type: 'string', description: 'Session ID (use with jurisdiction + identifier)' },
72
+ identifier: { type: 'string', description: 'Bill identifier within the session (e.g., "AB-123")' },
73
+ },
74
+ required: [],
75
+ },
76
+ },
77
+ {
78
+ name: 'search_legislators',
79
+ description:
80
+ 'Find state legislators. Filter by jurisdiction, name, chamber (upper/lower), party, or district. Returns name, current/prior roles, party, contact details, OpenStates IDs.',
81
+ inputSchema: {
82
+ type: 'object',
83
+ properties: {
84
+ jurisdiction: { type: 'string', description: '2-letter state code or name' },
85
+ name: { type: 'string', description: 'Name fragment' },
86
+ org_classification: { type: 'string', description: 'upper | lower | legislature' },
87
+ district: { type: 'string', description: 'District identifier' },
88
+ party: { type: 'string', description: 'Party name (e.g., "Democratic", "Republican")' },
89
+ per_page: { type: 'number', description: '1-50 (default 20)' },
90
+ page: { type: 'number', description: '1-based page' },
91
+ },
92
+ required: [],
93
+ },
94
+ },
95
+ {
96
+ name: 'get_legislator',
97
+ description: 'Fetch a single legislator by OpenStates person ID. Returns biographical info, current roles, prior offices, contact methods, sources.',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ person_id: { type: 'string', description: 'OpenStates person ID (e.g., "ocd-person/...")' },
102
+ },
103
+ required: ['person_id'],
104
+ },
105
+ },
106
+ ];
107
+
108
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
109
+ const apiKey = (args._apiKey as string | undefined)?.trim();
110
+ if (!apiKey) {
111
+ throw new Error(
112
+ 'OpenStates requires an API key. Contact the operator about platform credentials, or BYO via ?_apiKey=<key> after registering at https://openstates.org/account/profile/.',
113
+ );
114
+ }
115
+ switch (name) {
116
+ case 'search_bills':
117
+ return searchBills(apiKey, args);
118
+ case 'get_bill':
119
+ return getBill(apiKey, args);
120
+ case 'search_legislators':
121
+ return searchLegislators(apiKey, args);
122
+ case 'get_legislator':
123
+ return getLegislator(apiKey, reqStr(args, 'person_id', '"ocd-person/..."'));
124
+ default:
125
+ throw new Error(`Unknown tool: ${name}`);
126
+ }
127
+ }
128
+
129
+ function reqStr(args: Record<string, unknown>, key: string, example: string): string {
130
+ const v = args[key];
131
+ if (typeof v !== 'string' || !v.trim()) {
132
+ throw new Error(`Required argument "${key}" is missing or empty. Pass a string like ${example}.`);
133
+ }
134
+ return v;
135
+ }
136
+
137
+ async function osFetch<T>(apiKey: string, path: string, params: URLSearchParams): Promise<T> {
138
+ const url = `${BASE_URL}${path}${params.toString() ? `?${params}` : ''}`;
139
+ const res = await fetch(url, {
140
+ headers: { 'X-API-KEY': apiKey, Accept: 'application/json' },
141
+ });
142
+ if (res.status === 401 || res.status === 403) throw new Error('OpenStates: unauthorized — check the API key');
143
+ if (res.status === 404) throw new Error('OpenStates: not found (HTTP 404)');
144
+ if (res.status === 429) throw new Error('OpenStates: rate-limit (HTTP 429) — free tier ~5k req/day');
145
+ if (!res.ok) {
146
+ const body = await res.text();
147
+ throw new Error(`OpenStates error: ${res.status} ${body.slice(0, 200)}`);
148
+ }
149
+ return res.json() as Promise<T>;
150
+ }
151
+
152
+ interface OsBill {
153
+ id?: string;
154
+ identifier?: string;
155
+ title?: string;
156
+ jurisdiction?: { id?: string; name?: string };
157
+ session?: string;
158
+ classification?: string[];
159
+ subject?: string[];
160
+ from_organization?: { name?: string; classification?: string };
161
+ latest_action_date?: string;
162
+ latest_action_description?: string;
163
+ openstates_url?: string;
164
+ sources?: { url?: string }[];
165
+ sponsorships?: { name?: string; classification?: string; primary?: boolean; person?: { id?: string; name?: string } }[];
166
+ versions?: { date?: string; note?: string; links?: { url?: string; media_type?: string }[] }[];
167
+ actions?: { date?: string; description?: string; classification?: string[] }[];
168
+ votes?: { id?: string; motion_text?: string; start_date?: string; result?: string; counts?: { option?: string; value?: number }[] }[];
169
+ }
170
+
171
+ function normalizeBill(b: OsBill, full = false) {
172
+ const base: Record<string, unknown> = {
173
+ openstates_id: b.id ?? null,
174
+ identifier: b.identifier ?? null,
175
+ title: b.title ?? null,
176
+ jurisdiction: b.jurisdiction?.name ?? null,
177
+ session: b.session ?? null,
178
+ classification: b.classification ?? [],
179
+ subject: b.subject ?? [],
180
+ chamber: b.from_organization?.classification ?? b.from_organization?.name ?? null,
181
+ latest_action_date: b.latest_action_date ?? null,
182
+ latest_action: b.latest_action_description ?? null,
183
+ openstates_url: b.openstates_url ?? null,
184
+ };
185
+ if (full) {
186
+ base.sponsors = (b.sponsorships ?? []).map((s) => ({
187
+ name: s.name ?? null,
188
+ person_id: s.person?.id ?? null,
189
+ classification: s.classification ?? null,
190
+ primary: s.primary ?? null,
191
+ }));
192
+ base.versions = (b.versions ?? []).map((v) => ({
193
+ date: v.date ?? null,
194
+ note: v.note ?? null,
195
+ links: (v.links ?? []).map((l) => ({ url: l.url ?? null, media_type: l.media_type ?? null })),
196
+ }));
197
+ base.actions = (b.actions ?? []).map((a) => ({
198
+ date: a.date ?? null,
199
+ description: a.description ?? null,
200
+ classification: a.classification ?? [],
201
+ }));
202
+ base.votes = (b.votes ?? []).map((v) => ({
203
+ id: v.id ?? null,
204
+ motion: v.motion_text ?? null,
205
+ start_date: v.start_date ?? null,
206
+ result: v.result ?? null,
207
+ counts: v.counts ?? [],
208
+ }));
209
+ base.sources = (b.sources ?? []).map((s) => s.url).filter(Boolean);
210
+ }
211
+ return base;
212
+ }
213
+
214
+ async function searchBills(apiKey: string, args: Record<string, unknown>) {
215
+ const params = new URLSearchParams({
216
+ jurisdiction: String(args.jurisdiction),
217
+ per_page: String(Math.min(50, Math.max(1, (args.per_page as number) ?? 20))),
218
+ page: String(Math.max(1, (args.page as number) ?? 1)),
219
+ });
220
+ if (args.query) params.set('q', String(args.query));
221
+ if (args.session) params.set('session', String(args.session));
222
+ if (args.classification) params.set('classification', String(args.classification));
223
+ if (args.sponsor) params.set('sponsor', String(args.sponsor));
224
+ if (args.sort) params.set('sort', String(args.sort));
225
+
226
+ const data = await osFetch<{
227
+ results?: OsBill[];
228
+ pagination?: { total_items?: number; total_pages?: number; page?: number };
229
+ }>(apiKey, '/bills', params);
230
+
231
+ return {
232
+ total: data.pagination?.total_items ?? 0,
233
+ page: data.pagination?.page ?? null,
234
+ total_pages: data.pagination?.total_pages ?? null,
235
+ returned: data.results?.length ?? 0,
236
+ bills: (data.results ?? []).map((b) => normalizeBill(b, false)),
237
+ };
238
+ }
239
+
240
+ async function getBill(apiKey: string, args: Record<string, unknown>) {
241
+ const id = (args.openstates_id as string | undefined)?.trim();
242
+ const params = new URLSearchParams({ include: 'sponsorships,actions,votes,versions,sources,abstracts' });
243
+
244
+ if (id) {
245
+ const data = await osFetch<OsBill>(apiKey, `/bills/${encodeURIComponent(id)}`, params);
246
+ return normalizeBill(data, true);
247
+ }
248
+ const jurisdiction = (args.jurisdiction as string | undefined)?.trim();
249
+ const session = (args.session as string | undefined)?.trim();
250
+ const identifier = (args.identifier as string | undefined)?.trim();
251
+ if (!jurisdiction || !session || !identifier) {
252
+ throw new Error('Pass either openstates_id, OR all three of jurisdiction + session + identifier.');
253
+ }
254
+ const data = await osFetch<OsBill>(
255
+ apiKey,
256
+ `/bills/${encodeURIComponent(jurisdiction)}/${encodeURIComponent(session)}/${encodeURIComponent(identifier)}`,
257
+ params,
258
+ );
259
+ return normalizeBill(data, true);
260
+ }
261
+
262
+ interface OsPerson {
263
+ id?: string;
264
+ name?: string;
265
+ family_name?: string;
266
+ given_name?: string;
267
+ party?: string;
268
+ current_role?: { title?: string; org_classification?: string; district?: string; division_id?: string };
269
+ jurisdiction?: { id?: string; name?: string };
270
+ birth_date?: string;
271
+ death_date?: string;
272
+ gender?: string;
273
+ image?: string;
274
+ email?: string;
275
+ links?: { url?: string; note?: string }[];
276
+ sources?: { url?: string }[];
277
+ offices?: { name?: string; classification?: string; address?: string; voice?: string; email?: string }[];
278
+ openstates_url?: string;
279
+ other_names?: { name?: string }[];
280
+ }
281
+
282
+ function normalizeLegislator(p: OsPerson, full = false) {
283
+ const base: Record<string, unknown> = {
284
+ person_id: p.id ?? null,
285
+ name: p.name ?? null,
286
+ party: p.party ?? null,
287
+ jurisdiction: p.jurisdiction?.name ?? null,
288
+ chamber: p.current_role?.org_classification ?? null,
289
+ district: p.current_role?.district ?? null,
290
+ title: p.current_role?.title ?? null,
291
+ image: p.image ?? null,
292
+ email: p.email ?? null,
293
+ openstates_url: p.openstates_url ?? null,
294
+ };
295
+ if (full) {
296
+ base.given_name = p.given_name ?? null;
297
+ base.family_name = p.family_name ?? null;
298
+ base.gender = p.gender ?? null;
299
+ base.birth_date = p.birth_date ?? null;
300
+ base.death_date = p.death_date ?? null;
301
+ base.other_names = (p.other_names ?? []).map((n) => n.name).filter(Boolean);
302
+ base.offices = (p.offices ?? []).map((o) => ({
303
+ name: o.name ?? null,
304
+ classification: o.classification ?? null,
305
+ address: o.address ?? null,
306
+ voice: o.voice ?? null,
307
+ email: o.email ?? null,
308
+ }));
309
+ base.links = (p.links ?? []).map((l) => ({ url: l.url ?? null, note: l.note ?? null }));
310
+ base.sources = (p.sources ?? []).map((s) => s.url).filter(Boolean);
311
+ }
312
+ return base;
313
+ }
314
+
315
+ async function searchLegislators(apiKey: string, args: Record<string, unknown>) {
316
+ const params = new URLSearchParams({
317
+ per_page: String(Math.min(50, Math.max(1, (args.per_page as number) ?? 20))),
318
+ page: String(Math.max(1, (args.page as number) ?? 1)),
319
+ });
320
+ if (args.jurisdiction) params.set('jurisdiction', String(args.jurisdiction));
321
+ if (args.name) params.set('name', String(args.name));
322
+ if (args.org_classification) params.set('org_classification', String(args.org_classification));
323
+ if (args.district) params.set('district', String(args.district));
324
+ if (args.party) params.set('party', String(args.party));
325
+
326
+ const data = await osFetch<{
327
+ results?: OsPerson[];
328
+ pagination?: { total_items?: number; total_pages?: number; page?: number };
329
+ }>(apiKey, '/people', params);
330
+
331
+ return {
332
+ total: data.pagination?.total_items ?? 0,
333
+ page: data.pagination?.page ?? null,
334
+ total_pages: data.pagination?.total_pages ?? null,
335
+ returned: data.results?.length ?? 0,
336
+ legislators: (data.results ?? []).map((p) => normalizeLegislator(p, false)),
337
+ };
338
+ }
339
+
340
+ async function getLegislator(apiKey: string, personId: string) {
341
+ const params = new URLSearchParams({ include: 'other_names,offices,sources,links' });
342
+ const data = await osFetch<OsPerson>(apiKey, `/people/${encodeURIComponent(personId)}`, params);
343
+ return normalizeLegislator(data, true);
344
+ }
345
+
346
+ export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true
12
+ },
13
+ "include": ["src"]
14
+ }