Tool arguments
Tool arguments are the typed parameters a model fills from a schema, and descriptions, limits and fixed choices make right arguments easier and wrong ones impossible.
Last updated: 29 Sep, 2026 · MCP 2.2
A tool so far took a plain order_id: str. A model writes arguments from the schema alone, so this lesson adds a search tool whose parameters carry descriptions, a range and a fixed set of choices.
Annotated, Field and Literal
from typing import Annotated, Literal
from pydantic import Field
# Annotated[type, Field(...)] attaches limits and a description to a type
# Literal[...] allows only the listed values
# = value makes an argument optional with a defaultThe help articles
Add the imports from the box above to the top of shop.py: from typing import Annotated, Literal and from pydantic import Field. The search tool looks in a small table of articles, each tagged with a topic.
ARTICLES = {
"Where is my order?": "orders",
"Changing an order": "orders",
"How refunds work": "refunds",
"Refunds for damaged items": "refunds",
"Resetting your password": "account",
}The search_help tool
Annotated[str, Field(description=...)] is still a str, with a description for the model. Field(ge=1, le=5) holds limit to 1 through 5. Literal[...] allows only those three topics, and | None = None makes the topic optional.
@mcp.tool()
def search_help(
query: Annotated[str, Field(description="Words to look for in the help articles.")],
topic: Literal["orders", "refunds", "account"] | None = None,
limit: Annotated[int, Field(ge=1, le=5)] = 3,
) -> str:
"""Search the help centre and return matching article titles."""
found = [title for title, t in ARTICLES.items() if query.lower() in title.lower() and topic in (None, t)]
return "; ".join(found[:limit]) or "No articles found."View the code here
from typing import Annotated, Literal
from pydantic import Field
from mcp.server import MCPServer
mcp = MCPServer("Shop support")
ORDERS = {
"A17": {"item": "blue mug", "status": "shipped", "total": 12.50},
"B42": {"item": "desk lamp", "status": "processing", "total": 48.00},
}
ARTICLES = {
"Where is my order?": "orders",
"Changing an order": "orders",
"How refunds work": "refunds",
"Refunds for damaged items": "refunds",
"Resetting your password": "account",
}
@mcp.tool()
def lookup_order(order_id: str) -> str:
"""Look up an order by its id and say where it is."""
order = ORDERS[order_id]
return f"Order {order_id}: {order['item']}, {order['status']}."
@mcp.tool()
def search_help(
query: Annotated[str, Field(description="Words to look for in the help articles.")],
topic: Literal["orders", "refunds", "account"] | None = None,
limit: Annotated[int, Field(ge=1, le=5)] = 3,
) -> str:
"""Search the help centre and return matching article titles."""
found = [title for title, t in ARTICLES.items() if query.lower() in title.lower() and topic in (None, t)]
return "; ".join(found[:limit]) or "No articles found."
Reading the generated schema
Every limit you wrote lands in the schema the model receives.
import asyncio
from mcp import Client
from shop import mcp
async def main():
async with Client(mcp) as client:
tools = await client.list_tools()
schema = tools.tools[1].input_schema
print(schema["properties"]["query"])
print(schema["properties"]["topic"])
print(schema["properties"]["limit"])
print(schema["required"])
asyncio.run(main()){'description': 'Words to look for in the help articles.', 'title': 'Query', 'type': 'string'}
{'anyOf': [{'enum': ['orders', 'refunds', 'account'], 'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Topic'}
{'default': 3, 'maximum': 5, 'minimum': 1, 'title': 'Limit', 'type': 'integer'}
['query']What landed in the schema
- The description is attached to
queryfor the model to read. - The topic became an
enumof three values or null, with a default of null. - The limit carries
minimumandmaximumand a default of 3. - Only
queryis required; the two with defaults are optional.
A call that breaks the rules
A model calls the tool with limit=50, above the maximum you set.
import asyncio
from mcp import Client
from shop import mcp
async def main():
async with Client(mcp) as client:
good = await client.call_tool("search_help", {"query": "refund", "topic": "refunds"})
print(good.content[0].text)
bad = await client.call_tool("search_help", {"query": "refund", "limit": 50})
print(bad.is_error)
print(bad.content[0].text)
asyncio.run(main())How refunds work; Refunds for damaged items
True
Error executing tool search_help: 1 validation error for search_helpArguments
limit
Input should be less than or equal to 5 [type=less_than_equal, input_value=50, input_type=int]
For further information visit https://errors.pydantic.dev/2.13/v/less_than_equallimit=50 never reached your function. The SDK checked the arguments against the schema, and the result came back as an error whose text says what was wrong. A model reads that text as the tool's answer and can retry with a valid value, so a limit you write once also teaches the model.
A plain hint vs a schema limit
| A hint in the docstring | A Field limit | |
|---|---|---|
| Where it lives | Prose the model may ignore | The schema, enforced by the SDK |
| A bad value | Reaches your function | Rejected before your function runs |
| The model learns | Only if it reads carefully | From the error text, every time |
When to constrain an argument
- Use
Literalwhen only a fixed set of values makes sense, like a topic or a status. - Use
Field(ge=, le=)for a number with a sensible range, so a huge value cannot slip through. - Use
Field(description=)whenever the name alone does not tell the model what to send.
Related
- Previous: MCP Inspector with mcp dev
- Next: Structured output
- Reference: MCP tools specification
- Call
search_helpwith"topic": "billing". - Remove the
Fieldfromqueryand compare its schema entry. - Call it with
"limit": "two".
Little by little, you're building something great.