@projectsolo/solo-mission-mcp 0.23.2 → 0.23.3
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/.github/workflows/lint.yml +42 -0
- package/.github/workflows/release.yml +20 -8
- package/.github/workflows/test.yml +37 -0
- package/DEVELOPER_README.md +3 -1
- package/README.md +3 -1
- package/dist/index.js +26 -1397
- package/dist/server-U6ZDX3NA.js +1403 -0
- package/package.json +2 -2
- package/src/index.ts +14 -86
- package/src/nodeVersion.test.ts +67 -0
- package/src/nodeVersion.ts +49 -0
- package/src/server.ts +92 -0
|
@@ -0,0 +1,1403 @@
|
|
|
1
|
+
import {
|
|
2
|
+
apiGet,
|
|
3
|
+
apiPatch,
|
|
4
|
+
apiPost,
|
|
5
|
+
publicApiPost
|
|
6
|
+
} from "./chunk-OP4QYTL4.js";
|
|
7
|
+
|
|
8
|
+
// src/server.ts
|
|
9
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import {
|
|
12
|
+
CallToolRequestSchema,
|
|
13
|
+
ListToolsRequestSchema
|
|
14
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
15
|
+
|
|
16
|
+
// src/tools/missions.ts
|
|
17
|
+
var RESPONSE_SCHEMA_PROPERTY = {
|
|
18
|
+
type: "array",
|
|
19
|
+
maxItems: 20,
|
|
20
|
+
description: 'Structured completion questions a human answers to complete the mission. Question ids must be unique within one schema \u2014 a duplicate id is rejected at creation/update, not merged or silently overwritten. When set, finalize_qualification auto-derives qualified UIDs from completion instead of requiring an explicit qualified_human_uids list \u2014 same mechanism media_review has always used for track ratings, generalized to any mission type. media_review defaults to a canonical two-question schema when this is omitted at creation \u2014 literally { id: "rating", kind: "stars", required: true } and { id: "comment", kind: "long", required: true } \u2014 so read/append against those exact ids if you rely on the default rather than supplying your own schema. Every other type has no default (stays fully manual/chat-based unless you set this).',
|
|
21
|
+
items: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
id: { type: "string", description: "Unique id within this schema \u2014 the key answers are stored under. Duplicates are rejected, not merged." },
|
|
25
|
+
kind: {
|
|
26
|
+
type: "string",
|
|
27
|
+
enum: [
|
|
28
|
+
"single",
|
|
29
|
+
"multi",
|
|
30
|
+
"likert",
|
|
31
|
+
"stars",
|
|
32
|
+
"short",
|
|
33
|
+
"long",
|
|
34
|
+
"dropdown",
|
|
35
|
+
"checkbox",
|
|
36
|
+
"timestamp_tag",
|
|
37
|
+
"image_region",
|
|
38
|
+
"video_region_duration"
|
|
39
|
+
],
|
|
40
|
+
description: 'single=one choice, multi=several choices, likert=a scale rendered as a row of buttons (NOT fixed at 5 points \u2014 the number of options you supply IS the number of points, so give exactly as many option labels as the scale should have; a common convention is 5 labels like "1 Strongly disagree".."5 Strongly agree" but nothing enforces that), stars=1-5 rating (fixed, no options), short=one line of text, long=paragraph text (see min_length), dropdown=one choice from a long list, checkbox=a checkbox that must be explicitly true (usable for ANY yes/no question, not just consent-flavored ones) \u2014 the checkbox\'s own clickable text is `label` itself (there is no separate heading rendered above it, unlike every other kind); use `help` for any additional wording you want shown near it.\n\nMedia-anchored kinds \u2014 media_review only, need a track to annotate (options/min_length don\'t apply; see min_count instead):\n timestamp_tag: the human flags a moment in an audio/video track while it plays. Each flag is { t (seconds), tag (category \u2014 one of `options` if you set it, otherwise any non-empty string), note? }. `options` is OPTIONAL here (unlike the choice kinds above) \u2014 omit it for free-form tagging, or set it to offer a closed category list.\n image_region: the human draws a rectangle on an image track and comments on it. Each region is { x, y, width, height (all 0-1, fraction of the image, not pixels), comment }.\n video_region_duration: like image_region, but the rectangle is held for a single time range rather than a single point \u2014 { start, end (seconds, end > start), x, y, width, height, comment }. One fixed box for the whole range, not tracked frame by frame.'
|
|
41
|
+
},
|
|
42
|
+
label: { type: "string", description: "Question text shown to the human (max 200 chars). For the checkbox kind this is the checkbox's own clickable text, not a separate heading." },
|
|
43
|
+
help: { type: "string", description: "Optional helper text shown under the label (max 500 chars)." },
|
|
44
|
+
required: {
|
|
45
|
+
type: "boolean",
|
|
46
|
+
description: "Whether this question must be answered to complete the mission. A required: false question does NOT block mission completion or finalize_qualification's auto-qualification \u2014 both derive purely from the required questions, so an optional question left blank (or still being typed into) will not stop either from firing the instant the required ones are answered. If every question in the schema should actually be answered, mark them all required: true; reserve required: false for a genuinely optional/skippable field."
|
|
47
|
+
},
|
|
48
|
+
options: {
|
|
49
|
+
type: "array",
|
|
50
|
+
items: { type: "string" },
|
|
51
|
+
description: "Required (non-empty) for single/multi/dropdown/likert (max 20 options, 50 chars each). Optional for timestamp_tag (a closed category list; omit for free-form tagging). Not accepted by any other kind."
|
|
52
|
+
},
|
|
53
|
+
min_length: { type: "number", description: "Minimum answer length. Only read for the 'long' kind \u2014 silently ignored (not rejected) if set on any other kind." },
|
|
54
|
+
min_count: { type: "number", description: "Minimum number of annotations required. Only read for timestamp_tag/image_region/video_region_duration (defaults to 1 if required and unset) \u2014 silently ignored on any other kind." }
|
|
55
|
+
},
|
|
56
|
+
required: ["id", "kind", "label", "required"]
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
var missionTools = [
|
|
60
|
+
{
|
|
61
|
+
name: "create_mission",
|
|
62
|
+
description: "Create a new mission. Off-chain missions (no budget field) are always free \u2014 no payment, no escrow. Include the budget field for a paid mission escrowed on Solana: it is created in pending_funding, and you then fund it with fund_solana_mission (pass the returned mission id). Solana is the only chain (the backend also defaults a missing chain to solana, and this tool always sends it). A paid mission's response includes funding_params: chain ('solana'), cluster, program_id, rpc_url, mint, token_address (same as mint), token_decimals, amount_raw, base_pool, lottery_reward_per_winner_raw, lottery_winner_count, qualify_deadline, settlement_deadline, seed_commit and expires_at. There is no escrow address or task id yet: the program assigns those when the mission is funded. expires_at is creation + 24 hours \u2014 a mission still unfunded then is cancelled automatically. You do not need to build anything from funding_params; fund_solana_mission does the whole funding step. The response's mission.solana_quoted_fee_bps is the platform fee rate you were quoted: the escrow transaction carries it as max_fee_bps, the most the program may charge, and fund_solana_mission refuses to sign any other value. A paid lottery (lottery_winner_count > 0) is funded with a transaction the platform Operator has already co-signed; fund_solana_mission verifies that co-signature before adding yours. Paid-mission deadlines follow the escrow program's rules \u2014 see hiring_duration_hours and work_duration_hours.",
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: {
|
|
66
|
+
type: {
|
|
67
|
+
type: "string",
|
|
68
|
+
enum: ["coffee_chat", "opinion", "survey", "general", "media_review"],
|
|
69
|
+
description: 'Type of mission. Use "media_review" for audio/music rating missions.'
|
|
70
|
+
},
|
|
71
|
+
chain: {
|
|
72
|
+
type: "string",
|
|
73
|
+
enum: ["solana"],
|
|
74
|
+
description: "Optional. Solana is the only chain: a paid (budget-bearing) mission is always sent with chain 'solana' whether or not you set this. Ignored for off-chain missions (no budget field)."
|
|
75
|
+
},
|
|
76
|
+
title: { type: "string", description: "Mission title (max 100 chars)" },
|
|
77
|
+
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." },
|
|
78
|
+
requirements: {
|
|
79
|
+
type: "object",
|
|
80
|
+
description: "Optional participant requirements",
|
|
81
|
+
properties: {
|
|
82
|
+
skills: { type: "array", items: { type: "string" } },
|
|
83
|
+
location: { type: "string" },
|
|
84
|
+
languages: { type: "array", items: { type: "string" } },
|
|
85
|
+
min_rating: { type: "number", minimum: 0, maximum: 5 }
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
reward_usdt: { type: "number", minimum: 0, description: "Deprecated \u2014 ignored for off-chain missions (which are always free). Has no effect. Omit this field." },
|
|
89
|
+
max_participants: { type: "number", minimum: 1, description: "Maximum number of participants" },
|
|
90
|
+
expires_in_hours: { type: "number", minimum: 1, description: "Hours until mission expires" },
|
|
91
|
+
budget: { type: "number", description: "Total mission budget in USDC. Enables on-chain escrow on Solana (fund it afterwards with fund_solana_mission). Must also cover the platform fee, which is reserved out of the budget: base_reward * max_humans + lottery_prize_per_winner * lottery_winner_count + floor(budget * fee_bps / 10000) <= budget (equality passes). fee_bps is read from the Solana escrow program's on-chain config, not a fixed rate (currently 0 on devnet). A 400 for this rule states the rate in use; a 503 means the rate could not be read, nothing was created, and you should retry shortly. Both the backend and the Solana escrow program enforce this." },
|
|
92
|
+
max_humans: { type: "number", minimum: 1, description: "On-chain: maximum number of participants (both base-reward and lottery entrants)." },
|
|
93
|
+
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." },
|
|
94
|
+
reward_per_human: { type: "number", minimum: 0, description: "Deprecated \u2014 use base_reward instead." },
|
|
95
|
+
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. For a paid mission the draw happens on chain at settlement: final_entropy = keccak256(seed_reveal \u2016 entropy_hash), where seed_reveal opens the seed_commit fixed at funding and entropy_hash is the SlotHashes sysvar bank hash of the first produced slot after finalize_qualification, stored on the Task by record_entropy (settle captures it if nobody recorded it). It is NOT the blockhash getBlock returns for that slot, so recompute a draw from the Task account's entropy_hash, not from getBlock. The platform Operator co-signs a lottery's funding transaction (the escrow program requires it)." },
|
|
96
|
+
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." },
|
|
97
|
+
hiring_duration_hours: { type: "number", minimum: 0.0166, 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. Closing hiring does NOT end the work: hired humans keep voting and submitting answers after it (see work_duration_hours). Backend floor is 60s (0.0166h) \u2014 see work_duration_hours for what that floor does and does not guarantee. For a paid mission the end of the hiring window is the escrow's qualify_deadline: it may be at most 180 days ahead, and it is the cancel cut-off \u2014 refund_solana_mission's cancel is refused on chain from qualify_deadline on (TooLateToCancel)." },
|
|
98
|
+
work_duration_hours: { type: "number", minimum: 0.0166, description: "How long (hours) hired participants have to complete the work: settlement_deadline = end of the hiring window + work_duration_hours. Agent must call settle_mission before this period ends. Humans can vote and submit answers while the mission is active: on-chain until max(qualify_deadline, settlement_deadline \u2212 1260s), i.e. about 21 minutes before settlement_deadline; off-chain until finalize. Either way, finalize_qualification closes submissions. Backend floor is 60s (0.0166h) \u2014 a sanity check against a near-zero window, not a guarantee the escrow accepts it. For a paid mission the Solana escrow program separately requires settlement_deadline >= qualify_deadline + min_review_window + finalize grace, and settlement_deadline <= qualify_deadline + 90 days. min_review_window is the program's configured review window and the finalize grace equals its floor (10 seconds each on the current devnet build; the floor is 1 hour on a production build). create_mission checks both against the live program config and returns 400 on a violation; the program enforces them again when the escrow is funded, and finalize_qualification must still leave min_review_window before settlement_deadline." },
|
|
99
|
+
auto_accept_applicants: { type: "boolean", description: "Defaults to true \u2014 applicants are automatically hired when they apply, no manual hire_participant call needed, first-come first-served up to max_humans, face verification still required. Pass false to opt into manual review instead (see hire_participant for how unreviewed applicants are still handled once the hiring window closes)." },
|
|
100
|
+
response_schema: RESPONSE_SCHEMA_PROPERTY
|
|
101
|
+
},
|
|
102
|
+
required: ["type", "title", "description"]
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "update_mission_questions",
|
|
107
|
+
description: "Set or replace a mission's response_schema after creation. Locked once hiring starts \u2014 same window as add_mission_track (on-chain: only before funding; off-chain: only while active with zero hired participants yet). This window is what makes a schema replacement safe: a human can only submit answers once hired, and the schema can no longer change once anyone has been hired \u2014 there is no state where a human has answered under one schema and the mission later presents a different one. Replaces the entire schema, not a merge.",
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
mission_id: { type: "string" },
|
|
112
|
+
response_schema: RESPONSE_SCHEMA_PROPERTY
|
|
113
|
+
},
|
|
114
|
+
required: ["mission_id", "response_schema"]
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "list_missions",
|
|
119
|
+
description: "List missions created by this agent. Filter by status to manage your mission portfolio.",
|
|
120
|
+
inputSchema: {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: {
|
|
123
|
+
status: {
|
|
124
|
+
type: "string",
|
|
125
|
+
enum: ["pending_funding", "active", "qualifying", "completed", "refundable", "refunded", "cancelled", "expired"],
|
|
126
|
+
description: "Filter by status"
|
|
127
|
+
},
|
|
128
|
+
limit: { type: "number", description: "Max results (default 20)" },
|
|
129
|
+
page: { type: "number", description: "Page number (default 1)" }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "get_mission",
|
|
135
|
+
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. Once settled, a settlement_outcome field ('completed' / 'completed_refundable' / 'no_payout_refunded') tells you whether anyone was actually paid \u2014 see settle_mission's description; don't infer that from status alone.`,
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: "object",
|
|
138
|
+
properties: {
|
|
139
|
+
mission_id: { type: "string", description: "Mission ID" }
|
|
140
|
+
},
|
|
141
|
+
required: ["mission_id"]
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "hire_participant",
|
|
146
|
+
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. IMPORTANT: silence is not neutral \u2014 an applicant you never call this (or reject_participant) on is automatically hired (up to max_humans, oldest applied_at first). That does NOT happen when the hiring window closes. For an on-chain mission it happens in the window just before settlement_deadline (roughly 21 to 1 minutes before it), in the same pass that auto-finalizes and settles. For an off-chain mission it happens at expires_at. If you do not want someone, call reject_participant before then.",
|
|
147
|
+
inputSchema: {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
mission_id: { type: "string" },
|
|
151
|
+
uid: { type: "string", description: "Firebase UID of the applicant to hire" }
|
|
152
|
+
},
|
|
153
|
+
required: ["mission_id", "uid"]
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "reject_participant",
|
|
158
|
+
description: "Reject a human applicant or hired participant. Valid for applied or hired status, before finalize_qualification is called. This is the ONLY way to exclude someone \u2014 the platform auto-hires remaining applicants and auto-qualifies hired participants by default (silence = go), so anyone you do not explicitly reject ends up hired, qualified and paid. That happens in the window just before settlement_deadline for an on-chain mission (roughly 21 to 1 minutes before it) and at expires_at for an off-chain mission, not when the hiring window closes.",
|
|
159
|
+
inputSchema: {
|
|
160
|
+
type: "object",
|
|
161
|
+
properties: {
|
|
162
|
+
mission_id: { type: "string" },
|
|
163
|
+
uid: { type: "string", description: "Firebase UID of the participant to reject" }
|
|
164
|
+
},
|
|
165
|
+
required: ["mission_id", "uid"]
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: "finalize_qualification",
|
|
170
|
+
description: "Lock in the qualified participants after reviewing their work. If the mission has a response_schema (media_review always does, by default or explicitly; any other type only if you set one via create_mission/update_mission_questions), pass an empty body \u2014 qualified_human_uids is ignored and the backend auto-qualifies anyone who completed it (every ready track for media_review, a full submission for other types). Otherwise provide an explicit list of UIDs whose work was accepted. For on-chain missions, the backend records the qualified set on the Solana escrow program. Mission transitions to qualifying. If you never call this, the platform does it for you (silence = go), qualifying every hired participant you did not explicitly reject_participant. It does NOT happen when the hiring window closes. For an on-chain mission it happens in a short window just before settlement_deadline (roughly 21 to 1 minutes before it), where the platform also auto-hires any remaining applicants, finalizes and settles in one pass. For an off-chain mission it happens at expires_at. Call reject_participant first for anyone whose work should NOT be paid.",
|
|
171
|
+
inputSchema: {
|
|
172
|
+
type: "object",
|
|
173
|
+
properties: {
|
|
174
|
+
mission_id: { type: "string" },
|
|
175
|
+
qualified_human_uids: {
|
|
176
|
+
type: "array",
|
|
177
|
+
items: { type: "string" },
|
|
178
|
+
description: "Firebase UIDs of humans whose work was accepted. Ignored for any mission with a response_schema (auto-derived from completion instead)."
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
required: ["mission_id"]
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "settle_mission",
|
|
186
|
+
description: "Settle the mission after finalize_qualification. For on-chain missions, the backend settles the Solana escrow with aggregate payout numbers only (no per-wallet computation); mission transitions to completed or refundable. A 'refundable' mission is holding leftover budget for you: claim it with refund_solana_mission, action: 'claim_refund' (the mission then moves to refunded). IMPORTANT: status alone cannot tell you whether anyone was paid \u2014 a mission with 8/10 slots filled (2 slots' budget refunded) and one with 0/10 filled (the whole budget refunded) both land on 'refundable'/'refunded'. Check the settlement_outcome field on the response instead: 'completed' (fully spent, no refund), 'completed_refundable' (real participants were paid, only the unfilled slots refund), or 'no_payout_refunded' (nobody qualified, the entire budget bounces). Rewards are NOT immediately claimable \u2014 a separate batched process publishes the Merkle root that makes them claimable, gated by a review window currently defaulting to about 10 seconds (minimized pre-launch; will lengthen to an hour or more once real funds are at stake). Check GET /human/solana/rewards for actual claimable status rather than assuming a fixed delay. For free (off-chain) missions, marks qualified participants as completed \u2014 no payment is involved; settlement_outcome is always 'completed' there. You may settle at any time after finalize_qualification and before settlement_deadline; there is no need to wait. If you never call this, the platform settles on your behalf, but NOT right after finalize: for an on-chain mission it happens in the window just before settlement_deadline (roughly 21 to 1 minutes before it), and for an off-chain mission at expires_at. Who is qualified is fixed at finalize, so reject anyone unwanted before finalize, not after.",
|
|
187
|
+
inputSchema: {
|
|
188
|
+
type: "object",
|
|
189
|
+
properties: {
|
|
190
|
+
mission_id: { type: "string" }
|
|
191
|
+
},
|
|
192
|
+
required: ["mission_id"]
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "cancel_mission",
|
|
197
|
+
description: "Cancel a mission that holds no escrowed funds: an off-chain mission (status active or qualifying), or an unfunded Solana mission still in pending_funding. For a Solana mission the backend first checks the chain: if it finds an escrow that was created but never confirmed, it returns 409 with that task_id and does not cancel \u2014 use refund_solana_mission with action: 'cancel' then. For a funded mission also use refund_solana_mission with action: 'cancel': it returns the escrowed budget to you and moves the mission to cancelled, but only strictly before qualify_deadline (the end of the hiring window) \u2014 the escrow program refuses a cancel from then on (TooLateToCancel), and the remaining exit is emergency_refund once settlement_deadline passes without a settlement. An unfunded Solana mission you leave alone is cancelled automatically at its funding_params.expires_at (creation + 24 hours).",
|
|
198
|
+
inputSchema: {
|
|
199
|
+
type: "object",
|
|
200
|
+
properties: {
|
|
201
|
+
mission_id: { type: "string", description: "Mission ID" }
|
|
202
|
+
},
|
|
203
|
+
required: ["mission_id"]
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: "rate_participant",
|
|
208
|
+
description: "Leave a rating and optional comment for a mission participant. Allowed for every settled outcome (mission status completed, refundable or refunded), within 7 days of settlement. Limited to one rating per participant per mission; calling again overwrites the previous rating/comment.",
|
|
209
|
+
inputSchema: {
|
|
210
|
+
type: "object",
|
|
211
|
+
properties: {
|
|
212
|
+
mission_id: { type: "string", description: "Mission ID" },
|
|
213
|
+
uid: { type: "string", description: "Participant UID" },
|
|
214
|
+
rating: { type: "number", minimum: 1, maximum: 5, description: "Rating 1-5" },
|
|
215
|
+
comment: { type: "string", description: "Optional comment (max 500 chars). Shown publicly on the participant's profile." }
|
|
216
|
+
},
|
|
217
|
+
required: ["mission_id", "uid", "rating"]
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
];
|
|
221
|
+
async function handleMissionTool(name, args) {
|
|
222
|
+
switch (name) {
|
|
223
|
+
case "create_mission": {
|
|
224
|
+
const { chain: _chain, ...body } = args;
|
|
225
|
+
return apiPost("/agent/missions", body.budget !== void 0 ? { ...body, chain: "solana" } : body);
|
|
226
|
+
}
|
|
227
|
+
case "list_missions": {
|
|
228
|
+
const params = {};
|
|
229
|
+
if (args.status) params.status = args.status;
|
|
230
|
+
if (args.limit) params.limit = args.limit;
|
|
231
|
+
if (args.page) params.page = args.page;
|
|
232
|
+
return apiGet("/agent/missions", params);
|
|
233
|
+
}
|
|
234
|
+
case "get_mission":
|
|
235
|
+
return apiGet(`/agent/missions/${args.mission_id}`);
|
|
236
|
+
case "update_mission_questions":
|
|
237
|
+
return apiPatch(`/agent/missions/${args.mission_id}/questions`, { response_schema: args.response_schema });
|
|
238
|
+
case "hire_participant":
|
|
239
|
+
return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/hire`);
|
|
240
|
+
case "reject_participant":
|
|
241
|
+
return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/reject`);
|
|
242
|
+
case "finalize_qualification":
|
|
243
|
+
return apiPost(`/agent/missions/${args.mission_id}/finalize-qualification`, {
|
|
244
|
+
qualified_human_uids: args.qualified_human_uids
|
|
245
|
+
});
|
|
246
|
+
case "settle_mission":
|
|
247
|
+
return apiPost(`/agent/missions/${args.mission_id}/settle`);
|
|
248
|
+
case "cancel_mission":
|
|
249
|
+
return apiPost(`/agent/missions/${args.mission_id}/cancel`);
|
|
250
|
+
case "rate_participant":
|
|
251
|
+
return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/comment`, {
|
|
252
|
+
rating: args.rating,
|
|
253
|
+
comment: args.comment
|
|
254
|
+
});
|
|
255
|
+
default:
|
|
256
|
+
throw new Error(`Unknown mission tool: ${name}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/tools/humans.ts
|
|
261
|
+
var humanTools = [
|
|
262
|
+
{
|
|
263
|
+
name: "browse_humans",
|
|
264
|
+
description: "Browse face-verified humans on the Solo platform. Filter by skills, location, languages, and more.",
|
|
265
|
+
inputSchema: {
|
|
266
|
+
type: "object",
|
|
267
|
+
properties: {
|
|
268
|
+
skills: { type: "string", description: 'Comma-separated skill keywords (e.g. "Software Development,Data Analysis")' },
|
|
269
|
+
location: { type: "string", description: "Filter by city or country" },
|
|
270
|
+
languages: { type: "string", description: 'Comma-separated languages (e.g. "English,Spanish")' },
|
|
271
|
+
min_rating: { type: "number", minimum: 0, maximum: 5, description: "Minimum average rating (0\u20135)" },
|
|
272
|
+
max_hourly_rate: { type: "number", description: "Maximum hourly rate in USD" },
|
|
273
|
+
min_tier: { type: "number", minimum: 1, description: "Minimum contribution tier (1=Scout, 2=Curator, 3=Oracle)" },
|
|
274
|
+
sort_by: { type: "string", enum: ["tier", "avg_rating", "completed_count"], description: "Sort results by this field (descending)" },
|
|
275
|
+
limit: { type: "number", description: "Max results (default 20)" },
|
|
276
|
+
page: { type: "number", description: "Page number (default 1)" }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: "get_human_profile",
|
|
282
|
+
description: "Get the full mission profile of a specific human by their user_id.",
|
|
283
|
+
inputSchema: {
|
|
284
|
+
type: "object",
|
|
285
|
+
properties: {
|
|
286
|
+
user_id: { type: "string", description: "The human's user_id (from browse_humans results)" }
|
|
287
|
+
},
|
|
288
|
+
required: ["user_id"]
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
];
|
|
292
|
+
async function handleHumanTool(name, args) {
|
|
293
|
+
switch (name) {
|
|
294
|
+
case "browse_humans": {
|
|
295
|
+
const params = {};
|
|
296
|
+
if (args.skills) params.skills = args.skills;
|
|
297
|
+
if (args.location) params.location = args.location;
|
|
298
|
+
if (args.languages) params.languages = args.languages;
|
|
299
|
+
if (args.min_rating !== void 0) params.min_rating = args.min_rating;
|
|
300
|
+
if (args.max_hourly_rate !== void 0) params.max_hourly_rate = args.max_hourly_rate;
|
|
301
|
+
if (args.min_tier !== void 0) params.min_tier = args.min_tier;
|
|
302
|
+
if (args.sort_by) params.sort_by = args.sort_by;
|
|
303
|
+
if (args.limit) params.limit = args.limit;
|
|
304
|
+
if (args.page) params.page = args.page;
|
|
305
|
+
return apiGet("/agent/humans", params);
|
|
306
|
+
}
|
|
307
|
+
case "get_human_profile":
|
|
308
|
+
return apiGet(`/agent/humans/${args.user_id}`);
|
|
309
|
+
default:
|
|
310
|
+
throw new Error(`Unknown human tool: ${name}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/realtime/poller.ts
|
|
315
|
+
var watches = /* @__PURE__ */ new Map();
|
|
316
|
+
var FIBONACCI_MS = [
|
|
317
|
+
1e3,
|
|
318
|
+
1e3,
|
|
319
|
+
2e3,
|
|
320
|
+
3e3,
|
|
321
|
+
5e3,
|
|
322
|
+
8e3,
|
|
323
|
+
13e3,
|
|
324
|
+
21e3,
|
|
325
|
+
34e3,
|
|
326
|
+
55e3,
|
|
327
|
+
89e3,
|
|
328
|
+
144e3,
|
|
329
|
+
233e3,
|
|
330
|
+
377e3,
|
|
331
|
+
6e5
|
|
332
|
+
];
|
|
333
|
+
function getFibMs(step) {
|
|
334
|
+
return FIBONACCI_MS[Math.min(step, FIBONACCI_MS.length - 1)];
|
|
335
|
+
}
|
|
336
|
+
function scheduleNext(conversationId, state) {
|
|
337
|
+
state.timeoutId = setTimeout(() => pollOnce(conversationId, state), getFibMs(state.fibStep));
|
|
338
|
+
}
|
|
339
|
+
async function pollOnce(conversationId, state) {
|
|
340
|
+
if (!watches.has(conversationId)) return;
|
|
341
|
+
try {
|
|
342
|
+
const data = await apiGet(
|
|
343
|
+
`/agent/conversations/${conversationId}/messages`,
|
|
344
|
+
{ since: state.lastSeen }
|
|
345
|
+
);
|
|
346
|
+
const msgs = data.messages ?? [];
|
|
347
|
+
if (msgs.length > 0) {
|
|
348
|
+
state.queue.push(...msgs);
|
|
349
|
+
const latest = msgs[msgs.length - 1];
|
|
350
|
+
if (latest?.created_at) {
|
|
351
|
+
const raw = latest.created_at;
|
|
352
|
+
if (typeof raw === "string") {
|
|
353
|
+
state.lastSeen = raw;
|
|
354
|
+
} else if (raw?._seconds !== void 0) {
|
|
355
|
+
state.lastSeen = new Date(raw._seconds * 1e3 + Math.floor((raw._nanoseconds ?? 0) / 1e6)).toISOString();
|
|
356
|
+
} else if (raw?.seconds !== void 0) {
|
|
357
|
+
state.lastSeen = new Date(raw.seconds * 1e3).toISOString();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (msgs.some((m) => m.sender_type === "human")) {
|
|
361
|
+
state.fibStep = 0;
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
state.fibStep = Math.min(state.fibStep + 1, FIBONACCI_MS.length - 1);
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
}
|
|
368
|
+
if (watches.has(conversationId)) {
|
|
369
|
+
scheduleNext(conversationId, state);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function startPolling(conversationId) {
|
|
373
|
+
if (watches.has(conversationId)) return;
|
|
374
|
+
const state = {
|
|
375
|
+
timeoutId: null,
|
|
376
|
+
fibStep: 0,
|
|
377
|
+
lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
378
|
+
queue: []
|
|
379
|
+
};
|
|
380
|
+
watches.set(conversationId, state);
|
|
381
|
+
scheduleNext(conversationId, state);
|
|
382
|
+
}
|
|
383
|
+
function drainQueue(conversationId) {
|
|
384
|
+
const state = watches.get(conversationId);
|
|
385
|
+
if (!state) return [];
|
|
386
|
+
const msgs = [...state.queue];
|
|
387
|
+
state.queue = [];
|
|
388
|
+
return msgs;
|
|
389
|
+
}
|
|
390
|
+
function stopPolling(conversationId) {
|
|
391
|
+
const state = watches.get(conversationId);
|
|
392
|
+
if (!state) return false;
|
|
393
|
+
if (state.timeoutId !== null) clearTimeout(state.timeoutId);
|
|
394
|
+
watches.delete(conversationId);
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
function listWatched() {
|
|
398
|
+
return [...watches.keys()];
|
|
399
|
+
}
|
|
400
|
+
function getPollIntervalMs(conversationId) {
|
|
401
|
+
const state = watches.get(conversationId);
|
|
402
|
+
if (!state) return null;
|
|
403
|
+
return getFibMs(state.fibStep);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/tools/conversations.ts
|
|
407
|
+
var conversationTools = [
|
|
408
|
+
{
|
|
409
|
+
name: "start_conversation",
|
|
410
|
+
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.",
|
|
411
|
+
inputSchema: {
|
|
412
|
+
type: "object",
|
|
413
|
+
properties: {
|
|
414
|
+
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." },
|
|
415
|
+
initial_message: { type: "string", description: "First message to send" },
|
|
416
|
+
mission_id: { type: "string", description: "Optional: link conversation to a mission" }
|
|
417
|
+
},
|
|
418
|
+
required: ["human_uid", "initial_message"]
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
name: "list_conversations",
|
|
423
|
+
description: "List conversations. Use status filter to focus on active conversations. Pagination supported for agents with many conversations.",
|
|
424
|
+
inputSchema: {
|
|
425
|
+
type: "object",
|
|
426
|
+
properties: {
|
|
427
|
+
status: { type: "string", enum: ["active", "archived", "closed"], description: "Filter by status (default: all)" },
|
|
428
|
+
limit: { type: "number", description: "Max results (default 20, max 100)" },
|
|
429
|
+
page: { type: "number", description: "Page number (default 1)" }
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
name: "get_conversation_upload_url",
|
|
435
|
+
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.",
|
|
436
|
+
inputSchema: {
|
|
437
|
+
type: "object",
|
|
438
|
+
properties: {
|
|
439
|
+
conversation_id: { type: "string", description: "Conversation ID" },
|
|
440
|
+
content_type: { type: "string", description: "MIME type of the file, e.g. image/jpeg, image/png, image/webp (default image/jpeg)" }
|
|
441
|
+
},
|
|
442
|
+
required: ["conversation_id"]
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
name: "upload_conversation_image",
|
|
447
|
+
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.",
|
|
448
|
+
inputSchema: {
|
|
449
|
+
type: "object",
|
|
450
|
+
properties: {
|
|
451
|
+
conversation_id: { type: "string", description: "Conversation ID" },
|
|
452
|
+
image_base64: { type: "string", description: "Base64-encoded image data (no data URL prefix)" },
|
|
453
|
+
content_type: { type: "string", description: "MIME type: image/jpeg, image/png, or image/webp (default image/jpeg)" }
|
|
454
|
+
},
|
|
455
|
+
required: ["conversation_id", "image_base64"]
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
name: "send_message",
|
|
460
|
+
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.",
|
|
461
|
+
inputSchema: {
|
|
462
|
+
type: "object",
|
|
463
|
+
properties: {
|
|
464
|
+
conversation_id: { type: "string", description: "Conversation ID" },
|
|
465
|
+
content: { type: "string", description: "Message text (can be empty if attachment_paths provided)" },
|
|
466
|
+
attachment_paths: { type: "array", items: { type: "string" }, description: "Storage paths from get_conversation_upload_url flow; max 4 per message" }
|
|
467
|
+
},
|
|
468
|
+
required: ["conversation_id"]
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
name: "get_messages",
|
|
473
|
+
description: "Retrieve messages from a conversation. Use the `since` parameter to poll for new messages.",
|
|
474
|
+
inputSchema: {
|
|
475
|
+
type: "object",
|
|
476
|
+
properties: {
|
|
477
|
+
conversation_id: { type: "string", description: "Conversation ID" },
|
|
478
|
+
since: { type: "string", description: "ISO 8601 timestamp \u2014 only return messages after this time" },
|
|
479
|
+
limit: { type: "number", description: "Max messages to return (default 50)" }
|
|
480
|
+
},
|
|
481
|
+
required: ["conversation_id"]
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
name: "close_conversation",
|
|
486
|
+
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.',
|
|
487
|
+
inputSchema: {
|
|
488
|
+
type: "object",
|
|
489
|
+
properties: {
|
|
490
|
+
conversation_id: { type: "string", description: "Conversation ID" },
|
|
491
|
+
action: { type: "string", enum: ["archive", "close", "reopen"], description: "Action to perform" }
|
|
492
|
+
},
|
|
493
|
+
required: ["conversation_id", "action"]
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
];
|
|
497
|
+
async function handleConversationTool(name, args) {
|
|
498
|
+
switch (name) {
|
|
499
|
+
case "start_conversation":
|
|
500
|
+
return apiPost("/agent/conversations", {
|
|
501
|
+
human_uid: args.human_uid,
|
|
502
|
+
initial_message: args.initial_message,
|
|
503
|
+
...args.mission_id ? { mission_id: args.mission_id } : {}
|
|
504
|
+
});
|
|
505
|
+
case "list_conversations": {
|
|
506
|
+
const params = {};
|
|
507
|
+
if (args.status) params.status = args.status;
|
|
508
|
+
if (args.limit) params.limit = args.limit;
|
|
509
|
+
if (args.page) params.page = args.page;
|
|
510
|
+
return apiGet("/agent/conversations", params);
|
|
511
|
+
}
|
|
512
|
+
case "get_conversation_upload_url": {
|
|
513
|
+
const ct = args.content_type || "image/jpeg";
|
|
514
|
+
const path = `/agent/conversations/${args.conversation_id}/upload-url?content_type=${encodeURIComponent(ct)}`;
|
|
515
|
+
return apiPost(path);
|
|
516
|
+
}
|
|
517
|
+
case "upload_conversation_image": {
|
|
518
|
+
const ct = args.content_type || "image/jpeg";
|
|
519
|
+
const path = `/agent/conversations/${args.conversation_id}/upload-url?content_type=${encodeURIComponent(ct)}`;
|
|
520
|
+
const { upload_url, storage_path } = await apiPost(path);
|
|
521
|
+
const body = Buffer.from(args.image_base64, "base64");
|
|
522
|
+
const res = await fetch(upload_url, {
|
|
523
|
+
method: "PUT",
|
|
524
|
+
body,
|
|
525
|
+
headers: { "Content-Type": ct }
|
|
526
|
+
});
|
|
527
|
+
if (!res.ok) throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
|
|
528
|
+
return { storage_path };
|
|
529
|
+
}
|
|
530
|
+
case "send_message": {
|
|
531
|
+
const body = { content: args.content ?? "" };
|
|
532
|
+
if (args.attachment_paths?.length) body.attachment_paths = args.attachment_paths;
|
|
533
|
+
return apiPost(`/agent/conversations/${args.conversation_id}/messages`, body);
|
|
534
|
+
}
|
|
535
|
+
case "get_messages": {
|
|
536
|
+
const params = {};
|
|
537
|
+
if (args.since) params.since = args.since;
|
|
538
|
+
if (args.limit) params.limit = args.limit;
|
|
539
|
+
return apiGet(`/agent/conversations/${args.conversation_id}/messages`, params);
|
|
540
|
+
}
|
|
541
|
+
case "close_conversation": {
|
|
542
|
+
const result = await apiPost(`/agent/conversations/${args.conversation_id}/${args.action}`);
|
|
543
|
+
if (args.action === "close") {
|
|
544
|
+
stopPolling(args.conversation_id);
|
|
545
|
+
}
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
548
|
+
default:
|
|
549
|
+
throw new Error(`Unknown conversation tool: ${name}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/realtime/missionPoller.ts
|
|
554
|
+
var missionWatches = /* @__PURE__ */ new Map();
|
|
555
|
+
async function startMissionPolling(missionId, intervalMinutes = 10) {
|
|
556
|
+
if (missionWatches.has(missionId)) return;
|
|
557
|
+
let knownParticipantUids = /* @__PURE__ */ new Set();
|
|
558
|
+
try {
|
|
559
|
+
const data = await apiGet(`/agent/missions/${missionId}`);
|
|
560
|
+
for (const p of data.participants ?? []) {
|
|
561
|
+
knownParticipantUids.add(p.uid);
|
|
562
|
+
}
|
|
563
|
+
} catch {
|
|
564
|
+
}
|
|
565
|
+
const intervalMs = intervalMinutes * 60 * 1e3;
|
|
566
|
+
const state = {
|
|
567
|
+
knownParticipantUids,
|
|
568
|
+
queue: [],
|
|
569
|
+
intervalId: setInterval(async () => {
|
|
570
|
+
try {
|
|
571
|
+
const data = await apiGet(`/agent/missions/${missionId}`);
|
|
572
|
+
for (const p of data.participants ?? []) {
|
|
573
|
+
if (!state.knownParticipantUids.has(p.uid)) {
|
|
574
|
+
state.knownParticipantUids.add(p.uid);
|
|
575
|
+
state.queue.push({
|
|
576
|
+
mission_id: missionId,
|
|
577
|
+
type: "new_participant",
|
|
578
|
+
participant: {
|
|
579
|
+
uid: p.uid,
|
|
580
|
+
user_id: p.user_id,
|
|
581
|
+
joined_at: p.joined_at,
|
|
582
|
+
...p.conversation_id ? { conversation_id: p.conversation_id } : {}
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
} catch {
|
|
588
|
+
}
|
|
589
|
+
}, intervalMs)
|
|
590
|
+
};
|
|
591
|
+
missionWatches.set(missionId, state);
|
|
592
|
+
}
|
|
593
|
+
function drainMissionQueue(missionId) {
|
|
594
|
+
const state = missionWatches.get(missionId);
|
|
595
|
+
if (!state) return [];
|
|
596
|
+
const updates = [...state.queue];
|
|
597
|
+
state.queue = [];
|
|
598
|
+
return updates;
|
|
599
|
+
}
|
|
600
|
+
function stopMissionPolling(missionId) {
|
|
601
|
+
const state = missionWatches.get(missionId);
|
|
602
|
+
if (!state) return false;
|
|
603
|
+
clearInterval(state.intervalId);
|
|
604
|
+
missionWatches.delete(missionId);
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
function listWatchedMissions() {
|
|
608
|
+
return [...missionWatches.keys()];
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/tools/realtime.ts
|
|
612
|
+
var realtimeTools = [
|
|
613
|
+
{
|
|
614
|
+
name: "watch_conversation",
|
|
615
|
+
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.",
|
|
616
|
+
inputSchema: {
|
|
617
|
+
type: "object",
|
|
618
|
+
properties: {
|
|
619
|
+
conversation_id: { type: "string", description: "Conversation ID to watch" }
|
|
620
|
+
},
|
|
621
|
+
required: ["conversation_id"]
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
name: "get_pending_messages",
|
|
626
|
+
description: "Return and clear all buffered new messages for a watched conversation.",
|
|
627
|
+
inputSchema: {
|
|
628
|
+
type: "object",
|
|
629
|
+
properties: {
|
|
630
|
+
conversation_id: { type: "string", description: "Conversation ID" }
|
|
631
|
+
},
|
|
632
|
+
required: ["conversation_id"]
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
name: "unwatch_conversation",
|
|
637
|
+
description: "Stop watching a conversation and discard its message buffer.",
|
|
638
|
+
inputSchema: {
|
|
639
|
+
type: "object",
|
|
640
|
+
properties: {
|
|
641
|
+
conversation_id: { type: "string", description: "Conversation ID to stop watching" }
|
|
642
|
+
},
|
|
643
|
+
required: ["conversation_id"]
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
name: "watch_mission",
|
|
648
|
+
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.',
|
|
649
|
+
inputSchema: {
|
|
650
|
+
type: "object",
|
|
651
|
+
properties: {
|
|
652
|
+
mission_id: { type: "string", description: "Mission ID to watch" },
|
|
653
|
+
interval_minutes: {
|
|
654
|
+
type: "number",
|
|
655
|
+
description: "How often to check for new participants in minutes (default: 10)",
|
|
656
|
+
minimum: 1
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
required: ["mission_id"]
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
name: "get_pending_mission_updates",
|
|
664
|
+
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.",
|
|
665
|
+
inputSchema: {
|
|
666
|
+
type: "object",
|
|
667
|
+
properties: {
|
|
668
|
+
mission_id: { type: "string", description: "Mission ID" }
|
|
669
|
+
},
|
|
670
|
+
required: ["mission_id"]
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
name: "unwatch_mission",
|
|
675
|
+
description: "Stop polling a mission for new participants.",
|
|
676
|
+
inputSchema: {
|
|
677
|
+
type: "object",
|
|
678
|
+
properties: {
|
|
679
|
+
mission_id: { type: "string", description: "Mission ID to stop watching" }
|
|
680
|
+
},
|
|
681
|
+
required: ["mission_id"]
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
];
|
|
685
|
+
async function handleRealtimeTool(name, args) {
|
|
686
|
+
switch (name) {
|
|
687
|
+
case "watch_conversation": {
|
|
688
|
+
const id = args.conversation_id;
|
|
689
|
+
const alreadyWatching = listWatched().includes(id);
|
|
690
|
+
startPolling(id);
|
|
691
|
+
const intervalMs = getPollIntervalMs(id) ?? 5e3;
|
|
692
|
+
return {
|
|
693
|
+
success: true,
|
|
694
|
+
conversation_id: id,
|
|
695
|
+
watching: true,
|
|
696
|
+
already_was_watching: alreadyWatching,
|
|
697
|
+
poll_interval_seconds: intervalMs / 1e3,
|
|
698
|
+
message: alreadyWatching ? `Already watching conversation ${id}` : `Started watching conversation ${id} \u2014 polling every ${intervalMs / 1e3}s (Fibonacci schedule)`
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
case "get_pending_messages": {
|
|
702
|
+
const id = args.conversation_id;
|
|
703
|
+
const msgs = drainQueue(id);
|
|
704
|
+
return {
|
|
705
|
+
conversation_id: id,
|
|
706
|
+
pending_count: msgs.length,
|
|
707
|
+
messages: msgs
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
case "unwatch_conversation": {
|
|
711
|
+
const id = args.conversation_id;
|
|
712
|
+
const stopped = stopPolling(id);
|
|
713
|
+
return {
|
|
714
|
+
success: true,
|
|
715
|
+
conversation_id: id,
|
|
716
|
+
was_watching: stopped,
|
|
717
|
+
message: stopped ? `Stopped watching conversation ${id}` : `Conversation ${id} was not being watched`
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
case "watch_mission": {
|
|
721
|
+
const missionId = args.mission_id;
|
|
722
|
+
const intervalMinutes = args.interval_minutes ?? 10;
|
|
723
|
+
const alreadyWatching = listWatchedMissions().includes(missionId);
|
|
724
|
+
await startMissionPolling(missionId, intervalMinutes);
|
|
725
|
+
return {
|
|
726
|
+
success: true,
|
|
727
|
+
mission_id: missionId,
|
|
728
|
+
already_was_watching: alreadyWatching,
|
|
729
|
+
interval_minutes: intervalMinutes,
|
|
730
|
+
message: alreadyWatching ? `Already watching mission ${missionId}` : `Started watching mission ${missionId} \u2014 checking for new participants every ${intervalMinutes} min`
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
case "get_pending_mission_updates": {
|
|
734
|
+
const missionId = args.mission_id;
|
|
735
|
+
const updates = drainMissionQueue(missionId);
|
|
736
|
+
return {
|
|
737
|
+
mission_id: missionId,
|
|
738
|
+
pending_count: updates.length,
|
|
739
|
+
updates
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
case "unwatch_mission": {
|
|
743
|
+
const missionId = args.mission_id;
|
|
744
|
+
const stopped = stopMissionPolling(missionId);
|
|
745
|
+
return {
|
|
746
|
+
success: true,
|
|
747
|
+
mission_id: missionId,
|
|
748
|
+
was_watching: stopped,
|
|
749
|
+
message: stopped ? `Stopped watching mission ${missionId}` : `Mission ${missionId} was not being watched`
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
default:
|
|
753
|
+
throw new Error(`Unknown realtime tool: ${name}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// src/tools/agent.ts
|
|
758
|
+
var agentTools = [
|
|
759
|
+
{
|
|
760
|
+
name: "register_agent",
|
|
761
|
+
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.",
|
|
762
|
+
inputSchema: {
|
|
763
|
+
type: "object",
|
|
764
|
+
properties: {
|
|
765
|
+
name: {
|
|
766
|
+
type: "string",
|
|
767
|
+
description: "Agent name (3\u201350 characters)",
|
|
768
|
+
minLength: 3,
|
|
769
|
+
maxLength: 50
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
required: ["name"]
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
];
|
|
776
|
+
async function handleAgentTool(name, args) {
|
|
777
|
+
switch (name) {
|
|
778
|
+
case "register_agent":
|
|
779
|
+
return publicApiPost("/agent/register", { name: args.name });
|
|
780
|
+
default:
|
|
781
|
+
throw new Error(`Unknown agent tool: ${name}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// src/tools/tracks.ts
|
|
786
|
+
import { readFile } from "fs/promises";
|
|
787
|
+
var trackTools = [
|
|
788
|
+
{
|
|
789
|
+
name: "add_mission_track",
|
|
790
|
+
description: "Upload a media item (audio, image, or video) to a media_review mission. Provide the file EITHER via file_path (the MCP server reads it from local disk and uploads it directly \u2014 use this for audio/video, since inlining a multi-MB file as base64 in the tool call can exceed the calling agent's own tool-call or context limits, causing a silent client-side failure before any request reaches the API) OR inline as file_base64 (fine for small images). Exactly one of the two is required. For on-chain missions, call this BEFORE fund_solana_mission \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. Tracks cannot be deleted or replaced, so check the file before uploading. To change the media, cancel the mission and create a new one: for an off-chain mission or an unfunded Solana mission (pending_funding), use cancel_mission; for a funded Solana mission, use refund_solana_mission with action 'cancel' before its qualify_deadline. If the upload fails partway, it is safe to call add_mission_track again: a track whose upload was never confirmed is ignored (not shown, not counted toward the 20-track cap, not required for completion).",
|
|
791
|
+
inputSchema: {
|
|
792
|
+
type: "object",
|
|
793
|
+
properties: {
|
|
794
|
+
mission_id: { type: "string", description: "ID of the media_review mission" },
|
|
795
|
+
title: { type: "string", description: "Item title (required)" },
|
|
796
|
+
artist: { type: "string", description: "Artist / creator name (optional; typically used for audio)" },
|
|
797
|
+
file_path: {
|
|
798
|
+
type: "string",
|
|
799
|
+
description: "Path to a local file to upload, as an alternative to file_base64. The MCP server reads this file itself and PUTs it to the signed upload URL, so the calling agent only needs to pass the path \u2014 not the file contents. Prefer this for audio/video files. Use an absolute path: a relative path resolves against the MCP server process's working directory, not the caller's. Exactly one of file_base64 or file_path is required."
|
|
800
|
+
},
|
|
801
|
+
file_base64: {
|
|
802
|
+
type: "string",
|
|
803
|
+
description: "Base64-encoded file contents, as an alternative to file_path. Fine for small images; for audio/video prefer file_path \u2014 encoding a multi-MB file inline can exceed the calling agent's own tool-call or context limits. Exactly one of file_base64 or file_path is required."
|
|
804
|
+
},
|
|
805
|
+
content_type: {
|
|
806
|
+
type: "string",
|
|
807
|
+
enum: ["audio/mpeg", "audio/mp4", "image/jpeg", "image/png", "image/webp", "video/mp4"],
|
|
808
|
+
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."
|
|
809
|
+
},
|
|
810
|
+
duration_seconds: { type: "number", description: "Duration in seconds (optional; applicable to audio and video only)" }
|
|
811
|
+
},
|
|
812
|
+
required: ["mission_id", "title", "content_type"]
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
name: "list_mission_tracks",
|
|
817
|
+
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.",
|
|
818
|
+
inputSchema: {
|
|
819
|
+
type: "object",
|
|
820
|
+
properties: {
|
|
821
|
+
mission_id: { type: "string", description: "ID of the mission" }
|
|
822
|
+
},
|
|
823
|
+
required: ["mission_id"]
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
name: "get_track_ratings",
|
|
828
|
+
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.",
|
|
829
|
+
inputSchema: {
|
|
830
|
+
type: "object",
|
|
831
|
+
properties: {
|
|
832
|
+
mission_id: { type: "string", description: "ID of the mission" },
|
|
833
|
+
track_id: { type: "string", description: "ID of the track" }
|
|
834
|
+
},
|
|
835
|
+
required: ["mission_id", "track_id"]
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
];
|
|
839
|
+
async function handleTrackTool(name, args) {
|
|
840
|
+
switch (name) {
|
|
841
|
+
case "add_mission_track": {
|
|
842
|
+
const { mission_id, title, artist, file_base64, file_path, content_type, duration_seconds } = args;
|
|
843
|
+
if (!file_base64 && !file_path) {
|
|
844
|
+
throw new Error("add_mission_track requires either file_base64 or file_path");
|
|
845
|
+
}
|
|
846
|
+
if (file_base64 && file_path) {
|
|
847
|
+
throw new Error("add_mission_track accepts only one of file_base64 or file_path, not both");
|
|
848
|
+
}
|
|
849
|
+
let fileBytes;
|
|
850
|
+
if (file_path) {
|
|
851
|
+
try {
|
|
852
|
+
fileBytes = await readFile(file_path);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
throw new Error(`Could not read file_path "${file_path}": ${err.message}`);
|
|
855
|
+
}
|
|
856
|
+
} else {
|
|
857
|
+
fileBytes = Buffer.from(file_base64, "base64");
|
|
858
|
+
}
|
|
859
|
+
const urlRes = await apiPost(
|
|
860
|
+
`/agent/missions/${mission_id}/tracks/upload-url`,
|
|
861
|
+
{ title, artist, content_type }
|
|
862
|
+
);
|
|
863
|
+
const uploadRes = await fetch(urlRes.upload_url, {
|
|
864
|
+
method: "PUT",
|
|
865
|
+
headers: { "Content-Type": content_type },
|
|
866
|
+
body: fileBytes
|
|
867
|
+
});
|
|
868
|
+
if (!uploadRes.ok) {
|
|
869
|
+
const errText = await uploadRes.text().catch(() => "");
|
|
870
|
+
throw new Error(
|
|
871
|
+
`Media upload failed: ${uploadRes.status} ${errText}. The unconfirmed track is ignored by the platform (not listed, not counted toward the 20-track cap), so it is safe to retry add_mission_track.`
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
const confirmed = await apiPost(
|
|
875
|
+
`/agent/missions/${mission_id}/tracks/${urlRes.track_id}/confirm`,
|
|
876
|
+
{ title, artist, duration_seconds }
|
|
877
|
+
);
|
|
878
|
+
return confirmed.track;
|
|
879
|
+
}
|
|
880
|
+
case "list_mission_tracks": {
|
|
881
|
+
const { mission_id } = args;
|
|
882
|
+
return apiGet(`/agent/missions/${mission_id}/tracks`);
|
|
883
|
+
}
|
|
884
|
+
case "get_track_ratings": {
|
|
885
|
+
const { mission_id, track_id } = args;
|
|
886
|
+
return apiGet(`/agent/missions/${mission_id}/tracks/${track_id}/ratings`);
|
|
887
|
+
}
|
|
888
|
+
default:
|
|
889
|
+
throw new Error(`Unknown track tool: ${name}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// src/tools/solana.ts
|
|
894
|
+
var solanaTools = [
|
|
895
|
+
{
|
|
896
|
+
name: "get_solana_config",
|
|
897
|
+
description: "Read the Solana escrow deployment: program id, cluster, RPC endpoint, accepted mints and their decimals, the minimum first payout, and escrow_interface \u2014 the program interface funding transactions use. This server verifies only escrow_interface 'v2' and refuses to fund against anything else. Call this before funding so you use a whitelisted mint \u2014 a mint that is not whitelisted is rejected on chain, not by the API. Requires no wallet.",
|
|
898
|
+
inputSchema: { type: "object", properties: {} }
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
name: "get_solana_wallet",
|
|
902
|
+
description: "Show your Solana wallet address and its balances. Reports SOL (needed for transaction fees and for account rent) and the token balance for a given mint (the mission budget). Use this before funding: an agent needs BOTH, and the rent line is easy to miss. Rent is a refundable deposit, not a fee \u2014 most of it returns when the task is closed. If no wallet is configured this explains how to set one up.",
|
|
903
|
+
inputSchema: {
|
|
904
|
+
type: "object",
|
|
905
|
+
properties: {
|
|
906
|
+
mint: {
|
|
907
|
+
type: "string",
|
|
908
|
+
description: "Mint to report a balance for. Defaults to the deployment payout mint from get_solana_config."
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
name: "fund_solana_mission",
|
|
915
|
+
description: "Fund a Solana mission end to end: the backend builds the escrow transaction, this tool DECODES AND VERIFIES it against the parameters you expect, signs it locally with your wallet, and submits it. Your key never leaves this process.\n\nVerification is not optional and cannot be skipped. On Solana the backend builds the transaction rather than publishing a parameter set for you to rebuild, so without a decode you would be signing bytes you cannot read. This tool refuses to sign if anything differs from what you expect \u2014 a substituted mint, an altered budget or deadline, an extra instruction, an account that is not the program-derived address, a raised fee ceiling, a lottery co-signer that is not the program's Operator \u2014 and returns the discrepancies instead.\n\nThe escrow program id is pinned in this package per cluster (SOLO_SOLANA_PROGRAM_ID can pin another) and the API's program_id must match it; the mint's decimals, which scale expected_budget, are read from chain.\n\nEscrow interface v2 (the only one this server verifies; get_solana_config reports it):\n- max_fee_bps: create_task carries the highest platform fee the escrow may take. It must equal the fee rate create_mission quoted you (mission.solana_quoted_fee_bps). If the program's fee has risen above it by the time the transaction lands, create_task fails with FeeAboveSponsorMax instead of charging you more; cancel the mission and create a new one for a fresh quote.\n- Lottery co-signature: a mission with lottery_winner_count > 0 must be co-signed by the platform Operator, so the transaction arrives already signed by it. This tool checks that the co-signer is the Operator in the program's on-chain Config (read directly from the cluster, not from the API), that its ed25519 signature is valid over the exact message, and that you, not the Operator, pay the fee. It then adds ONLY your signature and leaves the Operator's untouched; any change to the bytes would invalidate it. A non-lottery transaction must carry no co-signer.\n\nCall create_mission with a budget first and pass that mission's id here. Fund it within 24 hours: an unfunded mission is cancelled automatically at funding_params.expires_at. A media_review mission needs at least one ready track (add_mission_track) before funding, or this returns 409 before anything is signed.",
|
|
916
|
+
inputSchema: {
|
|
917
|
+
type: "object",
|
|
918
|
+
properties: {
|
|
919
|
+
mission_id: { type: "string", description: "A paid (budget-bearing) mission from create_mission, still in pending_funding." },
|
|
920
|
+
expected_budget: {
|
|
921
|
+
type: "number",
|
|
922
|
+
description: "The total budget in whole tokens (e.g. 10 for 10 USDC) you expect to escrow. Verified against the transaction before signing. Pass what you intended, NOT what the API told you \u2014 comparing the API to itself proves nothing."
|
|
923
|
+
},
|
|
924
|
+
expected_mint: {
|
|
925
|
+
type: "string",
|
|
926
|
+
description: "The mint you expect the budget to be taken in. Verified before signing. Defaults to the mint create_mission recorded for this mission (its funding_params.mint)."
|
|
927
|
+
},
|
|
928
|
+
expected_max_fee_bps: {
|
|
929
|
+
type: "integer",
|
|
930
|
+
minimum: 0,
|
|
931
|
+
description: "The platform fee rate, in basis points, that create_mission quoted you: its response's mission.solana_quoted_fee_bps. Verified against the transaction's max_fee_bps before signing. Defaults to the quote recorded on the mission."
|
|
932
|
+
},
|
|
933
|
+
dry_run: {
|
|
934
|
+
type: "boolean",
|
|
935
|
+
description: "Build and verify, then stop without signing or submitting. Use this to inspect what would be signed. Nothing is escrowed and no fee is paid."
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
required: ["mission_id", "expected_budget"]
|
|
939
|
+
}
|
|
940
|
+
},
|
|
941
|
+
{
|
|
942
|
+
name: "refund_solana_mission",
|
|
943
|
+
description: "Get a Solana mission's escrowed funds back to the sponsor. Covers all three routes:\n- cancel: while the task is funded and strictly before qualify_deadline (the end of the hiring window). The escrow program refuses a cancel from qualify_deadline on (TooLateToCancel), even one built earlier, because by then the participants' work is done.\n- emergency_refund: once settlement_deadline has passed and the platform has not settled. It stays available after the cancel cut-off, and a pause does not block it.\n- claim_refund: leftover budget after a settle (mission status refundable).\n\nSame flow as funding: the backend builds the transaction, this tool VERIFIES it, signs locally, and submits. Your key never leaves this process.\n\nCall with no action to ask what is currently available \u2014 the response lists the legal actions for the mission's state rather than guessing. If the backend or the chain rejects an action, the response says which one did, names the program error, and lists the actions that remain.",
|
|
944
|
+
inputSchema: {
|
|
945
|
+
type: "object",
|
|
946
|
+
properties: {
|
|
947
|
+
mission_id: { type: "string" },
|
|
948
|
+
action: {
|
|
949
|
+
type: "string",
|
|
950
|
+
enum: ["cancel", "emergency_refund", "claim_refund"],
|
|
951
|
+
description: "Omit to query what is available for this mission right now instead of attempting one."
|
|
952
|
+
},
|
|
953
|
+
dry_run: {
|
|
954
|
+
type: "boolean",
|
|
955
|
+
description: "Build and verify without signing or submitting. Nothing moves, no fee."
|
|
956
|
+
}
|
|
957
|
+
},
|
|
958
|
+
required: ["mission_id"]
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
];
|
|
962
|
+
function toRawAmount(amount, decimals) {
|
|
963
|
+
const s = amount.toString();
|
|
964
|
+
if (!/^\d+(\.\d+)?$/.test(s)) {
|
|
965
|
+
throw new Error(`amount must be a non-negative decimal number, got ${s}`);
|
|
966
|
+
}
|
|
967
|
+
const [whole, frac = ""] = s.split(".");
|
|
968
|
+
if (frac.length > decimals) {
|
|
969
|
+
throw new Error(
|
|
970
|
+
`amount ${s} has ${frac.length} decimal places but the mint has only ${decimals}`
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
return (whole + frac.padEnd(decimals, "0")).replace(/^0+(?=\d)/, "");
|
|
974
|
+
}
|
|
975
|
+
var SOLANA_TOOL_NAMES = new Set(solanaTools.map((t) => t.name));
|
|
976
|
+
function apiErrorBody(e) {
|
|
977
|
+
return e?.data ?? e?.response?.data ?? e?.body ?? {};
|
|
978
|
+
}
|
|
979
|
+
var CANCEL_CUTOFF_RULE = "cancel is refused on chain from qualify_deadline on (TooLateToCancel, error 6125); emergency_refund opens once settlement_deadline has passed without a settlement.";
|
|
980
|
+
async function handleSolanaTool(name, args) {
|
|
981
|
+
const { apiGet: apiGet2, apiPost: apiPost2 } = await import("./client-NS6J6IO2.js");
|
|
982
|
+
const { resolvePinnedProgram, resolveTrustedRpc, solanaRpcUrl } = await import("./deployment-UCUWVIPJ.js");
|
|
983
|
+
switch (name) {
|
|
984
|
+
case "get_solana_config":
|
|
985
|
+
return apiGet2("/agent/solana/config");
|
|
986
|
+
case "get_solana_wallet": {
|
|
987
|
+
const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress, KEY_STORAGE_GUIDANCE } = await import("./wallet-LX7SI5RE.js");
|
|
988
|
+
if (!hasSolanaWallet()) {
|
|
989
|
+
return {
|
|
990
|
+
configured: false,
|
|
991
|
+
how_to_configure: {
|
|
992
|
+
recommended: "SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE \u2014 a keyfile encrypted with `openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256`, plus a separate passphrase file (chmod 600 both)",
|
|
993
|
+
alternative: "SOLO_SOLANA_KEYPAIR_PATH \u2014 path to a plaintext `solana-keygen` keyfile, e.g. ~/.config/solana/id.json (chmod 600)",
|
|
994
|
+
key_storage: KEY_STORAGE_GUIDANCE
|
|
995
|
+
},
|
|
996
|
+
what_you_need: "SOL for transaction fees and account rent, plus the payout token for the budget. Rent is a refundable deposit, not a fee \u2014 most of it returns on close_task. Roughly $0.58 is locked per mission and about $0.24 is permanent, which buys the on-chain record that makes the escrow verifiable by anyone."
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
const wallet = await loadSolanaWallet();
|
|
1000
|
+
const cfg = await apiGet2("/agent/solana/config");
|
|
1001
|
+
const symbol = Object.keys(cfg.mints)[0];
|
|
1002
|
+
const mint = args.mint ?? cfg.mints[symbol];
|
|
1003
|
+
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
1004
|
+
const rpc = async (method, params) => {
|
|
1005
|
+
const res = await fetch(solanaRpcUrl(cfg), {
|
|
1006
|
+
method: "POST",
|
|
1007
|
+
headers: { "Content-Type": "application/json" },
|
|
1008
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params })
|
|
1009
|
+
});
|
|
1010
|
+
return await res.json();
|
|
1011
|
+
};
|
|
1012
|
+
const solRes = await rpc("getBalance", [wallet.publicKey]);
|
|
1013
|
+
const tokRes = await rpc("getTokenAccountBalance", [tokenAccount]);
|
|
1014
|
+
const lamports = solRes.result?.value ?? 0;
|
|
1015
|
+
const decimals = cfg.decimals[symbol] ?? 6;
|
|
1016
|
+
return {
|
|
1017
|
+
configured: true,
|
|
1018
|
+
address: wallet.publicKey,
|
|
1019
|
+
sol: lamports / 1e9,
|
|
1020
|
+
// A token account only exists once tokens first arrive. create_task reads it, so funding
|
|
1021
|
+
// fails without one — and the on-chain error names the account rather than the missing
|
|
1022
|
+
// balance, which is confusing enough to call out explicitly.
|
|
1023
|
+
token_account: tokenAccount,
|
|
1024
|
+
token_account_exists: !tokRes.error,
|
|
1025
|
+
token_balance: tokRes.result?.value?.uiAmountString ?? "0",
|
|
1026
|
+
mint,
|
|
1027
|
+
can_pay_fees: lamports > 1e7,
|
|
1028
|
+
// ~0.01 SOL — comfortably covers rent plus fees
|
|
1029
|
+
note: lamports === 0 ? 'No SOL. Funding will fail with "Attempt to debit an account but found no record of a prior credit", which does not mention SOL.' : void 0,
|
|
1030
|
+
_decimals: decimals
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
case "fund_solana_mission": {
|
|
1034
|
+
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-LX7SI5RE.js");
|
|
1035
|
+
const { verifyFundingTransaction, readEscrowConfig, readMintDecimals, SUPPORTED_ESCROW_INTERFACE } = await import("./verify-7EUVRUAS.js");
|
|
1036
|
+
const wallet = await loadSolanaWallet();
|
|
1037
|
+
const cfg = await apiGet2("/agent/solana/config");
|
|
1038
|
+
if (cfg.escrow_interface !== SUPPORTED_ESCROW_INTERFACE) {
|
|
1039
|
+
return {
|
|
1040
|
+
funded: false,
|
|
1041
|
+
refused_to_sign: true,
|
|
1042
|
+
problems: [
|
|
1043
|
+
`the deployment reports escrow_interface ${JSON.stringify(cfg.escrow_interface)}; this version of @projectsolo/solo-mission-mcp verifies only '${SUPPORTED_ESCROW_INTERFACE}' funding transactions`
|
|
1044
|
+
],
|
|
1045
|
+
what_this_means: "Nothing was built, signed or escrowed. This server cannot decode the funding transaction the deployment would build, so it will not sign one. Use a version of this package that supports the reported escrow interface."
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
const pin = resolvePinnedProgram(cfg);
|
|
1049
|
+
const rpc = resolveTrustedRpc(cfg);
|
|
1050
|
+
if ("problem" in pin || "problem" in rpc) {
|
|
1051
|
+
return {
|
|
1052
|
+
funded: false,
|
|
1053
|
+
refused_to_sign: true,
|
|
1054
|
+
problems: [
|
|
1055
|
+
..."problem" in pin ? [pin.problem] : [],
|
|
1056
|
+
..."problem" in rpc ? [rpc.problem] : []
|
|
1057
|
+
],
|
|
1058
|
+
what_this_means: "Nothing was built, signed or escrowed."
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
const rpcUrl = rpc.rpc_url;
|
|
1062
|
+
const { mission } = await apiGet2(`/agent/missions/${args.mission_id}`);
|
|
1063
|
+
if (!mission) {
|
|
1064
|
+
return {
|
|
1065
|
+
funded: false,
|
|
1066
|
+
error: `Could not read mission ${args.mission_id} \u2014 call get_mission to check it exists.`
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
const mint = args.expected_mint ?? mission.token_address ?? cfg.mints[Object.keys(cfg.mints)[0]];
|
|
1070
|
+
let decimals;
|
|
1071
|
+
try {
|
|
1072
|
+
decimals = await readMintDecimals(rpcUrl, mint);
|
|
1073
|
+
} catch (e) {
|
|
1074
|
+
return {
|
|
1075
|
+
funded: false,
|
|
1076
|
+
refused_to_sign: true,
|
|
1077
|
+
problems: [
|
|
1078
|
+
`cannot read mint ${mint}'s decimals from chain, which expected_budget is scaled by: ` + e.message
|
|
1079
|
+
],
|
|
1080
|
+
what_this_means: "Nothing was built, signed or escrowed."
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
1084
|
+
const built = await apiPost2(
|
|
1085
|
+
`/agent/solana/missions/${args.mission_id}/funding-transaction`,
|
|
1086
|
+
{ sponsor_wallet: wallet.publicKey, sponsor_token_account: tokenAccount }
|
|
1087
|
+
);
|
|
1088
|
+
let onchainConfig = null;
|
|
1089
|
+
let onchainConfigError;
|
|
1090
|
+
try {
|
|
1091
|
+
onchainConfig = await readEscrowConfig(rpcUrl, pin.program_id);
|
|
1092
|
+
} catch (e) {
|
|
1093
|
+
onchainConfigError = e.message;
|
|
1094
|
+
}
|
|
1095
|
+
let quotedFeeBps;
|
|
1096
|
+
let feeQuoteSource;
|
|
1097
|
+
if (args.expected_max_fee_bps !== void 0) {
|
|
1098
|
+
quotedFeeBps = Number(args.expected_max_fee_bps);
|
|
1099
|
+
feeQuoteSource = "expected_max_fee_bps argument";
|
|
1100
|
+
} else if (typeof mission.solana_quoted_fee_bps === "number") {
|
|
1101
|
+
quotedFeeBps = mission.solana_quoted_fee_bps;
|
|
1102
|
+
feeQuoteSource = "mission.solana_quoted_fee_bps (the create_mission quote)";
|
|
1103
|
+
} else {
|
|
1104
|
+
quotedFeeBps = onchainConfig?.fee_bps;
|
|
1105
|
+
feeQuoteSource = "on-chain Config.fee_bps (the mission has no recorded quote)";
|
|
1106
|
+
}
|
|
1107
|
+
const expectedBudgetRaw = toRawAmount(args.expected_budget, decimals);
|
|
1108
|
+
const verdict = await verifyFundingTransaction({
|
|
1109
|
+
transaction_base64: built.transaction_base64,
|
|
1110
|
+
escrow_interface: built.escrow_interface,
|
|
1111
|
+
task_id: String(built.task_id),
|
|
1112
|
+
declared: built.declared,
|
|
1113
|
+
accounts: built.accounts,
|
|
1114
|
+
message_sha256: built.message_sha256,
|
|
1115
|
+
expected: {
|
|
1116
|
+
budget_raw: expectedBudgetRaw,
|
|
1117
|
+
// base_pool is derived by the backend from reward × max_humans. The agent's check on it
|
|
1118
|
+
// is the quoted value against the encoded bytes, which verifyFundingTransaction does —
|
|
1119
|
+
// asserting a locally recomputed figure would require duplicating that arithmetic here
|
|
1120
|
+
// and would fail on a legitimately rounded reward.
|
|
1121
|
+
base_pool_raw: String(built.declared.base_pool),
|
|
1122
|
+
// The rest is what create_mission recorded, not what the funding response says: whether
|
|
1123
|
+
// this is a lottery decides whether a co-signer is required at all.
|
|
1124
|
+
lottery_winner_count: Number(mission.lottery_winner_count ?? 0),
|
|
1125
|
+
lottery_prize_per_winner_raw: String(mission.lottery_prize_per_winner_raw ?? "0"),
|
|
1126
|
+
qualify_deadline: Number(mission.qualify_deadline),
|
|
1127
|
+
settlement_deadline: Number(mission.settlement_deadline),
|
|
1128
|
+
seed_commit: mission.seed_commit,
|
|
1129
|
+
max_fee_bps: quotedFeeBps,
|
|
1130
|
+
mint,
|
|
1131
|
+
sponsor: wallet.publicKey,
|
|
1132
|
+
sponsor_token_account: tokenAccount
|
|
1133
|
+
},
|
|
1134
|
+
expected_program_id: pin.program_id,
|
|
1135
|
+
onchain_config: onchainConfig,
|
|
1136
|
+
onchain_config_error: onchainConfigError
|
|
1137
|
+
});
|
|
1138
|
+
if (!verdict.ok) {
|
|
1139
|
+
return {
|
|
1140
|
+
funded: false,
|
|
1141
|
+
refused_to_sign: true,
|
|
1142
|
+
problems: verdict.problems,
|
|
1143
|
+
warnings: verdict.warnings,
|
|
1144
|
+
summary: verdict.summary,
|
|
1145
|
+
fee_quote_source: feeQuoteSource,
|
|
1146
|
+
what_this_means: "The transaction does not match what you asked for, so it was NOT signed and nothing was escrowed. This is the verifier doing its job. Do not retry blindly \u2014 the discrepancy above is either a bug or an attempt to have you authorise something else."
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
if (args.dry_run) {
|
|
1150
|
+
return {
|
|
1151
|
+
funded: false,
|
|
1152
|
+
dry_run: true,
|
|
1153
|
+
verified: true,
|
|
1154
|
+
task_id: built.task_id,
|
|
1155
|
+
summary: verdict.summary,
|
|
1156
|
+
warnings: verdict.warnings,
|
|
1157
|
+
fee_quote_source: feeQuoteSource,
|
|
1158
|
+
would_escrow: `${args.expected_budget} (${expectedBudgetRaw} raw) of ${mint}`
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
const signed = await signTransaction(built.transaction_base64, wallet);
|
|
1162
|
+
let confirmed;
|
|
1163
|
+
try {
|
|
1164
|
+
confirmed = await apiPost2(`/agent/solana/missions/${args.mission_id}/confirm-funding`, {
|
|
1165
|
+
signed_transaction: signed,
|
|
1166
|
+
task_id: built.task_id
|
|
1167
|
+
});
|
|
1168
|
+
} catch (e) {
|
|
1169
|
+
const { escrowErrorFromMessage } = await import("./escrowErrors-4XSAYLGT.js");
|
|
1170
|
+
const body = apiErrorBody(e);
|
|
1171
|
+
const message = body.message ?? e?.message ?? String(e);
|
|
1172
|
+
return {
|
|
1173
|
+
funded: false,
|
|
1174
|
+
verified: true,
|
|
1175
|
+
signed: true,
|
|
1176
|
+
status: e?.status,
|
|
1177
|
+
message,
|
|
1178
|
+
program_error: escrowErrorFromMessage(message) ?? void 0,
|
|
1179
|
+
...body.funding_mismatch ? { funding_mismatch: body.funding_mismatch } : {},
|
|
1180
|
+
...body.task_id ? { task_id: body.task_id } : {}
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
return {
|
|
1184
|
+
funded: true,
|
|
1185
|
+
verified: true,
|
|
1186
|
+
lottery: verdict.summary.lottery,
|
|
1187
|
+
operator_cosigned: verdict.summary.operator_signature === "valid",
|
|
1188
|
+
max_fee_bps: verdict.summary.max_fee_bps,
|
|
1189
|
+
...confirmed
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
case "refund_solana_mission": {
|
|
1193
|
+
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-LX7SI5RE.js");
|
|
1194
|
+
const { escrowErrorFromMessage } = await import("./escrowErrors-4XSAYLGT.js");
|
|
1195
|
+
const wallet = await loadSolanaWallet();
|
|
1196
|
+
const cfg = await apiGet2("/agent/solana/config");
|
|
1197
|
+
const { mission } = await apiGet2(`/agent/missions/${args.mission_id}`);
|
|
1198
|
+
const mint = mission?.token_address;
|
|
1199
|
+
if (!mint) {
|
|
1200
|
+
return {
|
|
1201
|
+
refunded: false,
|
|
1202
|
+
error: `Could not resolve mission ${args.mission_id}'s token_address \u2014 call get_mission to check it exists and is funded.`
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
1206
|
+
const deadlines = {
|
|
1207
|
+
qualify_deadline: mission?.qualify_deadline,
|
|
1208
|
+
settlement_deadline: mission?.settlement_deadline,
|
|
1209
|
+
...mission?.qualify_deadline !== void 0 ? { cancel_cutoff: new Date(mission.qualify_deadline * 1e3).toISOString() } : {}
|
|
1210
|
+
};
|
|
1211
|
+
if (!args.action) {
|
|
1212
|
+
try {
|
|
1213
|
+
await apiPost2(`/agent/solana/missions/${args.mission_id}/refund-transaction`, {
|
|
1214
|
+
action: "claim_refund",
|
|
1215
|
+
sponsor_wallet: wallet.publicKey,
|
|
1216
|
+
sponsor_token_account: tokenAccount
|
|
1217
|
+
});
|
|
1218
|
+
return { available_actions: ["claim_refund"], note: "claim_refund is available now" };
|
|
1219
|
+
} catch (e) {
|
|
1220
|
+
const body = apiErrorBody(e);
|
|
1221
|
+
return {
|
|
1222
|
+
available_actions: body.available_actions ?? [],
|
|
1223
|
+
why_not_claim_refund: body.message,
|
|
1224
|
+
...deadlines,
|
|
1225
|
+
note: (body.available_actions?.length ?? 0) === 0 ? `Nothing is refundable right now. ${CANCEL_CUTOFF_RULE}` : `Re-run with one of available_actions. ${CANCEL_CUTOFF_RULE}`
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
const nowSec = Math.floor(Date.now() / 1e3);
|
|
1230
|
+
if (args.action === "cancel" && typeof mission?.qualify_deadline === "number" && nowSec >= mission.qualify_deadline) {
|
|
1231
|
+
return {
|
|
1232
|
+
refunded: false,
|
|
1233
|
+
rejected_by: "client",
|
|
1234
|
+
action: "cancel",
|
|
1235
|
+
message: `qualify_deadline (${deadlines.cancel_cutoff}) has passed by this machine's clock, so the escrow program would refuse this cancel (TooLateToCancel). Nothing was built or signed.`,
|
|
1236
|
+
...deadlines,
|
|
1237
|
+
what_to_do: CANCEL_CUTOFF_RULE
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
const pin = resolvePinnedProgram(cfg);
|
|
1241
|
+
if ("problem" in pin) {
|
|
1242
|
+
return {
|
|
1243
|
+
refunded: false,
|
|
1244
|
+
refused_to_sign: true,
|
|
1245
|
+
problems: [pin.problem],
|
|
1246
|
+
what_this_means: "Nothing was built, signed or moved."
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
let built;
|
|
1250
|
+
try {
|
|
1251
|
+
built = await apiPost2(
|
|
1252
|
+
`/agent/solana/missions/${args.mission_id}/refund-transaction`,
|
|
1253
|
+
{
|
|
1254
|
+
action: args.action,
|
|
1255
|
+
sponsor_wallet: wallet.publicKey,
|
|
1256
|
+
sponsor_token_account: tokenAccount
|
|
1257
|
+
}
|
|
1258
|
+
);
|
|
1259
|
+
} catch (e) {
|
|
1260
|
+
const body = apiErrorBody(e);
|
|
1261
|
+
const message = body.message ?? e?.message ?? String(e);
|
|
1262
|
+
return {
|
|
1263
|
+
refunded: false,
|
|
1264
|
+
rejected_by: "backend",
|
|
1265
|
+
action: args.action,
|
|
1266
|
+
status: e?.status,
|
|
1267
|
+
message,
|
|
1268
|
+
available_actions: body.available_actions ?? [],
|
|
1269
|
+
program_error: escrowErrorFromMessage(message) ?? void 0,
|
|
1270
|
+
...deadlines,
|
|
1271
|
+
what_to_do: CANCEL_CUTOFF_RULE
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
const { Transaction } = await import("@solana/web3.js");
|
|
1275
|
+
const tx = Transaction.from(Buffer.from(built.transaction_base64, "base64"));
|
|
1276
|
+
const problems = [];
|
|
1277
|
+
if (tx.instructions.length !== 1) {
|
|
1278
|
+
problems.push(`expected 1 instruction, found ${tx.instructions.length}`);
|
|
1279
|
+
}
|
|
1280
|
+
const ix = tx.instructions[0];
|
|
1281
|
+
if (ix?.programId?.toBase58() !== pin.program_id) {
|
|
1282
|
+
problems.push(`program is ${ix?.programId?.toBase58()}, expected ${pin.program_id}`);
|
|
1283
|
+
}
|
|
1284
|
+
const signers = (ix?.keys ?? []).filter((k) => k.isSigner).map((k) => k.pubkey.toBase58());
|
|
1285
|
+
if (signers.length !== 1 || signers[0] !== wallet.publicKey) {
|
|
1286
|
+
problems.push(`expected only ${wallet.publicKey} to sign, found [${signers.join(", ")}]`);
|
|
1287
|
+
}
|
|
1288
|
+
const keys = (ix?.keys ?? []).map((k) => k.pubkey.toBase58());
|
|
1289
|
+
if (!keys.includes(tokenAccount)) {
|
|
1290
|
+
problems.push(
|
|
1291
|
+
`the refund destination ${tokenAccount} is not in the transaction \u2014 the funds would go elsewhere`
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
if ((ix?.data?.length ?? 0) !== 8) {
|
|
1295
|
+
problems.push(`instruction data is ${ix?.data?.length} bytes, expected 8`);
|
|
1296
|
+
}
|
|
1297
|
+
if (problems.length > 0) {
|
|
1298
|
+
return {
|
|
1299
|
+
refunded: false,
|
|
1300
|
+
refused_to_sign: true,
|
|
1301
|
+
problems,
|
|
1302
|
+
what_this_means: "The refund transaction does not match what was asked for, so it was NOT signed and nothing moved. Do not retry blindly."
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
if (args.dry_run) {
|
|
1306
|
+
return {
|
|
1307
|
+
refunded: false,
|
|
1308
|
+
dry_run: true,
|
|
1309
|
+
verified: true,
|
|
1310
|
+
action: args.action,
|
|
1311
|
+
task_id: built.task_id,
|
|
1312
|
+
destination: tokenAccount,
|
|
1313
|
+
...built.cancel_deadline ? { cancel_deadline: built.cancel_deadline } : {}
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
const signed = await signTransaction(built.transaction_base64, wallet);
|
|
1317
|
+
let confirmed;
|
|
1318
|
+
try {
|
|
1319
|
+
confirmed = await apiPost2(`/agent/solana/missions/${args.mission_id}/confirm-refund`, {
|
|
1320
|
+
signed_transaction: signed,
|
|
1321
|
+
action: args.action
|
|
1322
|
+
});
|
|
1323
|
+
} catch (e) {
|
|
1324
|
+
const body = apiErrorBody(e);
|
|
1325
|
+
const message = body.message ?? e?.message ?? String(e);
|
|
1326
|
+
const programError = escrowErrorFromMessage(message);
|
|
1327
|
+
return {
|
|
1328
|
+
refunded: false,
|
|
1329
|
+
rejected_by: programError || /transaction failed/i.test(message) ? "chain" : "backend",
|
|
1330
|
+
action: args.action,
|
|
1331
|
+
status: e?.status,
|
|
1332
|
+
message,
|
|
1333
|
+
program_error: programError ?? void 0,
|
|
1334
|
+
available_actions: body.available_actions,
|
|
1335
|
+
...deadlines,
|
|
1336
|
+
what_to_do: programError?.what_to_do ?? CANCEL_CUTOFF_RULE
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
return { refunded: true, verified: true, ...confirmed };
|
|
1340
|
+
}
|
|
1341
|
+
default:
|
|
1342
|
+
throw new Error(`Unknown Solana tool: ${name}`);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// src/server.ts
|
|
1347
|
+
var ALL_TOOLS = [...agentTools, ...missionTools, ...humanTools, ...conversationTools, ...realtimeTools, ...trackTools, ...solanaTools];
|
|
1348
|
+
var AGENT_TOOL_NAMES = new Set(agentTools.map((t) => t.name));
|
|
1349
|
+
var MISSION_TOOL_NAMES = new Set(missionTools.map((t) => t.name));
|
|
1350
|
+
var HUMAN_TOOL_NAMES = new Set(humanTools.map((t) => t.name));
|
|
1351
|
+
var CONVERSATION_TOOL_NAMES = new Set(conversationTools.map((t) => t.name));
|
|
1352
|
+
var REALTIME_TOOL_NAMES = new Set(realtimeTools.map((t) => t.name));
|
|
1353
|
+
var TRACK_TOOL_NAMES = new Set(trackTools.map((t) => t.name));
|
|
1354
|
+
var server = new Server(
|
|
1355
|
+
{ name: "solo-mission-mcp", version: "0.23.3" },
|
|
1356
|
+
{ capabilities: { tools: {} } }
|
|
1357
|
+
);
|
|
1358
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1359
|
+
tools: ALL_TOOLS
|
|
1360
|
+
}));
|
|
1361
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1362
|
+
const { name, arguments: args = {} } = request.params;
|
|
1363
|
+
try {
|
|
1364
|
+
let result;
|
|
1365
|
+
if (AGENT_TOOL_NAMES.has(name)) {
|
|
1366
|
+
result = await handleAgentTool(name, args);
|
|
1367
|
+
} else if (MISSION_TOOL_NAMES.has(name)) {
|
|
1368
|
+
result = await handleMissionTool(name, args);
|
|
1369
|
+
} else if (HUMAN_TOOL_NAMES.has(name)) {
|
|
1370
|
+
result = await handleHumanTool(name, args);
|
|
1371
|
+
} else if (CONVERSATION_TOOL_NAMES.has(name)) {
|
|
1372
|
+
result = await handleConversationTool(name, args);
|
|
1373
|
+
} else if (REALTIME_TOOL_NAMES.has(name)) {
|
|
1374
|
+
result = await handleRealtimeTool(name, args);
|
|
1375
|
+
} else if (TRACK_TOOL_NAMES.has(name)) {
|
|
1376
|
+
result = await handleTrackTool(name, args);
|
|
1377
|
+
} else if (SOLANA_TOOL_NAMES.has(name)) {
|
|
1378
|
+
result = await handleSolanaTool(name, args);
|
|
1379
|
+
} else {
|
|
1380
|
+
return {
|
|
1381
|
+
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
1382
|
+
isError: true
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
return {
|
|
1386
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1387
|
+
};
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
const message = error?.response?.data ? JSON.stringify(error.response.data) : error?.message ?? String(error);
|
|
1390
|
+
return {
|
|
1391
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1392
|
+
isError: true
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
async function main() {
|
|
1397
|
+
const transport = new StdioServerTransport();
|
|
1398
|
+
await server.connect(transport);
|
|
1399
|
+
}
|
|
1400
|
+
main().catch((err) => {
|
|
1401
|
+
console.error("Failed to start solo-mission-mcp:", err);
|
|
1402
|
+
process.exit(1);
|
|
1403
|
+
});
|