@kazuph/mcp-fetch
Version:
A Model Context Protocol server that provides web content fetching capabilities with automatic image saving and optional AI display
1,095 lines (1,088 loc) • 44.8 kB
JavaScript
#!/usr/bin/env node
import dns from "node:dns";
import { promises as fs } from "node:fs";
import net from "node:net";
import path from "node:path";
import { URL } from "node:url";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
import { Readability } from "@mozilla/readability";
import { JSDOM } from "jsdom";
import robotsParser from "robots-parser";
import sharp from "sharp";
import TurndownService from "turndown";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
// Global resource registry for images
const imageResources = new Map();
// Server instance to send notifications
let serverInstance;
let serverConnected = false;
// --------------------
// Security hardening
// --------------------
// Defaults (can be overridden by env vars)
const FETCH_TIMEOUT_MS = Number(process.env.MCP_FETCH_TIMEOUT_MS || 12000);
const MAX_REDIRECTS = Number(process.env.MCP_FETCH_MAX_REDIRECTS || 3);
const MAX_HTML_BYTES = Number(process.env.MCP_FETCH_MAX_HTML_BYTES || 2_000_000); // 2MB
const MAX_IMAGE_BYTES = Number(process.env.MCP_FETCH_MAX_IMAGE_BYTES || 10_000_000); // 10MB
const DISABLE_SSRF_GUARD = process.env.MCP_FETCH_DISABLE_SSRF_GUARD === "1";
function isPrivateIPv4(ip) {
const parts = ip.split(".").map((v) => Number(v));
if (parts.length !== 4 ||
parts.some((n) => Number.isNaN(n) || n < 0 || n > 255))
return false;
const [a, b] = parts;
if (a === 10)
return true; // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31)
return true; // 172.16.0.0/12
if (a === 192 && b === 168)
return true; // 192.168.0.0/16
if (a === 127)
return true; // loopback
if (a === 169 && b === 254)
return true; // link-local
if (a === 0)
return true; // non-routable
if (a >= 224 && a <= 239)
return true; // multicast
if (a >= 240)
return true; // reserved
return false;
}
function isPrivateIPv6(ip) {
const lower = ip.toLowerCase();
return (lower === "::" ||
lower === "::1" ||
lower.startsWith("fe80:") || // link-local
lower.startsWith("fc") || // fc00::/7 (fc/fd)
lower.startsWith("fd") ||
lower.startsWith("ff") // multicast
);
}
function isNodeErrorWithCode(error) {
return (error instanceof Error &&
typeof error.code === "string");
}
async function resolveAllIps(hostname) {
try {
const records = await dns.promises.lookup(hostname, {
all: true,
verbatim: true,
});
return records.map((r) => r.address);
}
catch {
return [];
}
}
async function isSafeUrl(input) {
let u;
try {
u = new URL(input);
}
catch {
return { ok: false, reason: "Invalid URL" };
}
if (!(u.protocol === "http:" || u.protocol === "https:")) {
return { ok: false, reason: "Only http/https schemes are allowed" };
}
if (DISABLE_SSRF_GUARD) {
return { ok: true, url: u };
}
const hostname = u.hostname;
if (!hostname)
return { ok: false, reason: "Missing hostname" };
const isIp = net.isIP(hostname) !== 0;
if (isIp) {
if (net.isIP(hostname) === 4 && isPrivateIPv4(hostname)) {
return { ok: false, reason: "IPv4 address is private/reserved" };
}
if (net.isIP(hostname) === 6 && isPrivateIPv6(hostname)) {
return { ok: false, reason: "IPv6 address is private/reserved" };
}
}
else {
const lower = hostname.toLowerCase();
if (lower === "localhost" ||
lower.endsWith(".localhost") ||
lower.endsWith(".local")) {
return { ok: false, reason: "Local hostnames are not allowed" };
}
const ips = await resolveAllIps(hostname);
for (const ip of ips) {
if ((net.isIP(ip) === 4 && isPrivateIPv4(ip)) ||
(net.isIP(ip) === 6 && isPrivateIPv6(ip))) {
return {
ok: false,
reason: "Hostname resolves to private/reserved address",
};
}
}
}
return { ok: true, url: u };
}
function withTimeout(p, ms, label = "request") {
if (!ms || ms <= 0)
return p;
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
p.then((v) => {
clearTimeout(t);
resolve(v);
}, (e) => {
clearTimeout(t);
reject(e);
});
});
}
async function safeFollowFetch(inputUrl, init = {}, opts = {}) {
const maxRedirects = opts.maxRedirects ?? MAX_REDIRECTS;
const timeoutMs = opts.timeoutMs ?? FETCH_TIMEOUT_MS;
let current = inputUrl;
for (let i = 0; i <= maxRedirects; i++) {
const safe = await isSafeUrl(current);
if (!safe.ok)
throw new Error(`Blocked URL: ${safe.reason}`);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const reqInit = {
...(init || {}),
redirect: "manual",
signal: controller.signal,
};
const resp = await fetch(current, reqInit);
clearTimeout(timer);
if ([301, 302, 303, 307, 308].includes(resp.status)) {
const loc = resp.headers.get("location");
if (!loc)
throw new Error(`Redirect status ${resp.status} without Location header`);
const next = new URL(loc, current).toString();
current = next;
continue;
}
return { response: resp, finalUrl: current };
}
catch (e) {
clearTimeout(timer);
throw e;
}
}
throw new Error("Too many redirects");
}
// Drains a WHATWG ReadableStream (the body type of the global fetch) while
// enforcing a hard byte cap incrementally, so an oversized response is aborted
// mid-stream instead of being buffered fully into memory first.
async function readBodyLimited(resp, maxBytes) {
const cl = resp.headers.get("content-length");
if (cl && Number(cl) > maxBytes) {
throw new Error(`Response too large (${cl} bytes > ${maxBytes})`);
}
if (!resp.body) {
const ab = await resp.arrayBuffer();
const buf = Buffer.from(ab);
if (buf.length > maxBytes) {
throw new Error(`Response exceeded limit (${maxBytes} bytes)`);
}
return buf;
}
let size = 0;
const chunks = [];
const reader = resp.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done)
break;
if (!value)
continue;
size += value.byteLength;
if (size > maxBytes) {
await reader.cancel();
throw new Error(`Response exceeded limit (${maxBytes} bytes)`);
}
chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength));
}
return Buffer.concat(chunks);
}
async function readTextLimited(resp, maxBytes) {
const ct = resp.headers.get("content-type") || "";
const buf = await withTimeout(readBodyLimited(resp, maxBytes), FETCH_TIMEOUT_MS, "read text");
return { text: buf.toString("utf8"), contentType: ct };
}
async function readBufferLimited(resp, maxBytes) {
return withTimeout(readBodyLimited(resp, maxBytes), FETCH_TIMEOUT_MS, "read buffer");
}
/**
* リソースリストが変更されたことをクライアントに通知
*/
async function notifyResourcesChanged() {
if (!serverInstance || !serverConnected)
return;
try {
await serverInstance.sendResourceListChanged();
}
catch (error) {
// When not connected to an MCP client, avoid noisy warnings in CI/tests
if (serverConnected) {
console.warn("Failed to notify resource list changed:", error);
}
}
}
/**
* 既存のダウンロードファイルをスキャンしてリソースとして登録
*/
async function scanAndRegisterExistingFiles() {
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
const baseDir = path.join(homeDir, "Downloads", "mcp-fetch");
try {
// 日付ディレクトリをスキャン
const dateDirs = await fs.readdir(baseDir);
for (const dateDir of dateDirs) {
if (dateDir.startsWith("."))
continue; // .DS_Store などをスキップ
const datePath = path.join(baseDir, dateDir);
const stats = await fs.stat(datePath);
if (!stats.isDirectory())
continue;
try {
// 日付ディレクトリ直下のファイルをチェック
const files = await fs.readdir(datePath);
for (const file of files) {
if (!file.toLowerCase().endsWith(".jpg"))
continue;
const filePath = path.join(datePath, file);
const fileStats = await fs.stat(filePath);
if (!fileStats.isFile())
continue;
// リソースURIを生成 (file:// scheme)
const resourceUri = `file://${filePath}`;
// ファイル名から情報を抽出
const baseName = path.basename(file, ".jpg");
const isIndividual = file.includes("individual");
const resourceName = `${dateDir}/${baseName}`;
const description = `${isIndividual ? "Individual" : "Merged"} image from ${dateDir}`;
const resource = {
uri: resourceUri,
name: resourceName,
description,
mimeType: "image/jpeg",
filePath,
};
imageResources.set(resourceUri, resource);
}
// サブディレクトリもチェック (individual/merged が存在する場合)
const subDirs = ["individual", "merged"];
for (const subDir of subDirs) {
const subDirPath = path.join(datePath, subDir);
try {
const subFiles = await fs.readdir(subDirPath);
for (const file of subFiles) {
if (!file.toLowerCase().endsWith(".jpg"))
continue;
const filePath = path.join(subDirPath, file);
const fileStats = await fs.stat(filePath);
if (!fileStats.isFile())
continue;
// リソースURIを生成 (file:// scheme)
const resourceUri = `file://${filePath}`;
// ファイル名から情報を抽出
const baseName = path.basename(file, ".jpg");
const resourceName = `${dateDir}/${subDir}/${baseName}`;
const description = `${subDir === "individual" ? "Individual" : "Merged"} image from ${dateDir}`;
const resource = {
uri: resourceUri,
name: resourceName,
description,
mimeType: "image/jpeg",
filePath,
};
imageResources.set(resourceUri, resource);
}
}
catch (_error) {
// サブディレクトリが存在しない場合はスキップ
}
}
}
catch (error) {
console.warn(`Failed to scan directory ${datePath}:`, error);
}
}
console.error(`Registered ${imageResources.size} existing image resources`);
}
catch (error) {
if (isNodeErrorWithCode(error) && error.code === "ENOENT") {
// No downloads directory yet; nothing to register on startup
return;
}
console.warn("Failed to scan existing downloads:", error);
}
}
const DEFAULT_USER_AGENT_AUTONOMOUS = "ModelContextProtocol/1.0 (Autonomous; +https://github.com/modelcontextprotocol/servers)";
// const DEFAULT_USER_AGENT_MANUAL =
// "ModelContextProtocol/1.0 (User-Specified; +https://github.com/modelcontextprotocol/servers)";
/**
* URLから元のファイル名を抽出
*/
function extractFilenameFromUrl(url) {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = path.basename(pathname);
// ファイル名が空の場合や拡張子がない場合のデフォルト処理
if (!filename || !filename.includes(".")) {
return "image.jpg";
}
return filename;
}
catch {
return "image.jpg";
}
}
// New structured API (optional)
const NewImagesSchema = z
.union([
z.boolean(),
z.object({
output: z.enum(["base64", "file", "both"]).optional(),
layout: z.enum(["merged", "individual", "both"]).optional(),
maxCount: z.number().int().min(0).max(10).optional(),
startIndex: z.number().int().min(0).optional(),
size: z
.object({
maxWidth: z.number().int().min(100).max(10000).optional(),
maxHeight: z.number().int().min(100).max(10000).optional(),
quality: z.number().int().min(1).max(100).optional(),
})
.optional(),
originPolicy: z.enum(["cross-origin", "same-origin"]).optional(),
saveDir: z.string().optional(),
}),
])
.optional();
const NewTextSchema = z
.object({
maxLength: z.number().int().positive().max(1000000).optional(),
startIndex: z.number().int().min(0).optional(),
raw: z.boolean().optional(),
})
.optional();
const NewSecuritySchema = z
.object({
ignoreRobotsTxt: z.boolean().optional(),
})
.optional();
const FetchArgsSchema = z.object({
url: z
.string()
.url()
.refine((val) => {
try {
const u = new URL(val);
return u.protocol === "http:" || u.protocol === "https:";
}
catch {
return false;
}
}, { message: "Only http/https URLs are allowed" }),
// legacy flat params (kept for backward compatibility)
maxLength: z
.union([z.number(), z.string()])
.transform((val) => Number(val))
.pipe(z.number().positive().max(1000000))
.default(20000),
startIndex: z
.union([z.number(), z.string()])
.transform((val) => Number(val))
.pipe(z.number().min(0))
.default(0),
imageStartIndex: z
.union([z.number(), z.string()])
.transform((val) => Number(val))
.pipe(z.number().min(0))
.default(0),
raw: z
.union([z.boolean(), z.string()])
.transform((val) => typeof val === "string" ? val.toLowerCase() === "true" : val)
.default(false),
imageMaxCount: z
.union([z.number(), z.string()])
.transform((val) => Number(val))
.pipe(z.number().min(0).max(10))
.default(3),
imageMaxHeight: z
.union([z.number(), z.string()])
.transform((val) => Number(val))
.pipe(z.number().min(100).max(10000))
.default(4000),
imageMaxWidth: z
.union([z.number(), z.string()])
.transform((val) => Number(val))
.pipe(z.number().min(100).max(10000))
.default(1000),
imageQuality: z
.union([z.number(), z.string()])
.transform((val) => Number(val))
.pipe(z.number().min(1).max(100))
.default(80),
enableFetchImages: z
.union([z.boolean(), z.string()])
.transform((val) => typeof val === "string" ? val.toLowerCase() === "true" : val)
.default(false),
allowCrossOriginImages: z
.union([z.boolean(), z.string()])
.transform((val) => typeof val === "string" ? val.toLowerCase() === "true" : val)
.default(true),
ignoreRobotsTxt: z
.union([z.boolean(), z.string()])
.transform((val) => typeof val === "string" ? val.toLowerCase() === "true" : val)
.default(false),
saveImages: z
.union([z.boolean(), z.string()])
.transform((val) => typeof val === "string" ? val.toLowerCase() === "true" : val)
.default(true),
returnBase64: z
.union([z.boolean(), z.string()])
.transform((val) => typeof val === "string" ? val.toLowerCase() === "true" : val)
.default(false),
// new structured params (optional)
images: NewImagesSchema,
text: NewTextSchema,
security: NewSecuritySchema,
});
function extractContentFromHtml(html, url) {
const dom = new JSDOM(html, { url });
const reader = new Readability(dom.window.document);
const article = reader.parse();
if (!article || !article.content) {
return "<e>Page failed to be simplified from HTML</e>";
}
// Extract images from the article content only
const articleDom = new JSDOM(article.content);
const imgElements = Array.from(articleDom.window.document.querySelectorAll("img"));
const images = imgElements.map((img) => {
const src = img.src;
const alt = img.alt || "";
const filename = extractFilenameFromUrl(src);
return { src, alt, filename };
});
const turndownService = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
});
const markdown = turndownService.turndown(article.content);
return { markdown, images, title: article.title ?? undefined };
}
async function fetchImages(images, baseOrigin, allowCrossOrigin) {
const fetchedImages = [];
for (const img of images) {
try {
const safe = await isSafeUrl(img.src);
if (!safe.ok)
continue;
const srcOrigin = new URL(img.src).origin;
if (!allowCrossOrigin && srcOrigin !== baseOrigin)
continue;
const { response } = await safeFollowFetch(img.src, {}, { timeoutMs: FETCH_TIMEOUT_MS });
const imageBuffer = await readBufferLimited(response, MAX_IMAGE_BYTES);
// GIF画像の場合は最初のフレームのみ抽出
if (img.src.toLowerCase().endsWith(".gif")) {
// GIF処理のロジック
}
fetchedImages.push({
...img,
data: imageBuffer,
});
}
catch (error) {
console.warn(`Failed to process image ${img.src}:`, error);
}
}
return fetchedImages;
}
/**
* 複数の画像を垂直方向に結合して1つの画像として返す
*/
async function mergeImagesVertically(images, maxWidth, maxHeight, quality) {
if (images.length === 0) {
throw new Error("No images to merge");
}
// 各画像のメタデータを取得
const imageMetas = await Promise.all(images.map(async (buffer) => {
const metadata = await sharp(buffer).metadata();
return {
width: metadata.width || 0,
height: metadata.height || 0,
buffer,
};
}));
// 最大幅を計算
const width = Math.min(maxWidth, Math.max(...imageMetas.map((meta) => meta.width)));
// 画像の高さを合計
const totalHeight = Math.min(maxHeight, imageMetas.reduce((sum, meta) => sum + meta.height, 0));
// 新しい画像を作成
const composite = sharp({
create: {
width,
height: totalHeight,
channels: 4,
background: { r: 255, g: 255, b: 255, alpha: 1 },
},
});
// 各画像を配置
let currentY = 0;
const overlays = [];
for (const meta of imageMetas) {
// 画像がキャンバスの高さを超えないようにする
if (currentY >= maxHeight)
break;
// 画像のリサイズ(必要な場合のみ)
let processedImage = sharp(meta.buffer);
if (meta.width > width) {
processedImage = processedImage.resize(width);
}
const resizedBuffer = await processedImage.toBuffer();
const resizedMeta = await sharp(resizedBuffer).metadata();
overlays.push({
input: resizedBuffer,
top: currentY,
left: 0,
});
currentY += resizedMeta.height || 0;
}
// 品質を指定して出力(PNGの代わりにJPEGを使用)
return composite
.composite(overlays)
.jpeg({
quality, // JPEG品質を指定(1-100)
mozjpeg: true, // mozjpegを使用して更に最適化
})
.toBuffer();
}
// removed unused getImageDimensions helper to satisfy linter
/**
* 画像を日付ベースのディレクトリに保存し、ファイルパスを返す
*/
async function saveImageToFile(imageBuffer, sourceUrl, imageIndex = 0) {
// 現在の日付をYYYY-MM-DD形式で取得
const now = new Date();
const dateStr = now.toISOString().split("T")[0];
// 保存先ディレクトリ: ~/Downloads/mcp-fetch/YYYY-MM-DD/merged/
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
const baseDir = path.join(homeDir, "Downloads", "mcp-fetch", dateStr, "merged");
// ディレクトリが存在しない場合は作成
await fs.mkdir(baseDir, { recursive: true });
// ファイル名を生成(URLのホスト名 + タイムスタンプ + インデックス)
const urlObj = new URL(sourceUrl);
const hostname = urlObj.hostname.replace(/[^a-zA-Z0-9]/g, "_");
const timestamp = now
.toISOString()
.replace(/[:.]/g, "-")
.split("T")[1]
.split(".")[0];
const filename = `${hostname}_${timestamp}_${imageIndex}.jpg`;
const filePath = path.join(baseDir, filename);
// ファイルに保存
await fs.writeFile(filePath, imageBuffer);
// リソースとして登録
const resourceUri = `file://${filePath}`;
const resourceName = `${dateStr}/merged/${filename}`;
const description = `Merged image from ${sourceUrl} saved on ${dateStr}`;
const resource = {
uri: resourceUri,
name: resourceName,
description,
mimeType: "image/jpeg",
filePath,
};
imageResources.set(resourceUri, resource);
// クライアントにリソース変更を通知
await notifyResourcesChanged();
return filePath;
}
/**
* 個別画像を保存してリソースとして登録
*/
async function saveIndividualImageAndRegisterResource(imageBuffer, sourceUrl, imageIndex, altText = "", originalFilename = "image.jpg") {
// 現在の日付をYYYY-MM-DD形式で取得
const now = new Date();
const dateStr = now.toISOString().split("T")[0];
// 保存先ディレクトリ: ~/Downloads/mcp-fetch/YYYY-MM-DD/individual/
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
const baseDir = path.join(homeDir, "Downloads", "mcp-fetch", dateStr, "individual");
// ディレクトリが存在しない場合は作成
await fs.mkdir(baseDir, { recursive: true });
// 元のファイル名を使用してユニークファイル名を生成
const ext = path.extname(originalFilename);
const baseName = path.basename(originalFilename, ext);
const safeBaseName = baseName.replace(/[^a-zA-Z0-9\-_]/g, "_");
const filename = `${imageIndex}_${safeBaseName}${ext || ".jpg"}`;
const filePath = path.join(baseDir, filename);
// ファイルに保存
await fs.writeFile(filePath, imageBuffer);
// リソースとして登録
const resourceUri = `file://${filePath}`;
const resourceName = `${safeBaseName}_${imageIndex}`;
const description = `${originalFilename}${altText ? ` (${altText})` : ""} from ${sourceUrl}`;
const resource = {
uri: resourceUri,
name: resourceName,
description,
mimeType: "image/jpeg",
filePath,
};
imageResources.set(resourceUri, resource);
// クライアントにリソース変更を通知
await notifyResourcesChanged();
return filePath;
}
async function checkRobotsTxt(url, userAgent) {
const { protocol, host } = new URL(url);
const robotsUrl = `${protocol}//${host}/robots.txt`;
try {
const { response } = await safeFollowFetch(robotsUrl, { headers: { "User-Agent": userAgent } }, { timeoutMs: Math.min(FETCH_TIMEOUT_MS, 8000) });
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new Error("Autonomous fetching not allowed based on robots.txt response");
}
return true; // Allow if no robots.txt
}
const { text: robotsTxt } = await readTextLimited(response, 100_000);
const robots = robotsParser(robotsUrl, robotsTxt);
if (!robots.isAllowed(url, userAgent)) {
throw new Error("The site's robots.txt specifies that autonomous fetching is not allowed. " +
"Try manually fetching the page using the fetch prompt.");
}
return true;
}
catch (error) {
// ロボットテキストの取得に失敗した場合はアクセスを許可する
if (error instanceof Error && error.message.includes("robots.txt")) {
throw error;
}
return true;
}
}
async function fetchUrl(url, userAgent, forceRaw = false, options = {
imageMaxCount: 3,
imageMaxHeight: 4000,
imageMaxWidth: 1000,
imageQuality: 80,
imageStartIndex: 0,
startIndex: 0,
maxLength: 20000,
enableFetchImages: false,
allowCrossOriginImages: true,
saveImages: true,
returnBase64: false,
}) {
const { response, finalUrl } = await safeFollowFetch(url, {
headers: { "User-Agent": userAgent },
});
if (!response.ok) {
throw new Error(`Failed to fetch ${url} - status code ${response.status}`);
}
const { text, contentType } = await readTextLimited(response, MAX_HTML_BYTES);
const isHtml = text.toLowerCase().includes("<html") || contentType.includes("text/html");
if (isHtml && !forceRaw) {
const result = extractContentFromHtml(text, finalUrl);
if (typeof result === "string") {
return {
content: result,
images: [],
remainingContent: 0,
remainingImages: 0,
};
}
const { markdown, images, title } = result;
const processedImages = [];
if (options.enableFetchImages &&
options.imageMaxCount > 0 &&
images.length > 0) {
try {
const startIdx = options.imageStartIndex;
const baseOrigin = new URL(finalUrl).origin;
let fetchedImages = await fetchImages(images.slice(startIdx), baseOrigin, options.allowCrossOriginImages ?? false);
fetchedImages = fetchedImages.slice(0, options.imageMaxCount);
if (fetchedImages.length > 0) {
const imageBuffers = fetchedImages.map((img) => img.data);
const layout = options.layout;
const output = options.output;
const legacyMode = options.output === undefined &&
options.layout === undefined;
const shouldSaveIndividual = legacyMode
? true // 互換性のため、レガシーでは常に保存
: (layout === "individual" || layout === "both") &&
(output === "file" || output === "both");
if (shouldSaveIndividual) {
for (let i = 0; i < fetchedImages.length; i++) {
try {
const img = fetchedImages[i];
const optimizedIndividualImage = await sharp(img.data)
.jpeg({ quality: 80, mozjpeg: true })
.toBuffer();
await saveIndividualImageAndRegisterResource(optimizedIndividualImage, finalUrl, startIdx + i, img.alt, img.filename || "image.jpg");
}
catch (error) {
console.warn(`Failed to save individual image ${i}:`, error);
}
}
}
const mergedImage = await mergeImagesVertically(imageBuffers, options.imageMaxWidth, options.imageMaxHeight, options.imageQuality);
// Base64エンコード前に画像を最適化
const optimizedImage = await sharp(mergedImage)
.resize({
width: Math.min(options.imageMaxWidth, 1200), // 最大幅を1200pxに制限
height: Math.min(options.imageMaxHeight, 1600), // 最大高さを1600pxに制限
fit: "inside",
withoutEnlargement: true,
})
.jpeg({
quality: Math.min(options.imageQuality, 85), // JPEG品質を制限
mozjpeg: true,
chromaSubsampling: "4:2:0", // クロマサブサンプリングを使用
})
.toBuffer();
const base64Image = optimizedImage.toString("base64");
// ファイル保存機能(新API: outputがfile/both の場合のみ)
let filePath;
const shouldSaveMerged = legacyMode
? options.saveImages
: output === "file" || output === "both";
if (shouldSaveMerged) {
try {
filePath = await saveImageToFile(optimizedImage, finalUrl, options.imageStartIndex);
// Diagnostics MUST go to stderr: stdout is reserved for the
// MCP JSON-RPC stream and any stray stdout write corrupts it
// (clients fail with "... is not valid JSON").
console.error(`Image saved to: ${filePath}`);
}
catch (error) {
console.warn("Failed to save image to file:", error);
}
}
processedImages.push({
data: (legacyMode && options.returnBase64) ||
(!legacyMode && (output === "base64" || output === "both"))
? base64Image
: "",
mimeType: "image/jpeg", // MIMEタイプをJPEGに変更
filePath,
});
}
}
catch (err) {
console.error("Error processing images:", err);
}
}
return {
content: markdown,
images: processedImages,
remainingContent: text.length - (options.startIndex + options.maxLength),
remainingImages: Math.max(0, images.length - (options.imageStartIndex + options.imageMaxCount)),
title,
};
}
return {
content: `Content type ${contentType} cannot be simplified to markdown, but here is the raw content:\n${text}`,
images: [],
remainingContent: 0,
remainingImages: 0,
title: undefined,
};
}
// コマンドライン引数の解析
const args = process.argv.slice(2);
const IGNORE_ROBOTS_TXT = args.includes("--ignore-robots-txt");
// Server setup
const server = new Server({
name: "mcp-fetch",
version: "1.6.2",
}, {
capabilities: {
tools: {},
resources: {
subscribe: true,
listChanged: true,
},
},
});
// Store server instance for notifications
serverInstance = server;
// コマンドライン引数の情報をログに出力
console.error(`Server started with options: ${IGNORE_ROBOTS_TXT ? "ignore-robots-txt" : "respect-robots-txt"}`);
server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools = [
{
name: "imageFetch",
description: `
画像取得に強いMCPフェッチツール。記事本文をMarkdown化し、ページ内の画像を抽出・最適化して返します。
新APIの既定(imagesを指定した場合)
- 画像: 取得してBASE64で返却(最大3枚を縦結合した1枚JPEG)
- 保存: しない(オプトイン)
- クロスオリジン: 許可(CDN想定)
パラメータ(新API)
- url: 取得先URL(必須)
- images: true | { output, layout, maxCount, startIndex, size, originPolicy, saveDir }
- output: "base64" | "file" | "both"(既定: base64)
- layout: "merged" | "individual" | "both"(既定: merged)
- maxCount/startIndex(既定: 3 / 0)
- size: { maxWidth, maxHeight, quality }(既定: 1000/1600/80)
- originPolicy: "cross-origin" | "same-origin"(既定: cross-origin)
- text: { maxLength, startIndex, raw }(既定: 20000/0/false)
- security: { ignoreRobotsTxt }(既定: false)
旧APIキー(enableFetchImages, returnBase64, saveImages, imageMax*, imageStartIndex 等)は後方互換のため引き続き受け付けます(非推奨)。
Examples(新API)
{
"url": "https://example.com",
"images": true
}
{
"url": "https://example.com",
"images": { "output": "both", "layout": "both", "maxCount": 4 }
}
Examples(旧API互換)
{
"url": "https://example.com",
"enableFetchImages": true,
"returnBase64": true,
"imageMaxCount": 2
}`,
inputSchema: zodToJsonSchema(FetchArgsSchema),
},
];
return { tools };
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
if (name !== "imageFetch") {
throw new Error(`Unknown tool: ${name}`);
}
const parsed = FetchArgsSchema.safeParse(args || {});
if (!parsed.success) {
throw new Error(`Invalid arguments: ${parsed.error}`);
}
const a = parsed.data;
// Legacy mode detection: no new keys and/or legacy keys present
const hasNewKeys = a.images !== undefined ||
a.text !== undefined ||
a.security !== undefined;
const hasLegacyKeys = a.enableFetchImages !== undefined ||
a.saveImages !== undefined ||
a.returnBase64 !== undefined ||
a.imageMaxWidth !== undefined ||
a.imageMaxHeight !== undefined ||
a.imageQuality !== undefined ||
a.imageStartIndex !== undefined ||
a.allowCrossOriginImages !== undefined ||
a.startIndex !== undefined ||
a.maxLength !== undefined ||
a.raw !== undefined;
const legacyMode = (!hasNewKeys && hasLegacyKeys) || (!hasNewKeys && !hasLegacyKeys);
// Build fetch options with backward compatibility
const fetchOptions = {
imageMaxCount: 3,
imageMaxHeight: 4000,
imageMaxWidth: 1000,
imageQuality: 80,
imageStartIndex: 0,
startIndex: 0,
maxLength: 20000,
enableFetchImages: false,
allowCrossOriginImages: true,
saveImages: false,
returnBase64: false,
// new API additions (optional)
output: undefined,
layout: undefined,
};
if (legacyMode) {
// Legacy defaults
fetchOptions.startIndex =
a.startIndex ?? fetchOptions.startIndex;
fetchOptions.maxLength =
a.maxLength ?? fetchOptions.maxLength;
fetchOptions.raw = a.raw ?? false;
fetchOptions.imageMaxCount =
a.imageMaxCount ?? fetchOptions.imageMaxCount;
fetchOptions.imageMaxHeight =
a.imageMaxHeight ?? fetchOptions.imageMaxHeight;
fetchOptions.imageMaxWidth =
a.imageMaxWidth ?? fetchOptions.imageMaxWidth;
fetchOptions.imageQuality =
a.imageQuality ?? fetchOptions.imageQuality;
fetchOptions.imageStartIndex =
a.imageStartIndex ??
fetchOptions.imageStartIndex;
fetchOptions.enableFetchImages = a.enableFetchImages ?? false;
fetchOptions.allowCrossOriginImages = a.allowCrossOriginImages ?? true;
fetchOptions.saveImages = a.saveImages ?? true; // keep previous default behavior
fetchOptions.returnBase64 = a.returnBase64 ?? false;
// In legacy mode we preserve prior implicit behavior: individual images saved when any saving occurs
fetchOptions.output =
fetchOptions.saveImages && fetchOptions.returnBase64
? "both"
: fetchOptions.returnBase64
? "base64"
: fetchOptions.saveImages
? "file"
: undefined;
fetchOptions.layout = "merged"; // merged remains primary; individual saving handled inside legacy path
}
else {
// New API mode
const imagesCfg = a.images;
const textCfg = a.text || {};
const securityCfg = a.security || {};
fetchOptions.startIndex = textCfg.startIndex ?? fetchOptions.startIndex;
fetchOptions.maxLength = textCfg.maxLength ?? fetchOptions.maxLength;
fetchOptions.raw = textCfg.raw ?? false;
// images: true | object | undefined (default true for new API?)
const imagesEnabled = imagesCfg === undefined
? false
: typeof imagesCfg === "boolean"
? imagesCfg
: true;
fetchOptions.enableFetchImages = imagesEnabled;
if (imagesEnabled) {
const cfg = (typeof imagesCfg === "object" && imagesCfg !== null
? imagesCfg
: {});
fetchOptions.imageMaxCount = cfg.maxCount ?? fetchOptions.imageMaxCount;
fetchOptions.imageStartIndex =
cfg.startIndex ?? fetchOptions.imageStartIndex;
const size = cfg.size || {};
fetchOptions.imageMaxWidth =
size.maxWidth ?? fetchOptions.imageMaxWidth;
fetchOptions.imageMaxHeight =
size.maxHeight ?? fetchOptions.imageMaxHeight;
fetchOptions.imageQuality = size.quality ?? fetchOptions.imageQuality;
fetchOptions.allowCrossOriginImages =
(cfg.originPolicy ?? "cross-origin") === "cross-origin";
fetchOptions.saveImages =
(cfg.output ?? "base64") === "file" ||
(cfg.output ?? "base64") === "both";
fetchOptions.returnBase64 =
(cfg.output ?? "base64") === "base64" ||
(cfg.output ?? "base64") === "both";
fetchOptions.output = cfg.output ?? "base64";
fetchOptions.layout = cfg.layout ?? "merged";
// NOTE: saveDir (cfg.saveDir) is respected in save functions when implemented (future)
}
// security
a.ignoreRobotsTxt = securityCfg.ignoreRobotsTxt ?? false;
}
// robots.txt respect unless ignored
if (!a.ignoreRobotsTxt && !IGNORE_ROBOTS_TXT) {
await checkRobotsTxt(a.url, DEFAULT_USER_AGENT_AUTONOMOUS);
}
const { content, images, remainingContent, remainingImages, title } = await fetchUrl(a.url, DEFAULT_USER_AGENT_AUTONOMOUS, fetchOptions.raw ?? false, fetchOptions);
let finalContent = content.slice(fetchOptions.startIndex, fetchOptions.startIndex + fetchOptions.maxLength);
// 残りの情報を追加
const remainingInfo = [];
if (remainingContent > 0) {
remainingInfo.push(`${remainingContent} characters of text remaining`);
}
if (remainingImages > 0) {
remainingInfo.push(`${remainingImages} more images available (${fetchOptions.imageStartIndex + images.length}/${fetchOptions.imageStartIndex + images.length + remainingImages} shown)`);
}
if (remainingInfo.length > 0) {
finalContent += `\n\n<e>Content truncated. ${remainingInfo.join(", ")}. Call the imageFetch tool with start_index=${fetchOptions.startIndex + fetchOptions.maxLength} and/or imageStartIndex=${fetchOptions.imageStartIndex + images.length} to get more content.</e>`;
}
// MCP レスポンスの作成
const responseContent = [
{
type: "text",
text: `Contents of ${parsed.data.url}${title ? `: ${title}` : ""}:\n${finalContent}`,
},
];
// 画像があれば追加(Base64データが存在する場合のみ)
for (const image of images) {
if (image.data) {
responseContent.push({
type: "image",
mimeType: image.mimeType,
data: image.data,
});
}
}
// 保存されたファイルの情報があれば追加
const savedFiles = images.filter((img) => img.filePath);
if (savedFiles.length > 0) {
const fileInfoText = savedFiles
.map((img, index) => `Image ${index + 1} saved to: ${img.filePath}`)
.join("\n");
responseContent.push({
type: "text",
text: `\n📁 Saved Images:\n${fileInfoText}`,
});
}
return {
content: responseContent,
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
// Resources handlers
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const resources = Array.from(imageResources.values()).map((resource) => ({
uri: resource.uri,
name: resource.name,
description: resource.description,
mimeType: resource.mimeType,
}));
return {
resources,
};
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const resource = imageResources.get(request.params.uri);
if (!resource) {
throw new Error(`Resource not found: ${request.params.uri}`);
}
try {
const fileData = await fs.readFile(resource.filePath);
const base64Data = fileData.toString("base64");
return {
contents: [
{
uri: resource.uri,
mimeType: resource.mimeType,
blob: base64Data,
},
],
};
}
catch (error) {
throw new Error(`Failed to read resource file: ${error}`);
}
});
// Start server
async function runServer() {
// サーバー起動時に既存のファイルをリソースとして登録
await scanAndRegisterExistingFiles();
const transport = new StdioServerTransport();
await server.connect(transport);
serverConnected = true;
}
if (process.env.MCP_FETCH_DISABLE_SERVER !== "1") {
runServer().catch((error) => {
process.stderr.write(`Fatal error running server: ${error}\n`);
process.exit(1);
});
}
export { fetchUrl };