@pipeworx/mcp-openstates 0.1.2 → 0.1.4
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 +218 -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.4",
|
|
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-d9939703c0e177f41da0fbd131835af380ebc15e9c75b8fd58b9dbebd35552be",
|
|
30
|
+
"sourceCommit": "69152ffc15233d5998bfe6fe195c040cdb328366"
|
|
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.4",
|
|
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,208 @@ 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 = isSpecialSession(a.s) ? 1 : 0;
|
|
1001
|
+
const pb = isSpecialSession(b.s) ? 1 : 0;
|
|
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
|
+
// OpenStates does not always populate `classification` (Texas leaves it off and
|
|
1013
|
+
// names its specials "…Called Session"), so read the name too — otherwise the
|
|
1014
|
+
// newest special session outranks the regular one for the same year.
|
|
1015
|
+
function isSpecialSession(s: OsSession): boolean {
|
|
1016
|
+
if (s.classification) return s.classification !== 'primary';
|
|
1017
|
+
return /\b(special|called|extraordinary|extra)\b/i.test(s.name ?? '');
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const TERRITORIES = new Set(['pr', 'gu', 'vi', 'as', 'mp']);
|
|
1021
|
+
|
|
1022
|
+
function jurisdictionPathId(jurisdiction: string): string {
|
|
1023
|
+
const j = jurisdiction.trim();
|
|
1024
|
+
if (/^[A-Za-z]{2}$/.test(j)) {
|
|
1025
|
+
const abbr = j.toLowerCase();
|
|
1026
|
+
const kind = abbr === 'dc' ? 'district' : TERRITORIES.has(abbr) ? 'territory' : 'state';
|
|
1027
|
+
return `ocd-jurisdiction/country:us/${kind}:${abbr}/government`;
|
|
1028
|
+
}
|
|
1029
|
+
return j.startsWith('ocd-jurisdiction/') ? j : encodeURIComponent(j);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Session lists change a few times a year; the upstream free tier is small and
|
|
1033
|
+
// per-minute throttled, so keep them per isolate for an hour rather than
|
|
1034
|
+
// spending a request on every zero-row search.
|
|
1035
|
+
const SESSION_TTL_MS = 60 * 60 * 1000;
|
|
1036
|
+
const sessionCache = new Map<string, { at: number; data: { name?: string; legislative_sessions?: OsSession[] } }>();
|
|
1037
|
+
|
|
1038
|
+
async function jurisdictionSessions(apiKey: string, jurisdiction: string) {
|
|
1039
|
+
const key = jurisdictionPathId(jurisdiction);
|
|
1040
|
+
const hit = sessionCache.get(key);
|
|
1041
|
+
if (hit && Date.now() - hit.at < SESSION_TTL_MS) return hit.data;
|
|
1042
|
+
const data = await osFetch<{ name?: string; legislative_sessions?: OsSession[] }>(
|
|
1043
|
+
apiKey,
|
|
1044
|
+
`/jurisdictions/${key}`,
|
|
1045
|
+
new URLSearchParams({ include: 'legislative_sessions' }),
|
|
1046
|
+
);
|
|
1047
|
+
sessionCache.set(key, { at: Date.now(), data });
|
|
1048
|
+
return data;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Check a session value against the jurisdiction's real session list.
|
|
1053
|
+
* exact → it IS a valid identifier (an empty result is genuine).
|
|
1054
|
+
* candidates → year-shaped value mapped by date overlap.
|
|
1055
|
+
* Neither → throws, naming the valid identifiers, so a wrong id can never
|
|
1056
|
+
* masquerade as "no bills".
|
|
1057
|
+
*/
|
|
1058
|
+
async function resolveSession(
|
|
1059
|
+
apiKey: string,
|
|
1060
|
+
jurisdiction: string,
|
|
1061
|
+
session: string,
|
|
1062
|
+
): Promise<{ exact?: string; candidates: OsSession[] }> {
|
|
1063
|
+
const data = await jurisdictionSessions(apiKey, jurisdiction);
|
|
1064
|
+
const sessions = (data.legislative_sessions ?? []).filter((s) => s && s.identifier);
|
|
1065
|
+
const norm = session.trim().toLowerCase();
|
|
1066
|
+
const exact = sessions.find(
|
|
1067
|
+
(s) => s.identifier.toLowerCase() === norm || (s.name ?? '').trim().toLowerCase() === norm,
|
|
1068
|
+
);
|
|
1069
|
+
if (exact) {
|
|
1070
|
+
if (exact.identifier === session) return { exact: exact.identifier, candidates: [] };
|
|
1071
|
+
return { candidates: [exact] };
|
|
1072
|
+
}
|
|
1073
|
+
const candidates = resolveSessionCandidates(session, sessions);
|
|
1074
|
+
if (candidates.length) return { candidates };
|
|
1075
|
+
|
|
1076
|
+
const recent = [...sessions]
|
|
1077
|
+
.sort((a, b) => ((a.start_date ?? '') < (b.start_date ?? '') ? 1 : -1))
|
|
1078
|
+
.slice(0, 8)
|
|
1079
|
+
.map((s) => `"${s.identifier}"${s.name ? ` (${s.name})` : ''}`);
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
`user_error: "${session}" is not a session in ${data.name ?? jurisdiction}. Session identifiers differ by state. ` +
|
|
1082
|
+
`Valid recent sessions: ${recent.join(', ') || 'none published'}. Omit session to search all sessions.`,
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
902
1086
|
async function getBill(apiKey: string, args: Record<string, unknown>) {
|
|
903
1087
|
const id = (args.openstates_id as string | undefined)?.trim();
|
|
904
1088
|
const params = new URLSearchParams({ include: 'sponsorships,actions,votes,versions,sources,abstracts' });
|
|
@@ -913,12 +1097,30 @@ async function getBill(apiKey: string, args: Record<string, unknown>) {
|
|
|
913
1097
|
if (!jurisdiction || !session || !identifier) {
|
|
914
1098
|
throw new Error('Pass either openstates_id, OR all three of jurisdiction + session + identifier.');
|
|
915
1099
|
}
|
|
916
|
-
const
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1100
|
+
const byTriple = (sess: string) =>
|
|
1101
|
+
osFetch<OsBill>(
|
|
1102
|
+
apiKey,
|
|
1103
|
+
`/bills/${encodeURIComponent(jurisdiction)}/${encodeURIComponent(sess)}/${encodeURIComponent(identifier)}`,
|
|
1104
|
+
params,
|
|
1105
|
+
);
|
|
1106
|
+
try {
|
|
1107
|
+
return normalizeBill(await byTriple(session), true);
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
if (!/HTTP 404/.test(String((err as Error)?.message))) throw err;
|
|
1110
|
+
// Session ids are per-state (fleet #2465): map a year-shaped value onto the
|
|
1111
|
+
// state's own session before calling the bill missing.
|
|
1112
|
+
const resolution = await resolveSession(apiKey, jurisdiction, session);
|
|
1113
|
+
if (resolution.exact) throw err;
|
|
1114
|
+
for (const cand of resolution.candidates) {
|
|
1115
|
+
try {
|
|
1116
|
+
const bill = normalizeBill(await byTriple(cand.identifier), true);
|
|
1117
|
+
return { ...bill, session_resolved: sessionNote(session, cand.identifier, cand.name, resolution.candidates) };
|
|
1118
|
+
} catch (e) {
|
|
1119
|
+
if (!/HTTP 404/.test(String((e as Error)?.message))) throw e;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
throw err;
|
|
1123
|
+
}
|
|
922
1124
|
}
|
|
923
1125
|
|
|
924
1126
|
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.4' },
|
|
13
13
|
{ capabilities: { tools: {} } },
|
|
14
14
|
);
|
|
15
15
|
|