MCPサーバーに日付が「今日」で飛んでくる — AIの引数を4つのガードで弾くFastMCP完全実装 #Python - Qiita
@yukihayama(Yuki Hayama)

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
0
Posted at 2026-11-11

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.

# Failed design (reproduction of the search_tasks I had actually written) from datetime import datetime _TASKS = [ {"id": "T-001" , "status": "todo ", "TAG173@@due "TAG177@@: "2026-11-05"}, {"id": "T-003" , "status": "todo ", "TAG223@@due ": "2026-11-20"}, ]def search_tasks_bad(args: dict) -> list: """Search for tasks""" try: due_from = datetime.strptime(args.get("due_from", ""), "%Y-%m-%d").date() return [t for t in _TASKS if t["status"] == args.get("status") and t["due"] >= due_from.isoformat()]except Exception: return [] # ← "Today" raises a ValueError, but an empty list is interpreted as "success" by the AI

There are three reasons it breaks.

  1. 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".
  2. 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.
  3. 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.

# tasks_server.py — A task-search MCP server that rejects AI arguments with four guards from datetime import date from typing import Annotated, Literal, Optional from mcp.server.fastmcp import FastMCP from pydantic import BaseModel, Fieldmcp = FastMCP("tasks") # ---- Demo data (normally read from a database or spreadsheet) ---- _RAW = [ ("T-001", "Invoice journal entry", "todo", "2026-11-05"), ("T-002", "Monthly report", "doing", "2026-11-10"), ("T-003", "Tag organization", "todo", "2026-11-20"),("T-004", "Batch Modification", "done", "2026-10-31"), ] class Task(BaseModel): id: str title: str status: Literal["todo", "doing", "done"] due: date _TASKS = [Task(id=i, title=t, status=s, due=date.fromisoformat(d))for i, t, s, d in _RAW] def _ok(tasks: list[Task]) -> dict: return {"ok": True, "count": len(tasks), "tasks": [t.model_dump(mode="json") for t in tasks]} def _error(code: str, message: str, hint: str) -> dict:# Guard 4: A structured error that the AI can read and self-correct (does not return an “empty list”) return {"ok": False, "count": 0, "tasks": [], "error": {"code": code, "message": message, "hint": hint}} () def search_tasks( status: Literal["todo", "doing", "done"], # Guard 1: Restrict the options with the schema due_from: date, # Guard 1: “Today” is rejected by the type due_to: Optional[date] = None, limit: Annotated[int, Field(ge=1, le=100)] = 20, # Guard 2: Declare the range in the schema ) -> dict: """Search for pending tasks by deadline. status must be one of todo / doing / done. due_from / due_to must be dates in YYYY-MM-DD format only (relative expressions such as "today" are not allowed). When specifying due_to, it must be a date on or after due_from. """ tasks = [t for t in _TASKS if t.status == status and t.due >= due_from]if due_to is not None: if due_to < due_from: # Guard 3: Business rules that cannot be written in the schema are handled by an early return at the beginning of the function return _error( "INVALID_RANGE", f"due_to({due_to.isoformat()}) is earlier than due_from({due_from.isoformat()}).", "Please specify a date for due_to in YYYY-MM-DD format that is the same as or later than due_from.", ) tasks = [t for t in tasks if t.due <= due_to]return _ok(tasks[:limit]) if __name__ == "__main__": mcp.run(transport="stdio")

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.

# client_test.py — Verify that all four guards are working import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_clientasync def call(session, name, args): r = await session.call_tool(name, args) text = r.content[0].text if r.content else "" print(f" isError={r.isError} / {text[:120]}") async def main():server = StdioServerParameters(command="python", args=["tasks_server.py"]) async with stdio_client(server) as (read, write): async with ClientSession(read, write) as session: await session.initialize() print("(1) Normal case: ISO dates pass") await call(session, "search_tasks",{"status": "todo", "due_from": "2026-11-01"}) print("(2) Guard 1: \"today\" is rejected by schema validation (it does not reach the function)") await call(session, "search_tasks", {"status": "todo", "due_from": "today"}) print("(3) Guard 2: limit=999 is rejected as out of range") await call(session, "search_tasks",{"status": "todo", "due_from": "2026-11-01", "limit": 999}) print("(4) Guard 3+4: reversed periods are returned as a structured error + hint") await call(session, "search_tasks", {"status": "todo", "due_from": "2026-11-30", "due_to": "2026-11-01"})asyncio.run(main())

When run, the four invocations break down as follows:

(1) Normal case: The ISO date passes isError=False / {"ok": true, "count": 2, (2) Guard 1: "today" is rejected by the schema (the function is not entered) isError=True / Error executing tool search_tasks ... due_from: Input should be a valid date ... (3) Guard 2: limit=999 is rejected by the range validation isError=True / Error executing tool search_tasks ... limit: Input should be less than or equal to 100 (4) Guard 3+4: A reversed date range returns a structured error with a hint isError=False / {"ok": false, "count": 0, "error": {"code": "INVALID_RANGE", "message": "due_to(2026-11-01) is earlier than due_from(2026-11-30).", "hint": "Specify a date in YYYY-MM-DD format for due_to that is the same as or later than due_from."}}

(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

  1. 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
  2. Replace choices with Literal, dates with date, and numeric ranges with Field(ge=, le=), and guard them with the schema
  3. 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.

🎁 Get the reader bonus (free—email registration)

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
Sign upLogin