MCP — Model Context Protocol

The open protocol that turns the M×N tool-integration problem into M+N: how MCP standardizes exposing tools, resources, and prompts to any LLM client. Build a server, consume it from a client, and learn when an MCP server beats a plain in-process function tool.

~3hmcpagentstool-use
Learning objectives
  • Explain the problem MCP solves: turning M×N custom tool integrations into M+N reusable connectors.
  • Name MCP's three server primitives — tools, resources, prompts — and the host/client/server architecture.
  • Build a minimal MCP server that exposes a tool and a resource.
  • Consume an MCP server from a client, and judge when to build a server vs. a plain in-process function tool.
Note

Python for JavaScript developers:

  • A decorator @mcp.tool() — wraps a function to register it, like an annotation; the function stays a normal function.
  • A docstring (the """…""" under a def) becomes the tool's description the model reads.
  • async def / await — same as JS async/await; MCP clients are async.
  • JSON-RPC — a simple "call this method with these params, get a result" message format over a transport (stdio or HTTP).

Every code block is commented line-by-line.

The problem: M×N integrations

In Topic 2 you gave an agent tools by writing function schemas inside your app. That works, but every tool is bespoke to that app. Now multiply: M AI apps (Claude Desktop, your agent, an IDE, a chatbot) each want to talk to N systems (GitHub, Postgres, Slack, your CRM). Without a standard, that's M×N custom integrations — every app re-implements every connector.

MCP (Model Context Protocol) — an open standard introduced by Anthropic — collapses that to M+N. Each system is wrapped once as an MCP server; each app is an MCP client once. Any client can then use any server. It's "USB-C for AI tools": one protocol, universal connectors. (This is the same protocol behind the ghostcrab, Gmail, and browser tools you use in this very environment.)

Architecture: host, client, server

Three roles:

  • Host — the AI application the user interacts with (Claude Desktop, an IDE, your agent). It embeds one or more clients.
  • Client — lives inside the host; maintains a 1:1 connection to one server and speaks the protocol.
  • Server — a separate process exposing capabilities (tools/resources/prompts) for a specific system.

They communicate over JSON-RPC on a transport: stdio (the server is a local subprocess — most common for local tools) or HTTP (for remote/networked servers). The host's LLM never talks to servers directly; the client mediates every call.

The three server primitives

An MCP server can expose three kinds of capability — this taxonomy is the heart of the protocol:

PrimitiveWhat it isWho controls itAnalogy
Toolsactions the model can invoke (functions with side effects)model-driven (the LLM decides to call)POST endpoints
Resourcesread-only data the client can load into contextapp-driven (host decides what to load)GET endpoints / files
Promptsreusable prompt templates the user can invokeuser-driven (e.g. a slash command)saved snippets

The distinction matters: tools are for doing (and the model chooses them), resources are for reading (the app supplies them as context), prompts are user-triggered workflows. Most servers start with tools.

Building a server

The Python SDK's FastMCP makes a server almost as simple as writing functions — decorate them and it generates the schemas and protocol plumbing (the schema inference is the same idea as Topic 2, automated):

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather")                       # create a server named "weather"

@mcp.tool()                                     # register a TOOL (a model-invocable action)
def get_forecast(city: str) -> str:
    """Get today's weather forecast for a city."""   # docstring -> the tool description the model reads
    return f"Sunny, 21°C in {city}"             # (a real server would call a weather API)

@mcp.resource("config://units")                 # register a RESOURCE at a URI (read-only context)
def units() -> str:
    """The unit system this server reports in."""
    return "metric (Celsius)"                   # the host can load this into the model's context

if __name__ == "__main__":
    mcp.run()                                    # start the server over stdio (default transport)

The @mcp.tool() decorator inspects the function's signature and docstring to build the JSON schema automatically — the same tool-schema idea from Topic 2, but now the tool lives in a reusable server any MCP client can connect to, not hard-wired into one app.

Tip

Exercise 1 has you build this server with a tool, and Exercise 2 has you add a resource and see how a read-only resource differs from a model-invoked tool.

Consuming a server from a client

A client connects to the server over a transport, lists what it offers, and calls it. Here's the shape with the Python client SDK talking to a stdio server:

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

params = StdioServerParameters(command="python", args=["weather_server.py"])  # how to launch the server process

async def main():
    async with stdio_client(params) as (read, write):        # spawn the server, get read/write streams
        async with ClientSession(read, write) as session:    # a protocol session over those streams
            await session.initialize()                        # handshake (exchange capabilities/versions)

            tools = await session.list_tools()                # discover what the server exposes
            print([t.name for t in tools.tools])              # -> ['get_forecast']

            result = await session.call_tool("get_forecast", {"city": "Paris"})   # invoke a tool by name + args
            print(result.content)                             # -> "Sunny, 21°C in Paris"

In practice you rarely write this by hand — hosts like Claude Desktop, and agent frameworks (LangChain, LangGraph adapters), consume MCP servers for you from a config file. But knowing the initialize → list → call handshake demystifies what's happening under the hood.

Tip

Exercise 3 has you connect a client to your Exercise-1 server, list its tools, and call one — seeing the full server↔client round trip.

When to build an MCP server vs. a plain tool

MCP is powerful, but it's not free — a server is a separate process, protocol, and deployment. Use the right tool:

  • Plain in-process function tool (Topic 2) when the tool is used by one app, is simple, and shares the app's process/lifecycle. No protocol overhead.
  • MCP server when the capability should be reused across multiple apps/hosts, needs to run as a separate process (different language, isolation, independent deployment), or you want to plug into the MCP ecosystem (Claude Desktop, IDEs, other agents) without bespoke glue.

The heuristic: one app, one use → function tool; a connector many clients should share → MCP server. The whole point of MCP is reuse and interoperability; if there's nothing to reuse and no other client, a plain function is simpler.

Tip

Exercise 4 has you decide, for four capabilities, whether each should be an MCP server or a plain function tool — the build-vs-inline judgment.

Pitfalls

  • Reaching for MCP when a function will do. For a single app's single tool, a plain function tool is simpler — MCP's value is cross-app reuse.
  • Confusing tools and resources. Tools are model-invoked actions (side effects); resources are app-loaded read-only context. Modeling a data read as a tool (or vice versa) muddies the design.
  • Trusting a server blindly. A server runs code and can act on systems — vet third-party servers as you would any dependency, and scope its permissions (Security track).
  • Forgetting the transport choice. stdio for local subprocess tools; HTTP for remote/networked servers — picking wrong complicates deployment.
  • Weak tool docstrings. As always, the description drives selection — write server tool docstrings carefully.

Recap

  • MCP is an open protocol that turns M×N bespoke integrations into M+N reusable connectors — "USB-C for AI tools."
  • Architecture: a host embeds clients, each connected 1:1 to a server, over JSON-RPC on stdio or HTTP.
  • Servers expose three primitives: tools (model-invoked actions), resources (app-loaded read-only context), prompts (user-invoked templates).
  • Build a server by decorating functions (@mcp.tool(), @mcp.resource()); consume it via the initialize → list → call handshake (usually done for you by the host).
  • Choose MCP when a connector should be reused across apps or run as a separate process; a plain function tool when it's one app's single use.
  • Worked examples map to the exercises: a server tool (Ex 1), a resource (Ex 2), a client round trip (Ex 3), and the server-vs-function decision (Ex 4).

MCP — Model Context Protocol — Exercises

Install the SDK: pip install mcp. You'll write a small server and a client that talks to it. APIs evolve — check the current MCP Python SDK docs if an import differs; the primitives and handshake are stable.


Exercise 1 — A minimal MCP server with a tool

Write a FastMCP server named notes exposing a search_notes(query) tool (back it with a dict for now). Run it over stdio.

Reference solution
# notes_server.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("notes")
NOTES = {"pricing": "Pro plan is €49/month.", "security": "Encrypted at rest with AES-256."}

@mcp.tool()
def search_notes(query: str) -> str:
    """Search the notes store and return the best matching note."""
    return NOTES.get(query.lower().strip(), "No matching note.")

if __name__ == "__main__":
    mcp.run()   # stdio transport by default

The @mcp.tool() decorator reads the signature and docstring to auto-generate the tool schema — the same schema you wrote by hand in the Tool Use topic, now inferred and exposed over the protocol so any MCP client can call it.


Exercise 2 — Add a resource

Add a read-only resource to the server (e.g. notes://index returning the list of note titles). Note how a resource is app-loaded context, not a model-invoked action.

Reference solution
@mcp.resource("notes://index")
def note_index() -> str:
    """The list of available note titles."""
    return ", ".join(NOTES.keys())     # "pricing, security"

A tool (search_notes) is something the model decides to call to take an action. A resource (notes://index) is data the host loads into context — it's addressed by a URI and read, not invoked with arguments by the model. Same server, two different interaction models: doing vs. reading.


Exercise 3 — Consume the server from a client

Write a client that launches notes_server.py, runs the initialize handshake, lists the tools, and calls search_notes("pricing").

Reference solution
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

params = StdioServerParameters(command="python", args=["notes_server.py"])

async def main():
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()                          # handshake
            tools = await session.list_tools()
            print("tools:", [t.name for t in tools.tools])      # ['search_notes']
            res = await session.call_tool("search_notes", {"query": "pricing"})
            print("result:", res.content)                       # Pro plan is €49/month.

asyncio.run(main())

The initialize → list_tools → call_tool sequence is the whole protocol in miniature. In real use a host (Claude Desktop, an agent framework) does this from a config file — but now you know what it's doing under the hood, and why any client can reuse this same server.


Exercise 4 — MCP server or plain function tool?

For each capability, decide MCP server or plain in-process function tool, and justify in one line.

  1. A one-off word_count(text) helper used only inside your single agent.
  2. A GitHub connector you want usable from Claude Desktop, your IDE, and two different agents.
  3. A Postgres query tool written in Python that your Node.js chatbot also needs to call.
  4. A tiny date-formatting helper used in one prompt-building step.
Reference solution
  1. Function tool. One app, one trivial use, shares the process — MCP's protocol overhead buys nothing.
  2. MCP server. The entire point: wrap GitHub once, reuse across many hosts/clients without bespoke glue.
  3. MCP server. Cross-language reuse (Python capability, Node client) is exactly what a separate-process protocol server enables.
  4. Function tool. Inline, single-use, no cross-app reuse — a plain function is simpler.

The heuristic: one app / one use → function tool; a connector many clients should share (or a separate process / language) → MCP server. MCP's value is reuse and interoperability; without those, inline is simpler.