Building a Custom Fetch MCP Server for LLM-Ready Web Content

Introduction

When working with Large Language Models (LLMs), one common challenge is feeding them web content in a clean, consistent format. While you could use simple HTTP requests or existing scraping tools, building a dedicated MCP server for this task offers several advantages:

Setting Up the Project

Let's create a new MCP server using the TypeScript template:

npx @modelcontextprotocol/create-server fetch-server
cd fetch-server
npm install playwright turndown

We'll use Playwright for web scraping and Turndown for converting HTML to Markdown.

Implementing the Server

Here's our implementation in src/index.ts:

#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ErrorCode,
  ListToolsRequestSchema,
  McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { chromium } from 'playwright';
import TurndownService from 'turndown';

// Initialize Turndown for HTML to Markdown conversion
const turndown = new TurndownService({
  headingStyle: 'atx',
  codeBlockStyle: 'fenced'
});

// Custom rules for better markdown output
turndown.addRule('removeScripts', {
  filter: ['script', 'style', 'iframe', 'noscript'],
  replacement: () => ''
});

turndown.addRule('preserveLinks', {
  filter: 'a',
  replacement: (content, node: any) => {
    const href = node.getAttribute('href');
    return href ? `[${content}](${href})` : content;
  }
});

class FetchServer {
  private server: Server;
  private browser: any;

  constructor() {
    this.server = new Server(
      {
        name: 'fetch-server',
        version: '0.1.0',
      },
      {
        capabilities: {
          tools: {},
        },
      }
    );

    this.setupToolHandlers();
    
    // Error handling
    this.server.onerror = (error) => console.error('[MCP Error]', error);
    process.on('SIGINT', async () => {
      await this.cleanup();
      process.exit(0);
    });
  }

  private async initBrowser() {
    if (!this.browser) {
      this.browser = await chromium.launch();
    }
  }

  private async cleanup() {
    if (this.browser) {
      await this.browser.close();
    }
    await this.server.close();
  }

  private setupToolHandlers() {
    this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
      tools: [
        {
          name: 'fetch_and_process',
          description: 'Fetch a webpage and convert it to markdown for LLM processing',
          inputSchema: {
            type: 'object',
            properties: {
              url: {
                type: 'string',
                description: 'URL to fetch'
              },
              waitForSelector: {
                type: 'string',
                description: 'Optional CSS selector to wait for before processing',
              },
              excludeSelectors: {
                type: 'array',
                items: { type: 'string' },
                description: 'Optional CSS selectors to remove from content',
              },
              maxLength: {
                type: 'number',
                description: 'Maximum length of processed content (default: 100000)',
              }
            },
            required: ['url']
          }
        }
      ]
    }));

    this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
      if (request.params.name !== 'fetch_and_process') {
        throw new McpError(
          ErrorCode.MethodNotFound,
          `Unknown tool: ${request.params.name}`
        );
      }

      const args = request.params.arguments as {
        url: string;
        waitForSelector?: string;
        excludeSelectors?: string[];
        maxLength?: number;
      };

      try {
        await this.initBrowser();
        const page = await this.browser.newPage();

        // Set viewport and user agent
        await page.setViewport({ width: 1280, height: 800 });
        await page.setExtraHTTPHeaders({
          'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
        });

        // Navigate and wait
        await page.goto(args.url, { waitUntil: 'networkidle' });
        if (args.waitForSelector) {
          await page.waitForSelector(args.waitForSelector);
        }

        // Remove unwanted elements
        if (args.excludeSelectors) {
          for (const selector of args.excludeSelectors) {
            await page.$$eval(selector, (elements) => 
              elements.forEach(el => el.remove())
            );
          }
        }

        // Extract content
        const content = await page.evaluate(() => {
          // Remove hidden elements
          document.querySelectorAll('[hidden], [style*="display: none"]')
            .forEach(el => el.remove());
          
          return document.body.innerHTML;
        });

        await page.close();

        // Convert to markdown
        let markdown = turndown.turndown(content);

        // Clean up the markdown
        markdown = markdown
          .replace(/\n{3,}/g, '\n\n')  // Remove excess newlines
          .replace(/!\[.*?\]\(.*?\)/g, '')  // Remove images
          .trim();

        // Truncate if needed
        const maxLength = args.maxLength || 100000;
        if (markdown.length > maxLength) {
          markdown = markdown.slice(0, maxLength) + '\n\n[Content truncated...]';
        }

        return {
          content: [
            {
              type: 'text',
              text: markdown
            }
          ]
        };

      } catch (error) {
        if (error instanceof Error) {
          return {
            content: [
              {
                type: 'text',
                text: `Error processing URL: ${error.message}`
              }
            ],
            isError: true
          };
        }
        throw error;
      }
    });
  }

  async run() {
    const transport = new StdioServerTransport();
    await this.server.connect(transport);
    console.error('Fetch MCP server running on stdio');
  }
}

const server = new FetchServer();
server.run().catch(console.error);

Key Features

Our fetch MCP server includes several important features:

Using the Server

After building and installing the server, you can use it like this:

// Example usage in an LLM application
const response = await use_mcp_tool({
  server_name: "fetch-server",
  tool_name: "fetch_and_process",
  arguments: {
    url: "https://example.com/article",
    waitForSelector: "article",
    excludeSelectors: [
      ".comments",
      ".ads",
      "nav",
      "footer"
    ],
    maxLength: 50000
  }
});

Advanced Configuration

You can enhance the server further with these additions:

  1. Rate Limiting: Add delays between requests to respect website limits
  2. Caching: Store processed content to avoid repeated fetches
  3. Custom Processing: Add site-specific content extraction rules
  4. Error Recovery: Implement retries and fallback strategies

Ethical Considerations

When building web scraping tools, always:

Conclusion

A custom fetch MCP server provides a robust foundation for feeding web content to LLMs. By centralizing the fetching and processing logic, you ensure consistent, clean input for your language models while maintaining scalability and reusability across different projects.