@projectsolo/solo-mission-mcp 0.21.11 → 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 +29 -6
- package/package.json +1 -1
- package/src/tools/missions.ts +1 -1
- package/src/tools/tracks.test.ts +151 -0
- package/src/tools/tracks.ts +44 -5
package/dist/index.js
CHANGED
|
@@ -95,7 +95,7 @@ var missionTools = [
|
|
|
95
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." },
|
|
96
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." },
|
|
97
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." },
|
|
98
|
-
auto_accept_applicants: { type: "boolean", description: "
|
|
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)." },
|
|
99
99
|
response_schema: RESPONSE_SCHEMA_PROPERTY
|
|
100
100
|
},
|
|
101
101
|
required: ["type", "title", "description"]
|
|
@@ -875,17 +875,25 @@ async function handleAgentTool(name, args) {
|
|
|
875
875
|
}
|
|
876
876
|
|
|
877
877
|
// src/tools/tracks.ts
|
|
878
|
+
import { readFile } from "fs/promises";
|
|
878
879
|
var trackTools = [
|
|
879
880
|
{
|
|
880
881
|
name: "add_mission_track",
|
|
881
|
-
description: "Upload a media item (audio, image, or video) to a media_review mission. Provide the file
|
|
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.",
|
|
882
883
|
inputSchema: {
|
|
883
884
|
type: "object",
|
|
884
885
|
properties: {
|
|
885
886
|
mission_id: { type: "string", description: "ID of the media_review mission" },
|
|
886
887
|
title: { type: "string", description: "Item title (required)" },
|
|
887
888
|
artist: { type: "string", description: "Artist / creator name (optional; typically used for audio)" },
|
|
888
|
-
|
|
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
|
+
},
|
|
889
897
|
content_type: {
|
|
890
898
|
type: "string",
|
|
891
899
|
enum: ["audio/mpeg", "audio/mp4", "image/jpeg", "image/png", "image/webp", "video/mp4"],
|
|
@@ -893,7 +901,7 @@ var trackTools = [
|
|
|
893
901
|
},
|
|
894
902
|
duration_seconds: { type: "number", description: "Duration in seconds (optional; applicable to audio and video only)" }
|
|
895
903
|
},
|
|
896
|
-
required: ["mission_id", "title", "
|
|
904
|
+
required: ["mission_id", "title", "content_type"]
|
|
897
905
|
}
|
|
898
906
|
},
|
|
899
907
|
{
|
|
@@ -935,12 +943,27 @@ var trackTools = [
|
|
|
935
943
|
async function handleTrackTool(name, args) {
|
|
936
944
|
switch (name) {
|
|
937
945
|
case "add_mission_track": {
|
|
938
|
-
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
|
+
}
|
|
939
963
|
const urlRes = await apiPost(
|
|
940
964
|
`/agent/missions/${mission_id}/tracks/upload-url`,
|
|
941
965
|
{ title, artist, content_type }
|
|
942
966
|
);
|
|
943
|
-
const fileBytes = Buffer.from(file_base64, "base64");
|
|
944
967
|
const uploadRes = await fetch(urlRes.upload_url, {
|
|
945
968
|
method: "PUT",
|
|
946
969
|
headers: { "Content-Type": content_type },
|
package/package.json
CHANGED
package/src/tools/missions.ts
CHANGED
|
@@ -116,7 +116,7 @@ export const missionTools: Tool[] = [
|
|
|
116
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.' },
|
|
117
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.' },
|
|
118
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.' },
|
|
119
|
-
auto_accept_applicants: { type: 'boolean', description: '
|
|
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).' },
|
|
120
120
|
response_schema: RESPONSE_SCHEMA_PROPERTY,
|
|
121
121
|
},
|
|
122
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
|
+
});
|
package/src/tools/tracks.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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', '
|
|
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 },
|