@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
JavaScript
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);
});