magicpod-mcp-server 0.1.7 → 0.1.8
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/build/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { searchMagicpodArticles } from "./tools/search-magicpod-articles.js";
|
|
|
5
5
|
import { readMagicpodArticle } from "./tools/read-magicpod-article.js";
|
|
6
6
|
import { initMagicPodApiProxy } from "./tools/magicpod-web-api.js";
|
|
7
7
|
import { apiV1_0UploadFileCreate } from "./tools/api-v1-0-upload-file-create.js";
|
|
8
|
+
import { apiV1_0UploadDataPatterns } from "./tools/api-v1-0-upload-data-patterns.js";
|
|
8
9
|
const program = new Command();
|
|
9
10
|
program.option("--api-token <key>", "MagicPod API token to use");
|
|
10
11
|
program.option("--debug", "For internal debug use");
|
|
@@ -19,6 +20,7 @@ async function main() {
|
|
|
19
20
|
const baseUrl = baseUrlEnvironmentVariable || "https://app.magicpod.com";
|
|
20
21
|
const proxy = await initMagicPodApiProxy(baseUrl, options.apiToken, [
|
|
21
22
|
apiV1_0UploadFileCreate(baseUrl, options.apiToken),
|
|
23
|
+
apiV1_0UploadDataPatterns(baseUrl, options.apiToken),
|
|
22
24
|
searchMagicpodArticles(),
|
|
23
25
|
readMagicpodArticle(),
|
|
24
26
|
]);
|
|
@@ -12,7 +12,7 @@ export class MCPProxy {
|
|
|
12
12
|
openApiLookup;
|
|
13
13
|
constructor(name, openApiSpec, apiToken, otherTools) {
|
|
14
14
|
this.otherTools = otherTools;
|
|
15
|
-
this.server = new Server({ name, version: "0.1.
|
|
15
|
+
this.server = new Server({ name, version: "0.1.8" }, { capabilities: { tools: {} } });
|
|
16
16
|
const baseUrl = openApiSpec.servers?.[0].url;
|
|
17
17
|
if (!baseUrl) {
|
|
18
18
|
throw new Error("No base URL found in OpenAPI spec");
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import axios from "axios";
|
|
5
|
+
import FormData from "form-data";
|
|
6
|
+
const checkBatchTaskStatus = async (baseUrl, apiToken, organizationName, projectName, batchTaskId) => {
|
|
7
|
+
const url = `${baseUrl}/api/v1.0/${organizationName}/${projectName}/batch-task/${batchTaskId}/`;
|
|
8
|
+
try {
|
|
9
|
+
const response = await axios.get(url, {
|
|
10
|
+
headers: {
|
|
11
|
+
Authorization: `Token ${apiToken}`,
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
if (response.status !== 200) {
|
|
15
|
+
throw new Error(`Failed to check batch task status: ${response.status}`);
|
|
16
|
+
}
|
|
17
|
+
return response.data;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error.response) {
|
|
21
|
+
// HTTP error response from server
|
|
22
|
+
const status = error.response.status;
|
|
23
|
+
const errorData = error.response.data;
|
|
24
|
+
if (status === 401) {
|
|
25
|
+
throw new Error("Authentication failed while checking batch task status. Please check your API token.");
|
|
26
|
+
}
|
|
27
|
+
else if (status === 403) {
|
|
28
|
+
throw new Error("Access denied while checking batch task status.");
|
|
29
|
+
}
|
|
30
|
+
else if (status === 404) {
|
|
31
|
+
throw new Error(`Batch task ${batchTaskId} not found.`);
|
|
32
|
+
}
|
|
33
|
+
else if (status === 400) {
|
|
34
|
+
const errorMsg = typeof errorData === 'object' && errorData.detail
|
|
35
|
+
? errorData.detail
|
|
36
|
+
: errorData;
|
|
37
|
+
throw new Error(`Invalid batch task request: ${errorMsg}`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
throw new Error(`Failed to check batch task status: HTTP ${status} - ${errorData}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
// Network or other error
|
|
45
|
+
throw new Error(`Network error while checking batch task status: ${error.message || error}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const sleep = (ms) => {
|
|
50
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
51
|
+
};
|
|
52
|
+
const waitForBatchTaskCompletion = async (baseUrl, apiToken, organizationName, projectName, batchTaskId, timeoutMs = 5 * 60 * 1000, // 5 minutes default
|
|
53
|
+
pollIntervalMs = 3000 // 3 seconds default
|
|
54
|
+
) => {
|
|
55
|
+
const startTime = Date.now();
|
|
56
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
57
|
+
try {
|
|
58
|
+
const taskResponse = await checkBatchTaskStatus(baseUrl, apiToken, organizationName, projectName, batchTaskId);
|
|
59
|
+
if (taskResponse.status === "succeeded") {
|
|
60
|
+
return {
|
|
61
|
+
success: true,
|
|
62
|
+
status: taskResponse.status,
|
|
63
|
+
message: "Data pattern upload completed successfully"
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
else if (taskResponse.status === "failed") {
|
|
67
|
+
return {
|
|
68
|
+
success: false,
|
|
69
|
+
status: taskResponse.status,
|
|
70
|
+
message: "Data pattern upload failed"
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// Task is still in progress, wait before checking again
|
|
74
|
+
await sleep(pollIntervalMs);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.error("Error checking batch task status:", error);
|
|
78
|
+
// For critical errors (auth, not found, etc.), stop polling and return error
|
|
79
|
+
if (error instanceof Error &&
|
|
80
|
+
(error.message.includes('Authentication failed') ||
|
|
81
|
+
error.message.includes('not found') ||
|
|
82
|
+
error.message.includes('Access denied'))) {
|
|
83
|
+
return {
|
|
84
|
+
success: false,
|
|
85
|
+
status: "error",
|
|
86
|
+
message: error.message
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// For network errors, continue polling with backoff
|
|
90
|
+
await sleep(pollIntervalMs);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Timeout reached
|
|
94
|
+
return {
|
|
95
|
+
success: false,
|
|
96
|
+
status: "timeout",
|
|
97
|
+
message: `Data pattern upload timed out after ${timeoutMs / 1000} seconds`
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
export const apiV1_0UploadDataPatterns = (baseUrl, apiToken) => {
|
|
101
|
+
return {
|
|
102
|
+
name: "API-v1_0_upload-data-patterns_create",
|
|
103
|
+
description: "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.",
|
|
104
|
+
inputSchema: z.object({
|
|
105
|
+
organizationName: z
|
|
106
|
+
.string()
|
|
107
|
+
.describe("The organization name"),
|
|
108
|
+
projectName: z.string().describe("The project name"),
|
|
109
|
+
testCaseNumber: z
|
|
110
|
+
.number()
|
|
111
|
+
.int()
|
|
112
|
+
.describe("The test case number"),
|
|
113
|
+
localFilePath: z
|
|
114
|
+
.string()
|
|
115
|
+
.describe("A local file path to upload CSV data pattern to MagicPod. Note that an absolute path is required. Its extension must be .csv"),
|
|
116
|
+
overwrite: z
|
|
117
|
+
.boolean()
|
|
118
|
+
.optional()
|
|
119
|
+
.default(false)
|
|
120
|
+
.describe("If true, overwrite the existing data pattern by the uploaded CSV file. If false, an error is raised if the data pattern already exists."),
|
|
121
|
+
}),
|
|
122
|
+
handleRequest: async ({ organizationName, projectName, testCaseNumber, localFilePath, overwrite = false }) => {
|
|
123
|
+
try {
|
|
124
|
+
if (!fs.existsSync(localFilePath)) {
|
|
125
|
+
return {
|
|
126
|
+
content: [
|
|
127
|
+
{
|
|
128
|
+
type: "text",
|
|
129
|
+
text: JSON.stringify({
|
|
130
|
+
error: "No such file exists. Note that an absolute path is required",
|
|
131
|
+
status: "file_not_found",
|
|
132
|
+
}),
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const fileExtension = path.extname(localFilePath).toLowerCase();
|
|
138
|
+
if (fileExtension !== ".csv") {
|
|
139
|
+
return {
|
|
140
|
+
content: [
|
|
141
|
+
{
|
|
142
|
+
type: "text",
|
|
143
|
+
text: JSON.stringify({
|
|
144
|
+
error: "Invalid file extension. The file must be a CSV file (.csv)",
|
|
145
|
+
status: "invalid_file_type",
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const formData = new FormData();
|
|
152
|
+
const fileStream = fs.createReadStream(localFilePath);
|
|
153
|
+
const fileName = path.basename(localFilePath);
|
|
154
|
+
formData.append("file", fileStream, fileName);
|
|
155
|
+
formData.append("overwrite", overwrite.toString());
|
|
156
|
+
const url = `${baseUrl}/api/v1.0/${organizationName}/${projectName}/test-cases/${testCaseNumber}/start-upload-data-patterns/`;
|
|
157
|
+
let response;
|
|
158
|
+
try {
|
|
159
|
+
response = await axios.post(url, formData, {
|
|
160
|
+
headers: {
|
|
161
|
+
...formData.getHeaders(),
|
|
162
|
+
Authorization: `Token ${apiToken}`,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (error.response) {
|
|
168
|
+
// HTTP error response from server
|
|
169
|
+
const status = error.response.status;
|
|
170
|
+
const errorData = error.response.data;
|
|
171
|
+
let errorMessage = "An error occurred during upload";
|
|
172
|
+
if (status === 400) {
|
|
173
|
+
if (errorData && typeof errorData === 'object') {
|
|
174
|
+
errorMessage = `Upload failed: ${JSON.stringify(errorData)}`;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
errorMessage = `Upload failed with status ${status}: ${errorData}`;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else if (status === 401) {
|
|
181
|
+
errorMessage = "Authentication failed. Please check your API token.";
|
|
182
|
+
}
|
|
183
|
+
else if (status === 403) {
|
|
184
|
+
errorMessage = "Access denied. You don't have permission to upload data patterns to this test case.";
|
|
185
|
+
}
|
|
186
|
+
else if (status === 404) {
|
|
187
|
+
errorMessage = `Test case ${testCaseNumber} not found in project ${organizationName}/${projectName}.`;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
errorMessage = `Upload failed with status ${status}: ${errorData}`;
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
content: [
|
|
194
|
+
{
|
|
195
|
+
type: "text",
|
|
196
|
+
text: JSON.stringify({
|
|
197
|
+
error: errorMessage,
|
|
198
|
+
status: status,
|
|
199
|
+
}),
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
// Network or other error
|
|
206
|
+
const errorMessage = error.message || "Network error during upload";
|
|
207
|
+
return {
|
|
208
|
+
content: [
|
|
209
|
+
{
|
|
210
|
+
type: "text",
|
|
211
|
+
text: JSON.stringify({
|
|
212
|
+
error: errorMessage,
|
|
213
|
+
status: "network_error",
|
|
214
|
+
}),
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (response.status !== 200) {
|
|
221
|
+
return {
|
|
222
|
+
content: [
|
|
223
|
+
{
|
|
224
|
+
type: "text",
|
|
225
|
+
text: JSON.stringify({
|
|
226
|
+
error: "Unexpected response status from upload API",
|
|
227
|
+
status: response.status,
|
|
228
|
+
}),
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const batchTaskId = response.data.batch_task_id;
|
|
234
|
+
if (!batchTaskId) {
|
|
235
|
+
return {
|
|
236
|
+
content: [
|
|
237
|
+
{
|
|
238
|
+
type: "text",
|
|
239
|
+
text: JSON.stringify({
|
|
240
|
+
error: "Upload started but no batch task ID was returned",
|
|
241
|
+
status: "invalid_response",
|
|
242
|
+
}),
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
// Wait for the batch task to complete
|
|
248
|
+
const result = await waitForBatchTaskCompletion(baseUrl, apiToken, organizationName, projectName, batchTaskId);
|
|
249
|
+
if (result.success) {
|
|
250
|
+
return {
|
|
251
|
+
content: [
|
|
252
|
+
{
|
|
253
|
+
type: "text",
|
|
254
|
+
text: JSON.stringify({
|
|
255
|
+
message: result.message,
|
|
256
|
+
batch_task_id: batchTaskId,
|
|
257
|
+
status: result.status,
|
|
258
|
+
}),
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
return {
|
|
265
|
+
content: [
|
|
266
|
+
{
|
|
267
|
+
type: "text",
|
|
268
|
+
text: JSON.stringify({
|
|
269
|
+
error: result.message,
|
|
270
|
+
batch_task_id: batchTaskId,
|
|
271
|
+
status: result.status,
|
|
272
|
+
}),
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
console.error("Failed to upload the data pattern CSV file: ", error instanceof Error ? error.message : String(error));
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
};
|
|
@@ -3,10 +3,106 @@ import fs from "fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import axios from "axios";
|
|
5
5
|
import FormData from "form-data";
|
|
6
|
+
import { createReadStream } from "fs";
|
|
7
|
+
import { Transform } from "stream";
|
|
8
|
+
const ALLOWED_EXTENSIONS = ['.apk', '.aab', '.ipa', '.zip'];
|
|
9
|
+
const isValidFileExtension = (filePath) => {
|
|
10
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
11
|
+
return ALLOWED_EXTENSIONS.includes(ext);
|
|
12
|
+
};
|
|
13
|
+
const isValidZipWithApp = async (filePath) => {
|
|
14
|
+
if (path.extname(filePath).toLowerCase() !== '.zip') {
|
|
15
|
+
return true; // Not a zip file, skip this validation
|
|
16
|
+
}
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const fileStream = createReadStream(filePath);
|
|
19
|
+
let buffer = Buffer.alloc(0);
|
|
20
|
+
let hasAppDirectory = false;
|
|
21
|
+
let bytesRead = 0;
|
|
22
|
+
const MAX_BYTES_TO_READ = 10 * 1024 * 1024; // 10MB limit for safety
|
|
23
|
+
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
|
24
|
+
const zipParser = new Transform({
|
|
25
|
+
transform(chunk, _encoding, callback) {
|
|
26
|
+
if (hasAppDirectory || bytesRead > MAX_BYTES_TO_READ) {
|
|
27
|
+
callback();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
bytesRead += chunk.length;
|
|
31
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
32
|
+
// Parse ZIP local file headers
|
|
33
|
+
let offset = 0;
|
|
34
|
+
while (offset < buffer.length - 30) { // Minimum header size is 30 bytes
|
|
35
|
+
// Look for local file header signature
|
|
36
|
+
const signature = buffer.readUInt32LE(offset);
|
|
37
|
+
if (signature !== LOCAL_FILE_HEADER_SIGNATURE) {
|
|
38
|
+
offset++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
// Read filename length from header (at offset 26)
|
|
42
|
+
if (offset + 30 > buffer.length)
|
|
43
|
+
break;
|
|
44
|
+
const filenameLength = buffer.readUInt16LE(offset + 26);
|
|
45
|
+
const extraFieldLength = buffer.readUInt16LE(offset + 28);
|
|
46
|
+
// Check if we have the complete entry
|
|
47
|
+
if (offset + 30 + filenameLength > buffer.length) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
// Extract filename
|
|
51
|
+
const filename = buffer.subarray(offset + 30, offset + 30 + filenameLength).toString('utf8');
|
|
52
|
+
// Check if this is a .app directory (directories in ZIP end with /)
|
|
53
|
+
if (filename.toLowerCase().endsWith('.app/')) {
|
|
54
|
+
hasAppDirectory = true;
|
|
55
|
+
callback();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Move to next entry
|
|
59
|
+
offset += 30 + filenameLength + extraFieldLength;
|
|
60
|
+
}
|
|
61
|
+
// Keep last 1KB of buffer for potential split headers
|
|
62
|
+
if (buffer.length > 1024) {
|
|
63
|
+
buffer = buffer.subarray(buffer.length - 1024);
|
|
64
|
+
}
|
|
65
|
+
callback();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
fileStream.pipe(zipParser);
|
|
69
|
+
fileStream.on('end', () => {
|
|
70
|
+
resolve(hasAppDirectory);
|
|
71
|
+
});
|
|
72
|
+
fileStream.on('error', () => {
|
|
73
|
+
resolve(false);
|
|
74
|
+
});
|
|
75
|
+
zipParser.on('error', () => {
|
|
76
|
+
resolve(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
const validateFile = async (filePath) => {
|
|
81
|
+
if (!fs.existsSync(filePath)) {
|
|
82
|
+
return { valid: false, error: "No such file exists. Note that an absolute path is required" };
|
|
83
|
+
}
|
|
84
|
+
if (!isValidFileExtension(filePath)) {
|
|
85
|
+
return {
|
|
86
|
+
valid: false,
|
|
87
|
+
error: "Invalid file type. Only .apk, .aab, .ipa files, or zipped .app files are allowed"
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
91
|
+
if (ext === '.zip') {
|
|
92
|
+
const hasValidApp = await isValidZipWithApp(filePath);
|
|
93
|
+
if (!hasValidApp) {
|
|
94
|
+
return {
|
|
95
|
+
valid: false,
|
|
96
|
+
error: "ZIP file must contain an .app directory to be valid"
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { valid: true };
|
|
101
|
+
};
|
|
6
102
|
export const apiV1_0UploadFileCreate = (baseUrl, apiToken) => {
|
|
7
103
|
return {
|
|
8
104
|
name: "API-v1_0_upload-file_create",
|
|
9
|
-
description: "Upload target app files (.
|
|
105
|
+
description: "Upload target app files (.ipa, .apk, .aab, or zipped .app) to MagicPod cloud",
|
|
10
106
|
inputSchema: z.object({
|
|
11
107
|
organizationName: z
|
|
12
108
|
.string()
|
|
@@ -14,16 +110,17 @@ export const apiV1_0UploadFileCreate = (baseUrl, apiToken) => {
|
|
|
14
110
|
projectName: z.string().describe("The project name to upload the file"),
|
|
15
111
|
localFilePath: z
|
|
16
112
|
.string()
|
|
17
|
-
.describe("A local file path to upload to MagicPod. Note that an absolute path is required.
|
|
113
|
+
.describe("A local file path to upload to MagicPod. Note that an absolute path is required. Supported formats: .ipa, .apk, .aab files, or .zip files containing .app directories"),
|
|
18
114
|
}),
|
|
19
115
|
handleRequest: async ({ organizationName, projectName, localFilePath }) => {
|
|
20
116
|
try {
|
|
21
|
-
|
|
117
|
+
const validation = await validateFile(localFilePath);
|
|
118
|
+
if (!validation.valid) {
|
|
22
119
|
return {
|
|
23
120
|
content: [
|
|
24
121
|
{
|
|
25
122
|
type: "text",
|
|
26
|
-
text:
|
|
123
|
+
text: validation.error,
|
|
27
124
|
},
|
|
28
125
|
],
|
|
29
126
|
};
|
|
@@ -27,7 +27,8 @@ const unsupportedPaths = [
|
|
|
27
27
|
'/v1.0/{organization_name}/{project_name}/screenshots/{batch_task_id}/',
|
|
28
28
|
'/v1.0/magicpod-clients/api/{os}/{tag_or_version}/',
|
|
29
29
|
'/v1.0/magicpod-clients/local/{os}/{version}/',
|
|
30
|
-
'/v1.0/{organization_name}/{project_name}/upload-file/'
|
|
30
|
+
'/v1.0/{organization_name}/{project_name}/upload-file/',
|
|
31
|
+
'/v1.0/{organization_name}/{project_name}/test-cases/{test_case_number}/start-upload-data-patterns/'
|
|
31
32
|
];
|
|
32
33
|
export const initMagicPodApiProxy = async (baseUrl, apiToken, tools) => {
|
|
33
34
|
const schemaUrl = `${baseUrl}/api/v1.0/doc/?format=openapi`;
|