UNPKG

@patruff/server-curl

Version:

MCP server for making curl/HTTP requests to fetch resources from the internet, with optional image conversion

364 lines (363 loc) 18.1 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ListToolsRequestSchema, CallToolRequestSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; import fetch from 'node-fetch'; import { isValidCurlArgs } from "./types.js"; import fs from 'fs'; import path from 'path'; // Helper function to safely get error message const getErrorMessage = (error) => { if (error instanceof Error) return error.message; return String(error); }; // Import Sharp with a fallback mechanism let sharp; try { sharp = require('sharp'); // Reduce memory usage for large images sharp.cache(false); console.error("Sharp module loaded successfully"); } catch (err) { console.error("Warning: Sharp module could not be loaded:", getErrorMessage(err)); sharp = null; } class CurlServer { constructor() { this.server = new Server({ name: "curl-mcp-server", version: "1.1.0" }, { capabilities: { tools: {} } }); this.setupHandlers(); this.setupErrorHandling(); } setupErrorHandling() { this.server.onerror = (error) => { console.error("[MCP Error]", error); }; process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } setupHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{ name: "curl", description: "Make HTTP requests to fetch resources from the internet with optional conversion and saving", inputSchema: { type: "object", properties: { url: { type: "string", description: "The URL to request" }, method: { type: "string", description: "HTTP method (GET, POST, PUT, etc.)", default: "GET" }, headers: { type: "object", description: "HTTP headers to include in the request" }, body: { type: "string", description: "Request body (for POST, PUT, etc.)" }, timeout: { type: "number", description: "Request timeout in milliseconds", default: 30000 }, convertTo: { type: "string", description: "Convert image to this format (png, jpeg, jpg, webp). Will quickly revert to original format if conversion fails.", enum: ["png", "jpeg", "jpg", "webp", "gif"] }, saveAs: { type: "string", description: "Save the response to this filename (e.g., 'image.png'). Extension will be adjusted based on actual format." } }, required: ["url"] } }] })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name !== "curl") { throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`); } if (!isValidCurlArgs(request.params.arguments)) { throw new McpError(ErrorCode.InvalidParams, "Invalid curl arguments"); } try { const { url, method = "GET", headers = {}, body, timeout = 30000, convertTo, saveAs } = request.params.arguments; const options = { method, headers, timeout }; if (body && (method === "POST" || method === "PUT" || method === "PATCH")) { options.body = body; } console.error(`Making ${method} request to ${url}`); let response; try { response = await fetch(url, options); } catch (fetchError) { console.error(`Fetch error: ${getErrorMessage(fetchError)}`); throw new Error(`Failed to fetch URL: ${getErrorMessage(fetchError)}`); } const contentType = response.headers.get("Content-Type") || ""; // Infer content type from URL if not specified in headers let effectiveContentType = contentType; if (effectiveContentType === "application/octet-stream" && url.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)) { const ext = url.split('.').pop()?.toLowerCase(); if (ext === 'jpg' || ext === 'jpeg') effectiveContentType = "image/jpeg"; else if (ext === 'png') effectiveContentType = "image/png"; else if (ext === 'gif') effectiveContentType = "image/gif"; else if (ext === 'webp') effectiveContentType = "image/webp"; else if (ext === 'svg') effectiveContentType = "image/svg+xml"; } // Detect Replicate URLs (which are likely from Flux) const isReplicateUrl = url.includes('replicate.delivery'); if (isReplicateUrl && effectiveContentType === "application/octet-stream") { effectiveContentType = "image/webp"; // Flux usually generates WebP } // Check if response is binary (images, pdfs, etc.) const isBinary = effectiveContentType.includes("image/") || effectiveContentType.includes("application/pdf") || effectiveContentType.includes("application/octet-stream") || effectiveContentType.includes("audio/") || effectiveContentType.includes("video/"); // Convert headers to a plain object const responseHeaders = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); const result = { success: true, statusCode: response.status, headers: responseHeaders, contentType: effectiveContentType, isBinary }; if (isBinary) { // Get the binary data as a buffer let arrayBuffer; try { arrayBuffer = await response.arrayBuffer(); } catch (bufferError) { console.error(`Error reading response buffer: ${getErrorMessage(bufferError)}`); throw new Error(`Failed to read response: ${getErrorMessage(bufferError)}`); } let responseBuffer = Buffer.from(arrayBuffer); // Check if this is an image const isImage = effectiveContentType.includes("image/") || url.match(/\.(jpg|jpeg|png|gif|webp)$/i) || isReplicateUrl; let finalContentType = effectiveContentType; let converted = false; let originalFormat = effectiveContentType.split('/')[1] || 'bin'; let targetFormat = convertTo?.toLowerCase() || originalFormat; // Convert if requested and this is an image if (isImage && convertTo && sharp) { console.error(`Attempting to convert image from ${originalFormat} to ${targetFormat}`); try { // Set a timeout for conversion (3 seconds) const conversionPromise = new Promise((resolve, reject) => { // Use setTimeout to ensure we don't block forever const timeoutId = setTimeout(() => { reject(new Error("Conversion timed out after 3 seconds")); }, 3000); // Try to convert with Sharp (async () => { try { let sharpInstance = sharp(responseBuffer, { failOnError: false }); switch (targetFormat) { case 'png': sharpInstance = sharpInstance.png({ compressionLevel: 6 }); break; case 'jpeg': case 'jpg': sharpInstance = sharpInstance.jpeg({ quality: 85 }); break; case 'webp': sharpInstance = sharpInstance.webp({ quality: 85 }); break; case 'gif': sharpInstance = sharpInstance.gif(); break; } const newBuffer = await sharpInstance.toBuffer(); clearTimeout(timeoutId); resolve(newBuffer); } catch (error) { clearTimeout(timeoutId); reject(error); } })(); }); // Try to convert but fail fast responseBuffer = await conversionPromise; finalContentType = `image/${targetFormat}`; converted = true; console.error(`Conversion successful: ${effectiveContentType} to ${finalContentType}`); } catch (error) { console.error(`Conversion failed: ${getErrorMessage(error)} - using original format`); // Keep the original format targetFormat = originalFormat; } } else if (convertTo && !sharp) { console.error("Conversion requested but Sharp is not available - using original format"); } // Convert to base64 const base64Data = responseBuffer.toString('base64'); // Handle file saving let savedFilePath = null; if (saveAs) { try { // Parse the saveAs path to ensure correct extension let parsedPath = path.parse(saveAs); // Use the target format extension unless conversion failed let extension = '.' + targetFormat; // Build the final path with appropriate extension const finalPath = path.join(parsedPath.dir, parsedPath.name + extension); console.error(`Saving file to: ${finalPath}`); await fs.promises.writeFile(finalPath, responseBuffer); savedFilePath = finalPath; console.error(`File saved successfully`); } catch (saveError) { console.error(`Error saving file: ${getErrorMessage(saveError)}`); } } // Create metadata response const metadataResponse = { success: true, statusCode: response.status, contentType: finalContentType, originalContentType: converted ? effectiveContentType : undefined, isBinary: true, dataSize: responseBuffer.byteLength, dataFormat: targetFormat, converted, conversionAvailable: !!sharp, savedTo: savedFilePath }; // Only include base64 data if not saved to a file return { content: [ { type: "text", text: `Fetched binary content (${responseBuffer.byteLength} bytes, ${finalContentType})${converted ? ` - Converted from ${originalFormat}` : ''}${savedFilePath ? `\nSaved to: ${savedFilePath}` : ''}` }, // For images, return a viewable image ...(isImage ? [{ type: "image", data: base64Data, mimeType: finalContentType }] : []), { type: "text", text: JSON.stringify(metadataResponse, null, 2) }, // Only include base64 data if not saved to file (to keep response size reasonable) ...(savedFilePath ? [] : [ { type: "text", text: `--- BASE64_DATA_BEGINS (use with 'writeFile' and encoding: 'base64') ---\nSuggested extension: .${targetFormat}` }, { type: "text", text: base64Data }, { type: "text", text: `--- BASE64_DATA_ENDS ---` } ]) ] }; } else { // For text data, return as is result.data = await response.text(); // Save text to file if requested if (saveAs) { try { console.error(`Saving text to: ${saveAs}`); await fs.promises.writeFile(saveAs, result.data); result.savedTo = saveAs; console.error(`Text file saved successfully`); } catch (saveError) { console.error(`Error saving text file: ${getErrorMessage(saveError)}`); } } // For larger text responses, truncate the display but keep full data const displayText = result.data.length > 1000 ? `${result.data.substring(0, 1000)}... [${result.data.length} chars total]` : result.data; const responseWithTruncatedText = { ...result, data: displayText }; return { content: [{ type: "text", text: JSON.stringify(responseWithTruncatedText, null, 2) }] }; } } catch (error) { console.error("Error in curl tool:", error); const result = { success: false, error: error instanceof Error ? error.message : String(error) }; return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: true }; } }); } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error("Curl MCP server running on stdio"); if (sharp) { console.error("Image conversion is available using Sharp"); } else { console.error("WARNING: Sharp module not available, image conversion will be skipped"); } } } const server = new CurlServer(); server.run().catch(error => { console.error("Fatal error in MCP server:", getErrorMessage(error)); process.exit(1); });