Host, client, server
The protocol is JSON-RPC. The spec splits a data layer (tools, resources, prompts, discovery) from a transport layer (stdio or Streamable HTTP). A host such as VS Code creates a new client object for each server: filesystem, database, a remote service. Local stdio servers usually serve one client. Remote HTTP servers serve many. The server never calls a model. You can test it with no API key.
Starter
print("Host: Cursor, Claude, or your own app")
print("Client: one connection")
print("Server: your Python program, no model inside")
Example
“Weather in New York” cannot be answered from training. The client fetches the tool list, the model picks get_weather, and the server runs your function.
Anthropic introduced MCP in November 2024. The messages are JSON-RPC. Three roles:
| Role | Who it is | Example |
|---|---|---|
| Host | The AI app the person uses | Claude Desktop, Cursor, your own chatbot |
| Client | The connection the host opens to one server | One client per server |
| Server | The process that offers tools and data | A small Python program for your shop’s orders |
Ask “what’s the weather in New York?”. The model cannot know today’s weather from training. The client fetches the server’s tool list, the model picks get_weather, and the call goes to the server. The server does not call a model. It runs your function and returns text. You can build and test the server with no API key.
A server for one shop
FastMCP turns that function into a tool. The name, description, and JSON Schema come from the function and the type hints. Write the server once and attach it to Claude, Cursor, and your own agent. Return a short string. A 2 MB blob blows the model’s context. Log the rest on the server.
Starter
ORDERS = {"A17": "shipped on 3 March"}
def lookup_order(order_id: str) -> str:
"""Look up shipping status by order id, such as A17."""
return ORDERS.get(order_id, "unknown order")
print(lookup_order("A17"))
Example
The same order server can sit in a support desktop app and in an engineer’s editor. The second app does not get a custom plugin.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("shop")
ORDERS = {"A17": "shipped on 3 March", "C40": "waiting for stock"}
@mcp.tool()
def lookup_order(order_id: str) -> str:
"""Look up an order's shipping status by its id, such as A17."""
status = ORDERS.get(order_id)
return f"{order_id} {status}." if status else f"{order_id} is not an order we have."
if __name__ == "__main__":
mcp.run() # speaks MCP on stdin/stdout
The SDK builds the tool name, description, and JSON Schema from the function and its type hints. A docstring is not optional polish. It is what the model reads when it decides whether to call the tool.
Write once, connect everywhere
- The same order server in Claude Desktop for support, and in Cursor for an engineer reproducing a bug.
- A Postgres server the data team already runs, attached to a new internal agent without a custom plugin.
- A GitHub or filesystem server so a coding agent reads issues through MCP instead of a one-off shell wrapper.
Tools, resources, and prompts
Memory is not the server’s job. Calls are stateless. The host keeps the conversation. Do not hide a customer’s history in a global on the server and expect the next host to see it. Dangerous tools should require a person to approve them inside the host.
Starter
tools = ["lookup_order"]
resources = ["policy://returns"]
prompts = ["refund_reply"]
print({"tools": tools, "resources": resources, "prompts": prompts})
Example
A returns policy is a resource. “Draft a refund reply” is a prompt. lookup_order is a tool. create_refund is a tool that should ask first.
- Tools are functions the model calls.
lookup_order,create_refund. - Resources are data the app can read, like a file or a policy page, addressed by a URI. The model does not have to “call” them as actions; the host attaches them.
- Prompts are templates a user can pick, the way slash commands work. “Draft a refund reply” with holes for the order id.
@mcp.resource("policy://returns")
def returns_policy() -> str:
return "30 days, unused, with receipt."
@mcp.prompt()
def refund_reply(order_id: str) -> str:
return f"Write a short refund update for order {order_id}. Use only tool results."
stdio locally, HTTP when it is remote
That config is what a desktop host uses for a local server. A remote server must authenticate, or a refund tool on the internet is an open refund button. Keep API keys in the server environment, not in the chat.
Starter
config = {
"mcpServers": {
"shop": {"command": "python", "args": ["server.py"]}
}
}
print(config["mcpServers"]["shop"]["command"])
Example
An internal REST “get order” endpoint becomes one MCP tool. Every agent sees a name and a schema. The key stays on the server.
A local server is a process the host starts. Messages go over standard input and output. That is what mcp.run() does, and it is what a Claude or Cursor config points at:
{
"mcpServers": {
"shop": {
"command": "python",
"args": ["server.py"]
}
}
}
A remote server speaks Streamable HTTP so many people can use one deployment. Put auth on that endpoint. A tool that refunds an order, exposed on the internet with no identity check, is an open refund button.
Use case: an internal API
You already have REST for “get order”. Wrap it in one MCP tool instead of pasting the URL into every agent prompt. The model sees a name and a schema. The API key stays in the server’s environment, not in the chat.
Your own agent as the host
Test with an in-memory client first. You exercise the real protocol without a port and without a model. When A17 returns the shipped sentence, then attach a model. LangChain and the OpenAI Agents SDK both have adapters so you do not hand-roll JSON-RPC. Approval still belongs on refunds, deletes, and email.
Starter
tools = {"lookup_order": lambda order_id: "A17 shipped" if order_id == "A17" else "unknown"}
print(tools["lookup_order"]("A17"))
Example
A green test that lookup_order("A17") returns the ledger sentence is the whole server. The model is a later guest.
After the server works, a host can list tools and loop: send the question and the tool list to a model, run the tool the model picked, send the result back, stop when the model answers in text. LangChain and the OpenAI Agents SDK both have MCP adapters so you do not hand-roll JSON-RPC.
Test the server with an in-memory client first. You exercise the real protocol without a port and without a model. When lookup of A17 returns the shipped sentence, then attach a model.
create_refund. Do that for anything that spends money, deletes data, or sends mail.When plain Python is enough
Adopt MCP when Claude, Cursor, and a bot you wrote all need the same tool. Keep results small. Put auth on HTTP. Do not store conversation memory on the server.
Starter
def lookup_order(order_id: str) -> str:
return "shipped"
print("call lookup_order directly until a second host needs it")
Example
A helper used by one script stays a function. The order lookup your whole company will attach to editors becomes a server.
If only your own process will ever call the function, an MCP server is extra moving parts. Use a normal function. Adopt MCP when a second app, or a second team, needs the same tool.
Keep tool results small. Returning a 2 MB JSON blob blows the model’s context. Return the one order, or a short error. Log the rest on the server.
Practice
- Add
list_recent_orders()with a docstring that says it returns at most 5 ids. - Add a resource for the shipping policy and a prompt named
where_is_my_order. - Decide which tool needs a human approval, and write the one-line reason.
- Explain why the server file has no API key for the LLM.