@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/dist/index.js ADDED
@@ -0,0 +1,1040 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import {
7
+ CallToolRequestSchema,
8
+ ListToolsRequestSchema
9
+ } from "@modelcontextprotocol/sdk/types.js";
10
+
11
+ // src/config.ts
12
+ import "dotenv/config";
13
+ var config = {
14
+ agentKey: process.env.SOLO_AGENT_KEY ?? "",
15
+ apiUrl: process.env.SOLO_MISSION_API_URL ?? "https://api.mission.projectsolo.xyz"
16
+ };
17
+ if (!config.agentKey) {
18
+ console.warn("Warning: SOLO_AGENT_KEY is not set. Only register_agent will work until a key is configured.");
19
+ }
20
+
21
+ // src/api/client.ts
22
+ var DEFAULT_HEADERS = {
23
+ "Content-Type": "application/json",
24
+ "X-Agent-Key": config.agentKey
25
+ };
26
+ var ApiResponseError = class extends Error {
27
+ status;
28
+ data;
29
+ constructor(status, data) {
30
+ const msg = data?.message || data?.error || `Request failed with status ${status}`;
31
+ super(status === 429 ? "Rate limit exceeded. Please slow down and retry after a moment." : msg);
32
+ this.status = status;
33
+ this.data = data;
34
+ }
35
+ };
36
+ async function parseErrorResponse(response) {
37
+ const data = await response.json().catch(() => ({}));
38
+ throw new ApiResponseError(response.status, data);
39
+ }
40
+ async function apiGet(path, params) {
41
+ const url = new URL(`${config.apiUrl}${path}`);
42
+ if (params) {
43
+ for (const [key, value] of Object.entries(params)) {
44
+ if (value != null) url.searchParams.set(key, String(value));
45
+ }
46
+ }
47
+ const response = await fetch(url.toString(), {
48
+ headers: DEFAULT_HEADERS,
49
+ signal: AbortSignal.timeout(3e4)
50
+ });
51
+ if (!response.ok) return parseErrorResponse(response);
52
+ return response.json();
53
+ }
54
+ async function apiPost(path, body) {
55
+ const response = await fetch(`${config.apiUrl}${path}`, {
56
+ method: "POST",
57
+ headers: DEFAULT_HEADERS,
58
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
59
+ signal: AbortSignal.timeout(3e4)
60
+ });
61
+ if (!response.ok) return parseErrorResponse(response);
62
+ return response.json();
63
+ }
64
+ async function apiDelete(path) {
65
+ const response = await fetch(`${config.apiUrl}${path}`, {
66
+ method: "DELETE",
67
+ headers: DEFAULT_HEADERS,
68
+ signal: AbortSignal.timeout(3e4)
69
+ });
70
+ if (!response.ok) return parseErrorResponse(response);
71
+ return response.json();
72
+ }
73
+ async function publicApiPost(path, body) {
74
+ const response = await fetch(`${config.apiUrl}${path}`, {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/json" },
77
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
78
+ signal: AbortSignal.timeout(3e4)
79
+ });
80
+ if (!response.ok) return parseErrorResponse(response);
81
+ return response.json();
82
+ }
83
+
84
+ // src/tools/missions.ts
85
+ var missionTools = [
86
+ {
87
+ name: "create_mission",
88
+ description: "Create a new mission. Off-chain missions (no budget field) are always free \u2014 no payment, no escrow. For paid missions with EscrowVault escrow, include the budget field \u2014 the response will contain funding_params for you to call createTask() on the contract.",
89
+ inputSchema: {
90
+ type: "object",
91
+ properties: {
92
+ type: {
93
+ type: "string",
94
+ enum: ["coffee_chat", "opinion", "survey", "general", "media_review"],
95
+ description: 'Type of mission. Use "media_review" for audio/music rating missions.'
96
+ },
97
+ title: { type: "string", description: "Mission title (max 100 chars)" },
98
+ description: { type: "string", description: "Detailed mission description (max 2000 chars). Supports Markdown \u2014 use ## headings, - bullet lists, and **bold** to structure your content. The platform renders it as formatted text." },
99
+ requirements: {
100
+ type: "object",
101
+ description: "Optional participant requirements",
102
+ properties: {
103
+ skills: { type: "array", items: { type: "string" } },
104
+ location: { type: "string" },
105
+ languages: { type: "array", items: { type: "string" } },
106
+ min_rating: { type: "number", minimum: 0, maximum: 5 }
107
+ }
108
+ },
109
+ reward_usdt: { type: "number", minimum: 0, description: "Deprecated \u2014 ignored for off-chain missions (which are always free). Has no effect. Omit this field." },
110
+ max_participants: { type: "number", minimum: 1, description: "Maximum number of participants" },
111
+ expires_in_hours: { type: "number", minimum: 1, description: "Hours until mission expires" },
112
+ 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." },
113
+ max_humans: { type: "number", minimum: 1, description: "On-chain: maximum number of participants (both base-reward and lottery entrants)." },
114
+ 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." },
115
+ reward_per_human: { type: "number", minimum: 0, description: "Deprecated \u2014 use base_reward instead." },
116
+ 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 \u2014 auditable by anyone." },
117
+ 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." },
118
+ 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." },
119
+ 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 \u2014 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." },
120
+ auto_accept_applicants: { type: "boolean", description: "When true, applicants are automatically hired when they apply \u2014 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." }
121
+ },
122
+ required: ["type", "title", "description"]
123
+ }
124
+ },
125
+ {
126
+ name: "list_missions",
127
+ description: "List missions created by this agent. Filter by status to manage your mission portfolio.",
128
+ inputSchema: {
129
+ type: "object",
130
+ properties: {
131
+ status: {
132
+ type: "string",
133
+ enum: ["pending_funding", "active", "qualifying", "completed", "refundable", "refunded", "cancelled", "expired"],
134
+ description: "Filter by status"
135
+ },
136
+ limit: { type: "number", description: "Max results (default 20)" },
137
+ page: { type: "number", description: "Page number (default 1)" }
138
+ }
139
+ }
140
+ },
141
+ {
142
+ name: "get_mission",
143
+ 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" \u2014 use it directly with watch_conversation or send_message. Each participant also includes an agent_rating field ({ rating: 1\u20135, 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.',
144
+ inputSchema: {
145
+ type: "object",
146
+ properties: {
147
+ mission_id: { type: "string", description: "Mission ID" }
148
+ },
149
+ required: ["mission_id"]
150
+ }
151
+ },
152
+ {
153
+ name: "confirm_funding",
154
+ 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 \u2192 active. tx_hash is optional \u2014 backend reconciles from the contract if omitted. For media_review missions: returns 409 if no tracks are confirmed yet \u2014 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) \u2014 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.`,
155
+ inputSchema: {
156
+ type: "object",
157
+ properties: {
158
+ mission_id: { type: "string" },
159
+ tx_hash: { type: "string", description: "Transaction hash of createTask() call (optional)" }
160
+ },
161
+ required: ["mission_id"]
162
+ }
163
+ },
164
+ {
165
+ name: "hire_participant",
166
+ 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.",
167
+ inputSchema: {
168
+ type: "object",
169
+ properties: {
170
+ mission_id: { type: "string" },
171
+ uid: { type: "string", description: "Firebase UID of the applicant to hire" }
172
+ },
173
+ required: ["mission_id", "uid"]
174
+ }
175
+ },
176
+ {
177
+ name: "reject_participant",
178
+ description: "Reject a human applicant or hired participant. Valid for applied or hired status, before finalize_qualification is called.",
179
+ inputSchema: {
180
+ type: "object",
181
+ properties: {
182
+ mission_id: { type: "string" },
183
+ uid: { type: "string", description: "Firebase UID of the participant to reject" }
184
+ },
185
+ required: ["mission_id", "uid"]
186
+ }
187
+ },
188
+ {
189
+ name: "finalize_qualification",
190
+ 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 \u2014 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.",
191
+ inputSchema: {
192
+ type: "object",
193
+ properties: {
194
+ mission_id: { type: "string" },
195
+ qualified_human_uids: {
196
+ type: "array",
197
+ items: { type: "string" },
198
+ description: "Firebase UIDs of humans whose work was accepted. Ignored for media_review missions (auto-derived from rating completion)."
199
+ }
200
+ },
201
+ required: ["mission_id"]
202
+ }
203
+ },
204
+ {
205
+ name: "settle_mission",
206
+ 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 \u2014 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 \u2014 no payment is involved.",
207
+ inputSchema: {
208
+ type: "object",
209
+ properties: {
210
+ mission_id: { type: "string" }
211
+ },
212
+ required: ["mission_id"]
213
+ }
214
+ },
215
+ {
216
+ name: "cancel_mission",
217
+ description: "Cancel an off-chain mission directly. Valid when status is active or qualifying. For on-chain missions, use get_cancel_params instead.",
218
+ inputSchema: {
219
+ type: "object",
220
+ properties: {
221
+ mission_id: { type: "string", description: "Mission ID" }
222
+ },
223
+ required: ["mission_id"]
224
+ }
225
+ },
226
+ {
227
+ name: "get_cancel_params",
228
+ 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.",
229
+ inputSchema: {
230
+ type: "object",
231
+ properties: {
232
+ mission_id: { type: "string" }
233
+ },
234
+ required: ["mission_id"]
235
+ }
236
+ },
237
+ {
238
+ name: "confirm_cancel",
239
+ description: "After executing cancelTask() on EscrowVault, confirm the cancellation on the SOLO platform. Mission transitions to cancelled.",
240
+ inputSchema: {
241
+ type: "object",
242
+ properties: {
243
+ mission_id: { type: "string" },
244
+ tx_hash: { type: "string", description: "Transaction hash of cancelTask() (optional)" }
245
+ },
246
+ required: ["mission_id"]
247
+ }
248
+ },
249
+ {
250
+ name: "get_emergency_refund_params",
251
+ 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.",
252
+ inputSchema: {
253
+ type: "object",
254
+ properties: {
255
+ mission_id: { type: "string" }
256
+ },
257
+ required: ["mission_id"]
258
+ }
259
+ },
260
+ {
261
+ name: "confirm_emergency_refund",
262
+ description: "After executing emergencyRefund() on EscrowVault, confirm on the SOLO platform. Mission transitions to cancelled.",
263
+ inputSchema: {
264
+ type: "object",
265
+ properties: {
266
+ mission_id: { type: "string" },
267
+ tx_hash: { type: "string", description: "Transaction hash of emergencyRefund() (optional)" }
268
+ },
269
+ required: ["mission_id"]
270
+ }
271
+ },
272
+ {
273
+ name: "get_refund_params",
274
+ 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).",
275
+ inputSchema: {
276
+ type: "object",
277
+ properties: {
278
+ mission_id: { type: "string" }
279
+ },
280
+ required: ["mission_id"]
281
+ }
282
+ },
283
+ {
284
+ name: "confirm_refund",
285
+ description: "After executing claimRefund() on EscrowVault, confirm the refund on the SOLO platform. Mission transitions to refunded.",
286
+ inputSchema: {
287
+ type: "object",
288
+ properties: {
289
+ mission_id: { type: "string" },
290
+ tx_hash: { type: "string", description: "Transaction hash of claimRefund() (optional)" }
291
+ },
292
+ required: ["mission_id"]
293
+ }
294
+ },
295
+ {
296
+ name: "rate_participant",
297
+ 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.",
298
+ inputSchema: {
299
+ type: "object",
300
+ properties: {
301
+ mission_id: { type: "string", description: "Mission ID" },
302
+ uid: { type: "string", description: "Participant UID" },
303
+ rating: { type: "number", minimum: 1, maximum: 5, description: "Rating 1-5" },
304
+ comment: { type: "string", description: "Optional comment (max 500 chars). Shown publicly on the participant's profile." }
305
+ },
306
+ required: ["mission_id", "uid", "rating"]
307
+ }
308
+ }
309
+ ];
310
+ async function handleMissionTool(name, args) {
311
+ switch (name) {
312
+ case "create_mission":
313
+ return apiPost("/agent/missions", args);
314
+ case "list_missions": {
315
+ const params = {};
316
+ if (args.status) params.status = args.status;
317
+ if (args.limit) params.limit = args.limit;
318
+ if (args.page) params.page = args.page;
319
+ return apiGet("/agent/missions", params);
320
+ }
321
+ case "get_mission":
322
+ return apiGet(`/agent/missions/${args.mission_id}`);
323
+ case "confirm_funding":
324
+ return apiPost(`/agent/missions/${args.mission_id}/confirm-funding`, { tx_hash: args.tx_hash });
325
+ case "hire_participant":
326
+ return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/hire`);
327
+ case "reject_participant":
328
+ return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/reject`);
329
+ case "finalize_qualification":
330
+ return apiPost(`/agent/missions/${args.mission_id}/finalize-qualification`, {
331
+ qualified_human_uids: args.qualified_human_uids
332
+ });
333
+ case "settle_mission":
334
+ return apiPost(`/agent/missions/${args.mission_id}/settle`);
335
+ case "cancel_mission":
336
+ return apiPost(`/agent/missions/${args.mission_id}/cancel`);
337
+ case "get_cancel_params":
338
+ return apiGet(`/agent/missions/${args.mission_id}/cancel-params`);
339
+ case "confirm_cancel":
340
+ return apiPost(`/agent/missions/${args.mission_id}/confirm-cancel`, { tx_hash: args.tx_hash });
341
+ case "get_emergency_refund_params":
342
+ return apiGet(`/agent/missions/${args.mission_id}/emergency-refund-params`);
343
+ case "confirm_emergency_refund":
344
+ return apiPost(`/agent/missions/${args.mission_id}/confirm-emergency-refund`, { tx_hash: args.tx_hash });
345
+ case "get_refund_params":
346
+ return apiGet(`/agent/missions/${args.mission_id}/refund-params`);
347
+ case "confirm_refund":
348
+ return apiPost(`/agent/missions/${args.mission_id}/confirm-refund`, { tx_hash: args.tx_hash });
349
+ case "rate_participant":
350
+ return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/comment`, {
351
+ rating: args.rating,
352
+ comment: args.comment
353
+ });
354
+ default:
355
+ throw new Error(`Unknown mission tool: ${name}`);
356
+ }
357
+ }
358
+
359
+ // src/tools/humans.ts
360
+ var humanTools = [
361
+ {
362
+ name: "browse_humans",
363
+ description: "Browse face-verified humans on the Solo platform. Filter by skills, location, languages, and more.",
364
+ inputSchema: {
365
+ type: "object",
366
+ properties: {
367
+ skills: { type: "string", description: 'Comma-separated skill keywords (e.g. "Software Development,Data Analysis")' },
368
+ location: { type: "string", description: "Filter by city or country" },
369
+ languages: { type: "string", description: 'Comma-separated languages (e.g. "English,Spanish")' },
370
+ min_rating: { type: "number", minimum: 0, maximum: 5, description: "Minimum average rating (0\u20135)" },
371
+ max_hourly_rate: { type: "number", description: "Maximum hourly rate in USD" },
372
+ min_tier: { type: "number", minimum: 1, description: "Minimum contribution tier (1=Scout, 2=Curator, 3=Oracle)" },
373
+ sort_by: { type: "string", enum: ["tier", "avg_rating", "completed_count"], description: "Sort results by this field (descending)" },
374
+ limit: { type: "number", description: "Max results (default 20)" },
375
+ page: { type: "number", description: "Page number (default 1)" }
376
+ }
377
+ }
378
+ },
379
+ {
380
+ name: "get_human_profile",
381
+ description: "Get the full mission profile of a specific human by their user_id.",
382
+ inputSchema: {
383
+ type: "object",
384
+ properties: {
385
+ user_id: { type: "string", description: "The human's user_id (from browse_humans results)" }
386
+ },
387
+ required: ["user_id"]
388
+ }
389
+ }
390
+ ];
391
+ async function handleHumanTool(name, args) {
392
+ switch (name) {
393
+ case "browse_humans": {
394
+ const params = {};
395
+ if (args.skills) params.skills = args.skills;
396
+ if (args.location) params.location = args.location;
397
+ if (args.languages) params.languages = args.languages;
398
+ if (args.min_rating !== void 0) params.min_rating = args.min_rating;
399
+ if (args.max_hourly_rate !== void 0) params.max_hourly_rate = args.max_hourly_rate;
400
+ if (args.min_tier !== void 0) params.min_tier = args.min_tier;
401
+ if (args.sort_by) params.sort_by = args.sort_by;
402
+ if (args.limit) params.limit = args.limit;
403
+ if (args.page) params.page = args.page;
404
+ return apiGet("/agent/humans", params);
405
+ }
406
+ case "get_human_profile":
407
+ return apiGet(`/agent/humans/${args.user_id}`);
408
+ default:
409
+ throw new Error(`Unknown human tool: ${name}`);
410
+ }
411
+ }
412
+
413
+ // src/realtime/poller.ts
414
+ var watches = /* @__PURE__ */ new Map();
415
+ var FIBONACCI_MS = [
416
+ 1e3,
417
+ 1e3,
418
+ 2e3,
419
+ 3e3,
420
+ 5e3,
421
+ 8e3,
422
+ 13e3,
423
+ 21e3,
424
+ 34e3,
425
+ 55e3,
426
+ 89e3,
427
+ 144e3,
428
+ 233e3,
429
+ 377e3,
430
+ 6e5
431
+ ];
432
+ function getFibMs(step) {
433
+ return FIBONACCI_MS[Math.min(step, FIBONACCI_MS.length - 1)];
434
+ }
435
+ function scheduleNext(conversationId, state) {
436
+ state.timeoutId = setTimeout(() => pollOnce(conversationId, state), getFibMs(state.fibStep));
437
+ }
438
+ async function pollOnce(conversationId, state) {
439
+ if (!watches.has(conversationId)) return;
440
+ try {
441
+ const data = await apiGet(
442
+ `/agent/conversations/${conversationId}/messages`,
443
+ { since: state.lastSeen }
444
+ );
445
+ const msgs = data.messages ?? [];
446
+ if (msgs.length > 0) {
447
+ state.queue.push(...msgs);
448
+ const latest = msgs[msgs.length - 1];
449
+ if (latest?.created_at) {
450
+ const raw = latest.created_at;
451
+ if (typeof raw === "string") {
452
+ state.lastSeen = raw;
453
+ } else if (raw?._seconds !== void 0) {
454
+ state.lastSeen = new Date(raw._seconds * 1e3 + Math.floor((raw._nanoseconds ?? 0) / 1e6)).toISOString();
455
+ } else if (raw?.seconds !== void 0) {
456
+ state.lastSeen = new Date(raw.seconds * 1e3).toISOString();
457
+ }
458
+ }
459
+ if (msgs.some((m) => m.sender_type === "human")) {
460
+ state.fibStep = 0;
461
+ }
462
+ } else {
463
+ state.fibStep = Math.min(state.fibStep + 1, FIBONACCI_MS.length - 1);
464
+ }
465
+ } catch {
466
+ }
467
+ if (watches.has(conversationId)) {
468
+ scheduleNext(conversationId, state);
469
+ }
470
+ }
471
+ function startPolling(conversationId) {
472
+ if (watches.has(conversationId)) return;
473
+ const state = {
474
+ timeoutId: null,
475
+ fibStep: 0,
476
+ lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
477
+ queue: []
478
+ };
479
+ watches.set(conversationId, state);
480
+ scheduleNext(conversationId, state);
481
+ }
482
+ function drainQueue(conversationId) {
483
+ const state = watches.get(conversationId);
484
+ if (!state) return [];
485
+ const msgs = [...state.queue];
486
+ state.queue = [];
487
+ return msgs;
488
+ }
489
+ function stopPolling(conversationId) {
490
+ const state = watches.get(conversationId);
491
+ if (!state) return false;
492
+ if (state.timeoutId !== null) clearTimeout(state.timeoutId);
493
+ watches.delete(conversationId);
494
+ return true;
495
+ }
496
+ function listWatched() {
497
+ return [...watches.keys()];
498
+ }
499
+ function getPollIntervalMs(conversationId) {
500
+ const state = watches.get(conversationId);
501
+ if (!state) return null;
502
+ return getFibMs(state.fibStep);
503
+ }
504
+
505
+ // src/tools/conversations.ts
506
+ var conversationTools = [
507
+ {
508
+ name: "start_conversation",
509
+ description: "Start a new conversation with a human. Idempotent \u2014 if a conversation already exists with this human (and mission), returns the existing one. If the existing conversation was archived, it will be reopened.",
510
+ inputSchema: {
511
+ type: "object",
512
+ properties: {
513
+ human_uid: { type: "string", description: "The human's Firebase uid \u2014 use the 'uid' field from browse_humans results, NOT 'user_id'. user_id is a mutable display name; uid is the stable internal identifier." },
514
+ initial_message: { type: "string", description: "First message to send" },
515
+ mission_id: { type: "string", description: "Optional: link conversation to a mission" }
516
+ },
517
+ required: ["human_uid", "initial_message"]
518
+ }
519
+ },
520
+ {
521
+ name: "list_conversations",
522
+ description: "List conversations. Use status filter to focus on active conversations. Pagination supported for agents with many conversations.",
523
+ inputSchema: {
524
+ type: "object",
525
+ properties: {
526
+ status: { type: "string", enum: ["active", "archived", "closed"], description: "Filter by status (default: all)" },
527
+ limit: { type: "number", description: "Max results (default 20, max 100)" },
528
+ page: { type: "number", description: "Page number (default 1)" }
529
+ }
530
+ }
531
+ },
532
+ {
533
+ name: "get_conversation_upload_url",
534
+ description: "Get a signed upload URL to attach an image to a message. For agent uploads prefer upload_conversation_image instead. Otherwise: upload the file with PUT to the returned upload_url, then pass the returned storage_path in send_message attachment_paths.",
535
+ inputSchema: {
536
+ type: "object",
537
+ properties: {
538
+ conversation_id: { type: "string", description: "Conversation ID" },
539
+ content_type: { type: "string", description: "MIME type of the file, e.g. image/jpeg, image/png, image/webp (default image/jpeg)" }
540
+ },
541
+ required: ["conversation_id"]
542
+ }
543
+ },
544
+ {
545
+ name: "upload_conversation_image",
546
+ description: "Upload an image into a conversation and get its storage_path for use in send_message. Pass the image as base64-encoded data (e.g. from a user attachment or generated image). Returns storage_path to include in send_message attachment_paths.",
547
+ inputSchema: {
548
+ type: "object",
549
+ properties: {
550
+ conversation_id: { type: "string", description: "Conversation ID" },
551
+ image_base64: { type: "string", description: "Base64-encoded image data (no data URL prefix)" },
552
+ content_type: { type: "string", description: "MIME type: image/jpeg, image/png, or image/webp (default image/jpeg)" }
553
+ },
554
+ required: ["conversation_id", "image_base64"]
555
+ }
556
+ },
557
+ {
558
+ name: "send_message",
559
+ description: "Send a message in an existing conversation. Optionally include attachment_paths (from get_conversation_upload_url + upload) to attach images. At least one of content or attachment_paths is required.",
560
+ inputSchema: {
561
+ type: "object",
562
+ properties: {
563
+ conversation_id: { type: "string", description: "Conversation ID" },
564
+ content: { type: "string", description: "Message text (can be empty if attachment_paths provided)" },
565
+ attachment_paths: { type: "array", items: { type: "string" }, description: "Storage paths from get_conversation_upload_url flow; max 4 per message" }
566
+ },
567
+ required: ["conversation_id"]
568
+ }
569
+ },
570
+ {
571
+ name: "get_messages",
572
+ description: "Retrieve messages from a conversation. Use the `since` parameter to poll for new messages.",
573
+ inputSchema: {
574
+ type: "object",
575
+ properties: {
576
+ conversation_id: { type: "string", description: "Conversation ID" },
577
+ since: { type: "string", description: "ISO 8601 timestamp \u2014 only return messages after this time" },
578
+ limit: { type: "number", description: "Max messages to return (default 50)" }
579
+ },
580
+ required: ["conversation_id"]
581
+ }
582
+ },
583
+ {
584
+ name: "close_conversation",
585
+ description: 'Manage conversation lifecycle. Use "archive" to shelve inactive conversations (reopenable). Use "close" to permanently end a conversation. Use "reopen" to resume an archived conversation. Best practice: archive conversations when waiting for a long response, close when the objective is met.',
586
+ inputSchema: {
587
+ type: "object",
588
+ properties: {
589
+ conversation_id: { type: "string", description: "Conversation ID" },
590
+ action: { type: "string", enum: ["archive", "close", "reopen"], description: "Action to perform" }
591
+ },
592
+ required: ["conversation_id", "action"]
593
+ }
594
+ }
595
+ ];
596
+ async function handleConversationTool(name, args) {
597
+ switch (name) {
598
+ case "start_conversation":
599
+ return apiPost("/agent/conversations", {
600
+ human_uid: args.human_uid,
601
+ initial_message: args.initial_message,
602
+ ...args.mission_id ? { mission_id: args.mission_id } : {}
603
+ });
604
+ case "list_conversations": {
605
+ const params = {};
606
+ if (args.status) params.status = args.status;
607
+ if (args.limit) params.limit = args.limit;
608
+ if (args.page) params.page = args.page;
609
+ return apiGet("/agent/conversations", params);
610
+ }
611
+ case "get_conversation_upload_url": {
612
+ const ct = args.content_type || "image/jpeg";
613
+ const path = `/agent/conversations/${args.conversation_id}/upload-url?content_type=${encodeURIComponent(ct)}`;
614
+ return apiPost(path);
615
+ }
616
+ case "upload_conversation_image": {
617
+ const ct = args.content_type || "image/jpeg";
618
+ const path = `/agent/conversations/${args.conversation_id}/upload-url?content_type=${encodeURIComponent(ct)}`;
619
+ const { upload_url, storage_path } = await apiPost(path);
620
+ const body = Buffer.from(args.image_base64, "base64");
621
+ const res = await fetch(upload_url, {
622
+ method: "PUT",
623
+ body,
624
+ headers: { "Content-Type": ct }
625
+ });
626
+ if (!res.ok) throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
627
+ return { storage_path };
628
+ }
629
+ case "send_message": {
630
+ const body = { content: args.content ?? "" };
631
+ if (args.attachment_paths?.length) body.attachment_paths = args.attachment_paths;
632
+ return apiPost(`/agent/conversations/${args.conversation_id}/messages`, body);
633
+ }
634
+ case "get_messages": {
635
+ const params = {};
636
+ if (args.since) params.since = args.since;
637
+ if (args.limit) params.limit = args.limit;
638
+ return apiGet(`/agent/conversations/${args.conversation_id}/messages`, params);
639
+ }
640
+ case "close_conversation": {
641
+ const result = await apiPost(`/agent/conversations/${args.conversation_id}/${args.action}`);
642
+ if (args.action === "close") {
643
+ stopPolling(args.conversation_id);
644
+ }
645
+ return result;
646
+ }
647
+ default:
648
+ throw new Error(`Unknown conversation tool: ${name}`);
649
+ }
650
+ }
651
+
652
+ // src/realtime/missionPoller.ts
653
+ var missionWatches = /* @__PURE__ */ new Map();
654
+ async function startMissionPolling(missionId, intervalMinutes = 10) {
655
+ if (missionWatches.has(missionId)) return;
656
+ let knownParticipantUids = /* @__PURE__ */ new Set();
657
+ try {
658
+ const data = await apiGet(`/agent/missions/${missionId}`);
659
+ for (const p of data.participants ?? []) {
660
+ knownParticipantUids.add(p.uid);
661
+ }
662
+ } catch {
663
+ }
664
+ const intervalMs = intervalMinutes * 60 * 1e3;
665
+ const state = {
666
+ knownParticipantUids,
667
+ queue: [],
668
+ intervalId: setInterval(async () => {
669
+ try {
670
+ const data = await apiGet(`/agent/missions/${missionId}`);
671
+ for (const p of data.participants ?? []) {
672
+ if (!state.knownParticipantUids.has(p.uid)) {
673
+ state.knownParticipantUids.add(p.uid);
674
+ state.queue.push({
675
+ mission_id: missionId,
676
+ type: "new_participant",
677
+ participant: {
678
+ uid: p.uid,
679
+ user_id: p.user_id,
680
+ joined_at: p.joined_at,
681
+ ...p.conversation_id ? { conversation_id: p.conversation_id } : {}
682
+ }
683
+ });
684
+ }
685
+ }
686
+ } catch {
687
+ }
688
+ }, intervalMs)
689
+ };
690
+ missionWatches.set(missionId, state);
691
+ }
692
+ function drainMissionQueue(missionId) {
693
+ const state = missionWatches.get(missionId);
694
+ if (!state) return [];
695
+ const updates = [...state.queue];
696
+ state.queue = [];
697
+ return updates;
698
+ }
699
+ function stopMissionPolling(missionId) {
700
+ const state = missionWatches.get(missionId);
701
+ if (!state) return false;
702
+ clearInterval(state.intervalId);
703
+ missionWatches.delete(missionId);
704
+ return true;
705
+ }
706
+ function listWatchedMissions() {
707
+ return [...missionWatches.keys()];
708
+ }
709
+
710
+ // src/tools/realtime.ts
711
+ var realtimeTools = [
712
+ {
713
+ name: "watch_conversation",
714
+ 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.",
715
+ inputSchema: {
716
+ type: "object",
717
+ properties: {
718
+ conversation_id: { type: "string", description: "Conversation ID to watch" }
719
+ },
720
+ required: ["conversation_id"]
721
+ }
722
+ },
723
+ {
724
+ name: "get_pending_messages",
725
+ description: "Return and clear all buffered new messages for a watched conversation.",
726
+ inputSchema: {
727
+ type: "object",
728
+ properties: {
729
+ conversation_id: { type: "string", description: "Conversation ID" }
730
+ },
731
+ required: ["conversation_id"]
732
+ }
733
+ },
734
+ {
735
+ name: "unwatch_conversation",
736
+ description: "Stop watching a conversation and discard its message buffer.",
737
+ inputSchema: {
738
+ type: "object",
739
+ properties: {
740
+ conversation_id: { type: "string", description: "Conversation ID to stop watching" }
741
+ },
742
+ required: ["conversation_id"]
743
+ }
744
+ },
745
+ {
746
+ name: "watch_mission",
747
+ description: 'Start polling a mission for new participants. When a human joins and taps "Say Hi", their participant entry gains a conversation_id \u2014 drain updates with get_pending_mission_updates, then call watch_conversation on each conversation_id to start chatting.',
748
+ inputSchema: {
749
+ type: "object",
750
+ properties: {
751
+ mission_id: { type: "string", description: "Mission ID to watch" },
752
+ interval_minutes: {
753
+ type: "number",
754
+ description: "How often to check for new participants in minutes (default: 10)",
755
+ minimum: 1
756
+ }
757
+ },
758
+ required: ["mission_id"]
759
+ }
760
+ },
761
+ {
762
+ name: "get_pending_mission_updates",
763
+ 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.",
764
+ inputSchema: {
765
+ type: "object",
766
+ properties: {
767
+ mission_id: { type: "string", description: "Mission ID" }
768
+ },
769
+ required: ["mission_id"]
770
+ }
771
+ },
772
+ {
773
+ name: "unwatch_mission",
774
+ description: "Stop polling a mission for new participants.",
775
+ inputSchema: {
776
+ type: "object",
777
+ properties: {
778
+ mission_id: { type: "string", description: "Mission ID to stop watching" }
779
+ },
780
+ required: ["mission_id"]
781
+ }
782
+ }
783
+ ];
784
+ async function handleRealtimeTool(name, args) {
785
+ switch (name) {
786
+ case "watch_conversation": {
787
+ const id = args.conversation_id;
788
+ const alreadyWatching = listWatched().includes(id);
789
+ startPolling(id);
790
+ const intervalMs = getPollIntervalMs(id) ?? 5e3;
791
+ return {
792
+ success: true,
793
+ conversation_id: id,
794
+ watching: true,
795
+ already_was_watching: alreadyWatching,
796
+ poll_interval_seconds: intervalMs / 1e3,
797
+ message: alreadyWatching ? `Already watching conversation ${id}` : `Started watching conversation ${id} \u2014 polling every ${intervalMs / 1e3}s (Fibonacci schedule)`
798
+ };
799
+ }
800
+ case "get_pending_messages": {
801
+ const id = args.conversation_id;
802
+ const msgs = drainQueue(id);
803
+ return {
804
+ conversation_id: id,
805
+ pending_count: msgs.length,
806
+ messages: msgs
807
+ };
808
+ }
809
+ case "unwatch_conversation": {
810
+ const id = args.conversation_id;
811
+ const stopped = stopPolling(id);
812
+ return {
813
+ success: true,
814
+ conversation_id: id,
815
+ was_watching: stopped,
816
+ message: stopped ? `Stopped watching conversation ${id}` : `Conversation ${id} was not being watched`
817
+ };
818
+ }
819
+ case "watch_mission": {
820
+ const missionId = args.mission_id;
821
+ const intervalMinutes = args.interval_minutes ?? 10;
822
+ const alreadyWatching = listWatchedMissions().includes(missionId);
823
+ await startMissionPolling(missionId, intervalMinutes);
824
+ return {
825
+ success: true,
826
+ mission_id: missionId,
827
+ already_was_watching: alreadyWatching,
828
+ interval_minutes: intervalMinutes,
829
+ message: alreadyWatching ? `Already watching mission ${missionId}` : `Started watching mission ${missionId} \u2014 checking for new participants every ${intervalMinutes} min`
830
+ };
831
+ }
832
+ case "get_pending_mission_updates": {
833
+ const missionId = args.mission_id;
834
+ const updates = drainMissionQueue(missionId);
835
+ return {
836
+ mission_id: missionId,
837
+ pending_count: updates.length,
838
+ updates
839
+ };
840
+ }
841
+ case "unwatch_mission": {
842
+ const missionId = args.mission_id;
843
+ const stopped = stopMissionPolling(missionId);
844
+ return {
845
+ success: true,
846
+ mission_id: missionId,
847
+ was_watching: stopped,
848
+ message: stopped ? `Stopped watching mission ${missionId}` : `Mission ${missionId} was not being watched`
849
+ };
850
+ }
851
+ default:
852
+ throw new Error(`Unknown realtime tool: ${name}`);
853
+ }
854
+ }
855
+
856
+ // src/tools/agent.ts
857
+ var agentTools = [
858
+ {
859
+ name: "register_agent",
860
+ description: "Self-register a new agent on the Solo platform. No existing agent key required. Returns agent_id and api_key \u2014 the key is shown only once, save it immediately. Use this to bootstrap a fresh agent identity.",
861
+ inputSchema: {
862
+ type: "object",
863
+ properties: {
864
+ name: {
865
+ type: "string",
866
+ description: "Agent name (3\u201350 characters)",
867
+ minLength: 3,
868
+ maxLength: 50
869
+ }
870
+ },
871
+ required: ["name"]
872
+ }
873
+ }
874
+ ];
875
+ async function handleAgentTool(name, args) {
876
+ switch (name) {
877
+ case "register_agent":
878
+ return publicApiPost("/agent/register", { name: args.name });
879
+ default:
880
+ throw new Error(`Unknown agent tool: ${name}`);
881
+ }
882
+ }
883
+
884
+ // src/tools/tracks.ts
885
+ var trackTools = [
886
+ {
887
+ name: "add_mission_track",
888
+ description: "Upload a media item (audio, image, or video) to a media_review mission. Provide the file as a base64-encoded string. For on-chain missions, call this BEFORE confirm_funding \u2014 uploads are blocked once the mission is active. For off-chain missions, call while the mission is active and before any participant is hired. The item becomes visible to hired participants once confirmed.",
889
+ inputSchema: {
890
+ type: "object",
891
+ properties: {
892
+ mission_id: { type: "string", description: "ID of the media_review mission" },
893
+ title: { type: "string", description: "Item title (required)" },
894
+ artist: { type: "string", description: "Artist / creator name (optional; typically used for audio)" },
895
+ file_base64: { type: "string", description: "Base64-encoded file contents" },
896
+ content_type: {
897
+ type: "string",
898
+ enum: ["audio/mpeg", "audio/mp4", "image/jpeg", "image/png", "image/webp", "video/mp4"],
899
+ description: "MIME type of the file. Mobile-compatible formats only (iOS + Android). audio/mpeg (MP3) and audio/mp4 (AAC/M4A): max 25 MB. image/jpeg, image/png, image/webp: max 10 MB. video/mp4: max 200 MB \u2014 must be faststart-encoded (moov atom first) for partial play."
900
+ },
901
+ duration_seconds: { type: "number", description: "Duration in seconds (optional; applicable to audio and video only)" }
902
+ },
903
+ required: ["mission_id", "title", "file_base64", "content_type"]
904
+ }
905
+ },
906
+ {
907
+ name: "list_mission_tracks",
908
+ description: "List all media items on a media_review mission. Returns raw stats for each item: 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). Use these to compute your own scoring.",
909
+ inputSchema: {
910
+ type: "object",
911
+ properties: {
912
+ mission_id: { type: "string", description: "ID of the mission" }
913
+ },
914
+ required: ["mission_id"]
915
+ }
916
+ },
917
+ {
918
+ name: "get_track_ratings",
919
+ description: "Get per-participant star ratings for one track on a media_review mission. Only participants who submitted a rating are returned \u2014 play-only rows are excluded. Each entry includes uid, rating (1\u20135), optional comment, total_listen_seconds, and rated_at.",
920
+ inputSchema: {
921
+ type: "object",
922
+ properties: {
923
+ mission_id: { type: "string", description: "ID of the mission" },
924
+ track_id: { type: "string", description: "ID of the track" }
925
+ },
926
+ required: ["mission_id", "track_id"]
927
+ }
928
+ },
929
+ {
930
+ name: "delete_mission_track",
931
+ description: "Delete a track from a mission. Only allowed if the track has received no ratings yet.",
932
+ inputSchema: {
933
+ type: "object",
934
+ properties: {
935
+ mission_id: { type: "string", description: "ID of the mission" },
936
+ track_id: { type: "string", description: "ID of the track to delete" }
937
+ },
938
+ required: ["mission_id", "track_id"]
939
+ }
940
+ }
941
+ ];
942
+ async function handleTrackTool(name, args) {
943
+ switch (name) {
944
+ case "add_mission_track": {
945
+ const { mission_id, title, artist, file_base64, content_type, duration_seconds } = args;
946
+ const urlRes = await apiPost(
947
+ `/agent/missions/${mission_id}/tracks/upload-url`,
948
+ { title, artist, content_type }
949
+ );
950
+ const fileBytes = Buffer.from(file_base64, "base64");
951
+ const uploadRes = await fetch(urlRes.upload_url, {
952
+ method: "PUT",
953
+ headers: { "Content-Type": content_type },
954
+ body: fileBytes
955
+ });
956
+ if (!uploadRes.ok) {
957
+ const errText = await uploadRes.text().catch(() => "");
958
+ await apiDelete(`/agent/missions/${mission_id}/tracks/${urlRes.track_id}`).catch(() => {
959
+ });
960
+ throw new Error(`Media upload failed: ${uploadRes.status} ${errText}`);
961
+ }
962
+ const confirmed = await apiPost(
963
+ `/agent/missions/${mission_id}/tracks/${urlRes.track_id}/confirm`,
964
+ { title, artist, duration_seconds }
965
+ );
966
+ return confirmed.track;
967
+ }
968
+ case "list_mission_tracks": {
969
+ const { mission_id } = args;
970
+ return apiGet(`/agent/missions/${mission_id}/tracks`);
971
+ }
972
+ case "get_track_ratings": {
973
+ const { mission_id, track_id } = args;
974
+ return apiGet(`/agent/missions/${mission_id}/tracks/${track_id}/ratings`);
975
+ }
976
+ case "delete_mission_track": {
977
+ const { mission_id, track_id } = args;
978
+ return apiDelete(`/agent/missions/${mission_id}/tracks/${track_id}`);
979
+ }
980
+ default:
981
+ throw new Error(`Unknown track tool: ${name}`);
982
+ }
983
+ }
984
+
985
+ // src/index.ts
986
+ var ALL_TOOLS = [...agentTools, ...missionTools, ...humanTools, ...conversationTools, ...realtimeTools, ...trackTools];
987
+ var AGENT_TOOL_NAMES = new Set(agentTools.map((t) => t.name));
988
+ var MISSION_TOOL_NAMES = new Set(missionTools.map((t) => t.name));
989
+ var HUMAN_TOOL_NAMES = new Set(humanTools.map((t) => t.name));
990
+ var CONVERSATION_TOOL_NAMES = new Set(conversationTools.map((t) => t.name));
991
+ var REALTIME_TOOL_NAMES = new Set(realtimeTools.map((t) => t.name));
992
+ var TRACK_TOOL_NAMES = new Set(trackTools.map((t) => t.name));
993
+ var server = new Server(
994
+ { name: "solo-mission-mcp", version: "0.1.0" },
995
+ { capabilities: { tools: {} } }
996
+ );
997
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
998
+ tools: ALL_TOOLS
999
+ }));
1000
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1001
+ const { name, arguments: args = {} } = request.params;
1002
+ try {
1003
+ let result;
1004
+ if (AGENT_TOOL_NAMES.has(name)) {
1005
+ result = await handleAgentTool(name, args);
1006
+ } else if (MISSION_TOOL_NAMES.has(name)) {
1007
+ result = await handleMissionTool(name, args);
1008
+ } else if (HUMAN_TOOL_NAMES.has(name)) {
1009
+ result = await handleHumanTool(name, args);
1010
+ } else if (CONVERSATION_TOOL_NAMES.has(name)) {
1011
+ result = await handleConversationTool(name, args);
1012
+ } else if (REALTIME_TOOL_NAMES.has(name)) {
1013
+ result = await handleRealtimeTool(name, args);
1014
+ } else if (TRACK_TOOL_NAMES.has(name)) {
1015
+ result = await handleTrackTool(name, args);
1016
+ } else {
1017
+ return {
1018
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
1019
+ isError: true
1020
+ };
1021
+ }
1022
+ return {
1023
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1024
+ };
1025
+ } catch (error) {
1026
+ const message = error?.response?.data ? JSON.stringify(error.response.data) : error?.message ?? String(error);
1027
+ return {
1028
+ content: [{ type: "text", text: `Error: ${message}` }],
1029
+ isError: true
1030
+ };
1031
+ }
1032
+ });
1033
+ async function main() {
1034
+ const transport = new StdioServerTransport();
1035
+ await server.connect(transport);
1036
+ }
1037
+ main().catch((err) => {
1038
+ console.error("Failed to start solo-mission-mcp:", err);
1039
+ process.exit(1);
1040
+ });