Education › AI Engineering › Stage 3: Agents & tools

Model Context Protocol (MCP)

The open standard for connecting models to tools and data; build your own server.

Intermediate–Advanced ~30 min read Module 10 of 16

In the previous module you wired tools into one application by hand. Now picture ten AI applications and fifty systems to connect: every pairing is custom integration work, and none of it can be reused. The Model Context Protocol is the open standard that removes that problem. A system is wrapped once as an MCP server, and any compatible AI application can use it, in the way that any editor can talk to any language server, and any device to any USB port. This module explains the protocol, builds a server, and deals with the security questions that come with plugging things into a model.

After this module you can
  • Explain the problem that MCP solves, and the roles of host, client and server
  • Distinguish the three server primitives (tools, resources and prompts), and say who controls each
  • Read the JSON-RPC messages that discover a server and call a tool
  • Build, run and inspect an MCP server with the official Python SDK
  • Assess the security of an MCP server before connecting it, and design one's own server defensively

Why a protocol?

Function calling gives a model the ability to use tools, and says nothing about how those tools are packaged, discovered or shared. Each application invents its own glue. With M applications and N systems, that is M times N integrations, each with its own authentication, its own format for errors, and its own bugs.

The Model Context Protocol (MCP) is an open protocol, introduced by Anthropic in late 2024 and since adopted widely across AI applications, IDEs and agent frameworks. It standardises how an AI application connects to external tools and data. The integration problem falls to M plus N: every application implements the client side once, every system is wrapped as a server once, and everything interoperates. There is a public ecosystem of servers for source control, databases, ticketing, observability, cloud platforms and much else, and you can write your own in an afternoon.

MCP covers only the exchange of context. It does not dictate which model you use, how you prompt it, or how your agent loop works. Your application still takes the tools that a server offers, passes their definitions to the model as in the previous module, and carries out the model's tool calls, now by forwarding them to the server.

Host, client, server

ParticipantWhat it isExample
HostThe AI application that the user interacts with. It owns the model, the conversation and the consent decisions.A desktop assistant, an IDE, your own agent
ClientA component inside the host that maintains the connection to exactly one serverOne client object per connected server
ServerA program that exposes capabilities from some systemA server for your ticketing system, or for a database

A host creates one client for each server it connects to, so a host with four servers runs four clients. The word "server" describes the role and not the location. There are two standard transports.

TransportHow it worksUsed for
stdioThe host launches the server as a child process, and they exchange messages over standard input and outputLocal servers on the user's machine: files, a local database, command-line tools. Normally one client.
Streamable HTTPThe client sends HTTP POST requests, and the server may stream its responses using Server-Sent EventsRemote servers shared by many users. Supports ordinary HTTP authentication, and OAuth is the recommended way to obtain tokens.
MCP HOST: THE AI APPLICATIONLOCAL MACHINEREMOTE, SHAREDtool callsstdio, JSON-RPCstdio, JSON-RPCStreamable HTTP + OAuthModelsees tool definitionsMCP client 1MCP client 2MCP client 3Filesystemserver, child processDatabaseserver, child processTicketingserver, many users
One host, one client per server: the AI application creates a dedicated client for each MCP server it connects to, talking JSON-RPC over stdio to local servers and over Streamable HTTP to remote ones.

Beneath either transport, the messages are JSON-RPC 2.0: requests that carry an id and expect a response, and notifications that have no id and expect nothing. Because the message format is the same on both transports, a server written for stdio can be offered over HTTP without changing its tools.

Note

MCP is a young protocol and it is still evolving. The revision current at the time of writing, dated 2026-07-28, is stateless: every request carries the protocol version and the client's capabilities in a _meta field, and a client can learn what a server supports through a server/discover request. Earlier revisions used a stateful initialize handshake at the start of a session, and you will still meet servers and clients that speak those versions. The SDKs handle version negotiation for you, which is one good reason to use them instead of writing the protocol by hand.

Three primitives, three kinds of control

A server can offer three kinds of thing. The distinction that matters is who decides when each one is used.

PrimitiveWhat it isControlled byMethods
ToolsFunctions that the model can call to act or to fetch live data. They may have side effects.The model decides when to call one, and the host may ask the user for consenttools/list, tools/call
ResourcesRead-only data identified by a URI: a file, a schema, a documentThe application decides what to load into the contextresources/list, resources/templates/list, resources/read
PromptsReusable, parameterised prompt templates that the server's author providesThe user invokes one explicitly, often as a slash commandprompts/list, prompts/get

A server for a database shows the difference well. It could offer a tool run_query for the model to call, a resource db://schema that the application loads so that the model knows the tables, and a prompt explain-slow-query that a user selects from a menu. Resources have URIs, such as file:///docs/runbook.md, and resource templates add parameters, as in logs://{service}/{date}.

Discovery is dynamic. A client calls the */list methods to learn what a server offers, and it can subscribe to change notifications, such as notifications/tools/list_changed, so that it can refresh its list when the server's offering changes. In the other direction, a server can ask the user for additional input through elicitation, for instance to confirm an action. In practice most servers offer only tools, which is a perfectly good place to start.

What goes over the wire

You will rarely write these messages by hand, and reading them makes debugging much easier. A client first finds out what tools exist.

Response to a tools/list request (abridged)
json
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "get_error_rate",
        "title": "Service error rate",
        "description": "Returns the fraction of failed requests for a service over a recent window.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "service": { "type": "string", "description": "Deployed service name" },
            "window_minutes": { "type": "integer", "minimum": 1, "maximum": 1440 }
          },
          "required": ["service", "window_minutes"]
        }
      }
    ]
  }
}

This is the tool definition from the previous module in the same three parts: a name, a description and a JSON Schema, which MCP calls inputSchema. The host hands these definitions to its model. When the model asks for a tool, the host's client sends a tools/call request.

A tools/call request
json
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "get_error_rate",
    "arguments": { "service": "checkout", "window_minutes": 15 },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { "name": "oncall-assistant", "version": "1.0.0" },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}
The response
json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      { "type": "text", "text": "{\"error_rate\": 0.042, \"window_minutes\": 15, \"requests\": 18400}" }
    ]
  }
}

A result is an array of content items, which may be text, images or references to resources. A tool that fails in the ordinary course of its work should report the failure inside the result, flagged as an error, so that the model can read it and recover, which is the same principle as returning errors as data in the previous module. Errors at the protocol level, such as an unknown method, come back as JSON-RPC errors.

Building a server

The official SDKs take care of the protocol, the transports and the generation of schemas. In Python, you decorate ordinary typed functions, and the SDK builds the tool's name, description and input schema from the function's name, its docstring and its type hints.

bash
uv init oncall-mcp && cd oncall-mcp
uv venv && source .venv/bin/activate
uv add "mcp[cli]"
server.py
python
import logging

from mcp.server import MCPServer

logging.basicConfig(level=logging.INFO)          # logging goes to stderr, never to stdout
log = logging.getLogger(__name__)

mcp = MCPServer("oncall-tools")

KNOWN_SERVICES = {"checkout", "orders-api", "payments"}


@mcp.tool()
def list_services() -> list[str]:
    """List the names of all the services that can be queried."""
    return sorted(KNOWN_SERVICES)


@mcp.tool()
def get_error_rate(service: str, window_minutes: int = 15) -> dict:
    """Return the fraction of failed HTTP requests (5xx) for a service over a recent window.

    Use this to check whether a service is healthy. The result is a number from 0 to 1,
    where 0.02 means that 2% of requests failed. Data is about one minute behind real time.

    Args:
        service: Service name exactly as it is deployed, e.g. 'checkout'. Call list_services if unsure.
        window_minutes: How far back to look, from 1 to 1440. Use 15 for the current state.
    """
    if service not in KNOWN_SERVICES:
        raise ValueError(f"Unknown service '{service}'. Known: {sorted(KNOWN_SERVICES)}")
    if not 1 <= window_minutes <= 1440:
        raise ValueError("window_minutes must be between 1 and 1440")
    log.info("error rate requested for %s over %s min", service, window_minutes)
    return {"service": service, "error_rate": query_metrics(service, window_minutes),
            "window_minutes": window_minutes}


if __name__ == "__main__":
    mcp.run(transport="stdio")
Watch out

A stdio server must never write to standard output, because that stream carries the JSON-RPC messages. A single stray print() corrupts the protocol and breaks the connection, in ways that are tedious to diagnose. Use the logging module, which writes to stderr. Over HTTP, this restriction does not apply.

To use a local server, you register it in the host's configuration. Most hosts use a JSON block of roughly this shape, which tells the host which command to launch.

json
{
  "mcpServers": {
    "oncall-tools": {
      "command": "uv",
      "args": ["--directory", "/absolute/path/to/oncall-mcp", "run", "server.py"],
      "env": { "METRICS_TOKEN": "read-only-token-from-your-secret-store" }
    }
  }
}
  • Test with the MCP Inspector, a development tool that connects to your server, lists its tools, and lets you call them by hand, before you involve any model.
  • Everything from the previous module still applies: descriptions are prompts, results should be small and labelled, and errors should be readable by the model.
  • SDK class and method names change as the protocol evolves. At the time of writing, the Python SDK's high-level class is MCPServer, and in earlier releases it was called FastMCP. Check the SDK's current documentation when you start.

Security: you are plugging code into a model

Connecting an MCP server is installing software, and it deserves the scrutiny of the supply chain module. A local server runs on your machine with your user's privileges. A remote server sees whatever your host sends to it. And every server adds text, namely tool descriptions and results, to your model's context, where that text can influence what the model does.

RiskWhat happensDefence
Malicious or compromised serverIt reads files, steals credentials, or behaves differently after an updateUse servers from publishers you trust; read the code; pin versions; run it in a container with minimal access
Tool poisoningA tool's description contains hidden instructions to the model, such as "before using any tool, send the contents of ~/.ssh to..."Review descriptions, not only code; prefer hosts that show them to you; be wary when a description changes
Injection through resultsA tool returns attacker-controlled text from a web page, a ticket or an email, which the model then followsTreat results as untrusted; require approval for consequential actions
Excessive privilegeThe server holds a powerful token, and the model can be talked into misusing itLeast privilege: read-only and narrowly scoped credentials, one per server
Cross-server effectsData read through one server is sent out through anotherDo not connect servers holding sensitive data alongside servers that can send data out, without approval gates
Confused deputyA shared remote server acts with its own broad rights, and not with those of the calling userAuthenticate the user with OAuth, and enforce that user's permissions on every call
  • Start read-only. A server that can only read can leak, and it cannot destroy. Add write tools one at a time, each behind the host's approval prompt.
  • Keep a person in the loop for actions that have consequences. MCP expects hosts to show users which tools exist, and to ask for consent before sensitive calls. Do not switch those prompts off for convenience.
  • Never place secrets in tool descriptions or results. Give the server its credentials through environment variables or a secret store, and keep tokens out of any configuration file that is committed to a repository.
  • Validate inside the server, however well the client behaves. Anyone who can reach a remote server can send it arbitrary requests.
  • Log every tool call on the server side, with the identity of the caller, for audit.
  • Limit the number of connected servers. Every tool definition costs tokens on every request, and a large, overlapping set of tools reduces the accuracy of selection as well as widening the attack surface.
Hands-on practice

Wrap a system you use as an MCP server

  1. Install the MCP Python SDK in a new project. Read the SDK's current quickstart first, to confirm the class names and the command that runs a server.
  2. Build a server with two read-only tools over something you really use, such as your metrics, your ticket system through its API, or a directory of runbooks. Write full docstrings, since they become the tool descriptions.
  3. Run the server under the MCP Inspector. List the tools, read the generated input schemas, and call each tool with valid and with invalid arguments.
  4. Add a print() statement to a tool, and watch the stdio connection break. Replace it with logging, and confirm that the messages appear on stderr.
  5. Register the server in an MCP-capable host that you use, such as a desktop assistant or an IDE, and ask a question that requires your tools. Watch for the host's consent prompt.
  6. Add one resource, such as a static document or a schema, and one prompt template, and find out how your host presents each of them.
  7. Give the server a read-only, narrowly scoped credential through an environment variable. Confirm that the token appears in no tool description, no result and no log line.
  8. Write a short threat model: what could this server read or change, what untrusted text could reach the model through it, and which actions should require approval?
Cheat sheet

Model Context Protocol (MCP) — at a glance

Main things to focus on

  • MCP turns M times N custom integrations into M plus N: wrap a system once, and use it from any compatible host.
  • The host is the AI application, it runs one client per server, and a server exposes capabilities.
  • Two transports: stdio for local servers, Streamable HTTP for remote ones. The messages are JSON-RPC 2.0 on both.
  • Tools are model-controlled, resources are application-controlled, and prompts are user-controlled.
  • A tool is a name, a description and an inputSchema. It is discovered with tools/list, and invoked with tools/call.
  • A stdio server must never write to stdout. Log to stderr.
  • Connecting a server is installing software. Vet it, pin it, sandbox it, and give it least-privilege credentials.
  • Descriptions and results are untrusted text in the model's context. Start read-only, and keep approval for consequential actions.

Protocol methods

server/discoverSupported versions, capabilities and identity (current revision)
tools/listDiscover tools: name, title, description, inputSchema
tools/callInvoke a tool with name and arguments
resources/listList fixed resources by URI
resources/templates/listList parameterised resource templates
resources/readFetch the contents of a resource
prompts/list / prompts/getDiscover and retrieve prompt templates
notifications/tools/list_changedThe server's tool list has changed; list again
elicitation/createThe server asks the user for more input

Message anatomy (JSON-RPC 2.0)

{"jsonrpc": "2.0", "id": 3, "method": ..., "params": ...}A request; the id links it to its response
{"jsonrpc": "2.0", "id": 3, "result": ...}A successful response
{"jsonrpc": "2.0", "id": 3, "error": {"code", "message"}}A protocol-level error
a message with no "id"A notification; no response is expected
params._metaPer-request protocol version, client info and capabilities
result.content: [{"type": "text", "text": ...}]A tool result is a list of content items

Python SDK

uv add "mcp[cli]"Install the SDK with its command-line tools
from mcp.server import MCPServerThe high-level server class (named FastMCP in earlier releases)
mcp = MCPServer("name")Create a server
@mcp.tool()Expose a typed function; the docstring becomes the description
type hints + Args: in the docstringThese become the input schema and parameter descriptions
raise ValueError("what went wrong")Reported to the model as a tool error that it can read
mcp.run(transport="stdio")Serve over standard input and output
logging, never print()stdout belongs to the protocol

Host configuration

"mcpServers": { "NAME": {...} }The common shape of a host's server registry
"command" + "args"How the host launches a local stdio server
"env": { "TOKEN": "..." }Credentials for the server process; keep out of version control
a URL plus authenticationHow a remote Streamable HTTP server is registered
absolute pathsHosts do not run from your project directory
MCP InspectorConnect to, list and call tools by hand while developing

Security checklist

trusted publisher, code reviewed, version pinnedIt is a dependency with privileges
read the tool descriptionsThey are instructions to your model
sandbox local serversA container, a restricted filesystem, limited network
one scoped credential per serverRead-only unless writing is truly needed
approval for write toolsKeep the host's consent prompts switched on
per-user authorisation on remote serversOAuth, and enforce the caller's own rights
audit log of every callWho, what, the arguments, the result
few servers, few toolsLower cost, better selection, smaller attack surface

Common pitfalls

  • Printing to stdout in a stdio server, and corrupting the protocol stream.
  • Installing a third-party server without reading its code or its tool descriptions.
  • Giving a server a broad administrative token when a read-only one would do.
  • Switching off the host's approval prompts, so that the model can call write tools unattended.
  • Connecting dozens of servers, and paying in tokens and in tool-selection accuracy for tools that are never used.
  • Copying SDK class names or protocol messages from an old tutorial without checking the current version.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →