Model Context Protocol in practice
What MCP standardises, how to run a server, exposing tools and resources, transport choices, and how to reuse one server across many clients.
The integration problem MCP solves
Before a shared protocol, connecting N agent clients to M tool integrations needed N times M adapters, each with its own schema dialect and its own authentication story. MCP defines one JSON-RPC contract, so a server written once is usable by every client that speaks it.
| Primitive | Controlled by | What it is |
|---|---|---|
| Tools | The model | Functions with a JSON schema that the model may call |
| Resources | The application or user | Readable data addressed by URI, from a file to a database row |
| Prompts | The user | Parameterised templates surfaced as commands |
| Sampling | The server, via the client | A server asking the host model to generate something |
| Roots | The client | The filesystem or workspace boundaries a server may operate in |
The distinction that matters operationally: tools are a model capability and can be invoked without a human deciding to, while resources and prompts are pulled in by the application or the person. Different trust levels, different review requirements.
Running a server
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("orders")
@mcp.tool()
def lookup_order(order_id: str) -> dict:
"""Find one order by id. Use before answering anything about status."""
return db.orders.find_one({"_id": order_id})
@mcp.tool()
def cancel_order(order_id: str, reason: str) -> dict:
"""Cancel an order. Irreversible: requires prior approval."""
if not approval_granted(order_id):
return {"error": "approval required"}
return service.cancel(order_id, reason)
@mcp.resource("orders://{order_id}/history")
def order_history(order_id: str) -> str:
"""Order events, read-only, addressed by URI."""
return render_history(order_id)
if __name__ == "__main__":
mcp.run() # stdio transport by default# stdio: the client spawns the server as a child process
python -m orders_server
# streamable HTTP: the server runs independently, possibly remotely
python -m orders_server --transport streamable-http --port 8931
curl -s http://localhost:8931/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'{
"mcpServers": {
"orders": { "command": "python", "args": ["-m", "orders_server"] },
"docs": { "url": "https://mcp.internal.example.com/docs" }
}
}- stdio is the right default for local servers: no ports, no auth, lifetime tied to the client.
- Streamable HTTP is for shared servers, remote deployment and multi-user access. It brings back every question about authentication, tenancy and rate limits.
- A server exposing twenty tools is a usability problem for the model, not a feature. Expose the few that belong to one domain.
- Descriptions are the model's only guide. Write the tool docstring as carefully as you would a prompt, because it is one.
Operating MCP safely
- A local MCP server runs as your user with your filesystem and your environment variables. Installing one is closer to installing a browser extension than to adding a library.
- A remote server is a third party that will return text your model then treats as context. Assume that text is hostile and keep authorisation decisions in your own code.
- Scope credentials per server: a docs server needs read access to docs and nothing else.
- Pin versions and record which server version produced which tool result in your traces, so a behaviour change is attributable.
- Keep an allow-list of servers and tools per environment. An unvetted server in production is an unvetted dependency with write access.
ALLOWED = {"orders": {"lookup_order"}, "docs": {"search_docs"}}
WRITE_TOOLS = {"cancel_order", "refund_order"}
def dispatch(server: str, tool: str, args: dict):
if tool not in ALLOWED.get(server, set()):
return {"error": "tool not permitted in this environment"}
if tool in WRITE_TOOLS and not current_run().approval_token:
return {"error": "approval required"}
return clients[server].call_tool(tool, args)⚠️
MCP makes tools portable, and it makes a compromised or careless tool portable too. Anything a server returns is untrusted input flowing into your agent's context, which is exactly the prompt-injection surface. Treat server output as data, never as instructions, and never as authorisation.
FAQ
When is MCP worth the extra process?
When the same tool must serve more than one client, or when you want to install a capability without writing code. For one agent talking to one internal API, a plain function is simpler and has fewer moving parts.
How do I debug a server the client will not load?
Run the server manually and speak JSON-RPC to it:
tools/list then tools/call. If that works, the problem is the client configuration, the command path or the working directory. Most failures are a relative path that only resolves in your shell.Related
Agent frameworks compared Agent security and permissions
Last refreshed 2026-09-18.