cd /news/artificial-intelligence/building-an-mcp-server-in-python-wha… · home topics artificial-intelligence article
[ARTICLE · art-79586] src=dev.to ↗ pub= topic=artificial-intelligence verified=true sentiment=· neutral

Building an MCP server in Python: what I learned about tool design

A developer building an MCP server for Google Sheets learned that tool descriptions, not implementation, determine AI model accuracy. The developer found that models like Claude guess column names and prefer imprecise search options, so they redesigned tool names, docstrings, and argument descriptions to steer model behavior. The key fix was adding a cheap discovery tool and using mutual steering in descriptions to guide the model toward exact filters.

read7 min views3 publishedJul 30, 2026

The first version of my MCP server worked and was still useless. Every tool returned correct data when I called it by hand in the Inspector, and then I pointed Claude at it and watched the model do the wrong thing about a third of the time. It would search for a value it already had an exact column name for, page through rows one at a time, or invent a column called Name

for a sheet whose column was Student Name

.

None of that was a bug in my code. The handlers were fine. What was wrong was everything I had written about the handlers: the tool names, the descriptions, the argument docs, and the error strings. The model only ever sees those. It never sees your implementation, so your implementation is not what it is reasoning about.

MCP, the Model Context Protocol, is the standard way AI clients like Claude and Cursor talk to outside data. An MCP server advertises a fixed list of tools, and the client can only call what that list contains. I build PasteSheet, which publishes a Google Sheet as a read-only MCP server, so my whole surface is three tools over a spreadsheet. That turns out to be enough to get wrong in a lot of instructive ways.

One disclosure before the code. My server is written in PHP, not Python. The lessons below are protocol-level rather than language-level, and I am showing them in Python because the official SDK is what most people reach for when they build one of these. Everything here is a real decision I shipped, translated.

The SDK is one install, and the tool surface is plain type-hinted functions.

uv add "mcp[cli]"      # or: pip install "mcp[cli]"
python
from typing import Annotated, Literal

from mcp.server import MCPServer
from mcp.types import ToolAnnotations
from pydantic import Field

mcp = MCPServer("Sheet")

if __name__ == "__main__":
    mcp.run(transport="streamable-http", port=3001)

You write no JSON Schema. The type hints are the schema, which is pleasant right up to the moment you realise the model is reading your prose and not your types.

My first mistake was assuming the model knew the shape of the sheet. It does not, so it guesses, and a guessed column name means an empty result the model then reports as "there are no matching rows." That is the worst failure mode available to you, because it looks like an answer.

The fix is a separate, cheap discovery tool whose docstring says when to reach for it.

@mcp.tool(
    title="Get the sheet's columns",
    annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),
)
def get_schema() -> dict:
    """Get the columns for the connected sheet.

    Call this before query_rows so that filters use real column names.
    """
    return {"columns": ["Student Name", "Grade", "Status", "Enrolled On"]}

That second line of the docstring did more for accuracy than any change I made to the query logic. Note the annotations too: read_only_hint

and idempotent_hint

tell the client this call changes nothing, which is what lets some clients stop asking the user to approve every single read.

This was the expensive lesson. My row-reading tool accepts exact filters, case-insensitive partial matching, and a full-text search across every column. Offer a model all three and it reaches for the loosest one, every time. Full-text search is the option that cannot fail outright, so it is the safe choice from the model's point of view, and it is also the slowest and the least precise.

You cannot fix that by documenting it somewhere else. The only text the model reads is the descriptions themselves, so the steering has to live inside them and it has to be mutual, with each looser argument pointing back at the tighter one.

@mcp.tool(
    title="Read rows",
    annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),
)
def query_rows(
    filters: Annotated[
        dict[str, str] | None,
        Field(
            description="Exact-match column filters as a {column: value} object. "
            "Prefer this to look up rows by a known exact value."
        ),
    ] = None,
    search: Annotated[
        str | None,
        Field(
            description="Full-text search matched case-insensitively across all "
            "columns. For an exact value in a known column, prefer `filters`."
        ),
    ] = None,
    order: Annotated[
        Literal["asc", "desc"],
        Field(description="Sort direction when sorting."),
    ] = "asc",
    limit: Annotated[
        int, Field(description="Maximum number of rows to return.")
    ] = 50,
) -> dict:
    """Read rows from the connected sheet.

    Returns {data, total, limit, offset}.
    """
    ...

Two smaller things are doing work in there. Literal["asc", "desc"]

constrains the sort direction in the schema instead of describing the valid values in prose, and the SDK rejects anything else before your function runs, handing the model a validation error it can correct itself. And the docstring names the return shape, {data, total, limit, offset}

, so the model knows a total

exists and can plan a second paged call rather than discovering pagination by accident.

An agent reads your error and immediately tries again. That makes every error string a chance to get the right answer on the next turn, and a dead end if you write it the way you would write it for a human staring at a log.

Here is the real one from my server, near enough verbatim:

if search and not plan_allows_search():
    raise ValueError(
        "Partial-match and full-text search require a Pro plan. To look up rows "
        "by an exact value on this plan, use the `filters` argument instead, "
        'e.g. filters={"Student Name": "Alexandra"}.'
    )

"Requires a Pro plan" on its own ends the conversation. Naming the argument that still works, and showing it filled in with a column this sheet actually has, turns a refusal into a redirect. The model retries with filters

and the user gets their answer. This one change removed most of my remaining dead ends.

The same logic applies to the tools you do not offer. Mine are read-only on purpose, and I wrote up why read-only is the safer default separately, but the design point here is narrow: a tool that does not exist cannot be called, and an error that explains the boundary teaches the model the shape of your server faster than any description does.

The cleanest version of the rule above is to never show the argument at all. On my server, partial matching and aggregation are paid features, so the schema is built per request and those arguments are simply absent for callers who are not entitled to them. A model cannot misuse an argument it has never seen, and every absent argument is a smaller decision space.

Python makes this less ergonomic than I would like. Type hints are static, so per-caller variation means either registering a different tool set when you build the server for a given deployment, or dropping to the lower-level list-tools handler. If neither is worth it for your case, the corrective error above is the honest fallback. It costs one wasted turn instead of zero.

Here is the part I would want to read before starting. The MCP half of this is genuinely easy, and if you are building a server over your own data you should just do it. The SDK is good and a few tools is not much code.

The expensive half is whatever you are wrapping. For Google Sheets specifically that means a Google Cloud project, the Sheets API enabled, a service account and its JSON key, and then quota. Google allows 300 read requests per minute per project and 60 per minute per user before returning a 429

, and agents are chatty in a way that human traffic is not. One question from the user becomes a schema read plus three or four queries. A naive server hits that ceiling fast, so you end up writing a cache, and now you are maintaining infrastructure rather than the thing you set out to build. I went through that in more detail in a piece on Google Sheets API rate limits.

That is the calculation, not a pitch. Build it yourself when you need custom tools or write access, because then the work buys you something. Reach for something hosted when you want the three read tools that a Google Sheets MCP server would give you anyway, because in that case the afternoon buys you a cache you did not want to own.

I build PasteSheet: paste a Google Sheet URL and get a cached JSON API plus a read-only MCP server your AI agent can query. Free tier, no credit card, no Google Cloud project. If you have shipped an MCP server, I want to hear which of your tool descriptions the model kept ignoring, because I suspect everyone has one.

── more in #artificial-intelligence 4 stories · sorted by recency
── more on @claude 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/building-an-mcp-serv…] indexed:0 read:7min 2026-07-30 ·