cheat sheet
MCP Frameworks
Model Context Protocol (MCP) framework overview. Covers client/server architecture, stdio vs SSE vs streamable HTTP transports, FastMCP, mcp-go, the Python and TypeScript SDKs, and comparison with custom tool servers.
MCP Frameworks — Servers, Clients, and Transports
What it is
The Model Context Protocol (MCP) is an open JSON-RPC protocol introduced by Anthropic in late 2024 for exposing tools, resources, and prompts to LLM clients. Instead of every LLM application reimplementing tool integrations, an MCP server publishes its capabilities once and any MCP client (Claude Code, Claude Desktop, Codex CLI, Cursor, ChatGPT desktop, custom agents) can connect, list, and invoke them. The protocol is transport-agnostic — it runs over stdio, Server-Sent Events (SSE), and the newer Streamable HTTP transport.
This page covers the frameworks ecosystem around MCP: the official Python and TypeScript SDKs (with their FastMCP ergonomic wrappers), the Go server library mcp-go, Rust and Java SDKs, and where MCP fits relative to home-grown tool servers like OpenAI function-calling endpoints or custom REST/gRPC APIs.
For client-side configuration of MCP servers inside specific tools, see the claude-code MCP page and codex MCP page.
Install
The official Python SDK:
pip install "mcp[cli]"
Output:
Successfully installed mcp-1.x.x ...
TypeScript SDK:
npm install @modelcontextprotocol/sdk
Output:
added 12 packages in 3s
Go SDK (mark3labs/mcp-go):
go get github.com/mark3labs/mcp-go
Output:
go: added github.com/mark3labs/mcp-go v0.x.x
Quick example — a FastMCP server in Python
FastMCP is the high-level decorator-driven API in the official Python SDK. Three decorators cover most servers: @mcp.tool(), @mcp.resource(...), and @mcp.prompt().
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("demo-server")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two integers and return the result."""
return a + b
@mcp.tool()
def greet(name: str) -> str:
"""Return a friendly greeting for the given name."""
return f"Hello, {name}!"
@mcp.resource("config://app")
def app_config() -> str:
"""Static application configuration."""
return '{"version": "1.0", "feature_flags": {"beta": true}}'
@mcp.prompt()
def code_review(language: str, code: str) -> str:
"""Prompt template for reviewing code in the given language."""
return f"Review the following {language} code for bugs and style:\n\n{code}"
if __name__ == "__main__":
mcp.run()
Run it:
python server.py
Output:
[INFO] FastMCP server "demo-server" running on stdio
By default mcp.run() listens on stdio — the transport every MCP client supports out of the box. Configure Claude Desktop, Claude Code, or Codex to launch this process and they will discover add, greet, config://app, and code_review automatically.
When / why to use it
- You have a tool, dataset, or workflow that should be available to multiple LLM clients — Claude Desktop, Claude Code, Cursor, and custom agents — without reimplementing per-client.
- You want a clean transport boundary: the LLM runs in one process, your tools in another, isolated by a JSON-RPC contract.
- You need to expose resources (read-only content the LLM can reference, like file system roots, database tables, or knowledge bases) in addition to tools.
- You want prompt templates that clients can offer to users as slash commands or quick actions.
- You are integrating with a vendor-specific MCP server (GitHub, Notion, Linear, Slack, Sentry) and need to understand the protocol so you can debug.
Common pitfalls
stdio servers must not print to stdout — stdio is the JSON-RPC transport. Any stray
print()corrupts the protocol stream. Log tostderror to a file.FastMCPdoes this correctly by default; custom raw servers must be careful.
SSE transport is being deprecated — the original SSE transport is replaced by Streamable HTTP (single endpoint, bi-directional, supports resumability). New servers should publish Streamable HTTP; SSE remains supported for older clients.
Type hints are the schema —
FastMCPreads Python type hints to generate the JSON schema clients send to the LLM.def f(x)without a hint exposesxas untyped — the LLM may pass anything. Always annotate.
Tool side effects run on every call — there is no idempotency layer. Use a
tool_call_idargument or external dedup if the underlying action is non-idempotent (sending email, charging cards).
Use
mcp dev path/to/server.pyto launch the MCP Inspector — a web UI that lets you call tools, list resources, and inspect prompts without an LLM client. Indispensable while developing servers.
Wrap tools that hit external APIs with timeouts. MCP clients usually impose their own (Claude Code: 60s by default), but tools that hang block the entire client agent loop.
Architecture — client, server, transport
+----------------+ JSON-RPC 2.0 +-----------------+
| MCP client | <-----------------------> | MCP server |
| (Claude Code, | stdio / SSE / HTTP | (FastMCP, mcp- |
| Cursor, etc.) | | go, custom) |
+----------------+ +-----------------+
| |
| initialize → list_tools → call_tool ... |
v v
+----------------+ +-----------------+
| Host LLM | | Your business |
| (Claude, | | logic / APIs |
| GPT, etc.) | | |
+----------------+ +-----------------+
The client is whichever LLM-driving application the user is interacting with. The server exposes capabilities. The transport carries JSON-RPC frames between them. Servers never call LLMs themselves — they only return data the client passes to the LLM.
Lifecycle methods
| Method | Direction | Purpose |
|---|---|---|
initialize | client → server | Negotiate protocol version and capabilities. |
tools/list | client → server | Enumerate available tools. |
tools/call | client → server | Execute a tool by name with arguments. |
resources/list | client → server | Enumerate resources. |
resources/read | client → server | Fetch resource contents. |
prompts/list | client → server | Enumerate prompt templates. |
prompts/get | client → server | Render a prompt template. |
notifications/* | bidirectional | Progress, log, resource-changed events. |
Transports — stdio, SSE, Streamable HTTP
stdio
Default for local servers. The client launches the server as a subprocess and exchanges JSON-RPC frames over stdin/stdout. Simplest to deploy, no networking, no auth required.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("local")
if __name__ == "__main__":
mcp.run(transport="stdio")
Output:
[INFO] FastMCP server "local" running on stdio
Client config (Claude Desktop / Claude Code):
{
"mcpServers": {
"local": {
"command": "python",
"args": ["/abs/path/to/server.py"]
}
}
}
Server-Sent Events (SSE) — legacy
Two HTTP endpoints: a long-lived GET /sse event stream and a POST /messages write endpoint. Works through HTTPS and reverse proxies; supported by all clients today but deprecated in favour of Streamable HTTP.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("remote-sse")
if __name__ == "__main__":
mcp.run(transport="sse", host="0.0.0.0", port=8000)
Output:
[INFO] FastMCP server "remote-sse" listening at http://0.0.0.0:8000
[INFO] SSE endpoint: http://0.0.0.0:8000/sse
[INFO] Message endpoint: http://0.0.0.0:8000/messages
Streamable HTTP — current standard
A single POST /mcp endpoint that supports both request/response and server-pushed events using chunked transfer or SSE in the response body. Adds session IDs, optional resumability via Last-Event-ID, and a clean auth story.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("remote-http")
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
Output:
[INFO] FastMCP server "remote-http" listening at http://0.0.0.0:8000/mcp
| Transport | Local | Remote | Auth | Resumable | Status |
|---|---|---|---|---|---|
| stdio | yes | no | n/a (subprocess) | no | recommended for local |
| SSE | no | yes | bearer / cookie | no | deprecated |
| Streamable HTTP | no | yes | bearer / OAuth | yes (via session ID) | recommended for remote |
FastMCP — Python decorators
Tools
from typing import Annotated
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("github")
@mcp.tool()
def open_issue(
repo: Annotated[str, "owner/name"],
title: Annotated[str, "Issue title"],
body: Annotated[str, "Issue body in Markdown"] = "",
) -> str:
"""Open a new GitHub issue and return its URL."""
...
return f"https://github.com/{repo}/issues/123"
Type hints become the JSON schema. Annotated[T, "description"] adds the parameter description the LLM sees. Default values mark a parameter as optional.
Resources — static and templated
@mcp.resource("docs://intro")
def docs_intro() -> str:
"""The intro page of the docs."""
return "Welcome to the docs..."
@mcp.resource("file:///{path}")
def file_read(path: str) -> str:
"""Read an arbitrary file by absolute path."""
with open(path) as f:
return f.read()
Resources are read-only by convention — the LLM may reference them by URI but does not invoke side effects. Templated URIs let one definition cover many resources.
Prompts
@mcp.prompt()
def summarise(language: str) -> str:
"""Summarise a document in the given language."""
return f"Summarise the attached document in {language}. Keep it under 200 words."
Clients surface prompts as slash commands (/summarise) or quick-action buttons.
Async tools
import httpx
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather")
@mcp.tool()
async def get_weather(city: str) -> str:
"""Return current weather for a city."""
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"https://wttr.in/{city}?format=3")
return r.text
FastMCP runs async tools on its event loop and pushes blocking sync tools to a thread pool.
Context — progress and logs
from mcp.server.fastmcp import Context, FastMCP
mcp = FastMCP("indexer")
@mcp.tool()
async def reindex(directory: str, ctx: Context) -> str:
"""Re-index all files in a directory, reporting progress."""
files = list_files(directory)
for i, f in enumerate(files):
await ctx.info(f"indexing {f}")
await ctx.report_progress(progress=i + 1, total=len(files))
process(f)
return f"Indexed {len(files)} files."
ctx.info / warning / error ship log lines to the client; ctx.report_progress updates the client's progress UI.
TypeScript SDK
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "ts-demo", version: "1.0.0" });
server.registerTool(
"multiply",
{
title: "Multiply",
description: "Multiply two numbers.",
inputSchema: { a: z.number(), b: z.number() },
},
async ({ a, b }) => ({
content: [{ type: "text", text: String(a * b) }],
}),
);
server.registerResource(
"version",
"app://version",
{ mimeType: "text/plain" },
async () => ({ contents: [{ uri: "app://version", text: "1.0.0" }] }),
);
const transport = new StdioServerTransport();
await server.connect(transport);
zod schemas drive the JSON schema the client sends to the LLM. For Streamable HTTP, swap in StreamableHTTPServerTransport.
Go SDK — mcp-go
package main
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func main() {
s := server.NewMCPServer("go-demo", "1.0.0",
server.WithToolCapabilities(true),
)
s.AddTool(mcp.NewTool("multiply",
mcp.WithDescription("Multiply two integers."),
mcp.WithNumber("a", mcp.Required(), mcp.Description("first operand")),
mcp.WithNumber("b", mcp.Required(), mcp.Description("second operand")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
a := req.GetFloat("a", 0)
b := req.GetFloat("b", 0)
return mcp.NewToolResultText(fmt.Sprintf("%g", a*b)), nil
})
server.ServeStdio(s)
}
Build and run:
go build -o go-mcp .
./go-mcp
Output:
(silent — stdio server waiting for client frames)
mcp-go is particularly attractive for embedding a server inside an existing Go service (Kubernetes operator, gRPC backend) without spinning up a Python process.
Building an MCP client
Most engineers will consume MCP servers through existing tools (Claude Code, Cursor, etc.), but building a client is useful for custom agents.
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main():
params = StdioServerParameters(command="python", args=["server.py"])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print([t.name for t in tools.tools])
result = await session.call_tool("add", {"a": 2, "b": 3})
print(result.content[0].text)
asyncio.run(main())
Output:
['add', 'greet']
5
For Streamable HTTP servers, swap stdio_client for streamablehttp_client(url="https://...") from mcp.client.streamable_http.
Auth for remote servers
Streamable HTTP servers accept bearer tokens via the Authorization header. The 2025 specification also defines an OAuth 2.1 flow for end-user delegation.
from mcp.server.fastmcp import FastMCP
from starlette.requests import Request
mcp = FastMCP("authed")
@mcp.tool()
async def whoami(ctx) -> str:
"""Return the authenticated user from the request token."""
request: Request = ctx.request
token = request.headers.get("authorization", "").removeprefix("Bearer ")
return verify_jwt(token).sub
Client side, pass headers when constructing the transport:
from mcp.client.streamable_http import streamablehttp_client
async with streamablehttp_client(
url="https://example.com/mcp",
headers={"Authorization": f"Bearer {token}"},
) as (read, write, _):
...
stdio servers run as the client user with the client's environment — there is no auth boundary. Anything sensitive in env vars (API keys) is visible to the server process.
When to use MCP vs a custom tool server
| Decision | Choose MCP | Choose custom |
|---|---|---|
| Multiple LLM clients will use the tools | yes | no — one client |
| Want tools surfaced as a marketplace listing | yes | no |
| Need fine-grained per-client UI affordances (rich cards, file pickers) | partly — capability evolving | yes, full control |
| Tool latency budget is sub-millisecond | no — JSON-RPC overhead matters | yes |
| Tools are stateless HTTP APIs that LLMs hit via OpenAI tool calling | optional | acceptable |
| Want OAuth-delegated remote tools | yes (Streamable HTTP) | bespoke |
| Need server-pushed notifications / progress | yes | bespoke |
Rule of thumb: if you envision more than one LLM client consuming the tool, write an MCP server. If the tool is internal to a single agent and latency matters, embed it directly as a function in your tool-calling code.
Real-world recipes
Recipe — MCP server in front of a Postgres database
import os
import psycopg
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("postgres")
DSN = os.environ["DATABASE_URL"]
@mcp.tool()
def list_tables() -> list[str]:
"""List public tables in the connected Postgres database."""
with psycopg.connect(DSN) as cn, cn.cursor() as cur:
cur.execute("select table_name from information_schema.tables where table_schema = 'public'")
return [r[0] for r in cur.fetchall()]
@mcp.tool()
def query(sql: str) -> list[dict]:
"""Run a read-only SQL query and return rows as dicts."""
if not sql.lstrip().lower().startswith("select"):
raise ValueError("Only SELECT queries are allowed.")
with psycopg.connect(DSN) as cn, cn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(sql)
return cur.fetchmany(100)
if __name__ == "__main__":
mcp.run()
Add a SQL allow-list and row caps; LLMs that read this server can now answer questions about the database.
Recipe — wrap an existing REST API
import httpx
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("jira")
BASE = os.environ["JIRA_BASE_URL"]
TOKEN = os.environ["JIRA_TOKEN"]
client = httpx.AsyncClient(base_url=BASE, headers={"Authorization": f"Bearer {TOKEN}"})
@mcp.tool()
async def search_issues(jql: str) -> list[dict]:
"""Search Jira issues using JQL."""
r = await client.get("/rest/api/3/search", params={"jql": jql, "maxResults": 20})
return r.json().get("issues", [])
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8001)
Deploy behind your auth layer; point any MCP client at the URL.
Recipe — combine multiple MCP servers in one client
A client agent connects to several servers and exposes their union to the LLM.
from mcp.client.stdio import stdio_client
from mcp import ClientSession, StdioServerParameters
import asyncio
async def gather_tools():
servers = {
"fs": StdioServerParameters(command="python", args=["./fs_server.py"]),
"github": StdioServerParameters(command="python", args=["./gh_server.py"]),
"db": StdioServerParameters(command="python", args=["./db_server.py"]),
}
sessions = {}
all_tools = []
for name, params in servers.items():
read, write = await stdio_client(params).__aenter__()
session = ClientSession(read, write)
await session.__aenter__()
await session.initialize()
tools = (await session.list_tools()).tools
for t in tools:
t.name = f"{name}.{t.name}"
all_tools.extend(tools)
sessions[name] = session
return sessions, all_tools
Namespacing tools by server name (fs.read, github.open_issue) avoids collisions and helps the LLM reason about provenance.
Recipe — typed errors with content blocks
from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent, ToolError
mcp = FastMCP("billing")
@mcp.tool()
def charge(customer_id: str, amount_cents: int) -> list[TextContent]:
"""Charge a customer's saved payment method."""
if amount_cents <= 0:
raise ToolError("amount_cents must be positive")
txn_id = stripe_charge(customer_id, amount_cents)
return [TextContent(type="text", text=f"Charged. Transaction {txn_id}.")]
Throwing ToolError returns an isError: true result the LLM can recover from; uncaught exceptions become protocol errors.
Recipe — local dev with MCP Inspector
pip install "mcp[cli]"
mcp dev server.py
Output:
Inspector running at http://localhost:5173
Server stdio attached.
Open the URL, list tools, invoke them with form inputs, and inspect raw JSON-RPC frames — the fastest dev loop for a new server.
Quick reference
| Task | Code |
|---|---|
| Install Python SDK | pip install "mcp[cli]" |
| Install TS SDK | npm install @modelcontextprotocol/sdk |
| Install Go SDK | go get github.com/mark3labs/mcp-go |
| Create server (Py) | mcp = FastMCP("name") |
| Add tool | @mcp.tool() on a typed function |
| Add resource | @mcp.resource("scheme://path") |
| Add prompt | @mcp.prompt() on a function returning the prompt text |
| Stdio run | mcp.run() or mcp.run(transport="stdio") |
| SSE run | mcp.run(transport="sse", host=..., port=...) |
| Streamable HTTP run | mcp.run(transport="streamable-http", host=..., port=...) |
| Inspector | mcp dev server.py |
| Progress / logs | ctx.report_progress, ctx.info, ctx.warning, ctx.error |
| Errors | raise ToolError("...") |
| Client (stdio) | async with stdio_client(params) as (r, w): ClientSession(r, w) |
| Client (HTTP) | async with streamablehttp_client(url=..., headers=...) as ... |
| List tools | await session.list_tools() |
| Call tool | await session.call_tool(name, args) |
| List resources | await session.list_resources() |
| Read resource | await session.read_resource(uri) |
| Render prompt | await session.get_prompt(name, args) |