@projectsolo/solo-mission-mcp 0.20.1 → 0.21.2

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 CHANGED
@@ -957,6 +957,26 @@ var solanaTools = [
957
957
  },
958
958
  required: ["mission_id", "expected_budget"]
959
959
  }
960
+ },
961
+ {
962
+ name: "refund_solana_mission",
963
+ description: "Get a Solana mission's escrowed funds back to the sponsor. Covers all three routes: cancel (while funded, before hiring closes), emergency_refund (once the settlement deadline passes and the platform has not settled), and claim_refund (leftover budget after a partial settle).\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. Without this tool a funded Solana mission's money was unreachable except by hand-assembling an Anchor instruction, for which Solana has no `cast send` equivalent.",
964
+ inputSchema: {
965
+ type: "object",
966
+ properties: {
967
+ mission_id: { type: "string" },
968
+ action: {
969
+ type: "string",
970
+ enum: ["cancel", "emergency_refund", "claim_refund"],
971
+ description: "Omit to query what is available for this mission right now instead of attempting one."
972
+ },
973
+ dry_run: {
974
+ type: "boolean",
975
+ description: "Build and verify without signing or submitting. Nothing moves, no fee."
976
+ }
977
+ },
978
+ required: ["mission_id"]
979
+ }
960
980
  }
961
981
  ];
962
982
  function toRawAmount(amount, decimals) {
@@ -1084,6 +1104,86 @@ async function handleSolanaTool(name, args) {
1084
1104
  );
1085
1105
  return { funded: true, verified: true, ...confirmed };
1086
1106
  }
1107
+ case "refund_solana_mission": {
1108
+ const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-IUQWBW6F.js");
1109
+ const wallet = await loadSolanaWallet();
1110
+ const cfg = await apiGet2("/agent/solana/config");
1111
+ const mint = cfg.mints.TEST_USDC ?? Object.values(cfg.mints)[0];
1112
+ const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
1113
+ if (!args.action) {
1114
+ try {
1115
+ await apiPost2(`/agent/solana/missions/${args.mission_id}/refund-transaction`, {
1116
+ action: "claim_refund",
1117
+ sponsor_wallet: wallet.publicKey,
1118
+ sponsor_token_account: tokenAccount
1119
+ });
1120
+ return { available_actions: ["claim_refund"], note: "claim_refund is available now" };
1121
+ } catch (e) {
1122
+ const body = e?.data ?? e?.response?.data ?? e?.body ?? {};
1123
+ return {
1124
+ available_actions: body.available_actions ?? [],
1125
+ why_not_claim_refund: body.message,
1126
+ note: (body.available_actions?.length ?? 0) === 0 ? "Nothing is refundable right now. cancel needs the hiring window still open; emergency_refund needs the settlement deadline to have passed." : "Re-run with one of available_actions."
1127
+ };
1128
+ }
1129
+ }
1130
+ const built = await apiPost2(
1131
+ `/agent/solana/missions/${args.mission_id}/refund-transaction`,
1132
+ {
1133
+ action: args.action,
1134
+ sponsor_wallet: wallet.publicKey,
1135
+ sponsor_token_account: tokenAccount
1136
+ }
1137
+ );
1138
+ const { Transaction, PublicKey } = await import("@solana/web3.js");
1139
+ const tx = Transaction.from(Buffer.from(built.transaction_base64, "base64"));
1140
+ const problems = [];
1141
+ if (tx.instructions.length !== 1) {
1142
+ problems.push(`expected 1 instruction, found ${tx.instructions.length}`);
1143
+ }
1144
+ const ix = tx.instructions[0];
1145
+ if (ix?.programId?.toBase58() !== cfg.program_id) {
1146
+ problems.push(`program is ${ix?.programId?.toBase58()}, expected ${cfg.program_id}`);
1147
+ }
1148
+ const signers = (ix?.keys ?? []).filter((k) => k.isSigner).map((k) => k.pubkey.toBase58());
1149
+ if (signers.length !== 1 || signers[0] !== wallet.publicKey) {
1150
+ problems.push(`expected only ${wallet.publicKey} to sign, found [${signers.join(", ")}]`);
1151
+ }
1152
+ const keys = (ix?.keys ?? []).map((k) => k.pubkey.toBase58());
1153
+ if (!keys.includes(tokenAccount)) {
1154
+ problems.push(
1155
+ `the refund destination ${tokenAccount} is not in the transaction \u2014 the funds would go elsewhere`
1156
+ );
1157
+ }
1158
+ if ((ix?.data?.length ?? 0) !== 8) {
1159
+ problems.push(`instruction data is ${ix?.data?.length} bytes, expected 8`);
1160
+ }
1161
+ void PublicKey;
1162
+ if (problems.length > 0) {
1163
+ return {
1164
+ refunded: false,
1165
+ refused_to_sign: true,
1166
+ problems,
1167
+ 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."
1168
+ };
1169
+ }
1170
+ if (args.dry_run) {
1171
+ return {
1172
+ refunded: false,
1173
+ dry_run: true,
1174
+ verified: true,
1175
+ action: args.action,
1176
+ task_id: built.task_id,
1177
+ destination: tokenAccount
1178
+ };
1179
+ }
1180
+ const signed = await signTransaction(built.transaction_base64, wallet);
1181
+ const confirmed = await apiPost2(
1182
+ `/agent/solana/missions/${args.mission_id}/confirm-refund`,
1183
+ { signed_transaction: signed, action: args.action }
1184
+ );
1185
+ return { refunded: true, verified: true, ...confirmed };
1186
+ }
1087
1187
  default:
1088
1188
  throw new Error(`Unknown Solana tool: ${name}`);
1089
1189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectsolo/solo-mission-mcp",
3
- "version": "0.20.1",
3
+ "version": "0.21.2",
4
4
  "description": "MCP server for Solo Mission Platform \u2014 lets AI agents create missions, browse humans, and chat.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -122,10 +122,46 @@ try {
122
122
  process.exit(1);
123
123
  }
124
124
 
125
- const uncalled = specRoutes.filter((route) => !called.has(route) && !route.endsWith('/agent/openapi.json'));
125
+ /**
126
+ * Whether a documented route is this package's responsibility at all.
127
+ *
128
+ * This is an AGENT-facing package: every tool authenticates with X-Agent-Key. A human-authenticated
129
+ * route is out of scope by design — an agent has no Firebase user token and could not call it if a
130
+ * tool existed.
131
+ *
132
+ * The check's original premise, "every route the spec documents must be called by something here",
133
+ * held only while the spec happened to annotate agent routes exclusively. It broke the moment
134
+ * solo-firebase documented `GET /human/solana/rewards` and `POST /profile/wallet/solana/bind` —
135
+ * correctly, since a published API should describe its human surface too. The Base equivalents
136
+ * (`/human/rewards`, `/profile/wallet/bind`) are simply not annotated yet, which is why this never
137
+ * fired before rather than the rule ever having been right.
138
+ *
139
+ * Scoped by path prefix rather than an allowlist of specific routes, so the next human endpoint
140
+ * does not fail a release for the same reason.
141
+ */
142
+ function isAgentScoped(route: string): boolean {
143
+ const path = route.split(' ')[1] ?? '';
144
+ return path.startsWith('/agent') || path.startsWith('/missions') || path.startsWith('/humans');
145
+ }
146
+
147
+ const uncalled = specRoutes.filter(
148
+ (route) =>
149
+ !called.has(route) &&
150
+ !route.endsWith('/agent/openapi.json') &&
151
+ isAgentScoped(route),
152
+ );
153
+
154
+ const outOfScope = specRoutes.filter((route) => !isAgentScoped(route));
155
+ if (outOfScope.length > 0) {
156
+ // Reported, not silent: a route landing here because it is genuinely human-facing is fine, but
157
+ // one landing here because an agent route was misnamed would otherwise vanish from the check.
158
+ console.log('Human-authenticated routes, out of scope for this agent-facing package:');
159
+ for (const route of outOfScope) console.log(` - ${route}`);
160
+ console.log('');
161
+ }
126
162
 
127
163
  if (uncalled.length > 0) {
128
- console.error("The live API spec documents routes that no tool in src/tools/*.ts calls:");
164
+ console.error("The live API spec documents AGENT routes that no tool in src/tools/*.ts calls:");
129
165
  for (const route of uncalled) console.error(` - ${route}`);
130
166
  console.error(`\nSpec source: ${SPEC_URL}`);
131
167
  console.error("Either a tool's REST call was changed/removed without updating this check's");
@@ -63,6 +63,28 @@ export const solanaTools: Tool[] = [
63
63
  required: ['mission_id', 'expected_budget'],
64
64
  },
65
65
  },
66
+ {
67
+ name: 'refund_solana_mission',
68
+ description:
69
+ "Get a Solana mission's escrowed funds back to the sponsor. Covers all three routes: cancel (while funded, before hiring closes), emergency_refund (once the settlement deadline passes and the platform has not settled), and claim_refund (leftover budget after a partial settle).\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 — the response lists the legal actions for the mission's state rather than guessing. Without this tool a funded Solana mission's money was unreachable except by hand-assembling an Anchor instruction, for which Solana has no `cast send` equivalent.",
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ mission_id: { type: 'string' },
74
+ action: {
75
+ type: 'string',
76
+ enum: ['cancel', 'emergency_refund', 'claim_refund'],
77
+ description:
78
+ 'Omit to query what is available for this mission right now instead of attempting one.',
79
+ },
80
+ dry_run: {
81
+ type: 'boolean',
82
+ description: 'Build and verify without signing or submitting. Nothing moves, no fee.',
83
+ },
84
+ },
85
+ required: ['mission_id'],
86
+ },
87
+ },
66
88
  ];
67
89
 
68
90
  /**
@@ -248,6 +270,115 @@ export async function handleSolanaTool(
248
270
  return { funded: true, verified: true, ...(confirmed as Record<string, unknown>) };
249
271
  }
250
272
 
273
+ case 'refund_solana_mission': {
274
+ const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import(
275
+ '../solana/wallet.js'
276
+ );
277
+ const wallet = await loadSolanaWallet();
278
+ const cfg = (await apiGet('/agent/solana/config')) as {
279
+ program_id: string;
280
+ mints: Record<string, string>;
281
+ };
282
+ const mint = cfg.mints.TEST_USDC ?? Object.values(cfg.mints)[0];
283
+ const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
284
+
285
+ // No action: ask what is legal rather than guessing and getting a 409. Uses a deliberately
286
+ // invalid action so the backend answers with available_actions for this mission's state.
287
+ if (!args.action) {
288
+ try {
289
+ await apiPost(`/agent/solana/missions/${args.mission_id}/refund-transaction`, {
290
+ action: 'claim_refund',
291
+ sponsor_wallet: wallet.publicKey,
292
+ sponsor_token_account: tokenAccount,
293
+ });
294
+ return { available_actions: ['claim_refund'], note: 'claim_refund is available now' };
295
+ } catch (e: any) {
296
+ // ApiResponseError carries the parsed body on `.data`; the other shapes are kept for
297
+ // any client that wraps differently. Reading only the latter silently yielded an empty
298
+ // available_actions, i.e. a false "nothing is refundable" on a mission that could cancel.
299
+ const body: any = e?.data ?? e?.response?.data ?? e?.body ?? {};
300
+ return {
301
+ available_actions: body.available_actions ?? [],
302
+ why_not_claim_refund: body.message,
303
+ note:
304
+ (body.available_actions?.length ?? 0) === 0
305
+ ? 'Nothing is refundable right now. cancel needs the hiring window still open; ' +
306
+ 'emergency_refund needs the settlement deadline to have passed.'
307
+ : 'Re-run with one of available_actions.',
308
+ };
309
+ }
310
+ }
311
+
312
+ const built = (await apiPost(
313
+ `/agent/solana/missions/${args.mission_id}/refund-transaction`,
314
+ {
315
+ action: args.action,
316
+ sponsor_wallet: wallet.publicKey,
317
+ sponsor_token_account: tokenAccount,
318
+ },
319
+ )) as { transaction_base64: string; task_id: string };
320
+
321
+ // Verify before signing, same rule as funding: a headless agent has no wallet UI, so an
322
+ // unverified blob is bytes it cannot read. A refund moves the WHOLE escrow, so the checks
323
+ // that matter are that it is our program, we are the only signer, and the tokens land in our
324
+ // own token account.
325
+ const { Transaction, PublicKey } = await import('@solana/web3.js');
326
+ const tx = Transaction.from(Buffer.from(built.transaction_base64, 'base64'));
327
+ const problems: string[] = [];
328
+ if (tx.instructions.length !== 1) {
329
+ problems.push(`expected 1 instruction, found ${tx.instructions.length}`);
330
+ }
331
+ const ix = tx.instructions[0];
332
+ if (ix?.programId?.toBase58() !== cfg.program_id) {
333
+ problems.push(`program is ${ix?.programId?.toBase58()}, expected ${cfg.program_id}`);
334
+ }
335
+ const signers = (ix?.keys ?? []).filter((k) => k.isSigner).map((k) => k.pubkey.toBase58());
336
+ if (signers.length !== 1 || signers[0] !== wallet.publicKey) {
337
+ problems.push(`expected only ${wallet.publicKey} to sign, found [${signers.join(', ')}]`);
338
+ }
339
+ const keys = (ix?.keys ?? []).map((k) => k.pubkey.toBase58());
340
+ if (!keys.includes(tokenAccount)) {
341
+ problems.push(
342
+ `the refund destination ${tokenAccount} is not in the transaction — the funds would go elsewhere`,
343
+ );
344
+ }
345
+ // No arguments on any of the three instructions, so the data is exactly the 8-byte
346
+ // discriminator. Anything longer means something was appended.
347
+ if ((ix?.data?.length ?? 0) !== 8) {
348
+ problems.push(`instruction data is ${ix?.data?.length} bytes, expected 8`);
349
+ }
350
+ void PublicKey;
351
+
352
+ if (problems.length > 0) {
353
+ return {
354
+ refunded: false,
355
+ refused_to_sign: true,
356
+ problems,
357
+ what_this_means:
358
+ 'The refund transaction does not match what was asked for, so it was NOT signed and ' +
359
+ 'nothing moved. Do not retry blindly.',
360
+ };
361
+ }
362
+
363
+ if (args.dry_run) {
364
+ return {
365
+ refunded: false,
366
+ dry_run: true,
367
+ verified: true,
368
+ action: args.action,
369
+ task_id: built.task_id,
370
+ destination: tokenAccount,
371
+ };
372
+ }
373
+
374
+ const signed = await signTransaction(built.transaction_base64, wallet);
375
+ const confirmed = await apiPost(
376
+ `/agent/solana/missions/${args.mission_id}/confirm-refund`,
377
+ { signed_transaction: signed, action: args.action },
378
+ );
379
+ return { refunded: true, verified: true, ...(confirmed as Record<string, unknown>) };
380
+ }
381
+
251
382
  default:
252
383
  throw new Error(`Unknown Solana tool: ${name}`);
253
384
  }