Part
6
  |  
MCP and the Agent Frontier
  |  
Chapter
20

Tools, Resources, and Real-World Integrations

An MCP server with one toy function proves the protocol works. An MCP server that reads files, queries databases, and calls live APIs proves it's worth adopting.
Reading Time
11
mins
BACK TO CLAUDE MASTERCLASS

The trap with MCP tutorials is that they stop at "hello world." You build a tool that adds two numbers, you inspect it, you see the schema appear, and you feel accomplished. Then you try to connect a real API — one with authentication, rate limits, error codes, and response payloads that change depending on which tier of the service you're paying for — and everything the tutorial taught you falls apart.

The problem isn't the protocol. The problem is that most people learn MCP in a vacuum, disconnected from the messy reality of production data sources. A tool that adds integers and a tool that fetches live weather data from a third-party API are architecturally identical inside MCP. The protocol doesn't care. But the engineering effort between them is enormous, and that gap is where real competence lives.

A tool that adds integers and a tool that fetches live weather data are architecturally identical inside MCP. But the engineering effort between them is where real competence lives.

Building Tools That Hit the Real World

A production MCP tool does three things a toy tool doesn't: it handles failure, it shapes its output for machine consumption, and it provides enough context for the model to decide whether the result is useful.

Here's a weather tool that wraps a public API. This is a pattern you'll repeat for any external service — the specifics change, but the structure stays identical.

from mcp.server.fastmcp import FastMCP
import urllib.request
import json

mcp = FastMCP("WeatherServer")

@mcp.tool()
def get_weather(city: str) -> dict:
    """Fetch current weather for a city from wttr.in."""
    try:
        url = f"https://wttr.in/{city}?format=j1"
        req = urllib.request.Request(url)
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read().decode())
        
        current = data["current_condition"][0]
        return {
            "city": city,
            "temperature_c": current["temp_C"],
            "feels_like_c": current["FeelsLikeC"],
            "description": current["weatherDesc"][0]["value"],
            "humidity": current["humidity"],
            "status": "success"
        }
    except Exception as e:
        return {
            "city": city,
            "status": "error",
            "error": str(e)
        }

@mcp.tool()
def get_weather_brief(city: str) -> str:
    """Quick one-line weather summary for a city."""
    try:
        url = f"https://wttr.in/{city}?format=j1"
        req = urllib.request.Request(url)
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read().decode())
        
        current = data["current_condition"][0]
        desc = current["weatherDesc"][0]["value"]
        temp = current["temp_C"]
        return f"{city}: {temp}°C, {desc}"
    except Exception as e:
        return f"{city}: unavailable ({e})"

if __name__ == "__main__":
    mcp.run()

Notice the two versions of the same capability. The full tool returns structured JSON with every field the model might need for detailed reasoning. The brief tool returns a single formatted string for quick lookups. This is a real pattern — not redundancy. A model deciding whether to suggest outdoor activities needs the humidity and feels-like temperature. A model summarizing a user's day just needs "22°C, partly cloudy."

Framework · The Two-Depth Pattern · TDP

For every external data source, build two tools: a detailed version that returns structured data for complex reasoning, and a brief version that returns a summary string for quick context. The model picks the right depth based on the task.

The error handling pattern is equally important. Both tools wrap the HTTP call in a try/except block and return a structured error response instead of raising an exception. This matters because MCP tools that crash don't just fail — they break the model's reasoning chain. A tool that returns {"status": "error", "error": "timeout after 10s"} gives the model enough information to retry, try a different tool, or tell the user what happened. A tool that throws an unhandled exception gives the model nothing.

Resources: Your Server's Read-Only Data Layer

If tools are actions, resources are the knowledge your server exposes for passive consumption. The model doesn't invoke a resource the way it invokes a tool — it reads a resource the way you'd read a file listing or a configuration dump.

Here's a resource provider that exposes a document index and individual documents by ID:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("DocumentServer")

@mcp.resource("docs://list")
def list_documents() -> list[dict]:
    """Return an index of all available documents."""
    return [
        {"id": "req-001", "title": "Project Requirements"},
        {"id": "arch-002", "title": "Architecture Decision Record"},
        {"id": "api-003", "title": "API Design Specification"},
    ]

@mcp.resource("docs://{doc_id}")
def get_document(doc_id: str) -> dict:
    """Retrieve a specific document by ID."""
    documents = {
        "req-001": {
            "title": "Project Requirements",
            "content": "The system shall support...",
            "last_updated": "2025-01-15"
        },
        "arch-002": {
            "title": "Architecture Decision Record",
            "content": "We chose event-driven...",
            "last_updated": "2025-02-20"
        },
    }
    if doc_id in documents:
        return documents[doc_id]
    return {"error": f"Document {doc_id} not found"}

if __name__ == "__main__":
    mcp.run()

The parameterized URI pattern docs://{doc_id} is doing real work here. When a client requests docs://req-001, MCP extracts req-001 and passes it as the doc_id argument. The model can first read the index resource to discover what's available, then request specific documents by ID. This browse-then-read pattern mirrors how humans use file systems — and it's exactly how models should interact with knowledge bases.

Resources are not tools

A common mistake is implementing data retrieval as a tool instead of a resource. If the operation is read-only, has no side effects, and returns data that helps the model reason — make it a resource. Tools should be reserved for operations that change state or interact with the outside world. Mixing the two confuses the model about what's safe to call exploratively.

The Multi-Source Server

The real power of MCP shows up when a single server unifies access to multiple data sources that would otherwise require separate integrations. A file system, a database, and an external API all look the same to the client — structured data behind standard URIs.

from mcp.server.fastmcp import FastMCP
import json
import os
import urllib.request

mcp = FastMCP("MultiSourceServer")

# --- Source 1: Local files ---
NOTES_FILE = "notes.json"

@mcp.resource("files://notes")
def get_file_notes() -> list[dict]:
    """Read notes from local JSON file."""
    if not os.path.exists(NOTES_FILE):
        default = [{"id": 1, "text": "Sample note"}]
        with open(NOTES_FILE, "w") as f:
            json.dump(default, f)
    
    with open(NOTES_FILE) as f:
        return json.load(f)

# --- Source 2: In-memory database ---
USER_DB = [
    {"user_id": 1, "name": "Alice", "role": "engineer"},
    {"user_id": 2, "name": "Bob", "role": "designer"},
    {"user_id": 3, "name": "Carol", "role": "manager"},
]

@mcp.resource("db://users")
def get_users() -> list[dict]:
    """Query user records from the database."""
    return USER_DB

# --- Source 3: External API ---
@mcp.resource("api://external-status")
def get_api_status() -> dict:
    """Fetch live status from an external service."""
    try:
        url = "https://httpbin.org/get"
        req = urllib.request.Request(url)
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read().decode())
        return {"status": "available", "origin": data.get("origin")}
    except Exception as e:
        return {"status": "unavailable", "error": str(e)}

# --- Discovery resource ---
@mcp.resource("server://info")
def server_info() -> dict:
    """Describe available data sources."""
    return {
        "name": "MultiSourceServer",
        "sources": [
            "files://notes - Local file storage",
            "db://users - User database",
            "api://external-status - External service health",
        ]
    }

if __name__ == "__main__":
    mcp.run()

From the model's perspective, files://notes, db://users, and api://external-status are all the same kind of thing: structured data behind a URI. It doesn't matter that one reads a JSON file, another queries an in-memory list, and the third makes an HTTP request. MCP abstracts the source completely. This is the practical payoff of the protocol — a single server can federate access across your entire data landscape without the model knowing or caring about the underlying plumbing.

Key takeaway

The value of MCP isn't in any single integration. It's in the fact that files, databases, and APIs all present the same interface to the model. Your fiftieth data source looks exactly like your first — same URI pattern, same structured response, same error handling. That uniformity is what makes AI systems maintainable at scale.

Designing Schemas That Agents Can Trust

I've seen this pattern more times than I'd like: a developer builds an MCP tool, tests it with clean inputs, ships it, and watches it fail in production because the model sent an edge-case input the developer never considered. A city name with Unicode characters. A priority value of negative three. An empty string where a document ID was expected.

The defense is strict input-output schemas, and MCP gives you the infrastructure to enforce them at the protocol level. Type annotations on your function parameters generate input schemas automatically. Pydantic models on your return values enforce output schemas explicitly.

The combination of these two gives you a contract that both sides of the protocol can trust:

  • The client knows what types and shapes the server expects.
  • The server knows what types and shapes it will return.
  • Invalid inputs are rejected before execution.
  • Invalid outputs are caught before they reach the model.
✕ Loose schemas
  • Model sends unexpected types
  • Tool returns inconsistent shapes
  • Errors surface as hallucinations
  • Debugging requires reading model context
✓ Strict schemas
  • Invalid inputs rejected at the boundary
  • Every response has a predictable structure
  • Errors are explicit and traceable
  • Debugging starts at the validation layer
The model reads your docstrings

FastMCP uses your function's docstring as the tool's description in the capability manifest. The model sees this description when deciding whether to call your tool. Write docstrings for the model, not for developers — be specific about what the tool does, what inputs it expects, and what the output represents.

The Inspector: Your Development Feedback Loop

The MCP inspector is the most underused tool in the ecosystem. Most developers write a server, connect it to Claude, and debug by reading conversation logs. That's backward. The inspector lets you verify your server's capability surface before any model touches it.

# Terminal 1: Start the server
python server.py

# Terminal 2: Inspect capabilities
fastmcp inspect server.py

# Or use dev mode for a full UI
fastmcp dev server.py

The inspector shows you exactly what a client would see: the server name, the MCP version, every tool with its input schema, every resource with its URI pattern. If a tool doesn't appear in the inspector, no model will ever find it. If a schema looks wrong in the inspector, it will be wrong when the model tries to use it.

Run the inspector after every change. It's the equivalent of running your test suite — except it validates the contract your server presents to the entire AI ecosystem, not just to your unit tests.

If a tool doesn't appear in the inspector, no model will ever find it. If a schema looks wrong in the inspector, it will be wrong when the model tries to use it.

What to Do Monday Morning

Build an MCP server that wraps a real API

Pick an API you already use — weather, GitHub, a monitoring service. Build an MCP tool that calls it, handles errors with structured responses, and returns typed output. Use both a detailed and a brief version (the Two-Depth Pattern).

Add resources that expose your project's knowledge

Create at least two resources: an index resource that lists available data, and a parameterized resource that retrieves specific items by ID. Test the browse-then-read flow in the inspector.

Build a multi-source server

Combine file access, a data store, and an external API into a single MCP server. Add a discovery resource at server://info that lists all available sources. Verify in the inspector that all sources appear as standard MCP resources.

Write docstrings for the model, not for humans

Review every tool and resource in your server. Rewrite the docstrings to describe what the tool does, what inputs it expects, and what the output represents — in the kind of plain, specific language a model can use to decide whether this tool is the right one for a given task.

Run the inspector after every change

Make it a habit. Every time you add a tool, modify a schema, or change a resource URI, run fastmcp inspect before connecting to a model. Catch schema mismatches at the boundary, not three reasoning steps downstream.

The gap between a demo MCP server and a production MCP server is the same gap between a function that works and a function that fails gracefully, returns typed data, and can be trusted by a system that has no way to read your source code.