sethserver.com Subscribe
An orange and blue cartoon robot with large eyes and a striped grin stands centered on a mint green background, holding books in its striped orange arms while six more colorful open books (in pink, blue, yellow, and green) float around it. Small geometric shapes (triangles and circles in orange, pink, blue, and yellow) are scattered throughout the composition.

Building Your First MCP Server

By Seth Black • Updated: September 17, 2026

Programming · 8 min read

Like this kind of writing? Get one email a week with notes on startups, AI, and the occasional strong opinion about Python: subscribe to the newsletter.

You read the MCP vs Tools guide. Good. In that post, we covered when to build an MCP vs a tool. Short version: if multiple teams need it, make it an MCP. Now let's actually build one.

If every team builds their own "little helper," you don't have a platform. You have utility sprawl. And we all know how fun it is to untangle hundreds of microservices.

This is where an MCP server earns its keep. It's a system boundary, so your agents can do useful work without turning your data layer into a panic factory.

What MCP actually is (the non-hand-wavy version)

MCP (Model Context Protocol) is a JSON-RPC 2.0-based protocol. No magic. Your MCP server exposes a set of named tools, and a compliant client (Claude, a custom agent loop, whatever) can discover and call those tools at runtime.

The lifecycle looks like this:

  1. Initialize: client connects, server responds with its capabilities and protocol version
  2. tools/list: client asks what tools exist; server returns a JSON schema for each
  3. tools/call: client sends a tool name + arguments; server executes and returns a result
  4. Shutdown: clean teardown

That's the whole protocol surface you care about for 95% of use cases. The key insight is that tools/list is how your agent discovers what it can do at runtime: no hardcoded function signatures, no redeployment when you add a tool. The schema you return there is also what the LLM uses to decide how to call the tool, so write your descriptions like they're docstrings for a junior engineer who is smart but knows nothing about your system.

Here's what a minimal but real MCP server looks like using the official Python SDK:

# server.py
from mcp.server.fastmcp import FastMCP
import boto3
from botocore.exceptions import ClientError
import logging
import uuid

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

mcp = FastMCP("s3-knowledge-server")
s3 = boto3.client("s3")

ALLOWED_BUCKETS = {"company-knowledge-prod"}
MAX_BYTES = 8_000

@mcp.tool()
def s3_list_objects(bucket: str, prefix: str = "") -> dict:
    """
    List objects in an allowed S3 bucket under a given prefix.
    Returns keys and sizes. Use this before fetching to confirm an object exists.
    """
    request_id = str(uuid.uuid4())[:8]
    logger.info(f"[{request_id}] list bucket={bucket} prefix={prefix}")

    if bucket not in ALLOWED_BUCKETS:
        return {"error": "bucket_not_allowed", "request_id": request_id}

    try:
        resp = s3.list_objects_v2(Bucket=bucket, Prefix=prefix, MaxKeys=50)
        objects = [
            {"key": obj["Key"], "size_bytes": obj["Size"]}
            for obj in resp.get("Contents", [])
        ]
        return {"objects": objects, "count": len(objects), "request_id": request_id}
    except ClientError as e:
        code = e.response["Error"]["Code"]
        logger.error(f"[{request_id}] S3 error: {code}")
        return {"error": code, "request_id": request_id}


@mcp.tool()
def s3_get_text(bucket: str, key: str) -> dict:
    """
    Fetch the text content of an S3 object.
    Returns up to 8000 characters. For binary files, returns metadata only.
    Always call s3_list_objects first if you're not sure the key exists.
    """
    request_id = str(uuid.uuid4())[:8]
    logger.info(f"[{request_id}] fetch bucket={bucket} key={key}")

    if bucket not in ALLOWED_BUCKETS:
        return {"error": "bucket_not_allowed", "request_id": request_id}

    try:
        obj = s3.get_object(Bucket=bucket, Key=key)
        content_type = obj["ContentType"]

        if not content_type.startswith("text/") and "json" not in content_type:
            return {
                "key": key,
                "content_type": content_type,
                "size_bytes": obj["ContentLength"],
                "text": None,
                "note": "Binary file. Metadata returned only.",
                "request_id": request_id,
            }

        body = obj["Body"].read().decode("utf-8", errors="replace")
        truncated = len(body) > MAX_BYTES

        return {
            "key": key,
            "content_type": content_type,
            "text": body[:MAX_BYTES],
            "truncated": truncated,
            "request_id": request_id,
        }

    except ClientError as e:
        code = e.response["Error"]["Code"]
        logger.error(f"[{request_id}] S3 error: {code}")
        return {"error": code, "request_id": request_id}


if __name__ == "__main__":
    mcp.run(transport="stdio")  # swap to "sse" for network deployment

A few things worth calling out in this version vs the sketch above:

Two tools, not one. Giving the agent s3_list_objects separately means it can check what exists before fetching. Without this, agents hallucinate keys and you get a flood of 404s with no good recovery path. Design your tool surface so the agent can self-correct.

request_id on every response. This costs you nothing and saves hours when you're tracing a weird agent behavior through logs. Every tool response should carry a correlation ID.

Content-type gating. Agents will happily try to "read" a 200MB Parquet file if you let them. Check content type and return metadata-only for binary. The agent can decide what to do with that; you're not making the decision for it, just not exploding.

Error returns, not exceptions. MCP tool calls that throw unhandled exceptions return a generic error to the client. Return structured error dicts instead. The agent can reason about {"error": "NoSuchKey"} in a way it cannot reason about a stack trace.

The protocol details that actually matter

Tool schemas are your API contract. When you return schemas from tools/list, the LLM uses the description field to decide when to call the tool and the parameter descriptions to decide how. Treat these like you'd treat an OpenAPI spec. Be explicit about what the tool won't do: "returns up to 8000 characters" is more useful than "returns text."

Transport is a deployment decision, not a design decision. The SDK supports stdio (local, subprocess) and SSE (network). Start with stdio for development and testing. Move to SSE when you need a shared service. The server code doesn't change. You're just swapping the transport layer.

Stateless by default. MCP servers can maintain session state, but don't unless you need to. Stateless tools are easier to scale, easier to test, and easier to reason about when something goes wrong. If you find yourself wanting to store state between calls, ask whether that state belongs in the agent's context instead.

The annotations field exists and you should use it. Each tool schema can carry an annotations object. The spec defines readOnlyHint and destructiveHint as first-class fields. Set readOnlyHint: true on your read tools. It signals intent to the client and lets compliant agents apply different trust levels to read vs. write operations.

@mcp.tool(annotations={"readOnlyHint": True})
def s3_get_text(bucket: str, key: str) -> dict:
    ...

The goal: safe access to S3-backed unstructured data

Say you have unstructured data in S3: PDFs, call transcripts, support tickets, random DOCX files from 2017 that somehow still matter (joke's on me, the oldest document in my Google Drive is from June 11th 2007 lulz). You want agents to:

Once another team asks for access, or you need policy (allowed buckets, prefixes, PII rules), or compliance wants audit logs, a one-off tool stops being sufficient. You're now managing shared capability with rules attached to it. That's MCP territory.

Building an MCP server can sound like overkill when you just want to read some files. But trust me, the first time someone asks for audit logs, or "who accessed this object," you'll be glad you built the boundary instead of another script.

Start with the boring parts

I always start with the boring stuff. Not because I'm boring. Because when something breaks and you're half-asleep and a customer is waiting, you want logs. You want to know what happened. You want to roll back without crying.

The boring stuff is also what lets you move fast without breaking trust:

No cleverness here. On purpose. Clever code is expensive to maintain and basically impossible to debug under pressure.

Test the failure paths

Most MCP tutorials only show the happy path. That's how you end up with an agent stuck in a retry loop because nobody tested what happens when S3 throttles you.

# tests/test_s3_tools.py
from unittest.mock import patch, MagicMock
from botocore.exceptions import ClientError
from server import s3_get_text, s3_list_objects

def make_client_error(code):
    return ClientError({"Error": {"Code": code, "Message": ""}}, "GetObject")

def test_bucket_not_allowed():
    result = s3_get_text(bucket="some-other-bucket", key="file.txt")
    assert result["error"] == "bucket_not_allowed"

def test_key_not_found():
    with patch("server.s3") as mock_s3:
        mock_s3.get_object.side_effect = make_client_error("NoSuchKey")
        result = s3_get_text(bucket="company-knowledge-prod", key="missing.txt")
    assert result["error"] == "NoSuchKey"

def test_output_is_bounded():
    big_body = "x" * 20_000
    with patch("server.s3") as mock_s3:
        mock_s3.get_object.return_value = {
            "Body": MagicMock(read=lambda: big_body.encode()),
            "ContentType": "text/plain",
            "ContentLength": 20_000,
        }
        result = s3_get_text(bucket="company-knowledge-prod", key="big.txt")
    assert len(result["text"]) <= 8_000
    assert result["truncated"] is True

def test_throttling_returns_structured_error():
    with patch("server.s3") as mock_s3:
        mock_s3.get_object.side_effect = make_client_error("SlowDown")
        result = s3_get_text(bucket="company-knowledge-prod", key="file.txt")
    assert "error" in result
    assert "request_id" in result

Write tests like this before you ship. The point isn't coverage percentages. It's that you've thought through what the agent receives when things go wrong, and that it's something the agent can actually act on.

Here's what you need so future-you doesn't hate current-you

Centralize policy: allowed buckets and prefixes, content types you will and won't return, PII rules and what happens when you detect it.

Log access: who asked, what bucket/key, when, and a request ID so you can trace a whole call chain.

Bound outputs: size limits, timeouts, pagination for listing.

Test the failure cases: S3 404s, permission errors, what happens when S3 throttles you or sends back a bad input (missing key, weird encoding).

Annotate your tools: use readOnlyHint and destructiveHint. Write descriptions for the LLM, not for yourself.

Start simple. Add the boring parts. Test the failure cases. When someone on another team asks if they can use it too, you'll know you built the right thing.

For MCP specifics: https://modelcontextprotocol.io/

-Sethers

Share this post
Newsletter

One email, once a week.

Notes on databases, systems, and the occasional strong opinion about Python. No spam, unsubscribe anytime.

Seth Black
Written by

Seth Black

Engineer and founder based in Texas. Writes about databases, AI, and running things in production. Embeds in small teams as lead engineer.

More from Programming

View all →
Programming

What's the Differences Between Relational, NoSQL, Vectors, and Caches

Apr 14, 2026
Programming

Git and Source Control in AI Land

Apr 14, 2026
Programming

Domains, DNS, and why it feels fragile

Apr 14, 2026