Lesson 1: Why MCP exists: one protocol, many applications
Module 1 · Lesson 1 · About 40 minutes · Needs: basic Python; no model key for this lesson
The situation
It is Monday 7 September 2026. Nare runs a small sleep and memory lab and keeps her research notes as Markdown files in one folder. She wants to type "what did we decide about the nap study?" into any of the four AI applications she uses and get the answer from her notes.
You are the engineer. Before building anything, you need to know why a plug-in per application gets expensive, what the Model Context Protocol (MCP) changes, and when it is not worth using. This lesson answers all three with arithmetic, one small server and a stopwatch.
A real case, first
In April 2025 Angie Jones of Block described how the company adopted MCP for goose, its open-source AI agent ("MCP in the Enterprise: Real World Adoption at Block", goose blog, 21 April 2025). Block's reason was Nare's problem at company scale: "We didn't want to build one-off integrations or hardwire AI into a specific vendor ecosystem." Because MCP is "model-agnostic and tool-agnostic", one agent reaches internal APIs, open-source tools and hosted services the same way; the post names Snowflake, GitHub, Jira, Slack and Google Drive.
The post is also honest about what the protocol did not do. Every MCP server used internally is written by Block's own engineers, and OAuth flows run inside locally run servers, "a practical but temporary solution". Thousands of employees use goose daily (most report, in their own estimate, saving 50 to 75 percent of their time on common tasks). The lesson: a protocol pays off when many applications reach many tools, and it moves work such as security and sign-in rather than removing it.
What you will be able to do
- Count Nare's integrations: N times M against N plus M.
- Show what a protocol fixes that a library cannot, by talking to a server with no MCP library.
- Place MCP next to native function calling, REST and agent-to-agent protocols.
- Name the official SDKs and what their tiers mean.
- Decide when MCP is the wrong tool, using measured costs.
1. The problem Nare actually has (Essential)
Nare's notes are only useful if the assistant she is using can read them. Everything in this course follows from that one wish.
Nare uses a desktop chat assistant, an AI code editor, a command-line coding agent and the lab's own small assistant (the host you build in Module 6). Each is an AI application: a program that puts a language model in front of a user. All four should answer from the same folder.
A person finds the answer quickly: the note lab-sync-2026-09-02 says, on its second body line, that the nap study will run with 24 participants instead of 16. The hard part is letting four programs, written by four companies, reach it.
Each application has its own way of adding a tool: its own plug-in format, settings file and sign-in. So the folder is wired up four times, and each wiring breaks when its application changes. Then Tomas asks for the equipment calendar and Priya for her reference manager, and the wiring multiplies.
2. The N applications times M tools arithmetic (Essential)
The cost of custom wiring is a multiplication. Counting it tells you when a shared protocol starts to pay.
Call the number of AI applications N and the number of tool systems M (anything an application should reach). Without a shared agreement, every application needs its own adapter for every system. Nare's lab has 4 applications and 6 systems (notes, calendar, references, EEG data, email, participant spreadsheet): 24 adapters.
With a shared protocol each application implements the client side once and each system the server side once: N plus M, here 10. The vendors already wrote their clients, so Nare writes only the servers nobody has published. The course's MCP cell installs the official Python SDK:
%pip install -q mcp==2.3.0
import logging
for name in ("mcp", "httpx2", "uvicorn"):
logging.getLogger(name).setLevel(logging.WARNING)Code walkthrough
mcp==2.3.0is the official SDK, pinned to the tested version (checked October 2026); it speaks protocol revision 2026-07-28.- The loop turns three libraries' log messages down to warnings so they do not bury your output.
The arithmetic itself needs no library:
def integrations(apps, systems):
"""Pieces of glue to write: without a shared protocol, and with one."""
return apps * systems, apps + systems
SETTINGS = [("Nare's lab", 4, 6), ("A mid-sized team", 10, 50), ("A large company", 30, 400)]
for name, n, m in SETTINGS:
bespoke, shared = integrations(n, m)
saved = bespoke - shared
print(f"{name:17} {n:>3} x {m:<3} = {bespoke:>6,} {n:>3} + {m:<3} = {shared:>3} "
f"saved {saved:,} ({saved / bespoke:.0%})")
print("a fifth application in the lab:", integrations(5, 6))Code walkthrough
integrationsreturns the product (one adapter per pair) and the sum (one piece per side).SETTINGSholds Nare's lab and two made-up larger sizes for comparison.- The output was 24 against 10 (58 percent saved), 500 against 60 (88 percent) and 12,000 against 430 (96 percent). The last line gave
(30, 11).
A product grows faster than a sum. Two caveats: the saving is in wiring, not thinking (each server still needs good descriptions, sign-in and a security review, Modules 3, 7 and 8), and it holds only if the applications really speak the protocol. The Agentic AI Engineer course makes this argument briefly (Module 4, Lesson 1); this lesson goes down to the messages.
A fifth application shows the asymmetry: 30 adapters instead of 24, six new things to test, against 11 pieces instead of 10, a client its vendor already wrote.
3. What a protocol standardises that an SDK cannot (Essential)
Why not just a good library? The answer decides how far you can trust code written by people you never meet, which is most of what an MCP host runs.
An SDK (software development kit) is code in one language, owned by one team, that you import. A protocol is an agreement about the messages that cross a boundary, so programs that share no code can work together. The difference shows in five places.
MCP's specification says it "takes some inspiration from the Language Server Protocol", the agreement that lets any code editor support any programming language. One agreement in the middle turned a grid of plug-ins into a sum; MCP does the same for AI applications.
The most practical difference is discovery: a client asks a running server what it offers instead of hard-coding it. Here is a one-tool server, written to a file (Lesson 3 explains it fully):
%%writefile notes_server.py
from mcp.server import MCPServer
NOTES = {"lab-sync-2026-09-02": "Decision: run the nap study with 24 participants instead of 16."}
mcp = MCPServer("notes")
@mcp.tool()
def search_notes(query: str) -> list[str]:
"""Search Nare's notes and return the ids of notes that mention the query."""
return [nid for nid, body in NOTES.items() if query.lower() in body.lower()]
if __name__ == "__main__":
mcp.run() # stdio by defaultCode walkthrough
%%writefilesaves the cell as a file, so another program can start it.NOTESholds one line of the anchor note; Lesson 2 loads all eight.@mcp.tool()publishes the function as a tool, with the docstring as its description and the type hints as its input schema.mcp.run()serves it over standard input and output.
Ask it what it offers, as any client would:
import json
import notes_server
from mcp import Client
async with Client(notes_server.mcp) as client: # in memory: no process, no port
listed = await client.list_tools()
tool = listed.tools[0].model_dump(by_alias=True, exclude_none=True)
print(json.dumps({k: tool[k] for k in ("name", "description", "inputSchema")}, indent=2))Code walkthrough
Client(notes_server.mcp)connects in memory. Top-levelasync withandawaitwork in Colab, which already runs an event loop.list_tools()sends one request,tools/list.model_dump(by_alias=True, ...)gives the protocol's own JSON names. The output showedsearch_notes, its docstring, and a schema with one required string,query.
What crossed the boundary is JSON-RPC 2.0, a small standard for "call this method with these parameters" messages. Under revision 2026-07-28 each request also carries _meta with the protocol version and the client's capabilities. The message is the contract, so you do not need the SDK. This cell talks to the server with only the standard library:
import json, subprocess, sys
META = {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}}
server = subprocess.Popen([sys.executable, "notes_server.py"], text=True,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=open("server.log", "w"))
def request(req_id, method, params=None, meta=META):
"""Send one JSON-RPC request as a line on stdin; read one line of reply from stdout."""
message = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": {**(params or {}), "_meta": meta}}
server.stdin.write(json.dumps(message) + "\n")
server.stdin.flush()
return json.loads(server.stdout.readline())
print(request(1, "server/discover")["result"]["supportedVersions"])
reply = request(2, "tools/call", {"name": "search_notes", "arguments": {"query": "nap"}})
print(reply["result"]["content"][0]["text"])Code walkthrough
METAholds the two_metaentries required on every request: version and capabilities (none here).subprocess.Popenstarts the server as a child program; its standard input and output are the connection, and its error output goes toserver.log.requestwrites one message as one line and reads one line back: the whole stdio transport.server/discoverprinted['2026-07-28'];tools/callprintedlab-sync-2026-09-02, the note holding Nare's answer.
Plain Python, sharing no code with the SDK, used its server; so can a TypeScript editor or a Go agent.
Break it: ask for a version the server does not speak
Protocols change, so a mismatch must fail loudly. Send a made-up future version, then no _meta at all.
future = {**META, "io.modelcontextprotocol/protocolVersion": "2099-01-01"}
print(request(3, "tools/list", meta=future)["error"])
empty = request(4, "tools/list", meta={})["error"]
print(empty["code"], empty["message"])
server.terminate()Code walkthrough
futurecopiesMETAwith another version. The reply was error-32022, "Unsupported protocol version", withsupported: ['2026-07-28'], so the client knows what to retry with.emptycame back as-32602(invalid parameters), naming the two missing keys.server.terminate()stops the child program.
The specification requires this: a server that lacks the requested version "MUST respond with an UnsupportedProtocolVersionError listing the versions it does support". A library's version number cannot promise that across languages and owners. Module 2 takes the wire apart properly.
4. Official SDKs in other languages (Optional)
You will meet servers in other languages, and may write one. Know which SDKs exist and how far to rely on each.
The MCP site lists ten official SDKs in three tiers, by "feature completeness, protocol support, and maintenance commitment" (checked October 2026). Tiers move: on release day, 28 July 2026, the maintainers named four Tier 1 SDKs as speaking the new revision and Rust as supporting it in beta. Check the SDK page before choosing. Every one sends the messages you just sent by hand.
5. MCP versus native function calling, REST and agent-to-agent protocols (Essential)
Three neighbours get confused with MCP, and picking the wrong one costs weeks.
Native function calling (also called tool calling) is a feature of a model's API. You send the model tool definitions; it replies "call search_notes with query set to nap"; your code runs it and returns the result. It says nothing about where the tool lives or how another application reuses it. MCP sits one step out: how the host finds tools in another program and runs them there.
Most hosts use function calling underneath, translating each MCP tool into the model's format:
def as_function_tool(mcp_tool):
"""Translate an MCP tool definition into the function-calling format most model APIs accept."""
return {"type": "function",
"function": {"name": mcp_tool["name"],
"description": mcp_tool["description"],
"parameters": mcp_tool["inputSchema"]}}
print(json.dumps(as_function_tool(tool), indent=2))Code walkthrough
as_function_toolwraps the tool listed in section 3 in the widely used OpenAI-style shape.- Name and description carry over;
inputSchemabecomesparameters, as the output showed. Lesson 2 calls a model with a tool in this format; Module 6's host translates automatically.
REST (an HTTP API, often described with OpenAPI) is how services talk to services, and many MCP servers wrap one. What REST does not define is behaviour for a model: a standard tool list, descriptions written for a model, prompts a user picks, or a way to ask the user a question mid-call.
An agent-to-agent protocol connects one autonomous agent to another. Google announced A2A (Agent2Agent) on 9 April 2025 as "an open protocol that complements Anthropic's Model Context Protocol (MCP)". With A2A an agent hands a task to a remote agent that plans for itself; with MCP the other side does what it is told. An agent can use both.
6. When MCP is the wrong tool (Essential)
Every protocol boundary costs a process, a message, a dependency and a review. Knowing when not to pay is part of using MCP well.
MCP fits when several AI applications must reach the same capability and a model decides when to call it. Otherwise it is overhead: a nightly export needs plain code, one script with two functions needs function calling, service traffic with no model needs REST or a queue.
The overhead is measurable. This cell calls search_notes 100 times three ways: directly, through an in-memory client and through a stdio server.
import time
from mcp import StdioServerParameters
from mcp.client.stdio import stdio_client
CALLS = 100
async def per_call_ms(client):
t = time.perf_counter()
for _ in range(CALLS):
await client.call_tool("search_notes", {"query": "nap"})
return (time.perf_counter() - t) / CALLS * 1000
t = time.perf_counter()
for _ in range(CALLS):
notes_server.search_notes("nap")
print(f"direct call {(time.perf_counter() - t) / CALLS * 1e6:7.2f} microseconds per call")
async with Client(notes_server.mcp) as client:
print(f"MCP in memory {await per_call_ms(client) * 1000:7.0f} microseconds per call")
params = StdioServerParameters(command=sys.executable, args=["notes_server.py"])
with open("server.log", "w") as log:
t = time.perf_counter()
async with Client(stdio_client(params, errlog=log)) as client:
await client.call_tool("search_notes", {"query": "nap"})
start = (time.perf_counter() - t) * 1000
print(f"MCP over stdio {await per_call_ms(client) * 1000:7.0f} microseconds per call, "
f"after {start:.0f} ms to start")Code walkthrough
per_call_msaverages 100call_toolrequests on a connected client, in milliseconds.- The first loop calls the function directly;
@mcp.tool()leaves it an ordinary function. stdio_client(params, errlog=log)starts the server as a child program, so its timing includes start-up.- On the course's runs (a laptop, not Colab) the output was 0.1 to 0.25 microseconds direct, 300 to 400 in memory, and 600 to 900 over stdio after 210 to 230 ms to start.
Thousands of times slower, yet under a millisecond is nothing beside a model call that takes a second. It matters only with no model involved or in tight loops.
The second cost is context: every tool definition is text the model reads each turn. Mario Zechner measured Playwright MCP's 21 tools at 13.7k tokens and Chrome DevTools MCP's 26 at 18.0k, against 225 for a README of his own command-line tools (November 2025). His view: "in many situations, you don't need or even want an MCP server". For an agent that can run shell commands, he is often right.
In production
Block's post shows what a team adds around the protocol: servers written in-house, "a curated set of approved MCP servers" by default, model allowlists on some servers, tokens in the system keychain, and tools marked read-only or destructive so users confirm risky calls. None of it is in the wire format; the specification says MCP "cannot enforce these security principles at the protocol level". Modules 6, 7 and 8 build these controls for Nare. Expect change here: Block called its sign-in approach temporary, and the specification is revised every few months.
What you added to the notes assistant
- The lab's integration count: 24 adapters against 10 pieces, and 6 against 1 per new application.
- A one-tool notes server, reached through the SDK and through plain JSON over stdio.
as_function_tool, the translation Module 6's host performs.- A measured rule for when not to use MCP.
Check yourself
- Nare's lab grows to 6 applications and 8 tool systems. How many pieces of glue with and without a protocol?
- Name two things a protocol gives that a Python library cannot.
- Where does native function calling end and MCP begin?
- A nightly job exports every note to a backup drive. Should it use MCP?
- What does a server do when a request names a protocol version it does not support?
- 48 adapters without a protocol (6 times 8) and 14 pieces with one (6 plus 8).
- Any two of: any language; a public owner and change process; discovery at run time; versions on every request with a defined error; a separate program.
- Function calling is the model asking the host for a tool; MCP is how the host finds and runs it in another program.
- No: no model decides and nothing is shared, so plain code is simpler.
- It returns error -32022 listing the versions it supports.
Exercise
Send tools/list with the raw request function and translate the result with as_function_tool. The check restarts the server and must print ok.
server = subprocess.Popen([sys.executable, "notes_server.py"], text=True,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=open("server.log", "w"))
listed_raw = request(10, "tools/list")["result"]["tools"]
fn = as_function_tool(listed_raw[0])
assert fn["function"]["name"] == "search_notes"
assert fn["function"]["parameters"]["required"] == ["query"]
assert integrations(6, 8) == (48, 14)
server.terminate()
print("ok")Code walkthrough
server = subprocess.Popen(...)restarts the server;requestuses whateverservernames.request(10, "tools/list")returns the raw tool list, andas_function_toolworks on it because it reads the protocol's own field names.- The asserts check the name, the required argument and question 1's arithmetic.
Key terms
| Term | Meaning |
|---|---|
| Model Context Protocol (MCP) | An open protocol for connecting AI applications to tools and data |
| Adapter | Custom glue between one application and one tool system |
| Protocol | An agreement about messages, so programs that share no code can work together |
| SDK | A library in one language that implements the protocol for you |
| Discovery | A client asking a running server what it offers |
| JSON-RPC 2.0 | The message format MCP uses: a method, parameters, and an id to match the reply |
| Native function calling | A model API feature where the model asks your code to run a named function |
| A2A | Agent2Agent, a protocol for one agent to hand a task to another |
Sources
- Angie Jones (Block), "MCP in the Enterprise: Real World Adoption at Block", goose blog, 21 April 2025. https://goose-docs.ai/blog/2025/04/21/mcp-in-enterprise
- Model Context Protocol, "Specification" (revision 2026-07-28), overview, fetched 13 October 2026. https://modelcontextprotocol.io/specification/2026-07-28
- Model Context Protocol, "Versioning and Compatibility" (revision 2026-07-28), fetched 13 October 2026. https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning
- Model Context Protocol, "SDKs", fetched 13 October 2026. https://modelcontextprotocol.io/docs/2026-07-28/sdk
- David Soria Parra and Den Delimarsky, "The 2026-07-28 Specification", MCP blog, 28 July 2026. https://blog.modelcontextprotocol.io/posts/2026-07-28/
- Rao Surapaneni, Miku Jha, Michael Vakoc and Todd Segal (Google), "Announcing the Agent2Agent Protocol (A2A)", 9 April 2025. https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/
- Mario Zechner, "What if you don't need MCP at all?", 2 November 2025. https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/