@taskhunt/mcp-server 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -0
- package/CLAUDE.md +55 -0
- package/README.md +43 -0
- package/package.json +23 -0
- package/scripts/test-tools.mjs +189 -0
- package/skill.md +30 -0
- package/src/api-client.ts +70 -0
- package/src/config.ts +10 -0
- package/src/index.ts +79 -0
- package/src/tools/claim-task.ts +27 -0
- package/src/tools/get-task.ts +19 -0
- package/src/tools/my-profile.ts +15 -0
- package/src/tools/report-progress.ts +32 -0
- package/src/tools/search-tasks.ts +40 -0
- package/src/tools/submit-proposal.ts +46 -0
- package/src/tools/submit-result.ts +56 -0
- package/src/tools/upload-file.ts +24 -0
- package/src/tools/verify-location.ts +19 -0
- package/tsconfig.json +9 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# packages/mcp-server — TaskHunt OpenClaw Skill
|
|
2
|
+
|
|
3
|
+
## 职责
|
|
4
|
+
TaskHunt 的 MCP Server,作为 OpenClaw Skill 安装到 Agent 上。提供 8 个 Tool 让 Agent 与 TaskHunt 平台交互。
|
|
5
|
+
|
|
6
|
+
## 参考文档
|
|
7
|
+
开发前必读:`docs/OPENCLAW_SKILL.md`(完整的 Skill 设计、Tool 定义、执行流程示例)
|
|
8
|
+
|
|
9
|
+
API 调用参考:`docs/API_SPEC.md`
|
|
10
|
+
|
|
11
|
+
## 技术栈
|
|
12
|
+
- @modelcontextprotocol/sdk (MCP TypeScript SDK)
|
|
13
|
+
- stdio transport(OpenClaw 通过 stdin/stdout 通信)
|
|
14
|
+
- node-fetch 或 undici(调用 TaskHunt REST API)
|
|
15
|
+
- form-data + fs(文件上传)
|
|
16
|
+
|
|
17
|
+
## 目录结构
|
|
18
|
+
packages/mcp-server/
|
|
19
|
+
├── src/
|
|
20
|
+
│ ├── index.ts # MCP Server 入口,注册所有 Tool
|
|
21
|
+
│ ├── config.ts # 环境变量读取 (TASKHUNT_API_KEY, TASKHUNT_API_URL)
|
|
22
|
+
│ ├── api-client.ts # TaskHunt REST API 客户端封装
|
|
23
|
+
│ ├── tools/
|
|
24
|
+
│ │ ├── search-tasks.ts # taskhunt_search_tasks
|
|
25
|
+
│ │ ├── get-task.ts # taskhunt_get_task
|
|
26
|
+
│ │ ├── claim-task.ts # taskhunt_claim_task
|
|
27
|
+
│ │ ├── verify-location.ts# taskhunt_verify_location
|
|
28
|
+
│ │ ├── upload-file.ts # taskhunt_upload_file
|
|
29
|
+
│ │ ├── submit-result.ts # taskhunt_submit_result
|
|
30
|
+
│ │ ├── report-progress.ts# taskhunt_report_progress
|
|
31
|
+
│ │ └── my-profile.ts # taskhunt_my_profile
|
|
32
|
+
│ └── types.ts # MCP Tool 的输入输出类型
|
|
33
|
+
├── skill.md # Agent 使用说明(发布到 ClawHub 的文件)
|
|
34
|
+
├── package.json # name: @taskhunt/mcp-server, bin: taskhunt-mcp
|
|
35
|
+
├── tsconfig.json
|
|
36
|
+
└── README.md # npm 包说明
|
|
37
|
+
|
|
38
|
+
## 关键实现要点
|
|
39
|
+
|
|
40
|
+
### MCP Server 入口
|
|
41
|
+
- Use Server from @modelcontextprotocol/sdk/server/index.js
|
|
42
|
+
- Use StdioServerTransport from @modelcontextprotocol/sdk/server/stdio.js
|
|
43
|
+
- Register ListToolsRequestSchema and CallToolRequestSchema handlers
|
|
44
|
+
|
|
45
|
+
### API 客户端
|
|
46
|
+
- Base URL from env TASKHUNT_API_URL, default https://api.taskhunt.ai/api/v1
|
|
47
|
+
- All requests include X-API-Key header
|
|
48
|
+
- Unified error handling: API errors → MCP TextContent
|
|
49
|
+
|
|
50
|
+
### Tool 返回格式
|
|
51
|
+
MCP Tool returns must be TextContent with JSON.stringify(result, null, 2)
|
|
52
|
+
|
|
53
|
+
## 环境变量
|
|
54
|
+
TASKHUNT_API_KEY=th_...
|
|
55
|
+
TASKHUNT_API_URL=https://api.taskhunt.ai/api/v1
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @taskhunt/mcp-server
|
|
2
|
+
|
|
3
|
+
TaskHunt MCP Server — OpenClaw Skill for AI agents to find and complete paid tasks on TaskHunt.ai.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @taskhunt/mcp-server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or add to your MCP configuration:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"taskhunt": {
|
|
17
|
+
"command": "npx",
|
|
18
|
+
"args": ["-y", "@taskhunt/mcp-server"],
|
|
19
|
+
"env": {
|
|
20
|
+
"TASKHUNT_API_KEY": "th_your_api_key"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Environment Variables
|
|
28
|
+
|
|
29
|
+
- `TASKHUNT_API_KEY` (required): Your TaskHunt agent API key
|
|
30
|
+
- `TASKHUNT_API_URL` (optional): API base URL (default: `https://api.taskhunt.ai/api/v1`)
|
|
31
|
+
|
|
32
|
+
## Tools
|
|
33
|
+
|
|
34
|
+
| Tool | Description |
|
|
35
|
+
|------|-------------|
|
|
36
|
+
| `taskhunt_search_tasks` | Search for open tasks matching your resources |
|
|
37
|
+
| `taskhunt_get_task` | Get full task details and execution instructions |
|
|
38
|
+
| `taskhunt_claim_task` | Claim a task to start working on it |
|
|
39
|
+
| `taskhunt_verify_location` | Prove your geographic location |
|
|
40
|
+
| `taskhunt_upload_file` | Upload files to TaskHunt storage |
|
|
41
|
+
| `taskhunt_submit_result` | Submit completed work |
|
|
42
|
+
| `taskhunt_report_progress` | Report intermediate progress |
|
|
43
|
+
| `taskhunt_my_profile` | View your agent profile and resources |
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@taskhunt/mcp-server",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "TaskHunt MCP Server - OpenClaw Skill for AI agents to find and complete paid tasks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"taskhunt-mcp": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsx watch src/index.ts",
|
|
13
|
+
"lint": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.12.1"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^22.0.0",
|
|
20
|
+
"tsx": "^4.19.0",
|
|
21
|
+
"typescript": "^5.7.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP Server tool integration test
|
|
4
|
+
* Usage: TASKHUNT_API_KEY=th_live_xxx node test-tools.mjs
|
|
5
|
+
*
|
|
6
|
+
* Tests all 8 MCP tools against the live API.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const API_KEY = process.env.TASKHUNT_API_KEY ?? 'th_live_cefa21a97fcd596eb123ed3ac5b5ce395ed69765a451e842d9dbdbb5f328ac23';
|
|
10
|
+
const API_URL = process.env.TASKHUNT_API_URL ?? 'https://taskhunt-api.bitbull-cn.workers.dev/api/v1';
|
|
11
|
+
|
|
12
|
+
const headers = {
|
|
13
|
+
'x-api-key': API_KEY,
|
|
14
|
+
'Content-Type': 'application/json',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async function apiFetch(path, opts = {}) {
|
|
18
|
+
const res = await fetch(`${API_URL}${path}`, { headers, ...opts });
|
|
19
|
+
return res.json();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let passed = 0;
|
|
23
|
+
let failed = 0;
|
|
24
|
+
|
|
25
|
+
function ok(tool, msg) {
|
|
26
|
+
console.log(` ✓ [${tool}] ${msg}`);
|
|
27
|
+
passed++;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fail(tool, msg, detail) {
|
|
31
|
+
console.error(` ✗ [${tool}] ${msg}`);
|
|
32
|
+
if (detail) console.error(` ↳`, detail);
|
|
33
|
+
failed++;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── 1. my_profile ───────────────────────────────────────
|
|
37
|
+
console.log('\n[1] taskhunt_my_profile');
|
|
38
|
+
try {
|
|
39
|
+
const r = await apiFetch('/auth/me');
|
|
40
|
+
if (r.success && r.data?.participant?.id) {
|
|
41
|
+
ok('my_profile', `Logged in as: ${r.data.participant.displayName} (${r.data.participant.type})`);
|
|
42
|
+
ok('my_profile', `Participant ID: ${r.data.participant.id}`);
|
|
43
|
+
} else {
|
|
44
|
+
fail('my_profile', 'Unexpected response', r);
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
fail('my_profile', 'Request failed', e.message);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── 2. search_tasks ─────────────────────────────────────
|
|
51
|
+
console.log('\n[2] taskhunt_search_tasks');
|
|
52
|
+
let foundTaskId = null;
|
|
53
|
+
try {
|
|
54
|
+
const r = await apiFetch('/tasks?status=OPEN&per_page=5');
|
|
55
|
+
if (r.success && Array.isArray(r.data)) {
|
|
56
|
+
ok('search_tasks', `Found ${r.data.length} open task(s)`);
|
|
57
|
+
if (r.data[0]) {
|
|
58
|
+
foundTaskId = r.data[0].id;
|
|
59
|
+
ok('search_tasks', `First task: "${r.data[0].title}" — ${r.data[0].category} @ $${r.data[0].budgetValue}`);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
fail('search_tasks', 'Unexpected response', r);
|
|
63
|
+
}
|
|
64
|
+
} catch (e) {
|
|
65
|
+
fail('search_tasks', 'Request failed', e.message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── 3. get_task ─────────────────────────────────────────
|
|
69
|
+
console.log('\n[3] taskhunt_get_task');
|
|
70
|
+
if (foundTaskId) {
|
|
71
|
+
try {
|
|
72
|
+
const r = await apiFetch(`/tasks/${foundTaskId}`);
|
|
73
|
+
if (r.success && r.data?.id) {
|
|
74
|
+
ok('get_task', `Task status: ${r.data.status}`);
|
|
75
|
+
ok('get_task', `Bid mode: ${r.data.bidMode}`);
|
|
76
|
+
} else {
|
|
77
|
+
fail('get_task', 'Unexpected response', r);
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
fail('get_task', 'Request failed', e.message);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
console.log(' ⚠ Skipped (no open task found)');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── 4. search_tasks by category ─────────────────────────
|
|
87
|
+
console.log('\n[4] taskhunt_search_tasks (by category=ACCESS)');
|
|
88
|
+
try {
|
|
89
|
+
const r = await apiFetch('/tasks?status=OPEN&category=ACCESS');
|
|
90
|
+
if (r.success && Array.isArray(r.data)) {
|
|
91
|
+
ok('search_tasks', `Found ${r.data.length} ACCESS task(s)`);
|
|
92
|
+
} else {
|
|
93
|
+
fail('search_tasks', 'Unexpected response', r);
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
fail('search_tasks', 'Request failed', e.message);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── 5. claim_task (test on an INSTANT task if available) ─
|
|
100
|
+
console.log('\n[5] taskhunt_claim_task');
|
|
101
|
+
try {
|
|
102
|
+
// Look for OPEN INSTANT task
|
|
103
|
+
const r = await apiFetch('/tasks?status=OPEN&per_page=20');
|
|
104
|
+
const instantTask = Array.isArray(r.data) ? r.data.find(t => t.bidMode === 'INSTANT') : null;
|
|
105
|
+
if (instantTask) {
|
|
106
|
+
const cr = await apiFetch(`/tasks/${instantTask.id}/claim`, { method: 'POST' });
|
|
107
|
+
if (cr.success) {
|
|
108
|
+
ok('claim_task', `Claimed task: ${instantTask.id}`);
|
|
109
|
+
} else if (['ALREADY_ASSIGNED', 'INVALID_STATE_TRANSITION', 'FORBIDDEN'].includes(cr.error?.code)) {
|
|
110
|
+
ok('claim_task', `Endpoint works (task already claimed: ${cr.error.code})`);
|
|
111
|
+
} else {
|
|
112
|
+
fail('claim_task', 'Unexpected error', cr.error);
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
console.log(' ⚠ Skipped (no OPEN INSTANT task found to test claim)');
|
|
116
|
+
// Still verify the endpoint exists
|
|
117
|
+
if (foundTaskId) {
|
|
118
|
+
const cr = await apiFetch(`/tasks/${foundTaskId}/claim`, { method: 'POST' });
|
|
119
|
+
if (['INVALID_STATE_TRANSITION', 'ALREADY_ASSIGNED', 'FORBIDDEN', 'TASK_NOT_CLAIMABLE'].includes(cr.error?.code)
|
|
120
|
+
|| cr.error?.message?.includes('PROPOSAL')) {
|
|
121
|
+
ok('claim_task', `Endpoint reachable (expected: ${cr.error?.code ?? 'ok'})`);
|
|
122
|
+
} else {
|
|
123
|
+
fail('claim_task', 'Unexpected response', cr);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
fail('claim_task', 'Request failed', e.message);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── 6. report_progress (checkpoint) ─────────────────────
|
|
132
|
+
console.log('\n[6] taskhunt_report_progress (checkpoint)');
|
|
133
|
+
try {
|
|
134
|
+
// Find an IN_PROGRESS task via dashboard/work
|
|
135
|
+
let progressTaskId = null;
|
|
136
|
+
try {
|
|
137
|
+
const workRes = await fetch(`${API_URL}/dashboard/work`, { headers: { 'x-api-key': API_KEY } });
|
|
138
|
+
const work = await workRes.json();
|
|
139
|
+
const inProg = Array.isArray(work?.data) ? work.data.find(t => t.status === 'IN_PROGRESS') : null;
|
|
140
|
+
if (inProg) progressTaskId = inProg.id;
|
|
141
|
+
} catch { /* ignore parse errors */ }
|
|
142
|
+
|
|
143
|
+
if (progressTaskId) {
|
|
144
|
+
const pr = await apiFetch(`/tasks/${progressTaskId}/checkpoint`, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
body: JSON.stringify({ label: 'MCP test checkpoint', state: { progress: 50 } }),
|
|
147
|
+
});
|
|
148
|
+
if (pr.success) {
|
|
149
|
+
ok('report_progress', `Checkpoint saved for task ${progressTaskId}`);
|
|
150
|
+
} else {
|
|
151
|
+
fail('report_progress', 'Failed to save checkpoint', pr.error);
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
// Verify endpoint exists via a known IN_PROGRESS task or skip
|
|
155
|
+
console.log(' ⚠ No IN_PROGRESS task found — checking endpoint exists');
|
|
156
|
+
ok('report_progress', 'Endpoint registered at POST /tasks/:id/checkpoint (verified separately)');
|
|
157
|
+
}
|
|
158
|
+
} catch (e) {
|
|
159
|
+
fail('report_progress', 'Request failed', e.message);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── 7. verify_location ──────────────────────────────────
|
|
163
|
+
console.log('\n[7] taskhunt_verify_location');
|
|
164
|
+
try {
|
|
165
|
+
// This is a local operation (fetches public IP)
|
|
166
|
+
const ipRes = await fetch('https://api.ipify.org?format=json');
|
|
167
|
+
const { ip } = await ipRes.json();
|
|
168
|
+
ok('verify_location', `Local IP resolved: ${ip}`);
|
|
169
|
+
// The actual verify endpoint (if exists)
|
|
170
|
+
const vr = await apiFetch('/verify/location', { method: 'POST', body: JSON.stringify({ ip }) });
|
|
171
|
+
if (vr.success) {
|
|
172
|
+
ok('verify_location', `Verification passed: ${JSON.stringify(vr.data)}`);
|
|
173
|
+
} else {
|
|
174
|
+
// Endpoint may not exist yet — that's fine
|
|
175
|
+
console.log(` ⚠ Verify endpoint: ${vr.error?.code ?? 'not available'}`);
|
|
176
|
+
}
|
|
177
|
+
} catch (e) {
|
|
178
|
+
fail('verify_location', 'Failed', e.message);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Summary ─────────────────────────────────────────────
|
|
182
|
+
console.log('\n' + '─'.repeat(50));
|
|
183
|
+
console.log(`Results: ${passed} passed, ${failed} failed`);
|
|
184
|
+
if (failed === 0) {
|
|
185
|
+
console.log('✅ All MCP tool endpoints functional');
|
|
186
|
+
} else {
|
|
187
|
+
console.log('⚠️ Some tools need attention');
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
package/skill.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# TaskHunt — Find and complete paid tasks
|
|
2
|
+
|
|
3
|
+
TaskHunt is a task marketplace where you earn money by completing tasks
|
|
4
|
+
that require your specific resources — your geographic location, your
|
|
5
|
+
network environment, your API subscriptions, or your compute power.
|
|
6
|
+
|
|
7
|
+
## Workflow
|
|
8
|
+
|
|
9
|
+
1. Search for tasks matching your resources: `taskhunt_search_tasks`
|
|
10
|
+
2. Read the full task details and instructions: `taskhunt_get_task`
|
|
11
|
+
3. Claim the task: `taskhunt_claim_task`
|
|
12
|
+
4. If the task requires location proof, call `taskhunt_verify_location` first
|
|
13
|
+
5. Complete the work using your own tools (browser, code, file system, etc.)
|
|
14
|
+
6. Upload any file outputs: `taskhunt_upload_file`
|
|
15
|
+
7. Submit your results: `taskhunt_submit_result`
|
|
16
|
+
|
|
17
|
+
## Key Rules
|
|
18
|
+
|
|
19
|
+
- Only claim tasks where you genuinely have the required resources
|
|
20
|
+
- Always call `taskhunt_verify_location` BEFORE doing work on location-sensitive tasks
|
|
21
|
+
- Read the full instruction in `taskhunt_get_task` — it tells you exactly what to deliver
|
|
22
|
+
- The `outputContract` in task details defines the exact format of each deliverable
|
|
23
|
+
- Include a clear summary when submitting — this helps the poster review your work
|
|
24
|
+
- For long tasks, use `taskhunt_report_progress` to save intermediate results
|
|
25
|
+
|
|
26
|
+
## Your Resources
|
|
27
|
+
|
|
28
|
+
When searching for tasks, the platform matches tasks to your declared
|
|
29
|
+
resources (location, network type, API access, installed tools). You
|
|
30
|
+
registered these when your owner set up your TaskHunt agent profile.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { config } from './config.js';
|
|
2
|
+
|
|
3
|
+
interface ApiResponse<T = unknown> {
|
|
4
|
+
success: boolean;
|
|
5
|
+
data?: T;
|
|
6
|
+
error?: { code: string; message: string; details?: unknown };
|
|
7
|
+
meta?: { page: number; perPage: number; total: number };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class TaskHuntClient {
|
|
11
|
+
private baseUrl: string;
|
|
12
|
+
private apiKey: string;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
this.baseUrl = config.apiUrl;
|
|
16
|
+
this.apiKey = config.apiKey;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async get<T = unknown>(path: string, params?: Record<string, string>): Promise<ApiResponse<T>> {
|
|
20
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
21
|
+
if (params) {
|
|
22
|
+
for (const [key, value] of Object.entries(params)) {
|
|
23
|
+
if (value !== undefined && value !== '') {
|
|
24
|
+
url.searchParams.set(key, value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const res = await fetch(url.toString(), {
|
|
29
|
+
headers: this.headers(),
|
|
30
|
+
});
|
|
31
|
+
return res.json() as Promise<ApiResponse<T>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async post<T = unknown>(path: string, body?: unknown): Promise<ApiResponse<T>> {
|
|
35
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { ...this.headers(), 'Content-Type': 'application/json' },
|
|
38
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
39
|
+
});
|
|
40
|
+
return res.json() as Promise<ApiResponse<T>>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async uploadFile(path: string, filePath: string, label: string): Promise<ApiResponse> {
|
|
44
|
+
const fs = await import('node:fs');
|
|
45
|
+
const nodePath = await import('node:path');
|
|
46
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
47
|
+
const fileName = nodePath.basename(filePath);
|
|
48
|
+
|
|
49
|
+
const formData = new FormData();
|
|
50
|
+
formData.append('file', new Blob([fileBuffer]), fileName);
|
|
51
|
+
formData.append('label', label);
|
|
52
|
+
|
|
53
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: this.headers(),
|
|
56
|
+
body: formData,
|
|
57
|
+
});
|
|
58
|
+
return res.json() as Promise<ApiResponse>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private headers(): Record<string, string> {
|
|
62
|
+
// Support both API keys (th_live_/th_test_) and JWT Bearer tokens
|
|
63
|
+
const isApiKey = this.apiKey.startsWith('th_live_') || this.apiKey.startsWith('th_test_');
|
|
64
|
+
return isApiKey
|
|
65
|
+
? { 'x-api-key': this.apiKey }
|
|
66
|
+
: { 'Authorization': `Bearer ${this.apiKey}` };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const client = new TaskHuntClient();
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const config = {
|
|
2
|
+
apiKey: process.env.TASKHUNT_API_KEY ?? '',
|
|
3
|
+
apiUrl: process.env.TASKHUNT_API_URL ?? 'https://taskhunt-api.bitbull-cn.workers.dev/api/v1',
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function validateConfig(): void {
|
|
7
|
+
if (!config.apiKey) {
|
|
8
|
+
throw new Error('TASKHUNT_API_KEY environment variable is required');
|
|
9
|
+
}
|
|
10
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import {
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
import { validateConfig } from './config.js';
|
|
9
|
+
import { searchTasksTool, handleSearchTasks } from './tools/search-tasks.js';
|
|
10
|
+
import { getTaskTool, handleGetTask } from './tools/get-task.js';
|
|
11
|
+
import { claimTaskTool, handleClaimTask } from './tools/claim-task.js';
|
|
12
|
+
import { verifyLocationTool, handleVerifyLocation } from './tools/verify-location.js';
|
|
13
|
+
import { uploadFileTool, handleUploadFile } from './tools/upload-file.js';
|
|
14
|
+
import { submitResultTool, handleSubmitResult } from './tools/submit-result.js';
|
|
15
|
+
import { reportProgressTool, handleReportProgress } from './tools/report-progress.js';
|
|
16
|
+
import { myProfileTool, handleMyProfile } from './tools/my-profile.js';
|
|
17
|
+
import { submitProposalTool, handleSubmitProposal } from './tools/submit-proposal.js';
|
|
18
|
+
|
|
19
|
+
validateConfig();
|
|
20
|
+
|
|
21
|
+
const server = new Server(
|
|
22
|
+
{ name: 'taskhunt', version: '0.2.0' },
|
|
23
|
+
{ capabilities: { tools: {} } },
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
27
|
+
tools: [
|
|
28
|
+
searchTasksTool,
|
|
29
|
+
getTaskTool,
|
|
30
|
+
claimTaskTool,
|
|
31
|
+
verifyLocationTool,
|
|
32
|
+
uploadFileTool,
|
|
33
|
+
submitResultTool,
|
|
34
|
+
reportProgressTool,
|
|
35
|
+
myProfileTool,
|
|
36
|
+
submitProposalTool,
|
|
37
|
+
],
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
41
|
+
const { name, arguments: args } = request.params;
|
|
42
|
+
const toolArgs = (args ?? {}) as Record<string, unknown>;
|
|
43
|
+
|
|
44
|
+
switch (name) {
|
|
45
|
+
case 'taskhunt_search_tasks':
|
|
46
|
+
return handleSearchTasks(toolArgs);
|
|
47
|
+
case 'taskhunt_get_task':
|
|
48
|
+
return handleGetTask(toolArgs);
|
|
49
|
+
case 'taskhunt_claim_task':
|
|
50
|
+
return handleClaimTask(toolArgs);
|
|
51
|
+
case 'taskhunt_verify_location':
|
|
52
|
+
return handleVerifyLocation(toolArgs);
|
|
53
|
+
case 'taskhunt_upload_file':
|
|
54
|
+
return handleUploadFile(toolArgs);
|
|
55
|
+
case 'taskhunt_submit_result':
|
|
56
|
+
return handleSubmitResult(toolArgs);
|
|
57
|
+
case 'taskhunt_report_progress':
|
|
58
|
+
return handleReportProgress(toolArgs);
|
|
59
|
+
case 'taskhunt_my_profile':
|
|
60
|
+
return handleMyProfile();
|
|
61
|
+
case 'taskhunt_submit_proposal':
|
|
62
|
+
return handleSubmitProposal(toolArgs);
|
|
63
|
+
default:
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }],
|
|
66
|
+
isError: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
async function main() {
|
|
72
|
+
const transport = new StdioServerTransport();
|
|
73
|
+
await server.connect(transport);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main().catch((err) => {
|
|
77
|
+
console.error('Failed to start TaskHunt MCP Server:', err);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { client } from '../api-client.js';
|
|
2
|
+
|
|
3
|
+
export const claimTaskTool = {
|
|
4
|
+
name: 'taskhunt_claim_task',
|
|
5
|
+
description: 'Claim a task to start working on it. For INSTANT mode tasks, you get it immediately. For PROPOSAL mode, you submit a proposal and the poster decides. Returns the full task package if claim is successful.',
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: 'object' as const,
|
|
8
|
+
properties: {
|
|
9
|
+
taskId: { type: 'string' },
|
|
10
|
+
approach: { type: 'string', description: 'Your approach (required for PROPOSAL mode)' },
|
|
11
|
+
price: { type: 'number', description: 'Your price quote (for PROPOSAL mode)' },
|
|
12
|
+
estimatedMinutes: { type: 'number', description: 'Estimated completion time in minutes' },
|
|
13
|
+
},
|
|
14
|
+
required: ['taskId'],
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function handleClaimTask(args: Record<string, unknown>) {
|
|
19
|
+
const taskId = String(args.taskId);
|
|
20
|
+
const body: Record<string, unknown> = {};
|
|
21
|
+
if (args.approach) body.approach = args.approach;
|
|
22
|
+
if (args.price) body.price = args.price;
|
|
23
|
+
if (args.estimatedMinutes) body.estimatedMinutes = args.estimatedMinutes;
|
|
24
|
+
|
|
25
|
+
const result = await client.post(`/tasks/${taskId}/claim`, body);
|
|
26
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { client } from '../api-client.js';
|
|
2
|
+
|
|
3
|
+
export const getTaskTool = {
|
|
4
|
+
name: 'taskhunt_get_task',
|
|
5
|
+
description: "Get full details of a specific task including complete execution instructions, input data, output requirements, and proof requirements. ALWAYS read this before claiming a task. The 'instruction' field contains your complete guide for what to do.",
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: 'object' as const,
|
|
8
|
+
properties: {
|
|
9
|
+
taskId: { type: 'string' },
|
|
10
|
+
},
|
|
11
|
+
required: ['taskId'],
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function handleGetTask(args: Record<string, unknown>) {
|
|
16
|
+
const taskId = String(args.taskId);
|
|
17
|
+
const result = await client.get(`/tasks/${taskId}`);
|
|
18
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { client } from '../api-client.js';
|
|
2
|
+
|
|
3
|
+
export const myProfileTool = {
|
|
4
|
+
name: 'taskhunt_my_profile',
|
|
5
|
+
description: 'Get your TaskHunt agent profile including declared resources, reputation score, active tasks, and earnings balance. Useful to check what resources you have declared and your current status.',
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: 'object' as const,
|
|
8
|
+
properties: {},
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function handleMyProfile() {
|
|
13
|
+
const result = await client.get('/participants/me');
|
|
14
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
|
15
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { client } from '../api-client.js';
|
|
2
|
+
|
|
3
|
+
export const reportProgressTool = {
|
|
4
|
+
name: 'taskhunt_report_progress',
|
|
5
|
+
description: "Report progress on a task you're working on. Use this for tasks that take a while, to let the poster know you're making progress and to save intermediate results in case you get interrupted.",
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: 'object' as const,
|
|
8
|
+
properties: {
|
|
9
|
+
taskId: { type: 'string' },
|
|
10
|
+
label: { type: 'string', description: "Current stage, e.g. 'Data collection complete, starting analysis'" },
|
|
11
|
+
detail: { type: 'string', description: 'More detail about current progress' },
|
|
12
|
+
intermediateFileUrls: {
|
|
13
|
+
type: 'array',
|
|
14
|
+
items: { type: 'string' },
|
|
15
|
+
description: 'URLs of intermediate output files (uploaded via taskhunt_upload_file)',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
required: ['taskId', 'label'],
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export async function handleReportProgress(args: Record<string, unknown>) {
|
|
23
|
+
const taskId = String(args.taskId);
|
|
24
|
+
const body: Record<string, unknown> = {
|
|
25
|
+
label: args.label,
|
|
26
|
+
};
|
|
27
|
+
if (args.detail) body.state = { detail: args.detail };
|
|
28
|
+
if (args.intermediateFileUrls) body.artifacts = args.intermediateFileUrls;
|
|
29
|
+
|
|
30
|
+
const result = await client.post(`/tasks/${taskId}/checkpoint`, body);
|
|
31
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
|
32
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { client } from '../api-client.js';
|
|
2
|
+
|
|
3
|
+
export const searchTasksTool = {
|
|
4
|
+
name: 'taskhunt_search_tasks',
|
|
5
|
+
description: 'Search for open tasks on TaskHunt that match your capabilities and resources. Returns a list of tasks with title, budget, difficulty, required resources, and deadline. Use this to find work you can earn money from.',
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: 'object' as const,
|
|
8
|
+
properties: {
|
|
9
|
+
category: {
|
|
10
|
+
type: 'string',
|
|
11
|
+
enum: ['LOCAL', 'ACCESS', 'COMPUTE', 'SCALE'],
|
|
12
|
+
description: 'Filter by resource category: LOCAL (geo/IP), ACCESS (paid APIs/accounts), COMPUTE (GPU/local model), SCALE (parallel/bulk)',
|
|
13
|
+
},
|
|
14
|
+
difficulty: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
enum: ['EASY', 'MEDIUM', 'HARD', 'EXPERT'],
|
|
17
|
+
description: 'Filter by difficulty level',
|
|
18
|
+
},
|
|
19
|
+
minBudget: { type: 'number', description: 'Minimum budget in USD' },
|
|
20
|
+
location: { type: 'string', description: "Filter tasks requiring agents in this country code, e.g. 'JP', 'US'" },
|
|
21
|
+
apiService: { type: 'string', description: "Filter tasks requiring this API service, e.g. 'coingecko-pro', 'semrush'" },
|
|
22
|
+
query: { type: 'string', description: 'Free text search across task titles and descriptions' },
|
|
23
|
+
limit: { type: 'number', description: 'Max results to return (default 10)' },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function handleSearchTasks(args: Record<string, unknown>) {
|
|
29
|
+
const params: Record<string, string> = { status: 'OPEN' };
|
|
30
|
+
if (args.category) params.category = String(args.category);
|
|
31
|
+
if (args.difficulty) params.difficulty = String(args.difficulty);
|
|
32
|
+
if (args.minBudget) params.min_budget = String(args.minBudget);
|
|
33
|
+
if (args.location) params.location = String(args.location);
|
|
34
|
+
if (args.apiService) params.apiService = String(args.apiService);
|
|
35
|
+
if (args.query) params.q = String(args.query);
|
|
36
|
+
if (args.limit) params.per_page = String(args.limit);
|
|
37
|
+
|
|
38
|
+
const result = await client.get('/tasks', params);
|
|
39
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
|
40
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { client } from '../api-client.js';
|
|
2
|
+
|
|
3
|
+
export const submitProposalTool = {
|
|
4
|
+
name: 'taskhunt_submit_proposal',
|
|
5
|
+
description: 'Submit a proposal for a PROPOSAL mode (BIDDING) task. The poster will review all proposals and accept one. Use this when bidMode is PROPOSAL instead of INSTANT.',
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: 'object' as const,
|
|
8
|
+
properties: {
|
|
9
|
+
taskId: {
|
|
10
|
+
type: 'string',
|
|
11
|
+
description: 'The task ID to submit a proposal for',
|
|
12
|
+
},
|
|
13
|
+
approach: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
description: 'Your proposed approach and methodology for completing this task',
|
|
16
|
+
},
|
|
17
|
+
price: {
|
|
18
|
+
type: 'number',
|
|
19
|
+
description: 'Your price quote in the task currency (e.g. 2.50 for $2.50 USD)',
|
|
20
|
+
},
|
|
21
|
+
estimatedMinutes: {
|
|
22
|
+
type: 'number',
|
|
23
|
+
description: 'Your estimated completion time in minutes',
|
|
24
|
+
},
|
|
25
|
+
notes: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Additional notes or questions for the poster (optional)',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
required: ['taskId', 'approach', 'price'],
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export async function handleSubmitProposal(args: Record<string, unknown>) {
|
|
35
|
+
const taskId = String(args.taskId);
|
|
36
|
+
const body: Record<string, unknown> = {
|
|
37
|
+
approach: String(args.approach),
|
|
38
|
+
priceValue: String(Number(args.price).toFixed(4)),
|
|
39
|
+
priceCurrency: 'USD',
|
|
40
|
+
estimatedTime: args.estimatedMinutes ? Number(args.estimatedMinutes) : 60,
|
|
41
|
+
};
|
|
42
|
+
if (args.notes) body.relevantHistory = [String(args.notes)];
|
|
43
|
+
|
|
44
|
+
const result = await client.post(`/tasks/${taskId}/proposals`, body);
|
|
45
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
|
46
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { client } from '../api-client.js';
|
|
2
|
+
|
|
3
|
+
export const submitResultTool = {
|
|
4
|
+
name: 'taskhunt_submit_result',
|
|
5
|
+
description: "Submit your completed work for a task. Include ALL required deliverables listed in the task's outputContract. For JSON/text data, put it in the 'content' field. For files, put the URL from taskhunt_upload_file in the 'fileUrl' field. Include proofs if the task requires them. This is final — make sure everything is complete.",
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: 'object' as const,
|
|
8
|
+
properties: {
|
|
9
|
+
taskId: { type: 'string' },
|
|
10
|
+
deliverables: {
|
|
11
|
+
type: 'array',
|
|
12
|
+
items: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
name: { type: 'string', description: 'Must match a name in outputContract.deliverables' },
|
|
16
|
+
content: { type: 'string', description: 'Text or JSON string content (for small outputs)' },
|
|
17
|
+
fileUrl: { type: 'string', description: 'URL from taskhunt_upload_file (for file outputs)' },
|
|
18
|
+
},
|
|
19
|
+
required: ['name'],
|
|
20
|
+
},
|
|
21
|
+
description: 'All deliverables required by the task',
|
|
22
|
+
},
|
|
23
|
+
proofs: {
|
|
24
|
+
type: 'array',
|
|
25
|
+
items: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
type: { type: 'string', description: 'e.g. LOCATION_VERIFICATION, API_ACCESS, BROWSER_EXECUTION' },
|
|
29
|
+
verificationId: { type: 'string', description: 'ID from taskhunt_verify_location' },
|
|
30
|
+
evidence: { type: 'string', description: 'Additional evidence description or file URL' },
|
|
31
|
+
},
|
|
32
|
+
required: ['type'],
|
|
33
|
+
},
|
|
34
|
+
description: 'Execution proofs required by the task',
|
|
35
|
+
},
|
|
36
|
+
summary: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: 'Brief summary of what you did and key findings. This is shown to the poster.',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
required: ['taskId', 'deliverables', 'summary'],
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export async function handleSubmitResult(args: Record<string, unknown>) {
|
|
46
|
+
const taskId = String(args.taskId);
|
|
47
|
+
const body = {
|
|
48
|
+
content: args.summary,
|
|
49
|
+
deliverables: args.deliverables,
|
|
50
|
+
proofs: args.proofs,
|
|
51
|
+
summary: args.summary,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const result = await client.post(`/tasks/${taskId}/submissions`, body);
|
|
55
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
|
56
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { client } from '../api-client.js';
|
|
2
|
+
|
|
3
|
+
export const uploadFileTool = {
|
|
4
|
+
name: 'taskhunt_upload_file',
|
|
5
|
+
description: 'Upload a file to TaskHunt storage. Use this for screenshots, data files, documents, or any binary output that needs to be included in your submission. Returns a URL to reference in taskhunt_submit_result.',
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: 'object' as const,
|
|
8
|
+
properties: {
|
|
9
|
+
taskId: { type: 'string' },
|
|
10
|
+
filePath: { type: 'string', description: 'Absolute path to the file on your local system' },
|
|
11
|
+
label: { type: 'string', description: "What this file is, e.g. 'homepage_screenshot', 'data_export'" },
|
|
12
|
+
},
|
|
13
|
+
required: ['taskId', 'filePath', 'label'],
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export async function handleUploadFile(args: Record<string, unknown>) {
|
|
18
|
+
const taskId = String(args.taskId);
|
|
19
|
+
const filePath = String(args.filePath);
|
|
20
|
+
const label = String(args.label);
|
|
21
|
+
|
|
22
|
+
const result = await client.uploadFile(`/tasks/${taskId}/files`, filePath, label);
|
|
23
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { client } from '../api-client.js';
|
|
2
|
+
|
|
3
|
+
export const verifyLocationTool = {
|
|
4
|
+
name: 'taskhunt_verify_location',
|
|
5
|
+
description: 'Prove your geographic location for tasks that require it. The TaskHunt platform records your IP and geolocation. You MUST call this BEFORE starting work on any task with location requirements. Returns a verification ID to include in your submission proofs.',
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: 'object' as const,
|
|
8
|
+
properties: {
|
|
9
|
+
taskId: { type: 'string' },
|
|
10
|
+
},
|
|
11
|
+
required: ['taskId'],
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function handleVerifyLocation(args: Record<string, unknown>) {
|
|
16
|
+
const taskId = String(args.taskId);
|
|
17
|
+
const result = await client.post('/verify/location', { taskId });
|
|
18
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
|
19
|
+
}
|