
Dates come flying into the MCP server as “today” — a complete FastMCP implementation that rejects AI arguments with four guards
- MCP
- Python
- pydantic
- AI
- Claude
The overnight task-organization agent suddenly stopped returning even one search result one morning. Identifying the cause took me three days—a failure on my part. The logs showed that what had been passed to the MCP tool was due_from: "today". The AI did not read the schema and sent the relative date unchanged. Because the design interpreted the date inside the function, it swallowed the ValueError and returned an empty list. The AI mistook that for success and kept retrying with the same arguments.
four guards in a table organized by decision criteria, along with a complete, copy-and-paste-ready FastMCP server and a verification client, reproduced exactly as they are.
Conclusion first: decision criteria for the four guards
| Guard | What it rejects | Where to implement it | How it is conveyed to the AI |
|---|---|---|---|
| 1. Type guard | Type mismatches and values outside the permitted choices (due_from: "today") | Argument type hints (Literal / date) | Visible as part of the schema before the call |
| 2. Range guard | Extreme numeric values (limit=999) | Field(ge=, le=) | Minimum and maximum values in the schema |
| 3. Semantic guard | Business rules that cannot be expressed in the schema (a reversed date range) | An early return at the beginning of the function | Structured error |
| 4. Recovery guard | Failures for which “how to fix it” is unclear | error.hint in the return value | The AI reads it and self-corrects the arguments |
Order matters.Guards 1–2 take effect before the function is called
(because the schema is passed to the AI).Guard 3 is a catch-all for rules that cannot be expressed in a schema, and Guard 4 is a mechanism that returns rejected input in a form the AI can fix itself. By stacking these four cards from the top (1→4) rather than from the bottom (4→1), the AI no longer sends malformed arguments in the first place; even if it does, they are rejected, and it can fix them based on the reason for rejection.Failed design: Receiving arguments as a dict and swallowing exceptions
This is what I initially wrote (I attached @mcp.tool() and exposed it through FastMCP). This function could work correctly sometimes and break at other times, making it the most difficult design to deal with.
There are three reasons it breaks.
- The schema ends up "accepting anything" — Because the argument is a dict, the schema passed to the AI is {"args": {"type": "object"}}. The AI has no way of knowing it should "put a date in due_from" or "wrap it in args in the first place," and will happily send either {"args": {"due_from": "today"}} or "today".
- Swallowed exceptions turn into "success" — return [] does not indicate an error. The AI receives it as a normal "zero matches" result and cannot tell that the arguments are invalid. It therefore retries with the same arguments over and over.
- Errors are not structured — Even if an exception is raised, what is returned to the AI is an English traceback. It does not explain where or how to fix the problem, leaving self-correction to chance.
Complete implementation: tasks_server.py with four guards
For Python 3.10 or later, all you need is pip install "mcp[cli]". Register it with Claude Code using claude mcp add tasks -- python tasks_server.py, and verify its behavior with client_test.py as described below.
The key to the design is a three-way division: “what can be written in the schema goes into type hints, what cannot goes into an early return, and how to communicate failures goes into hints.” Since FastMCP automatically converts the date and Literal arguments into JSON Schema, the AI knows before the call that “due_from is in YYYY-MM-DD format” and “status has three options.” The root cause of my failure was that I had made this schema a dict that would pass even for “today.”
That said, the result of guard 3 is designed to be returned as a payload with ok=false while leaving isError=False. Depending on the client, isError=True may stop the agent’s loop itself, so failures of the kind where “fixing the arguments will make it succeed” are more stable for overnight batch processing when returned as a result the AI can read.
Verification: Run client_test.py for 60 seconds
in the same folder as tasks_server.py.
When run, the four invocations break down as follows:
(2) and (3) were rejected before the function by FastMCP validation. Since this error is returned to the AI, it corrects the format by itself on the next call. (4) is a rule that cannot be expressed in the schema, so the function returns a structured error with a hint through an early return. The important point is that it does not merely reject the input and stop. By writing the "correct format" in the hint, the AI can self-correct in a single attempt.
Comparison of three methods for returning errors
| Method | AI behavior | Practical impact |
|---|---|---|
| Swallow the exception and return empty (a failed design) | Interprets "0 results" as success and continues retrying with the same arguments | Infinite loop or silent failure (an incident I experienced) |
| Re-raise the exception as-is | Stops at isError. The reason is given only in an English message | Self-correction is left to chance. The loop may also stop |
| Return a structured error with a hint (the implementation in this article) | Reads the hint, corrects the arguments, and resends them | Self-corrects on the first attempt. The overnight batch does not stop |
Summary: Next Steps
- Search for tools in your own MCP server that “only accept arguments as dict/str”—for every one of them, you have given the AI free rein to send malformed arguments
- Replace choices with Literal, dates with date, and numeric ranges with Field(ge=, le=), and guard them with the schema
- For business rules that cannot be expressed in the schema, return early at the beginning of the function and use structured code / message / hint errors to explain “how to fix it”
Implementation Checklist for This Article (for Saving)
- There are no tools that accept arguments as a plain dict (i.e., the schema accepts “anything”)
- Added type hints: choices use Literal, and dates use date (Guard 1)
- Added numeric ranges using Field(ge=, le=) (Guard 2)
- Business rules that cannot be expressed in the schema are validated at the beginning of the function (Guard 3)
- When rejected, they are returned as structured code / message / hint errors (Guard 4)
- The except blocks do not swallow errors and return an empty list or None
- At least once, I ran a “malformed-argument test” equivalent to client_test.py
- Documented the “invalid formats” in the tool description (docstring)
Reader Bonus (Free)
We are giving away for free a cheat sheet that comprehensively summarizes implementation patterns like those in this article, along with a collection of templates for Obsidian and Notion. It also includes a decision chart for “what to do with which tool,” based on the contents of eight books.
Related articles:
- When I Gave an MCP Server 11 Tools, the AI Misused 4 Out of 10—Five Patterns for Writing Descriptions That Make Tools Get Selected
- Error Handling in MCP Servers—Five Design Patterns That Enable the AI to Recover on Its Own
- Implementing a “Read-Only Mode” in a Custom MCP Server—Designing What the AI Should Not Be Allowed to Do
Reference book: MCP in Practice—Model Context Protocol for Extending AI Agents (available on Kindle / included with Kindle Unlimited) (Yuki Hayama)
Author: Yuki Hayama—the book series is available on Zenn / Amazon
Go to list of users who liked
Go to list of comments
Register as a new user and use Qiita more conveniently
- You get articles that match your needs
- You can efficiently read back useful information
- You can use dark theme
