MCP Protocol¶
The Model Context Protocol (MCP) is a JSON-RPC 2.0 protocol that lets AI agents discover and call tools, read resources, and retrieve prompt templates from external servers. This page covers the protocol mechanics -- what gets sent over the wire, how transports differ, and how to test a running server.
For a hands-on walkthrough, see Module 3: Build an MCP Server.
Capabilities¶
MCP defines three capability types. A server can expose any combination.
| Capability | Purpose | Decorator | Example |
|---|---|---|---|
| Tools | Functions the LLM can call during reasoning | @tool |
integrate(expression="x**2", variable="x") |
| Resources | Data the client can read on demand | @resource |
weather://london/current |
| Prompts | Reusable prompt templates with parameters | @prompt |
A step-by-step calculus tutor prompt |
Tools¶
Tools are the most common capability. Each tool has a name, description, and JSON Schema describing its parameters. The LLM sees this schema as part of its tool-calling interface and decides when to invoke each tool.
from typing import Annotated
from pydantic import Field
from fastmcp import Context
from fastmcp.tools import tool
@tool(
annotations={
"readOnlyHint": True,
"idempotentHint": True,
"openWorldHint": False,
}
)
async def integrate(
expression: Annotated[str, Field(description="The integrand in Python/SymPy syntax.")],
variable: Annotated[str, Field(description="Variable of integration, e.g. 'x'.")],
ctx: Context = None,
) -> dict:
"""Compute indefinite or definite integrals."""
...
Tool annotations are protocol-level hints about behavior. readOnlyHint
tells the client the tool doesn't modify state. idempotentHint says calling
it twice with the same input produces the same result. Clients can use these
to make retry and caching decisions.
Resources¶
Resources expose read-only data via URI templates -- useful for configuration, reference data, or anything the agent might need to look up.
from fastmcp.resources import resource
@resource("weather://{city}/current", name="current_weather")
def get_weather(city: str) -> dict:
"""Current weather for a city."""
return {"city": city, "temperature": 22}
Prompts¶
Prompts are server-side templates with named parameters. A server can package domain expertise as reusable instructions.
from fastmcp.prompts import prompt
@prompt()
def tutor_prompt(topic: str) -> str:
"""Step-by-step calculus tutoring prompt."""
return f"Explain {topic} step by step, showing all work."
Return types can be str, a single PromptMessage, or
list[PromptMessage] for multi-turn conversations.
Transports¶
MCP supports two transport modes. The protocol messages are identical -- only the delivery mechanism changes.
| Transport | Wire format | Typical use | How to start |
|---|---|---|---|
| Streamable HTTP | HTTP POST with JSON-RPC body | Production, OpenShift, remote access | mcp.run(transport="http", host="0.0.0.0", port=8080, path="/mcp/") |
| STDIO | JSON-RPC over stdin/stdout | Local development, CLI testing | mcp.run(transport="stdio") |
Streamable HTTP¶
The production transport. The client sends HTTP POST requests to the server's
MCP endpoint (typically /mcp/). Each request contains a JSON-RPC 2.0
message; the response is a JSON-RPC 2.0 result.
FastMCP configures the transport from environment variables:
| Variable | Default | Purpose |
|---|---|---|
MCP_TRANSPORT |
stdio |
Set to http for streamable HTTP |
MCP_HTTP_HOST |
127.0.0.1 |
Bind address |
MCP_HTTP_PORT |
8000 |
Listen port |
MCP_HTTP_PATH |
/mcp/ |
Endpoint path |
In OpenShift, the Containerfile typically sets MCP_TRANSPORT=http and
MCP_HTTP_HOST=0.0.0.0 so the server listens on all interfaces at port 8080.
STDIO¶
The development transport. The MCP client launches the server as a subprocess
and communicates over stdin/stdout. This is how cmcp (the CLI testing tool)
works:
cmcp ".venv/bin/python -m src.main" tools/list
cmcp ".venv/bin/python -m src.main" tools/call integrate '{"expression": "x**2", "variable": "x"}'
No network configuration needed -- the client manages the process lifecycle.
SSE is deprecated
Earlier MCP drafts used Server-Sent Events (SSE) as the HTTP transport. Streamable HTTP replaced SSE in the 2025-03-26 protocol revision. FastMCP 3.x uses streamable HTTP by default.
Protocol messages¶
Every MCP interaction is a JSON-RPC 2.0 request/response pair. The three messages you'll use most often:
initialize¶
The handshake. The client sends its protocol version and capabilities; the server responds with its own.
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "my-agent", "version": "1.0" }
}
}
The server responds with its name, version, protocol version, and a
capabilities object listing which capability types it supports (tools,
resources, prompts).
tools/list¶
Discover available tools. The response includes the name, description, and JSON Schema for each tool's parameters.
tools/call¶
Invoke a tool by name with arguments.
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "integrate",
"arguments": { "expression": "x**2", "variable": "x" }
}
}
The response wraps the tool's return value in the MCP content format:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "{\"result\": \"x**3/3\", \"latex\": \"\\\\frac{x^{3}}{3}\", \"is_exact\": true, \"assumptions\": [\"integration constant omitted\"]}"
}
]
}
}
Equivalent methods exist for resources (resources/list, resources/read)
and prompts (prompts/list, prompts/get).
Auto-discovery: the connect_mcp flow¶
When an agent starts, BaseAgent reads the mcp_servers: list from
agent.yaml and calls connect_mcp(url) for each entry. Here's what
happens:
- Create client -- BaseAgent instantiates a
fastmcp.Clientpointed at the server URL. - Connect -- The client opens the connection (HTTP or STDIO) and sends
initialize. - List tools -- The client calls
tools/listand receives the full schema for every tool the server exposes. - Register tools -- Each discovered tool is added to the agent's tool
registry with
llm_onlyvisibility. The LLM sees them in its nextcall_model()invocation alongside any local tools. - Store client -- The client connection is kept open for the agent's lifetime. Tool calls go through this persistent connection.
# agent.yaml
mcp_servers:
- url: ${MCP_CALCULUS_URL:-http://mcp-server.calculus-mcp.svc.cluster.local:8080/mcp/}
On the server side, auto-discovery of components works through
FileSystemProvider. The server bootstrap creates one provider per capability
directory:
from fastmcp.server.providers import FileSystemProvider
providers = [
FileSystemProvider(SRC_ROOT / "tools", reload=hot_reload),
FileSystemProvider(SRC_ROOT / "resources", reload=hot_reload),
FileSystemProvider(SRC_ROOT / "prompts", reload=hot_reload),
]
mcp = FastMCP(name, providers=providers, middleware=middleware)
Drop a Python file with a @tool-decorated function into src/tools/, and
the server picks it up automatically. No registration code, no import lists.
With reload=True (controlled by the MCP_HOT_RELOAD env var), the server
detects new files at runtime.
Standalone decorators
FastMCP 3.x uses standalone decorators (from fastmcp.tools import tool)
rather than a shared server instance. Each component file is
self-contained -- it never imports an mcp object. This is what makes
directory-scanning discovery possible.
Testing with curl¶
Any streamable-HTTP server can be tested with plain curl. Set URL to a
local address or an OpenShift route:
# Local
URL="http://localhost:8000/mcp/"
# Or from an OpenShift route
URL="https://$(oc get route mcp-server -n calculus-mcp -o jsonpath='{.spec.host}')/mcp/"
The streamable-http transport requires two headers on every request:
Content-Type: application/json and Accept: application/json,
text/event-stream. After initialize, subsequent requests must also include
the Mcp-Session-Id returned in the response headers. Add -sk for
self-signed TLS certs.
# 1. Initialize -- dump headers so we can capture the session ID
curl -s "$URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-D /tmp/mcp-headers.txt \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
# Capture session ID from response headers
SESSION=$(grep -i mcp-session-id /tmp/mcp-headers.txt | tr -d '\r' | awk '{print $2}')
# 2. List tools
curl -s "$URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# 3. Call a tool
curl -s "$URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"integrate","arguments":{"expression":"x**2","variable":"x"}}}'
Responses arrive as SSE events, prefixed with event: message\ndata: ....
The JSON payload follows on the data: line.
For deployed servers, mcp-test-mcp wraps the handshake into one-line
commands:
mcp-test-mcp list_tools --server-url "$URL"
mcp-test-mcp test_tool --server-url "$URL" \
--tool-name integrate \
--params '{"expression": "x**2", "variable": "x"}'
Authentication¶
MCP servers can require authentication using JWT tokens. FastMCP provides
JWTVerifier for token validation and RemoteAuthProvider for OAuth 2.0
Protected Resource metadata (RFC 9728).
Auth is configured via environment variables on the server:
| Variable | Purpose |
|---|---|
MCP_AUTH_JWT_ALG |
JWT algorithm (RS256, HS256, etc.). Auth disabled if unset. |
MCP_AUTH_JWT_SECRET |
Shared secret for HMAC algorithms |
MCP_AUTH_JWT_PUBLIC_KEY |
Public key for RSA/EC algorithms |
MCP_AUTH_JWT_JWKS_URI |
JWKS endpoint URL (alternative to static key) |
MCP_AUTH_JWT_ISSUER |
Expected token issuer |
MCP_AUTH_JWT_AUDIENCE |
Expected token audience |
MCP_AUTH_REQUIRED_SCOPES |
Comma-separated default required scopes |
Individual tools can require additional scopes using the auth parameter:
from fastmcp.server.auth import require_scopes
from fastmcp.tools import tool
@tool(auth=require_scopes("admin"))
async def admin_only_tool() -> str:
"""Only accessible with admin scope."""
return "secret data"
When auth is enabled, clients must include a Bearer token in the
Authorization header of their HTTP requests.
Error handling¶
MCP uses JSON-RPC 2.0 error responses. FastMCP's ToolError exception maps
to the standard error format:
from fastmcp.exceptions import ToolError
@tool()
async def my_tool(expression: str) -> str:
if not expression.strip():
raise ToolError("Expression cannot be empty.")
...
The client receives a JSON-RPC error response with the message. When building tools for agent consumption, write error messages that help the LLM recover -- state what went wrong and what valid input looks like.
Quick reference¶
Common protocol methods:
| Method | Direction | Purpose |
|---|---|---|
initialize |
Client -> Server | Handshake, exchange capabilities |
tools/list |
Client -> Server | Discover available tools |
tools/call |
Client -> Server | Invoke a tool |
resources/list |
Client -> Server | Discover available resources |
resources/read |
Client -> Server | Read a resource by URI |
prompts/list |
Client -> Server | Discover available prompts |
prompts/get |
Client -> Server | Retrieve a prompt template |
notifications/initialized |
Client -> Server | Client ready (after initialize) |