@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.
@@ -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: `ProjectSoloXYZ/solo_mission_mcp`
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: false } \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).',
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: { type: "boolean", description: "Whether this question must be answered to complete the mission." },
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: "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." },
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 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.",
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
- file_base64: { type: "string", description: "Base64-encoded file contents" },
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", "file_base64", "content_type"]
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectsolo/solo-mission-mcp",
3
- "version": "0.21.11",
3
+ "version": "0.21.14",
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",
@@ -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: false } — so read/append against those exact ids if you rely on the default rather ' +
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: { type: 'boolean', description: 'Whether this question must be answered to complete the mission.' },
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: '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.' },
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
+ });
@@ -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 },