v1.10.90-0e025b8
Skip to main content
AI/MLMCPGuide

Building MCP (Model Context Protocol) Data Servers

12 min read

By Hex Proxies Engineering Team

Building MCP (Model Context Protocol) Data Servers

Anthropic's Model Context Protocol (MCP) is the first widely adopted standard for exposing tools and data to LLMs. A client (Claude Desktop, an agent framework, an IDE) speaks MCP to one or more servers, each of which exposes a set of tools, resources, and prompts. The server is where you decide what data the model can read, what actions it can take, and under what constraints.

This post covers the MCP spec in practical terms, how to build a server that exposes web-sourced data, and the security and proxy integration patterns that make it production-ready.

What MCP Actually Is

MCP is a JSON-RPC 2.0 protocol over stdio, SSE, or streamable HTTP. A client connects to a server and exchanges messages to:

  • List capabilities: what tools, resources, and prompts the server offers
  • Call tools: execute an action, receive a structured result
  • Read resources: retrieve content addressable by URI
  • Subscribe: receive notifications when resources change

The spec is maintained at modelcontextprotocol.io. Reference SDKs exist in TypeScript and Python; community ones for Go, Rust, and Java.

The Three Primitives

Tools

Tools are functions the model can call with typed arguments. A tool definition includes a name, description, and JSON Schema for inputs. The model decides when to call; the server executes and returns a result.

Resources

Resources are addressable content with URIs. Unlike tools, resources are declarative -- the client (and the user) chooses what to include in context, not the model. Good for large corpora that should not be spammed into every prompt.

Prompts

Prompts are reusable templates the client can surface to users. Less commonly used in data servers.

A Minimal Python Server

from mcp.server import Server
from mcp.server.stdio import stdio_server
import httpx

app = Server("web-fetch")

PROXY = "http://user:pass@gate.hexproxies.com:7777"

@app.list_tools()
async def list_tools():
    return [{
        "name": "fetch_url",
        "description": "Fetch a URL and return cleaned text.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "format": "uri"},
                "region": {"type": "string", "enum": ["us", "uk", "de", "jp"]}
            },
            "required": ["url"]
        }
    }]

@app.call_tool()
async def call_tool(name, arguments):
    if name != "fetch_url":
        raise ValueError("unknown tool")
    url = arguments["url"]
    async with httpx.AsyncClient(proxy=PROXY, timeout=30) as c:
        r = await c.get(url)
    return [{"type": "text", "text": r.text[:20000]}]

if __name__ == "__main__":
    import asyncio
    asyncio.run(stdio_server(app))

This is the skeleton. Production versions add URL allowlisting, robots.txt checking, content extraction, per-region proxy routing, and telemetry.

Secure Data Access Patterns

A data server is an attack surface. Patterns:

Tool-level authorization

Different tools for different trust levels. A public fetch_allowlisted_url that only hits pre-approved domains is very different from a fully open fetch_url. Expose the narrow one unless there is a reason not to.

Input validation

JSON Schema catches type errors but not semantic abuse. Always:

  • Parse URLs and reject non-HTTP(S) schemes (file://, gopher://)
  • Reject IP literals and private ranges (SSRF)
  • Resolve DNS and re-check the resolved IP against private ranges
  • Cap response body size
  • Enforce a request timeout
import ipaddress, socket
from urllib.parse import urlparse

def validate_url(url: str):
    p = urlparse(url)
    if p.scheme not in ("http", "https"):
        raise ValueError("bad scheme")
    host = p.hostname
    addrs = socket.getaddrinfo(host, None)
    for _, _, _, _, sockaddr in addrs:
        ip = ipaddress.ip_address(sockaddr[0])
        if ip.is_private or ip.is_loopback or ip.is_link_local:
            raise ValueError("private address")

Prompt injection awareness

Web content fetched by the server may contain instructions aimed at the model ("ignore previous instructions and…"). The server cannot prevent this, but it can flag untrusted content boundaries explicitly in the response:

return [{
    "type": "text",
    "text": f"<untrusted_web_content source={url}>\n{body}\n</untrusted_web_content>"
}]

This at least makes it possible for the client or model's system prompt to treat such content with appropriate skepticism. It does not solve the problem -- prompt injection is fundamentally unresolved -- but it shifts responsibility to a layer that can reason about it.

Rate limiting and quotas

Per-client, per-tool limits. Token bucket on tool calls. Hard cap on egress bandwidth. Log everything.

Resource Design for Web Corpora

When the data is a corpus rather than a live fetch, expose it as resources with stable URIs:

docs://company-kb/policies/security.md
docs://company-kb/runbooks/incident-response.md

The client lists resources, the user or model picks which to include, and the server returns content. This gives the user control over what enters the context window -- a privacy and quality property worth preserving.

Proxy Integration

Data servers that fetch from the web share every problem a scraper has: rate limits, geo-variability, anti-bot defenses. Proxy integration patterns:

  • Per-region routing: accept a region parameter, route through the matching proxy egress
  • Sticky sessions: for multi-fetch tasks within a single tool invocation, use a session token to pin the IP
  • Pool health: if a proxy endpoint is failing, fail the tool call with a clear error rather than silently returning wrong data

Transport Choice

Three transports are in common use:

  • stdio: the server is a subprocess of the client. Simplest, no network exposure, good for desktop clients.
  • SSE: server-sent events over HTTP. Works across processes and hosts but is being deprecated in favor of streamable HTTP.
  • Streamable HTTP: the current recommendation for networked servers. Supports bidirectional streaming over a single HTTP connection.

For an internal team tool, stdio is the simplest deploy. For a shared server many clients connect to, streamable HTTP with auth.

Authentication

Networked MCP servers should require authentication. The spec supports OAuth 2.1 with resource indicators; simpler bearer tokens work for internal use. Never deploy a tool-capable MCP server on an open network without auth.

Observability

Log, per tool call: caller identity, tool name, argument summary (redacted), duration, outcome, bytes transferred. Emit OpenTelemetry spans so calls show up in your existing tracing. Alert on unusual patterns: spikes in calls to one tool, repeated failures, SSRF attempts.

Testing

Treat the server like any other API:

  • Unit tests for tool handlers
  • Integration tests that spawn the server and connect a test client
  • Fuzz tests for URL validation
  • Load tests for rate-limit behavior

The MCP SDKs ship with client implementations that make integration tests straightforward.

Publishing

Community servers are listed in the MCP registry. If you publish one, include a dataset card equivalent: what tools exist, what data they reach, what auth is required, what rate limits apply, and what security assumptions the server makes.

Closing

MCP is the right layer to put web-data access behind: the model sees a constrained, auditable interface; the server owns rate limits, proxies, and validation; the client decides what to include in context. Build servers narrowly, validate inputs carefully, assume everything the web returns is untrusted, and instrument the whole thing. Done right, an MCP data server becomes a reusable capability that many agents can share without each reinventing the collection layer.