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 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://fierce-philanthropy-directory.laravel.cloud)'s social impact directory. Like Folding@Home, but for AI tokens — crowdsourced compute for social good.
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 Devin as an MCP server.
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 [fierce-philanthropy-directory.laravel.cloud/contribute](https://fierce-philanthropy-directory.laravel.cloud/contribute) (GitHub OAuth, free) and copy your API key.
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
- ## Development
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokens-for-good",
3
- "version": "0.4.2",
3
+ "version": "0.4.5",
4
4
  "type": "module",
5
5
  "description": "Donate your spare AI tokens to research nonprofits for Fierce Philanthropy",
6
6
  "bin": {
@@ -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
- - **Intermediary:** changes in behavior or action from gains in knowledge, skills, or attitudes
57
- - **Ultimate:** changes in condition or life status (reduced poverty, improved health, economic stability)
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
- Then include this scored checklist. Base score is out of 100. Counterfactuals are extra credit (max 120).
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]/100** (can exceed 100 with extra credit, max 120)
106
+ **Score: [X]/120**
99
107
 
100
- **Section 2 — The Social Problem**
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 3 — The Solution**
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 4 — Key Outputs**
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 5 — Key Intermediate Outcomes**
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 6 — Key Ultimate Outcomes**
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 7 — Continual Learning & Adaptation**
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 7 summary sections present with substantive content
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 7 summary sections present (Sections 1-7)
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/100** (can exceed 100 with extra credit, max 120)
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://fierce-philanthropy-directory.laravel.cloud/api';
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://fierce-philanthropy-directory.laravel.cloud/contribute');
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
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  // CLI entry point for tokens-for-good.
4
4
  // Usage:
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://fierce-philanthropy-directory.laravel.cloud/contribute):',
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
- const abs = (() => {
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() { return homeRelative(join(homedir(), '.claude', 'settings.json')); }
170
- function skillPath(name) { return homeRelative(join(homedir(), '.claude', 'skills', name, 'SKILL.md')); }
171
- function statePath() { return homeRelative(join(homedir(), '.tokens-for-good', 'state.json')); }
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
- try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return {}; }
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
- const cron = freq === 'hourly' ? '0 * * * *' : freq === 'weekly' ? '0 2 * * 1' : '0 2 * * *';
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://fierce-philanthropy-directory.laravel.cloud/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://fierce-philanthropy-directory.laravel.cloud/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://fierce-philanthropy-directory.laravel.cloud/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, null, 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().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
- // --- Prompts (session start) ---
274
-
275
- server.prompt('session_start', 'Check if you should research an org or complete a peer review', {}, async () => {
276
- // No API key -- guide through setup
277
- if (!client) {
278
- return {
279
- messages: [{
280
- role: 'user',
281
- content: { type: 'text', text: NO_KEY_INSTRUCTIONS },
282
- }],
283
- };
284
- }
285
-
286
- const state = loadState();
287
-
288
- // Check for pending peer review first
289
- if (client) {
290
- try {
291
- const review = await client.getNextPeerReview();
292
- return {
293
- messages: [{
294
- role: 'user',
295
- 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.` },
296
- }],
297
- };
298
- } catch {
299
- // No pending review, continue
300
- }
301
- }
302
-
303
- if (isSnoozed()) {
304
- return { messages: [{ role: 'user', content: { type: 'text', text: 'Tokens for Good is snoozed. No action needed.' } }] };
305
- }
306
-
307
- if (state.auto_schedule) {
308
- try {
309
- const impact = await client?.getImpact();
310
- const c = impact?.contributor;
311
- return {
312
- messages: [{
313
- role: 'user',
314
- content: { type: 'text', text: `Tokens for Good: You're auto-contributing. ${c?.total_orgs || 0} orgs researched so far. Tier: ${c?.tier || 'new'}.` },
315
- }],
316
- };
317
- } catch {
318
- return { messages: [{ role: 'user', content: { type: 'text', text: 'Tokens for Good: Auto-contributions active.' } }] };
319
- }
320
- }
321
-
322
- if (hasContributedToday()) {
323
- return { messages: [{ role: 'user', content: { type: 'text', text: 'Tokens for Good: You already contributed today. Nice work!' } }] };
324
- }
325
-
326
- // Show the session start prompt
327
- if (isSchedulable(platform)) {
328
- return {
329
- messages: [{
330
- role: 'user',
331
- 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` },
332
- }],
333
- };
334
- } else {
335
- return {
336
- messages: [{
337
- role: 'user',
338
- 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.` },
339
- }],
340
- };
341
- }
342
- });
343
-
344
- // --- Start ---
345
-
346
- async function main() {
347
- const transport = new StdioServerTransport();
348
- await server.connect(transport);
349
- }
350
-
351
- main().catch(console.error);
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://fierce-philanthropy-directory.laravel.cloud/api';
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
  }
package/src/state.js CHANGED
@@ -78,6 +78,7 @@ export function markSetupComplete() {
78
78
  updateState({ first_setup_complete: true });
79
79
  }
80
80
 
81
+
81
82
  export function isInitialized() {
82
83
  const state = loadState();
83
84
  return state.intended_flow !== null;