opensecrets-mcp 1.0.0
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 +27 -0
- package/dist/api.d.ts +12 -0
- package/dist/api.js +63 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +12 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +54 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# OpenSecrets MCP
|
|
2
|
+
|
|
3
|
+
MCP server for OpenSecrets: money in U.S. politics. Needs a free API key.
|
|
4
|
+
|
|
5
|
+
Pairs well with `fec-mcp`, `congress-gov-mcp`, and `govtrack-mcp`.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
Get a key at https://www.opensecrets.org/api/ (free), then:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
export OPENSECRETS_API_KEY=your_key_here
|
|
13
|
+
npm install
|
|
14
|
+
npm run build
|
|
15
|
+
node dist/index.js
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The server uses stdio, so it can be connected to Claude Desktop, Cursor, VS Code, MCP Inspector, or another compatible MCP client.
|
|
19
|
+
|
|
20
|
+
## Tools at a glance
|
|
21
|
+
|
|
22
|
+
- `search_candidates`: Candidates by name with party/state/chamber.
|
|
23
|
+
- `candidate_summary`: Raised, spent, cash, debt per cycle.
|
|
24
|
+
|
|
25
|
+
## Limits and privacy
|
|
26
|
+
|
|
27
|
+
This project is intentionally narrow. It should be treated as a practical helper, not a complete certification or security audit. Check the implementation and the returned data before using it with sensitive material. Your key stays local and is only sent to OpenSecrets. Read-only.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class OpenSecretsError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export interface Candidate {
|
|
4
|
+
id?: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
party?: string;
|
|
7
|
+
state?: string;
|
|
8
|
+
chamber?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function searchCandidates(query: string, cycle?: string): Promise<Candidate[]>;
|
|
11
|
+
export declare function candidateSummary(cid: string, cycle?: string): Promise<string>;
|
|
12
|
+
export declare function formatCandidate(c: Candidate, index?: number): string;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenSecrets API client. Needs OPENSECRETS_API_KEY
|
|
3
|
+
* (free at https://www.opensecrets.org/api/).
|
|
4
|
+
* Docs: https://www.opensecrets.org/resources/create/apis.php
|
|
5
|
+
*/
|
|
6
|
+
const BASE = "https://www.opensecrets.org/api";
|
|
7
|
+
export class OpenSecretsError extends Error {
|
|
8
|
+
}
|
|
9
|
+
function apiKey() {
|
|
10
|
+
const k = process.env.OPENSECRETS_API_KEY;
|
|
11
|
+
if (!k)
|
|
12
|
+
throw new OpenSecretsError("Set OPENSECRETS_API_KEY first (free at opensecrets.org/api).");
|
|
13
|
+
return k;
|
|
14
|
+
}
|
|
15
|
+
async function getJson(params) {
|
|
16
|
+
const qs = new URLSearchParams({ ...params, apikey: apiKey(), output: "json" }).toString();
|
|
17
|
+
const res = await fetch(`${BASE}/?${qs}`, {
|
|
18
|
+
headers: { "User-Agent": "opensecrets-mcp/1.0", Accept: "application/json" },
|
|
19
|
+
signal: AbortSignal.timeout(25000),
|
|
20
|
+
});
|
|
21
|
+
if (res.status === 401 || res.status === 403)
|
|
22
|
+
throw new OpenSecretsError("OpenSecrets refused (401/403). Check API key.");
|
|
23
|
+
if (!res.ok)
|
|
24
|
+
throw new OpenSecretsError(`OpenSecrets error ${res.status}`);
|
|
25
|
+
return (await res.json());
|
|
26
|
+
}
|
|
27
|
+
export async function searchCandidates(query, cycle = "2024") {
|
|
28
|
+
if (!query.trim())
|
|
29
|
+
throw new OpenSecretsError("Query is empty.");
|
|
30
|
+
const data = await getJson({ method: "getLegislators", id: query.trim() });
|
|
31
|
+
const rows = Array.isArray(data.response?.legislator) ? data.response.legislator : [];
|
|
32
|
+
const attrs = (r) => r["@attributes"] ?? {};
|
|
33
|
+
return rows.slice(0, 10).map((r) => {
|
|
34
|
+
const a = attrs(r);
|
|
35
|
+
return {
|
|
36
|
+
id: a.cid,
|
|
37
|
+
name: `${a.firstname ?? ""} ${a.lastname ?? ""}`.trim() || undefined,
|
|
38
|
+
party: a.party,
|
|
39
|
+
state: a.state,
|
|
40
|
+
chamber: a.chamber,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export async function candidateSummary(cid, cycle = "2024") {
|
|
45
|
+
if (!cid.trim())
|
|
46
|
+
throw new OpenSecretsError("Candidate id is empty.");
|
|
47
|
+
const data = await getJson({ method: "candSummary", cid: cid.trim(), cycle });
|
|
48
|
+
const a = data.response?.summary?.["@attributes"] ?? {};
|
|
49
|
+
if (!a.candidate_name)
|
|
50
|
+
throw new OpenSecretsError(`No summary for ${cid} in ${cycle}.`);
|
|
51
|
+
return [
|
|
52
|
+
`${a.candidate_name ?? cid}${a.party ? ` (${a.party}-${a.state ?? "?"})` : ""} — ${cycle} cycle`,
|
|
53
|
+
`Raised: $${Number(a.total ?? 0).toLocaleString()}`,
|
|
54
|
+
`Spent: $${Number(a.spent ?? 0).toLocaleString()}`,
|
|
55
|
+
`Cash on hand: $${Number(a.cash_on_hand ?? 0).toLocaleString()}`,
|
|
56
|
+
`Debt: $${Number(a.debt ?? 0).toLocaleString()}`,
|
|
57
|
+
a.source ? `Source: ${a.source}` : "",
|
|
58
|
+
].filter(Boolean).join("\n");
|
|
59
|
+
}
|
|
60
|
+
export function formatCandidate(c, index) {
|
|
61
|
+
const prefix = index !== undefined ? `${index + 1}. ` : "";
|
|
62
|
+
return `${prefix}[${c.id ?? "?"}] ${c.name ?? "(unnamed)"}${c.party ? ` (${c.party}${c.state ? `-${c.state}` : ""})` : ""}${c.chamber ? ` — ${c.chamber}` : ""}`;
|
|
63
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
+
import { createServer } from "./server.js";
|
|
3
|
+
async function main() {
|
|
4
|
+
const server = createServer();
|
|
5
|
+
const transport = new StdioServerTransport();
|
|
6
|
+
await server.connect(transport);
|
|
7
|
+
console.error("MCP server running on stdio");
|
|
8
|
+
}
|
|
9
|
+
main().catch((err) => {
|
|
10
|
+
console.error("Fatal error:", err);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
});
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { candidateSummary, formatCandidate, OpenSecretsError, searchCandidates } from "./api.js";
|
|
4
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
5
|
+
const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
6
|
+
const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
|
|
7
|
+
export function createServer() {
|
|
8
|
+
const server = new McpServer({
|
|
9
|
+
name: "opensecrets-mcp",
|
|
10
|
+
version: "1.0.0",
|
|
11
|
+
});
|
|
12
|
+
server.registerTool("search_candidates", {
|
|
13
|
+
title: "Search candidates",
|
|
14
|
+
description: "Find federal candidates by name fragment: party, state, chamber, CRP id.",
|
|
15
|
+
inputSchema: z.object({
|
|
16
|
+
query: z.string().describe("Name fragment, e.g. 'Smith'"),
|
|
17
|
+
}),
|
|
18
|
+
annotations: READ_ONLY,
|
|
19
|
+
}, async ({ query }) => {
|
|
20
|
+
try {
|
|
21
|
+
const rows = await searchCandidates(query);
|
|
22
|
+
if (rows.length === 0)
|
|
23
|
+
return text(`No candidates match "${query}".`);
|
|
24
|
+
return text(rows.map((c, i) => formatCandidate(c, i)).join("\n"));
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
return textError(errorMessage(e));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
server.registerTool("candidate_summary", {
|
|
31
|
+
title: "Money summary",
|
|
32
|
+
description: "Raised, spent, cash on hand and debt for one candidate and cycle.",
|
|
33
|
+
inputSchema: z.object({
|
|
34
|
+
cid: z.string().describe("CRP candidate id, e.g. 'N00007360' (use search_candidates)"),
|
|
35
|
+
cycle: z.string().default("2024").describe("Election cycle, e.g. '2024'"),
|
|
36
|
+
}),
|
|
37
|
+
annotations: READ_ONLY,
|
|
38
|
+
}, async ({ cid, cycle }) => {
|
|
39
|
+
try {
|
|
40
|
+
return text(await candidateSummary(cid, cycle));
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
return textError(errorMessage(e));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return server;
|
|
47
|
+
}
|
|
48
|
+
function errorMessage(e) {
|
|
49
|
+
if (e instanceof OpenSecretsError)
|
|
50
|
+
return `Error: ${e.message}`;
|
|
51
|
+
if (e instanceof Error)
|
|
52
|
+
return `Error: ${e.message}`;
|
|
53
|
+
return `Error: ${String(e)}`;
|
|
54
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opensecrets-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for OpenSecrets: money in U.S. politics. Needs a free API key.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"mcpName": "io.github.mrfentmen/opensecrets-mcp",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/mrfentmen/awesome-mcps.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"opensecrets-mcp": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"start": "node dist/index.js",
|
|
21
|
+
"dev": "rm -rf dist && tsc -p tsconfig.json && node dist/index.js",
|
|
22
|
+
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"opensecrets",
|
|
27
|
+
"politics",
|
|
28
|
+
"api"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
33
|
+
"zod": "^3.23.8"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^22.0.0",
|
|
37
|
+
"typescript": "^5.6.0"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
}
|
|
42
|
+
}
|