# FastMCP: turn a Python function into an MCP tool Claude and Cursor can call

> Source: <https://dev.to/m_t_ramkrushna/fastmcp-turn-a-python-function-into-an-mcp-tool-claude-and-cursor-can-call-nkm>
> Published: 2026-11-12 04:10:35+00:00

Thursday AI Tool/Workflow. This week's tool: **FastMCP**, the Python framework for building Model Context Protocol (MCP) servers and clients. You write a normal Python function, add one decorator, and Claude Code, Cursor, or any other MCP client can discover it and call it as a tool.

If you've tried to give an AI assistant access to your own systems, you know the usual mess: copy-pasting query results into chat, writing a one-off plugin for each client, or hand-writing JSON schemas that drift from the code. MCP fixes the protocol side: one server, many clients. FastMCP fixes the Python side: the schema comes from your type hints.

Here's exactly how to use it, with a real workflow: expose an internal order lookup as a read-only tool, test it, and connect it to a client.

From the official MCP docs: an **MCP host** (an AI app like Claude Code, Claude Desktop or VS Code) creates one **MCP client** per **MCP server** it connects to. Servers expose three core primitives:

Messages are JSON-RPC 2.0, carried over **stdio** (a local process) or **Streamable HTTP** (a remote server).

**FastMCP** is maintained by Prefect (authors Jeremiah Lowin and Nate Nowack), Apache-2.0 licensed. FastMCP 1.0 was incorporated into the official MCP Python SDK in 2024; the standalone `fastmcp` package is the actively maintained project. The current release on PyPI is **4.1.8** (published November 11, 2026 IST), and it requires Python 3.10+.

When you decorate a function with `@mcp.tool`, FastMCP uses the function name as the tool name, the docstring as the description (including per-argument descriptions from an `Args:` section), and the type hints as the input schema. It also validates arguments before your code runs.

```
uv add fastmcp
# or
pip install fastmcp

fastmcp version
```

Say your support team constantly asks "where is order X?" and the answer lives in an internal DB. Here's a server with one narrow, read-only tool:

``` python
# server.py
from typing import Annotated, Literal

from pydantic import Field
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from mcp.types import ToolAnnotations

mcp = FastMCP("Order Lookup", mask_error_details=True)

# Stand-in for your real DB / internal API
ORDERS = {
    "ORD-10421": {"status": "shipped", "carrier": "BlueDart", "eta": "2026-11-14"},
    "ORD-10422": {"status": "processing", "carrier": None, "eta": None},
}

@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
def get_order_status(
    order_id: Annotated[str, Field(pattern=r"^ORD-\d{5}$")],
    fields: list[Literal["status", "carrier", "eta"]] = ["status"],
) -> dict:
    """Look up one order by ID and return only the requested fields.

    Args:
        order_id: Order ID in the form ORD-12345.
        fields: Which fields to return.
    """
    order = ORDERS.get(order_id)
    if order is None:
        raise ToolError(f"No order found with ID {order_id}.")
    return {"order_id": order_id, **{f: order[f] for f in fields}}

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

What each piece does:

`Field(pattern=...)` puts a regex in the schema `ORD-` plus five digits is rejected before your function runs.`Literal[...]` becomes a JSON Schema `enum`, so the model can only ask for fields you allow. No "give me the customer's address".`ToolAnnotations(readOnlyHint=True)` tells clients this tool doesn't modify anything. The FastMCP docs note clients use these hints to decide on confirmation prompts, but they are `ToolError` messages always reach the client. With `mask_error_details=True`, any `dict` gives the client both a text block and `structuredContent` JSON.
Here's the input schema FastMCP generated from that signature (I ran it with 4.1.8):

```
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "order_id": {"type": "string", "pattern": "^ORD-\\d{5}$",
                 "description": "Order ID in the form ORD-12345."},
    "fields": {"type": "array", "default": ["status"],
               "items": {"type": "string", "enum": ["status", "carrier", "eta"]},
               "description": "Which fields to return."}
  },
  "required": ["order_id"]
}
```

No hand-written schema. Change the function, the schema follows.

```
# stdio (default) - what local clients like Claude Code and Cursor launch
fastmcp run server.py

# Streamable HTTP - for a shared/remote server, served at /mcp
fastmcp run server.py --transport http --port 8000
```

`fastmcp run` finds an object named `mcp`, `server` or `app` in the file, or you can point at one with `server.py:my_server`.

Three ways, from fastest to most visual.

**CLI:** list tools and call one directly.

```
fastmcp list server.py
fastmcp call server.py get_order_status order_id=ORD-10421
# {"order_id": "ORD-10421", "status": "shipped"}
```

**In-memory client:** pass the server object straight to `Client`. No subprocess, no network, so it drops straight into pytest.

``` python
import asyncio
from fastmcp import Client
from server import mcp

async def main():
    async with Client(mcp) as client:
        print([t.name for t in await client.list_tools()])

        ok = await client.call_tool(
            "get_order_status", {"order_id": "ORD-10421", "fields": ["status", "eta"]}
        )
        print(ok.data)
        # {'order_id': 'ORD-10421', 'status': 'shipped', 'eta': '2026-11-14'}

        bad = await client.call_tool(
            "get_order_status", {"order_id": "DROP TABLE"}, raise_on_error=False
        )
        print(bad.is_error)  # True - string_pattern_mismatch, function never ran

        missing = await client.call_tool(
            "get_order_status", {"order_id": "ORD-99999"}, raise_on_error=False
        )
        print(missing.content[0].text)  # No order found with ID ORD-99999.

asyncio.run(main())
```

Those are the real outputs from my run. Write these as pytest cases (the FastMCP testing docs use `pytest-asyncio` with `asyncio_mode = "auto"`) and you have a contract test for every tool.

**MCP Inspector:** the official visual debugger from the MCP project.

```
fastmcp dev inspector server.py
```

It starts your server with the Inspector UI, reloads on file changes, and lets you click through tools and see raw requests and responses.

FastMCP has installers for common clients:

```
fastmcp install claude-code server.py
fastmcp install cursor server.py
fastmcp install claude-desktop server.py
```

There are also `gemini-cli` and `goose` installers. For anything else, generate the config:

```
fastmcp install mcp-json server.py
{
  "Order Lookup": {
    "command": "uv",
    "args": ["run", "--with", "fastmcp", "fastmcp", "run", "/path/to/server.py"]
  }
}
```

Or register it with Claude Code yourself:

```
claude mcp add order-lookup -- uv run --with fastmcp fastmcp run server.py
```

Then ask: *"What's the status and ETA for ORD-10421?"* The client lists tools, sees `get_order_status`, calls it with typed arguments, and answers from your data.

An MCP tool is code the model decides to run. Treat every tool like a public API endpoint that a very fast, very literal intern can call.

`get_order_status(order_id)` beats `run_sql(query)`. One job per tool, smallest possible blast radius.` pattern`, `ge`/` le`, `max_length` and `Literal` are enforced before your code runs. Optionally set `FastMCP(..., strict_input_validation=True)` to stop type coercion such as `"10"` becoming `10`.` readOnlyHint=True`, mark irreversible actions `destructiveHint=True`, but enforce permissions in your code. Hints are advisory.` user_id`, credentials or DB connections with `Depends()`; those parameters are excluded from the schema.`@mcp.tool(timeout=30.0)` returns an MCP error if a tool runs too long. Note the docs say a timeout does not undo side effects.
Start with one read-only tool your team asks about daily. Test it with `Client(mcp)`. Then connect it.

*What's the first internal lookup you'd expose as an MCP tool, and what would you refuse to expose?*
