Skip to content

Commit 876006f

Browse files
feat: add upload data pattern CSV tool (#6)
Resolves magicpod-internal/magicpod#22876
1 parent ccc629f commit 876006f

File tree

3 files changed

+337
-1
lines changed

3 files changed

+337
-1
lines changed

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { searchMagicpodArticles } from "./tools/search-magicpod-articles.js";
66
import { readMagicpodArticle } from "./tools/read-magicpod-article.js";
77
import { initMagicPodApiProxy } from "./tools/magicpod-web-api.js";
88
import { apiV1_0UploadFileCreate } from "./tools/api-v1-0-upload-file-create.js";
9+
import { apiV1_0UploadDataPatterns } from "./tools/api-v1-0-upload-data-patterns.js";
910

1011
const program = new Command();
1112
program.option("--api-token <key>", "MagicPod API token to use");
@@ -23,6 +24,7 @@ async function main() {
2324
const baseUrl = baseUrlEnvironmentVariable || "https://app.magicpod.com";
2425
const proxy = await initMagicPodApiProxy(baseUrl, options.apiToken, [
2526
apiV1_0UploadFileCreate(baseUrl, options.apiToken),
27+
apiV1_0UploadDataPatterns(baseUrl, options.apiToken),
2628
searchMagicpodArticles(),
2729
readMagicpodArticle(),
2830
]);
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
import { z } from "zod";
2+
import { OtherToolDefinition } from "../openapi-mcp-server/mcp/proxy.js";
3+
import fs from "fs";
4+
import path from "node:path";
5+
import axios from "axios";
6+
import FormData from "form-data";
7+
8+
interface BatchTaskResponse {
9+
status: string;
10+
}
11+
12+
const checkBatchTaskStatus = async (
13+
baseUrl: string,
14+
apiToken: string,
15+
organizationName: string,
16+
projectName: string,
17+
batchTaskId: number
18+
): Promise<BatchTaskResponse> => {
19+
const url = `${baseUrl}/api/v1.0/${organizationName}/${projectName}/batch-task/${batchTaskId}/`;
20+
21+
try {
22+
const response = await axios.get(url, {
23+
headers: {
24+
Authorization: `Token ${apiToken}`,
25+
},
26+
});
27+
28+
if (response.status !== 200) {
29+
throw new Error(`Failed to check batch task status: ${response.status}`);
30+
}
31+
32+
return response.data;
33+
} catch (error: any) {
34+
if (error.response) {
35+
// HTTP error response from server
36+
const status = error.response.status;
37+
const errorData = error.response.data;
38+
39+
if (status === 401) {
40+
throw new Error("Authentication failed while checking batch task status. Please check your API token.");
41+
} else if (status === 403) {
42+
throw new Error("Access denied while checking batch task status.");
43+
} else if (status === 404) {
44+
throw new Error(`Batch task ${batchTaskId} not found.`);
45+
} else if (status === 400) {
46+
const errorMsg = typeof errorData === 'object' && errorData.detail
47+
? errorData.detail
48+
: errorData;
49+
throw new Error(`Invalid batch task request: ${errorMsg}`);
50+
} else {
51+
throw new Error(`Failed to check batch task status: HTTP ${status} - ${errorData}`);
52+
}
53+
} else {
54+
// Network or other error
55+
throw new Error(`Network error while checking batch task status: ${error.message || error}`);
56+
}
57+
}
58+
};
59+
60+
const sleep = (ms: number): Promise<void> => {
61+
return new Promise(resolve => setTimeout(resolve, ms));
62+
};
63+
64+
const waitForBatchTaskCompletion = async (
65+
baseUrl: string,
66+
apiToken: string,
67+
organizationName: string,
68+
projectName: string,
69+
batchTaskId: number,
70+
timeoutMs: number = 5 * 60 * 1000, // 5 minutes default
71+
pollIntervalMs: number = 3000 // 3 seconds default
72+
): Promise<{ success: boolean; status: string; message: string }> => {
73+
const startTime = Date.now();
74+
75+
while (Date.now() - startTime < timeoutMs) {
76+
try {
77+
const taskResponse = await checkBatchTaskStatus(
78+
baseUrl,
79+
apiToken,
80+
organizationName,
81+
projectName,
82+
batchTaskId
83+
);
84+
85+
if (taskResponse.status === "succeeded") {
86+
return {
87+
success: true,
88+
status: taskResponse.status,
89+
message: "Data pattern upload completed successfully"
90+
};
91+
} else if (taskResponse.status === "failed") {
92+
return {
93+
success: false,
94+
status: taskResponse.status,
95+
message: "Data pattern upload failed"
96+
};
97+
}
98+
99+
// Task is still in progress, wait before checking again
100+
await sleep(pollIntervalMs);
101+
} catch (error) {
102+
console.error("Error checking batch task status:", error);
103+
// For critical errors (auth, not found, etc.), stop polling and return error
104+
if (error instanceof Error &&
105+
(error.message.includes('Authentication failed') ||
106+
error.message.includes('not found') ||
107+
error.message.includes('Access denied'))) {
108+
return {
109+
success: false,
110+
status: "error",
111+
message: error.message
112+
};
113+
}
114+
// For network errors, continue polling with backoff
115+
await sleep(pollIntervalMs);
116+
}
117+
}
118+
119+
// Timeout reached
120+
return {
121+
success: false,
122+
status: "timeout",
123+
message: `Data pattern upload timed out after ${timeoutMs / 1000} seconds`
124+
};
125+
};
126+
127+
export const apiV1_0UploadDataPatterns = (baseUrl: string, apiToken: string) => {
128+
return {
129+
name: "API-v1_0_upload-data-patterns_create",
130+
description:
131+
"Upload data pattern CSV to test case and wait for completion. This operation runs synchronously and will wait for the upload to finish before returning.",
132+
inputSchema: z.object({
133+
organizationName: z
134+
.string()
135+
.describe("The organization name"),
136+
projectName: z.string().describe("The project name"),
137+
testCaseNumber: z
138+
.number()
139+
.int()
140+
.describe("The test case number"),
141+
localFilePath: z
142+
.string()
143+
.describe(
144+
"A local file path to upload CSV data pattern to MagicPod. Note that an absolute path is required. Its extension must be .csv",
145+
),
146+
overwrite: z
147+
.boolean()
148+
.optional()
149+
.default(false)
150+
.describe(
151+
"If true, overwrite the existing data pattern by the uploaded CSV file. If false, an error is raised if the data pattern already exists.",
152+
),
153+
}),
154+
handleRequest: async ({
155+
organizationName,
156+
projectName,
157+
testCaseNumber,
158+
localFilePath,
159+
overwrite = false
160+
}) => {
161+
try {
162+
if (!fs.existsSync(localFilePath)) {
163+
return {
164+
content: [
165+
{
166+
type: "text",
167+
text: JSON.stringify({
168+
error: "No such file exists. Note that an absolute path is required",
169+
status: "file_not_found",
170+
}),
171+
},
172+
],
173+
};
174+
}
175+
176+
const fileExtension = path.extname(localFilePath).toLowerCase();
177+
if (fileExtension !== ".csv") {
178+
return {
179+
content: [
180+
{
181+
type: "text",
182+
text: JSON.stringify({
183+
error: "Invalid file extension. The file must be a CSV file (.csv)",
184+
status: "invalid_file_type",
185+
}),
186+
},
187+
],
188+
};
189+
}
190+
191+
const formData = new FormData();
192+
const fileStream = fs.createReadStream(localFilePath);
193+
const fileName = path.basename(localFilePath);
194+
formData.append("file", fileStream, fileName);
195+
formData.append("overwrite", overwrite.toString());
196+
197+
const url = `${baseUrl}/api/v1.0/${organizationName}/${projectName}/test-cases/${testCaseNumber}/start-upload-data-patterns/`;
198+
let response;
199+
200+
try {
201+
response = await axios.post(url, formData, {
202+
headers: {
203+
...formData.getHeaders(),
204+
Authorization: `Token ${apiToken}`,
205+
},
206+
});
207+
} catch (error: any) {
208+
if (error.response) {
209+
// HTTP error response from server
210+
const status = error.response.status;
211+
const errorData = error.response.data;
212+
213+
let errorMessage = "An error occurred during upload";
214+
215+
if (status === 400) {
216+
if (errorData && typeof errorData === 'object') {
217+
errorMessage = `Upload failed: ${JSON.stringify(errorData)}`;
218+
} else {
219+
errorMessage = `Upload failed with status ${status}: ${errorData}`;
220+
}
221+
} else if (status === 401) {
222+
errorMessage = "Authentication failed. Please check your API token.";
223+
} else if (status === 403) {
224+
errorMessage = "Access denied. You don't have permission to upload data patterns to this test case.";
225+
} else if (status === 404) {
226+
errorMessage = `Test case ${testCaseNumber} not found in project ${organizationName}/${projectName}.`;
227+
} else {
228+
errorMessage = `Upload failed with status ${status}: ${errorData}`;
229+
}
230+
231+
return {
232+
content: [
233+
{
234+
type: "text",
235+
text: JSON.stringify({
236+
error: errorMessage,
237+
status: status,
238+
}),
239+
},
240+
],
241+
};
242+
} else {
243+
// Network or other error
244+
const errorMessage = error.message || "Network error during upload";
245+
return {
246+
content: [
247+
{
248+
type: "text",
249+
text: JSON.stringify({
250+
error: errorMessage,
251+
status: "network_error",
252+
}),
253+
},
254+
],
255+
};
256+
}
257+
}
258+
259+
if (response.status !== 200) {
260+
return {
261+
content: [
262+
{
263+
type: "text",
264+
text: JSON.stringify({
265+
error: "Unexpected response status from upload API",
266+
status: response.status,
267+
}),
268+
},
269+
],
270+
};
271+
}
272+
273+
const batchTaskId = response.data.batch_task_id;
274+
if (!batchTaskId) {
275+
return {
276+
content: [
277+
{
278+
type: "text",
279+
text: JSON.stringify({
280+
error: "Upload started but no batch task ID was returned",
281+
status: "invalid_response",
282+
}),
283+
},
284+
],
285+
};
286+
}
287+
288+
// Wait for the batch task to complete
289+
const result = await waitForBatchTaskCompletion(
290+
baseUrl,
291+
apiToken,
292+
organizationName,
293+
projectName,
294+
batchTaskId
295+
);
296+
297+
if (result.success) {
298+
return {
299+
content: [
300+
{
301+
type: "text",
302+
text: JSON.stringify({
303+
message: result.message,
304+
batch_task_id: batchTaskId,
305+
status: result.status,
306+
}),
307+
},
308+
],
309+
};
310+
} else {
311+
return {
312+
content: [
313+
{
314+
type: "text",
315+
text: JSON.stringify({
316+
error: result.message,
317+
batch_task_id: batchTaskId,
318+
status: result.status,
319+
}),
320+
},
321+
],
322+
};
323+
}
324+
} catch (error) {
325+
console.error(
326+
"Failed to upload the data pattern CSV file: ",
327+
error instanceof Error ? error.message : String(error),
328+
);
329+
throw error;
330+
}
331+
},
332+
} satisfies OtherToolDefinition<any>;
333+
};

src/tools/magicpod-web-api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ const unsupportedPaths = [
3131
'/v1.0/{organization_name}/{project_name}/screenshots/{batch_task_id}/',
3232
'/v1.0/magicpod-clients/api/{os}/{tag_or_version}/',
3333
'/v1.0/magicpod-clients/local/{os}/{version}/',
34-
'/v1.0/{organization_name}/{project_name}/upload-file/'
34+
'/v1.0/{organization_name}/{project_name}/upload-file/',
35+
'/v1.0/{organization_name}/{project_name}/test-cases/{test_case_number}/start-upload-data-patterns/'
3536
];
3637

3738
export const initMagicPodApiProxy = async (

0 commit comments

Comments
 (0)