exiftool-mcp-ai-agent
Version:
An MCP Server for retrieving EXIF data from images (photos) and videos using ExifTool
231 lines (230 loc) • 8.3 kB
JavaScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { execa } from "execa";
const gpsTags = [
"-GPSLatitude",
"-GPSLongitude",
"-GPSAltitude",
"-GPSLatitudeRef",
"-GPSLongitudeRef",
"-GPSAltitudeRef",
];
const timeTags = [
"-DateTimeOriginal",
"-CreateDate",
"-ModifyDate",
"-OffsetTimeOriginal",
"-OffsetTime",
"-MediaCreateDate",
"-MediaModifyDate",
"-TrackCreateDate",
"-TrackModifyDate",
"-CreationDate",
"-ContentCreateDate",
"-ContentModifyDate",
];
const TOOL_ALL_OR_SOME = "EXIF_all_or_some";
const TOOL_LOCATION = "EXIF_location";
const TOOL_TIMESTAMP = "EXIF_timestamp";
const TOOL_LOCATION_AND_TIMESTAMP = "EXIF_location_and_timestamp";
/**
* Throws an error if the argument contains characters that could be used for
* command injection via piping, redirection, or chaining commands.
* @param arg The argument string to validate.
*/
function ensureSafeArgument(arg) {
// Characters that can be used for command injection
const unsafeChars = /[|&;<>$`'"\\\n\r]/;
if (unsafeChars.test(arg)) {
throw new Error(`Unsafe characters detected in argument: ${arg}. ` +
`Arguments must not contain characters that can be used for command injection.`);
}
}
async function runToolFunction(filePath, tags, toolName) {
if (!filePath || !PathUtils.isValidFilePath(filePath)) {
throw new Error(`Invalid filePath argument for tool "${toolName ?? "unknown"}".`);
}
// Normalize path before using it
filePath = PathUtils.normalizePath(filePath);
// Ensure the filePath argument is safe
ensureSafeArgument(filePath);
// Ensure all tag arguments are safe
for (const tag of tags) {
ensureSafeArgument(tag);
}
const runArgs = [...tags, filePath];
return runExiftool(runArgs).then(result => ({
content: [
{
type: "text",
text: JSON.stringify(convertGpsCoordinates(result)),
},
],
}));
}
function prepareExiftoolArgs(args) {
if (!args.includes("-j") && !args.includes("-json")) {
args.unshift("-j");
}
// Ensure all arguments except last start with "-"
const preparedArgs = args.map((arg, idx) => {
if (idx === args.length - 1) {
return arg; // last arg is file path, leave as is
}
return arg.startsWith("-") ? arg : `-${arg}`;
});
return preparedArgs;
}
async function runExiftool(args) {
const preparedArgs = prepareExiftoolArgs(args);
try {
const { stdout } = await execa("exiftool", preparedArgs);
return JSON.parse(stdout);
}
catch (err) {
handleExiftoolRunError(err);
// The above function always throws, but TypeScript doesn't know that.
// Add a throw here to satisfy the return type.
throw err;
}
function handleExiftoolRunError(err) {
if (typeof err === "object" && err !== null) {
const errorObj = err;
if (errorObj.code === "ENOENT" ||
(errorObj.stderr &&
/exiftool(?!.*not found)/i.test(errorObj.stderr) &&
/not found|no such file|command not found/i.test(errorObj.stderr))) {
throw new Error("ExifTool executable not found. Please install ExifTool from https://exiftool.org/ and ensure it is in your system PATH.");
}
if (errorObj.message && errorObj.message.startsWith("Failed to parse exiftool JSON output:")) {
throw err;
}
throw new Error("Failed to run exiftool: " + (errorObj.message ?? "Unknown error"));
}
throw new Error("Failed to run exiftool: Unknown error");
}
}
function parseDMS(dmsString) {
if (typeof dmsString !== "string")
return null;
const dmsRegex = /(\d+)\s*deg\s*(\d+)'?\s*([\d.]+)"?\s*([NSEW])/i;
const match = dmsString.match(dmsRegex);
if (!match)
return null;
const degrees = parseFloat(match[1]);
const minutes = parseFloat(match[2]);
const seconds = parseFloat(match[3]);
const direction = match[4].toUpperCase();
let decimal = degrees + minutes / 60 + seconds / 3600;
if (direction === "S" || direction === "W") {
decimal = -decimal;
}
return decimal;
}
function convertGpsCoordinates(exifDataArray) {
if (!Array.isArray(exifDataArray))
return exifDataArray;
return exifDataArray.map((item) => {
const newItem = { ...item };
const gpsLatitude = newItem["GPSLatitude"];
if (typeof gpsLatitude === "string") {
const latDecimal = parseDMS(gpsLatitude);
if (latDecimal !== null) {
newItem["GPSLatitudeGoogleMapsCompatible"] = latDecimal;
}
}
const gpsLongitude = newItem["GPSLongitude"];
if (typeof gpsLongitude === "string") {
const lonDecimal = parseDMS(gpsLongitude);
if (lonDecimal !== null) {
newItem["GPSLongitudeGoogleMapsCompatible"] = lonDecimal;
}
}
return newItem;
});
}
const server = new McpServer({
name: "ExifTool MCP Server",
version: "1.0.0",
});
class PathUtils {
static normalizePath(path) {
path = this._trimQuotes(path);
return this._normalizePathInternal(path);
}
static _normalizePathInternal(path) {
if (!path)
return path;
// Check if path starts with ~ or ~/
if (path.startsWith("~")) {
// Get home directory from environment variables
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
if (!homeDir) {
// If home directory is not found, return path as is
return path;
}
if (path === "~") {
return homeDir;
}
else if (path.startsWith("~/") || path.startsWith("~\\")) {
// Replace ~ with home directory and keep the rest of the path
return homeDir + path.slice(1);
}
}
return path;
}
static _trimQuotes(str) {
if (!str)
return str;
str = str.trim();
let start = 0;
let end = str.length;
// Trim leading quotes
while (start < end && (str[start] === '"' || str[start] === "'")) {
start++;
}
// Trim trailing quotes
while (end > start && (str[end - 1] === '"' || str[end - 1] === "'")) {
end--;
}
return str.substring(start, end);
}
static isValidFilePath(path) {
path = PathUtils.normalizePath(path);
// Basic pattern check for MacOS and Windows file paths
// MacOS: starts with / or ~ or relative path (./ or ../) or /Volumes/ for network shares
// Windows: drive letter + :\ or UNC path \\
const macosPattern = /^(\/|~\/|\.\/|\.\.\/|\/Volumes\/).+/;
const windowsPattern = /^(?:[a-zA-Z]:\\|\\\\)/;
return macosPattern.test(path) || windowsPattern.test(path);
}
}
server.tool(TOOL_ALL_OR_SOME, {
filePath: z.string(),
optionalExifTags: z.array(z.string()).optional(),
}, async (params) => {
const optionalExifTags = params.optionalExifTags;
const tags = optionalExifTags?.map(tag => (tag.startsWith("-") ? tag : `-${tag}`)) || [];
return runToolFunction(params.filePath, tags, TOOL_ALL_OR_SOME);
});
server.tool(TOOL_LOCATION, {
filePath: z.string(),
}, async ({ filePath }) => {
return runToolFunction(filePath, gpsTags, TOOL_LOCATION);
});
server.tool(TOOL_TIMESTAMP, {
filePath: z.string(),
}, async ({ filePath }) => {
return runToolFunction(filePath, timeTags, TOOL_TIMESTAMP);
});
server.tool(TOOL_LOCATION_AND_TIMESTAMP, {
filePath: z.string(),
}, async ({ filePath }) => {
return runToolFunction(filePath, [...gpsTags, ...timeTags], TOOL_LOCATION_AND_TIMESTAMP);
});
const transport = new StdioServerTransport();
server.connect(transport).then(() => {
// No logging needed; Inspector will detect readiness via protocol
});