TechByteByByte

Tools in MCP: How Servers Expose Callable Capabilities

Six progressive, real tools — from a calculator to a database-backed support ticket creator — plus discovery, argument validation, and the honest range of ways a real tool call can fail.

#MCP#Tools#AI Agents#Tool Calling

Recall Module 2’s own real distinction — Tools are the first, and most familiar, of the three real capability types a Server exposes. An MCP tool is a callable capability exposed by a Server — recall your own tool-calling coursework directly: the same real idea as a LangChain @tool or a LlamaIndex FunctionTool, just exposed through MCP’s own, standardized interface instead of being wired directly into one specific framework.

Tool 1: the simplest real tool — a calculator

Let’s build one directly, using FastMCP — the current, real, high-level Python framework almost universally used for building MCP servers.

We’ll define one real, minimal tool and register it on a genuine, running MCP server.

from fastmcp import FastMCP

mcp = FastMCP("calculator-server")  # a real, named MCP server instance

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two whole numbers together."""
    return a + b  # the actual, real computation

Notice from fastmcp import FastMCP — worth being precise about naming here, since it’s a genuine, easy point of confusion. FastMCP is the real, current, high-level framework this module uses throughout; it also ships bundled inside the official mcp package as mcp.server.fastmcp, but the standalone fastmcp package is the one you’ll see in the overwhelming majority of current, real documentation and examples — worth using directly rather than the bundled version.

Notice @mcp.tool() genuinely pulls the tool’s name, its description, and its real input schema directly from the function itself — its real name, its real docstring, its real type hints. This is precisely the same discipline your LangChain course taught about @tool — MCP didn’t invent a new philosophy here, it standardized an already-familiar one.

Tool 2: a weather lookup, with a real, external call

Let’s build something genuinely closer to real, production use — a tool that reaches an actual, external service.

We’ll wrap a real API call, the same way you’d write any tool in your prior coursework.

import httpx

@mcp.tool()
async def get_weather(city: str) -> str:
    """Get the current weather for a given city."""
    async with httpx.AsyncClient() as client:
        response = await client.get(f"https://api.weather.example/v1/current?city={city}")  # a real, external call
        data = response.json()
        return f"It's {data['temp']}°C and {data['condition']} in {city}."

Notice this tool is genuinely async — real MCP servers are commonly built this way specifically because real tools frequently make real, network-bound calls, and blocking the whole server on one slow request would genuinely hurt every other, concurrent client.

Tool 3: a real customer lookup

Let’s build a genuinely realistic, enterprise-style tool — the kind that would sit behind a real support assistant.

We’ll query a small, real, in-memory customer store, structured the way a genuine business tool actually would be.

CUSTOMERS = {"C123": {"name": "Amara Okafor", "plan": "enterprise", "since": "2022"}}

@mcp.tool()
def get_customer(customer_id: str) -> dict:
    """Look up a customer's account details by their customer ID."""
    customer = CUSTOMERS.get(customer_id)
    if not customer:
        return {"error": f"No customer found with ID {customer_id}"}  # a real, honest failure, not a crash
    return customer

Notice this returns a real, structured dict, not just a string — recall your structured output coursework’s own real value here: a real, calling agent can genuinely read customer["plan"] directly, rather than needing to parse free text.

Tool 4: a real, parameterized database query

Let’s build something with genuinely more real, structured input than the tools so far.

We’ll expose a deliberately narrow, safe query capability — not arbitrary SQL, a real, important distinction covered fully once this course reaches security.

@mcp.tool()
def get_customer_orders(customer_id: str, include_cancelled: bool = False) -> list[dict]:
    """Look up a customer's real orders, optionally including cancelled ones."""
    orders = [
        {"order_id": "O1", "status": "shipped"},
        {"order_id": "O2", "status": "cancelled"},
    ]
    if not include_cancelled:
        orders = [o for o in orders if o["status"] != "cancelled"]  # a real, deliberate filter
    return orders

Notice include_cancelled: bool = False — a real, genuine default value, meaning a calling agent doesn’t have to specify every real parameter explicitly, only the ones it actually needs to override.

Tool 5: creating a real, genuine support ticket

Let’s build a tool that performs a real, actual write action, not just a read — worth pausing on, since this is a genuinely different category of real risk.

We’ll build a tool that creates a real, new record, with real validation on what’s actually required.

from pydantic import BaseModel, Field

class TicketInput(BaseModel):
    customer_id: str
    subject: str = Field(min_length=5, description="A short, real summary of the issue.")
    priority: str = Field(default="normal", pattern="^(low|normal|high)$")

@mcp.tool()
def create_support_ticket(ticket: TicketInput) -> dict:
    """Create a real support ticket for a customer."""
    new_ticket = {"ticket_id": "T789", "customer_id": ticket.customer_id, "subject": ticket.subject, "priority": ticket.priority}
    return new_ticket  # in a real deployment, this would genuinely write to a database

Notice Field(min_length=5, ...) and pattern="^(low|normal|high)$" — real, genuine, structural validation, enforced before your actual function body ever runs. This is worth remembering directly once Module 14 covers why real, write-capable tools deserve this much more real scrutiny than read-only ones.

Tool 6: searching real documentation

Let’s build one more, genuinely different capability — a real search over unstructured, real content.

We’ll expose a small, real, keyword-based search, the kind of tool a genuine documentation assistant would actually use.

DOCS = {"install.md": "To install, run pip install our-package.", "auth.md": "Authentication uses API keys."}

@mcp.tool()
def search_documentation(query: str) -> list[dict]:
    """Search internal documentation for content matching the query."""
    results = [{"file": name, "snippet": text} for name, text in DOCS.items() if query.lower() in text.lower()]
    return results

Tool discovery — how a real client actually finds these

Recall Module 4’s own real lifecycle — after a connection is negotiated, a Client genuinely asks the Server what tools actually exist. Here’s the real, complete flow, from discovery to an actual decision.

flowchart LR
    A[Client sends tools/list] --> B[Server reports every real tool]
    B --> C[LLM / agent reads names + descriptions]
    C --> D{Decides whether\nand which to call}
    D --> E[Client sends tools/call]
    E --> F[Server actually executes]

Let’s see the real, structured request behind the first step of this diagram.

We’ll send the real, structured request a Client uses to list every tool a Server exposes.

# the real, actual shape of a tools/list request
list_tools_request = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}

# and the real, actual server response — every tool this specific server genuinely exposes
list_tools_response = {
    "jsonrpc": "2.0",
    "id": 2,
    "result": {
        "tools": [
            {"name": "add", "description": "Add two whole numbers together.", "inputSchema": {"type": "object", "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}}},
            {"name": "get_customer", "description": "Look up a customer's account details by their customer ID.", "inputSchema": {"type": "object", "properties": {"customer_id": {"type": "string"}}}},
        ]
    },
}

This is precisely the moment a real, calling agent — recall your own agent orchestration coursework — actually decides which tool to use. MCP exposes tools; the LLM or agent decides whether and how to use them. The Server never makes that decision itself.

Tool arguments — real, structured, and validated

Recall Tool 5’s own TicketInput — real schema validation is what stands between a genuine, malformed request and your actual function ever running.

Let’s watch a genuinely invalid call fail correctly, before it ever reaches your real business logic.

try:
    TicketInput(customer_id="C123", subject="hi")  # too short — genuinely fails validation
except Exception as e:
    print(f"Invalid arguments: {e}")  # a real, structured rejection, not a crash inside your logic

Tool errors — the honest, real range

Recall real production concerns from your prior coursework — a real tool genuinely fails in more than one way, and each deserves different, honest handling.

@mcp.tool()
async def get_order_status(order_id: str) -> dict:
    """Look up an order's real, current status."""
    try:
        async with httpx.AsyncClient(timeout=5.0) as client:
            response = await client.get(f"https://api.orders.example/{order_id}")
            response.raise_for_status()  # genuinely raises on a real downstream failure
            return response.json()
    except httpx.TimeoutException:
        return {"error": "The order service timed out. Please try again."}
    except httpx.HTTPStatusError as e:
        return {"error": f"Order service returned an error: {e.response.status_code}"}

Recall your LangChain course’s own real discipline here — a tool should almost always return a genuine, readable error, rather than letting an unhandled exception crash the whole real interaction.

Why schema quality genuinely matters this much

Recall your LangChain course’s own tool-description discipline directly — the same real principle applies here, at real, protocol scale. A real, calling agent — potentially built by a completely different team, in a completely different company — has only your tool’s name, description, and schema to reason from. A vague search(q: str) genuinely tells an agent almost nothing; search_documentation(query: str), with a real, clear docstring, gives it everything it needs to call correctly, the first time.

Common mistakes worth avoiding

Writing a tool with a vague name and no real docstring. Recall this module’s own real examples — every one of them had a genuine, specific name and a clear, real description; an agent calling your tool has nothing else to reason from.

Letting a real exception propagate unhandled out of a tool. Recall Tool 6’s own real try/except — a crashed tool call can break an entire real agent interaction; a returned, honest error string almost always serves the caller better.

Exposing write actions with the same casual schema discipline as read actions. Recall Tool 5’s own real, deliberate validation — a tool that actually creates or changes something deserves genuinely more scrutiny than one that only reads, a theme this course returns to fully in Module 14.

What you should take away from this module

  • An MCP tool is a callable capability, defined the same way you already know from LangChain and LlamaIndex — a real function, a real name, a real description, a real schema.
  • tools/list is how a real Client discovers what a Server exposes; the actual decision to call one belongs to the LLM or agent, never the Server itself.
  • Real, structured input validation — via Pydantic, in the current Python SDK — genuinely rejects malformed calls before your business logic ever runs.
  • A tool should handle its own likely, real failures — timeouts, downstream errors — and return an honest, readable message rather than crashing.

Where this goes next

The next module covers Resources — the second real capability type, for exposing genuine context and data rather than performing an action, with a precise, memorable distinction from everything this module just built.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed