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.

PrimitiveControlled byWhat it is
ToolsThe modelFunctions with a JSON schema that the model may call
ResourcesThe application or userReadable data addressed by URI, from a file to a database row
PromptsThe userParameterised templates surfaced as commands
SamplingThe server, via the clientA server asking the host model to generate something
RootsThe clientThe 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.

Agent frameworks compared Agent security and permissions

Last refreshed 2026-09-18.