@projectsolo/solo-mission-mcp 0.21.10 → 0.21.13

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
@@ -26,18 +26,31 @@ var RESPONSE_SCHEMA_PROPERTY = {
26
26
  id: { type: "string", description: "Unique id within this schema \u2014 the key answers are stored under. Duplicates are rejected, not merged." },
27
27
  kind: {
28
28
  type: "string",
29
- enum: ["single", "multi", "likert", "stars", "short", "long", "dropdown", "consent"],
30
- 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, consent=a checkbox that must be explicitly true \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.'
29
+ enum: [
30
+ "single",
31
+ "multi",
32
+ "likert",
33
+ "stars",
34
+ "short",
35
+ "long",
36
+ "dropdown",
37
+ "checkbox",
38
+ "timestamp_tag",
39
+ "image_region",
40
+ "video_region_duration"
41
+ ],
42
+ 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.'
31
43
  },
32
- label: { type: "string", description: "Question text shown to the human (max 200 chars). For the consent kind this is the checkbox's own clickable text, not a separate heading." },
44
+ 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." },
33
45
  help: { type: "string", description: "Optional helper text shown under the label (max 500 chars)." },
34
46
  required: { type: "boolean", description: "Whether this question must be answered to complete the mission." },
35
47
  options: {
36
48
  type: "array",
37
49
  items: { type: "string" },
38
- description: "Required (non-empty) for single/multi/dropdown/likert (max 20 options, 50 chars each). Not accepted by other kinds."
50
+ 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."
39
51
  },
40
- 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." }
52
+ 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." },
53
+ 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." }
41
54
  },
42
55
  required: ["id", "kind", "label", "required"]
43
56
  }
@@ -82,7 +95,7 @@ var missionTools = [
82
95
  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." },
83
96
  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. Backend floor is 60s (0.0166h), same for every chain \u2014 see work_duration_hours for the reasoning." },
84
97
  work_duration_hours: { type: "number", minimum: 0.0166, description: "How long (hours) hired participants have to complete the work. Agent must call settle_mission before this period ends. Backend floor is 60s (0.0166h) for every chain \u2014 this is a flat sanity check against a near-zero window, not a guarantee of a legal settlement window on every chain: Base's own EscrowVault contract separately enforces its own fixed 1-hour minimum regardless of what this floor allows through, so a too-short Base mission still fails downstream (at finalize_qualification, or on-chain) instead of being caught at creation." },
85
- auto_accept_applicants: { type: "boolean", description: "When true, applicants are automatically hired when they apply \u2014 no manual hire_participant call needed. First-come first-served up to max_humans. Face verification is still required. Ideal for open media_review missions." },
98
+ 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)." },
86
99
  response_schema: RESPONSE_SCHEMA_PROPERTY
87
100
  },
88
101
  required: ["type", "title", "description"]
@@ -862,17 +875,25 @@ async function handleAgentTool(name, args) {
862
875
  }
863
876
 
864
877
  // src/tools/tracks.ts
878
+ import { readFile } from "fs/promises";
865
879
  var trackTools = [
866
880
  {
867
881
  name: "add_mission_track",
868
- description: "Upload a media item (audio, image, or video) to a media_review mission. Provide the file as a base64-encoded string. For on-chain missions, call this BEFORE confirm_funding \u2014 uploads are blocked once the mission is active. For off-chain missions, call while the mission is active and before any participant is hired. The item becomes visible to hired participants once confirmed.",
882
+ 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 confirm_funding \u2014 uploads are blocked once the mission is active. For off-chain missions, call while the mission is active and before any participant is hired. The item becomes visible to hired participants once confirmed.",
869
883
  inputSchema: {
870
884
  type: "object",
871
885
  properties: {
872
886
  mission_id: { type: "string", description: "ID of the media_review mission" },
873
887
  title: { type: "string", description: "Item title (required)" },
874
888
  artist: { type: "string", description: "Artist / creator name (optional; typically used for audio)" },
875
- file_base64: { type: "string", description: "Base64-encoded file contents" },
889
+ file_path: {
890
+ type: "string",
891
+ 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."
892
+ },
893
+ file_base64: {
894
+ type: "string",
895
+ 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."
896
+ },
876
897
  content_type: {
877
898
  type: "string",
878
899
  enum: ["audio/mpeg", "audio/mp4", "image/jpeg", "image/png", "image/webp", "video/mp4"],
@@ -880,7 +901,7 @@ var trackTools = [
880
901
  },
881
902
  duration_seconds: { type: "number", description: "Duration in seconds (optional; applicable to audio and video only)" }
882
903
  },
883
- required: ["mission_id", "title", "file_base64", "content_type"]
904
+ required: ["mission_id", "title", "content_type"]
884
905
  }
885
906
  },
886
907
  {
@@ -922,12 +943,27 @@ var trackTools = [
922
943
  async function handleTrackTool(name, args) {
923
944
  switch (name) {
924
945
  case "add_mission_track": {
925
- const { mission_id, title, artist, file_base64, content_type, duration_seconds } = args;
946
+ const { mission_id, title, artist, file_base64, file_path, content_type, duration_seconds } = args;
947
+ if (!file_base64 && !file_path) {
948
+ throw new Error("add_mission_track requires either file_base64 or file_path");
949
+ }
950
+ if (file_base64 && file_path) {
951
+ throw new Error("add_mission_track accepts only one of file_base64 or file_path, not both");
952
+ }
953
+ let fileBytes;
954
+ if (file_path) {
955
+ try {
956
+ fileBytes = await readFile(file_path);
957
+ } catch (err) {
958
+ throw new Error(`Could not read file_path "${file_path}": ${err.message}`);
959
+ }
960
+ } else {
961
+ fileBytes = Buffer.from(file_base64, "base64");
962
+ }
926
963
  const urlRes = await apiPost(
927
964
  `/agent/missions/${mission_id}/tracks/upload-url`,
928
965
  { title, artist, content_type }
929
966
  );
930
- const fileBytes = Buffer.from(file_base64, "base64");
931
967
  const uploadRes = await fetch(urlRes.upload_url, {
932
968
  method: "PUT",
933
969
  headers: { "Content-Type": content_type },
@@ -1046,7 +1082,7 @@ async function handleSolanaTool(name, args) {
1046
1082
  case "get_solana_config":
1047
1083
  return apiGet2("/agent/solana/config");
1048
1084
  case "get_solana_wallet": {
1049
- const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import("./wallet-IUQWBW6F.js");
1085
+ const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import("./wallet-V4T4NTCM.js");
1050
1086
  if (!hasSolanaWallet()) {
1051
1087
  return {
1052
1088
  configured: false,
@@ -1092,7 +1128,7 @@ async function handleSolanaTool(name, args) {
1092
1128
  };
1093
1129
  }
1094
1130
  case "fund_solana_mission": {
1095
- const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-IUQWBW6F.js");
1131
+ const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-V4T4NTCM.js");
1096
1132
  const { verifyFundingTransaction } = await import("./verify-KAETIGV5.js");
1097
1133
  const wallet = await loadSolanaWallet();
1098
1134
  const cfg = await apiGet2("/agent/solana/config");
@@ -1152,7 +1188,7 @@ async function handleSolanaTool(name, args) {
1152
1188
  return { funded: true, verified: true, ...confirmed };
1153
1189
  }
1154
1190
  case "refund_solana_mission": {
1155
- const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-IUQWBW6F.js");
1191
+ const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-V4T4NTCM.js");
1156
1192
  const wallet = await loadSolanaWallet();
1157
1193
  const cfg = await apiGet2("/agent/solana/config");
1158
1194
  const mission = await apiGet2(`/agent/missions/${args.mission_id}`);
@@ -0,0 +1,143 @@
1
+ // src/solana/wallet.ts
2
+ import { readFileSync } from "fs";
3
+ import { createDecipheriv, pbkdf2Sync } from "crypto";
4
+ var SolanaWalletUnavailable = class extends Error {
5
+ constructor(reason) {
6
+ super(
7
+ `Solana wallet unavailable: ${reason}
8
+
9
+ Set one of:
10
+ SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE
11
+ - recommended: an openssl-aes-256-cbc-pbkdf2-encrypted
12
+ keyfile plus a separate passphrase file (chmod 600 both)
13
+ SOLO_SOLANA_KEYPAIR - JSON byte array, as \`solana-keygen\` writes it (plaintext)
14
+ SOLO_SOLANA_KEYPAIR_PATH - path to that file (e.g. ~/.config/solana/id.json), plaintext
15
+
16
+ The wallet needs SOL for rent and fees, and USDC for the mission budget. Rent is a
17
+ refundable deposit, not a fee: most of it returns when the task is closed.`
18
+ );
19
+ this.name = "SolanaWalletUnavailable";
20
+ }
21
+ };
22
+ function decryptOpenSslAes256CbcPbkdf2(encrypted, password) {
23
+ const MAGIC = "Salted__";
24
+ if (encrypted.length < 16 || encrypted.subarray(0, 8).toString("utf8") !== MAGIC) {
25
+ throw new SolanaWalletUnavailable('encrypted keyfile is missing the OpenSSL "Salted__" header');
26
+ }
27
+ const salt = encrypted.subarray(8, 16);
28
+ const ciphertext = encrypted.subarray(16);
29
+ const derived = pbkdf2Sync(password, salt, 1e4, 48, "sha256");
30
+ const key = derived.subarray(0, 32);
31
+ const iv = derived.subarray(32, 48);
32
+ const decipher = createDecipheriv("aes-256-cbc", key, iv);
33
+ try {
34
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
35
+ } catch (e) {
36
+ throw new SolanaWalletUnavailable(
37
+ `failed to decrypt keyfile \u2014 wrong passphrase, or the file wasn't produced by 'openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256' (${e.message})`
38
+ );
39
+ }
40
+ }
41
+ function parseKeypairBytes(raw) {
42
+ const trimmed = raw.trim();
43
+ if (!trimmed.startsWith("[")) {
44
+ throw new SolanaWalletUnavailable(
45
+ "value is not a JSON byte array \u2014 this is the format `solana-keygen new` writes"
46
+ );
47
+ }
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(trimmed);
51
+ } catch {
52
+ throw new SolanaWalletUnavailable("value looks like a JSON array but does not parse");
53
+ }
54
+ if (!Array.isArray(parsed) || !parsed.every((n) => typeof n === "number")) {
55
+ throw new SolanaWalletUnavailable("JSON array must contain only numbers");
56
+ }
57
+ const bytes = Uint8Array.from(parsed);
58
+ if (bytes.length !== 64) {
59
+ throw new SolanaWalletUnavailable(
60
+ `expected 64 bytes, got ${bytes.length}` + (bytes.length === 32 ? " \u2014 this is the seed alone, not the full keypair" : "")
61
+ );
62
+ }
63
+ return bytes;
64
+ }
65
+ function expandHome(path) {
66
+ return path.replace(/^~/, process.env.HOME ?? "~");
67
+ }
68
+ var cachedWallet;
69
+ async function loadSolanaWallet() {
70
+ if (cachedWallet) return cachedWallet;
71
+ const encryptedPath = process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH;
72
+ const passwordFile = process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE;
73
+ const inline = process.env.SOLO_SOLANA_KEYPAIR;
74
+ const path = process.env.SOLO_SOLANA_KEYPAIR_PATH;
75
+ let raw;
76
+ if (encryptedPath && encryptedPath.trim() !== "" || passwordFile && passwordFile.trim() !== "") {
77
+ if (!encryptedPath || !passwordFile) {
78
+ throw new SolanaWalletUnavailable(
79
+ "SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH and SOLO_SOLANA_KEYPAIR_PASSWORD_FILE must both be set \u2014 only one was found"
80
+ );
81
+ }
82
+ let encrypted;
83
+ let password;
84
+ try {
85
+ encrypted = readFileSync(expandHome(encryptedPath));
86
+ } catch (e) {
87
+ throw new SolanaWalletUnavailable(`cannot read ${encryptedPath}: ${e.message}`);
88
+ }
89
+ try {
90
+ password = readFileSync(expandHome(passwordFile), "utf8").replace(/\r?\n$/, "");
91
+ } catch (e) {
92
+ throw new SolanaWalletUnavailable(`cannot read ${passwordFile}: ${e.message}`);
93
+ }
94
+ raw = decryptOpenSslAes256CbcPbkdf2(encrypted, password).toString("utf8");
95
+ } else if (inline && inline.trim() !== "") {
96
+ raw = inline;
97
+ } else if (path && path.trim() !== "") {
98
+ try {
99
+ raw = readFileSync(expandHome(path), "utf8");
100
+ } catch (e) {
101
+ throw new SolanaWalletUnavailable(`cannot read ${path}: ${e.message}`);
102
+ }
103
+ } else {
104
+ throw new SolanaWalletUnavailable(
105
+ "no wallet source configured (checked SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH, SOLO_SOLANA_KEYPAIR, SOLO_SOLANA_KEYPAIR_PATH)"
106
+ );
107
+ }
108
+ const secretKey = parseKeypairBytes(raw);
109
+ const { Keypair } = await import("@solana/web3.js");
110
+ const kp = Keypair.fromSecretKey(secretKey);
111
+ cachedWallet = { publicKey: kp.publicKey.toBase58(), secretKey };
112
+ return cachedWallet;
113
+ }
114
+ function hasSolanaWallet() {
115
+ return Boolean(
116
+ cachedWallet || (process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH ?? "").trim() && (process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE ?? "").trim() || (process.env.SOLO_SOLANA_KEYPAIR ?? "").trim() || (process.env.SOLO_SOLANA_KEYPAIR_PATH ?? "").trim()
117
+ );
118
+ }
119
+ async function associatedTokenAddress(mint, owner) {
120
+ const { PublicKey } = await import("@solana/web3.js");
121
+ const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
122
+ const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");
123
+ const [address] = PublicKey.findProgramAddressSync(
124
+ [new PublicKey(owner).toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), new PublicKey(mint).toBuffer()],
125
+ ASSOCIATED_TOKEN_PROGRAM_ID
126
+ );
127
+ return address.toBase58();
128
+ }
129
+ async function signTransaction(transactionBase64, wallet) {
130
+ const { Keypair, Transaction } = await import("@solana/web3.js");
131
+ const kp = Keypair.fromSecretKey(wallet.secretKey);
132
+ const tx = Transaction.from(Buffer.from(transactionBase64, "base64"));
133
+ tx.partialSign(kp);
134
+ return tx.serialize().toString("base64");
135
+ }
136
+ export {
137
+ SolanaWalletUnavailable,
138
+ associatedTokenAddress,
139
+ decryptOpenSslAes256CbcPbkdf2,
140
+ hasSolanaWallet,
141
+ loadSolanaWallet,
142
+ signTransaction
143
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectsolo/solo-mission-mcp",
3
- "version": "0.21.10",
3
+ "version": "0.21.13",
4
4
  "description": "MCP server for Solo Mission Platform — lets AI agents create missions, browse humans, and chat.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,2 @@
1
+ Salted__\��H<�WCxUZ ��y`�7�A�Ǔ�K�=6��u5D$�Q���Yd�6D����a��'SHԛE����YB��h�`���/����=5�>�������,�S8�l��?[=�-H5�i^�^��Oa��R�W,��lj�Cz4/�$���bx�M�_�%;!�� A����Cұ
2
+ 0y�;�(V
@@ -0,0 +1 @@
1
+ GXkK4gbmGPGFmB7vobHeY82ktbAw9VPnHZJits1P2Qjw
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `decryptOpenSslAes256CbcPbkdf2` reimplements OpenSSL's KDF natively rather than shelling out, so
3
+ * it is tested against a REAL `openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256` fixture —
4
+ * same reasoning as `verify.test.ts`: a hand-rolled ciphertext would encode my assumptions about the
5
+ * format twice and could only ever agree with itself, not with the `openssl enc` command the docs
6
+ * actually tell operators to run.
7
+ *
8
+ * `fixtures/test-keypair.json.enc` is a throwaway, never-funded devnet keypair — safe to check in.
9
+ */
10
+ import { describe, it, expect } from 'vitest';
11
+ import { readFileSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { decryptOpenSslAes256CbcPbkdf2, SolanaWalletUnavailable } from './wallet';
14
+
15
+ const FIXTURE_DIR = join(__dirname, 'fixtures');
16
+ const encrypted = readFileSync(join(FIXTURE_DIR, 'test-keypair.json.enc'));
17
+ const expectedPubkey = readFileSync(join(FIXTURE_DIR, 'test-keypair.pubkey'), 'utf8').trim();
18
+ const PASSPHRASE = 'test-only-fixture-passphrase';
19
+
20
+ describe('decryptOpenSslAes256CbcPbkdf2', () => {
21
+ it('decrypts a real openssl-produced ciphertext back to the original JSON keypair', async () => {
22
+ const plaintext = decryptOpenSslAes256CbcPbkdf2(encrypted, PASSPHRASE).toString('utf8');
23
+ const bytes = JSON.parse(plaintext);
24
+ expect(Array.isArray(bytes)).toBe(true);
25
+ expect(bytes).toHaveLength(64);
26
+
27
+ // Confirm it's not just 64 numbers — it's the actual keypair, by deriving the same public key
28
+ // `solana-keygen pubkey` reported when the fixture was generated.
29
+ const { Keypair } = await import('@solana/web3.js');
30
+ const kp = Keypair.fromSecretKey(Uint8Array.from(bytes));
31
+ expect(kp.publicKey.toBase58()).toBe(expectedPubkey);
32
+ });
33
+
34
+ it('rejects a wrong passphrase rather than silently returning garbage', () => {
35
+ expect(() => decryptOpenSslAes256CbcPbkdf2(encrypted, 'definitely-not-it')).toThrow(
36
+ SolanaWalletUnavailable,
37
+ );
38
+ });
39
+
40
+ it('rejects a file missing the OpenSSL "Salted__" header', () => {
41
+ expect(() => decryptOpenSslAes256CbcPbkdf2(Buffer.from('not an openssl file'), PASSPHRASE)).toThrow(
42
+ /Salted__/,
43
+ );
44
+ });
45
+ });
@@ -19,9 +19,28 @@
19
19
  * the Solo API — the backend builds transactions and submits them, but only the agent can authorise
20
20
  * one. That is the whole point of the partial-signing flow: a compromised backend cannot move a
21
21
  * sponsor's funds, because it never holds the signing key.
22
+ *
23
+ * AT REST, prefer SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE over the
24
+ * plain SOLO_SOLANA_KEYPAIR / SOLO_SOLANA_KEYPAIR_PATH pair. `solana-keygen new`'s own output is a
25
+ * plaintext JSON byte array — chmod 600 protects it from other users on the box, but not from
26
+ * whoever gets that one file (a compromised account, a stray backup, a copied disk image). The
27
+ * encrypted form splits the secret in two: the ciphertext and the passphrase file are only useful
28
+ * together, matching what `references/wallet-setup.md` already does for the Base private key
29
+ * (openssl-encrypt at rest, decrypt only transiently). Encrypt with the exact command below so the
30
+ * decryptor here (which reimplements OpenSSL's KDF, not shells out to it) derives the same key:
31
+ *
32
+ * openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256 \
33
+ * -in ~/.config/solana/solo-sponsor.json -out solo-sponsor.json.enc
34
+ * chmod 600 solo-sponsor.json.enc
35
+ * echo -n "your-passphrase" > keystore.pass && chmod 600 keystore.pass
36
+ * # then delete the plaintext solo-sponsor.json
37
+ *
38
+ * The plain SOLO_SOLANA_KEYPAIR / SOLO_SOLANA_KEYPAIR_PATH pair still works, unchanged, for
39
+ * quick-start / devnet use where this doesn't matter.
22
40
  */
23
41
 
24
42
  import { readFileSync } from 'fs';
43
+ import { createDecipheriv, pbkdf2Sync } from 'crypto';
25
44
 
26
45
  export interface SolanaWallet {
27
46
  publicKey: string;
@@ -34,8 +53,11 @@ export class SolanaWalletUnavailable extends Error {
34
53
  super(
35
54
  `Solana wallet unavailable: ${reason}\n\n` +
36
55
  'Set one of:\n' +
37
- ' SOLO_SOLANA_KEYPAIR - JSON byte array, as `solana-keygen` writes it\n' +
38
- ' SOLO_SOLANA_KEYPAIR_PATH - path to that file (e.g. ~/.config/solana/id.json)\n\n' +
56
+ ' SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE\n' +
57
+ ' - recommended: an openssl-aes-256-cbc-pbkdf2-encrypted\n' +
58
+ ' keyfile plus a separate passphrase file (chmod 600 both)\n' +
59
+ ' SOLO_SOLANA_KEYPAIR - JSON byte array, as `solana-keygen` writes it (plaintext)\n' +
60
+ ' SOLO_SOLANA_KEYPAIR_PATH - path to that file (e.g. ~/.config/solana/id.json), plaintext\n\n' +
39
61
  'The wallet needs SOL for rent and fees, and USDC for the mission budget. Rent is a\n' +
40
62
  'refundable deposit, not a fee: most of it returns when the task is closed.',
41
63
  );
@@ -43,6 +65,39 @@ export class SolanaWalletUnavailable extends Error {
43
65
  }
44
66
  }
45
67
 
68
+ /**
69
+ * Decrypts a file produced by `openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256`.
70
+ *
71
+ * Reimplemented natively rather than shelling out to `openssl` — this codebase has no
72
+ * child_process/exec anywhere, and a decrypt helper is not where that invariant should end. OpenSSL
73
+ * prefixes salted output with the 8-byte literal "Salted__" followed by an 8-byte salt; with
74
+ * `-pbkdf2` it derives key||iv (32 + 16 bytes for aes-256-cbc) from the passphrase and that salt in
75
+ * one PBKDF2-HMAC-SHA256 call. Verified byte-for-byte against real `openssl enc`/`openssl enc -d`
76
+ * output in `wallet.test.ts`.
77
+ */
78
+ export function decryptOpenSslAes256CbcPbkdf2(encrypted: Buffer, password: string): Buffer {
79
+ const MAGIC = 'Salted__';
80
+ if (encrypted.length < 16 || encrypted.subarray(0, 8).toString('utf8') !== MAGIC) {
81
+ throw new SolanaWalletUnavailable('encrypted keyfile is missing the OpenSSL "Salted__" header');
82
+ }
83
+ const salt = encrypted.subarray(8, 16);
84
+ const ciphertext = encrypted.subarray(16);
85
+ const derived = pbkdf2Sync(password, salt, 10000, 48, 'sha256');
86
+ const key = derived.subarray(0, 32);
87
+ const iv = derived.subarray(32, 48);
88
+ const decipher = createDecipheriv('aes-256-cbc', key, iv);
89
+ try {
90
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
91
+ } catch (e) {
92
+ // Wrong passphrase almost always surfaces here (bad padding), not as a clean "auth failed" —
93
+ // CBC has no built-in integrity check, unlike GCM.
94
+ throw new SolanaWalletUnavailable(
95
+ `failed to decrypt keyfile — wrong passphrase, or the file wasn't produced by ` +
96
+ `'openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256' (${(e as Error).message})`,
97
+ );
98
+ }
99
+ }
100
+
46
101
  function parseKeypairBytes(raw: string): Uint8Array {
47
102
  const trimmed = raw.trim();
48
103
  if (!trimmed.startsWith('[')) {
@@ -71,6 +126,16 @@ function parseKeypairBytes(raw: string): Uint8Array {
71
126
  return bytes;
72
127
  }
73
128
 
129
+ function expandHome(path: string): string {
130
+ return path.replace(/^~/, process.env.HOME ?? '~');
131
+ }
132
+
133
+ // Cached after first successful load — avoids re-deriving the PBKDF2 key (and re-reading the
134
+ // passphrase file) on every tool call. Never cache a failure: a missing/misconfigured wallet on
135
+ // one call should not wrongly still look missing after the operator fixes it mid-session, and
136
+ // vice versa a transient read error shouldn't get baked in as permanent.
137
+ let cachedWallet: SolanaWallet | undefined;
138
+
74
139
  /**
75
140
  * Loads the agent's wallet.
76
141
  *
@@ -78,32 +143,61 @@ function parseKeypairBytes(raw: string): Uint8Array {
78
143
  * with no wallet configured at all — an agent running Base missions should not need one.
79
144
  */
80
145
  export async function loadSolanaWallet(): Promise<SolanaWallet> {
146
+ if (cachedWallet) return cachedWallet;
147
+
148
+ const encryptedPath = process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH;
149
+ const passwordFile = process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE;
81
150
  const inline = process.env.SOLO_SOLANA_KEYPAIR;
82
151
  const path = process.env.SOLO_SOLANA_KEYPAIR_PATH;
83
152
 
84
153
  let raw: string;
85
- if (inline && inline.trim() !== '') {
154
+ if ((encryptedPath && encryptedPath.trim() !== '') || (passwordFile && passwordFile.trim() !== '')) {
155
+ if (!encryptedPath || !passwordFile) {
156
+ throw new SolanaWalletUnavailable(
157
+ 'SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH and SOLO_SOLANA_KEYPAIR_PASSWORD_FILE must both be set — only one was found',
158
+ );
159
+ }
160
+ let encrypted: Buffer;
161
+ let password: string;
162
+ try {
163
+ encrypted = readFileSync(expandHome(encryptedPath));
164
+ } catch (e) {
165
+ throw new SolanaWalletUnavailable(`cannot read ${encryptedPath}: ${(e as Error).message}`);
166
+ }
167
+ try {
168
+ password = readFileSync(expandHome(passwordFile), 'utf8').replace(/\r?\n$/, '');
169
+ } catch (e) {
170
+ throw new SolanaWalletUnavailable(`cannot read ${passwordFile}: ${(e as Error).message}`);
171
+ }
172
+ raw = decryptOpenSslAes256CbcPbkdf2(encrypted, password).toString('utf8');
173
+ } else if (inline && inline.trim() !== '') {
86
174
  raw = inline;
87
175
  } else if (path && path.trim() !== '') {
88
176
  try {
89
- raw = readFileSync(path.replace(/^~/, process.env.HOME ?? '~'), 'utf8');
177
+ raw = readFileSync(expandHome(path), 'utf8');
90
178
  } catch (e) {
91
179
  throw new SolanaWalletUnavailable(`cannot read ${path}: ${(e as Error).message}`);
92
180
  }
93
181
  } else {
94
- throw new SolanaWalletUnavailable('neither SOLO_SOLANA_KEYPAIR nor SOLO_SOLANA_KEYPAIR_PATH is set');
182
+ throw new SolanaWalletUnavailable(
183
+ 'no wallet source configured (checked SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH, SOLO_SOLANA_KEYPAIR, SOLO_SOLANA_KEYPAIR_PATH)',
184
+ );
95
185
  }
96
186
 
97
187
  const secretKey = parseKeypairBytes(raw);
98
188
  const { Keypair } = await import('@solana/web3.js');
99
189
  const kp = Keypair.fromSecretKey(secretKey);
100
- return { publicKey: kp.publicKey.toBase58(), secretKey };
190
+ cachedWallet = { publicKey: kp.publicKey.toBase58(), secretKey };
191
+ return cachedWallet;
101
192
  }
102
193
 
103
194
  /** Whether a Solana wallet is configured, without throwing. */
104
195
  export function hasSolanaWallet(): boolean {
105
196
  return Boolean(
106
- (process.env.SOLO_SOLANA_KEYPAIR ?? '').trim() ||
197
+ cachedWallet ||
198
+ ((process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH ?? '').trim() &&
199
+ (process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE ?? '').trim()) ||
200
+ (process.env.SOLO_SOLANA_KEYPAIR ?? '').trim() ||
107
201
  (process.env.SOLO_SOLANA_KEYPAIR_PATH ?? '').trim(),
108
202
  );
109
203
  }
@@ -3,9 +3,17 @@ import { apiGet, apiPatch, apiPost } from '../api/client.js';
3
3
 
4
4
  /**
5
5
  * Shared JSON-schema for Mission.response_schema, used by both create_mission (set at creation)
6
- * and update_mission_questions (set/replace afterward, pre-hiring). v1 kinds only — text/choice/
7
- * rating input; kinds needing video-stimulus/gating infra (ab/slider/rank/tags/timestamps/upload)
8
- * aren't supported yet.
6
+ * and update_mission_questions (set/replace afterward, pre-hiring).
7
+ *
8
+ * Two families of kind:
9
+ * - Generic form kinds (single/multi/likert/stars/short/long/dropdown/checkbox) — usable on any
10
+ * mission type, self-contained text/choice/rating input.
11
+ * - Media-anchored kinds (timestamp_tag/image_region/video_region_duration) — need an actual
12
+ * track to annotate (a point in time, a region of an image, a region over a time range of a
13
+ * video), so they're only accepted on media_review missions. create_mission/
14
+ * update_mission_questions reject them on every other type.
15
+ * Kinds needing infra this platform doesn't have yet ('ab'/'slider'/'rank'/'tags'/'upload') aren't
16
+ * supported.
9
17
  */
10
18
  const RESPONSE_SCHEMA_PROPERTY = {
11
19
  type: 'array' as const,
@@ -27,26 +35,42 @@ const RESPONSE_SCHEMA_PROPERTY = {
27
35
  id: { type: 'string', description: 'Unique id within this schema — the key answers are stored under. Duplicates are rejected, not merged.' },
28
36
  kind: {
29
37
  type: 'string',
30
- enum: ['single', 'multi', 'likert', 'stars', 'short', 'long', 'dropdown', 'consent'],
38
+ enum: [
39
+ 'single', 'multi', 'likert', 'stars', 'short', 'long', 'dropdown', 'checkbox',
40
+ 'timestamp_tag', 'image_region', 'video_region_duration',
41
+ ],
31
42
  description:
32
43
  'single=one choice, multi=several choices, likert=a scale rendered as a row of buttons (NOT ' +
33
44
  'fixed at 5 points — the number of options you supply IS the number of points, so give exactly ' +
34
45
  'as many option labels as the scale should have; a common convention is 5 labels like "1 ' +
35
46
  'Strongly disagree".."5 Strongly agree" but nothing enforces that), stars=1-5 rating (fixed, ' +
36
47
  'no options), short=one line of text, long=paragraph text (see min_length), dropdown=one ' +
37
- 'choice from a long list, consent=a checkbox that must be explicitly true — the checkbox\'s ' +
38
- 'own clickable text is `label` itself (there is no separate heading rendered above it, unlike ' +
39
- 'every other kind); use `help` for any additional wording you want shown near it.',
48
+ 'choice from a long list, checkbox=a checkbox that must be explicitly true (usable for ANY ' +
49
+ 'yes/no question, not just consent-flavored ones) — the checkbox\'s own clickable text is ' +
50
+ '`label` itself (there is no separate heading rendered above it, unlike every other kind); ' +
51
+ 'use `help` for any additional wording you want shown near it.\n\n' +
52
+ 'Media-anchored kinds — media_review only, need a track to annotate (options/min_length ' +
53
+ 'don\'t apply; see min_count instead):\n' +
54
+ ' timestamp_tag: the human flags a moment in an audio/video track while it plays. Each ' +
55
+ 'flag is { t (seconds), tag (category — one of `options` if you set it, otherwise any ' +
56
+ 'non-empty string), note? }. `options` is OPTIONAL here (unlike the choice kinds above) — ' +
57
+ 'omit it for free-form tagging, or set it to offer a closed category list.\n' +
58
+ ' image_region: the human draws a rectangle on an image track and comments on it. Each ' +
59
+ 'region is { x, y, width, height (all 0-1, fraction of the image, not pixels), comment }.\n' +
60
+ ' video_region_duration: like image_region, but the rectangle is held for a single time ' +
61
+ 'range rather than a single point — { start, end (seconds, end > start), x, y, width, ' +
62
+ 'height, comment }. One fixed box for the whole range, not tracked frame by frame.',
40
63
  },
41
- label: { type: 'string', description: 'Question text shown to the human (max 200 chars). For the consent kind this is the checkbox\'s own clickable text, not a separate heading.' },
64
+ 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.' },
42
65
  help: { type: 'string', description: 'Optional helper text shown under the label (max 500 chars).' },
43
66
  required: { type: 'boolean', description: 'Whether this question must be answered to complete the mission.' },
44
67
  options: {
45
68
  type: 'array',
46
69
  items: { type: 'string' },
47
- description: 'Required (non-empty) for single/multi/dropdown/likert (max 20 options, 50 chars each). Not accepted by other kinds.',
70
+ 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.',
48
71
  },
49
72
  min_length: { type: 'number', description: "Minimum answer length. Only read for the 'long' kind — silently ignored (not rejected) if set on any other kind." },
73
+ 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) — silently ignored on any other kind.' },
50
74
  },
51
75
  required: ['id', 'kind', 'label', 'required'],
52
76
  },
@@ -92,7 +116,7 @@ export const missionTools: Tool[] = [
92
116
  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.' },
93
117
  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. Backend floor is 60s (0.0166h), same for every chain — see work_duration_hours for the reasoning.' },
94
118
  work_duration_hours: { type: 'number', minimum: 0.0166, description: 'How long (hours) hired participants have to complete the work. Agent must call settle_mission before this period ends. Backend floor is 60s (0.0166h) for every chain — this is a flat sanity check against a near-zero window, not a guarantee of a legal settlement window on every chain: Base\'s own EscrowVault contract separately enforces its own fixed 1-hour minimum regardless of what this floor allows through, so a too-short Base mission still fails downstream (at finalize_qualification, or on-chain) instead of being caught at creation.' },
95
- auto_accept_applicants: { type: 'boolean', description: 'When true, applicants are automatically hired when they apply — no manual hire_participant call needed. First-come first-served up to max_humans. Face verification is still required. Ideal for open media_review missions.' },
119
+ auto_accept_applicants: { type: 'boolean', description: 'Defaults to true — 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).' },
96
120
  response_schema: RESPONSE_SCHEMA_PROPERTY,
97
121
  },
98
122
  required: ['type', 'title', 'description'],
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `add_mission_track` accepts a file two ways: base64 inline in the tool-call args, or a local
3
+ * file_path the MCP server reads itself. The file_path option exists because inlining a multi-MB
4
+ * audio/video file as base64 can blow past the CALLING AGENT's own tool-call or context limits —
5
+ * a client-side failure that never reaches solo-firebase at all (root-caused against mission
6
+ * nJskovWGfrbfVu6gU3b5, where zero upload requests hit the backend). These tests cover the
7
+ * validation/ordering around that new path, plus a regression check that file_base64 still works
8
+ * unchanged.
9
+ */
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ const apiPost = vi.fn();
16
+ const apiGet = vi.fn();
17
+ const apiDelete = vi.fn();
18
+
19
+ vi.mock('../api/client.js', () => ({
20
+ apiPost: (...args: unknown[]) => apiPost(...args),
21
+ apiGet: (...args: unknown[]) => apiGet(...args),
22
+ apiDelete: (...args: unknown[]) => apiDelete(...args),
23
+ }));
24
+
25
+ import { handleTrackTool } from './tracks.js';
26
+
27
+ describe('add_mission_track', () => {
28
+ let tmpDir: string;
29
+ let fetchMock: ReturnType<typeof vi.fn>;
30
+
31
+ beforeEach(() => {
32
+ tmpDir = mkdtempSync(join(tmpdir(), 'solo-mcp-track-'));
33
+ apiPost.mockReset();
34
+ apiGet.mockReset();
35
+ apiDelete.mockReset();
36
+ fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => '' });
37
+ vi.stubGlobal('fetch', fetchMock);
38
+ });
39
+
40
+ afterEach(() => {
41
+ rmSync(tmpDir, { recursive: true, force: true });
42
+ vi.unstubAllGlobals();
43
+ });
44
+
45
+ function mockHappyPathApi() {
46
+ apiPost.mockImplementation(async (path: string) => {
47
+ if (path.endsWith('/tracks/upload-url')) {
48
+ return { upload_url: 'https://storage.example/signed-put', storage_path: 'x/y.mp3', track_id: 'trk_1' };
49
+ }
50
+ if (path.endsWith('/confirm')) {
51
+ return { track: { id: 'trk_1', title: 'My Track' } };
52
+ }
53
+ throw new Error(`Unexpected apiPost call: ${path}`);
54
+ });
55
+ }
56
+
57
+ it('reads the file from file_path and PUTs its exact bytes to the signed URL', async () => {
58
+ mockHappyPathApi();
59
+ const filePath = join(tmpDir, 'song.mp3');
60
+ const contents = Buffer.from('fake mp3 bytes');
61
+ writeFileSync(filePath, contents);
62
+
63
+ const result = await handleTrackTool('add_mission_track', {
64
+ mission_id: 'm1',
65
+ title: 'My Track',
66
+ content_type: 'audio/mpeg',
67
+ file_path: filePath,
68
+ });
69
+
70
+ expect(result).toEqual({ id: 'trk_1', title: 'My Track' });
71
+ expect(apiPost).toHaveBeenCalledWith('/agent/missions/m1/tracks/upload-url', {
72
+ title: 'My Track',
73
+ artist: undefined,
74
+ content_type: 'audio/mpeg',
75
+ });
76
+ expect(fetchMock).toHaveBeenCalledTimes(1);
77
+ const [url, init] = fetchMock.mock.calls[0];
78
+ expect(url).toBe('https://storage.example/signed-put');
79
+ expect(init.method).toBe('PUT');
80
+ expect(Buffer.isBuffer(init.body) ? init.body : Buffer.from(init.body)).toEqual(contents);
81
+ });
82
+
83
+ it('still accepts inline file_base64 unchanged (regression)', async () => {
84
+ mockHappyPathApi();
85
+ const contents = Buffer.from('fake image bytes');
86
+
87
+ await handleTrackTool('add_mission_track', {
88
+ mission_id: 'm1',
89
+ title: 'My Image',
90
+ content_type: 'image/png',
91
+ file_base64: contents.toString('base64'),
92
+ });
93
+
94
+ const [, init] = fetchMock.mock.calls[0];
95
+ expect(Buffer.isBuffer(init.body) ? init.body : Buffer.from(init.body)).toEqual(contents);
96
+ });
97
+
98
+ it('rejects when neither file_base64 nor file_path is provided, without calling the API', async () => {
99
+ await expect(
100
+ handleTrackTool('add_mission_track', { mission_id: 'm1', title: 'X', content_type: 'audio/mpeg' }),
101
+ ).rejects.toThrow(/requires either file_base64 or file_path/);
102
+ expect(apiPost).not.toHaveBeenCalled();
103
+ expect(fetchMock).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('rejects when both file_base64 and file_path are provided, without calling the API', async () => {
107
+ await expect(
108
+ handleTrackTool('add_mission_track', {
109
+ mission_id: 'm1',
110
+ title: 'X',
111
+ content_type: 'audio/mpeg',
112
+ file_base64: 'YWJj',
113
+ file_path: join(tmpDir, 'irrelevant.mp3'),
114
+ }),
115
+ ).rejects.toThrow(/only one of file_base64 or file_path/);
116
+ expect(apiPost).not.toHaveBeenCalled();
117
+ expect(fetchMock).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it('fails fast on an unreadable file_path, before creating a pending track doc', async () => {
121
+ await expect(
122
+ handleTrackTool('add_mission_track', {
123
+ mission_id: 'm1',
124
+ title: 'X',
125
+ content_type: 'audio/mpeg',
126
+ file_path: join(tmpDir, 'does-not-exist.mp3'),
127
+ }),
128
+ ).rejects.toThrow(/Could not read file_path/);
129
+ // The whole point of reading the file before Step 1: no upload-url/track doc created for a bad path.
130
+ expect(apiPost).not.toHaveBeenCalled();
131
+ expect(fetchMock).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it('deletes the pending track doc when the signed-URL PUT fails', async () => {
135
+ mockHappyPathApi();
136
+ apiDelete.mockResolvedValue(undefined);
137
+ fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' });
138
+ const filePath = join(tmpDir, 'song.mp3');
139
+ writeFileSync(filePath, 'bytes');
140
+
141
+ await expect(
142
+ handleTrackTool('add_mission_track', {
143
+ mission_id: 'm1',
144
+ title: 'My Track',
145
+ content_type: 'audio/mpeg',
146
+ file_path: filePath,
147
+ }),
148
+ ).rejects.toThrow(/Media upload failed/);
149
+ expect(apiDelete).toHaveBeenCalledWith('/agent/missions/m1/tracks/trk_1');
150
+ });
151
+ });
@@ -1,3 +1,4 @@
1
+ import { readFile } from 'node:fs/promises';
1
2
  import { Tool } from '@modelcontextprotocol/sdk/types.js';
2
3
  import { apiGet, apiPost, apiDelete } from '../api/client.js';
3
4
 
@@ -5,7 +6,11 @@ export const trackTools: Tool[] = [
5
6
  {
6
7
  name: 'add_mission_track',
7
8
  description:
8
- 'Upload a media item (audio, image, or video) to a media_review mission. Provide the file as a base64-encoded string. ' +
9
+ 'Upload a media item (audio, image, or video) to a media_review mission. Provide the file EITHER via file_path ' +
10
+ '(the MCP server reads it from local disk and uploads it directly — use this for audio/video, since inlining a ' +
11
+ 'multi-MB file as base64 in the tool call can exceed the calling agent\'s own tool-call or context limits, ' +
12
+ 'causing a silent client-side failure before any request reaches the API) OR inline as file_base64 (fine for ' +
13
+ 'small images). Exactly one of the two is required. ' +
9
14
  'For on-chain missions, call this BEFORE confirm_funding — uploads are blocked once the mission is active. ' +
10
15
  'For off-chain missions, call while the mission is active and before any participant is hired. ' +
11
16
  'The item becomes visible to hired participants once confirmed.',
@@ -15,7 +20,21 @@ export const trackTools: Tool[] = [
15
20
  mission_id: { type: 'string', description: 'ID of the media_review mission' },
16
21
  title: { type: 'string', description: 'Item title (required)' },
17
22
  artist: { type: 'string', description: 'Artist / creator name (optional; typically used for audio)' },
18
- file_base64: { type: 'string', description: 'Base64-encoded file contents' },
23
+ file_path: {
24
+ type: 'string',
25
+ description:
26
+ 'Path to a local file to upload, as an alternative to file_base64. The MCP server reads this file itself ' +
27
+ 'and PUTs it to the signed upload URL, so the calling agent only needs to pass the path — not the file ' +
28
+ 'contents. Prefer this for audio/video files. Use an absolute path: a relative path resolves against the ' +
29
+ "MCP server process's working directory, not the caller's. Exactly one of file_base64 or file_path is required.",
30
+ },
31
+ file_base64: {
32
+ type: 'string',
33
+ description:
34
+ 'Base64-encoded file contents, as an alternative to file_path. Fine for small images; for audio/video ' +
35
+ 'prefer file_path — encoding a multi-MB file inline can exceed the calling agent\'s own tool-call or ' +
36
+ 'context limits. Exactly one of file_base64 or file_path is required.',
37
+ },
19
38
  content_type: {
20
39
  type: 'string',
21
40
  enum: ['audio/mpeg', 'audio/mp4', 'image/jpeg', 'image/png', 'image/webp', 'video/mp4'],
@@ -27,7 +46,7 @@ export const trackTools: Tool[] = [
27
46
  },
28
47
  duration_seconds: { type: 'number', description: 'Duration in seconds (optional; applicable to audio and video only)' },
29
48
  },
30
- required: ['mission_id', 'title', 'file_base64', 'content_type'],
49
+ required: ['mission_id', 'title', 'content_type'],
31
50
  },
32
51
  },
33
52
  {
@@ -76,7 +95,28 @@ export const trackTools: Tool[] = [
76
95
  export async function handleTrackTool(name: string, args: Record<string, any>): Promise<unknown> {
77
96
  switch (name) {
78
97
  case 'add_mission_track': {
79
- const { mission_id, title, artist, file_base64, content_type, duration_seconds } = args;
98
+ const { mission_id, title, artist, file_base64, file_path, content_type, duration_seconds } = args;
99
+
100
+ if (!file_base64 && !file_path) {
101
+ throw new Error('add_mission_track requires either file_base64 or file_path');
102
+ }
103
+ if (file_base64 && file_path) {
104
+ throw new Error('add_mission_track accepts only one of file_base64 or file_path, not both');
105
+ }
106
+
107
+ // Read/decode the file up front, before creating any server-side state, so a
108
+ // missing or unreadable local file fails fast without leaving a pending track
109
+ // doc that would then need cleanup.
110
+ let fileBytes: Buffer;
111
+ if (file_path) {
112
+ try {
113
+ fileBytes = await readFile(file_path);
114
+ } catch (err) {
115
+ throw new Error(`Could not read file_path "${file_path}": ${(err as Error).message}`);
116
+ }
117
+ } else {
118
+ fileBytes = Buffer.from(file_base64, 'base64');
119
+ }
80
120
 
81
121
  // Step 1: Get signed upload URL and create pending track doc
82
122
  const urlRes = await apiPost<{ upload_url: string; storage_path: string; track_id: string }>(
@@ -87,7 +127,6 @@ export async function handleTrackTool(name: string, args: Record<string, any>):
87
127
  // Step 2: Upload binary to the signed URL.
88
128
  // On failure, delete the pending track doc so it doesn't consume the 20-track
89
129
  // slot or appear as a ghost in list results.
90
- const fileBytes = Buffer.from(file_base64, 'base64');
91
130
  const uploadRes = await fetch(urlRes.upload_url, {
92
131
  method: 'PUT',
93
132
  headers: { 'Content-Type': content_type },
@@ -1,90 +0,0 @@
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
- };