AI EngineeringZero to ProductionHome·About·Contact
Specialized Topics · Part T3

Model Context Protocol (MCP)

You built tools by hand in Ch 4 — every app re-implementing its own. MCP is the open standard that fixes that: a universal way to plug tools, data, and prompts into any LLM app (it's what powers Claude Code's own tools). This part explains what MCP is, its client/server architecture, its three primitives, and how to build a server — connecting straight back to the tool-use you already know.

⏱️ ~1.5 hours🧪 3 labs🎯 Beginner→Tech-lead

Learning objectives

  • Explain MCP's role in the AI tooling ecosystem.
  • Distinguish MCP from function-calling and plugins.
  • Understand the client-server architecture and discovery.
  • Reason about adopting MCP in an organization.
▶ Runnable companionEvery code block in this lesson is also a standalone file under code/xt3-mcp/ in the course, with a README on how to run it. Read here, run there — no need to copy-paste.

1 · Why a protocol at all essential

Before MCP, every AI app wrote custom integrations for each tool and data source — N apps × M tools = N×M bespoke connectors. A protocol collapses that: build a tool once as an MCP server (M servers), and any client speaks to all of them (N clients). N+M, not N×M.

N clients apps/agents MCP standard the contract M servers tools/data
🗺️ How to read this diagram

This picture is the one-sentence case for MCP. MCP (Model Context Protocol) is just an agreed-upon standard — a shared set of rules — for how an AI app can get tools and data from an outside program. The diagram shows what that standard buys you. Read it left to right.

  • The left box, "N clients", is your AI apps or agents — the programs that want to use tools (for example, Claude Code, a chatbot, your own script).
  • The right box, "M servers", is the tools and data those apps want to reach — a file system, a database, GitHub, a ticketing system. In MCP, each of these is wrapped in a small program called an MCP server.
  • The middle box, "MCP standard — the contract", is the shared language both sides speak. Because every client and every server follows the same contract, any client can talk to any server without custom glue code.
  • The arrows show the direction of connection: a client connects through the standard to a server. Without the standard you'd need a separate wire from every app to every tool (N×M of them); with it, each side just implements the contract once (N+M).

In short: If you have 5 apps and 8 tools, doing it by hand means 5×8 = 40 separate integrations to build and maintain. With one shared standard it's 5+8 = 13. The next code block proves that with a tiny function.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · the N×M vs N+M math (runs)
nxm.pydef integrations(n_clients, m_tools, with_protocol):
    return (n_clients + m_tools) if with_protocol else (n_clients * m_tools)

print("5 apps x 8 tools, custom:  ", integrations(5, 8, False), "integrations to build/maintain")
print("5 apps x 8 tools, via MCP: ", integrations(5, 8, True), "(each side implements the standard once)")
5 apps x 8 tools, custom:   40 integrations to build/maintain
5 apps x 8 tools, via MCP:  13 (each side implements the standard once)
▶ How this works

This tiny program turns the diagram's claim into a number you can check. It counts how many integrations you have to build in two worlds: the old custom way, and the MCP way. The whole point is to feel why a shared standard scales better.

  1. The function integrations(n_clients, m_tools, with_protocol) takes how many apps (n_clients), how many tools (m_tools), and a true/false flag for whether a protocol is used.
  2. The one-line body says it all: with a protocol you return n_clients + m_tools (each side wires up to the standard once — that's the N+M from the diagram). Without one you return n_clients * m_tools (every app needs its own connector to every tool — the N×M explosion).
  3. The two print lines call the function for 5 apps and 8 tools — once with False (custom) and once with True (via MCP) — and label each number so the difference is obvious.

What the output means: You get two lines: the custom approach needs 40 integrations, MCP needs 13. Same apps, same tools — the standard replaces multiplication with addition. That gap only grows as you add more of either side.

Try this: Change the numbers to integrations(20, 20, False) vs (20, 20, True). Custom jumps to 400 while MCP is just 40 — this is the scaling argument for a protocol, made concrete.

2 · MCP vs function-calling vs plugins essential

ApproachScopeReusable across clients?
Function callingone app's tools, in-codeno — app-specific
Vendor pluginsone vendor's ecosystemonly that vendor
MCPany client ↔ any serveryes — open standard

3 · Intermediate — the architecture intermediate

MCP is client-server. The host app (Claude Code) runs an MCP client that connects to one or more servers. On connect, the client discovers each server's tools/resources/prompts, then routes the model's calls to the right server.

4 · Advanced — capability discovery advanced

Discovery is what makes MCP composable: a client asks each server "what can you do?" and merges the capabilities. Model it.

Python · merge capabilities from multiple servers (runs)
discovery.pydef discover(servers):
    """servers: {name: {tools:[...], resources:[...]}}. Merge into one capability map."""
    tools, resources = {}, {}
    for name, cap in servers.items():
        for t in cap.get("tools", []): tools[t] = name        # tool -> which server
        for r in cap.get("resources", []): resources[r] = name
    return tools, resources

servers = {
    "docs":    {"tools": ["search_docs"], "resources": ["kb"]},
    "tickets": {"tools": ["create_ticket", "lookup_ticket"], "resources": ["queue"]},
}
tools, res = discover(servers)
print("available tools:", tools)
print("create_ticket lives on:", tools["create_ticket"])
available tools: {'search_docs': 'docs', 'create_ticket': 'tickets', 'lookup_ticket': 'tickets'}
create_ticket lives on: tickets
▶ How this works

This models capability discovery — the moment an MCP client connects to several servers and asks each one "what can you do?", then combines the answers into a single menu. It's how one AI app can offer tools that actually live in many different servers, without you hard-coding where each tool is.

  1. discover(servers) takes a dictionary describing each server: the key is the server's name, and the value lists the tools and resources it offers. (A tool is an action the model can call; a resource is data it can read.)
  2. Inside, two empty dictionaries tools and resources are the combined menu. The loop visits every server, and for each tool or resource it records which server owns it — that's what tools[t] = name does (tool name → server name).
  3. The servers example has a docs server (one search tool) and a tickets server (two tools). After calling discover, the code prints the merged tool list and then looks up who owns create_ticket.
  4. That final lookup is the payoff: given just a tool name, the client instantly knows which server to route the request to — no matter how many servers are connected.

What the output means: First line prints the merged menu — three tools, each mapped to its home server. Second line answers the routing question: create_ticket lives on the tickets server. That name→server map is exactly what a real MCP client builds on connect.

Try this: Add a third server, e.g. "github": {"tools": ["open_pr"], "resources": []}, and rerun. Notice the merged menu grows automatically — that's why MCP is called composable: new servers just add to the shared menu.

5 · Professional — the growing ecosystem professional

MCP is open, so an ecosystem of servers exists for common systems (filesystems, databases, GitHub, Slack, etc.). Adopting it means reusing community servers and exposing your own systems once for every AI tool your org uses — rather than rebuilding integrations per app.

6 · Tech-lead — an org MCP strategy tech-lead

A lead decides the org's MCP posture: which internal systems to expose as servers, which community servers to trust (vetting them — C4), a registry so teams discover them, and governance (access, audit). This turns scattered integrations into shared, governed infrastructure.

Without a strategyWith an org MCP strategy
each team rebuilds integrationsbuild each server once, shared
untrusted servers connected ad-hoca vetted, registered catalog
no visibility into tool usecentral audit + access control
Deep MCP engineering is in K6This chapter is the ecosystem/architecture view; the hands-on advanced server work (sampling, transports, roots, robustness) is K6 · MCP advanced. Together they take you from "why a protocol" to production servers.

Exercise XT3.1 — Map your integrations to MCP

Context: The lesson lands when you apply the N+M math to your own world instead of a toy example — deciding what to build as servers and what to reuse from the ecosystem.

Your task: List the tools and data an AI app in your world needs, compute the custom-vs-MCP integration count, and sketch which pieces you'd build as servers versus reuse, then show how a client would see them merged.

Requirements:

  • Enumerate the concrete tools/data sources your app would integrate
  • Use the N×M vs N+M helper to compute both integration counts for your numbers
  • Decide, per tool, whether you'd build an MCP server or reuse an existing one
  • Use the discovery shape to show how a client would see your servers merged into one capability map

💡 Hint: Anything already offered by the ecosystem is an N+M reuse, not a new server you have to build and maintain.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · The N×M vs N+M integration math (offline)Beginner

Context: MCP's core value is integration math: a shared protocol turns N clients × M tools of bespoke connectors into N+M standard ones. Building a tool once as a server lets any client speak to it.

Your task: Reproduce integrations(n, m, with_protocol) and show the savings for 5 clients and 8 tools.

Requirements:

  • Without a protocol, return n × m (every app wired to every tool)
  • With a protocol, return n + m (each side wires to the standard once)
  • Stdlib only — a couple of arithmetic branches
  • Print both counts and the number of integrations saved for n=5, m=8
  • Note the savings grow super-linearly as the ecosystem grows

💡 Hint: The two cases are just a multiply versus an add; the whole point is contrasting the 40 against the 13.

Show solution

Runnable, stdlib only:

def integrations(n_clients, m_tools, with_protocol):
    if with_protocol:
        return n_clients + m_tools     # each side wires to the standard once
    return n_clients * m_tools         # every app -> every tool

n, m = 5, 8
print(integrations(n, m, False))       # 40  (N x M explosion)
print(integrations(n, m, True))        # 13  (N + M)
print("saved:", integrations(n, m, False) - integrations(n, m, True))  # 27

Build a tool once as an MCP server; any client speaks to it. The savings grow super-linearly as the ecosystem grows.

Exercise 2 · Merge capabilities discovered from multiple servers (offline)Intermediate

Context: Discovery is what makes MCP composable: a client asks each server "what can you do?" and merges the advertised tools and resources into one map it can route against.

Your task: Reproduce discover(servers) that merges the tools and resources advertised by multiple servers into unified maps, and run it over two servers.

Requirements:

  • Input is a mapping of server name to its tools and resources lists
  • Build one map from each tool name to its owning server, and one for resources
  • Stdlib only; iterate the servers and accumulate into dicts
  • Run it over two example servers and print both merged maps
  • Frame the merged tool map as a routing table: tool name → which server to call

💡 Hint: A dict keyed by tool name with the server as the value is exactly the routing table the next rung consumes.

Show solution

Runnable, stdlib only:

def discover(servers):
    """servers: {name: {tools:[...], resources:[...]}}. Merge into one map."""
    tools, resources = {}, {}
    for name, cap in servers.items():
        for t in cap.get("tools", []):     tools[t] = name
        for r in cap.get("resources", []): resources[r] = name
    return tools, resources

servers = {
    "docs":    {"tools": ["search_docs"],                 "resources": ["kb"]},
    "tickets": {"tools": ["create_ticket", "lookup_ticket"], "resources": ["queue"]},
}
tools, resources = discover(servers)
print(tools)      # {'search_docs':'docs','create_ticket':'tickets','lookup_ticket':'tickets'}
print(resources)  # {'kb':'docs','queue':'tickets'}

The merged map is a routing table: given a tool name, the client knows which server to call.

Exercise 3 · Route a model's tool call to the owning server + detect collisions (offline)Advanced

Context: Once you have a merged capability map you need to route a model's tool call to the owning server — and handle the failure mode where two servers export the same tool name.

Your task: Extend discovery into a router: write route(tools, name) that returns the owning server and detect collisions where two servers export the same tool name. Runnable offline.

Requirements:

  • discover() records a collision when a second server exports an already-seen tool name
  • route() returns the owning server for a known tool
  • route() raises (e.g. KeyError) for an unknown tool
  • Show a clean route succeeding and a collision being reported for a shared name like search
  • Note that namespacing (e.g. gh.search vs jira.search) or precedence resolves collisions

💡 Hint: Detect the collision at merge time by checking whether the tool name is already in the map before assigning it.

Show solution

Runnable, stdlib only. Real MCP namespaces tools per-server; here we model the collision so you handle it explicitly:

def discover(servers):
    tools, collisions = {}, {}
    for name, cap in servers.items():
        for t in cap.get("tools", []):
            if t in tools:                       # two servers export same name
                collisions.setdefault(t, [tools[t]]).append(name)
            else:
                tools[t] = name
    return tools, collisions

def route(tools, name):
    if name not in tools:
        raise KeyError(f"no server exports tool {name!r}")
    return tools[name]

servers = {
    "gh":    {"tools": ["search", "create_issue"]},
    "jira":  {"tools": ["search", "create_ticket"]},   # 'search' collides
}
tools, collisions = discover(servers)
print(route(tools, "create_issue"))   # gh
print("collisions:", collisions)      # {'search': ['gh', 'jira']}

Namespacing (e.g. gh.search vs jira.search) or explicit precedence resolves collisions before they silently misroute a call.

Exercise 4 · A minimal MCP server exposing one tool (needs the SDK)Expert

Context: A real MCP server exposes tools over a standard transport so any compliant client can discover and call them. The SDK derives each tool's schema from your function's type hints and docstring.

Your task: Write a minimal MCP server with the Python SDK that exposes a single add tool over stdio. Needs the SDK (pip install "mcp[cli]").

Requirements:

  • Use the SDK's high-level FastMCP server with a display name
  • Decorate a typed add(a: int, b: int) -> int function as a tool
  • Let the docstring and type hints become the tool schema clients discover
  • Run the server over the stdio transport
  • Explain that on connect the client calls tools/list and later tools/call to invoke the function

💡 Hint: The @mcp.tool() decorator plus type hints is all the schema you need to write by hand.

Show solution

Needs the SDK (mcp, the official Model Context Protocol SDK). Uses FastMCP, the SDK's high-level server:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("calc")          # server name shown to clients on discovery

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two integers."""    # docstring + type hints become the tool schema
    return a + b

if __name__ == "__main__":
    mcp.run(transport="stdio") # stdio is the standard local transport

On connect, the client calls tools/list and sees add with a schema derived from the type hints; when the model chooses it, the client sends tools/call and the SDK runs your function.

Exercise 5 · A minimal MCP client that lists + calls tools (needs the SDK)Professional

Context: The other half of MCP is the client: it launches a server, runs discovery, and calls a tool by name — speaking the protocol once so it can drive any compliant server. This is the N+M win in code.

Your task: Write a minimal MCP client that launches the server over stdio, lists its tools (discovery), and calls add. Needs the SDK.

Requirements:

  • Use the SDK's stdio client transport and ClientSession (async)
  • Point the transport at the server command and its script arguments
  • Follow the handshake: initialize, then list_tools, then call_tool
  • Print the discovered tool names and the result of calling add
  • Note the client speaks the protocol once and can drive any compliant server

💡 Hint: Everything runs inside nested async with contexts and an asyncio.run entry point.

Show solution

Needs the SDK. Uses the SDK's ClientSession + stdio transport (async):

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

async def main():
    params = StdioServerParameters(command="python", args=["calc_server.py"])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()          # discovery
            print([t.name for t in tools.tools])        # ['add']
            result = await session.call_tool("add", {"a": 2, "b": 3})
            print(result.content[0].text)               # '5'

asyncio.run(main())

This is the N+M win in code: the client speaks the protocol once and can drive any compliant server — initialize then list_tools then call_tool.

Exercise 6 · Design an org MCP strategy: gateway, auth, and least-privilege (offline reasoning + code)Industry scenario

Context: As a tech lead you must connect many internal tools to many AI clients without an N×M mess or a security hole. The answer is one gateway that also becomes the single place for auth, allowlists, and auditing.

Your task: Model an MCP gateway that registers servers, enforces a per-client allowlist of tools, and audits every call. Runnable offline; the registry stands in for real MCP sessions.

Requirements:

  • A register(server, tools) builds a tool → server registry and rejects name collisions
  • A grant(client, tools) records a per-client allowlist (least privilege)
  • A call(client, tool, args) denies unknown tools and any tool not granted to that client
  • Every call appends an OK/DENIED record to an audit trail
  • Demonstrate a granted call succeeding and a high-blast-radius tool (run_sql) being blocked for a client not granted it
  • Frame the strategy: one gateway = N+M wiring plus centralized auth, allowlists, collision checks, and audit

💡 Hint: Check membership in the client's granted set before touching the registry so a denied call never routes anywhere and still lands in the audit log.

Show solution

Runnable offline — models the governance layer you'd put in front of real MCP servers:

class MCPGateway:
    def __init__(self):
        self.registry = {}       # tool_name -> server_name
        self.allow = {}          # client -> set(tool_name)
        self.audit = []

    def register(self, server, tools):
        for t in tools:
            if t in self.registry:
                raise ValueError(f"tool name collision: {t}")
            self.registry[t] = server

    def grant(self, client, tools):
        self.allow[client] = set(tools)

    def call(self, client, tool, args):
        if tool not in self.registry:
            raise KeyError(f"unknown tool {tool!r}")
        if tool not in self.allow.get(client, set()):     # least privilege
            self.audit.append((client, tool, "DENIED"))
            raise PermissionError(f"{client} may not call {tool}")
        self.audit.append((client, tool, "OK"))
        return f"routed {tool} -> {self.registry[tool]}({args})"

gw = MCPGateway()
gw.register("tickets", ["create_ticket"])
gw.register("prod-db", ["run_sql"])
gw.grant("support-bot", ["create_ticket"])   # NOT run_sql
print(gw.call("support-bot", "create_ticket", {"title": "reset"}))
try:
    gw.call("support-bot", "run_sql", {"q": "DROP TABLE users"})
except PermissionError as e:
    print("blocked:", e)
print("audit:", gw.audit)

Strategy: one gateway = N+M wiring plus a single place for auth, per-client allowlists (least privilege), collision checks, and an audit trail. High-blast-radius tools (run_sql) are granted narrowly, never by default.

✓ Checkpoint — you can move on when you can…

  • Explain why a protocol beats N×M custom integrations.
  • Distinguish MCP from function-calling/plugins.
  • Describe the client-server architecture + discovery.
  • Outline an org MCP adoption strategy.

Knowledge check check yourself

✓ Knowledge check

The lesson frames MCP as turning an N×M problem into N+M. What does each side of that comparison mean, and why does the protocol scale better?

Show answer
Without a standard, N apps each need a custom connector to M tools — N×M bespoke integrations to build and maintain (5 apps × 8 tools = 40). With MCP each app and each tool implements the shared contract once, so it's N+M (5+8 = 13). A shared standard replaces multiplication with addition, and the gap widens as you add more of either side.
✓ Knowledge check

What is capability discovery in MCP, and why does the lesson call it what makes MCP "composable"?

Show answer
On connect, an MCP client asks each server "what can you do?" and merges the tools/resources into one capability map (tool name → owning server), so it can route each call to the right server. It's composable because adding a new server just adds its tools to the shared menu automatically — no hard-coding where each tool lives.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in