@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 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 1683+ live data sources.
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 1683+ data sources. The
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.2",
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-251fdf0966b6cf0200dd5aeee47cb5654f16e519f6388124738e4e1c82dc80e9",
30
- "sourceCommit": "97740c6ee04c8acbfe7dddcacfde9e88ff2b9ca0"
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.2",
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: { type: 'string', description: 'Session identifier (e.g., "20232024")' },
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: { type: 'string', description: 'Session ID (use with jurisdiction + identifier)' },
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: String(args.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
- if (args.session) params.set('session', String(args.session));
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 data = await osFetch<{
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 data = await osFetch<OsBill>(
917
- apiKey,
918
- `/bills/${encodeURIComponent(jurisdiction)}/${encodeURIComponent(session)}/${encodeURIComponent(identifier)}`,
919
- params,
920
- );
921
- return normalizeBill(data, true);
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.2' },
12
+ { name: '@pipeworx/mcp-openstates', version: '0.1.4' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15