fetchserp-mcp-server
Version:
A Model Context Protocol (MCP) server that provides access to FetchSERP API for SEO analysis, SERP data, web scraping, and keyword research. Supports both stdio and HTTP transport modes.
835 lines (774 loc) ⢠28.5 kB
JavaScript
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import fetch from 'node-fetch';
import express from 'express';
const API_BASE_URL = 'https://www.fetchserp.com';
class FetchSERPServer {
constructor() {
this.server = new Server(
{
name: 'fetchserp-mcp-server',
version: '1.0.5',
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
}
setupToolHandlers() {
this.currentToken = null; // Store token for current request context
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'get_backlinks',
description: 'Get backlinks for a given domain',
inputSchema: {
type: 'object',
properties: {
domain: {
type: 'string',
description: 'The domain to search for backlinks',
},
search_engine: {
type: 'string',
description: 'The search engine to use (google, bing, yahoo, duckduckgo). Default: google',
default: 'google',
},
country: {
type: 'string',
description: 'The country to search from. Default: us',
default: 'us',
},
pages_number: {
type: 'integer',
description: 'The number of pages to search (1-30). Default: 15',
default: 15,
minimum: 1,
maximum: 30,
},
},
required: ['domain'],
},
},
{
name: 'get_domain_emails',
description: 'Retrieve emails from a given domain',
inputSchema: {
type: 'object',
properties: {
domain: {
type: 'string',
description: 'The domain to search emails from',
},
search_engine: {
type: 'string',
description: 'The search engine to use (google, bing, yahoo, duckduckgo). Default: google',
default: 'google',
},
country: {
type: 'string',
description: 'The country to search from. Default: us',
default: 'us',
},
pages_number: {
type: 'integer',
description: 'The number of pages to search (1-30). Default: 1',
default: 1,
minimum: 1,
maximum: 30,
},
},
required: ['domain'],
},
},
{
name: 'get_domain_info',
description: 'Get domain info including DNS records, WHOIS data, SSL certificates, and technology stack',
inputSchema: {
type: 'object',
properties: {
domain: {
type: 'string',
description: 'The domain to check',
},
},
required: ['domain'],
},
},
{
name: 'get_keywords_search_volume',
description: 'Get search volume for given keywords',
inputSchema: {
type: 'object',
properties: {
keywords: {
type: 'array',
items: { type: 'string' },
description: 'The keywords to search',
},
country: {
type: 'string',
description: 'The country code to search for',
},
},
required: ['keywords'],
},
},
{
name: 'get_keywords_suggestions',
description: 'Get keyword suggestions based on a url or a list of keywords',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'The url to search (optional if keywords provided)',
},
keywords: {
type: 'array',
items: { type: 'string' },
description: 'The keywords to search (optional if url provided)',
},
country: {
type: 'string',
description: 'The country code to search for',
},
},
},
},
{
name: 'get_long_tail_keywords',
description: 'Generate long-tail keywords for a given keyword',
inputSchema: {
type: 'object',
properties: {
keyword: {
type: 'string',
description: 'The seed keyword to generate long-tail keywords from',
},
search_intent: {
type: 'string',
description: 'The search intent (informational, commercial, transactional, navigational). Default: informational',
default: 'informational',
},
count: {
type: 'integer',
description: 'The number of long-tail keywords to generate (1-500). Default: 10',
default: 10,
minimum: 1,
maximum: 500,
},
},
required: ['keyword'],
},
},
{
name: 'get_moz_analysis',
description: 'Get Moz domain analysis data',
inputSchema: {
type: 'object',
properties: {
domain: {
type: 'string',
description: 'The domain to analyze',
},
},
required: ['domain'],
},
},
{
name: 'check_page_indexation',
description: 'Check if a domain is indexed for a given keyword',
inputSchema: {
type: 'object',
properties: {
domain: {
type: 'string',
description: 'The domain to check',
},
keyword: {
type: 'string',
description: 'The keyword to check',
},
},
required: ['domain', 'keyword'],
},
},
{
name: 'get_domain_ranking',
description: 'Get domain ranking for a given keyword',
inputSchema: {
type: 'object',
properties: {
keyword: {
type: 'string',
description: 'The keyword to search',
},
domain: {
type: 'string',
description: 'The domain to search',
},
search_engine: {
type: 'string',
description: 'The search engine to use (google, bing, yahoo, duckduckgo). Default: google',
default: 'google',
},
country: {
type: 'string',
description: 'The country to search from. Default: us',
default: 'us',
},
pages_number: {
type: 'integer',
description: 'The number of pages to search (1-30). Default: 10',
default: 10,
minimum: 1,
maximum: 30,
},
},
required: ['keyword', 'domain'],
},
},
{
name: 'scrape_webpage',
description: 'Scrape a web page without JS',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'The url to scrape',
},
},
required: ['url'],
},
},
{
name: 'scrape_domain',
description: 'Scrape a domain',
inputSchema: {
type: 'object',
properties: {
domain: {
type: 'string',
description: 'The domain to scrape',
},
max_pages: {
type: 'integer',
description: 'The maximum number of pages to scrape (up to 200). Default: 10',
default: 10,
maximum: 200,
},
},
required: ['domain'],
},
},
{
name: 'scrape_webpage_js',
description: 'Scrape a web page with custom JS',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'The url to scrape',
},
js_script: {
type: 'string',
description: 'The javascript code to execute on the page',
},
},
required: ['url', 'js_script'],
},
},
{
name: 'scrape_webpage_js_proxy',
description: 'Scrape a web page with JS and proxy',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'The url to scrape',
},
country: {
type: 'string',
description: 'The country to use for the proxy',
},
js_script: {
type: 'string',
description: 'The javascript code to execute on the page',
},
},
required: ['url', 'country', 'js_script'],
},
},
{
name: 'get_serp_results',
description: 'Get search engine results',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The query to search',
},
search_engine: {
type: 'string',
description: 'The search engine to use (google, bing, yahoo, duckduckgo). Default: google',
default: 'google',
},
country: {
type: 'string',
description: 'The country to search from. Default: us',
default: 'us',
},
pages_number: {
type: 'integer',
description: 'The number of pages to search (1-30). Default: 1',
default: 1,
minimum: 1,
maximum: 30,
},
},
required: ['query'],
},
},
{
name: 'get_serp_html',
description: 'Get search engine results with HTML content',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The query to search',
},
search_engine: {
type: 'string',
description: 'The search engine to use (google, bing, yahoo, duckduckgo). Default: google',
default: 'google',
},
country: {
type: 'string',
description: 'The country to search from. Default: us',
default: 'us',
},
pages_number: {
type: 'integer',
description: 'The number of pages to search (1-30). Default: 1',
default: 1,
minimum: 1,
maximum: 30,
},
},
required: ['query'],
},
},
{
name: 'get_serp_ai_mode',
description: 'Get SERP with AI Overview and AI Mode response. Returns AI overview and AI mode response for the query. Less reliable than the 2-step process but returns results in under 30 seconds.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The query to search',
},
country: {
type: 'string',
description: 'The country to search from. Default: us',
default: 'us',
},
},
required: ['query'],
},
},
{
name: 'get_serp_text',
description: 'Get search engine results with text content',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The query to search',
},
search_engine: {
type: 'string',
description: 'The search engine to use (google, bing, yahoo, duckduckgo). Default: google',
default: 'google',
},
country: {
type: 'string',
description: 'The country to search from. Default: us',
default: 'us',
},
pages_number: {
type: 'integer',
description: 'The number of pages to search (1-30). Default: 1',
default: 1,
minimum: 1,
maximum: 30,
},
},
required: ['query'],
},
},
{
name: 'get_user_info',
description: 'Get user information including API credit',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'get_webpage_ai_analysis',
description: 'Analyze a web page with AI',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'The url to analyze',
},
prompt: {
type: 'string',
description: 'The prompt to use for the analysis',
},
},
required: ['url', 'prompt'],
},
},
{
name: 'generate_wordpress_content',
description: 'Generate WordPress content using AI with customizable prompts and models',
inputSchema: {
type: 'object',
properties: {
user_prompt: {
type: 'string',
description: 'The user prompt',
},
system_prompt: {
type: 'string',
description: 'The system prompt',
},
ai_model: {
type: 'string',
description: 'The AI model (default: gpt-4.1-nano)',
default: 'gpt-4.1-nano',
},
},
required: ['user_prompt', 'system_prompt'],
},
},
{
name: 'generate_social_content',
description: 'Generate social media content using AI with customizable prompts and models',
inputSchema: {
type: 'object',
properties: {
user_prompt: {
type: 'string',
description: 'The user prompt',
},
system_prompt: {
type: 'string',
description: 'The system prompt',
},
ai_model: {
type: 'string',
description: 'The AI model (default: gpt-4.1-nano)',
default: 'gpt-4.1-nano',
},
},
required: ['user_prompt', 'system_prompt'],
},
},
{
name: 'get_playwright_mcp',
description: 'Use GPT-4.1 to remote control a browser via a Playwright MCP server',
inputSchema: {
type: 'object',
properties: {
prompt: {
type: 'string',
description: 'The prompt to use for remote control of the browser',
},
},
required: ['prompt'],
},
},
{
name: 'get_webpage_seo_analysis',
description: 'Get SEO analysis for a given url',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'The url to analyze',
},
},
required: ['url'],
},
},
],
};
});
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
const result = await this.handleToolCall(name, args, this.currentToken);
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
`Tool execution failed: ${error.message}`
);
}
});
}
async makeRequest(endpoint, method = 'GET', params = {}, body = null, token = null) {
const fetchserpToken = token || process.env.FETCHSERP_API_TOKEN;
if (!fetchserpToken) {
throw new McpError(
ErrorCode.InvalidRequest,
'FETCHSERP_API_TOKEN is required'
);
}
const url = new URL(`${API_BASE_URL}${endpoint}`);
// Add query parameters for GET requests
if (method === 'GET' && Object.keys(params).length > 0) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
value.forEach(v => url.searchParams.append(`${key}[]`, v));
} else {
url.searchParams.append(key, value.toString());
}
}
});
}
const fetchOptions = {
method,
headers: {
'Authorization': `Bearer ${fetchserpToken}`,
'Content-Type': 'application/json',
},
};
if (body && method !== 'GET') {
fetchOptions.body = JSON.stringify(body);
}
const response = await fetch(url.toString(), fetchOptions);
if (!response.ok) {
const errorText = await response.text();
throw new McpError(
ErrorCode.InternalError,
`API request failed: ${response.status} ${response.statusText} - ${errorText}`
);
}
return await response.json();
}
async handleToolCall(name, args, token = null) {
switch (name) {
case 'get_backlinks':
return await this.makeRequest('/api/v1/backlinks', 'GET', args, null, token);
case 'get_domain_emails':
return await this.makeRequest('/api/v1/domain_emails', 'GET', args, null, token);
case 'get_domain_info':
return await this.makeRequest('/api/v1/domain_infos', 'GET', args, null, token);
case 'get_keywords_search_volume':
return await this.makeRequest('/api/v1/keywords_search_volume', 'GET', args, null, token);
case 'get_keywords_suggestions':
return await this.makeRequest('/api/v1/keywords_suggestions', 'GET', args, null, token);
case 'get_long_tail_keywords':
return await this.makeRequest('/api/v1/long_tail_keywords_generator', 'GET', args, null, token);
case 'get_moz_analysis':
return await this.makeRequest('/api/v1/moz', 'GET', args, null, token);
case 'check_page_indexation':
return await this.makeRequest('/api/v1/page_indexation', 'GET', args, null, token);
case 'get_domain_ranking':
return await this.makeRequest('/api/v1/ranking', 'GET', args, null, token);
case 'scrape_webpage':
return await this.makeRequest('/api/v1/scrape', 'GET', args, null, token);
case 'scrape_domain':
return await this.makeRequest('/api/v1/scrape_domain', 'GET', args, null, token);
case 'scrape_webpage_js':
const { url, js_script, ...jsParams } = args;
return await this.makeRequest('/api/v1/scrape_js', 'POST', { url, ...jsParams }, { url, js_script }, token);
case 'scrape_webpage_js_proxy':
const { url: proxyUrl, country, js_script: proxyScript, ...proxyParams } = args;
return await this.makeRequest('/api/v1/scrape_js_with_proxy', 'POST', { url: proxyUrl, country, ...proxyParams }, { url: proxyUrl, js_script: proxyScript }, token);
case 'get_serp_results':
return await this.makeRequest('/api/v1/serp', 'GET', args, null, token);
case 'get_serp_html':
return await this.makeRequest('/api/v1/serp_html', 'GET', args, null, token);
case 'get_serp_ai_mode':
return await this.makeRequest('/api/v1/serp_ai_mode', 'GET', args, null, token);
case 'get_serp_text':
return await this.makeRequest('/api/v1/serp_text', 'GET', args, null, token);
case 'get_user_info':
return await this.makeRequest('/api/v1/user', 'GET', {}, null, token);
case 'get_webpage_ai_analysis':
return await this.makeRequest('/api/v1/web_page_ai_analysis', 'GET', args, null, token);
case 'generate_wordpress_content':
return await this.makeRequest('/api/v1/generate_wordpress_content', 'GET', args, null, token);
case 'generate_social_content':
return await this.makeRequest('/api/v1/generate_social_content', 'GET', args, null, token);
case 'get_playwright_mcp':
return await this.makeRequest('/api/v1/playwright_mcp', 'GET', args, null, token);
case 'get_webpage_seo_analysis':
return await this.makeRequest('/api/v1/web_page_seo_analysis', 'GET', args, null, token);
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${name}`
);
}
}
async run() {
// Check if we should run as HTTP server (for ngrok) or stdio
const useHttp = process.env.MCP_HTTP_MODE === 'true';
if (useHttp) {
// HTTP mode for ngrok
const port = process.env.PORT || 8000;
console.log('Starting HTTP server for ngrok...');
console.log(`Port: ${port}`);
const app = express();
app.use(express.json());
// Map to store transports by session ID
const transports = {};
// SSE endpoint for Claude MCP Connector
app.all('/sse', async (req, res) => {
try {
console.log(`Received ${req.method} MCP request from Claude via ngrok`);
// Extract FETCHSERP_API_TOKEN from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized - Bearer token required' });
}
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
this.currentToken = token; // Set token for this request
// Check for existing session ID
const sessionId = req.headers['mcp-session-id'];
let transport;
if (sessionId && transports[sessionId]) {
// Reuse existing transport
transport = transports[sessionId];
} else if (!sessionId && this.isInitializeRequest(req.body)) {
// New initialization request
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => Math.random().toString(36).substring(2, 15),
});
// Connect to the MCP server
await this.server.connect(transport);
// Handle the request first, then store the transport
await transport.handleRequest(req, res, req.body);
// Store the transport by session ID after handling the request
if (transport.sessionId) {
transports[transport.sessionId] = transport;
console.log(`ā
New session created and stored: ${transport.sessionId}`);
}
return; // Exit early since we already handled the request
} else {
// Invalid request
return res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
},
id: null,
});
}
// Handle the request using the transport (for existing sessions)
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling MCP request:', error);
if (!res.headersSent) {
res.status(500).json({
error: 'Internal server error',
details: error.message
});
}
}
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
server: 'fetchserp-mcp-server',
version: '1.0.5',
transport: 'StreamableHTTP',
protocol: 'http',
port: port,
note: 'Use ngrok for HTTPS tunneling',
endpoint: '/sse - StreamableHTTP transport for Claude MCP Connector'
});
});
// Root endpoint with info
app.get('/', (req, res) => {
res.json({
name: 'FetchSERP MCP Server',
version: '1.0.5',
description: 'MCP server for FetchSERP API with HTTP transport for ngrok tunneling',
protocol: 'http',
port: port,
endpoints: {
sse: `/sse - StreamableHTTP transport for Claude MCP Connector`,
health: `/health - Health check`
},
usage: 'Use ngrok to create HTTPS tunnel, then connect Claude to the ngrok URL + /sse',
note: 'Start with: ngrok http ' + port
});
});
app.listen(port, () => {
console.log(`\nā
HTTP server listening on port ${port}`);
console.log(`š Ready for ngrok tunneling`);
console.log(`š” Start ngrok with: ngrok http ${port}`);
console.log(`SSE endpoint: http://localhost:${port}/sse`);
console.log(`Health check: http://localhost:${port}/health`);
console.log('\nReady for Claude MCP Connector integration via ngrok\n');
});
} else {
// Stdio mode (default) - for Claude Desktop
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('FetchSERP MCP server running on stdio');
}
}
// Helper method to check if request is an initialize request
isInitializeRequest(body) {
if (Array.isArray(body)) {
return body.some(request => request.method === 'initialize');
}
return body && body.method === 'initialize';
}
}
const server = new FetchSERPServer();
server.run().catch(console.error);