Model Context Protocol / Building & Securing
A minimal server that exposes a real tool.
Reviewed by Yuvaraj
An MCP server is a small program that exposes capabilities, tools, resources, and prompts, to an AI application through one standard protocol. The server holds no model of its own; it is the typed, discoverable surface a model reaches through to run code, read data, or act on your systems. Because any compliant client, Claude Desktop, an IDE agent, or the MCP Inspector, can talk to any compliant server, a contract you write once becomes reusable everywhere. This lesson dissects the smallest useful server, one that exposes a single add tool, so the anatomy is unmistakable before you scale it up.
Almost every server is built from five pieces:
| Piece | What it is | Why it matters |
|---|---|---|
| Identity | A name and version | Lets clients recognize and log the server |
| Capabilities | The tools, resources, and prompts you offer | Advertised during the handshake so clients know what exists |
| Tool definition | name, description, and a JSON Schema inputSchema | The entire contract the model reads before calling |
| Handler | A function that receives validated arguments | Does the work and returns content blocks |
| Transport | stdio, or streamable HTTP |
Ask about this lesson, or about anything in AI. Answers cite the lessons they draw on.
Finished this lesson?
Mark it complete to earn XP, keep your streak, and schedule a review.
| Carries JSON-RPC messages between client and server |
When a client connects it runs an initialize handshake, exchanges capabilities, then calls tools/list to discover your tools and tools/call to invoke one. Your job is to describe those tools precisely and implement what happens on a call.
The official Python mcp SDK, in its low-level API, makes each part explicit:
import asyncio
from mcp.server.lowlevel import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
server = Server("math-server", version="1.0.0")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [Tool(
name="add",
description="Add two numbers and return their sum.",
inputSchema={
"type": "object",
"properties": {"a": {"type": "number"}, "b": {"type": "number"}},
"required": ["a", "b"],
},
)]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
total = arguments["a"] + arguments["b"]
return [TextContent(type="text", text=str(total))]
async def main():
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
asyncio.run(main())
list_tools returns the contract; call_tool is the handler that receives already-parsed arguments, computes , and returns a list of content blocks. create_initialization_options() packages the server identity and capabilities for the handshake, and stdio_server() wires standard input and output as the transport, exactly what a local client launches and speaks to.
The model never sees your code, only the tool name, description, and inputSchema. Those three fields are the prompt that determines whether the tool is chosen and called with correct arguments. A precise description and a strict schema, where a and b are typed as numbers and marked required, remove ambiguity; vague text and loose schemas invite wrong arguments or skipped calls.
The schema is a prompt
Treat the description and schema like API docs written for a reader who will act on them literally. Name the tool by what it does, describe when to use it, constrain every field with a type, and mark required inputs. Investment here moves tool-call reliability more than almost any change to the handler.
Common mistakes