proofly-mcp
Version:
Proofly MCP Client for Claude Desktop and other MCP-compatible clients
575 lines (505 loc) • 21.9 kB
JavaScript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import axios from 'axios';
import FormData from 'form-data';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function getPackageVersion() {
try {
const packageJsonPath = path.join(__dirname, 'package.json');
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageJsonContent);
return packageJson.version || 'unknown';
} catch (error) {
logError('Failed to read package.json for version:', error);
return 'unknown';
}
}
const PACKAGE_VERSION = getPackageVersion();
const PROOFLY_CONFIG = {
baseUrl: "https://api.proofly.ai",
apiKey: process.env.PROOFLY_API_KEY,
maxRetries: 60,
retryInterval: 2000
};
function logError(...args) {
console.error(`[${new Date().toISOString()}] [ProoflyMCPError]`, ...args);
}
function logInfo(...args) {
console.error(`[${new Date().toISOString()}] [ProoflyMCPInfo]`, ...args);
}
// Helper function to determine Content-Type by filename extension
function getContentTypeFromFilename(filename) {
const extension = filename.split('.').pop().toLowerCase();
const contentTypes = {
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'webp': 'image/webp',
'bmp': 'image/bmp',
'tiff': 'image/tiff',
'tif': 'image/tiff'
};
return contentTypes[extension] || 'application/octet-stream';
}
// Helper function to determine verdict based on probability
function getVerdict(probability) {
if (probability === null || typeof probability === 'undefined') return "Uncertain (no score)";
if (probability > 0.8) {
return "Likely Real";
} else if (probability < 0.2) {
return "Likely Fake";
} else {
return "Uncertain";
}
}
// Helper function to format results in a human-readable form
function formatResultsToHumanReadable(result) {
if (!result) return "Error: No result to format.";
let output = `**Image Analysis Results:**\n`;
output += `* Session UUID: ${result.uuid || 'N/A'}\n`;
if (result.sha256) {
output += `* SHA256 hash: ${result.sha256}\n`;
}
output += `* Status: ${result.status || 'N/A'}\n`;
if (result.message && (result.status === 'no_faces_found' || result.status === 'no faces found')) {
output += `* Message: ${result.message}\n`;
} else if (result.faces && result.faces.length > 0) {
output += `* Faces detected: ${result.total_faces || result.faces.length}\n\n`;
result.faces.forEach((face, index) => {
const faceVerdict = getVerdict(face.ansamble);
output += `**Face ${index + 1}:**\n`;
output += `* Verdict: **${faceVerdict}**\n`;
if (typeof face.ansamble !== 'undefined' && face.ansamble !== null) {
output += `* Probability "real": ${(face.ansamble * 100).toFixed(2)}%, "fake": ${(100 - face.ansamble * 100).toFixed(2)}%\n`;
}
if (face.is_real_model_1 !== undefined) {
output += `* Individual model results:\n`;
for (let i = 1; i <= 10; i++) {
if (face[`is_real_model_${i}`] !== undefined) {
output += ` - Model ${i}: ${(face[`is_real_model_${i}`] * 100).toFixed(2)}%\n`;
}
}
}
if (face.face_path) {
let faceImageUrl = `${PROOFLY_CONFIG.baseUrl}${face.face_path}`;
if (faceImageUrl.includes('ai./')) {
faceImageUrl = faceImageUrl.replace('ai./', 'ai/');
}
output += `* Face image URL: ${faceImageUrl}\n`;
}
output += `\n`;
});
} else if (result.status === 'done' || result.status === 'completed') { // If status is done but no faces
output += `* No faces detected in the image\n\n`;
} else if (!result.message) { // If there is no message and no faces (just in case)
output += `* No specific face data available or an issue occurred during processing.\n\n`;
}
output += `For unlimited speed access and additional features, register at check.proofly.ai\n`;
return output;
}
class ProoflyMcpServer {
constructor() {
logInfo(`Proofly MCP Server v${PACKAGE_VERSION} starting...`);
logInfo(`Node.js version: ${process.version}`);
logInfo(`PID: ${process.pid}`);
if (!PROOFLY_CONFIG.apiKey) {
logInfo("Warning: PROOFLY_API_KEY environment variable is not set. This might be required for some configurations or future use.");
}
this.mcpServer = new Server(
{
name: "proofly",
version: PACKAGE_VERSION,
},
{
capabilities: {
tools: {},
},
}
);
this.setupErrorHandlers();
this.setupSignalHandlers();
this.setupToolHandlers();
}
setupErrorHandlers() {
this.mcpServer.onerror = (error) => {
logError("MCP Server internal error:", error);
};
process.on('uncaughtException', (err, origin) => {
logError('Uncaught Exception:', err, 'Origin:', origin);
});
process.on('unhandledRejection', (reason, promise) => {
logError('Unhandled Rejection at:', promise, 'reason:', reason);
});
}
setupSignalHandlers() {
const signals = ['SIGINT', 'SIGTERM', 'SIGQUIT'];
signals.forEach(signal => {
process.on(signal, async () => {
logInfo(`Received ${signal}. Shutting down gracefully.`);
try {
await this.mcpServer.close();
logInfo("MCP Server closed successfully.");
} catch (e) {
logError("Error during MCP server shutdown:", e);
} finally {
process.exit(0);
}
});
});
}
setupToolHandlers() {
const tools = [
{
name: "analyze-image",
description: "Analyzes an image provided as a base64 string for deepfake detection.",
inputSchema: {
type: "object",
properties: {
imageBase64: { type: "string", description: "Base64 encoded image data." },
filename: { type: "string", description: "Original filename with extension (e.g., 'image.jpg')." },
format: { type: "string", enum: ["json", "text"], default: "text", description: "Output format." },
},
required: ["imageBase64", "filename"],
},
},
{
name: "analyze",
description: "Analyzes an image from a URL for deepfake detection.",
inputSchema: {
type: "object",
properties: {
imageUrl: { type: "string", description: "URL of the image to analyze." },
format: { type: "string", enum: ["json", "text"], default: "text", description: "Output format." },
},
required: ["imageUrl"],
},
},
{
name: "check-session-status",
description: "Check the status of a deepfake analysis session.",
inputSchema: {
type: "object",
properties: {
sessionUuid: { type: "string", description: "Session UUID to check status for." },
format: { type: "string", enum: ["json", "text"], default: "text", description: "Output format." },
},
required: ["sessionUuid"],
},
},
{
name: "get-face-details",
description: "Get detailed information about a specific face detected in an image.",
inputSchema: {
type: "object",
properties: {
sessionUuid: { type: "string", description: "Session UUID from the analyze-image result." },
faceIndex: { type: "number", description: "Index of the face to get details for (starting from 0)." },
format: { type: "string", enum: ["json", "text"], default: "text", description: "Output format." },
},
required: ["sessionUuid", "faceIndex"],
},
},
];
this.mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
this.mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
logInfo(`Received callTool request for: ${request.params.name}`, request.params.arguments);
const { name, arguments: args } = request.params;
try {
switch (name) {
case "analyze-image":
return await this.handleAnalyzeImage(args);
case "analyze":
return await this.handleAnalyzeImageUrl(args);
case "check-session-status":
return await this.handleCheckSessionStatus(args);
case "get-face-details":
return await this.handleGetFaceDetails(args);
default:
throw new McpError(ErrorCode.InvalidTool, `Unknown tool: ${name}`);
}
} catch (error) {
logError(`Error calling Proofly API for tool ${name}:`, error.message, error.stack);
if (error instanceof McpError) throw error;
throw new McpError(ErrorCode.INTERNAL_ERROR, `Failed to execute tool ${name}: ${error.message}`);
}
});
}
async handleAnalyzeImage(params) {
logInfo("Handling analyze-image with params:", params);
const { imageBase64, filename, format = 'text' } = params;
if (!PROOFLY_CONFIG.apiKey) {
logInfo("Warning: PROOFLY_API_KEY not set. Proceeding without API key for analyze-image.");
}
if (!imageBase64 || !filename) {
throw new McpError(ErrorCode.InvalidParams, "Missing imageBase64 or filename parameter");
}
try {
let actualImageBase64 = imageBase64;
if (imageBase64.startsWith('data:image')) {
actualImageBase64 = imageBase64.split(',')[1];
logInfo('Removed data URI prefix from base64 string');
}
const imageBuffer = Buffer.from(actualImageBase64, 'base64');
logInfo(`Decoded image size: ${(imageBuffer.length / 1024).toFixed(2)} KB`);
const formData = new FormData();
formData.append('file', imageBuffer, {
filename: filename,
contentType: getContentTypeFromFilename(filename)
});
logInfo(`Uploading image to ${PROOFLY_CONFIG.baseUrl}/api/upload...`);
const uploadResponse = await axios.post(`${PROOFLY_CONFIG.baseUrl}/api/upload`, formData, {
headers: {
...formData.getHeaders(),
}
});
logInfo('Upload response:', uploadResponse.data);
const { uuid } = uploadResponse.data;
if (!uuid) {
throw new Error('No session UUID returned from Proofly API after upload');
}
logInfo(`Received UUID: ${uuid}`);
let status = 'in progress';
let attempts = 0;
let analysisResult;
while (status === 'in progress' || status === 'processing' || status === 'pending') {
if (attempts >= PROOFLY_CONFIG.maxRetries) {
throw new Error('Maximum retry attempts reached for status check.');
}
await new Promise(res => setTimeout(res, PROOFLY_CONFIG.retryInterval));
attempts++;
logInfo(`Checking status for UUID ${uuid}, attempt ${attempts}`);
const statusResp = await axios.get(`${PROOFLY_CONFIG.baseUrl}/api/${uuid}/status`);
status = statusResp.data.status;
logInfo(`Status: ${status}`);
if (statusResp.data.result) {
analysisResult = statusResp.data.result;
}
}
if (status !== 'done' && status !== 'completed' && !analysisResult) {
if (status === 'no_faces_found' || status === 'no faces found') {
logInfo('No faces found in the image as per status update.');
analysisResult = {
uuid: uuid,
status: 'no_faces_found',
message: 'No faces detected in the image.',
faces: [],
total_faces: 0
};
} else {
throw new Error(`Analysis did not complete successfully. Last status: ${status}`);
}
}
if (!analysisResult) {
logInfo(`Fetching final result for UUID ${uuid}`);
const resultResp = await axios.get(`${PROOFLY_CONFIG.baseUrl}/api/${uuid}`);
analysisResult = resultResp.data;
}
logInfo('Final analysis result:', analysisResult);
if (format === 'json') {
return { content: [{ type: "text", text: JSON.stringify(analysisResult, null, 2) }] };
} else {
return { content: [{ type: "text", text: formatResultsToHumanReadable(analysisResult) }] };
}
} catch (error) {
logError("Error in handleAnalyzeImage:", error.message);
if (error.response) {
logError("Error response data:", error.response.data);
logError("Error response status:", error.response.status);
}
throw new McpError(ErrorCode.ServerError, `Failed to analyze image: ${error.message}`);
}
}
async handleAnalyzeImageUrl(params) {
logInfo("Handling analyze with params:", params);
const { imageUrl, format = 'text' } = params;
if (!PROOFLY_CONFIG.apiKey) {
logInfo("Warning: PROOFLY_API_KEY not set. Proceeding without API key for analyze-image-url.");
}
if (!imageUrl) {
throw new McpError(ErrorCode.InvalidParams, "Missing imageUrl parameter");
}
try {
const imageResponse = await axios.get(imageUrl, { responseType: 'arraybuffer' });
const imageBuffer = Buffer.from(imageResponse.data);
logInfo(`Image downloaded, size: ${(imageBuffer.length / 1024).toFixed(2)} KB`);
const urlParts = imageUrl.split('/');
const originalFilename = urlParts[urlParts.length - 1].split('?')[0] || 'image.jpg';
const formData = new FormData();
formData.append('file', imageBuffer, {
filename: originalFilename,
contentType: imageResponse.headers['content-type'] || 'application/octet-stream'
});
logInfo(`Uploading image to ${PROOFLY_CONFIG.baseUrl}/api/upload...`);
const uploadResponse = await axios.post(`${PROOFLY_CONFIG.baseUrl}/api/upload`, formData, {
headers: {
...formData.getHeaders(),
}
});
logInfo('Upload response:', uploadResponse.data);
const { uuid } = uploadResponse.data;
if (!uuid) {
throw new Error('No session UUID returned from Proofly API after upload');
}
logInfo(`Received UUID: ${uuid}`);
let status = 'in progress';
let attempts = 0;
let analysisResult;
while (status === 'in progress' || status === 'processing' || status === 'pending') {
if (attempts >= PROOFLY_CONFIG.maxRetries) {
throw new Error('Maximum retry attempts reached for status check.');
}
await new Promise(res => setTimeout(res, PROOFLY_CONFIG.retryInterval));
attempts++;
logInfo(`Checking status for UUID ${uuid}, attempt ${attempts}`);
const statusResp = await axios.get(`${PROOFLY_CONFIG.baseUrl}/api/${uuid}/status`);
status = statusResp.data.status;
logInfo(`Status: ${status}`);
if (statusResp.data.result) {
analysisResult = statusResp.data.result;
}
}
if (status !== 'done' && status !== 'completed' && !analysisResult) {
if (status === 'no_faces_found' || status === 'no faces found') {
logInfo('No faces found in the image as per status update.');
analysisResult = {
uuid: uuid, // Add uuid to result for consistency
status: 'no_faces_found',
message: 'No faces detected in the image.',
faces: [],
total_faces: 0
};
} else {
throw new Error(`Analysis did not complete successfully. Last status: ${status}`);
}
}
if (!analysisResult) {
logInfo(`Fetching final result for UUID ${uuid}`);
const resultResp = await axios.get(`${PROOFLY_CONFIG.baseUrl}/api/${uuid}`);
analysisResult = resultResp.data;
}
logInfo('Final analysis result:', analysisResult);
if (format === 'json') {
return { content: [{ type: "text", text: JSON.stringify(analysisResult, null, 2) }] };
} else {
return { content: [{ type: "text", text: formatResultsToHumanReadable(analysisResult) }] };
}
} catch (error) {
logError("Error in handleAnalyzeImageUrl:", error.message);
if (error.response) {
logError("Error response data:", error.response.data);
logError("Error response status:", error.response.status);
}
throw new McpError(ErrorCode.ServerError, `Failed to analyze image URL: ${error.message}`);
}
}
async handleCheckSessionStatus(params) {
logInfo("Handling check-session-status with params:", params);
const { sessionUuid, format = 'text' } = params;
if (!sessionUuid) {
throw new McpError(ErrorCode.InvalidParams, "Missing sessionUuid parameter");
}
try {
logInfo(`Fetching status for UUID ${sessionUuid}`);
const statusResp = await axios.get(`${PROOFLY_CONFIG.baseUrl}/api/${sessionUuid}/status`);
const statusData = statusResp.data;
logInfo(`Status data for ${sessionUuid}:`, statusData);
if (format === 'json') {
return { content: [{ type: "text", text: JSON.stringify(statusData, null, 2) }] };
} else {
// Simple text representation of the status
let output = `**Session Status for ${sessionUuid}:**\n`;
output += `* Status: ${statusData.status || 'N/A'}\n`;
if (statusData.message) {
output += `* Message: ${statusData.message}\n`;
}
if (statusData.result) {
output += `* Result available: Yes\n`;
}
return { content: [{ type: "text", text: output }] };
}
} catch (error) {
logError("Error in handleCheckSessionStatus:", error.message);
if (error.response) {
logError("Error response data:", error.response.data);
logError("Error response status:", error.response.status);
}
// If API returns 404 for unknown UUID, this may be expected
if (error.response && error.response.status === 404) {
throw new McpError(ErrorCode.NotFound, `Session with UUID ${sessionUuid} not found.`);
}
throw new McpError(ErrorCode.ServerError, `Failed to check session status: ${error.message}`);
}
}
async handleGetFaceDetails(params) {
logInfo("Handling get-face-details with params:", params);
const { sessionUuid, faceIndex, format = 'text' } = params;
if (!sessionUuid || typeof faceIndex !== 'number' || faceIndex < 0) {
throw new McpError(ErrorCode.InvalidParams, "Missing or invalid sessionUuid or faceIndex parameter");
}
try {
logInfo(`Fetching full analysis data for UUID ${sessionUuid} to get face details`);
const resultResp = await axios.get(`${PROOFLY_CONFIG.baseUrl}/api/${sessionUuid}`);
const analysisResult = resultResp.data;
logInfo(`Full analysis data for ${sessionUuid}:`, analysisResult);
if (!analysisResult.faces || faceIndex >= analysisResult.faces.length) {
throw new McpError(ErrorCode.NotFound, `Face with index ${faceIndex} not found in session ${sessionUuid}. Total faces: ${analysisResult.faces ? analysisResult.faces.length : 0}.`);
}
const specificFace = analysisResult.faces[faceIndex];
if (format === 'json') {
return { content: [{ type: "text", text: JSON.stringify(specificFace, null, 2) }] };
} else {
// Format only a single face
let output = `**Details for Face ${faceIndex + 1} (Session: ${sessionUuid}):**\n`;
const faceVerdict = getVerdict(specificFace.ansamble);
output += `* Verdict: **${faceVerdict}**\n`;
if (typeof specificFace.ansamble !== 'undefined' && specificFace.ansamble !== null) {
output += `* Probability "real": ${(specificFace.ansamble * 100).toFixed(2)}%, "fake": ${(100 - specificFace.ansamble * 100).toFixed(2)}%\n`;
}
if (specificFace.is_real_model_1 !== undefined) {
output += `* Individual model results:\n`;
for (let i = 1; i <= 10; i++) {
if (specificFace[`is_real_model_${i}`] !== undefined) {
output += ` - Model ${i}: ${(specificFace[`is_real_model_${i}`] * 100).toFixed(2)}%\n`;
}
}
}
if (specificFace.face_path) {
let faceImageUrl = `${PROOFLY_CONFIG.baseUrl}${specificFace.face_path}`;
if (faceImageUrl.includes('ai./')) {
faceImageUrl = faceImageUrl.replace('ai./', 'ai/');
}
output += `* Face image URL: ${faceImageUrl}\n`;
}
return { content: [{ type: "text", text: output }] };
}
} catch (error) {
logError("Error in handleGetFaceDetails:", error.message);
if (error.response) {
logError("Error response data:", error.response.data);
logError("Error response status:", error.response.status);
}
if (error.response && error.response.status === 404) {
throw new McpError(ErrorCode.NotFound, `Session with UUID ${sessionUuid} not found when fetching face details.`);
}
throw new McpError(ErrorCode.ServerError, `Failed to get face details: ${error.message}`);
}
}
async start() {
logInfo(`Proofly MCP Server (v${PACKAGE_VERSION}) instance created and handlers configured. Ready for connections.`);
const transport = new StdioServerTransport();
await this.mcpServer.connect(transport);
}
}
const serverInstance = new ProoflyMcpServer();
serverInstance.start().catch(err => {
logError("Failed to start ProoflyMcpServer:", err);
process.exit(1);
});
export { ProoflyMcpServer };