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.7" }, { capabilities: { tools: {} } });
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 (.app, .ipa, .apk or .aab) to MagicPod cloud",
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. Its extension must be .app, .ipa, .apk or .aab"),
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
- if (!fs.existsSync(localFilePath)) {
117
+ const validation = await validateFile(localFilePath);
118
+ if (!validation.valid) {
22
119
  return {
23
120
  content: [
24
121
  {
25
122
  type: "text",
26
- text: "No such file exists. Note that an absolute path is required",
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`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magicpod-mcp-server",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Model Context Protocol server for MagicPod integration",
5
5
  "type": "module",
6
6
  "bin": {