{"slug": "building-an-mcp-server-in-python-what-i-learned-about-tool-design", "title": "Building an MCP server in Python: what I learned about tool design", "summary": "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.", "body_md": "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`\n\nfor a sheet whose column was `Student Name`\n\n.\n\nNone 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.\n\nMCP, 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](https://pastesheet.com/), 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.\n\nOne 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.\n\nThe SDK is one install, and the tool surface is plain type-hinted functions.\n\n```\nuv add \"mcp[cli]\"      # or: pip install \"mcp[cli]\"\npython\nfrom typing import Annotated, Literal\n\nfrom mcp.server import MCPServer\nfrom mcp.types import ToolAnnotations\nfrom pydantic import Field\n\nmcp = MCPServer(\"Sheet\")\n\nif __name__ == \"__main__\":\n    mcp.run(transport=\"streamable-http\", port=3001)\n```\n\nYou 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.\n\nMy 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.\n\nThe fix is a separate, cheap discovery tool whose docstring says when to reach for it.\n\n```\n@mcp.tool(\n    title=\"Get the sheet's columns\",\n    annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),\n)\ndef get_schema() -> dict:\n    \"\"\"Get the columns for the connected sheet.\n\n    Call this before query_rows so that filters use real column names.\n    \"\"\"\n    return {\"columns\": [\"Student Name\", \"Grade\", \"Status\", \"Enrolled On\"]}\n```\n\nThat 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`\n\nand `idempotent_hint`\n\ntell the client this call changes nothing, which is what lets some clients stop asking the user to approve every single read.\n\nThis 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.\n\nYou 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.\n\n```\n@mcp.tool(\n    title=\"Read rows\",\n    annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),\n)\ndef query_rows(\n    filters: Annotated[\n        dict[str, str] | None,\n        Field(\n            description=\"Exact-match column filters as a {column: value} object. \"\n            \"Prefer this to look up rows by a known exact value.\"\n        ),\n    ] = None,\n    search: Annotated[\n        str | None,\n        Field(\n            description=\"Full-text search matched case-insensitively across all \"\n            \"columns. For an exact value in a known column, prefer `filters`.\"\n        ),\n    ] = None,\n    order: Annotated[\n        Literal[\"asc\", \"desc\"],\n        Field(description=\"Sort direction when sorting.\"),\n    ] = \"asc\",\n    limit: Annotated[\n        int, Field(description=\"Maximum number of rows to return.\")\n    ] = 50,\n) -> dict:\n    \"\"\"Read rows from the connected sheet.\n\n    Returns {data, total, limit, offset}.\n    \"\"\"\n    ...\n```\n\nTwo smaller things are doing work in there. `Literal[\"asc\", \"desc\"]`\n\nconstrains 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}`\n\n, so the model knows a `total`\n\nexists and can plan a second paged call rather than discovering pagination by accident.\n\nAn 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.\n\nHere is the real one from my server, near enough verbatim:\n\n```\nif search and not plan_allows_search():\n    raise ValueError(\n        \"Partial-match and full-text search require a Pro plan. To look up rows \"\n        \"by an exact value on this plan, use the `filters` argument instead, \"\n        'e.g. filters={\"Student Name\": \"Alexandra\"}.'\n    )\n```\n\n\"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`\n\nand the user gets their answer. This one change removed most of my remaining dead ends.\n\nThe 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](https://pastesheet.com/guides/read-only-google-sheets-mcp) 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.\n\nThe 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.\n\nPython 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.\n\nHere 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.\n\nThe 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`\n\n, 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](https://pastesheet.com/guides/google-sheets-api-rate-limits).\n\nThat 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](https://pastesheet.com/guides/google-sheets-mcp) would give you anyway, because in that case the afternoon buys you a cache you did not want to own.\n\nI build [PasteSheet](https://pastesheet.com/): 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.", "url": "https://wpnews.pro/news/building-an-mcp-server-in-python-what-i-learned-about-tool-design", "canonical_source": "https://dev.to/pastesheet/building-an-mcp-server-in-python-what-i-learned-about-tool-design-1aad", "published_at": "2026-07-30 01:01:43+00:00", "updated_at": "2026-07-30 02:01:53.111779+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-agents", "developer-tools"], "entities": ["Claude", "Cursor", "PasteSheet", "MCP", "Google Sheets"], "alternates": {"html": "https://wpnews.pro/news/building-an-mcp-server-in-python-what-i-learned-about-tool-design", "markdown": "https://wpnews.pro/news/building-an-mcp-server-in-python-what-i-learned-about-tool-design.md", "text": "https://wpnews.pro/news/building-an-mcp-server-in-python-what-i-learned-about-tool-design.txt", "jsonld": "https://wpnews.pro/news/building-an-mcp-server-in-python-what-i-learned-about-tool-design.jsonld"}}