@pipeworx/mcp-openstates 0.1.2 → 0.1.3
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/README.md +2 -2
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +195 -16
- package/src/server.ts +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
OpenStates v3 MCP — bills, legislators, votes in all 50 US states.
|
|
4
4
|
|
|
5
|
-
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to
|
|
5
|
+
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1684+ live data sources.
|
|
6
6
|
|
|
7
7
|
## Tools
|
|
8
8
|
|
|
@@ -64,7 +64,7 @@ directly, instead of just this one's:
|
|
|
64
64
|
}
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
Both URLs reach the same gateway and the same
|
|
67
|
+
Both URLs reach the same gateway and the same 1684+ data sources. The
|
|
68
68
|
only difference is which pack's tools are listed **directly**; `ask_pipeworx`
|
|
69
69
|
reaches all of them from either one.
|
|
70
70
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeworx/mcp-openstates",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "OpenStates MCP — bills, legislators, votes in all 50 US states",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@cloudflare/workers-types": "^4.20260405.1"
|
|
27
27
|
},
|
|
28
28
|
"pipeworx": {
|
|
29
|
-
"sourceHash": "v1-
|
|
30
|
-
"sourceCommit": "
|
|
29
|
+
"sourceHash": "v1-cb3703013cf69fc6e2fc70bca57a6857b7ead99de0e71beaa6a32b1312e11733",
|
|
30
|
+
"sourceCommit": "c7cc6672266b20d1b5feb0c4c2d631e50ad8ba17"
|
|
31
31
|
}
|
|
32
32
|
}
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.pipeworx-io/openstates",
|
|
4
4
|
"title": "Openstates",
|
|
5
5
|
"description": "OpenStates MCP — bills, legislators, votes in all 50 US states",
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.3",
|
|
7
7
|
"websiteUrl": "https://pipeworx.io/packs/openstates",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/pipeworx-io/mcp-openstates",
|
package/src/index.ts
CHANGED
|
@@ -708,7 +708,11 @@ const tools: McpToolExport['tools'] = [
|
|
|
708
708
|
properties: {
|
|
709
709
|
jurisdiction: { type: 'string', description: '2-letter state code or jurisdiction name' },
|
|
710
710
|
query: { type: 'string', description: 'Free-text search across title/summary' },
|
|
711
|
-
session: {
|
|
711
|
+
session: {
|
|
712
|
+
type: 'string',
|
|
713
|
+
description:
|
|
714
|
+
'Optional. Session identifiers are PER STATE (Ohio "136", California "20252026", Texas "89"). Omit to search every session. A year or year range ("2025", "2025-2026") is resolved to the state\'s own session by date; an unknown session is refused with the valid list.',
|
|
715
|
+
},
|
|
712
716
|
classification: { type: 'string', description: 'bill | resolution | constitutional amendment | etc.' },
|
|
713
717
|
sponsor: { type: 'string', description: 'Legislator name filter' },
|
|
714
718
|
sort: {
|
|
@@ -730,7 +734,11 @@ const tools: McpToolExport['tools'] = [
|
|
|
730
734
|
properties: {
|
|
731
735
|
openstates_id: { type: 'string', description: 'OpenStates bill ID (preferred, e.g., "ocd-bill/...")' },
|
|
732
736
|
jurisdiction: { type: 'string', description: 'State code (use with session + identifier)' },
|
|
733
|
-
session: {
|
|
737
|
+
session: {
|
|
738
|
+
type: 'string',
|
|
739
|
+
description:
|
|
740
|
+
'Session ID, per state (Ohio "136", California "20252026"); a year or year range is resolved to the state\'s session. Use with jurisdiction + identifier.',
|
|
741
|
+
},
|
|
734
742
|
identifier: { type: 'string', description: 'Bill identifier within the session (e.g., "AB-123")' },
|
|
735
743
|
},
|
|
736
744
|
required: [],
|
|
@@ -873,32 +881,185 @@ function normalizeBill(b: OsBill, full = false) {
|
|
|
873
881
|
return base;
|
|
874
882
|
}
|
|
875
883
|
|
|
884
|
+
type BillsPage = {
|
|
885
|
+
results?: OsBill[];
|
|
886
|
+
pagination?: { total_items?: number; total_pages?: number; page?: number };
|
|
887
|
+
};
|
|
888
|
+
|
|
876
889
|
async function searchBills(apiKey: string, args: Record<string, unknown>) {
|
|
890
|
+
const jurisdiction = String(args.jurisdiction);
|
|
877
891
|
const params = new URLSearchParams({
|
|
878
|
-
jurisdiction
|
|
892
|
+
jurisdiction,
|
|
879
893
|
per_page: String(Math.min(50, Math.max(1, (args.per_page as number) ?? 20))),
|
|
880
894
|
page: String(Math.max(1, (args.page as number) ?? 1)),
|
|
881
895
|
});
|
|
882
896
|
if (args.query) params.set('q', String(args.query));
|
|
883
|
-
|
|
897
|
+
const session = typeof args.session === 'string' ? args.session.trim() : args.session ? String(args.session) : '';
|
|
898
|
+
if (session) params.set('session', session);
|
|
884
899
|
if (args.classification) params.set('classification', String(args.classification));
|
|
885
900
|
if (args.sponsor) params.set('sponsor', String(args.sponsor));
|
|
886
901
|
if (args.sort) params.set('sort', String(args.sort));
|
|
887
902
|
|
|
888
|
-
const
|
|
889
|
-
results?: OsBill[];
|
|
890
|
-
pagination?: { total_items?: number; total_pages?: number; page?: number };
|
|
891
|
-
}>(apiKey, '/bills', params);
|
|
892
|
-
|
|
893
|
-
return {
|
|
903
|
+
const shape = (data: BillsPage, extra: Record<string, unknown> = {}) => ({
|
|
894
904
|
total: data.pagination?.total_items ?? 0,
|
|
895
905
|
page: data.pagination?.page ?? null,
|
|
896
906
|
total_pages: data.pagination?.total_pages ?? null,
|
|
897
907
|
returned: data.results?.length ?? 0,
|
|
908
|
+
...extra,
|
|
898
909
|
bills: (data.results ?? []).map((b) => normalizeBill(b, false)),
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
const data = await osFetch<BillsPage>(apiKey, '/bills', params);
|
|
913
|
+
if (!session || (data.results?.length ?? 0) > 0) return shape(data);
|
|
914
|
+
|
|
915
|
+
// Zero rows WITH a session filter: session ids are per-state (fleet #2465),
|
|
916
|
+
// so a zero here is as likely a wrong-shaped id as a genuine no-match. Check
|
|
917
|
+
// it against the state's own session list before reporting an empty result.
|
|
918
|
+
const resolution = await resolveSession(apiKey, jurisdiction, session);
|
|
919
|
+
if (resolution.exact) return shape(data, { session_checked: resolution.exact });
|
|
920
|
+
|
|
921
|
+
let last = data;
|
|
922
|
+
for (const cand of resolution.candidates) {
|
|
923
|
+
params.set('session', cand.identifier);
|
|
924
|
+
last = await osFetch<BillsPage>(apiKey, '/bills', params);
|
|
925
|
+
if ((last.results?.length ?? 0) > 0) {
|
|
926
|
+
return shape(last, {
|
|
927
|
+
session_resolved: sessionNote(session, cand.identifier, cand.name, resolution.candidates),
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
const tried = resolution.candidates[resolution.candidates.length - 1];
|
|
932
|
+
return shape(last, {
|
|
933
|
+
session_resolved: sessionNote(session, tried.identifier, tried.name, resolution.candidates),
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function sessionNote(requested: string, used: string, name: string | undefined, candidates: OsSession[]) {
|
|
938
|
+
return {
|
|
939
|
+
requested,
|
|
940
|
+
used,
|
|
941
|
+
name: name ?? null,
|
|
942
|
+
candidates: candidates.map((c) => c.identifier),
|
|
943
|
+
note: `"${requested}" is not a session identifier in this state; searched session "${used}"${name ? ` (${name})` : ''}, whose dates cover it.`,
|
|
899
944
|
};
|
|
900
945
|
}
|
|
901
946
|
|
|
947
|
+
interface OsSession {
|
|
948
|
+
identifier: string;
|
|
949
|
+
name?: string;
|
|
950
|
+
classification?: string;
|
|
951
|
+
start_date?: string;
|
|
952
|
+
end_date?: string;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Years named by a session-ish string: "20252026", "2025-2026", "2025–26",
|
|
957
|
+
* "2025". Null when the value is not year-shaped (e.g. Ohio's "136").
|
|
958
|
+
*/
|
|
959
|
+
export function yearRangeOf(raw: string): [number, number] | null {
|
|
960
|
+
const s = raw.trim();
|
|
961
|
+
let m = s.match(/^((?:19|20)\d{2})((?:19|20)\d{2})$/);
|
|
962
|
+
if (m) return order(+m[1], +m[2]);
|
|
963
|
+
m = s.match(/^((?:19|20)\d{2})\s*[-\u2013\u2014/ ]\s*(\d{2}|\d{4})\b/);
|
|
964
|
+
if (m) {
|
|
965
|
+
const a = +m[1];
|
|
966
|
+
const b = m[2].length === 2 ? Math.floor(a / 100) * 100 + +m[2] : +m[2];
|
|
967
|
+
return order(a, b);
|
|
968
|
+
}
|
|
969
|
+
m = s.match(/^((?:19|20)\d{2})\b/);
|
|
970
|
+
if (m) return [+m[1], +m[1]];
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function order(a: number, b: number): [number, number] {
|
|
975
|
+
return a <= b ? [a, b] : [b, a];
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function sessionYears(s: OsSession): [number, number] | null {
|
|
979
|
+
const start = parseInt((s.start_date ?? '').slice(0, 4), 10);
|
|
980
|
+
const end = parseInt((s.end_date ?? '').slice(0, 4), 10);
|
|
981
|
+
if (Number.isFinite(start)) {
|
|
982
|
+
// An open end_date means the session is still running.
|
|
983
|
+
return [start, Number.isFinite(end) ? end : Math.max(start, new Date().getUTCFullYear())];
|
|
984
|
+
}
|
|
985
|
+
// No dates published: fall back to years named in the identifier or name.
|
|
986
|
+
return yearRangeOf(s.identifier) ?? yearRangeOf((s.name ?? '').replace(/^.*?\b((?:19|20)\d{2})/, '$1'));
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** Sessions whose date span overlaps the requested years, regular sessions first, newest first. */
|
|
990
|
+
export function resolveSessionCandidates(requested: string, sessions: OsSession[]): OsSession[] {
|
|
991
|
+
const want = yearRangeOf(requested);
|
|
992
|
+
if (!want) return [];
|
|
993
|
+
const hits = sessions.filter((s) => {
|
|
994
|
+
const span = sessionYears(s);
|
|
995
|
+
return span !== null && span[0] <= want[1] && span[1] >= want[0];
|
|
996
|
+
});
|
|
997
|
+
return hits
|
|
998
|
+
.map((s, i) => ({ s, i }))
|
|
999
|
+
.sort((a, b) => {
|
|
1000
|
+
const pa = a.s.classification === 'primary' ? 0 : 1;
|
|
1001
|
+
const pb = b.s.classification === 'primary' ? 0 : 1;
|
|
1002
|
+
if (pa !== pb) return pa - pb;
|
|
1003
|
+
const da = a.s.start_date ?? '';
|
|
1004
|
+
const db = b.s.start_date ?? '';
|
|
1005
|
+
if (da !== db) return da < db ? 1 : -1;
|
|
1006
|
+
return a.i - b.i;
|
|
1007
|
+
})
|
|
1008
|
+
.map((x) => x.s)
|
|
1009
|
+
.slice(0, 4);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const TERRITORIES = new Set(['pr', 'gu', 'vi', 'as', 'mp']);
|
|
1013
|
+
|
|
1014
|
+
function jurisdictionPathId(jurisdiction: string): string {
|
|
1015
|
+
const j = jurisdiction.trim();
|
|
1016
|
+
if (/^[A-Za-z]{2}$/.test(j)) {
|
|
1017
|
+
const abbr = j.toLowerCase();
|
|
1018
|
+
const kind = abbr === 'dc' ? 'district' : TERRITORIES.has(abbr) ? 'territory' : 'state';
|
|
1019
|
+
return `ocd-jurisdiction/country:us/${kind}:${abbr}/government`;
|
|
1020
|
+
}
|
|
1021
|
+
return j.startsWith('ocd-jurisdiction/') ? j : encodeURIComponent(j);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Check a session value against the jurisdiction's real session list.
|
|
1026
|
+
* exact → it IS a valid identifier (an empty result is genuine).
|
|
1027
|
+
* candidates → year-shaped value mapped by date overlap.
|
|
1028
|
+
* Neither → throws, naming the valid identifiers, so a wrong id can never
|
|
1029
|
+
* masquerade as "no bills".
|
|
1030
|
+
*/
|
|
1031
|
+
async function resolveSession(
|
|
1032
|
+
apiKey: string,
|
|
1033
|
+
jurisdiction: string,
|
|
1034
|
+
session: string,
|
|
1035
|
+
): Promise<{ exact?: string; candidates: OsSession[] }> {
|
|
1036
|
+
const data = await osFetch<{ name?: string; legislative_sessions?: OsSession[] }>(
|
|
1037
|
+
apiKey,
|
|
1038
|
+
`/jurisdictions/${jurisdictionPathId(jurisdiction)}`,
|
|
1039
|
+
new URLSearchParams({ include: 'legislative_sessions' }),
|
|
1040
|
+
);
|
|
1041
|
+
const sessions = (data.legislative_sessions ?? []).filter((s) => s && s.identifier);
|
|
1042
|
+
const norm = session.trim().toLowerCase();
|
|
1043
|
+
const exact = sessions.find(
|
|
1044
|
+
(s) => s.identifier.toLowerCase() === norm || (s.name ?? '').trim().toLowerCase() === norm,
|
|
1045
|
+
);
|
|
1046
|
+
if (exact) {
|
|
1047
|
+
if (exact.identifier === session) return { exact: exact.identifier, candidates: [] };
|
|
1048
|
+
return { candidates: [exact] };
|
|
1049
|
+
}
|
|
1050
|
+
const candidates = resolveSessionCandidates(session, sessions);
|
|
1051
|
+
if (candidates.length) return { candidates };
|
|
1052
|
+
|
|
1053
|
+
const recent = [...sessions]
|
|
1054
|
+
.sort((a, b) => ((a.start_date ?? '') < (b.start_date ?? '') ? 1 : -1))
|
|
1055
|
+
.slice(0, 8)
|
|
1056
|
+
.map((s) => `"${s.identifier}"${s.name ? ` (${s.name})` : ''}`);
|
|
1057
|
+
throw new Error(
|
|
1058
|
+
`user_error: "${session}" is not a session in ${data.name ?? jurisdiction}. Session identifiers differ by state. ` +
|
|
1059
|
+
`Valid recent sessions: ${recent.join(', ') || 'none published'}. Omit session to search all sessions.`,
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
902
1063
|
async function getBill(apiKey: string, args: Record<string, unknown>) {
|
|
903
1064
|
const id = (args.openstates_id as string | undefined)?.trim();
|
|
904
1065
|
const params = new URLSearchParams({ include: 'sponsorships,actions,votes,versions,sources,abstracts' });
|
|
@@ -913,12 +1074,30 @@ async function getBill(apiKey: string, args: Record<string, unknown>) {
|
|
|
913
1074
|
if (!jurisdiction || !session || !identifier) {
|
|
914
1075
|
throw new Error('Pass either openstates_id, OR all three of jurisdiction + session + identifier.');
|
|
915
1076
|
}
|
|
916
|
-
const
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1077
|
+
const byTriple = (sess: string) =>
|
|
1078
|
+
osFetch<OsBill>(
|
|
1079
|
+
apiKey,
|
|
1080
|
+
`/bills/${encodeURIComponent(jurisdiction)}/${encodeURIComponent(sess)}/${encodeURIComponent(identifier)}`,
|
|
1081
|
+
params,
|
|
1082
|
+
);
|
|
1083
|
+
try {
|
|
1084
|
+
return normalizeBill(await byTriple(session), true);
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
if (!/HTTP 404/.test(String((err as Error)?.message))) throw err;
|
|
1087
|
+
// Session ids are per-state (fleet #2465): map a year-shaped value onto the
|
|
1088
|
+
// state's own session before calling the bill missing.
|
|
1089
|
+
const resolution = await resolveSession(apiKey, jurisdiction, session);
|
|
1090
|
+
if (resolution.exact) throw err;
|
|
1091
|
+
for (const cand of resolution.candidates) {
|
|
1092
|
+
try {
|
|
1093
|
+
const bill = normalizeBill(await byTriple(cand.identifier), true);
|
|
1094
|
+
return { ...bill, session_resolved: sessionNote(session, cand.identifier, cand.name, resolution.candidates) };
|
|
1095
|
+
} catch (e) {
|
|
1096
|
+
if (!/HTTP 404/.test(String((e as Error)?.message))) throw e;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
throw err;
|
|
1100
|
+
}
|
|
922
1101
|
}
|
|
923
1102
|
|
|
924
1103
|
interface OsPerson {
|
package/src/server.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
|
|
|
9
9
|
import pack from './index.js';
|
|
10
10
|
|
|
11
11
|
const server = new Server(
|
|
12
|
-
{ name: '@pipeworx/mcp-openstates', version: '0.1.
|
|
12
|
+
{ name: '@pipeworx/mcp-openstates', version: '0.1.3' },
|
|
13
13
|
{ capabilities: { tools: {} } },
|
|
14
14
|
);
|
|
15
15
|
|