mirror of
https://github.com/manooll/webfetch-mcp.git
synced 2026-09-27 22:24:22 +00:00
🎯 Major Rate Limiting Improvement - Research-Friendly Design New Rate Limiting System: ✅ 12 calls per 5-minute window (vs 8 per session) ✅ 8 calls per 30-second burst protection ✅ Automatic time-based reset (no LM Studio restarts needed) ✅ Clear feedback with remaining calls and reset times Key Benefits: - Supports extended research sessions without interruption - Prevents LLM spam while allowing legitimate research workflows - Users can work continuously without restarting LM Studio - Intelligent burst protection prevents overwhelming websites Technical Implementation: - Sliding window algorithm with timestamp tracking - Dual-layer protection: burst + window limits - Automatic cleanup of expired call history - User-friendly error messages with precise wait times This addresses the core user feedback that session-based limits were too restrictive for normal research use cases while maintaining responsible web scraping practices. Breaking Change: Rate limiting behavior changed from session-based to time-based Migration: No action needed - new system is more permissive
925 lines
28 KiB
JavaScript
925 lines
28 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* WebFetch.MCP v0.1.8
|
|
* Live Web Access for Your Local AI — Tunable Search & Clean Content Extraction
|
|
*
|
|
* A production-ready Model Context Protocol (MCP) server that provides web search
|
|
* and content extraction capabilities for LM Studio and other MCP clients.
|
|
*
|
|
* Features:
|
|
* - Web search via local SearxNG instance
|
|
* - Advanced web content extraction with Mozilla Readability
|
|
* - Browser simulation to bypass bot detection
|
|
* - Rate limiting and responsible scraping practices
|
|
* - Comprehensive error handling and logging
|
|
*
|
|
* @author Jay Leon (@manull)
|
|
* @license MIT
|
|
* @version 0.1.8
|
|
* @repository https://github.com/manull/webfetch-mcp
|
|
*
|
|
* Copyright (c) 2025 Jay Leon (@manull)
|
|
* Licensed under the MIT License - see LICENSE file for details
|
|
*/
|
|
|
|
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 { JSDOM } from "jsdom";
|
|
import { Readability } from "@mozilla/readability";
|
|
import { writeFileSync, appendFileSync, existsSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
// Use Node.js built-in fetch
|
|
const fetch = globalThis.fetch;
|
|
|
|
// ---------- Config ----------
|
|
const SEARXNG_BASE = process.env.SEARXNG_BASE || "http://localhost:8080";
|
|
const DEBUG = process.env.DEBUG === "true";
|
|
const DETAILED_LOG = process.env.DETAILED_LOG !== "false"; // Default to true
|
|
|
|
// Time-based rate limiting - more user-friendly approach
|
|
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
|
const MAX_CALLS_PER_WINDOW = 12; // Allow more calls but over time
|
|
const BURST_LIMIT = 8; // Max calls in quick succession
|
|
const BURST_WINDOW_MS = 30 * 1000; // 30 seconds
|
|
|
|
let callHistory = []; // Array of timestamps
|
|
|
|
const checkCallLimit = () => {
|
|
const now = Date.now();
|
|
|
|
// Clean up old calls outside the main window
|
|
callHistory = callHistory.filter(timestamp => now - timestamp < RATE_LIMIT_WINDOW_MS);
|
|
|
|
// Check burst limit (quick succession)
|
|
const recentCalls = callHistory.filter(timestamp => now - timestamp < BURST_WINDOW_MS);
|
|
|
|
if (recentCalls.length >= BURST_LIMIT) {
|
|
return {
|
|
limited: true,
|
|
message: `🛑 **Burst Limit Reached**: ${BURST_LIMIT} calls in ${BURST_WINDOW_MS/1000} seconds. Please wait ${Math.ceil((BURST_WINDOW_MS - (now - recentCalls[0]))/1000)} seconds before making more requests. This prevents overwhelming websites and ensures reliable service.`
|
|
};
|
|
}
|
|
|
|
// Check overall window limit
|
|
if (callHistory.length >= MAX_CALLS_PER_WINDOW) {
|
|
const oldestCall = Math.min(...callHistory);
|
|
const resetTime = Math.ceil((RATE_LIMIT_WINDOW_MS - (now - oldestCall)) / 1000 / 60);
|
|
return {
|
|
limited: true,
|
|
message: `🛑 **Rate Limit Reached**: ${MAX_CALLS_PER_WINDOW} calls in ${RATE_LIMIT_WINDOW_MS/1000/60} minutes. Please wait ${resetTime} minute(s) for the limit to reset. This ensures responsible web scraping and prevents server overload.`
|
|
};
|
|
}
|
|
|
|
// Add current call to history
|
|
callHistory.push(now);
|
|
|
|
// Provide helpful warnings
|
|
const remaining = MAX_CALLS_PER_WINDOW - callHistory.length;
|
|
const recentCount = recentCalls.length + 1; // +1 for current call
|
|
|
|
if (remaining <= 2) {
|
|
return {
|
|
limited: false,
|
|
warning: `⚠️ **${remaining} calls remaining** in this ${RATE_LIMIT_WINDOW_MS/1000/60}-minute window.`
|
|
};
|
|
}
|
|
|
|
if (recentCount >= BURST_LIMIT - 2) {
|
|
return {
|
|
limited: false,
|
|
warning: `⚠️ **${BURST_LIMIT - recentCount} quick calls remaining** - Consider spacing out requests.`
|
|
};
|
|
}
|
|
|
|
return { limited: false };
|
|
};
|
|
|
|
// Modern browser user agents (updated regularly)
|
|
const BROWSER_USER_AGENTS = [
|
|
// Chrome on Windows
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
// Chrome on macOS
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
// Firefox on Windows
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
|
// Firefox on macOS
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0",
|
|
// Safari on macOS
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
|
|
// Edge on Windows
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
|
|
];
|
|
|
|
// Simple user agent for search (less likely to be blocked)
|
|
const SEARCH_USER_AGENT = "MCP-WebTools/0.6 (+https://example.local)";
|
|
|
|
// Simple rate limiting to avoid overwhelming sites
|
|
const requestTimes = new Map();
|
|
const RATE_LIMIT_DELAY = 1000; // 1 second between requests to same domain
|
|
|
|
const getRateLimitDelay = (hostname) => {
|
|
const lastRequest = requestTimes.get(hostname) || 0;
|
|
const now = Date.now();
|
|
const timeSinceLastRequest = now - lastRequest;
|
|
|
|
if (timeSinceLastRequest < RATE_LIMIT_DELAY) {
|
|
return RATE_LIMIT_DELAY - timeSinceLastRequest;
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
const updateRequestTime = (hostname) => {
|
|
requestTimes.set(hostname, Date.now());
|
|
};
|
|
|
|
// Get a random browser user agent
|
|
const getRandomUserAgent = () => {
|
|
return BROWSER_USER_AGENTS[Math.floor(Math.random() * BROWSER_USER_AGENTS.length)];
|
|
};
|
|
|
|
// Generate realistic browser headers
|
|
const getBrowserHeaders = (url) => {
|
|
const urlObj = new URL(url);
|
|
const userAgent = getRandomUserAgent();
|
|
|
|
return {
|
|
"User-Agent": userAgent,
|
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
"Accept-Language": "en-US,en;q=0.9",
|
|
"Accept-Encoding": "gzip, deflate, br",
|
|
"DNT": "1",
|
|
"Connection": "keep-alive",
|
|
"Upgrade-Insecure-Requests": "1",
|
|
"Sec-Fetch-Dest": "document",
|
|
"Sec-Fetch-Mode": "navigate",
|
|
"Sec-Fetch-Site": "none",
|
|
"Sec-Fetch-User": "?1",
|
|
"Cache-Control": "max-age=0",
|
|
"sec-ch-ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
|
|
"sec-ch-ua-mobile": "?0",
|
|
"sec-ch-ua-platform": '"macOS"',
|
|
"Referer": `https://${urlObj.hostname}/`
|
|
};
|
|
};
|
|
|
|
// Setup detailed logging
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const LOG_FILE = join(__dirname, 'mcp-server-detailed.log');
|
|
|
|
// Initialize log file
|
|
if (DETAILED_LOG) {
|
|
const logHeader = `\n${'='.repeat(80)}\nMCP Server Session Started: ${new Date().toISOString()}\n${'='.repeat(80)}\n`;
|
|
if (existsSync(LOG_FILE)) {
|
|
appendFileSync(LOG_FILE, logHeader);
|
|
} else {
|
|
writeFileSync(LOG_FILE, logHeader);
|
|
}
|
|
}
|
|
|
|
// Enhanced logging functions
|
|
const log = (...args) => {
|
|
if (DEBUG) {
|
|
console.error("[DEBUG]", new Date().toISOString(), ...args);
|
|
}
|
|
};
|
|
|
|
const detailedLog = (category, data) => {
|
|
if (!DETAILED_LOG) return;
|
|
|
|
const timestamp = new Date().toISOString();
|
|
const logEntry = {
|
|
timestamp,
|
|
category,
|
|
data
|
|
};
|
|
|
|
const logLine = `[${timestamp}] ${category}: ${JSON.stringify(data, null, 2)}\n`;
|
|
|
|
try {
|
|
appendFileSync(LOG_FILE, logLine);
|
|
} catch (error) {
|
|
console.error("Failed to write to log file:", error.message);
|
|
}
|
|
|
|
// Also log to console if debug is enabled
|
|
if (DEBUG) {
|
|
console.error(`[DETAILED-${category}]`, JSON.stringify(data, null, 2));
|
|
}
|
|
};
|
|
|
|
// Helper function to safely extract text
|
|
const safeText = (s = "", max = 20000) =>
|
|
String(s ?? "").replace(/\s+/g, " ").trim().slice(0, max);
|
|
|
|
// ---------- Create MCP server ----------
|
|
const server = new Server(
|
|
{
|
|
name: "mcp-web-tools-working",
|
|
version: "0.6.0",
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {},
|
|
},
|
|
}
|
|
);
|
|
|
|
log("Starting MCP server...");
|
|
|
|
// ---------- Tool Definitions ----------
|
|
const TOOLS = [
|
|
{
|
|
name: "web_search",
|
|
description: "Search the web using a local SearxNG instance. Returns search results with titles, URLs, and snippets.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
query: {
|
|
type: "string",
|
|
description: "Search query",
|
|
},
|
|
limit: {
|
|
type: "number",
|
|
description: "Maximum number of results to return (1-20)",
|
|
minimum: 1,
|
|
maximum: 20,
|
|
default: 5,
|
|
},
|
|
site: {
|
|
type: "string",
|
|
description: "Restrict search to a specific site (e.g., 'weather.gov')",
|
|
},
|
|
engines: {
|
|
type: "string",
|
|
description: "Comma-separated list of search engines",
|
|
},
|
|
language: {
|
|
type: "string",
|
|
description: "Language code (e.g., 'en')",
|
|
},
|
|
safesearch: {
|
|
type: "number",
|
|
description: "Safe search level: 0=off, 1=moderate, 2=strict",
|
|
minimum: 0,
|
|
maximum: 2,
|
|
},
|
|
page: {
|
|
type: "number",
|
|
description: "Page number for pagination",
|
|
minimum: 1,
|
|
default: 1,
|
|
},
|
|
time_range: {
|
|
type: "string",
|
|
description: "Time range filter",
|
|
enum: ["day", "week", "month", "year"],
|
|
},
|
|
},
|
|
required: ["query"],
|
|
},
|
|
},
|
|
{
|
|
name: "web_fetch",
|
|
description: "Fetch and extract readable content from a web page URL using Mozilla Readability.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
url: {
|
|
type: "string",
|
|
description: "HTTP/HTTPS URL to fetch (must be a valid URL)",
|
|
},
|
|
max_chars: {
|
|
type: "number",
|
|
description: "Maximum characters to return",
|
|
minimum: 1000,
|
|
maximum: 100000,
|
|
default: 20000,
|
|
},
|
|
},
|
|
required: ["url"],
|
|
},
|
|
},
|
|
];
|
|
|
|
// ---------- Tool Handlers ----------
|
|
async function handleWebSearch(args) {
|
|
log("web_search called with args:", args);
|
|
|
|
// Check call limit
|
|
const limitCheck = checkCallLimit();
|
|
if (limitCheck.limited) {
|
|
return {
|
|
content: [{ type: "text", text: limitCheck.message }]
|
|
};
|
|
}
|
|
|
|
const { query, limit = 5, site, engines, language, safesearch, page = 1, time_range } = args;
|
|
|
|
if (!query) {
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: "Error: Missing required parameter 'query'"
|
|
}]
|
|
};
|
|
}
|
|
|
|
try {
|
|
// Build search query
|
|
const searchQuery = site ? `${query} site:${site}` : query;
|
|
|
|
// Build URL with parameters
|
|
const url = new URL("/search", SEARXNG_BASE);
|
|
url.searchParams.set("format", "json");
|
|
url.searchParams.set("q", searchQuery);
|
|
url.searchParams.set("pageno", String(page));
|
|
url.searchParams.set("categories", "general");
|
|
|
|
if (limit) {
|
|
url.searchParams.set("count", String(limit));
|
|
}
|
|
|
|
if (engines) url.searchParams.set("engines", engines);
|
|
if (language) url.searchParams.set("language", language);
|
|
if (typeof safesearch === "number") url.searchParams.set("safesearch", String(safesearch));
|
|
if (time_range) url.searchParams.set("time_range", time_range);
|
|
|
|
log("Fetching URL:", url.toString());
|
|
|
|
const startTime = Date.now();
|
|
const response = await fetch(url.toString(), {
|
|
headers: {
|
|
"User-Agent": SEARCH_USER_AGENT,
|
|
"Accept": "application/json",
|
|
},
|
|
signal: AbortSignal.timeout(15000)
|
|
});
|
|
|
|
const fetchTime = Date.now() - startTime;
|
|
log(`Response received in ${fetchTime}ms, status: ${response.status}`);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log("Error response:", errorText);
|
|
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `Search failed: HTTP ${response.status} - ${response.statusText}`
|
|
}]
|
|
};
|
|
}
|
|
|
|
const responseText = await response.text();
|
|
log("Response text length:", responseText.length);
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(responseText);
|
|
} catch (parseError) {
|
|
log("JSON parse error:", parseError);
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `Search failed: Invalid JSON response from search engine`
|
|
}]
|
|
};
|
|
}
|
|
|
|
log("Parsed response structure:", {
|
|
hasResults: Array.isArray(data.results),
|
|
resultsLength: data.results?.length || 0,
|
|
numberofResults: data.number_of_results,
|
|
});
|
|
|
|
const rawResults = data.results || [];
|
|
|
|
if (rawResults.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `No search results found for query: "${query}"`
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Process and format results
|
|
const results = rawResults.slice(0, limit).map((item, index) => {
|
|
log(`Processing result ${index + 1}:`, {
|
|
title: item.title?.slice(0, 50) + "...",
|
|
url: item.url,
|
|
engine: item.engine,
|
|
});
|
|
|
|
return {
|
|
title: safeText(item.title || "No title", 300),
|
|
url: item.url || "",
|
|
snippet: safeText(item.content || item.description || "", 500),
|
|
engine: item.engine || "unknown",
|
|
score: item.score || 0,
|
|
category: item.category || "general"
|
|
};
|
|
});
|
|
|
|
// Create formatted response text
|
|
let formattedResponse = `Search Results for "${query}":\n\n`;
|
|
results.forEach((result, index) => {
|
|
formattedResponse += `${index + 1}. **${result.title}**\n`;
|
|
formattedResponse += ` URL: ${result.url}\n`;
|
|
formattedResponse += ` ${result.snippet}\n`;
|
|
formattedResponse += ` Source: ${result.engine}\n\n`;
|
|
});
|
|
|
|
formattedResponse += `\nFound ${results.length} results`;
|
|
if (data.number_of_results) {
|
|
formattedResponse += ` (${data.number_of_results} total available)`;
|
|
}
|
|
|
|
// Add warning if approaching limit
|
|
if (limitCheck.warning) {
|
|
formattedResponse += `\n\n${limitCheck.warning}`;
|
|
}
|
|
|
|
log("Returning successful result with", results.length, "items");
|
|
|
|
return {
|
|
content: [{ type: "text", text: formattedResponse }]
|
|
};
|
|
|
|
} catch (error) {
|
|
log("Exception in web_search:", error);
|
|
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `Search failed: ${error.message}`
|
|
}]
|
|
};
|
|
}
|
|
}
|
|
|
|
async function handleWebFetch(args) {
|
|
log("web_fetch called with args:", args);
|
|
|
|
// Check call limit
|
|
const limitCheck = checkCallLimit();
|
|
if (limitCheck.limited) {
|
|
return {
|
|
content: [{ type: "text", text: limitCheck.message }]
|
|
};
|
|
}
|
|
|
|
const { url, max_chars = 20000, retry_count = 0 } = args;
|
|
|
|
if (!url) {
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: "Error: Missing required parameter 'url'"
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Validate URL
|
|
let validUrl;
|
|
try {
|
|
validUrl = new URL(url);
|
|
if (!["http:", "https:"].includes(validUrl.protocol)) {
|
|
throw new Error("Only HTTP and HTTPS URLs are supported");
|
|
}
|
|
} catch (urlError) {
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `Invalid URL: ${urlError.message}`
|
|
}]
|
|
};
|
|
}
|
|
|
|
try {
|
|
log("Fetching URL:", validUrl.toString());
|
|
|
|
// Apply rate limiting
|
|
const hostname = validUrl.hostname;
|
|
const rateLimitDelay = getRateLimitDelay(hostname);
|
|
if (rateLimitDelay > 0) {
|
|
log(`Rate limiting: waiting ${rateLimitDelay}ms for ${hostname}`);
|
|
await new Promise(resolve => setTimeout(resolve, rateLimitDelay));
|
|
}
|
|
updateRequestTime(hostname);
|
|
|
|
// Add realistic delay to simulate human browsing (only on first attempt)
|
|
if (retry_count === 0) {
|
|
await new Promise(resolve => setTimeout(resolve, Math.random() * 1000 + 500));
|
|
}
|
|
|
|
// Get realistic browser headers
|
|
const browserHeaders = getBrowserHeaders(validUrl.toString());
|
|
|
|
// Add some randomization to headers to avoid fingerprinting
|
|
const headers = { ...browserHeaders };
|
|
|
|
// Sometimes remove optional headers to vary fingerprint
|
|
if (Math.random() > 0.7) {
|
|
delete headers["DNT"];
|
|
}
|
|
if (Math.random() > 0.8) {
|
|
delete headers["sec-ch-ua"];
|
|
delete headers["sec-ch-ua-mobile"];
|
|
delete headers["sec-ch-ua-platform"];
|
|
}
|
|
|
|
log("Using headers:", Object.keys(headers).join(", "));
|
|
|
|
const response = await fetch(validUrl.toString(), {
|
|
headers,
|
|
signal: AbortSignal.timeout(20000), // Increased timeout
|
|
redirect: 'follow',
|
|
// Add realistic fetch options
|
|
method: 'GET',
|
|
mode: 'cors',
|
|
credentials: 'omit',
|
|
cache: 'default'
|
|
});
|
|
|
|
log("Fetch response status:", response.status);
|
|
|
|
// Handle different HTTP status codes with retry logic
|
|
if (!response.ok) {
|
|
// Retry on certain status codes if we haven't retried yet
|
|
if (retry_count === 0 && [429, 503, 502, 504].includes(response.status)) {
|
|
log(`Retrying request due to ${response.status} status`);
|
|
await new Promise(resolve => setTimeout(resolve, 2000 + Math.random() * 3000));
|
|
return handleWebFetch({ ...args, retry_count: 1 });
|
|
}
|
|
|
|
// Handle specific error codes with helpful messages
|
|
let errorMessage;
|
|
switch (response.status) {
|
|
case 403:
|
|
errorMessage = `Access forbidden (${response.status}). The site may be blocking automated requests or require authentication.`;
|
|
break;
|
|
case 404:
|
|
errorMessage = `Page not found (${response.status}). The URL may be incorrect or the page may have been moved.`;
|
|
break;
|
|
case 429:
|
|
errorMessage = `Rate limited (${response.status}). The site is temporarily blocking requests due to too many attempts.`;
|
|
break;
|
|
case 503:
|
|
errorMessage = `Service unavailable (${response.status}). The site may be temporarily down or overloaded.`;
|
|
break;
|
|
default:
|
|
errorMessage = `Failed to fetch URL: HTTP ${response.status} - ${response.statusText}`;
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: errorMessage
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Check content type to handle non-HTML content appropriately
|
|
const contentType = response.headers.get('content-type') || '';
|
|
log("Content-Type:", contentType);
|
|
|
|
// Handle non-HTML content types
|
|
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
|
|
const fileExtension = validUrl.pathname.split('.').pop()?.toLowerCase();
|
|
|
|
if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(fileExtension || '')) {
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `Cannot extract text from ${fileExtension?.toUpperCase() || 'binary'} files. This URL points to a ${contentType || 'binary'} file, not a web page. Please provide a URL to an HTML web page for text extraction.`
|
|
}]
|
|
};
|
|
}
|
|
|
|
if (contentType.includes('application/') || contentType.includes('image/') || contentType.includes('video/') || contentType.includes('audio/')) {
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `Cannot extract text from ${contentType} content. This URL points to a binary file, not a web page. Please provide a URL to an HTML web page for text extraction.`
|
|
}]
|
|
};
|
|
}
|
|
}
|
|
|
|
const html = await response.text();
|
|
log("Fetched content length:", html.length);
|
|
|
|
// Check if content looks like binary data (common with encoding issues)
|
|
const binaryPattern = /[\x00-\x08\x0E-\x1F\x7F-\xFF]/g;
|
|
const binaryMatches = html.match(binaryPattern);
|
|
if (binaryMatches && binaryMatches.length > html.length * 0.1) {
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `This URL appears to contain binary data or has encoding issues. The content cannot be properly extracted as readable text. Please verify the URL points to a standard HTML web page.`
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Enhanced content extraction with better DOM parsing
|
|
const dom = new JSDOM(html, {
|
|
url: validUrl.toString(),
|
|
pretendToBeVisual: true,
|
|
resources: "usable"
|
|
});
|
|
|
|
// Wait a moment for any immediate DOM updates
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
const document = dom.window.document;
|
|
|
|
// Remove unwanted elements that interfere with content extraction
|
|
const unwantedSelectors = [
|
|
'script', 'style', 'nav', 'header', 'footer', 'aside',
|
|
'.advertisement', '.ads', '.social-share', '.comments',
|
|
'.sidebar', '.menu', '.navigation', '.cookie-notice',
|
|
'[class*="ad-"]', '[id*="ad-"]', '[class*="social"]'
|
|
];
|
|
|
|
unwantedSelectors.forEach(selector => {
|
|
const elements = document.querySelectorAll(selector);
|
|
elements.forEach(el => el.remove());
|
|
});
|
|
|
|
// Try Readability first
|
|
const reader = new Readability(document);
|
|
const article = reader.parse();
|
|
|
|
let extractedContent;
|
|
if (article && article.textContent && article.textContent.trim().length > 100) {
|
|
log("Readability extraction successful");
|
|
extractedContent = `**${article.title || document.title || "Untitled"}**\n\n`;
|
|
if (article.byline) {
|
|
extractedContent += `By: ${article.byline}\n\n`;
|
|
}
|
|
extractedContent += safeText(article.textContent || "", max_chars);
|
|
} else {
|
|
log("Readability failed, trying enhanced fallback extraction");
|
|
|
|
// Enhanced fallback: try to find main content areas
|
|
const contentSelectors = [
|
|
'main', 'article', '[role="main"]', '.main-content', '.content',
|
|
'.post-content', '.entry-content', '.article-content', '.story-body',
|
|
'#content', '#main', '.container .content', '.page-content'
|
|
];
|
|
|
|
let bestContent = "";
|
|
let bestScore = 0;
|
|
|
|
for (const selector of contentSelectors) {
|
|
const elements = document.querySelectorAll(selector);
|
|
for (const element of elements) {
|
|
const text = element.textContent || "";
|
|
const score = text.length;
|
|
if (score > bestScore && score > 200) {
|
|
bestContent = text;
|
|
bestScore = score;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no good content found, fall back to body
|
|
if (!bestContent || bestContent.trim().length < 100) {
|
|
bestContent = document.body?.textContent || "";
|
|
}
|
|
|
|
// Check if content is meaningful
|
|
if (bestContent.trim().length < 100) {
|
|
// Try one more approach: look for paragraphs
|
|
const paragraphs = Array.from(document.querySelectorAll('p'))
|
|
.map(p => p.textContent || "")
|
|
.filter(text => text.trim().length > 20)
|
|
.join("\n\n");
|
|
|
|
if (paragraphs.length > 100) {
|
|
bestContent = paragraphs;
|
|
} else {
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `Unable to extract meaningful text content from this URL. The page may be:\n- A single-page application that loads content with JavaScript\n- A page with mostly images or media\n- Protected by authentication or paywall\n- Not a standard HTML page\n- Blocked by anti-bot measures\n\nPage title: ${document.title || "No title"}\nTry accessing the URL directly in a browser to verify the content is accessible.`
|
|
}]
|
|
};
|
|
}
|
|
}
|
|
|
|
extractedContent = `**${document.title || "Untitled"}**\n\n`;
|
|
extractedContent += safeText(bestContent, max_chars);
|
|
}
|
|
|
|
// Final check for garbled content
|
|
const cleanContent = extractedContent.replace(/[^\x20-\x7E\n\r\t]/g, '');
|
|
if (cleanContent.length < extractedContent.length * 0.5) {
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `The extracted content contains significant encoding issues or non-text data. This may be due to:\n- Character encoding problems\n- Binary content mixed with text\n- Non-standard page format\n\nPage title: ${dom.window.document.title || "No title"}\nURL: ${validUrl.toString()}`
|
|
}]
|
|
};
|
|
}
|
|
|
|
log("Content extraction completed, content length:", extractedContent.length);
|
|
|
|
// Add warning if approaching limit
|
|
if (limitCheck.warning) {
|
|
extractedContent += `\n\n${limitCheck.warning}`;
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text", text: extractedContent }]
|
|
};
|
|
|
|
} catch (error) {
|
|
log("Exception in web_fetch:", error);
|
|
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `Fetch failed: ${error.message}`
|
|
}]
|
|
};
|
|
}
|
|
}
|
|
|
|
// ---------- Server Handlers ----------
|
|
server.setRequestHandler(ListToolsRequestSchema, async (request) => {
|
|
detailedLog("INCOMING_REQUEST", {
|
|
method: "tools/list",
|
|
request: request,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
const response = {
|
|
tools: TOOLS,
|
|
};
|
|
|
|
detailedLog("OUTGOING_RESPONSE", {
|
|
method: "tools/list",
|
|
response: response,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
return response;
|
|
});
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
const { name, arguments: args } = request.params;
|
|
|
|
detailedLog("INCOMING_REQUEST", {
|
|
method: "tools/call",
|
|
tool_name: name,
|
|
arguments: args,
|
|
full_request: request,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
log("Tool called:", name, "with args:", args);
|
|
|
|
let response;
|
|
let error = null;
|
|
|
|
try {
|
|
switch (name) {
|
|
case "web_search":
|
|
response = await handleWebSearch(args || {});
|
|
break;
|
|
case "web_fetch":
|
|
response = await handleWebFetch(args || {});
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown tool: ${name}`);
|
|
}
|
|
} catch (err) {
|
|
error = err;
|
|
response = {
|
|
content: [{
|
|
type: "text",
|
|
text: `Tool execution failed: ${err.message}`
|
|
}]
|
|
};
|
|
}
|
|
|
|
detailedLog("OUTGOING_RESPONSE", {
|
|
method: "tools/call",
|
|
tool_name: name,
|
|
response: response,
|
|
error: error ? {
|
|
message: error.message,
|
|
stack: error.stack
|
|
} : null,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
if (error) {
|
|
detailedLog("ERROR", {
|
|
method: "tools/call",
|
|
tool_name: name,
|
|
error_details: {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
arguments: args
|
|
},
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
}
|
|
|
|
return response;
|
|
});
|
|
|
|
// ---------- Connect and Start ----------
|
|
async function main() {
|
|
// Log server startup
|
|
detailedLog("SERVER_STARTUP", {
|
|
server_info: {
|
|
name: "mcp-web-tools-final",
|
|
version: "0.6.0",
|
|
searxng_base: SEARXNG_BASE,
|
|
debug_enabled: DEBUG,
|
|
detailed_log_enabled: DETAILED_LOG,
|
|
log_file: LOG_FILE
|
|
},
|
|
environment: {
|
|
node_version: process.version,
|
|
platform: process.platform,
|
|
cwd: process.cwd(),
|
|
argv: process.argv
|
|
},
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
// Note: Initialization is handled automatically by the MCP SDK
|
|
// We'll capture it through transport-level logging if needed
|
|
|
|
// Log all unhandled requests
|
|
server.onerror = (error) => {
|
|
detailedLog("SERVER_ERROR", {
|
|
error: {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
name: error.name
|
|
},
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
console.error("Server error:", error);
|
|
};
|
|
|
|
const transport = new StdioServerTransport();
|
|
|
|
detailedLog("TRANSPORT_CONNECTING", {
|
|
transport_type: "stdio",
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
await server.connect(transport);
|
|
|
|
detailedLog("SERVER_READY", {
|
|
message: "MCP Web Tools Server is ready and listening",
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
console.error("MCP Web Tools Server ready");
|
|
console.error(`[mcp-web-tools-working] SearxNG Base: ${SEARXNG_BASE}`);
|
|
console.error(`[mcp-web-tools-working] Debug mode: ${DEBUG}`);
|
|
console.error(`[mcp-web-tools-working] Detailed logging: ${DETAILED_LOG ? 'ENABLED' : 'DISABLED'}`);
|
|
if (DETAILED_LOG) {
|
|
console.error(`[mcp-web-tools-working] Log file: ${LOG_FILE}`);
|
|
}
|
|
}
|
|
|
|
// Add process event handlers for cleanup logging
|
|
process.on('SIGINT', () => {
|
|
detailedLog("SERVER_SHUTDOWN", {
|
|
reason: "SIGINT",
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGTERM', () => {
|
|
detailedLog("SERVER_SHUTDOWN", {
|
|
reason: "SIGTERM",
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
console.error("[mcp-web-tools-working] Shutting down gracefully...");
|
|
process.exit(0);
|
|
});
|
|
|
|
main().catch((error) => {
|
|
console.error("Fatal error in main():", error);
|
|
process.exit(1);
|
|
});
|