@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 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 1679+ 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 1679+ 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.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-0b59fb90fceece54a5d2cf88b2eb1a3f08e9e026b88dfd116fb703859835ad6a",
30
- "sourceCommit": "adb69d997f82a62e8e22b594488c7d04a227db35"
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.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
- throw err;
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: { 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
+ },
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: { 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
+ },
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: String(args.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
- 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);
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 data = await osFetch<{
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 data = await osFetch<OsBill>(
882
- apiKey,
883
- `/bills/${encodeURIComponent(jurisdiction)}/${encodeURIComponent(session)}/${encodeURIComponent(identifier)}`,
884
- params,
885
- );
886
- return normalizeBill(data, true);
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.1' },
12
+ { name: '@pipeworx/mcp-openstates', version: '0.1.3' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15