@projectsolo/solo-mission-mcp 0.21.11 → 0.21.14
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/DEVELOPER_README.md +1 -1
- package/dist/index.js +34 -8
- package/package.json +1 -1
- package/src/tools/missions.ts +12 -3
- package/src/tools/tracks.test.ts +151 -0
- package/src/tools/tracks.ts +44 -5
package/DEVELOPER_README.md
CHANGED
|
@@ -99,7 +99,7 @@ but the package itself is `@projectsolo/solo-mission-mcp` (org-scoped) — insta
|
|
|
99
99
|
```
|
|
100
100
|
2. **Register Trusted Publishing.** On https://www.npmjs.com, open
|
|
101
101
|
`@projectsolo/solo-mission-mcp` → **Settings → Trusted Publisher → GitHub Actions**:
|
|
102
|
-
- Organization/repo: `
|
|
102
|
+
- Organization/repo: `ProjectSoloAI/solo_mission_mcp`
|
|
103
103
|
- Workflow filename: `release.yml`
|
|
104
104
|
- Environment: (leave blank)
|
|
105
105
|
3. **Grant team publish access** so other org members aren't stuck relying on step 1's
|
package/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
var RESPONSE_SCHEMA_PROPERTY = {
|
|
20
20
|
type: "array",
|
|
21
21
|
maxItems: 20,
|
|
22
|
-
description: 'Structured completion questions a human answers to complete the mission. Question ids must be unique within one schema \u2014 a duplicate id is rejected at creation/update, not merged or silently overwritten. When set, finalize_qualification auto-derives qualified UIDs from completion instead of requiring an explicit qualified_human_uids list \u2014 same mechanism media_review has always used for track ratings, generalized to any mission type. media_review defaults to a canonical two-question schema when this is omitted at creation \u2014 literally { id: "rating", kind: "stars", required: true } and { id: "comment", kind: "long", required:
|
|
22
|
+
description: 'Structured completion questions a human answers to complete the mission. Question ids must be unique within one schema \u2014 a duplicate id is rejected at creation/update, not merged or silently overwritten. When set, finalize_qualification auto-derives qualified UIDs from completion instead of requiring an explicit qualified_human_uids list \u2014 same mechanism media_review has always used for track ratings, generalized to any mission type. media_review defaults to a canonical two-question schema when this is omitted at creation \u2014 literally { id: "rating", kind: "stars", required: true } and { id: "comment", kind: "long", required: true } \u2014 so read/append against those exact ids if you rely on the default rather than supplying your own schema. Every other type has no default (stays fully manual/chat-based unless you set this).',
|
|
23
23
|
items: {
|
|
24
24
|
type: "object",
|
|
25
25
|
properties: {
|
|
@@ -43,7 +43,10 @@ var RESPONSE_SCHEMA_PROPERTY = {
|
|
|
43
43
|
},
|
|
44
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." },
|
|
45
45
|
help: { type: "string", description: "Optional helper text shown under the label (max 500 chars)." },
|
|
46
|
-
required: {
|
|
46
|
+
required: {
|
|
47
|
+
type: "boolean",
|
|
48
|
+
description: "Whether this question must be answered to complete the mission. A required: false question does NOT block mission completion or finalize_qualification's auto-qualification \u2014 both derive purely from the required questions, so an optional question left blank (or still being typed into) will not stop either from firing the instant the required ones are answered. If every question in the schema should actually be answered, mark them all required: true; reserve required: false for a genuinely optional/skippable field."
|
|
49
|
+
},
|
|
47
50
|
options: {
|
|
48
51
|
type: "array",
|
|
49
52
|
items: { type: "string" },
|
|
@@ -95,7 +98,7 @@ var missionTools = [
|
|
|
95
98
|
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
99
|
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
100
|
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: "
|
|
101
|
+
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
102
|
response_schema: RESPONSE_SCHEMA_PROPERTY
|
|
100
103
|
},
|
|
101
104
|
required: ["type", "title", "description"]
|
|
@@ -875,17 +878,25 @@ async function handleAgentTool(name, args) {
|
|
|
875
878
|
}
|
|
876
879
|
|
|
877
880
|
// src/tools/tracks.ts
|
|
881
|
+
import { readFile } from "fs/promises";
|
|
878
882
|
var trackTools = [
|
|
879
883
|
{
|
|
880
884
|
name: "add_mission_track",
|
|
881
|
-
description: "Upload a media item (audio, image, or video) to a media_review mission. Provide the file
|
|
885
|
+
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
886
|
inputSchema: {
|
|
883
887
|
type: "object",
|
|
884
888
|
properties: {
|
|
885
889
|
mission_id: { type: "string", description: "ID of the media_review mission" },
|
|
886
890
|
title: { type: "string", description: "Item title (required)" },
|
|
887
891
|
artist: { type: "string", description: "Artist / creator name (optional; typically used for audio)" },
|
|
888
|
-
|
|
892
|
+
file_path: {
|
|
893
|
+
type: "string",
|
|
894
|
+
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."
|
|
895
|
+
},
|
|
896
|
+
file_base64: {
|
|
897
|
+
type: "string",
|
|
898
|
+
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."
|
|
899
|
+
},
|
|
889
900
|
content_type: {
|
|
890
901
|
type: "string",
|
|
891
902
|
enum: ["audio/mpeg", "audio/mp4", "image/jpeg", "image/png", "image/webp", "video/mp4"],
|
|
@@ -893,7 +904,7 @@ var trackTools = [
|
|
|
893
904
|
},
|
|
894
905
|
duration_seconds: { type: "number", description: "Duration in seconds (optional; applicable to audio and video only)" }
|
|
895
906
|
},
|
|
896
|
-
required: ["mission_id", "title", "
|
|
907
|
+
required: ["mission_id", "title", "content_type"]
|
|
897
908
|
}
|
|
898
909
|
},
|
|
899
910
|
{
|
|
@@ -935,12 +946,27 @@ var trackTools = [
|
|
|
935
946
|
async function handleTrackTool(name, args) {
|
|
936
947
|
switch (name) {
|
|
937
948
|
case "add_mission_track": {
|
|
938
|
-
const { mission_id, title, artist, file_base64, content_type, duration_seconds } = args;
|
|
949
|
+
const { mission_id, title, artist, file_base64, file_path, content_type, duration_seconds } = args;
|
|
950
|
+
if (!file_base64 && !file_path) {
|
|
951
|
+
throw new Error("add_mission_track requires either file_base64 or file_path");
|
|
952
|
+
}
|
|
953
|
+
if (file_base64 && file_path) {
|
|
954
|
+
throw new Error("add_mission_track accepts only one of file_base64 or file_path, not both");
|
|
955
|
+
}
|
|
956
|
+
let fileBytes;
|
|
957
|
+
if (file_path) {
|
|
958
|
+
try {
|
|
959
|
+
fileBytes = await readFile(file_path);
|
|
960
|
+
} catch (err) {
|
|
961
|
+
throw new Error(`Could not read file_path "${file_path}": ${err.message}`);
|
|
962
|
+
}
|
|
963
|
+
} else {
|
|
964
|
+
fileBytes = Buffer.from(file_base64, "base64");
|
|
965
|
+
}
|
|
939
966
|
const urlRes = await apiPost(
|
|
940
967
|
`/agent/missions/${mission_id}/tracks/upload-url`,
|
|
941
968
|
{ title, artist, content_type }
|
|
942
969
|
);
|
|
943
|
-
const fileBytes = Buffer.from(file_base64, "base64");
|
|
944
970
|
const uploadRes = await fetch(urlRes.upload_url, {
|
|
945
971
|
method: "PUT",
|
|
946
972
|
headers: { "Content-Type": content_type },
|
package/package.json
CHANGED
package/src/tools/missions.ts
CHANGED
|
@@ -26,7 +26,7 @@ const RESPONSE_SCHEMA_PROPERTY = {
|
|
|
26
26
|
'media_review has always used for track ratings, generalized to any mission type. ' +
|
|
27
27
|
'media_review defaults to a canonical two-question schema when this is omitted at creation — ' +
|
|
28
28
|
'literally { id: "rating", kind: "stars", required: true } and { id: "comment", kind: "long", ' +
|
|
29
|
-
'required:
|
|
29
|
+
'required: true } — so read/append against those exact ids if you rely on the default rather ' +
|
|
30
30
|
'than supplying your own schema. Every other type has no default (stays fully manual/chat-based ' +
|
|
31
31
|
'unless you set this).',
|
|
32
32
|
items: {
|
|
@@ -63,7 +63,16 @@ const RESPONSE_SCHEMA_PROPERTY = {
|
|
|
63
63
|
},
|
|
64
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.' },
|
|
65
65
|
help: { type: 'string', description: 'Optional helper text shown under the label (max 500 chars).' },
|
|
66
|
-
required: {
|
|
66
|
+
required: {
|
|
67
|
+
type: 'boolean',
|
|
68
|
+
description:
|
|
69
|
+
'Whether this question must be answered to complete the mission. A required: false question ' +
|
|
70
|
+
'does NOT block mission completion or finalize_qualification\'s auto-qualification — both ' +
|
|
71
|
+
'derive purely from the required questions, so an optional question left blank (or still ' +
|
|
72
|
+
'being typed into) will not stop either from firing the instant the required ones are ' +
|
|
73
|
+
'answered. If every question in the schema should actually be answered, mark them all ' +
|
|
74
|
+
'required: true; reserve required: false for a genuinely optional/skippable field.',
|
|
75
|
+
},
|
|
67
76
|
options: {
|
|
68
77
|
type: 'array',
|
|
69
78
|
items: { type: 'string' },
|
|
@@ -116,7 +125,7 @@ export const missionTools: Tool[] = [
|
|
|
116
125
|
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
126
|
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
127
|
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: '
|
|
128
|
+
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
129
|
response_schema: RESPONSE_SCHEMA_PROPERTY,
|
|
121
130
|
},
|
|
122
131
|
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 },
|