tokens-for-good 0.4.2 → 0.4.5
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 +9 -4
- package/package.json +1 -1
- package/pipeline/01-research/PROMPT.md +23 -14
- package/pipeline/04-peer-review/PROMPT.md +3 -5
- package/src/api-client.js +2 -2
- package/src/cli.js +1 -1
- package/src/init.js +54 -22
- package/src/mcp-server.js +356 -351
- package/src/platform.js +15 -2
- package/src/state.js +1 -0
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Tokens for Good
|
|
2
2
|
|
|
3
|
-
Donate your spare AI tokens to research nonprofit organizations for [Fierce Philanthropy](https://
|
|
3
|
+
Donate your spare AI tokens to research nonprofit organizations for [Fierce Philanthropy](https://tokensforgood.ai)'s social impact directory. Like Folding@Home, but for AI tokens — crowdsourced compute for social good.
|
|
4
4
|
|
|
5
|
-
Works with Claude Code, OpenCode, Cursor, Windsurf, and
|
|
5
|
+
Works with Claude Code, OpenCode, Cursor, Windsurf, Devin, and Qwen Code as an MCP server.
|
|
6
6
|
|
|
7
7
|
## Quickstart
|
|
8
8
|
|
|
9
|
-
1. **Sign up** at [
|
|
9
|
+
1. **Sign up** at [tokensforgood.ai/contribute](https://tokensforgood.ai/contribute) (GitHub OAuth, free) and copy your API key.
|
|
10
10
|
2. **Run init in your terminal:**
|
|
11
11
|
|
|
12
12
|
```bash
|
|
@@ -58,9 +58,14 @@ Once installed, these are available to your AI via the MCP server:
|
|
|
58
58
|
## Non-Claude-Code platforms
|
|
59
59
|
|
|
60
60
|
- **OpenCode** — `init` writes `~/.config/opencode/opencode.json` and prints a cron line you can paste into `crontab -e`.
|
|
61
|
+
- **Qwen Code** — `init` writes `~/.qwen/settings.json` (preserving other keys) plus a `/tfg` slash command at `~/.qwen/commands/tfg.md`. For recurring runs, enable Qwen Code's experimental cron (`QWEN_CODE_ENABLE_CRON=1`) or use a system cron line.
|
|
61
62
|
- **Cursor / Windsurf / Devin** — `init` writes the MCP config; automation requires platform-native scheduling.
|
|
62
63
|
|
|
63
|
-
##
|
|
64
|
+
## Contributing
|
|
65
|
+
|
|
66
|
+
TFG has been built and tested primarily on **Claude Code**. Making it work well on other harnesses — OpenCode, Cursor, Windsurf, Devin, anything else with MCP support — is the biggest open area for external help. See [CONTRIBUTING.md](CONTRIBUTING.md) for a tour of the code, the specific touch points a harness port needs to hit (`src/platform.js`, `src/init.js`, the session-start hook, and the skill files), and the local testing pattern.
|
|
67
|
+
|
|
68
|
+
For quick dev setup:
|
|
64
69
|
|
|
65
70
|
```bash
|
|
66
71
|
git clone https://github.com/Tokens-for-Good/tokens-for-good
|
package/package.json
CHANGED
|
@@ -53,8 +53,19 @@ List the top 20 negative consequences of that social problem for that population
|
|
|
53
53
|
#### PROMPT 3 — Intermediary vs Ultimate Outcome Classification
|
|
54
54
|
|
|
55
55
|
Keep all 20 items. Add a column classifying each as Intermediary or Ultimate Outcome.
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
|
|
57
|
+
**Definitions:**
|
|
58
|
+
- **Intermediary:** changes in behavior, action, or resources that result from the intervention but don't yet prove lives improved (e.g., increased income, employment, school enrollment, access to healthcare, consumption)
|
|
59
|
+
- **Ultimate:** changes in condition or life status that directly reflect well-being improvements (e.g., improved health, housing security, quality of life, food security)
|
|
60
|
+
|
|
61
|
+
**Edge cases — apply these exactly:**
|
|
62
|
+
- Getting healthcare = Intermediary. Health actually improving = Ultimate.
|
|
63
|
+
- Income going up = Intermediary. Using that income to improve housing, education, or health = Ultimate.
|
|
64
|
+
- Moving out of poverty = Intermediary. Well-being or quality of life improving because of it = Ultimate.
|
|
65
|
+
- Increased farm yield = Intermediary. Enhanced food security = Ultimate.
|
|
66
|
+
- Increased access to most anything = Intermediary (we don't know if life improved because of that access).
|
|
67
|
+
- School learning outcomes or completing school = Intermediary. Quality of life changing due to a better job from those outcomes = Ultimate.
|
|
68
|
+
- Asset changes = Intermediary unless we know specifically what the asset is and how it improves life (safer housing, a latrine, durable productive tools = Ultimate; generic "asset score" or "asset holdings" = Intermediary).
|
|
58
69
|
|
|
59
70
|
Sort by Intermediary first, then Ultimate.
|
|
60
71
|
|
|
@@ -83,36 +94,33 @@ Keep the table with ALL previous columns. For each of the 20 negative consequenc
|
|
|
83
94
|
|
|
84
95
|
Write a recommendation (2-4 sentences): lead with stance, state strongest evidence, note caveats if any.
|
|
85
96
|
|
|
86
|
-
|
|
97
|
+
**Section 2 — Scorecard**
|
|
87
98
|
|
|
88
|
-
Base score (out of 100):
|
|
89
99
|
- [x] or [ ] a. Has Ultimate Outcome Goals (50 pts)
|
|
90
100
|
- [x] or [ ] b. Measures Intermediate Outcomes (10 pts)
|
|
91
101
|
- [x] or [ ] c. Measures Ultimate Outcomes (15 pts)
|
|
92
102
|
- [x] or [ ] d. Shows Continual Learning & Adaptation (25 pts)
|
|
93
|
-
|
|
94
|
-
Extra credit:
|
|
95
103
|
- [x] or [ ] e. Measures Intermediate Counterfactual (10 pts)
|
|
96
104
|
- [x] or [ ] f. Measures Ultimate Counterfactual (10 pts)
|
|
97
105
|
|
|
98
|
-
**Score: [X]/
|
|
106
|
+
**Score: [X]/120**
|
|
99
107
|
|
|
100
|
-
**Section
|
|
108
|
+
**Section 3 — The Social Problem**
|
|
101
109
|
Frame with specificity ("chronic malnutrition among children under 5 in rural sub-Saharan Africa", not just "poverty"). Include scale and cite prevalence data.
|
|
102
110
|
|
|
103
|
-
**Section
|
|
111
|
+
**Section 4 — The Solution**
|
|
104
112
|
What the organization actually does (not their mission statement). Explain the theory of change: how does activity X lead to outcome Y? Be specific about the intervention.
|
|
105
113
|
|
|
106
|
-
**Section
|
|
114
|
+
**Section 5 — Key Outputs**
|
|
107
115
|
Measured activities and direct products with specific numbers. Distinguish outputs (things produced) from outcomes (changes caused).
|
|
108
116
|
|
|
109
|
-
**Section
|
|
117
|
+
**Section 6 — Key Intermediate Outcomes**
|
|
110
118
|
Measurable short-to-medium term changes. Note whether data is self-reported or independently verified. Include any counterfactual data found.
|
|
111
119
|
|
|
112
|
-
**Section
|
|
120
|
+
**Section 7 — Key Ultimate Outcomes**
|
|
113
121
|
Long-term impact evidence only. This section may be thin. Do not pad it. If no ultimate outcome data exists, say so in one sentence.
|
|
114
122
|
|
|
115
|
-
**Section
|
|
123
|
+
**Section 8 — Continual Learning & Adaptation**
|
|
116
124
|
Documented program changes based on evidence. "They adapted" needs specifics: what changed, based on what data, when?
|
|
117
125
|
|
|
118
126
|
#### SOURCES
|
|
@@ -147,7 +155,7 @@ Run these checks before submitting. They are not optional.
|
|
|
147
155
|
|
|
148
156
|
**Structure:**
|
|
149
157
|
- [ ] All 5 prompt tables present and complete (20 rows each)
|
|
150
|
-
- [ ] All
|
|
158
|
+
- [ ] All 8 summary sections present with substantive content
|
|
151
159
|
- [ ] SOURCES section lists every URL cited inline
|
|
152
160
|
- [ ] Scored checklist adds up correctly
|
|
153
161
|
|
|
@@ -164,6 +172,7 @@ Run these checks before submitting. They are not optional.
|
|
|
164
172
|
- [ ] Replace "leverage" with "use", "utilize" with "use"
|
|
165
173
|
- [ ] Paragraphs under 4 sentences
|
|
166
174
|
- [ ] No superlatives unless backed by comparative data
|
|
175
|
+
- [ ] Every acronym defined in full before first use (e.g., "Randomized Controlled Trial (RCT)" not just "RCT")
|
|
167
176
|
|
|
168
177
|
### 5. Submit
|
|
169
178
|
|
|
@@ -31,7 +31,7 @@ Pick 3-5 citation URLs from the report (prioritize any flagged by the automated
|
|
|
31
31
|
|
|
32
32
|
Verify:
|
|
33
33
|
- [ ] All 5 prompt sections present (PROMPT 1-5) with 20 rows each
|
|
34
|
-
- [ ] All
|
|
34
|
+
- [ ] All 8 summary sections present (Sections 1-8)
|
|
35
35
|
- [ ] SOURCES section exists with citations
|
|
36
36
|
- [ ] Every factual claim has its own inline citation `[Source Name](URL)`
|
|
37
37
|
- [ ] No claims cited to general overview pages when a specific report or data page exists
|
|
@@ -40,17 +40,14 @@ Verify:
|
|
|
40
40
|
|
|
41
41
|
The scored checklist uses these weights. Verify the math and the evidence:
|
|
42
42
|
|
|
43
|
-
Base score (out of 100):
|
|
44
43
|
- a. Has Ultimate Outcome Goals (50 pts)
|
|
45
44
|
- b. Measures Intermediate Outcomes (10 pts)
|
|
46
45
|
- c. Measures Ultimate Outcomes (15 pts)
|
|
47
46
|
- d. Shows Continual Learning & Adaptation (25 pts)
|
|
48
|
-
|
|
49
|
-
Extra credit:
|
|
50
47
|
- e. Measures Intermediate Counterfactual (10 pts)
|
|
51
48
|
- f. Measures Ultimate Counterfactual (10 pts)
|
|
52
49
|
|
|
53
|
-
**Score: X/
|
|
50
|
+
**Score: X/120**
|
|
54
51
|
|
|
55
52
|
Check:
|
|
56
53
|
- Are checked items supported by evidence in the report?
|
|
@@ -65,6 +62,7 @@ Check:
|
|
|
65
62
|
- Sections that are empty or trivially short
|
|
66
63
|
- Claims that contradict other parts of the report
|
|
67
64
|
- Em dashes, filler adjectives (robust, comprehensive, innovative), AI transitions
|
|
65
|
+
- Acronyms used before being defined in full (e.g., "RCT" without first writing "Randomized Controlled Trial (RCT)")
|
|
68
66
|
|
|
69
67
|
### 7. Assign a Score
|
|
70
68
|
|
package/src/api-client.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// HTTP client for the Fierce Philanthropy coordination API
|
|
2
2
|
|
|
3
|
-
const BASE_URL = process.env.FIERCE_API_URL || 'https://
|
|
3
|
+
const BASE_URL = process.env.FIERCE_API_URL || 'https://tokensforgood.ai/api';
|
|
4
4
|
|
|
5
5
|
export class ApiClient {
|
|
6
6
|
constructor(apiKey) {
|
|
7
7
|
this.apiKey = apiKey;
|
|
8
8
|
if (!apiKey) {
|
|
9
|
-
throw new Error('TFG_API_KEY environment variable is required. Get your key at https://
|
|
9
|
+
throw new Error('TFG_API_KEY environment variable is required. Get your key at https://tokensforgood.ai/contribute');
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
package/src/cli.js
CHANGED
package/src/init.js
CHANGED
|
@@ -28,6 +28,7 @@ const PLATFORM_CHOICES = [
|
|
|
28
28
|
{ title: 'Cursor', value: 'cursor' },
|
|
29
29
|
{ title: 'Windsurf', value: 'windsurf' },
|
|
30
30
|
{ title: 'Devin', value: 'devin' },
|
|
31
|
+
{ title: 'Qwen Code', value: 'qwen-code' },
|
|
31
32
|
];
|
|
32
33
|
|
|
33
34
|
const onCancel = () => {
|
|
@@ -57,7 +58,7 @@ export async function runInit() {
|
|
|
57
58
|
const { apiKey } = await prompts({
|
|
58
59
|
type: 'password',
|
|
59
60
|
name: 'apiKey',
|
|
60
|
-
message: 'Paste your TFG API key (get one at https://
|
|
61
|
+
message: 'Paste your TFG API key (get one at https://tokensforgood.ai/contribute):',
|
|
61
62
|
validate: v => /^tfg_(live|test)_/.test((v || '').trim()) || 'Key should start with tfg_live_ or tfg_test_',
|
|
62
63
|
}, { onCancel });
|
|
63
64
|
|
|
@@ -110,6 +111,9 @@ export async function runInit() {
|
|
|
110
111
|
console.log(`✓ ${plans[2].label}`);
|
|
111
112
|
writeSkillFile('tfg');
|
|
112
113
|
console.log(`✓ ${plans[3].label}`);
|
|
114
|
+
} else if (platform === 'qwen-code') {
|
|
115
|
+
writeQwenCommand('tfg');
|
|
116
|
+
console.log(`✓ ${plans[1].label}`);
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
saveState({
|
|
@@ -137,6 +141,8 @@ function planWrites(platform) {
|
|
|
137
141
|
plans.push({ label: `${settingsPath()} (SessionStart hook)` });
|
|
138
142
|
plans.push({ label: `${skillPath('tfg-schedule')} (/tfg-schedule skill)` });
|
|
139
143
|
plans.push({ label: `${skillPath('tfg')} (/tfg skill)` });
|
|
144
|
+
} else if (platform === 'qwen-code') {
|
|
145
|
+
plans.push({ label: `${qwenCommandPath('tfg')} (/tfg slash command)` });
|
|
140
146
|
}
|
|
141
147
|
plans.push({ label: `${statePath()} (recorded choice)` });
|
|
142
148
|
return plans;
|
|
@@ -149,32 +155,28 @@ function homeRelative(abs) {
|
|
|
149
155
|
}
|
|
150
156
|
|
|
151
157
|
function mcpConfigPath(platform) {
|
|
152
|
-
|
|
153
|
-
switch (platform) {
|
|
154
|
-
case 'opencode':
|
|
155
|
-
return join(homedir(), '.config', 'opencode', 'opencode.json');
|
|
156
|
-
case 'cursor':
|
|
157
|
-
return join(process.cwd(), '.cursor', 'mcp.json');
|
|
158
|
-
case 'windsurf':
|
|
159
|
-
return join(homedir(), '.codeium', 'windsurf', 'mcp_config.json');
|
|
160
|
-
case 'devin':
|
|
161
|
-
case 'claude-code':
|
|
162
|
-
default:
|
|
163
|
-
return join(homedir(), '.mcp.json');
|
|
164
|
-
}
|
|
165
|
-
})();
|
|
166
|
-
return homeRelative(abs);
|
|
158
|
+
return homeRelative(absoluteMcpPath(platform));
|
|
167
159
|
}
|
|
168
160
|
|
|
169
|
-
function settingsPath()
|
|
170
|
-
function skillPath(name)
|
|
171
|
-
function
|
|
161
|
+
function settingsPath() { return homeRelative(join(homedir(), '.claude', 'settings.json')); }
|
|
162
|
+
function skillPath(name) { return homeRelative(join(homedir(), '.claude', 'skills', name, 'SKILL.md')); }
|
|
163
|
+
function qwenCommandPath(name) { return homeRelative(join(homedir(), '.qwen', 'commands', `${name}.md`)); }
|
|
164
|
+
function statePath() { return homeRelative(join(homedir(), '.tokens-for-good', 'state.json')); }
|
|
172
165
|
|
|
173
166
|
// --- File writers ---
|
|
174
167
|
|
|
175
168
|
function readJsonOrEmpty(path) {
|
|
176
169
|
if (!existsSync(path)) return {};
|
|
177
|
-
|
|
170
|
+
const raw = readFileSync(path, 'utf-8');
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(raw);
|
|
173
|
+
} catch {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`${path} exists but is not valid JSON.\n` +
|
|
176
|
+
`Fix or delete the file, then re-run init.\n` +
|
|
177
|
+
`(Tip: paste it into https://jsonlint.com to find the syntax error.)`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
178
180
|
}
|
|
179
181
|
|
|
180
182
|
function ensureDir(path) {
|
|
@@ -219,6 +221,8 @@ function absoluteMcpPath(platform) {
|
|
|
219
221
|
return join(process.cwd(), '.cursor', 'mcp.json');
|
|
220
222
|
case 'windsurf':
|
|
221
223
|
return join(homedir(), '.codeium', 'windsurf', 'mcp_config.json');
|
|
224
|
+
case 'qwen-code':
|
|
225
|
+
return join(homedir(), '.qwen', 'settings.json');
|
|
222
226
|
case 'devin':
|
|
223
227
|
case 'claude-code':
|
|
224
228
|
default:
|
|
@@ -284,6 +288,16 @@ function writeSkillFile(name) {
|
|
|
284
288
|
writeFileSync(dst, readFileSync(src, 'utf-8'), 'utf-8');
|
|
285
289
|
}
|
|
286
290
|
|
|
291
|
+
// Qwen Code uses Gemini CLI's custom-command format: one .md per command at
|
|
292
|
+
// ~/.qwen/commands/<name>.md. YAML frontmatter is supported; the body is the
|
|
293
|
+
// command prompt. Our existing skill body works as-is.
|
|
294
|
+
function writeQwenCommand(name) {
|
|
295
|
+
const src = join(PKG_ROOT, 'skills', `${name}.md`);
|
|
296
|
+
const dst = join(homedir(), '.qwen', 'commands', `${name}.md`);
|
|
297
|
+
ensureDir(dst);
|
|
298
|
+
writeFileSync(dst, readFileSync(src, 'utf-8'), 'utf-8');
|
|
299
|
+
}
|
|
300
|
+
|
|
287
301
|
// --- Closing guidance ---
|
|
288
302
|
|
|
289
303
|
function printClosingGuidance(platform, flow, freq) {
|
|
@@ -300,13 +314,25 @@ function printClosingGuidance(platform, flow, freq) {
|
|
|
300
314
|
if (flow === 'scheduled') {
|
|
301
315
|
console.log(`MCP config written to ${mcpConfigPath('opencode')}.\n`);
|
|
302
316
|
console.log('To run on a schedule, add this to your crontab (crontab -e):');
|
|
303
|
-
|
|
304
|
-
console.log(` ${cron} cd /path/to/workspace && opencode run "Research a nonprofit org for Fierce Philanthropy using the tokens-for-good MCP tools."\n`);
|
|
317
|
+
console.log(` ${cronExpression(freq)} cd /path/to/workspace && opencode run "Research a nonprofit org for Fierce Philanthropy using the tokens-for-good MCP tools."\n`);
|
|
305
318
|
} else {
|
|
306
319
|
console.log('MCP config written. In OpenCode run: "Research a nonprofit org for Fierce Philanthropy."\n');
|
|
307
320
|
}
|
|
308
321
|
return;
|
|
309
322
|
}
|
|
323
|
+
if (platform === 'qwen-code') {
|
|
324
|
+
console.log(`MCP config written to ${mcpConfigPath('qwen-code')}.`);
|
|
325
|
+
console.log(`Slash command written to ${qwenCommandPath('tfg')}.`);
|
|
326
|
+
console.log('Restart Qwen Code, then run `/tfg` to research one org.');
|
|
327
|
+
if (flow === 'scheduled') {
|
|
328
|
+
console.log('\nFor recurring runs, either:');
|
|
329
|
+
console.log(' • Enable Qwen Code\'s experimental cron (set QWEN_CODE_ENABLE_CRON=1 and use the Cron tool / /loop), or');
|
|
330
|
+
console.log(' • Add a system cron line (crontab -e):');
|
|
331
|
+
console.log(` ${cronExpression(freq)} cd /path/to/workspace && qwen --prompt "Run /tfg"`);
|
|
332
|
+
}
|
|
333
|
+
console.log('');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
310
336
|
// cursor, windsurf, devin
|
|
311
337
|
console.log(`MCP config written to ${mcpConfigPath(platform)}.`);
|
|
312
338
|
console.log(`Restart ${platform} and run: "Research a nonprofit org for Fierce Philanthropy."`);
|
|
@@ -315,3 +341,9 @@ function printClosingGuidance(platform, flow, freq) {
|
|
|
315
341
|
}
|
|
316
342
|
console.log('');
|
|
317
343
|
}
|
|
344
|
+
|
|
345
|
+
function cronExpression(freq) {
|
|
346
|
+
if (freq === 'hourly') return '0 * * * *';
|
|
347
|
+
if (freq === 'weekly') return '0 2 * * 1';
|
|
348
|
+
return '0 2 * * *';
|
|
349
|
+
}
|
package/src/mcp-server.js
CHANGED
|
@@ -1,351 +1,356 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
-
import { z } from 'zod';
|
|
5
|
-
import { ApiClient } from './api-client.js';
|
|
6
|
-
import { detectPlatform, isSchedulable, getAutomationInstructions } from './platform.js';
|
|
7
|
-
import { loadState, updateState, isSnoozed, hasContributedToday, markContributed, markSetupComplete } from './state.js';
|
|
8
|
-
import { readFileSync, existsSync } from 'fs';
|
|
9
|
-
import { join, dirname } from 'path';
|
|
10
|
-
import { fileURLToPath } from 'url';
|
|
11
|
-
import { homedir } from 'os';
|
|
12
|
-
|
|
13
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
const PIPELINE_DIR = join(__dirname, '..', 'pipeline');
|
|
15
|
-
const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')).version;
|
|
16
|
-
const STATE_FILE = join(homedir(), '.tokens-for-good', 'state.json');
|
|
17
|
-
|
|
18
|
-
const INIT_GUARD_MESSAGE = `Tokens for Good setup isn't complete on this machine yet.
|
|
19
|
-
|
|
20
|
-
Tell the user to run this in their terminal (not in Claude), then restart Claude Code:
|
|
21
|
-
|
|
22
|
-
npx tokens-for-good init
|
|
23
|
-
|
|
24
|
-
The init command asks them to choose a contribution cadence (hourly / daily / weekly / one-off) and wires up everything else automatically. It takes about 30 seconds.`;
|
|
25
|
-
|
|
26
|
-
// Gate: only fires for genuinely cold installs where state.json is missing
|
|
27
|
-
// entirely. Existing users — including those on the pre-0.4.0 schema — pass
|
|
28
|
-
// through untouched. Init writes state.json on first successful completion,
|
|
29
|
-
// so after that this never fires again.
|
|
30
|
-
function notInitialized() {
|
|
31
|
-
return !existsSync(STATE_FILE);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const apiKey = process.env.TFG_API_KEY;
|
|
35
|
-
let client;
|
|
36
|
-
try {
|
|
37
|
-
client = new ApiClient(apiKey);
|
|
38
|
-
} catch {
|
|
39
|
-
// Will fail on tool calls, but server can still start
|
|
40
|
-
client = null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const platform = detectPlatform();
|
|
44
|
-
updateState({ platform });
|
|
45
|
-
|
|
46
|
-
const server = new McpServer({
|
|
47
|
-
name: 'tokens-for-good',
|
|
48
|
-
version: PKG_VERSION,
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// --- No-key onboarding message ---
|
|
52
|
-
|
|
53
|
-
const NO_KEY_INSTRUCTIONS = `The user wants to set up Tokens for Good. Tell them to run this in their terminal (not here in Claude), then restart Claude Code:
|
|
54
|
-
|
|
55
|
-
npx tokens-for-good init
|
|
56
|
-
|
|
57
|
-
The command walks them through everything in under a minute:
|
|
58
|
-
1. Create an account at https://
|
|
59
|
-
2. Copy their API key (starts with \`tfg_live_\`) and paste it into the init prompt
|
|
60
|
-
3. Pick a cadence: **daily** (recommended), weekly, hourly, or one-off
|
|
61
|
-
4. Confirm
|
|
62
|
-
|
|
63
|
-
init writes everything — MCP config, SessionStart hook, /tfg and /tfg-schedule skills, and their recorded preference — in one shot. The first Claude Code session after init runs their chosen flow automatically.
|
|
64
|
-
|
|
65
|
-
**What is Tokens for Good?** A way for developers to donate their spare AI subscription tokens to research nonprofit organizations for Fierce Philanthropy's social impact directory. Each org takes ~5 minutes and ~$0.20 in tokens. Contributors get credit on a public leaderboard.`;
|
|
66
|
-
|
|
67
|
-
// --- Resources ---
|
|
68
|
-
|
|
69
|
-
server.resource('about', 'tokens-for-good://about', 'text/plain', async () => ({
|
|
70
|
-
contents: [{
|
|
71
|
-
uri: 'tokens-for-good://about',
|
|
72
|
-
text: `Tokens for Good - Donate Your Spare AI Tokens to Research Nonprofits
|
|
73
|
-
|
|
74
|
-
What: An MCP server that lets AI coding tool users (Claude Code, Opencode, Cursor, Windsurf, Devin) contribute their spare subscription tokens to research nonprofit organizations for Fierce Philanthropy's social impact directory.
|
|
75
|
-
|
|
76
|
-
How it works:
|
|
77
|
-
1. Sign up at https://
|
|
78
|
-
2. Get your API key, add it to your MCP config as TFG_API_KEY
|
|
79
|
-
3. Say "Research an org for Fierce Philanthropy"
|
|
80
|
-
4. Your AI claims an org, researches it (web search + analysis), verifies citations, humanizes the writing, and submits the report
|
|
81
|
-
5. Another contributor's AI peer-reviews your report
|
|
82
|
-
6. A human reviewer finalizes it for the directory
|
|
83
|
-
|
|
84
|
-
Research pipeline (per org, all done by your AI):
|
|
85
|
-
- Research the org using web search + web fetch, following the 6-prompt methodology
|
|
86
|
-
- Score using a weighted checklist (100 pts base, 120 max with extra credit)
|
|
87
|
-
- Verify citations by visiting each URL before submitting
|
|
88
|
-
- Clean up writing style (no AI tells, no filler adjectives, no em dashes)
|
|
89
|
-
|
|
90
|
-
Contributor tiers:
|
|
91
|
-
- New: first 5 orgs, easy orgs only
|
|
92
|
-
- Bronze: 5+ orgs
|
|
93
|
-
- Silver: 25+ orgs, >80% acceptance rate
|
|
94
|
-
- Gold: 100+ orgs, >90% acceptance rate
|
|
95
|
-
|
|
96
|
-
Automation: On Claude Code, use /schedule to auto-contribute daily. On Opencode, set up a system cron. On Cursor/Windsurf, contribute manually when prompted.
|
|
97
|
-
|
|
98
|
-
Cost: ~$0.15-0.25 per org in tokens. Scale: 750K+ US nonprofits to research.`,
|
|
99
|
-
}],
|
|
100
|
-
}));
|
|
101
|
-
|
|
102
|
-
// --- Tools ---
|
|
103
|
-
|
|
104
|
-
server.tool('claim_org', 'Claim the next available nonprofit org to research. Blocked if you have a pending peer review.', {
|
|
105
|
-
platform: z.string().optional().describe('Your platform (claude-code, opencode, cursor, windsurf, devin)'),
|
|
106
|
-
}, async ({ platform: plat }) => {
|
|
107
|
-
if (notInitialized()) return { content: [{ type: 'text', text: INIT_GUARD_MESSAGE }] };
|
|
108
|
-
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set. Get your key at https://
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
const result = await client.claimOrg(plat || platform);
|
|
112
|
-
return {
|
|
113
|
-
content: [{ type: 'text', text: `Claimed: ${result.org.name}\nURL: ${result.org.url}\nDescription: ${result.org.description || 'N/A'}\nSource: ${result.org.source || 'N/A'}\nClaim ID: ${result.claim_id}\nExpires: ${result.expires_at}\n\nNext steps:\n1. Call get_methodology with step="research" to get the full research instructions\n2. Follow the methodology to research this org using WebSearch and WebFetch\n3. The methodology includes citation verification and writing quality checks — complete them before submitting\n4. Submit with submit_report when done` }],
|
|
114
|
-
};
|
|
115
|
-
} catch (err) {
|
|
116
|
-
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
server.tool('get_methodology', 'Get the full research methodology, verification instructions, or humanization instructions.', {
|
|
121
|
-
step: z.enum(['research', 'verify', 'humanize', 'peer-review']).describe('Which pipeline step to get instructions for'),
|
|
122
|
-
}, async ({ step }) => {
|
|
123
|
-
const stepMap = {
|
|
124
|
-
'research': '01-research/PROMPT.md',
|
|
125
|
-
'verify': '02-verify/PROMPT.md',
|
|
126
|
-
'humanize': '03-humanize/PROMPT.md',
|
|
127
|
-
'peer-review': '04-peer-review/PROMPT.md',
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
const content = readFileSync(join(PIPELINE_DIR, stepMap[step]), 'utf-8');
|
|
132
|
-
return { content: [{ type: 'text', text: content }] };
|
|
133
|
-
} catch {
|
|
134
|
-
return { content: [{ type: 'text', text: `Error: Could not load ${step} methodology file.` }] };
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
server.tool('submit_report', 'Submit a completed research report for an org you claimed. You MUST include estimated_tokens.', {
|
|
139
|
-
claim_id: z.string().describe('The claim ID from claim_org'),
|
|
140
|
-
report_markdown: z.string().describe('The full research report in markdown'),
|
|
141
|
-
estimated_tokens: z.number().describe('Estimated total tokens used: count web searches (~1K each), web fetches (~2-5K each), report output (~4 tokens/word), plus ~10K overhead'),
|
|
142
|
-
model_used: z.string().optional().describe('The model that generated this report'),
|
|
143
|
-
}, async ({ claim_id, report_markdown, estimated_tokens, model_used }) => {
|
|
144
|
-
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
145
|
-
|
|
146
|
-
try {
|
|
147
|
-
const result = await client.submitReport(claim_id, report_markdown,
|
|
148
|
-
markContributed();
|
|
149
|
-
|
|
150
|
-
// One-off users: first successful submit completes their initial setup,
|
|
151
|
-
// so the SessionStart hook stops prompting from the next session onward.
|
|
152
|
-
const state = loadState();
|
|
153
|
-
if (state.intended_flow === 'one_off' && !state.first_setup_complete) {
|
|
154
|
-
markSetupComplete();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
content: [{ type: 'text', text: `Report submitted for ${result.org_name}!\n\nYour stats:\n- Total orgs: ${result.contributor_stats.total_orgs}\n- Tier: ${result.contributor_stats.tier}\n- Orgs remaining: ${result.orgs_remaining}\n\nYour report will now go through peer review. Thank you for contributing!` }],
|
|
159
|
-
};
|
|
160
|
-
} catch (err) {
|
|
161
|
-
return { content: [{ type: 'text', text: `Submit error: ${err.message}${err.data?.validation_errors ? '\n' + err.data.validation_errors.join('\n') : ''}` }] };
|
|
162
|
-
}
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
server.tool('get_peer_review', 'Get a draft report assigned to you for peer review. You must complete peer reviews before claiming new orgs.', {}, async () => {
|
|
166
|
-
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
167
|
-
|
|
168
|
-
try {
|
|
169
|
-
const result = await client.getNextPeerReview();
|
|
170
|
-
let peerMethodology = '';
|
|
171
|
-
try {
|
|
172
|
-
peerMethodology = readFileSync(join(PIPELINE_DIR, '04-peer-review/PROMPT.md'), 'utf-8');
|
|
173
|
-
} catch {
|
|
174
|
-
peerMethodology = 'Score 1-4: 4=Great, 3=Good with fixes (submit corrected version), 2=Needs redo, 1=Bad actor.';
|
|
175
|
-
}
|
|
176
|
-
let factCheckNote = '';
|
|
177
|
-
if (result.automated_review?.summary) {
|
|
178
|
-
const s = result.automated_review.summary;
|
|
179
|
-
const lines = [
|
|
180
|
-
`\n\n## Automated Fact-Check Results`,
|
|
181
|
-
`Quality: ${s.overall_quality} | Fact support: ${Math.round(s.fact_support_rate * 100)}% | Avg trust: ${Math.round(s.avg_trust_score * 100)}%`,
|
|
182
|
-
`Facts checked: ${result.automated_review.facts_checked}/${result.automated_review.facts_extracted} | Citations rated: ${result.automated_review.citations_rated}`,
|
|
183
|
-
];
|
|
184
|
-
if (s.red_flags?.length > 0) {
|
|
185
|
-
lines.push(`\nRed flags:\n${s.red_flags.map(f => ` - ${f}`).join('\n')}`);
|
|
186
|
-
}
|
|
187
|
-
if (s.strengths?.length > 0) {
|
|
188
|
-
lines.push(`\nStrengths:\n${s.strengths.map(f => ` - ${f}`).join('\n')}`);
|
|
189
|
-
}
|
|
190
|
-
lines.push(`\nUse these results to focus your spot-checks on flagged areas.`);
|
|
191
|
-
factCheckNote = lines.join('\n');
|
|
192
|
-
} else if (result.automated_review) {
|
|
193
|
-
factCheckNote = `\n\nAutomated Fact-Check: ${result.automated_review.status} (no summary available yet)`;
|
|
194
|
-
}
|
|
195
|
-
return {
|
|
196
|
-
content: [{ type: 'text', text: `Peer review assigned:\nOrg: ${result.org.name}\nAuthor: ${result.author}\nClaim ID: ${result.claim_id}${factCheckNote}\n\n---\n\n${peerMethodology}\n\n---\n\n${result.report_markdown}\n\n---\n\nUse submit_peer_review with your score and notes.` }],
|
|
197
|
-
};
|
|
198
|
-
} catch (err) {
|
|
199
|
-
if (err.status === 404) {
|
|
200
|
-
return { content: [{ type: 'text', text: 'No peer reviews assigned to you right now.' }] };
|
|
201
|
-
}
|
|
202
|
-
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
server.tool('submit_peer_review', 'Submit your peer review score for a report.', {
|
|
207
|
-
claim_id: z.string().describe('The claim ID of the report being reviewed'),
|
|
208
|
-
score: z.number().min(1).max(4).describe('Score: 4=great, 3=good with fixes, 2=needs redo, 1=bad actor'),
|
|
209
|
-
notes: z.string().optional().describe('Review notes explaining the score'),
|
|
210
|
-
updated_report: z.string().optional().describe('If score is 3, the fixed version of the report'),
|
|
211
|
-
}, async ({ claim_id, score, notes, updated_report }) => {
|
|
212
|
-
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
213
|
-
|
|
214
|
-
try {
|
|
215
|
-
const result = await client.submitPeerReview(claim_id, score, notes, updated_report);
|
|
216
|
-
return {
|
|
217
|
-
content: [{ type: 'text', text: `Peer review submitted for ${result.org_name}.\nScore: ${result.score}/4\n\nYou can now claim a new org to research.` }],
|
|
218
|
-
};
|
|
219
|
-
} catch (err) {
|
|
220
|
-
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
server.tool('research_status', 'See the overall Tokens for Good project progress and leaderboard.', {}, async () => {
|
|
225
|
-
try {
|
|
226
|
-
const clientForStatus = client || new ApiClient('dummy'); // Status is public
|
|
227
|
-
const result = await clientForStatus.getStatus();
|
|
228
|
-
const topList = result.top_contributors?.map((c, i) =>
|
|
229
|
-
`${i + 1}. @${c.github_handle} (${c.total_orgs} orgs, ${c.tier})`
|
|
230
|
-
).join('\n') || 'No contributors yet';
|
|
231
|
-
|
|
232
|
-
return {
|
|
233
|
-
content: [{ type: 'text', text: `Tokens for Good Progress:\n\nTotal orgs: ${result.total_orgs}\nPending research: ${result.pending_orgs}\nActive contributors (7d): ${result.active_contributors_7d}\n\nQueue:\n${Object.entries(result.queue || {}).map(([k, v]) => ` ${k}: ${v}`).join('\n')}\n\nTop Contributors:\n${topList}` }],
|
|
234
|
-
};
|
|
235
|
-
} catch (err) {
|
|
236
|
-
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
237
|
-
}
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
server.tool('my_impact', 'See your personal contribution stats, tier, and history.', {}, async () => {
|
|
241
|
-
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
242
|
-
|
|
243
|
-
try {
|
|
244
|
-
const result = await client.getImpact();
|
|
245
|
-
const c = result.contributor;
|
|
246
|
-
const estimatedCost = (c.total_tokens / 1_000_000 * 3).toFixed(2);
|
|
247
|
-
|
|
248
|
-
return {
|
|
249
|
-
content: [{ type: 'text', text: `Your Impact (@${c.github_handle}):\n\nTier: ${c.tier}\nOrgs researched: ${c.total_orgs}\nEstimated donation: ~$${estimatedCost}\nAcceptance rate: ${c.acceptance_rate}%\nAutomation: ${c.has_schedule ? 'Active' : 'Not set up'}\n\nRecent:\n${result.claims?.slice(0, 5).map(cl => ` ${cl.organization?.name || 'Unknown'} - ${cl.status}`).join('\n') || 'None'}` }],
|
|
250
|
-
};
|
|
251
|
-
} catch (err) {
|
|
252
|
-
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
253
|
-
}
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
server.tool('setup_guide', 'Get setup instructions for Tokens for Good. Use this if the user needs help with installation, API keys, or configuration.', {}, async () => {
|
|
257
|
-
return { content: [{ type: 'text', text: NO_KEY_INSTRUCTIONS }] };
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
server.tool('setup_automation', 'Get the scheduled-research prompt + setup instructions for the user\'s platform. Usually called by the /tfg-schedule skill (which extracts the prompt and invokes /schedule). Safe to call directly too — returns human-readable instructions.', {
|
|
261
|
-
frequency: z.enum(['hourly', 'daily', 'weekly']).optional().describe('How often to contribute'),
|
|
262
|
-
}, async ({ frequency }) => {
|
|
263
|
-
if (notInitialized()) return { content: [{ type: 'text', text: INIT_GUARD_MESSAGE }] };
|
|
264
|
-
const instructions = getAutomationInstructions(platform, frequency || 'daily', apiKey);
|
|
265
|
-
return { content: [{ type: 'text', text: instructions }] };
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
server.tool('mark_setup_complete', 'Called by the /tfg-schedule skill after /schedule confirms, or by the /tfg skill after a successful first submission. Flips local state so the SessionStart hook stops emitting first-session instructions. Idempotent — safe to call multiple times.', {}, async () => {
|
|
269
|
-
markSetupComplete();
|
|
270
|
-
return { content: [{ type: 'text', text: 'Marked setup complete. The SessionStart hook will go silent from the next session.' }] };
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if (
|
|
328
|
-
return {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
main()
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { ApiClient } from './api-client.js';
|
|
6
|
+
import { detectPlatform, isSchedulable, getAutomationInstructions } from './platform.js';
|
|
7
|
+
import { loadState, updateState, isSnoozed, snoozeDays, hasContributedToday, markContributed, markSetupComplete } from './state.js';
|
|
8
|
+
import { readFileSync, existsSync } from 'fs';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const PIPELINE_DIR = join(__dirname, '..', 'pipeline');
|
|
15
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')).version;
|
|
16
|
+
const STATE_FILE = join(homedir(), '.tokens-for-good', 'state.json');
|
|
17
|
+
|
|
18
|
+
const INIT_GUARD_MESSAGE = `Tokens for Good setup isn't complete on this machine yet.
|
|
19
|
+
|
|
20
|
+
Tell the user to run this in their terminal (not in Claude), then restart Claude Code:
|
|
21
|
+
|
|
22
|
+
npx tokens-for-good init
|
|
23
|
+
|
|
24
|
+
The init command asks them to choose a contribution cadence (hourly / daily / weekly / one-off) and wires up everything else automatically. It takes about 30 seconds.`;
|
|
25
|
+
|
|
26
|
+
// Gate: only fires for genuinely cold installs where state.json is missing
|
|
27
|
+
// entirely. Existing users — including those on the pre-0.4.0 schema — pass
|
|
28
|
+
// through untouched. Init writes state.json on first successful completion,
|
|
29
|
+
// so after that this never fires again.
|
|
30
|
+
function notInitialized() {
|
|
31
|
+
return !existsSync(STATE_FILE);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const apiKey = process.env.TFG_API_KEY;
|
|
35
|
+
let client;
|
|
36
|
+
try {
|
|
37
|
+
client = new ApiClient(apiKey);
|
|
38
|
+
} catch {
|
|
39
|
+
// Will fail on tool calls, but server can still start
|
|
40
|
+
client = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const platform = detectPlatform();
|
|
44
|
+
updateState({ platform });
|
|
45
|
+
|
|
46
|
+
const server = new McpServer({
|
|
47
|
+
name: 'tokens-for-good',
|
|
48
|
+
version: PKG_VERSION,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// --- No-key onboarding message ---
|
|
52
|
+
|
|
53
|
+
const NO_KEY_INSTRUCTIONS = `The user wants to set up Tokens for Good. Tell them to run this in their terminal (not here in Claude), then restart Claude Code:
|
|
54
|
+
|
|
55
|
+
npx tokens-for-good init
|
|
56
|
+
|
|
57
|
+
The command walks them through everything in under a minute:
|
|
58
|
+
1. Create an account at https://tokensforgood.ai/contribute (GitHub OAuth, free)
|
|
59
|
+
2. Copy their API key (starts with \`tfg_live_\`) and paste it into the init prompt
|
|
60
|
+
3. Pick a cadence: **daily** (recommended), weekly, hourly, or one-off
|
|
61
|
+
4. Confirm
|
|
62
|
+
|
|
63
|
+
init writes everything — MCP config, SessionStart hook, /tfg and /tfg-schedule skills, and their recorded preference — in one shot. The first Claude Code session after init runs their chosen flow automatically.
|
|
64
|
+
|
|
65
|
+
**What is Tokens for Good?** A way for developers to donate their spare AI subscription tokens to research nonprofit organizations for Fierce Philanthropy's social impact directory. Each org takes ~5 minutes and ~$0.20 in tokens. Contributors get credit on a public leaderboard.`;
|
|
66
|
+
|
|
67
|
+
// --- Resources ---
|
|
68
|
+
|
|
69
|
+
server.resource('about', 'tokens-for-good://about', 'text/plain', async () => ({
|
|
70
|
+
contents: [{
|
|
71
|
+
uri: 'tokens-for-good://about',
|
|
72
|
+
text: `Tokens for Good - Donate Your Spare AI Tokens to Research Nonprofits
|
|
73
|
+
|
|
74
|
+
What: An MCP server that lets AI coding tool users (Claude Code, Opencode, Cursor, Windsurf, Devin) contribute their spare subscription tokens to research nonprofit organizations for Fierce Philanthropy's social impact directory.
|
|
75
|
+
|
|
76
|
+
How it works:
|
|
77
|
+
1. Sign up at https://tokensforgood.ai/contribute (GitHub OAuth)
|
|
78
|
+
2. Get your API key, add it to your MCP config as TFG_API_KEY
|
|
79
|
+
3. Say "Research an org for Fierce Philanthropy"
|
|
80
|
+
4. Your AI claims an org, researches it (web search + analysis), verifies citations, humanizes the writing, and submits the report
|
|
81
|
+
5. Another contributor's AI peer-reviews your report
|
|
82
|
+
6. A human reviewer finalizes it for the directory
|
|
83
|
+
|
|
84
|
+
Research pipeline (per org, all done by your AI):
|
|
85
|
+
- Research the org using web search + web fetch, following the 6-prompt methodology
|
|
86
|
+
- Score using a weighted checklist (100 pts base, 120 max with extra credit)
|
|
87
|
+
- Verify citations by visiting each URL before submitting
|
|
88
|
+
- Clean up writing style (no AI tells, no filler adjectives, no em dashes)
|
|
89
|
+
|
|
90
|
+
Contributor tiers:
|
|
91
|
+
- New: first 5 orgs, easy orgs only
|
|
92
|
+
- Bronze: 5+ orgs
|
|
93
|
+
- Silver: 25+ orgs, >80% acceptance rate
|
|
94
|
+
- Gold: 100+ orgs, >90% acceptance rate
|
|
95
|
+
|
|
96
|
+
Automation: On Claude Code, use /schedule to auto-contribute daily. On Opencode, set up a system cron. On Cursor/Windsurf, contribute manually when prompted.
|
|
97
|
+
|
|
98
|
+
Cost: ~$0.15-0.25 per org in tokens. Scale: 750K+ US nonprofits to research.`,
|
|
99
|
+
}],
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
// --- Tools ---
|
|
103
|
+
|
|
104
|
+
server.tool('claim_org', 'Claim the next available nonprofit org to research. Blocked if you have a pending peer review.', {
|
|
105
|
+
platform: z.string().optional().describe('Your platform (claude-code, opencode, cursor, windsurf, devin)'),
|
|
106
|
+
}, async ({ platform: plat }) => {
|
|
107
|
+
if (notInitialized()) return { content: [{ type: 'text', text: INIT_GUARD_MESSAGE }] };
|
|
108
|
+
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set. Get your key at https://tokensforgood.ai/contribute' }] };
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const result = await client.claimOrg(plat || platform);
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: 'text', text: `Claimed: ${result.org.name}\nURL: ${result.org.url}\nDescription: ${result.org.description || 'N/A'}\nSource: ${result.org.source || 'N/A'}\nClaim ID: ${result.claim_id}\nExpires: ${result.expires_at}\n\nNext steps:\n1. Call get_methodology with step="research" to get the full research instructions\n2. Follow the methodology to research this org using WebSearch and WebFetch\n3. The methodology includes citation verification and writing quality checks — complete them before submitting\n4. Submit with submit_report when done` }],
|
|
114
|
+
};
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
server.tool('get_methodology', 'Get the full research methodology, verification instructions, or humanization instructions.', {
|
|
121
|
+
step: z.enum(['research', 'verify', 'humanize', 'peer-review']).describe('Which pipeline step to get instructions for'),
|
|
122
|
+
}, async ({ step }) => {
|
|
123
|
+
const stepMap = {
|
|
124
|
+
'research': '01-research/PROMPT.md',
|
|
125
|
+
'verify': '02-verify/PROMPT.md',
|
|
126
|
+
'humanize': '03-humanize/PROMPT.md',
|
|
127
|
+
'peer-review': '04-peer-review/PROMPT.md',
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const content = readFileSync(join(PIPELINE_DIR, stepMap[step]), 'utf-8');
|
|
132
|
+
return { content: [{ type: 'text', text: content }] };
|
|
133
|
+
} catch {
|
|
134
|
+
return { content: [{ type: 'text', text: `Error: Could not load ${step} methodology file.` }] };
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
server.tool('submit_report', 'Submit a completed research report for an org you claimed. You MUST include estimated_tokens.', {
|
|
139
|
+
claim_id: z.string().describe('The claim ID from claim_org'),
|
|
140
|
+
report_markdown: z.string().describe('The full research report in markdown'),
|
|
141
|
+
estimated_tokens: z.number().describe('Estimated total tokens used: count web searches (~1K each), web fetches (~2-5K each), report output (~4 tokens/word), plus ~10K overhead'),
|
|
142
|
+
model_used: z.string().optional().describe('The model that generated this report'),
|
|
143
|
+
}, async ({ claim_id, report_markdown, estimated_tokens, model_used }) => {
|
|
144
|
+
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const result = await client.submitReport(claim_id, report_markdown, estimated_tokens, null, model_used, PKG_VERSION);
|
|
148
|
+
markContributed();
|
|
149
|
+
|
|
150
|
+
// One-off users: first successful submit completes their initial setup,
|
|
151
|
+
// so the SessionStart hook stops prompting from the next session onward.
|
|
152
|
+
const state = loadState();
|
|
153
|
+
if (state.intended_flow === 'one_off' && !state.first_setup_complete) {
|
|
154
|
+
markSetupComplete();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: 'text', text: `Report submitted for ${result.org_name}!\n\nYour stats:\n- Total orgs: ${result.contributor_stats.total_orgs}\n- Tier: ${result.contributor_stats.tier}\n- Orgs remaining: ${result.orgs_remaining}\n\nYour report will now go through peer review. Thank you for contributing!` }],
|
|
159
|
+
};
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return { content: [{ type: 'text', text: `Submit error: ${err.message}${err.data?.validation_errors ? '\n' + err.data.validation_errors.join('\n') : ''}` }] };
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
server.tool('get_peer_review', 'Get a draft report assigned to you for peer review. You must complete peer reviews before claiming new orgs.', {}, async () => {
|
|
166
|
+
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const result = await client.getNextPeerReview();
|
|
170
|
+
let peerMethodology = '';
|
|
171
|
+
try {
|
|
172
|
+
peerMethodology = readFileSync(join(PIPELINE_DIR, '04-peer-review/PROMPT.md'), 'utf-8');
|
|
173
|
+
} catch {
|
|
174
|
+
peerMethodology = 'Score 1-4: 4=Great, 3=Good with fixes (submit corrected version), 2=Needs redo, 1=Bad actor.';
|
|
175
|
+
}
|
|
176
|
+
let factCheckNote = '';
|
|
177
|
+
if (result.automated_review?.summary) {
|
|
178
|
+
const s = result.automated_review.summary;
|
|
179
|
+
const lines = [
|
|
180
|
+
`\n\n## Automated Fact-Check Results`,
|
|
181
|
+
`Quality: ${s.overall_quality} | Fact support: ${Math.round(s.fact_support_rate * 100)}% | Avg trust: ${Math.round(s.avg_trust_score * 100)}%`,
|
|
182
|
+
`Facts checked: ${result.automated_review.facts_checked}/${result.automated_review.facts_extracted} | Citations rated: ${result.automated_review.citations_rated}`,
|
|
183
|
+
];
|
|
184
|
+
if (s.red_flags?.length > 0) {
|
|
185
|
+
lines.push(`\nRed flags:\n${s.red_flags.map(f => ` - ${f}`).join('\n')}`);
|
|
186
|
+
}
|
|
187
|
+
if (s.strengths?.length > 0) {
|
|
188
|
+
lines.push(`\nStrengths:\n${s.strengths.map(f => ` - ${f}`).join('\n')}`);
|
|
189
|
+
}
|
|
190
|
+
lines.push(`\nUse these results to focus your spot-checks on flagged areas.`);
|
|
191
|
+
factCheckNote = lines.join('\n');
|
|
192
|
+
} else if (result.automated_review) {
|
|
193
|
+
factCheckNote = `\n\nAutomated Fact-Check: ${result.automated_review.status} (no summary available yet)`;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
content: [{ type: 'text', text: `Peer review assigned:\nOrg: ${result.org.name}\nAuthor: ${result.author}\nClaim ID: ${result.claim_id}${factCheckNote}\n\n---\n\n${peerMethodology}\n\n---\n\n${result.report_markdown}\n\n---\n\nUse submit_peer_review with your score and notes.` }],
|
|
197
|
+
};
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (err.status === 404) {
|
|
200
|
+
return { content: [{ type: 'text', text: 'No peer reviews assigned to you right now.' }] };
|
|
201
|
+
}
|
|
202
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
server.tool('submit_peer_review', 'Submit your peer review score for a report.', {
|
|
207
|
+
claim_id: z.string().describe('The claim ID of the report being reviewed'),
|
|
208
|
+
score: z.number().int().min(1).max(4).describe('Score: 4=great, 3=good with fixes, 2=needs redo, 1=bad actor'),
|
|
209
|
+
notes: z.string().optional().describe('Review notes explaining the score'),
|
|
210
|
+
updated_report: z.string().optional().describe('If score is 3, the fixed version of the report'),
|
|
211
|
+
}, async ({ claim_id, score, notes, updated_report }) => {
|
|
212
|
+
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const result = await client.submitPeerReview(claim_id, score, notes, updated_report);
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: 'text', text: `Peer review submitted for ${result.org_name}.\nScore: ${result.score}/4\n\nYou can now claim a new org to research.` }],
|
|
218
|
+
};
|
|
219
|
+
} catch (err) {
|
|
220
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
server.tool('research_status', 'See the overall Tokens for Good project progress and leaderboard.', {}, async () => {
|
|
225
|
+
try {
|
|
226
|
+
const clientForStatus = client || new ApiClient('dummy'); // Status is public
|
|
227
|
+
const result = await clientForStatus.getStatus();
|
|
228
|
+
const topList = result.top_contributors?.map((c, i) =>
|
|
229
|
+
`${i + 1}. @${c.github_handle} (${c.total_orgs} orgs, ${c.tier})`
|
|
230
|
+
).join('\n') || 'No contributors yet';
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
content: [{ type: 'text', text: `Tokens for Good Progress:\n\nTotal orgs: ${result.total_orgs}\nPending research: ${result.pending_orgs}\nActive contributors (7d): ${result.active_contributors_7d}\n\nQueue:\n${Object.entries(result.queue || {}).map(([k, v]) => ` ${k}: ${v}`).join('\n')}\n\nTop Contributors:\n${topList}` }],
|
|
234
|
+
};
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
server.tool('my_impact', 'See your personal contribution stats, tier, and history.', {}, async () => {
|
|
241
|
+
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const result = await client.getImpact();
|
|
245
|
+
const c = result.contributor;
|
|
246
|
+
const estimatedCost = (c.total_tokens / 1_000_000 * 3).toFixed(2);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
content: [{ type: 'text', text: `Your Impact (@${c.github_handle}):\n\nTier: ${c.tier}\nOrgs researched: ${c.total_orgs}\nEstimated donation: ~$${estimatedCost}\nAcceptance rate: ${c.acceptance_rate}%\nAutomation: ${c.has_schedule ? 'Active' : 'Not set up'}\n\nRecent:\n${result.claims?.slice(0, 5).map(cl => ` ${cl.organization?.name || 'Unknown'} - ${cl.status}`).join('\n') || 'None'}` }],
|
|
250
|
+
};
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
server.tool('setup_guide', 'Get setup instructions for Tokens for Good. Use this if the user needs help with installation, API keys, or configuration.', {}, async () => {
|
|
257
|
+
return { content: [{ type: 'text', text: NO_KEY_INSTRUCTIONS }] };
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
server.tool('setup_automation', 'Get the scheduled-research prompt + setup instructions for the user\'s platform. Usually called by the /tfg-schedule skill (which extracts the prompt and invokes /schedule). Safe to call directly too — returns human-readable instructions.', {
|
|
261
|
+
frequency: z.enum(['hourly', 'daily', 'weekly']).optional().describe('How often to contribute'),
|
|
262
|
+
}, async ({ frequency }) => {
|
|
263
|
+
if (notInitialized()) return { content: [{ type: 'text', text: INIT_GUARD_MESSAGE }] };
|
|
264
|
+
const instructions = getAutomationInstructions(platform, frequency || 'daily', apiKey);
|
|
265
|
+
return { content: [{ type: 'text', text: instructions }] };
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
server.tool('mark_setup_complete', 'Called by the /tfg-schedule skill after /schedule confirms, or by the /tfg skill after a successful first submission. Flips local state so the SessionStart hook stops emitting first-session instructions. Idempotent — safe to call multiple times.', {}, async () => {
|
|
269
|
+
markSetupComplete();
|
|
270
|
+
return { content: [{ type: 'text', text: 'Marked setup complete. The SessionStart hook will go silent from the next session.' }] };
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
server.tool('snooze', 'Snooze Tokens for Good reminders. Call this when the user says to remind them tomorrow, next week, or in N days.', {
|
|
274
|
+
days: z.number().int().min(1).max(365).describe('Days to snooze (1 = tomorrow, 7 = next week)'),
|
|
275
|
+
}, async ({ days }) => {
|
|
276
|
+
snoozeDays(days);
|
|
277
|
+
return { content: [{ type: 'text', text: `Got it — Tokens for Good will stay quiet for ${days} day${days === 1 ? '' : 's'}.` }] };
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// --- Prompts (session start) ---
|
|
281
|
+
|
|
282
|
+
server.prompt('session_start', 'Check if you should research an org or complete a peer review', {}, async () => {
|
|
283
|
+
// No API key -- guide through setup
|
|
284
|
+
if (!client) {
|
|
285
|
+
return {
|
|
286
|
+
messages: [{
|
|
287
|
+
role: 'user',
|
|
288
|
+
content: { type: 'text', text: NO_KEY_INSTRUCTIONS },
|
|
289
|
+
}],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const state = loadState();
|
|
294
|
+
|
|
295
|
+
// Check for pending peer review first
|
|
296
|
+
try {
|
|
297
|
+
await client.getNextPeerReview();
|
|
298
|
+
return {
|
|
299
|
+
messages: [{
|
|
300
|
+
role: 'user',
|
|
301
|
+
content: { type: 'text', text: `You have a pending peer review to complete before you can claim a new org. Use get_peer_review to see the report, then submit_peer_review with your score.` },
|
|
302
|
+
}],
|
|
303
|
+
};
|
|
304
|
+
} catch {
|
|
305
|
+
// No pending review, continue
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (isSnoozed()) {
|
|
309
|
+
return { messages: [{ role: 'user', content: { type: 'text', text: 'Tokens for Good is snoozed. No action needed.' } }] };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (state.auto_schedule) {
|
|
313
|
+
try {
|
|
314
|
+
const impact = await client?.getImpact();
|
|
315
|
+
const c = impact?.contributor;
|
|
316
|
+
return {
|
|
317
|
+
messages: [{
|
|
318
|
+
role: 'user',
|
|
319
|
+
content: { type: 'text', text: `Tokens for Good: You're auto-contributing. ${c?.total_orgs || 0} orgs researched so far. Tier: ${c?.tier || 'new'}.` },
|
|
320
|
+
}],
|
|
321
|
+
};
|
|
322
|
+
} catch {
|
|
323
|
+
return { messages: [{ role: 'user', content: { type: 'text', text: 'Tokens for Good: Auto-contributions active.' } }] };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (hasContributedToday()) {
|
|
328
|
+
return { messages: [{ role: 'user', content: { type: 'text', text: 'Tokens for Good: You already contributed today. Nice work!' } }] };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Show the session start prompt
|
|
332
|
+
if (isSchedulable(platform)) {
|
|
333
|
+
return {
|
|
334
|
+
messages: [{
|
|
335
|
+
role: 'user',
|
|
336
|
+
content: { type: 'text', text: `Tokens for Good: Would you like to donate your spare tokens to research a nonprofit today?\n\n1. Set up automatic daily contributions (recommended) — run /tfg-schedule\n2. Just run one now — run /tfg\n3. Ask me tomorrow\n4. Ask me in a week` },
|
|
337
|
+
}],
|
|
338
|
+
};
|
|
339
|
+
} else {
|
|
340
|
+
return {
|
|
341
|
+
messages: [{
|
|
342
|
+
role: 'user',
|
|
343
|
+
content: { type: 'text', text: `Tokens for Good: Would you like to research a nonprofit org today? It takes about 5 minutes and costs ~$0.20 in tokens.\n\n1. Research an org now\n2. Ask me tomorrow\n3. Ask me in a week\n\nUse claim_org for option 1.` },
|
|
344
|
+
}],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// --- Start ---
|
|
350
|
+
|
|
351
|
+
async function main() {
|
|
352
|
+
const transport = new StdioServerTransport();
|
|
353
|
+
await server.connect(transport);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
main().catch(console.error);
|
package/src/platform.js
CHANGED
|
@@ -6,23 +6,26 @@ export function detectPlatform() {
|
|
|
6
6
|
if (process.env.DEVIN) return 'devin';
|
|
7
7
|
if (process.env.CURSOR_SESSION) return 'cursor';
|
|
8
8
|
if (process.env.WINDSURF_SESSION) return 'windsurf';
|
|
9
|
+
// Qwen Code doesn't reliably export a parent-identifying env var, so we
|
|
10
|
+
// fall through to the parent-process heuristic for it.
|
|
9
11
|
|
|
10
12
|
const parentName = process.env._ || process.env.PARENT_PROCESS || '';
|
|
11
13
|
if (parentName.includes('claude')) return 'claude-code';
|
|
12
14
|
if (parentName.includes('opencode')) return 'opencode';
|
|
13
15
|
if (parentName.includes('cursor')) return 'cursor';
|
|
14
16
|
if (parentName.includes('windsurf')) return 'windsurf';
|
|
17
|
+
if (parentName.includes('qwen')) return 'qwen-code';
|
|
15
18
|
|
|
16
19
|
// Default to claude-code since it's the primary MCP host
|
|
17
20
|
return 'claude-code';
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export function isSchedulable(platform) {
|
|
21
|
-
return ['claude-code', 'opencode', 'devin'].includes(platform);
|
|
24
|
+
return ['claude-code', 'opencode', 'devin', 'qwen-code'].includes(platform);
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export function getSchedulePrompt(apiKey) {
|
|
25
|
-
const base = 'https://
|
|
28
|
+
const base = 'https://tokensforgood.ai/api';
|
|
26
29
|
return `You are a research agent for Fierce Philanthropy's Tokens for Good program.
|
|
27
30
|
|
|
28
31
|
## Setup
|
|
@@ -74,6 +77,16 @@ Configure a ${frequency} recurring session with the prompt:
|
|
|
74
77
|
|
|
75
78
|
Devin runs in the cloud, fully autonomous.`;
|
|
76
79
|
|
|
80
|
+
case 'qwen-code':
|
|
81
|
+
return `Set up automated contributions on Qwen Code.
|
|
82
|
+
|
|
83
|
+
Qwen Code v0.14+ has experimental built-in cron — enable it with QWEN_CODE_ENABLE_CRON=1 (or "experimental.cron": true in ~/.qwen/settings.json) and then use the Cron tool / /loop skill.
|
|
84
|
+
|
|
85
|
+
For a portable option, use a system cron job (add via crontab -e):
|
|
86
|
+
${getCronExpression(frequency)} cd /path/to/workspace && qwen --prompt "Research a nonprofit org for Fierce Philanthropy using the tokens-for-good MCP tools. Claim an org, research it, then submit the report."
|
|
87
|
+
|
|
88
|
+
Your machine must stay on for system cron to run.`;
|
|
89
|
+
|
|
77
90
|
default:
|
|
78
91
|
return getAutomationInstructions('claude-code', frequency, apiKey);
|
|
79
92
|
}
|