The full source code for this project is available on GitHub: mcp-bun-fetch


Why Build an MCP Server? Link to heading

When using Claude Code, I kept running into a frustrating problem: the built-in WebFetch tool would frequently get blocked by servers. Many websites have started detecting and blocking requests that identify themselves as coming from AI tools. Consistently watching the agent get blocked repetitively, and then having to try a different site only to get blocked again is very annoying.

I thought this would be a good opportunity to make a simple MCP server to work around this. The idea was simple: create a tool that fetches web content using a generic user agent, so servers treat it like any other browser request.

What is MCP? Link to heading

MCP is Anthropic’s open protocol for connecting AI assistants to external tools and data sources. It provides a standardized way to extend what Claude can do, whether that’s querying databases, calling APIs, or in my case, fetching web content.

The protocol is straightforward: you define tools with parameters, and when Claude needs to use one, it calls your server with the appropriate arguments. Your server does the work and returns the result.

How MCP Servers Communicate Link to heading

MCP servers communicate through a simple but elegant mechanism: stdio (standard input/output). When Claude launches your MCP server as a subprocess, it sends requests to your server’s stdin and reads responses from your server’s stdout. This approach is beautifully simple—no ports to configure, no network setup, just plain text streams.

The messages themselves use JSON-RPC 2.0, a lightweight remote procedure call protocol. Each message is a single line of JSON containing:

  • jsonrpc: Always "2.0"
  • id: A unique identifier for request/response matching
  • method: The method being called (e.g., initialize, tools/list, tools/call)
  • params: The parameters for the method

Your server reads these JSON lines from stdin, processes them, and writes JSON-RPC responses back to stdout. Here’s what a typical exchange looks like:

→ stdin:  {"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
← stdout: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",...}}

→ stdin:  {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
← stdout: {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"fetch",...}]}}

→ stdin:  {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"fetch","arguments":{...}}}
← stdout: {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"..."}]}}

A Minimal JSON-RPC Implementation Link to heading

To make implementing MCP servers easier, I wrote a small utility class that handles all the JSON-RPC plumbing. You can find it at mcp.ts.

The core of the implementation is surprisingly simple:

async start(): Promise<void> {
  const decoder = new TextDecoder()
  let buffer = ""

  for await (const chunk of Bun.stdin.stream()) {
    buffer += decoder.decode(chunk, { stream: true })

    let index: number
    while ((index = buffer.indexOf("\n")) !== -1) {
      const line = buffer.slice(0, index).replace(/\r$/, "")
      buffer = buffer.slice(index + 1)

      if (line) {
        await this.handleMessage(JSON.parse(line))
      }
    }
  }
}

private send(msg: object): void {
  console.log(JSON.stringify(msg))
}

This reads from Bun’s stdin stream, buffers the input until it finds a newline, then parses and handles each complete JSON-RPC message. Responses go back out via console.log(), which writes to stdout.

The utility also handles the three core MCP methods:

  • initialize: Returns server info and capabilities
  • tools/list: Returns the list of available tools with their schemas
  • tools/call: Executes a tool and returns the result

With this utility, defining a tool becomes declarative:

new McpServer({ name: "fetch", version: "1.0.0" })
  .tool("fetch", {
    description: "Fetch a URL and extract information",
    schema: z.object({
      url: z.string().describe("The URL to fetch"),
      prompt: z.string().describe("What to extract from the page"),
    }),
    handler: async ({ url, prompt }) => {
      // Your implementation here
      return { content: [{ type: "text", text: result }] }
    },
  })
  .start()

The McpServer class uses Zod for schema validation, which gives you type safety and automatic JSON Schema generation for the MCP protocol.

Our Goal Link to heading

Build an MCP server that:

  1. Accepts a URL and a prompt
  2. Fetches the web content using a standard browser user agent
  3. Converts HTML to Markdown for easier processing
  4. Uses a fast LLM to sort through the data, summarizing the data that were looking for (so we don’t bloat our context window)
  5. Return the summarized content for Claude to analyze

Choosing Bun Link to heading

I decided to use Bun for this project, as I have for the past year any time I’ve needed to write any TypeScript. Bun is a fast JavaScript runtime that’s a drop-in replacement for Node.js. It has built-in TypeScript support, which means no separate compilation step, and it’s fast. More importantly though, it’s a single tool that has sane defaults, and replaces the entire toolchain that JavaScript developers have grown used to. Instead of playing around with webpack, babel, vite, etc, bun just does it all, and it does it really, really fast.

Bun’s extremely fast start times, and low process overhead, makes it an ideal candidate for running MCP servers. I think Anthropic must agree with me to some extent, given that they recently acquired Bun.

The Implementation Link to heading

The core of the server is surprisingly simple. Here’s what it does:

  1. Define the tool - Register a fetch tool that takes a url and prompt parameter
  2. Fetch with a standard user agent - Make the request look like it’s coming from a regular browser
  3. Convert HTML to Markdown - Strip out the noise and keep the content
  4. Return the result - Hand it back to Claude

The key insight is in the user agent. Claude’s built-in WebFetch identifies itself, which triggers blocking on many sites. By using a generic browser user agent, we bypass most of these restrictions.

Installation Link to heading

For Claude Desktop on macOS, add this to your config at ~/Library/Application Support/Claude/claude_desktop_config.json:

{
    "mcpServers": {
        "fetch": {
            "command": "bunx",
            "args": ["github:ScottPierce/mcp-bun-fetch#v1"]
        }
    }
}

For Claude Code, it’s even simpler:

claude mcp add fetch -- bunx github:ScottPierce/mcp-bun-fetch#v1

What’s Next? Link to heading

The server works well for my use cases, but there’s room to improve:

  • Caching - Add a simple cache to avoid re-fetching the same URLs
  • Better error handling - More informative errors when fetches fail
  • Support for authenticated requests - Some use cases might benefit from passing headers or cookies

The full source code is available at github.com/ScottPierce/mcp-bun-fetch.