@pipeworx/mcp-openstates 0.1.1 → 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 +231 -17
- 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
|
@@ -452,7 +452,42 @@ async function fetchWithTimeout(
|
|
|
452
452
|
),
|
|
453
453
|
);
|
|
454
454
|
}
|
|
455
|
-
|
|
455
|
+
// Fleet #2382. Everything that isn't a timeout/abort here is a genuine
|
|
456
|
+
// NETWORK-LEVEL failure — DNS resolution, connection refused, TLS handshake,
|
|
457
|
+
// Cloudflare's own "Network connection lost." — meaning `fetch()` itself
|
|
458
|
+
// threw and no HTTP response of any kind was ever received. Until this fix
|
|
459
|
+
// that raw exception was rethrown VERBATIM: a bare `TypeError: fetch failed`
|
|
460
|
+
// (or the Workers-runtime equivalent) names no upstream, carries no class
|
|
461
|
+
// token, and reads exactly like a defect in OUR code — because it says
|
|
462
|
+
// nothing about the call at all. It landed in `error`, the tier that means
|
|
463
|
+
// "Pipeworx has a defect", for every one of the (at the time of writing)
|
|
464
|
+
// ~470 packs that call this helper directly with no wrapper of their own.
|
|
465
|
+
//
|
|
466
|
+
// `dexscreener` hit this independently (fleet #1579) and fixed it with a
|
|
467
|
+
// bespoke per-pack try/catch around `fetchWithTimeout`. That fix is correct
|
|
468
|
+
// but only covers one pack; every other caller of this shared helper still
|
|
469
|
+
// leaked the raw exception. Moving the same fix HERE — the one place that
|
|
470
|
+
// already carries the timeout case — covers every pack that uses
|
|
471
|
+
// `fetchWithTimeout` without a wrapper, for free, and without widening
|
|
472
|
+
// `classifyToolError`'s regex list: the fix is giving the message a proper
|
|
473
|
+
// `upstream_down:` token at the point the two facts (no response was ever
|
|
474
|
+
// received, and which host we were trying to reach) are actually in hand,
|
|
475
|
+
// not teaching the classifier to guess from prose after the fact.
|
|
476
|
+
//
|
|
477
|
+
// Safe on the same grounds as the timeout branch above: no argument a
|
|
478
|
+
// caller passes can make `fetch()` itself throw a connection-level error,
|
|
479
|
+
// so this is always an availability failure, never a caller mistake. Same
|
|
480
|
+
// `markInternalOrigin` treatment — an origin we run that never answered is
|
|
481
|
+
// still ours, not a third party's outage.
|
|
482
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
483
|
+
throw new Error(
|
|
484
|
+
markInternalOrigin(
|
|
485
|
+
`upstream_down: could not reach ${name} at all (${raw.slice(0, 160)}). ` +
|
|
486
|
+
`No request reached ${name}, so this says NOTHING about whether the arguments you passed ` +
|
|
487
|
+
'are valid — do not re-check them on the strength of this error. Retry shortly.',
|
|
488
|
+
url,
|
|
489
|
+
),
|
|
490
|
+
);
|
|
456
491
|
}
|
|
457
492
|
}
|
|
458
493
|
|
|
@@ -673,7 +708,11 @@ const tools: McpToolExport['tools'] = [
|
|
|
673
708
|
properties: {
|
|
674
709
|
jurisdiction: { type: 'string', description: '2-letter state code or jurisdiction name' },
|
|
675
710
|
query: { type: 'string', description: 'Free-text search across title/summary' },
|
|
676
|
-
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
|
+
},
|
|
677
716
|
classification: { type: 'string', description: 'bill | resolution | constitutional amendment | etc.' },
|
|
678
717
|
sponsor: { type: 'string', description: 'Legislator name filter' },
|
|
679
718
|
sort: {
|
|
@@ -695,7 +734,11 @@ const tools: McpToolExport['tools'] = [
|
|
|
695
734
|
properties: {
|
|
696
735
|
openstates_id: { type: 'string', description: 'OpenStates bill ID (preferred, e.g., "ocd-bill/...")' },
|
|
697
736
|
jurisdiction: { type: 'string', description: 'State code (use with session + identifier)' },
|
|
698
|
-
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
|
+
},
|
|
699
742
|
identifier: { type: 'string', description: 'Bill identifier within the session (e.g., "AB-123")' },
|
|
700
743
|
},
|
|
701
744
|
required: [],
|
|
@@ -838,32 +881,185 @@ function normalizeBill(b: OsBill, full = false) {
|
|
|
838
881
|
return base;
|
|
839
882
|
}
|
|
840
883
|
|
|
884
|
+
type BillsPage = {
|
|
885
|
+
results?: OsBill[];
|
|
886
|
+
pagination?: { total_items?: number; total_pages?: number; page?: number };
|
|
887
|
+
};
|
|
888
|
+
|
|
841
889
|
async function searchBills(apiKey: string, args: Record<string, unknown>) {
|
|
890
|
+
const jurisdiction = String(args.jurisdiction);
|
|
842
891
|
const params = new URLSearchParams({
|
|
843
|
-
jurisdiction
|
|
892
|
+
jurisdiction,
|
|
844
893
|
per_page: String(Math.min(50, Math.max(1, (args.per_page as number) ?? 20))),
|
|
845
894
|
page: String(Math.max(1, (args.page as number) ?? 1)),
|
|
846
895
|
});
|
|
847
896
|
if (args.query) params.set('q', String(args.query));
|
|
848
|
-
|
|
897
|
+
const session = typeof args.session === 'string' ? args.session.trim() : args.session ? String(args.session) : '';
|
|
898
|
+
if (session) params.set('session', session);
|
|
849
899
|
if (args.classification) params.set('classification', String(args.classification));
|
|
850
900
|
if (args.sponsor) params.set('sponsor', String(args.sponsor));
|
|
851
901
|
if (args.sort) params.set('sort', String(args.sort));
|
|
852
902
|
|
|
853
|
-
const
|
|
854
|
-
results?: OsBill[];
|
|
855
|
-
pagination?: { total_items?: number; total_pages?: number; page?: number };
|
|
856
|
-
}>(apiKey, '/bills', params);
|
|
857
|
-
|
|
858
|
-
return {
|
|
903
|
+
const shape = (data: BillsPage, extra: Record<string, unknown> = {}) => ({
|
|
859
904
|
total: data.pagination?.total_items ?? 0,
|
|
860
905
|
page: data.pagination?.page ?? null,
|
|
861
906
|
total_pages: data.pagination?.total_pages ?? null,
|
|
862
907
|
returned: data.results?.length ?? 0,
|
|
908
|
+
...extra,
|
|
863
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.`,
|
|
864
944
|
};
|
|
865
945
|
}
|
|
866
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
|
+
|
|
867
1063
|
async function getBill(apiKey: string, args: Record<string, unknown>) {
|
|
868
1064
|
const id = (args.openstates_id as string | undefined)?.trim();
|
|
869
1065
|
const params = new URLSearchParams({ include: 'sponsorships,actions,votes,versions,sources,abstracts' });
|
|
@@ -878,12 +1074,30 @@ async function getBill(apiKey: string, args: Record<string, unknown>) {
|
|
|
878
1074
|
if (!jurisdiction || !session || !identifier) {
|
|
879
1075
|
throw new Error('Pass either openstates_id, OR all three of jurisdiction + session + identifier.');
|
|
880
1076
|
}
|
|
881
|
-
const
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
+
}
|
|
887
1101
|
}
|
|
888
1102
|
|
|
889
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
|
|