UNPKG

@buildappolis/sharex-mcp-server

Version:

Model Context Protocol server for seamless ShareX integration with Claude Code - view screenshots and GIFs instantly

506 lines 20.2 kB
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs/promises"; import * as path from "path"; import * as os from "os"; import { fileURLToPath } from "url"; import mime from "mime-types"; import chokidar from "chokidar"; import { defaultConfig } from "./config.js"; import { getShareXScreenshotPath } from "./utils/sharex.js"; import sharp from "sharp"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); class ShareXMCPServer { server; imageCache = new Map(); gifCache = new Map(); extractedFramesCache = new Map(); watcher = null; config; screenshotsDir = null; tempDir; constructor(config = {}) { this.config = { ...defaultConfig, ...config }; this.tempDir = this.config.tempFramesPath || path.join(os.tmpdir(), 'sharex-mcp-frames'); this.server = new Server({ name: "sharex-mcp-server", version: "1.0.0", }, { capabilities: { tools: {}, }, }); this.setupHandlers(); this.initializeWatcher(); this.ensureTempDir(); } async ensureTempDir() { try { await fs.mkdir(this.tempDir, { recursive: true }); } catch (error) { console.error("Failed to create temp directory:", error); } } async initializeWatcher() { try { // Determine screenshots directory if (this.config.shareXPath) { this.screenshotsDir = this.config.shareXPath; } else if (this.config.autoDetectShareX) { this.screenshotsDir = await getShareXScreenshotPath(); } if (!this.screenshotsDir) { console.error("Could not determine ShareX screenshots directory"); return; } console.error(`Watching ShareX directory: ${this.screenshotsDir}`); this.watcher = chokidar.watch(this.screenshotsDir, { ignored: /(^|[\/\\])\../, persistent: true, awaitWriteFinish: { stabilityThreshold: 1000, pollInterval: 100 } }); this.watcher .on("add", (filePath) => this.handleFileAdd(filePath)) .on("change", (filePath) => this.handleFileAdd(filePath)) .on("unlink", (filePath) => this.handleFileRemove(filePath)); await this.scanDirectory(); } catch (error) { console.error("Failed to initialize watcher:", error); } } async handleFileAdd(filePath) { try { const stats = await fs.stat(filePath); const name = path.basename(filePath); const type = mime.lookup(filePath) || "unknown"; const metadata = { name, path: filePath, size: stats.size, mtime: stats.mtime, type }; // Add to appropriate cache based on type if (type === "image/gif") { this.gifCache.set(name, metadata); this.enforceGifLimit(); // Clear extracted frames for this GIF if it was updated this.extractedFramesCache.delete(name); } else if (type.startsWith("image/")) { this.imageCache.set(name, metadata); this.enforceImageLimit(); } } catch (error) { console.error(`Failed to process file ${filePath}:`, error); } } handleFileRemove(filePath) { const name = path.basename(filePath); this.imageCache.delete(name); this.gifCache.delete(name); this.extractedFramesCache.delete(name); } enforceImageLimit() { if (this.imageCache.size <= this.config.maxImages) return; // Sort by modification time and remove oldest const sorted = Array.from(this.imageCache.entries()) .sort(([, a], [, b]) => a.mtime.getTime() - b.mtime.getTime()); const toRemove = sorted.slice(0, this.imageCache.size - this.config.maxImages); for (const [name] of toRemove) { this.imageCache.delete(name); } } enforceGifLimit() { if (this.gifCache.size <= this.config.maxGifs) return; // Sort by modification time and remove oldest const sorted = Array.from(this.gifCache.entries()) .sort(([, a], [, b]) => a.mtime.getTime() - b.mtime.getTime()); const toRemove = sorted.slice(0, this.gifCache.size - this.config.maxGifs); for (const [name] of toRemove) { this.gifCache.delete(name); this.extractedFramesCache.delete(name); } } async scanDirectory() { if (!this.screenshotsDir) return; try { const files = await fs.readdir(this.screenshotsDir); for (const file of files) { const filePath = path.join(this.screenshotsDir, file); await this.handleFileAdd(filePath); } } catch (error) { console.error("Failed to scan directory:", error); } } setupHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "check_latest_screenshots", description: "Get the most recent screenshot(s)", inputSchema: { type: "object", properties: { count: { type: "number", description: "Number of screenshots to retrieve (max 5)", default: 1, minimum: 1, maximum: 5 } } } }, { name: "check_latest_gif", description: "Get the most recent GIF (automatically extracts frames if needed)", inputSchema: { type: "object", properties: {} } }, { name: "check_gif_by_index", description: "Get a specific GIF by its index (1-5, where 1 is the most recent)", inputSchema: { type: "object", properties: { index: { type: "number", description: "Index of the GIF (1=newest, 2=second newest, etc.)", minimum: 1, maximum: 5 } }, required: ["index"] } }, { name: "list_gifs", description: "List available GIFs with their index numbers", inputSchema: { type: "object", properties: {} } }, { name: "get_screenshot_by_name", description: "Retrieve a specific screenshot by filename", inputSchema: { type: "object", properties: { filename: { type: "string", description: "The filename of the screenshot to retrieve" } }, required: ["filename"] } }, { name: "list_screenshots", description: "List all available screenshots with metadata", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Maximum number of screenshots to list", default: 20 } } } } ] })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { case "check_latest_screenshots": return await this.getLatestScreenshots(args?.count || 1); case "check_latest_gif": return await this.getGifWithFrames(); case "check_gif_by_index": return await this.getGifWithFrames(args?.index); case "list_gifs": return await this.listGifs(); case "get_screenshot_by_name": return await this.getScreenshotByName(args?.filename || ""); case "list_screenshots": return await this.listScreenshots(args?.limit || 20); default: throw new Error(`Unknown tool: ${name}`); } }); } async getLatestScreenshots(count) { const imageFiles = Array.from(this.imageCache.values()) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) .slice(0, count); if (imageFiles.length === 0) { return { content: [{ type: "text", text: "No screenshots found. Take a screenshot with ShareX and try again." }] }; } const content = []; for (const file of imageFiles) { try { const imageData = await fs.readFile(file.path); const base64 = imageData.toString("base64"); content.push({ type: "text", text: `Screenshot: ${file.name} (${new Date(file.mtime).toLocaleString()})` }); content.push({ type: "image", data: base64, mimeType: file.type }); } catch (error) { content.push({ type: "text", text: `Failed to read screenshot ${file.name}: ${error}` }); } } return { content }; } async listGifs() { const gifFiles = Array.from(this.gifCache.values()) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) .slice(0, 5); if (gifFiles.length === 0) { return { content: [{ type: "text", text: "No GIF files found. Record a GIF with ShareX and try again." }] }; } const gifList = gifFiles.map((file, index) => `${index + 1}. ${file.name} - ${(file.size / 1024).toFixed(2)} KB - ${new Date(file.mtime).toLocaleString()}`).join("\n"); return { content: [{ type: "text", text: `Available GIFs (use check_gif_by_index with the number):\n${gifList}\n\nUse index 1 for the latest GIF, or specify 2-${gifFiles.length} for older ones.` }] }; } async getGifWithFrames(index) { const gifFiles = Array.from(this.gifCache.values()) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); if (gifFiles.length === 0) { return { content: [{ type: "text", text: "No GIF files found. Record a GIF with ShareX and try again." }] }; } const gifIndex = (index || 1) - 1; if (gifIndex < 0 || gifIndex >= gifFiles.length) { return { content: [{ type: "text", text: `Invalid GIF index. Please use 1-${gifFiles.length}. Use list_gifs to see available GIFs.` }] }; } const targetGif = gifFiles[gifIndex]; // Check if we already have extracted frames cached if (this.extractedFramesCache.has(targetGif.name)) { const cached = this.extractedFramesCache.get(targetGif.name); return this.formatExtractedFrames(cached, targetGif); } // Extract frames try { const content = [{ type: "text", text: `Processing GIF: ${targetGif.name} (${(targetGif.size / 1024).toFixed(2)} KB)\nExtracting frames...` }]; // Check if GIF is too large const maxSize = 50 * 1024 * 1024; // 50MB absolute max if (targetGif.size > maxSize) { return { content: [{ type: "text", text: `GIF file ${targetGif.name} is too large (${(targetGif.size / 1024 / 1024).toFixed(2)} MB). Maximum supported size is 50MB.` }] }; } // Load and extract frames const gif = sharp(targetGif.path, { animated: true }); const metadata = await gif.metadata(); if (!metadata.pages || metadata.pages <= 1) { // Static image or single frame, just return it const gifData = await fs.readFile(targetGif.path); const base64 = gifData.toString("base64"); return { content: [ { type: "text", text: `GIF: ${targetGif.name} (static/single frame)` }, { type: "image", data: base64, mimeType: "image/gif" } ] }; } const totalFrames = metadata.pages; const framesToExtract = Math.min(totalFrames, this.config.maxFramesPerGif); const frameInterval = Math.max(1, Math.floor(totalFrames / framesToExtract)); const frames = []; for (let i = 0; i < framesToExtract; i++) { const frameIndex = Math.min(i * frameInterval, totalFrames - 1); try { const frameBuffer = await sharp(targetGif.path, { animated: true, page: frameIndex }) .png() .toBuffer(); frames.push(frameBuffer.toString("base64")); } catch (frameError) { console.error(`Failed to extract frame ${frameIndex}:`, frameError); } } // Cache the extracted frames const extracted = { gifName: targetGif.name, frames, totalFrames, extractedAt: new Date() }; this.extractedFramesCache.set(targetGif.name, extracted); return this.formatExtractedFrames(extracted, targetGif); } catch (error) { return { content: [{ type: "text", text: `Failed to process GIF: ${error}\n\nThe GIF file might be corrupted or in an unsupported format.` }] }; } } formatExtractedFrames(extracted, gif) { const content = [{ type: "text", text: `GIF: ${gif.name}\n` + `Size: ${(gif.size / 1024).toFixed(2)} KB\n` + `Total frames: ${extracted.totalFrames}\n` + `Showing: ${extracted.frames.length} frames${extracted.frames.length < extracted.totalFrames ? ` (every ${Math.floor(extracted.totalFrames / extracted.frames.length)} frames)` : ''}` }]; // Add each frame extracted.frames.forEach((frame, index) => { const actualFrameNumber = Math.floor(index * (extracted.totalFrames / extracted.frames.length)) + 1; content.push({ type: "text", text: `Frame ${actualFrameNumber}/${extracted.totalFrames}:` }); content.push({ type: "image", data: frame, mimeType: "image/png" }); }); return { content }; } async getScreenshotByName(filename) { const file = this.imageCache.get(filename) || this.gifCache.get(filename); if (!file) { return { content: [{ type: "text", text: `Screenshot "${filename}" not found. Use list_screenshots to see available files.` }] }; } // If it's a GIF, use the frame extraction logic if (file.type === "image/gif") { const gifFiles = Array.from(this.gifCache.values()) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); const index = gifFiles.findIndex(g => g.name === filename) + 1; return await this.getGifWithFrames(index); } try { const imageData = await fs.readFile(file.path); const base64 = imageData.toString("base64"); return { content: [ { type: "text", text: `Screenshot: ${file.name} (${new Date(file.mtime).toLocaleString()})` }, { type: "image", data: base64, mimeType: file.type } ] }; } catch (error) { return { content: [{ type: "text", text: `Failed to read file: ${error}` }] }; } } async listScreenshots(limit) { const allFiles = [ ...Array.from(this.imageCache.values()), ...Array.from(this.gifCache.values()) ] .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) .slice(0, limit); if (allFiles.length === 0) { return { content: [{ type: "text", text: "No files cached. Take a screenshot with ShareX to start tracking." }] }; } const fileList = allFiles.map(file => `- ${file.name} (${file.type}, ${(file.size / 1024).toFixed(2)} KB, ${new Date(file.mtime).toLocaleString()})`).join("\n"); const stats = `Images: ${this.imageCache.size}/${this.config.maxImages}, GIFs: ${this.gifCache.size}/${this.config.maxGifs}`; return { content: [{ type: "text", text: `Available screenshots (${allFiles.length} files, ${stats}):\n${fileList}` }] }; } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error("ShareX MCP Server running on stdio"); } } const server = new ShareXMCPServer(); server.run().catch(console.error); //# sourceMappingURL=index.js.map