@projectsolo/solo-mission-mcp 0.19.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/.env.example +2 -0
- package/.github/workflows/release.yml +52 -0
- package/DEVELOPER_README.md +120 -0
- package/README.md +207 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1040 -0
- package/package.json +32 -0
- package/src/api/client.ts +71 -0
- package/src/config.ts +10 -0
- package/src/index.ts +89 -0
- package/src/realtime/missionPoller.ts +104 -0
- package/src/realtime/poller.ts +128 -0
- package/src/tools/agent.ts +34 -0
- package/src/tools/conversations.ts +158 -0
- package/src/tools/humans.ts +58 -0
- package/src/tools/missions.ts +293 -0
- package/src/tools/realtime.ts +163 -0
- package/src/tools/tracks.ts +130 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { apiGet, apiPost } from '../api/client.js';
|
|
3
|
+
|
|
4
|
+
export const missionTools: Tool[] = [
|
|
5
|
+
{
|
|
6
|
+
name: 'create_mission',
|
|
7
|
+
description: 'Create a new mission. Off-chain missions (no budget field) are always free — no payment, no escrow. For paid missions with EscrowVault escrow, include the budget field — the response will contain funding_params for you to call createTask() on the contract.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
type: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
enum: ['coffee_chat', 'opinion', 'survey', 'general', 'media_review'],
|
|
14
|
+
description: 'Type of mission. Use "media_review" for audio/music rating missions.',
|
|
15
|
+
},
|
|
16
|
+
title: { type: 'string', description: 'Mission title (max 100 chars)' },
|
|
17
|
+
description: { type: 'string', description: 'Detailed mission description (max 2000 chars). Supports Markdown — use ## headings, - bullet lists, and **bold** to structure your content. The platform renders it as formatted text.' },
|
|
18
|
+
requirements: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
description: 'Optional participant requirements',
|
|
21
|
+
properties: {
|
|
22
|
+
skills: { type: 'array', items: { type: 'string' } },
|
|
23
|
+
location: { type: 'string' },
|
|
24
|
+
languages: { type: 'array', items: { type: 'string' } },
|
|
25
|
+
min_rating: { type: 'number', minimum: 0, maximum: 5 },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
reward_usdt: { type: 'number', minimum: 0, description: 'Deprecated — ignored for off-chain missions (which are always free). Has no effect. Omit this field.' },
|
|
29
|
+
max_participants: { type: 'number', minimum: 1, description: 'Maximum number of participants' },
|
|
30
|
+
expires_in_hours: { type: 'number', minimum: 1, description: 'Hours until mission expires' },
|
|
31
|
+
budget: { type: 'number', description: 'Total mission budget in USDC. Enables on-chain escrow. Must satisfy: base_reward * max_humans + lottery_prize_per_winner * lottery_winner_count <= budget.' },
|
|
32
|
+
max_humans: { type: 'number', minimum: 1, description: 'On-chain: maximum number of participants (both base-reward and lottery entrants).' },
|
|
33
|
+
base_reward: { type: 'number', minimum: 0, description: 'Per-participant base reward in USDC paid to every qualified human. Defaults to 0. Set to 0 for pure-lottery missions.' },
|
|
34
|
+
reward_per_human: { type: 'number', minimum: 0, description: 'Deprecated — use base_reward instead.' },
|
|
35
|
+
lottery_winner_count: { type: 'integer', minimum: 1, description: 'Number of winners randomly selected from all qualified participants. Must be <= max_humans. Must be paired with lottery_prize_per_winner. Winners are chosen deterministically from the on-chain seed reveal — auditable by anyone.' },
|
|
36
|
+
lottery_prize_per_winner: { type: 'number', minimum: 0, description: 'Additional prize in USDC paid to each lottery winner on top of base_reward. Must be paired with lottery_winner_count.' },
|
|
37
|
+
hiring_duration_hours: { type: 'number', minimum: 1, description: 'How long (hours) the mission accepts applications and the agent hires/rejects. The hiring window closes at now + hiring_duration_hours. Finalize-qualification cannot be called before this.' },
|
|
38
|
+
work_duration_hours: { type: 'number', minimum: 2, description: 'How long (hours) hired participants have to complete the work. Agent must call settle_mission before this period ends. Minimum is 2, not 1 — the contract requires at least 1 hour of settlement window remaining when finalize_qualification is called, and finalize can only happen after the hiring window already closes, so a value of exactly 1 leaves no reachable window at all.' },
|
|
39
|
+
auto_accept_applicants: { type: 'boolean', description: 'When true, applicants are automatically hired when they apply — no manual hire_participant call needed. First-come first-served up to max_humans. Face verification is still required. Ideal for open media_review missions.' },
|
|
40
|
+
},
|
|
41
|
+
required: ['type', 'title', 'description'],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'list_missions',
|
|
46
|
+
description: 'List missions created by this agent. Filter by status to manage your mission portfolio.',
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
status: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
enum: ['pending_funding', 'active', 'qualifying', 'completed', 'refundable', 'refunded', 'cancelled', 'expired'],
|
|
53
|
+
description: 'Filter by status',
|
|
54
|
+
},
|
|
55
|
+
limit: { type: 'number', description: 'Max results (default 20)' },
|
|
56
|
+
page: { type: 'number', description: 'Page number (default 1)' },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'get_mission',
|
|
62
|
+
description: 'Get details of a specific mission by ID, including all participants. Each participant includes a conversation_id once the human has tapped "Say Hi" — use it directly with watch_conversation or send_message. Each participant also includes an agent_rating field ({ rating: 1–5, comment?: string, updated_at }) if that human has rated the agent; null if not rated or if the 7-day rating window after mission completion has closed.',
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: {
|
|
66
|
+
mission_id: { type: 'string', description: 'Mission ID' },
|
|
67
|
+
},
|
|
68
|
+
required: ['mission_id'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'confirm_funding',
|
|
73
|
+
description: 'After calling createTask() on the EscrowVault contract, confirm the funding via the SOLO API. The backend verifies the transaction on-chain. Mission transitions from pending_funding → active. tx_hash is optional — backend reconciles from the contract if omitted. For media_review missions: returns 409 if no tracks are confirmed yet — call add_mission_track first. Also returns 409 if the on-chain task\'s budget, lottery, deadline, or seed_commit values do not match what create_mission originally quoted (e.g. createTask() was called with hand-typed or re-derived values instead of the exact funding_params fields) — unlike the "not yet FUNDED" 409, this one is NOT retryable: the task_id can never be confirmed. Call cancelTask() on-chain to reclaim the full escrow, then call create_mission again.',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
mission_id: { type: 'string' },
|
|
78
|
+
tx_hash: { type: 'string', description: 'Transaction hash of createTask() call (optional)' },
|
|
79
|
+
},
|
|
80
|
+
required: ['mission_id'],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'hire_participant',
|
|
85
|
+
description: 'Accept a human applicant for a mission. Only applied humans can be hired. Hired humans can start work via conversations. Only valid while mission is active.',
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
mission_id: { type: 'string' },
|
|
90
|
+
uid: { type: 'string', description: 'Firebase UID of the applicant to hire' },
|
|
91
|
+
},
|
|
92
|
+
required: ['mission_id', 'uid'],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'reject_participant',
|
|
97
|
+
description: 'Reject a human applicant or hired participant. Valid for applied or hired status, before finalize_qualification is called.',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
mission_id: { type: 'string' },
|
|
102
|
+
uid: { type: 'string', description: 'Firebase UID of the participant to reject' },
|
|
103
|
+
},
|
|
104
|
+
required: ['mission_id', 'uid'],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'finalize_qualification',
|
|
109
|
+
description: 'Lock in the qualified participants after reviewing their work. For standard missions provide an explicit list of UIDs whose work was accepted. For media_review missions pass an empty body — qualified_human_uids is ignored and the backend auto-qualifies anyone who rated every track. For on-chain missions, the backend calls finalizeQualification() on EscrowVault. Mission transitions to qualifying.',
|
|
110
|
+
inputSchema: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
mission_id: { type: 'string' },
|
|
114
|
+
qualified_human_uids: {
|
|
115
|
+
type: 'array',
|
|
116
|
+
items: { type: 'string' },
|
|
117
|
+
description: 'Firebase UIDs of humans whose work was accepted. Ignored for media_review missions (auto-derived from rating completion).',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
required: ['mission_id'],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'settle_mission',
|
|
125
|
+
description: 'Settle the mission after finalize_qualification. For on-chain missions, calls settleTask() on EscrowVault only (aggregate payout numbers, no per-wallet computation); mission transitions to completed or refundable. Rewards are NOT immediately claimable — a separate batched process publishes the Merkle root that makes them claimable, which can take anywhere from minutes to over an hour depending on the review window. For free (off-chain) missions, marks qualified participants as completed — no payment is involved.',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
type: 'object',
|
|
128
|
+
properties: {
|
|
129
|
+
mission_id: { type: 'string' },
|
|
130
|
+
},
|
|
131
|
+
required: ['mission_id'],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'cancel_mission',
|
|
136
|
+
description: 'Cancel an off-chain mission directly. Valid when status is active or qualifying. For on-chain missions, use get_cancel_params instead.',
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: {
|
|
140
|
+
mission_id: { type: 'string', description: 'Mission ID' },
|
|
141
|
+
},
|
|
142
|
+
required: ['mission_id'],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'get_cancel_params',
|
|
147
|
+
description: 'Get on-chain transaction parameters to cancel a funded mission via cancelTask() on EscrowVault. Only valid before qualify_deadline. After qualify_deadline, use get_emergency_refund_params instead.',
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
mission_id: { type: 'string' },
|
|
152
|
+
},
|
|
153
|
+
required: ['mission_id'],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'confirm_cancel',
|
|
158
|
+
description: 'After executing cancelTask() on EscrowVault, confirm the cancellation on the SOLO platform. Mission transitions to cancelled.',
|
|
159
|
+
inputSchema: {
|
|
160
|
+
type: 'object',
|
|
161
|
+
properties: {
|
|
162
|
+
mission_id: { type: 'string' },
|
|
163
|
+
tx_hash: { type: 'string', description: 'Transaction hash of cancelTask() (optional)' },
|
|
164
|
+
},
|
|
165
|
+
required: ['mission_id'],
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'get_emergency_refund_params',
|
|
170
|
+
description: 'Get on-chain transaction parameters to force-refund a mission after the settlement_deadline has passed without settlement. Returns eligible: true with params if eligible, or eligible: false with retry_after if not yet past the deadline.',
|
|
171
|
+
inputSchema: {
|
|
172
|
+
type: 'object',
|
|
173
|
+
properties: {
|
|
174
|
+
mission_id: { type: 'string' },
|
|
175
|
+
},
|
|
176
|
+
required: ['mission_id'],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'confirm_emergency_refund',
|
|
181
|
+
description: 'After executing emergencyRefund() on EscrowVault, confirm on the SOLO platform. Mission transitions to cancelled.',
|
|
182
|
+
inputSchema: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: {
|
|
185
|
+
mission_id: { type: 'string' },
|
|
186
|
+
tx_hash: { type: 'string', description: 'Transaction hash of emergencyRefund() (optional)' },
|
|
187
|
+
},
|
|
188
|
+
required: ['mission_id'],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: 'get_refund_params',
|
|
193
|
+
description: 'Get on-chain transaction parameters to claim unused budget via claimRefund() on EscrowVault. Only valid when mission is in refundable state (settled with leftover budget).',
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: 'object',
|
|
196
|
+
properties: {
|
|
197
|
+
mission_id: { type: 'string' },
|
|
198
|
+
},
|
|
199
|
+
required: ['mission_id'],
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: 'confirm_refund',
|
|
204
|
+
description: 'After executing claimRefund() on EscrowVault, confirm the refund on the SOLO platform. Mission transitions to refunded.',
|
|
205
|
+
inputSchema: {
|
|
206
|
+
type: 'object',
|
|
207
|
+
properties: {
|
|
208
|
+
mission_id: { type: 'string' },
|
|
209
|
+
tx_hash: { type: 'string', description: 'Transaction hash of claimRefund() (optional)' },
|
|
210
|
+
},
|
|
211
|
+
required: ['mission_id'],
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: 'rate_participant',
|
|
216
|
+
description: 'Leave a rating and optional comment for a mission participant. Requires the mission to be settled (completed, refundable, or refunded) and must be submitted within 7 days of mission completion. Limited to one rating per participant per mission; calling again overwrites the previous rating/comment.',
|
|
217
|
+
inputSchema: {
|
|
218
|
+
type: 'object',
|
|
219
|
+
properties: {
|
|
220
|
+
mission_id: { type: 'string', description: 'Mission ID' },
|
|
221
|
+
uid: { type: 'string', description: 'Participant UID' },
|
|
222
|
+
rating: { type: 'number', minimum: 1, maximum: 5, description: 'Rating 1-5' },
|
|
223
|
+
comment: { type: 'string', description: 'Optional comment (max 500 chars). Shown publicly on the participant\'s profile.' },
|
|
224
|
+
},
|
|
225
|
+
required: ['mission_id', 'uid', 'rating'],
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
export async function handleMissionTool(name: string, args: Record<string, any>): Promise<unknown> {
|
|
231
|
+
switch (name) {
|
|
232
|
+
case 'create_mission':
|
|
233
|
+
return apiPost('/agent/missions', args);
|
|
234
|
+
|
|
235
|
+
case 'list_missions': {
|
|
236
|
+
const params: Record<string, any> = {};
|
|
237
|
+
if (args.status) params.status = args.status;
|
|
238
|
+
if (args.limit) params.limit = args.limit;
|
|
239
|
+
if (args.page) params.page = args.page;
|
|
240
|
+
return apiGet('/agent/missions', params);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
case 'get_mission':
|
|
244
|
+
return apiGet(`/agent/missions/${args.mission_id}`);
|
|
245
|
+
|
|
246
|
+
case 'confirm_funding':
|
|
247
|
+
return apiPost(`/agent/missions/${args.mission_id}/confirm-funding`, { tx_hash: args.tx_hash });
|
|
248
|
+
|
|
249
|
+
case 'hire_participant':
|
|
250
|
+
return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/hire`);
|
|
251
|
+
|
|
252
|
+
case 'reject_participant':
|
|
253
|
+
return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/reject`);
|
|
254
|
+
|
|
255
|
+
case 'finalize_qualification':
|
|
256
|
+
return apiPost(`/agent/missions/${args.mission_id}/finalize-qualification`, {
|
|
257
|
+
qualified_human_uids: args.qualified_human_uids,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
case 'settle_mission':
|
|
261
|
+
return apiPost(`/agent/missions/${args.mission_id}/settle`);
|
|
262
|
+
|
|
263
|
+
case 'cancel_mission':
|
|
264
|
+
return apiPost(`/agent/missions/${args.mission_id}/cancel`);
|
|
265
|
+
|
|
266
|
+
case 'get_cancel_params':
|
|
267
|
+
return apiGet(`/agent/missions/${args.mission_id}/cancel-params`);
|
|
268
|
+
|
|
269
|
+
case 'confirm_cancel':
|
|
270
|
+
return apiPost(`/agent/missions/${args.mission_id}/confirm-cancel`, { tx_hash: args.tx_hash });
|
|
271
|
+
|
|
272
|
+
case 'get_emergency_refund_params':
|
|
273
|
+
return apiGet(`/agent/missions/${args.mission_id}/emergency-refund-params`);
|
|
274
|
+
|
|
275
|
+
case 'confirm_emergency_refund':
|
|
276
|
+
return apiPost(`/agent/missions/${args.mission_id}/confirm-emergency-refund`, { tx_hash: args.tx_hash });
|
|
277
|
+
|
|
278
|
+
case 'get_refund_params':
|
|
279
|
+
return apiGet(`/agent/missions/${args.mission_id}/refund-params`);
|
|
280
|
+
|
|
281
|
+
case 'confirm_refund':
|
|
282
|
+
return apiPost(`/agent/missions/${args.mission_id}/confirm-refund`, { tx_hash: args.tx_hash });
|
|
283
|
+
|
|
284
|
+
case 'rate_participant':
|
|
285
|
+
return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/comment`, {
|
|
286
|
+
rating: args.rating,
|
|
287
|
+
comment: args.comment,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
default:
|
|
291
|
+
throw new Error(`Unknown mission tool: ${name}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { startPolling, drainQueue, stopPolling, listWatched, getPollIntervalMs } from '../realtime/poller.js';
|
|
3
|
+
import { startMissionPolling, drainMissionQueue, stopMissionPolling, listWatchedMissions } from '../realtime/missionPoller.js';
|
|
4
|
+
|
|
5
|
+
export const realtimeTools: Tool[] = [
|
|
6
|
+
{
|
|
7
|
+
name: 'watch_conversation',
|
|
8
|
+
description: 'Start watching a conversation for new messages. Uses a Fibonacci delay schedule: starts at 1s, advances on each miss (up to 600s cap), resets to 1s when the human replies. Drain buffered messages with get_pending_messages.',
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
conversation_id: { type: 'string', description: 'Conversation ID to watch' },
|
|
13
|
+
},
|
|
14
|
+
required: ['conversation_id'],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'get_pending_messages',
|
|
19
|
+
description: 'Return and clear all buffered new messages for a watched conversation.',
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
conversation_id: { type: 'string', description: 'Conversation ID' },
|
|
24
|
+
},
|
|
25
|
+
required: ['conversation_id'],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'unwatch_conversation',
|
|
30
|
+
description: 'Stop watching a conversation and discard its message buffer.',
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
conversation_id: { type: 'string', description: 'Conversation ID to stop watching' },
|
|
35
|
+
},
|
|
36
|
+
required: ['conversation_id'],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'watch_mission',
|
|
41
|
+
description: 'Start polling a mission for new participants. When a human joins and taps "Say Hi", their participant entry gains a conversation_id — drain updates with get_pending_mission_updates, then call watch_conversation on each conversation_id to start chatting.',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
mission_id: { type: 'string', description: 'Mission ID to watch' },
|
|
46
|
+
interval_minutes: {
|
|
47
|
+
type: 'number',
|
|
48
|
+
description: 'How often to check for new participants in minutes (default: 10)',
|
|
49
|
+
minimum: 1,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
required: ['mission_id'],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'get_pending_mission_updates',
|
|
57
|
+
description: 'Return and clear buffered mission updates (new participants). Each update includes the participant\'s conversation_id if they have started a conversation. Call watch_conversation(conversation_id) for each new participant to begin chatting.',
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
mission_id: { type: 'string', description: 'Mission ID' },
|
|
62
|
+
},
|
|
63
|
+
required: ['mission_id'],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'unwatch_mission',
|
|
68
|
+
description: 'Stop polling a mission for new participants.',
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
mission_id: { type: 'string', description: 'Mission ID to stop watching' },
|
|
73
|
+
},
|
|
74
|
+
required: ['mission_id'],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
export async function handleRealtimeTool(name: string, args: Record<string, any>): Promise<unknown> {
|
|
80
|
+
switch (name) {
|
|
81
|
+
case 'watch_conversation': {
|
|
82
|
+
const id = args.conversation_id as string;
|
|
83
|
+
const alreadyWatching = listWatched().includes(id);
|
|
84
|
+
startPolling(id);
|
|
85
|
+
const intervalMs = getPollIntervalMs(id) ?? 5000;
|
|
86
|
+
return {
|
|
87
|
+
success: true,
|
|
88
|
+
conversation_id: id,
|
|
89
|
+
watching: true,
|
|
90
|
+
already_was_watching: alreadyWatching,
|
|
91
|
+
poll_interval_seconds: intervalMs / 1000,
|
|
92
|
+
message: alreadyWatching
|
|
93
|
+
? `Already watching conversation ${id}`
|
|
94
|
+
: `Started watching conversation ${id} — polling every ${intervalMs / 1000}s (Fibonacci schedule)`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
case 'get_pending_messages': {
|
|
99
|
+
const id = args.conversation_id as string;
|
|
100
|
+
const msgs = drainQueue(id);
|
|
101
|
+
return {
|
|
102
|
+
conversation_id: id,
|
|
103
|
+
pending_count: msgs.length,
|
|
104
|
+
messages: msgs,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
case 'unwatch_conversation': {
|
|
109
|
+
const id = args.conversation_id as string;
|
|
110
|
+
const stopped = stopPolling(id);
|
|
111
|
+
return {
|
|
112
|
+
success: true,
|
|
113
|
+
conversation_id: id,
|
|
114
|
+
was_watching: stopped,
|
|
115
|
+
message: stopped
|
|
116
|
+
? `Stopped watching conversation ${id}`
|
|
117
|
+
: `Conversation ${id} was not being watched`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case 'watch_mission': {
|
|
122
|
+
const missionId = args.mission_id as string;
|
|
123
|
+
const intervalMinutes = (args.interval_minutes as number) ?? 10;
|
|
124
|
+
const alreadyWatching = listWatchedMissions().includes(missionId);
|
|
125
|
+
await startMissionPolling(missionId, intervalMinutes);
|
|
126
|
+
return {
|
|
127
|
+
success: true,
|
|
128
|
+
mission_id: missionId,
|
|
129
|
+
already_was_watching: alreadyWatching,
|
|
130
|
+
interval_minutes: intervalMinutes,
|
|
131
|
+
message: alreadyWatching
|
|
132
|
+
? `Already watching mission ${missionId}`
|
|
133
|
+
: `Started watching mission ${missionId} — checking for new participants every ${intervalMinutes} min`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case 'get_pending_mission_updates': {
|
|
138
|
+
const missionId = args.mission_id as string;
|
|
139
|
+
const updates = drainMissionQueue(missionId);
|
|
140
|
+
return {
|
|
141
|
+
mission_id: missionId,
|
|
142
|
+
pending_count: updates.length,
|
|
143
|
+
updates,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case 'unwatch_mission': {
|
|
148
|
+
const missionId = args.mission_id as string;
|
|
149
|
+
const stopped = stopMissionPolling(missionId);
|
|
150
|
+
return {
|
|
151
|
+
success: true,
|
|
152
|
+
mission_id: missionId,
|
|
153
|
+
was_watching: stopped,
|
|
154
|
+
message: stopped
|
|
155
|
+
? `Stopped watching mission ${missionId}`
|
|
156
|
+
: `Mission ${missionId} was not being watched`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
default:
|
|
161
|
+
throw new Error(`Unknown realtime tool: ${name}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { apiGet, apiPost, apiDelete } from '../api/client.js';
|
|
3
|
+
|
|
4
|
+
export const trackTools: Tool[] = [
|
|
5
|
+
{
|
|
6
|
+
name: 'add_mission_track',
|
|
7
|
+
description:
|
|
8
|
+
'Upload a media item (audio, image, or video) to a media_review mission. Provide the file as a base64-encoded string. ' +
|
|
9
|
+
'For on-chain missions, call this BEFORE confirm_funding — uploads are blocked once the mission is active. ' +
|
|
10
|
+
'For off-chain missions, call while the mission is active and before any participant is hired. ' +
|
|
11
|
+
'The item becomes visible to hired participants once confirmed.',
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
mission_id: { type: 'string', description: 'ID of the media_review mission' },
|
|
16
|
+
title: { type: 'string', description: 'Item title (required)' },
|
|
17
|
+
artist: { type: 'string', description: 'Artist / creator name (optional; typically used for audio)' },
|
|
18
|
+
file_base64: { type: 'string', description: 'Base64-encoded file contents' },
|
|
19
|
+
content_type: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
enum: ['audio/mpeg', 'audio/mp4', 'image/jpeg', 'image/png', 'image/webp', 'video/mp4'],
|
|
22
|
+
description:
|
|
23
|
+
'MIME type of the file. Mobile-compatible formats only (iOS + Android). ' +
|
|
24
|
+
'audio/mpeg (MP3) and audio/mp4 (AAC/M4A): max 25 MB. ' +
|
|
25
|
+
'image/jpeg, image/png, image/webp: max 10 MB. ' +
|
|
26
|
+
'video/mp4: max 200 MB — must be faststart-encoded (moov atom first) for partial play.',
|
|
27
|
+
},
|
|
28
|
+
duration_seconds: { type: 'number', description: 'Duration in seconds (optional; applicable to audio and video only)' },
|
|
29
|
+
},
|
|
30
|
+
required: ['mission_id', 'title', 'file_base64', 'content_type'],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'list_mission_tracks',
|
|
35
|
+
description:
|
|
36
|
+
'List all media items on a media_review mission. Returns raw stats for each item: ' +
|
|
37
|
+
'media_type (audio/image/video), vote_counts ({1,2,3,4,5,total} star-rating distribution) and total_listen_seconds (cumulative engagement time across all participants). ' +
|
|
38
|
+
'Use these to compute your own scoring.',
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
mission_id: { type: 'string', description: 'ID of the mission' },
|
|
43
|
+
},
|
|
44
|
+
required: ['mission_id'],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'get_track_ratings',
|
|
49
|
+
description:
|
|
50
|
+
'Get per-participant star ratings for one track on a media_review mission. ' +
|
|
51
|
+
'Only participants who submitted a rating are returned — play-only rows are excluded. ' +
|
|
52
|
+
'Each entry includes uid, rating (1–5), optional comment, total_listen_seconds, and rated_at.',
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
mission_id: { type: 'string', description: 'ID of the mission' },
|
|
57
|
+
track_id: { type: 'string', description: 'ID of the track' },
|
|
58
|
+
},
|
|
59
|
+
required: ['mission_id', 'track_id'],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'delete_mission_track',
|
|
64
|
+
description: 'Delete a track from a mission. Only allowed if the track has received no ratings yet.',
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
mission_id: { type: 'string', description: 'ID of the mission' },
|
|
69
|
+
track_id: { type: 'string', description: 'ID of the track to delete' },
|
|
70
|
+
},
|
|
71
|
+
required: ['mission_id', 'track_id'],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
export async function handleTrackTool(name: string, args: Record<string, any>): Promise<unknown> {
|
|
77
|
+
switch (name) {
|
|
78
|
+
case 'add_mission_track': {
|
|
79
|
+
const { mission_id, title, artist, file_base64, content_type, duration_seconds } = args;
|
|
80
|
+
|
|
81
|
+
// Step 1: Get signed upload URL and create pending track doc
|
|
82
|
+
const urlRes = await apiPost<{ upload_url: string; storage_path: string; track_id: string }>(
|
|
83
|
+
`/agent/missions/${mission_id}/tracks/upload-url`,
|
|
84
|
+
{ title, artist, content_type },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Step 2: Upload binary to the signed URL.
|
|
88
|
+
// On failure, delete the pending track doc so it doesn't consume the 20-track
|
|
89
|
+
// slot or appear as a ghost in list results.
|
|
90
|
+
const fileBytes = Buffer.from(file_base64, 'base64');
|
|
91
|
+
const uploadRes = await fetch(urlRes.upload_url, {
|
|
92
|
+
method: 'PUT',
|
|
93
|
+
headers: { 'Content-Type': content_type },
|
|
94
|
+
body: fileBytes,
|
|
95
|
+
});
|
|
96
|
+
if (!uploadRes.ok) {
|
|
97
|
+
const errText = await uploadRes.text().catch(() => '');
|
|
98
|
+
// Best-effort cleanup — ignore errors so the original failure is surfaced
|
|
99
|
+
await apiDelete(`/agent/missions/${mission_id}/tracks/${urlRes.track_id}`).catch(() => {});
|
|
100
|
+
throw new Error(`Media upload failed: ${uploadRes.status} ${errText}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Step 3: Confirm upload and optionally set duration
|
|
104
|
+
const confirmed = await apiPost<{ track: unknown }>(
|
|
105
|
+
`/agent/missions/${mission_id}/tracks/${urlRes.track_id}/confirm`,
|
|
106
|
+
{ title, artist, duration_seconds },
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return confirmed.track;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
case 'list_mission_tracks': {
|
|
113
|
+
const { mission_id } = args;
|
|
114
|
+
return apiGet(`/agent/missions/${mission_id}/tracks`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case 'get_track_ratings': {
|
|
118
|
+
const { mission_id, track_id } = args;
|
|
119
|
+
return apiGet(`/agent/missions/${mission_id}/tracks/${track_id}/ratings`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case 'delete_mission_track': {
|
|
123
|
+
const { mission_id, track_id } = args;
|
|
124
|
+
return apiDelete(`/agent/missions/${mission_id}/tracks/${track_id}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
default:
|
|
128
|
+
throw new Error(`Unknown track tool: ${name}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"rootDir": "src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"resolveJsonModule": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "dist"]
|
|
16
|
+
}
|