@pipeworx/mcp-congress 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/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
 
@@ -637,6 +672,7 @@ function collapse(s: string): string {
637
672
  }
638
673
  /**
639
674
  * Congress MCP — US Congress data via GovTrack API (free, no auth required)
675
+ * plus committee rosters/leadership baked from a second, public-domain source.
640
676
  *
641
677
  * Tools:
642
678
  * - search_bills: Search congressional bills by name, subject or keyword, ranked locally by title match
@@ -644,8 +680,32 @@ function collapse(s: string): string {
644
680
  * - get_members: Get current members of Congress
645
681
  * - get_votes: Get recent congressional votes (newest first)
646
682
  * - get_recent_bills: Most recently introduced / acted-on bills
683
+ * - committee_leadership: every standing committee in a chamber with its chair + ranking member
684
+ * - committee_members: one committee's full roster (party, rank, chair/ranking title)
685
+ * - member_committees: one member's committee assignments by bioguide id
686
+ *
687
+ * COMMITTEE DATA (fleet #2843): GovTrack's API has no committee-membership or
688
+ * committee-leadership endpoint at all, so committee_leadership/
689
+ * committee_members/member_committees are baked from a SEPARATE source —
690
+ * github.com/unitedstates/congress-legislators (committees-current.yaml,
691
+ * committee-membership-current.yaml, legislators-current.yaml), CC0 1.0
692
+ * Universal ("no rights reserved" per that repo's own LICENSE, verified
693
+ * live). Refreshed by scripts/refresh-congress-committees.mjs into
694
+ * ./committee-index-data.ts, which is NEVER value-imported here (fleet #2754
695
+ * — a baked table as a static import blew the gateway bundle 24.4->37.8MiB
696
+ * and caused edge 1102s on ~1/4 of requests) — it is registered in
697
+ * workers/gateway/src/pack-baked-indexes.json, uploaded to KV at deploy, and
698
+ * injected as `args._bakedIndex` on every call into this pack. This data
699
+ * changes on a committee-reshuffle cadence, not daily; `data_as_of` on every
700
+ * response is the source files' own last-commit date, not "now".
647
701
  */
648
702
 
703
+ import type {
704
+ CommitteeRow,
705
+ CommitteeMemberRow,
706
+ MemberCommitteeAssignment,
707
+ MemberProfile,
708
+ } from './committee-index-data.js';
649
709
 
650
710
  // Bound every fetch() in this pack to a fixed timeout — an upstream that
651
711
  // degrades without erroring would otherwise hold the Worker in `await fetch()`
@@ -795,6 +855,243 @@ function formatVote(v: RawVote) {
795
855
  };
796
856
  }
797
857
 
858
+ // --- Baked committee index (fleet #2843) ---
859
+ //
860
+ // NEVER value-import ./committee-index-data.js here — the type-only import
861
+ // above is erased from the bundle; a value import is not (same trap as
862
+ // mcps/arkansas-code/src/index.ts's AR_SECTIONS, fleet #2754).
863
+ let COMMITTEES: CommitteeRow[] = [];
864
+ let COMMITTEE_MEMBERSHIP: Record<string, CommitteeMemberRow[]> = {};
865
+ let MEMBER_COMMITTEES: Record<string, MemberCommitteeAssignment[]> = {};
866
+ let MEMBER_PROFILES: Record<string, MemberProfile> = {};
867
+ let CONGRESS_DATA_AS_OF = '';
868
+ let COMMITTEE_INDEX_LOADED = false;
869
+
870
+ function ensureCommitteeIndex(args: Record<string, unknown>): void {
871
+ if (COMMITTEE_INDEX_LOADED) return;
872
+ const idx = args._bakedIndex as Record<string, unknown> | null | undefined;
873
+ const committees = idx?.COMMITTEES;
874
+ const membership = idx?.COMMITTEE_MEMBERSHIP;
875
+ const memberCommittees = idx?.MEMBER_COMMITTEES;
876
+ const memberProfiles = idx?.MEMBER_PROFILES;
877
+ const dataAsOf = idx?.CONGRESS_DATA_AS_OF;
878
+ const ok =
879
+ Array.isArray(committees) &&
880
+ committees.length > 0 &&
881
+ typeof membership === 'object' &&
882
+ membership !== null &&
883
+ typeof memberCommittees === 'object' &&
884
+ memberCommittees !== null &&
885
+ typeof memberProfiles === 'object' &&
886
+ memberProfiles !== null &&
887
+ typeof dataAsOf === 'string' &&
888
+ dataAsOf.length > 0;
889
+ if (!ok) {
890
+ // Loud on purpose, same posture as ar-code's ensureIndex: a missing or
891
+ // malformed baked index is a server-side KV fault, not "no committees
892
+ // exist" — collapsing the two would be exactly the silent-empty-array
893
+ // trap docs/silent-zero-policy.md holds every pack to.
894
+ throw new Error(
895
+ `congress: the baked committee index is unavailable (${idx == null ? 'not injected' : 'malformed'}). ` +
896
+ 'The gateway loads it from KV on the first call into committee_leadership/committee_members/member_committees; ' +
897
+ 'this is a server-side fault, not an empty roster — retry shortly. search_bills/get_bill/get_members/get_votes/get_recent_bills are unaffected (they call GovTrack live and never touch this index).',
898
+ );
899
+ }
900
+ COMMITTEES = committees as CommitteeRow[];
901
+ COMMITTEE_MEMBERSHIP = membership as Record<string, CommitteeMemberRow[]>;
902
+ MEMBER_COMMITTEES = memberCommittees as Record<string, MemberCommitteeAssignment[]>;
903
+ MEMBER_PROFILES = memberProfiles as Record<string, MemberProfile>;
904
+ CONGRESS_DATA_AS_OF = dataAsOf;
905
+ COMMITTEE_INDEX_LOADED = true;
906
+ }
907
+
908
+ const VALID_CHAMBERS = new Set(['house', 'senate', 'joint']);
909
+
910
+ /**
911
+ * Chair/ranking member resolution: prefer the explicit title (handles
912
+ * "Chair"/"Chairman"/"Chairwoman" and "Ranking Member"); fall back to
913
+ * majority/minority rank 1 for the handful of caucuses and select
914
+ * committees (e.g. the Senate Intelligence/Ethics committees, the Joint
915
+ * Commission on Security and Cooperation in Europe) that the source data
916
+ * does not label with an explicit title.
917
+ */
918
+ function resolveLeadership(members: CommitteeMemberRow[]): {
919
+ chair: CommitteeMemberRow | null;
920
+ ranking_member: CommitteeMemberRow | null;
921
+ } {
922
+ const chair =
923
+ members.find((m) => typeof m.title === 'string' && /^Chair/i.test(m.title)) ??
924
+ members.find((m) => m.party_role === 'majority' && m.rank === 1) ??
925
+ null;
926
+ const ranking =
927
+ members.find((m) => m.title === 'Ranking Member') ??
928
+ members.find((m) => m.party_role === 'minority' && m.rank === 1) ??
929
+ null;
930
+ return { chair, ranking_member: ranking };
931
+ }
932
+
933
+ /** Words that appear in nearly every committee name and carry no identifying signal. */
934
+ const COMMITTEE_NAME_STOPWORDS = new Set([
935
+ 'house', 'senate', 'joint', 'committee', 'committees', 'on', 'the', 'select',
936
+ 'permanent', 'special', 'of', 'congress', 'united', 'states', 'us', 'subcommittee',
937
+ 'and', 'for',
938
+ ]);
939
+
940
+ function normalizeCommitteeName(s: string): string[] {
941
+ return s
942
+ .toLowerCase()
943
+ .replace(/[^a-z0-9]+/g, ' ')
944
+ .trim()
945
+ .split(' ')
946
+ .filter((w) => w && !COMMITTEE_NAME_STOPWORDS.has(w));
947
+ }
948
+
949
+ /** Full-committee (not subcommittee) name search — every query word must appear in (or be a prefix/suffix of) a candidate word. */
950
+ function findCommitteesByName(name: string, chamber: string | undefined): CommitteeRow[] {
951
+ const qWords = normalizeCommitteeName(name);
952
+ if (qWords.length === 0) return [];
953
+ return COMMITTEES.filter((c) => {
954
+ if (chamber && c.chamber !== chamber) return false;
955
+ const cWords = normalizeCommitteeName(c.name);
956
+ return qWords.every((w) => cWords.some((cw) => cw === w || cw.startsWith(w) || w.startsWith(cw)));
957
+ });
958
+ }
959
+
960
+ function findCommitteeById(id: string): CommitteeRow | undefined {
961
+ const norm = id.trim().toUpperCase();
962
+ return COMMITTEES.find((c) => c.id.toUpperCase() === norm);
963
+ }
964
+
965
+ function buildFullCommittee(c: CommitteeRow) {
966
+ const members = COMMITTEE_MEMBERSHIP[c.id] ?? [];
967
+ const { chair, ranking_member } = resolveLeadership(members);
968
+ return {
969
+ id: c.id,
970
+ name: c.name,
971
+ chamber: c.chamber,
972
+ url: c.url,
973
+ minority_url: c.minority_url,
974
+ jurisdiction: c.jurisdiction,
975
+ chair,
976
+ ranking_member,
977
+ member_count: members.length,
978
+ members,
979
+ subcommittees: c.subcommittees,
980
+ };
981
+ }
982
+
983
+ function committeeLeadership(args: Record<string, unknown>) {
984
+ ensureCommitteeIndex(args);
985
+ const chamberArg = typeof args.chamber === 'string' ? args.chamber.trim().toLowerCase() : undefined;
986
+ if (chamberArg && !VALID_CHAMBERS.has(chamberArg)) {
987
+ return { error: `chamber must be "house", "senate" or "joint" — got ${JSON.stringify(args.chamber)}`, chamber: args.chamber ?? null };
988
+ }
989
+ const committees = COMMITTEES.filter((c) => !chamberArg || c.chamber === chamberArg);
990
+ return {
991
+ chamber: chamberArg ?? 'all',
992
+ congress: 119,
993
+ data_as_of: CONGRESS_DATA_AS_OF,
994
+ source: 'github.com/unitedstates/congress-legislators (public domain, CC0 1.0 Universal)',
995
+ total: committees.length,
996
+ committees: committees.map((c) => {
997
+ const members = COMMITTEE_MEMBERSHIP[c.id] ?? [];
998
+ const { chair, ranking_member } = resolveLeadership(members);
999
+ return {
1000
+ id: c.id,
1001
+ name: c.name,
1002
+ chamber: c.chamber,
1003
+ url: c.url,
1004
+ chair,
1005
+ ranking_member,
1006
+ member_count: members.length,
1007
+ };
1008
+ }),
1009
+ };
1010
+ }
1011
+
1012
+ function committeeMembers(args: Record<string, unknown>) {
1013
+ ensureCommitteeIndex(args);
1014
+ const chamberArg = typeof args.chamber === 'string' ? args.chamber.trim().toLowerCase() : undefined;
1015
+ if (chamberArg && !VALID_CHAMBERS.has(chamberArg)) {
1016
+ return { error: `chamber must be "house", "senate" or "joint" — got ${JSON.stringify(args.chamber)}`, chamber: args.chamber ?? null };
1017
+ }
1018
+ const idArg = typeof args.committee_id === 'string' && args.committee_id.trim() ? args.committee_id.trim() : undefined;
1019
+ const nameArg = typeof args.name === 'string' && args.name.trim() ? args.name.trim() : undefined;
1020
+ if (!idArg && !nameArg) {
1021
+ return { error: 'Pass committee_id (e.g. "HSWM") or name (e.g. "Ways and Means", "Appropriations") — call committee_leadership first to see every valid id/name.' };
1022
+ }
1023
+
1024
+ if (idArg) {
1025
+ const top = findCommitteeById(idArg);
1026
+ if (top) return { data_as_of: CONGRESS_DATA_AS_OF, ...buildFullCommittee(top) };
1027
+ for (const c of COMMITTEES) {
1028
+ const sub = c.subcommittees.find((s) => s.id.toUpperCase() === idArg.toUpperCase());
1029
+ if (sub) {
1030
+ const members = COMMITTEE_MEMBERSHIP[sub.id] ?? [];
1031
+ const { chair, ranking_member } = resolveLeadership(members);
1032
+ return {
1033
+ data_as_of: CONGRESS_DATA_AS_OF,
1034
+ id: sub.id,
1035
+ name: `${c.name} Subcommittee on ${sub.name}`,
1036
+ chamber: c.chamber,
1037
+ parent_committee_id: c.id,
1038
+ parent_committee_name: c.name,
1039
+ chair,
1040
+ ranking_member,
1041
+ member_count: members.length,
1042
+ members,
1043
+ };
1044
+ }
1045
+ }
1046
+ return {
1047
+ found: false,
1048
+ error: `No committee or subcommittee with id "${idArg}". Call committee_leadership to list valid ids.`,
1049
+ committee_id: idArg,
1050
+ };
1051
+ }
1052
+
1053
+ const matches = findCommitteesByName(nameArg!, chamberArg);
1054
+ if (matches.length === 0) {
1055
+ return {
1056
+ found: false,
1057
+ error: `No committee name matched "${nameArg}"${chamberArg ? ` in the ${chamberArg}` : ''}. Try a shorter name (e.g. "Appropriations", "Judiciary", "Ways and Means"), or pass chamber to disambiguate.`,
1058
+ name: nameArg,
1059
+ };
1060
+ }
1061
+ if (matches.length === 1) {
1062
+ return { data_as_of: CONGRESS_DATA_AS_OF, ...buildFullCommittee(matches[0]) };
1063
+ }
1064
+ return {
1065
+ data_as_of: CONGRESS_DATA_AS_OF,
1066
+ ambiguous: true,
1067
+ note: `"${nameArg}" matched ${matches.length} committees (one per chamber, usually) — pass chamber: "house" or "senate" to pick one.`,
1068
+ matches: matches.map(buildFullCommittee),
1069
+ };
1070
+ }
1071
+
1072
+ function memberCommittees(args: Record<string, unknown>) {
1073
+ ensureCommitteeIndex(args);
1074
+ const bioguide = typeof args.bioguide_id === 'string' ? args.bioguide_id.trim().toUpperCase() : undefined;
1075
+ if (!bioguide) {
1076
+ return { error: 'Pass bioguide_id — a Congress.gov/GovInfo "bioguide" id, e.g. "N000015" for Richard Neal, "G000386" for Chuck Grassley.' };
1077
+ }
1078
+ const assignments = MEMBER_COMMITTEES[bioguide];
1079
+ if (!assignments || assignments.length === 0) {
1080
+ return {
1081
+ found: false,
1082
+ error: `No committee assignments found for bioguide "${bioguide}". This covers the current (119th) Congress only; check the id is correct and the member currently serves.`,
1083
+ bioguide_id: bioguide,
1084
+ };
1085
+ }
1086
+ return {
1087
+ data_as_of: CONGRESS_DATA_AS_OF,
1088
+ bioguide_id: bioguide,
1089
+ profile: MEMBER_PROFILES[bioguide] ?? null,
1090
+ total: assignments.length,
1091
+ committees: assignments,
1092
+ };
1093
+ }
1094
+
798
1095
  // --- Tool definitions ---
799
1096
 
800
1097
  const tools: McpToolExport['tools'] = [
@@ -881,6 +1178,42 @@ const tools: McpToolExport['tools'] = [
881
1178
  },
882
1179
  },
883
1180
  },
1181
+ {
1182
+ name: 'committee_leadership',
1183
+ description:
1184
+ 'ANSWERS "who chairs X committee" / "who are the ranking members of every House committee" / "list of committee chairs in Congress" — returns EVERY standing committee in one chamber (or all three chambers) of the 119th Congress with its chair and ranking member (name, party, bioguide id). Pass chamber to scope to "house", "senate" or "joint"; omit it for all three. For one committee\'s FULL roster (every member, not just the chair/ranking pair) use committee_members instead. SOURCE: github.com/unitedstates/congress-legislators (public domain), baked periodically — NOT GovTrack, which has no committee-leadership endpoint at all. data_as_of in the response is the source data\'s own last-update date.',
1185
+ inputSchema: {
1186
+ type: 'object',
1187
+ properties: {
1188
+ chamber: { type: 'string', description: '"house", "senate" or "joint". Omit to return every chamber.' },
1189
+ },
1190
+ },
1191
+ },
1192
+ {
1193
+ name: 'committee_members',
1194
+ description:
1195
+ 'ANSWERS "who is on the House Appropriations committee" / "roster of the Senate Judiciary committee" / "Democratic members of Ways and Means" — returns one committee\'s full membership with each member\'s party, rank and title (Chair / Ranking Member / plain member), plus the committee\'s chair, ranking member and list of subcommittees. Identify the committee by committee_id (the exact id from committee_leadership, e.g. "HSWM" for House Ways and Means, or a subcommittee id like "HSWM05") OR by name (e.g. "Appropriations", "Ways and Means", "Judiciary") — a name with no chamber that matches committees in BOTH the House and Senate (e.g. "Appropriations") returns both as `matches` rather than guessing; pass chamber to pick one directly. For the chair/ranking member of EVERY committee at once, use committee_leadership instead — it is cheaper when you do not need the full roster. SOURCE: github.com/unitedstates/congress-legislators (public domain), baked periodically, current (119th) Congress only.',
1196
+ inputSchema: {
1197
+ type: 'object',
1198
+ properties: {
1199
+ committee_id: { type: 'string', description: 'Exact committee or subcommittee id, e.g. "HSWM" (House Ways and Means) or "SSJU" (Senate Judiciary). Takes priority over name.' },
1200
+ name: { type: 'string', description: 'Committee name or partial name to search for, e.g. "Appropriations", "Judiciary", "Foreign Affairs".' },
1201
+ chamber: { type: 'string', description: 'Optional: "house", "senate" or "joint" — disambiguates a name that matches more than one chamber.' },
1202
+ },
1203
+ },
1204
+ },
1205
+ {
1206
+ name: 'member_committees',
1207
+ description:
1208
+ 'ANSWERS "what committees is X on" / "committee assignments for Senator X" — returns every committee and subcommittee assignment (committee name, chamber, rank, title) for one member of the 119th Congress, identified by their bioguide id (the standard Congress.gov/GovInfo member identifier, e.g. "N000015" for Richard Neal — committee_leadership and committee_members both return bioguide ids for the members they list, so look a member up there first if you only have a name). SOURCE: github.com/unitedstates/congress-legislators (public domain), baked periodically, current Congress only.',
1209
+ inputSchema: {
1210
+ type: 'object',
1211
+ properties: {
1212
+ bioguide_id: { type: 'string', description: 'The member\'s bioguide id, e.g. "N000015".' },
1213
+ },
1214
+ required: ['bioguide_id'],
1215
+ },
1216
+ },
884
1217
  ];
885
1218
 
886
1219
  // --- callTool dispatcher ---
@@ -901,6 +1234,12 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
901
1234
  args.congress as number | undefined,
902
1235
  (args.limit as number) ?? 20,
903
1236
  );
1237
+ case 'committee_leadership':
1238
+ return committeeLeadership(args);
1239
+ case 'committee_members':
1240
+ return committeeMembers(args);
1241
+ case 'member_committees':
1242
+ return memberCommittees(args);
904
1243
  default:
905
1244
  throw new Error(`Unknown tool: ${name}`);
906
1245
  }
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-congress', version: '0.1.1' },
12
+ { name: '@pipeworx/mcp-congress', version: '0.1.3' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15