Free lesson · GenAI Agent Engineering

Define MCP tools

You can declare MCP tools with @server.list_tools() / @server.call_tool(), define input JSON schemas, return TextContent results, distinguish tools/list vs tools/call, write good tool descriptions, validate inputs, cache tool results, and handle concurrent tool calls.

Course: GenAI Agent Engineering · Chapter 24 · The MCP Server

Free to read — no subscription required.

Introduction

When you expose an action to an LLM through MCP, the model only sees what your tool definition tells it: a name, a description, and a JSON Schema for inputs. Get any of those wrong and the model either ignores the tool, hallucinates arguments that fail validation, or — worse — calls a half-defined handler that silently corrupts state. Teams that ship MCP servers without a disciplined definition layer end up with a graveyard of tools the model never picks correctly. By the end of this lesson you'll be able to define an MCP tool with a precise input schema, register it through a typed registry, and route invocations to an async handler that validates arguments and returns structured TextContent results.

Key Terminology

  • Tool definition — the bundle of name, description, JSON Schema, and handler that an MCP server publishes via tools/list; this is what makes a capability discoverable and callable by the model.
  • JSON Schema (input_schema) — the contract describing accepted arguments, required fields, types, and enums; the server uses it to validate every tools/call payload before the handler runs.
  • Tool handler — the async function that performs the actual work and returns a list of TextContent; isolating logic here keeps validation, dispatch, and execution cleanly separated.
  • Tool registry — the in-process map from tool name to definition; it owns registration, list emission for discovery, and invocation routing.
  • Tool description — the natural-language string the LLM reads when choosing a tool; ambiguity here is the single largest cause of wrong-tool selection.

Concepts

A tool definition is metadata plus behavior. The metadata (name, description, schema, examples) drives both client-side discovery and LLM tool-selection. The behavior (handler) is invoked only after the registry validates the incoming arguments. Separating these two layers is what makes a tool catalog maintainable: you can change a description, tighten a schema, or swap a handler without touching the others.

The description is the only signal the LLM has when choosing among tools. It must say what the tool does, what inputs matter, and ideally include one or two examples. The JSON Schema then enforces the contract at runtime — required fields, enum constraints, default values, and additionalProperties: false to reject typos. Schema correctness is your firewall against malformed arguments reaching the handler (see Code Walkthrough).

The registry centralizes lookup and dispatch. When a client calls tools/call, the registry resolves the name, validates the payload against the stored schema, and routes to the handler. Errors at any stage return structured TextContent rather than crashing the server.

Loading diagram...

Code Walkthrough

Now that you've seen the core ideas behind defining tools, this walkthrough turns them into working code.

The snippet below combines all three concepts — definition metadata, JSON Schema construction, and registry-mediated dispatch — into one coherent flow. Read it as: define the dataclass that holds tool metadata, build a schema for a concrete tool, register it, then invoke it through the registry's validation path.

Code snippetpython
1from dataclasses import dataclass 2from typing import Dict, Any, List, Optional, Callable, Awaitable 3from mcp.types import Tool, TextContent 4import aiofiles 5 6@dataclass 7class ToolDefinition: 8 name: str 9 description: str 10 input_schema: Dict[str, Any] 11 handler: Callable[[Dict[str, Any]], Awaitable[List[TextContent]]] 12 examples: Optional[List[Dict[str, Any]]] = None 13 14 def to_mcp_tool(self) -> Tool: 15 return Tool( 16 name=self.name, 17 description=self.description, 18 inputSchema=self.input_schema, 19 ) 20 21def create_tool_schema( 22 properties: Dict[str, Dict[str, Any]], 23 required: Optional[List[str]] = None, 24) -> Dict[str, Any]: 25 schema: Dict[str, Any] = { 26 "type": "object", 27 "properties": properties, 28 "additionalProperties": False, 29 } 30 if required: 31 schema["required"] = required 32 return schema 33 34class ValidationError(Exception): 35 pass 36 37class ToolRegistry: 38 def __init__(self) -> None: 39 self._tools: Dict[str, ToolDefinition] = {} 40 41 def register(self, tool: ToolDefinition) -> None: 42 self._tools[tool.name] = tool 43 44 def get_tools(self) -> List[Tool]: 45 return [t.to_mcp_tool() for t in self._tools.values()] 46 47 async def invoke( 48 self, name: str, arguments: Dict[str, Any] 49 ) -> List[TextContent]: 50 tool = self._tools.get(name) 51 if not tool: 52 raise ValueError(f"Unknown tool: {name}") 53 for field in tool.input_schema.get("required", []): 54 if field not in arguments: 55 raise ValidationError(f"Missing required field: {field}") 56 try: 57 return await tool.handler(arguments) 58 except Exception as e: 59 return [TextContent(type="text", text=f"Tool error: {e}")] 60 61async def read_file_handler(args: Dict[str, Any]) -> List[TextContent]: 62 async with aiofiles.open( 63 args["path"], mode="r", encoding=args.get("encoding", "utf-8") 64 ) as f: 65 content = await f.read() 66 return [TextContent(type="text", text=content)] 67 68read_file_tool = ToolDefinition( 69 name="read_file", 70 description="Read the contents of a file at the specified path.", 71 input_schema=create_tool_schema( 72 properties={ 73 "path": {"type": "string", "description": "Absolute file path"}, 74 "encoding": {"type": "string", "default": "utf-8"}, 75 }, 76 required=["path"], 77 ), 78 handler=read_file_handler, 79 examples=[{"input": {"path": "/etc/hostname"}}], 80) 81 82registry = ToolRegistry() 83registry.register(read_file_tool)

You'll know it works when registry.get_tools() returns a list whose first entry is an MCP Tool named read_file, and await registry.invoke("read_file", {"path": "/etc/hostname"}) returns a single TextContent whose text is the file contents — while await registry.invoke("read_file", {}) raises ValidationError: Missing required field: path before ever touching the handler.

Do's and Don'ts

Having walked through defining tools above, the following Do's and Don'ts distill it into practice.

Do's

  1. Do write descriptions for the LLM, not your teammates — the model picks tools by description alone, so name the action and the inputs explicitly.
  2. Do mark required fields and set additionalProperties: false — schema strictness catches typos and hallucinated arguments before the handler runs.
  3. Do return TextContent for errors instead of raising — surfacing failures as content lets the model self-correct on the next turn.

Don'ts

  1. Don't put validation logic in the handler — the registry validates against the schema; duplicating checks in handlers drifts over time.
  2. Don't reuse one generic "execute" tool with a free-form payload — the model can't distinguish capabilities without distinct names and schemas.
  3. Don't omit examples on non-trivial tools — a single example in the description measurably improves tool-selection accuracy.

This lesson is free to read. Its 2 hands-on labs — real code, in a cloud IDE — are part of the GenAI Agent Engineering subscription.

From · cancel anytime

More free lessons in GenAI Agent Engineering

All free lessons in GenAI Agent Engineering