MCP is just JSON-RPC with three primitives
The Model Context Protocol standardizes how AI clients connect to external capabilities. By 2026 the ecosystem passed 13,000 servers with OpenAI, Google, and Microsoft on board — but underneath the hype an MCP server is something refreshingly boring: a JSON-RPC 2.0 process that exposes three primitives over stdio or streamable HTTP:
- Tools — functions the model can call (send an email, query a DB).
- Resources — data the model can read (files, records, docs).
- Prompts — reusable templates the user or model can invoke.
If you understand the JSON messages, you understand MCP. SDKs (FastMCP for Python, the TypeScript SDK, Go) hide the wire format, but knowing it is what lets you debug when a client says "no tools found."
The initialize handshake
Every session starts with the client and server exchanging capabilities. It's a plain JSON-RPC request/response:
// client -> server
{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "protocolVersion": "2026-03-26", "capabilities": {}, "clientInfo": { "name": "claude", "version": "1.0" } } }// server -> client
{ "jsonrpc": "2.0", "id": 1, "result": {
"protocolVersion": "2026-03-26",
"capabilities": { "tools": {}, "resources": {}, "prompts": {} },
"serverInfo": { "name": "my-server", "version": "0.1.0" } } }Listing and calling a tool
The client discovers tools with tools/list; the server returns each tool's name, description, and an inputSchema — a JSON Schema the model uses to build valid arguments:
// tools/list result
{ "tools": [
{ "name": "get_weather",
"description": "Get current weather for a city",
"inputSchema": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
} } ] }To call it, the client sends tools/call with arguments that satisfy that schema, and the server returns a content array:
// client -> server
{ "jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": { "name": "get_weather", "arguments": { "city": "Ahmedabad" } } }
// server -> client
{ "jsonrpc": "2.0", "id": 7, "result": {
"content": [{ "type": "text", "text": "34°C, clear skies" }] } }The inputSchema is the contract that makes tool calls reliable — get it right and the model rarely sends bad arguments.
A minimal server with FastMCP
You don't hand-write JSON-RPC; an SDK maps decorated functions onto it:
from fastmcp import FastMCP
mcp = FastMCP("my-server")
@mcp.tool()
def get_weather(city: str) -> str:
"""Get current weather for a city."""
return f"34°C in {city}"
if __name__ == "__main__":
mcp.run() # speaks JSON-RPC over stdioFastMCP derives the inputSchema from your type hints and the description from the docstring — which is why good hints and docstrings directly improve how well the model uses your tool.
Inspecting the messages while you build
The single best debugging habit is to look at the raw JSON-RPC. The MCP Inspector lets you call tools in a browser without an LLM in the loop; alongside it:
- Copy a
tools/listresult and format it to read each tool'sinputSchemaclearly. - Validate your inputSchema as real JSON Schema — a malformed schema is the most common reason a client silently shows no tools.
- When a tool call fails, diff the arguments the client sent against the schema you expected to find the mismatch.
For the conceptual background see MCP Explained: JSON-RPC for AI and the MCP message JSON example.