9Model Context Protocol

Defining tools with MCP

6 min read1,192 words

Back in Module 6 you wrote JSON schemas by hand (or generated them with Claude). That was fine for three tools. For a real MCP server exposing a dozen or more tools, writing JSON schemas by hand is tedious, error-prone, and violates every DRY principle you've ever cared about. FastMCP — the official Python SDK for MCP — makes tool definition look like normal Python functions with type hints and docstrings. The SDK generates the JSON schemas for you automatically.

Setting up a FastMCP server

Initialising a server is one line:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("DocumentMCP", log_level="ERROR")

That's the whole boilerplate. FastMCP("DocumentMCP") creates a named server; the log_level parameter quiets the SDK's own log output so your CLI stays clean.

For this project, documents live in a plain Python dict keyed by document ID:

docs = {
    "deposition.md": "This deposition covers the testimony of Angela Smith, P.E.",
    "report.pdf": "The report details the state of a 20m condenser tower.",
    "financials.docx": "These financials outline the project's budget and expenditure.",
    "outlook.pdf": "This document presents the projected future performance of the project.",
    "plan.md": "The plan outlines the steps for the project's implementation.",
    "spec.txt": "These specifications define the technical requirements for the equipment.",
}

Six fake documents, each with an obvious content description. In a real implementation this dict would be a database query or a filesystem read — for the course, a dict keeps the focus on MCP.

Tool definition with @mcp.tool()

The @mcp.tool() decorator turns a plain Python function into an MCP tool. The SDK inspects the function's type hints, its docstring, its default values, and its Pydantic Field() annotations to automatically generate the JSON schema Claude needs.

from pydantic import Field

@mcp.tool(
    name="read_doc_contents",
    description="Read the contents of a document and return it as a string.",
)
def read_document(
    doc_id: str = Field(description="Id of the document to read"),
):
    if doc_id not in docs:
        raise ValueError(f"Doc with id {doc_id} not found")

    return docs[doc_id]

Three things this is doing:

  • @mcp.tool(name=..., description=...) registers the function with the MCP server. The name is what Claude will use to call it; the description is what Claude reads to decide when to call it. Same rules as Module 6 — a good description is three or four sentences covering what, when, and what-it-returns.
  • Type hints drive schema generation. doc_id: str tells the SDK that this parameter is a string; that becomes {"type": "string"} in the generated schema.
  • Field(description="...") adds parameter-level descriptions. Pydantic's Field is the mechanism the MCP SDK reads for per-argument documentation — exactly the kind of detail you'd otherwise be writing by hand in JSON.

When Claude calls read_doc_contents("deposition.md"), the MCP server receives the call, invokes your function with doc_id="deposition.md", and returns the dict lookup. If the doc doesn't exist, the function raises a ValueError with a clear message — which surfaces to Claude as a tool error and gives the model a chance to retry with a valid ID.

A second tool: edit_document

A read-only server isn't that interesting. Add a tool to modify documents via a find-and-replace operation:

@mcp.tool(
    name="edit_document",
    description="Edit a document by replacing a string in the document's "
                "content with a new string.",
)
def edit_document(
    doc_id: str = Field(description="Id of the document that will be edited"),
    old_str: str = Field(
        description="The text to replace. Must match exactly, "
                    "including whitespace."
    ),
    new_str: str = Field(
        description="The new text to insert in place of the old text."
    ),
):
    if doc_id not in docs:
        raise ValueError(f"Doc with id {doc_id} not found")

    docs[doc_id] = docs[doc_id].replace(old_str, new_str)

Three parameters now — doc ID, old string, new string — each with a Field description. Notice the description for old_str explicitly says "must match exactly, including whitespace." That is a message to Claude. The model will read that, understand that fuzzy matches won't work, and either use the exact substring or skip the call. Good parameter descriptions are load-bearing: they're how you tell Claude the constraints your function cares about.

The implementation is a one-liner — Python's str.replace() — because the point of the lesson is the MCP wiring, not the implementation. In a real system you'd want more robust matching, change history, and error handling.

Error handling that Claude can read

Both tools raise ValueError with descriptive messages when the doc ID isn't found. This is the same discipline from Module 6: raise clear errors, not silent failures. The MCP SDK wraps the exception into a tool error result, which flows back to the client, into Claude's context, and gives the model a shot at recovering.

What Claude sees on a bad doc_id:

Error: Doc with id unknown.md not found

That's informative enough that Claude can respond with "I don't see a document called unknown.md. Here are the documents I can access: ..." instead of confidently hallucinating content.

What the SDK does for you

Compared to writing MCP tools from scratch (or to writing Module 6 tools by hand):

| Task | Module 6 (manual) | FastMCP (decorated) | |---|---|---| | Function implementation | Write it | Write it | | JSON Schema authoring | Write it by hand | Generated from type hints | | Parameter descriptions | Write in JSON | Field(description=...) | | Tool registration | Add to tools=[...] list | @mcp.tool() | | Dispatch logic | Write run_tool router | Handled by SDK | | Type validation | Manual in your handler | Pydantic-backed |

You still write the implementation. Nothing replaces the business logic. What the SDK removes is every other piece of ceremony — schemas, dispatch, parameter descriptions — so you can focus on the function body.

Benefits recap

The FastMCP approach gives you:

  • Automatic JSON schema generation from Python type hints
  • Clean, readable code that's easier to review than JSON blobs
  • Pydantic-backed parameter validation for free
  • Less boilerplate than writing schemas by hand
  • IDE support — autocomplete, type checking, refactoring — because tools are just functions

The next lesson covers the MCP server inspector — a debugging tool that lets you launch your server, list its tools, and invoke them interactively without building the client yet. That's how you'll verify these two tools actually work before wiring them into the chatbot.

Key Takeaways

  • 1FastMCP is the official Python SDK for building MCP servers — it turns tool definition into decorated Python functions with type hints instead of hand-written JSON schemas.
  • 2Initialise a server with FastMCP('ServerName', log_level='ERROR') and register tools with @mcp.tool(name=..., description=...) decorators.
  • 3Python type hints drive automatic JSON schema generation, and pydantic Field(description=...) adds per-parameter documentation that Claude reads during tool selection.
  • 4Tool descriptions and parameter descriptions are load-bearing: they are the text Claude uses to decide whether to call the tool and how to format its arguments.
  • 5Raise ValueError with clear messages on invalid input — the SDK surfaces the error to Claude, which can recover by retrying with corrected arguments.
  • 6FastMCP replaces manual schema authoring, dispatch routers, and parameter plumbing so you focus on writing the function body, not the protocol ceremony.