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:
- Initialize: client connects, server responds with its capabilities and protocol version
- tools/list: client asks what tools exist; server returns a JSON schema for each
- tools/call: client sends a tool name + arguments; server executes and returns a result
- 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:
- search what exists
- fetch specific objects
- return small snippets or metadata
- never leak secrets or wander into buckets they shouldn't
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:
- consistent logs (with request IDs)
- auth and authorization checks
- bounded outputs and timeouts
- clear error codes
- tests for failure paths, not just happy paths
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