@projectsolo/solo-mission-mcp 0.19.2 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -1,2 +1,2 @@
1
1
  SOLO_AGENT_KEY=your-agent-api-key-here
2
- SOLO_MISSION_API_URL=https://api.mission.projectsolo.xyz
2
+ SOLO_MISSION_API_URL=https://api.mission.projectsolo.ai
@@ -1,14 +1,21 @@
1
1
  name: Release
2
2
 
3
+ # Tag prefix picks the runner: v1.2.3 runs on GitHub-hosted ubuntu-latest as
4
+ # before; u1.2.3 runs the identical job on our self-hosted VPS runner
5
+ # instead (registered with labels self-hosted,solo_mission_mcp-vps), to
6
+ # avoid burning GitHub-hosted runner minutes. Same steps, same OIDC identity
7
+ # for npm Trusted Publishing either way — the OIDC token is issued by
8
+ # GitHub's Actions service to the job, not tied to which runner executes it.
3
9
  on:
4
10
  push:
5
11
  tags:
6
12
  - 'v[0-9]+.[0-9]+.[0-9]+'
13
+ - 'u[0-9]+.[0-9]+.[0-9]+'
7
14
 
8
15
  jobs:
9
16
  publish:
10
17
  name: Build & Publish to npm
11
- runs-on: ubuntu-latest
18
+ runs-on: ${{ startsWith(github.ref_name, 'u') && fromJSON('["self-hosted","solo_mission_mcp-vps"]') || 'ubuntu-latest' }}
12
19
  permissions:
13
20
  contents: read
14
21
  id-token: write
@@ -37,12 +44,15 @@ jobs:
37
44
  - name: Build
38
45
  run: npm run build
39
46
 
47
+ - name: Check tool coverage against live API spec
48
+ run: npx tsx src/scripts/check-tools-against-spec.ts
49
+
40
50
  - name: Verify tag matches package.json version
41
51
  run: |
42
- TAG_VERSION="${GITHUB_REF_NAME#v}"
52
+ TAG_VERSION="${GITHUB_REF_NAME#[uv]}"
43
53
  PKG_VERSION="$(node -p "require('./package.json').version")"
44
54
  if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
45
- echo "Tag v$TAG_VERSION does not match package.json version $PKG_VERSION"
55
+ echo "Tag $GITHUB_REF_NAME does not match package.json version $PKG_VERSION"
46
56
  exit 1
47
57
  fi
48
58
 
@@ -0,0 +1,79 @@
1
+ // src/config.ts
2
+ import "dotenv/config";
3
+ var config = {
4
+ agentKey: process.env.SOLO_AGENT_KEY ?? "",
5
+ apiUrl: process.env.SOLO_MISSION_API_URL ?? "https://api.mission.projectsolo.ai"
6
+ };
7
+ if (!config.agentKey) {
8
+ console.warn("Warning: SOLO_AGENT_KEY is not set. Only register_agent will work until a key is configured.");
9
+ }
10
+
11
+ // src/api/client.ts
12
+ var DEFAULT_HEADERS = {
13
+ "Content-Type": "application/json",
14
+ "X-Agent-Key": config.agentKey
15
+ };
16
+ var ApiResponseError = class extends Error {
17
+ status;
18
+ data;
19
+ constructor(status, data) {
20
+ const msg = data?.message || data?.error || `Request failed with status ${status}`;
21
+ super(status === 429 ? "Rate limit exceeded. Please slow down and retry after a moment." : msg);
22
+ this.status = status;
23
+ this.data = data;
24
+ }
25
+ };
26
+ async function parseErrorResponse(response) {
27
+ const data = await response.json().catch(() => ({}));
28
+ throw new ApiResponseError(response.status, data);
29
+ }
30
+ async function apiGet(path, params) {
31
+ const url = new URL(`${config.apiUrl}${path}`);
32
+ if (params) {
33
+ for (const [key, value] of Object.entries(params)) {
34
+ if (value != null) url.searchParams.set(key, String(value));
35
+ }
36
+ }
37
+ const response = await fetch(url.toString(), {
38
+ headers: DEFAULT_HEADERS,
39
+ signal: AbortSignal.timeout(3e4)
40
+ });
41
+ if (!response.ok) return parseErrorResponse(response);
42
+ return response.json();
43
+ }
44
+ async function apiPost(path, body) {
45
+ const response = await fetch(`${config.apiUrl}${path}`, {
46
+ method: "POST",
47
+ headers: DEFAULT_HEADERS,
48
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
49
+ signal: AbortSignal.timeout(3e4)
50
+ });
51
+ if (!response.ok) return parseErrorResponse(response);
52
+ return response.json();
53
+ }
54
+ async function apiDelete(path) {
55
+ const response = await fetch(`${config.apiUrl}${path}`, {
56
+ method: "DELETE",
57
+ headers: DEFAULT_HEADERS,
58
+ signal: AbortSignal.timeout(3e4)
59
+ });
60
+ if (!response.ok) return parseErrorResponse(response);
61
+ return response.json();
62
+ }
63
+ async function publicApiPost(path, body) {
64
+ const response = await fetch(`${config.apiUrl}${path}`, {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
68
+ signal: AbortSignal.timeout(3e4)
69
+ });
70
+ if (!response.ok) return parseErrorResponse(response);
71
+ return response.json();
72
+ }
73
+
74
+ export {
75
+ apiGet,
76
+ apiPost,
77
+ apiDelete,
78
+ publicApiPost
79
+ };
@@ -0,0 +1,12 @@
1
+ import {
2
+ apiDelete,
3
+ apiGet,
4
+ apiPost,
5
+ publicApiPost
6
+ } from "./chunk-NXOOPOSF.js";
7
+ export {
8
+ apiDelete,
9
+ apiGet,
10
+ apiPost,
11
+ publicApiPost
12
+ };
package/dist/index.js CHANGED
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ apiDelete,
4
+ apiGet,
5
+ apiPost,
6
+ publicApiPost
7
+ } from "./chunk-NXOOPOSF.js";
2
8
 
3
9
  // src/index.ts
4
10
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -8,79 +14,6 @@ import {
8
14
  ListToolsRequestSchema
9
15
  } from "@modelcontextprotocol/sdk/types.js";
10
16
 
11
- // src/config.ts
12
- import "dotenv/config";
13
- var config = {
14
- agentKey: process.env.SOLO_AGENT_KEY ?? "",
15
- apiUrl: process.env.SOLO_MISSION_API_URL ?? "https://api.mission.projectsolo.ai"
16
- };
17
- if (!config.agentKey) {
18
- console.warn("Warning: SOLO_AGENT_KEY is not set. Only register_agent will work until a key is configured.");
19
- }
20
-
21
- // src/api/client.ts
22
- var DEFAULT_HEADERS = {
23
- "Content-Type": "application/json",
24
- "X-Agent-Key": config.agentKey
25
- };
26
- var ApiResponseError = class extends Error {
27
- status;
28
- data;
29
- constructor(status, data) {
30
- const msg = data?.message || data?.error || `Request failed with status ${status}`;
31
- super(status === 429 ? "Rate limit exceeded. Please slow down and retry after a moment." : msg);
32
- this.status = status;
33
- this.data = data;
34
- }
35
- };
36
- async function parseErrorResponse(response) {
37
- const data = await response.json().catch(() => ({}));
38
- throw new ApiResponseError(response.status, data);
39
- }
40
- async function apiGet(path, params) {
41
- const url = new URL(`${config.apiUrl}${path}`);
42
- if (params) {
43
- for (const [key, value] of Object.entries(params)) {
44
- if (value != null) url.searchParams.set(key, String(value));
45
- }
46
- }
47
- const response = await fetch(url.toString(), {
48
- headers: DEFAULT_HEADERS,
49
- signal: AbortSignal.timeout(3e4)
50
- });
51
- if (!response.ok) return parseErrorResponse(response);
52
- return response.json();
53
- }
54
- async function apiPost(path, body) {
55
- const response = await fetch(`${config.apiUrl}${path}`, {
56
- method: "POST",
57
- headers: DEFAULT_HEADERS,
58
- body: body !== void 0 ? JSON.stringify(body) : void 0,
59
- signal: AbortSignal.timeout(3e4)
60
- });
61
- if (!response.ok) return parseErrorResponse(response);
62
- return response.json();
63
- }
64
- async function apiDelete(path) {
65
- const response = await fetch(`${config.apiUrl}${path}`, {
66
- method: "DELETE",
67
- headers: DEFAULT_HEADERS,
68
- signal: AbortSignal.timeout(3e4)
69
- });
70
- if (!response.ok) return parseErrorResponse(response);
71
- return response.json();
72
- }
73
- async function publicApiPost(path, body) {
74
- const response = await fetch(`${config.apiUrl}${path}`, {
75
- method: "POST",
76
- headers: { "Content-Type": "application/json" },
77
- body: body !== void 0 ? JSON.stringify(body) : void 0,
78
- signal: AbortSignal.timeout(3e4)
79
- });
80
- if (!response.ok) return parseErrorResponse(response);
81
- return response.json();
82
- }
83
-
84
17
  // src/tools/missions.ts
85
18
  var missionTools = [
86
19
  {
@@ -982,8 +915,182 @@ async function handleTrackTool(name, args) {
982
915
  }
983
916
  }
984
917
 
918
+ // src/tools/solana.ts
919
+ var solanaTools = [
920
+ {
921
+ name: "get_solana_config",
922
+ description: "Read the Solana escrow deployment: program id, cluster, RPC endpoint, accepted mints and their decimals, and the minimum first payout. 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.",
923
+ inputSchema: { type: "object", properties: {} }
924
+ },
925
+ {
926
+ name: "get_solana_wallet",
927
+ 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 has no equivalent on Base. 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.",
928
+ inputSchema: {
929
+ type: "object",
930
+ properties: {
931
+ mint: {
932
+ type: "string",
933
+ description: "Mint to report a balance for. Defaults to the deployment payout mint from get_solana_config."
934
+ }
935
+ }
936
+ }
937
+ },
938
+ {
939
+ name: "fund_solana_mission",
940
+ 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, an extra instruction, a vault that is not a program-derived address \u2014 and returns the discrepancies instead.\n\nCall create_mission with chain='solana' first; pass that mission's id here.",
941
+ inputSchema: {
942
+ type: "object",
943
+ properties: {
944
+ mission_id: { type: "string", description: 'Mission created with chain="solana".' },
945
+ expected_budget: {
946
+ type: "number",
947
+ 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."
948
+ },
949
+ expected_mint: {
950
+ type: "string",
951
+ description: "The mint you expect the budget to be taken in. Verified before signing. Defaults to the deployment payout mint."
952
+ },
953
+ dry_run: {
954
+ type: "boolean",
955
+ 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."
956
+ }
957
+ },
958
+ required: ["mission_id", "expected_budget"]
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
+ async function handleSolanaTool(name, args) {
977
+ const { apiGet: apiGet2, apiPost: apiPost2 } = await import("./client-2NLDPRAH.js");
978
+ switch (name) {
979
+ case "get_solana_config":
980
+ return apiGet2("/agent/solana/config");
981
+ case "get_solana_wallet": {
982
+ const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import("./wallet-IUQWBW6F.js");
983
+ if (!hasSolanaWallet()) {
984
+ return {
985
+ configured: false,
986
+ how_to_configure: {
987
+ option_1: "SOLO_SOLANA_KEYPAIR \u2014 JSON byte array, as `solana-keygen new` writes it",
988
+ option_2: "SOLO_SOLANA_KEYPAIR_PATH \u2014 path to that file, e.g. ~/.config/solana/id.json"
989
+ },
990
+ 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."
991
+ };
992
+ }
993
+ const wallet = await loadSolanaWallet();
994
+ const cfg = await apiGet2("/agent/solana/config");
995
+ const symbol = Object.keys(cfg.mints)[0];
996
+ const mint = args.mint ?? cfg.mints[symbol];
997
+ const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
998
+ const rpc = async (method, params) => {
999
+ const res = await fetch(cfg.rpc_url, {
1000
+ method: "POST",
1001
+ headers: { "Content-Type": "application/json" },
1002
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params })
1003
+ });
1004
+ return await res.json();
1005
+ };
1006
+ const solRes = await rpc("getBalance", [wallet.publicKey]);
1007
+ const tokRes = await rpc("getTokenAccountBalance", [tokenAccount]);
1008
+ const lamports = solRes.result?.value ?? 0;
1009
+ const decimals = cfg.decimals[symbol] ?? 6;
1010
+ return {
1011
+ configured: true,
1012
+ address: wallet.publicKey,
1013
+ sol: lamports / 1e9,
1014
+ // A token account only exists once tokens first arrive. create_task reads it, so funding
1015
+ // fails without one — and the on-chain error names the account rather than the missing
1016
+ // balance, which is confusing enough to call out explicitly.
1017
+ token_account: tokenAccount,
1018
+ token_account_exists: !tokRes.error,
1019
+ token_balance: tokRes.result?.value?.uiAmountString ?? "0",
1020
+ mint,
1021
+ can_pay_fees: lamports > 1e7,
1022
+ // ~0.01 SOL — comfortably covers rent plus fees
1023
+ 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,
1024
+ _decimals: decimals
1025
+ };
1026
+ }
1027
+ case "fund_solana_mission": {
1028
+ const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-IUQWBW6F.js");
1029
+ const { verifyFundingTransaction } = await import("./verify-KAETIGV5.js");
1030
+ const wallet = await loadSolanaWallet();
1031
+ const cfg = await apiGet2("/agent/solana/config");
1032
+ const symbol = Object.keys(cfg.mints)[0];
1033
+ const mint = args.expected_mint ?? cfg.mints[symbol];
1034
+ const decimals = cfg.decimals[symbol] ?? 6;
1035
+ const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
1036
+ const built = await apiPost2(
1037
+ `/agent/solana/missions/${args.mission_id}/funding-transaction`,
1038
+ { sponsor_wallet: wallet.publicKey, sponsor_token_account: tokenAccount }
1039
+ );
1040
+ const expectedBudgetRaw = toRawAmount(args.expected_budget, decimals);
1041
+ const verdict = await verifyFundingTransaction({
1042
+ transaction_base64: built.transaction_base64,
1043
+ declared: built.declared,
1044
+ accounts: built.accounts,
1045
+ expected: {
1046
+ budget_raw: expectedBudgetRaw,
1047
+ // base_pool is derived by the backend from reward × max_humans. The agent's check on it
1048
+ // is the quoted value against the encoded bytes, which verifyFundingTransaction does —
1049
+ // asserting a locally recomputed figure would require duplicating that arithmetic here
1050
+ // and would fail on a legitimately rounded reward.
1051
+ base_pool_raw: String(built.declared.base_pool),
1052
+ lottery_winner_count: Number(built.declared.lottery_winner_count),
1053
+ lottery_prize_per_winner_raw: String(built.declared.lottery_prize_per_winner),
1054
+ qualify_deadline: Number(built.declared.qualify_deadline),
1055
+ settlement_deadline: Number(built.declared.settlement_deadline),
1056
+ mint,
1057
+ sponsor: wallet.publicKey
1058
+ },
1059
+ expected_program_id: cfg.program_id
1060
+ });
1061
+ if (!verdict.ok) {
1062
+ return {
1063
+ funded: false,
1064
+ refused_to_sign: true,
1065
+ problems: verdict.problems,
1066
+ summary: verdict.summary,
1067
+ 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."
1068
+ };
1069
+ }
1070
+ if (args.dry_run) {
1071
+ return {
1072
+ funded: false,
1073
+ dry_run: true,
1074
+ verified: true,
1075
+ task_id: built.task_id,
1076
+ summary: verdict.summary,
1077
+ would_escrow: `${args.expected_budget} (${expectedBudgetRaw} raw) of ${mint}`
1078
+ };
1079
+ }
1080
+ const signed = await signTransaction(built.transaction_base64, wallet);
1081
+ const confirmed = await apiPost2(
1082
+ `/agent/solana/missions/${args.mission_id}/confirm-funding`,
1083
+ { signed_transaction: signed, task_id: built.task_id }
1084
+ );
1085
+ return { funded: true, verified: true, ...confirmed };
1086
+ }
1087
+ default:
1088
+ throw new Error(`Unknown Solana tool: ${name}`);
1089
+ }
1090
+ }
1091
+
985
1092
  // src/index.ts
986
- var ALL_TOOLS = [...agentTools, ...missionTools, ...humanTools, ...conversationTools, ...realtimeTools, ...trackTools];
1093
+ var ALL_TOOLS = [...agentTools, ...missionTools, ...humanTools, ...conversationTools, ...realtimeTools, ...trackTools, ...solanaTools];
987
1094
  var AGENT_TOOL_NAMES = new Set(agentTools.map((t) => t.name));
988
1095
  var MISSION_TOOL_NAMES = new Set(missionTools.map((t) => t.name));
989
1096
  var HUMAN_TOOL_NAMES = new Set(humanTools.map((t) => t.name));
@@ -1013,6 +1120,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1013
1120
  result = await handleRealtimeTool(name, args);
1014
1121
  } else if (TRACK_TOOL_NAMES.has(name)) {
1015
1122
  result = await handleTrackTool(name, args);
1123
+ } else if (SOLANA_TOOL_NAMES.has(name)) {
1124
+ result = await handleSolanaTool(name, args);
1016
1125
  } else {
1017
1126
  return {
1018
1127
  content: [{ type: "text", text: `Unknown tool: ${name}` }],
@@ -0,0 +1,136 @@
1
+ // src/solana/verify.ts
2
+ var eqAmount = (a, b) => {
3
+ try {
4
+ return BigInt(a) === BigInt(b);
5
+ } catch {
6
+ return false;
7
+ }
8
+ };
9
+ async function verifyFundingTransaction(input) {
10
+ const { Transaction, PublicKey } = await import("@solana/web3.js");
11
+ const problems = [];
12
+ const tx = Transaction.from(Buffer.from(input.transaction_base64, "base64"));
13
+ if (tx.instructions.length !== 1) {
14
+ problems.push(
15
+ `expected exactly 1 instruction, found ${tx.instructions.length} \u2014 additional instructions would be authorised by the same signature`
16
+ );
17
+ }
18
+ const ix = tx.instructions[0];
19
+ const programId = ix?.programId?.toBase58() ?? "(none)";
20
+ if (programId !== input.expected_program_id) {
21
+ problems.push(`program is ${programId}, expected ${input.expected_program_id}`);
22
+ }
23
+ if (programId !== input.accounts.program_id) {
24
+ problems.push(
25
+ `transaction program ${programId} disagrees with the quoted accounts.program_id ${input.accounts.program_id}`
26
+ );
27
+ }
28
+ const signers = (ix?.keys ?? []).filter((k) => k.isSigner).map((k) => k.pubkey.toBase58());
29
+ if (signers.length !== 1 || signers[0] !== input.expected.sponsor) {
30
+ problems.push(
31
+ `expected the sponsor ${input.expected.sponsor} to be the only signer, found [${signers.join(", ")}]`
32
+ );
33
+ }
34
+ if (tx.feePayer?.toBase58() !== input.expected.sponsor) {
35
+ problems.push(
36
+ `fee payer is ${tx.feePayer?.toBase58() ?? "(unset)"}, expected ${input.expected.sponsor}`
37
+ );
38
+ }
39
+ const keyList = (ix?.keys ?? []).map((k) => k.pubkey.toBase58());
40
+ for (const [label, expected] of [
41
+ ["mint", input.expected.mint],
42
+ ["sponsor", input.expected.sponsor]
43
+ ]) {
44
+ if (!keyList.includes(expected)) {
45
+ problems.push(`${label} ${expected} does not appear in the transaction's accounts`);
46
+ }
47
+ }
48
+ if (input.accounts.mint !== input.expected.mint) {
49
+ problems.push(`quoted mint ${input.accounts.mint}, expected ${input.expected.mint}`);
50
+ }
51
+ if (input.accounts.vault === input.accounts.sponsor_token_account) {
52
+ problems.push("escrow vault equals the sponsor token account \u2014 the budget would not be escrowed");
53
+ }
54
+ try {
55
+ for (const [label, addr] of [
56
+ ["task", input.accounts.task],
57
+ ["vault", input.accounts.vault]
58
+ ]) {
59
+ if (PublicKey.isOnCurve(new PublicKey(addr).toBytes())) {
60
+ problems.push(`${label} ${addr} is not a program-derived address \u2014 someone holds its key`);
61
+ }
62
+ }
63
+ } catch {
64
+ problems.push("task or vault is not a valid address");
65
+ }
66
+ const d = input.declared;
67
+ const e = input.expected;
68
+ if (!eqAmount(d.budget, e.budget_raw)) {
69
+ problems.push(`budget is ${d.budget}, expected ${e.budget_raw}`);
70
+ }
71
+ if (!eqAmount(d.base_pool, e.base_pool_raw)) {
72
+ problems.push(`base_pool is ${d.base_pool}, expected ${e.base_pool_raw}`);
73
+ }
74
+ if (d.lottery_winner_count !== e.lottery_winner_count) {
75
+ problems.push(
76
+ `lottery_winner_count is ${d.lottery_winner_count}, expected ${e.lottery_winner_count}`
77
+ );
78
+ }
79
+ if (!eqAmount(d.lottery_prize_per_winner, e.lottery_prize_per_winner_raw)) {
80
+ problems.push(
81
+ `lottery_prize_per_winner is ${d.lottery_prize_per_winner}, expected ${e.lottery_prize_per_winner_raw}`
82
+ );
83
+ }
84
+ if (!eqAmount(d.qualify_deadline, String(e.qualify_deadline))) {
85
+ problems.push(`qualify_deadline is ${d.qualify_deadline}, expected ${e.qualify_deadline}`);
86
+ }
87
+ if (!eqAmount(d.settlement_deadline, String(e.settlement_deadline))) {
88
+ problems.push(
89
+ `settlement_deadline is ${d.settlement_deadline}, expected ${e.settlement_deadline}`
90
+ );
91
+ }
92
+ if (!/^[0-9a-f]{64}$/i.test(d.seed_commit)) {
93
+ problems.push(`seed_commit is not 32 bytes of hex: ${d.seed_commit}`);
94
+ }
95
+ const data = ix?.data ?? Buffer.alloc(0);
96
+ if (data.length !== 8 + 8 + 8 + 4 + 8 + 8 + 8 + 32) {
97
+ problems.push(
98
+ `instruction data is ${data.length} bytes, expected 84 for create_task \u2014 this is not the instruction it claims to be`
99
+ );
100
+ } else {
101
+ const encoded = {
102
+ budget: data.readBigUInt64LE(8).toString(),
103
+ base_pool: data.readBigUInt64LE(16).toString(),
104
+ lottery_winner_count: data.readUInt32LE(24),
105
+ lottery_prize_per_winner: data.readBigUInt64LE(28).toString(),
106
+ qualify_deadline: data.readBigInt64LE(36).toString(),
107
+ settlement_deadline: data.readBigInt64LE(44).toString(),
108
+ seed_commit: data.subarray(52, 84).toString("hex")
109
+ };
110
+ for (const key of Object.keys(encoded)) {
111
+ const inBytes = String(encoded[key]);
112
+ const quoted = String(d[key]);
113
+ const same = key === "seed_commit" || key === "lottery_winner_count" ? inBytes.toLowerCase() === quoted.toLowerCase() : eqAmount(inBytes, quoted);
114
+ if (!same) {
115
+ problems.push(
116
+ `the transaction encodes ${key}=${inBytes} but the response quoted ${quoted} \u2014 the backend described one thing and built another`
117
+ );
118
+ }
119
+ }
120
+ }
121
+ return {
122
+ ok: problems.length === 0,
123
+ problems,
124
+ summary: {
125
+ program_id: programId,
126
+ instruction_count: tx.instructions.length,
127
+ signers,
128
+ budget: d.budget,
129
+ mint: input.accounts.mint,
130
+ task: input.accounts.task
131
+ }
132
+ };
133
+ }
134
+ export {
135
+ verifyFundingTransaction
136
+ };
@@ -0,0 +1,90 @@
1
+ // src/solana/wallet.ts
2
+ import { readFileSync } from "fs";
3
+ var SolanaWalletUnavailable = class extends Error {
4
+ constructor(reason) {
5
+ super(
6
+ `Solana wallet unavailable: ${reason}
7
+
8
+ Set one of:
9
+ SOLO_SOLANA_KEYPAIR - JSON byte array, as \`solana-keygen\` writes it
10
+ SOLO_SOLANA_KEYPAIR_PATH - path to that file (e.g. ~/.config/solana/id.json)
11
+
12
+ The wallet needs SOL for rent and fees, and USDC for the mission budget. Rent is a
13
+ refundable deposit, not a fee: most of it returns when the task is closed.`
14
+ );
15
+ this.name = "SolanaWalletUnavailable";
16
+ }
17
+ };
18
+ function parseKeypairBytes(raw) {
19
+ const trimmed = raw.trim();
20
+ if (!trimmed.startsWith("[")) {
21
+ throw new SolanaWalletUnavailable(
22
+ "value is not a JSON byte array \u2014 this is the format `solana-keygen new` writes"
23
+ );
24
+ }
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(trimmed);
28
+ } catch {
29
+ throw new SolanaWalletUnavailable("value looks like a JSON array but does not parse");
30
+ }
31
+ if (!Array.isArray(parsed) || !parsed.every((n) => typeof n === "number")) {
32
+ throw new SolanaWalletUnavailable("JSON array must contain only numbers");
33
+ }
34
+ const bytes = Uint8Array.from(parsed);
35
+ if (bytes.length !== 64) {
36
+ throw new SolanaWalletUnavailable(
37
+ `expected 64 bytes, got ${bytes.length}` + (bytes.length === 32 ? " \u2014 this is the seed alone, not the full keypair" : "")
38
+ );
39
+ }
40
+ return bytes;
41
+ }
42
+ async function loadSolanaWallet() {
43
+ const inline = process.env.SOLO_SOLANA_KEYPAIR;
44
+ const path = process.env.SOLO_SOLANA_KEYPAIR_PATH;
45
+ let raw;
46
+ if (inline && inline.trim() !== "") {
47
+ raw = inline;
48
+ } else if (path && path.trim() !== "") {
49
+ try {
50
+ raw = readFileSync(path.replace(/^~/, process.env.HOME ?? "~"), "utf8");
51
+ } catch (e) {
52
+ throw new SolanaWalletUnavailable(`cannot read ${path}: ${e.message}`);
53
+ }
54
+ } else {
55
+ throw new SolanaWalletUnavailable("neither SOLO_SOLANA_KEYPAIR nor SOLO_SOLANA_KEYPAIR_PATH is set");
56
+ }
57
+ const secretKey = parseKeypairBytes(raw);
58
+ const { Keypair } = await import("@solana/web3.js");
59
+ const kp = Keypair.fromSecretKey(secretKey);
60
+ return { publicKey: kp.publicKey.toBase58(), secretKey };
61
+ }
62
+ function hasSolanaWallet() {
63
+ return Boolean(
64
+ (process.env.SOLO_SOLANA_KEYPAIR ?? "").trim() || (process.env.SOLO_SOLANA_KEYPAIR_PATH ?? "").trim()
65
+ );
66
+ }
67
+ async function associatedTokenAddress(mint, owner) {
68
+ const { PublicKey } = await import("@solana/web3.js");
69
+ const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
70
+ const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");
71
+ const [address] = PublicKey.findProgramAddressSync(
72
+ [new PublicKey(owner).toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), new PublicKey(mint).toBuffer()],
73
+ ASSOCIATED_TOKEN_PROGRAM_ID
74
+ );
75
+ return address.toBase58();
76
+ }
77
+ async function signTransaction(transactionBase64, wallet) {
78
+ const { Keypair, Transaction } = await import("@solana/web3.js");
79
+ const kp = Keypair.fromSecretKey(wallet.secretKey);
80
+ const tx = Transaction.from(Buffer.from(transactionBase64, "base64"));
81
+ tx.partialSign(kp);
82
+ return tx.serialize().toString("base64");
83
+ }
84
+ export {
85
+ SolanaWalletUnavailable,
86
+ associatedTokenAddress,
87
+ hasSolanaWallet,
88
+ loadSolanaWallet,
89
+ signTransaction
90
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@projectsolo/solo-mission-mcp",
3
- "version": "0.19.2",
4
- "description": "MCP server for Solo Mission Platform lets AI agents create missions, browse humans, and chat.",
3
+ "version": "0.20.1",
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",
7
7
  "publishConfig": {
@@ -14,17 +14,20 @@
14
14
  "build": "tsup src/index.ts --format esm --dts --clean",
15
15
  "dev": "tsx watch src/index.ts",
16
16
  "start": "node dist/index.js",
17
- "typecheck": "tsc --noEmit"
17
+ "typecheck": "tsc --noEmit",
18
+ "test": "vitest run"
18
19
  },
19
20
  "dependencies": {
20
21
  "@modelcontextprotocol/sdk": "^1.0.0",
22
+ "@solana/web3.js": "^1.98.4",
21
23
  "dotenv": "^16.4.0"
22
24
  },
23
25
  "devDependencies": {
24
26
  "@types/node": "^22.0.0",
25
27
  "tsup": "^8.3.0",
26
28
  "tsx": "^4.19.0",
27
- "typescript": "^5.7.0"
29
+ "typescript": "^5.7.0",
30
+ "vitest": "^4.1.11"
28
31
  },
29
32
  "engines": {
30
33
  "node": ">=20"