Model Context Protocol Explained for Agent Builders | MewCP | MewCP
What Is MCP? The Model Context Protocol, Explained for Agent Builders
by Rohit Gite, Founder CTO @MewCP··14 min read
Most MCP explainers stop at 'it is APIs for AI' and leave you unable to build anything. This is the version with the message shapes, the server code and the parts MCP does not solve.
The Model Context Protocol gets summarized as "APIs for AI" and that summary is why so many engineers bounce off it. If it were APIs with new branding there would be nothing to learn. You already have APIs. You already have HTTP clients. Nobody needs a spec for that.
The carousel gave you the correct one-liner: MCP standardizes the connection, not the tool. This is the part underneath. Message shapes, primitives, a server you can run in the next ten minutes, the transport decision, what the 2026-07-28 revision changed, and the four things the protocol hands straight back to you.
What the Model Context Protocol actually standardizes
Day 15 ended on a list. Discovery, a uniform call envelope, auth as a separate concern, a shared error vocabulary, transport independence, capability negotiation. That list was a specification wearing a disguise.
MCP is the answer to it. Three participants, and the relationship matters more than the names:
Host is the AI application. Claude Desktop, VS Code, your own agent runtime.
Client is a connector inside the host that maintains one dedicated connection to one server.
Server is a program that exposes capabilities to that client.
One host, many clients, one server per client. When a host connects to a second server it instantiates a second client. That is the detail most explainers flatten, and flattening it is what makes people think "client" means "the chat app."
The word "server" also does not mean "remote." A filesystem server launched as a subprocess on your laptop and a hosted server running in someone else's cloud are both MCP servers. Local and remote describe the transport, not two species of thing.
Two layers: data and transport
MCP splits into a data layer and a transport layer, and the split is the reason a server you write today survives being deployed somewhere else tomorrow.
The data layer is JSON-RPC 2.0. It defines discovery, the three server primitives, notifications, progress, and the exact shape of every message. This is where the semantics live.
The transport layer defines how those bytes move and how the caller is authenticated. There are two standard transports:
stdio, where the host launches the server as a subprocess and speaks newline-delimited JSON over stdin and stdout.
Streamable HTTP, where the server exposes a single HTTP endpoint, the client POSTs JSON-RPC to it, and the server may answer with a plain JSON body or an SSE stream.
The legacy HTTP+SSE transport, deprecated since 2025-03-26, is now formally Deprecated under the feature lifecycle policy. Do not build on it.
Conceptually the data layer is the inner layer and transport is the outer one. Your tool logic never knows which transport carried the call. That is the whole point, and it is why the same 40 lines of Python can run as a local subprocess during development and as a multi-tenant HTTP service in production.
The messages you will actually see
Here is what discovery and invocation look like on the wire. Every client request carries required metadata in _meta, because the protocol is stateless and the server cannot assume anything from an earlier message on the same connection.
The response is the thing that makes MCP interesting. Not a list of names, a list of contracts:
{ "jsonrpc": "2.0", "id": 1, "result": { "resultType": "complete", "tools": [ { "name": "search_books", "title": "Search the catalog", "description": "Search the bookshop catalog by title or author.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "description": "Title or author substring to match." }, "limit": { "type": "integer", "minimum": 1, "maximum": 20, "default": 5 } }, "required": ["query"] } } ], "ttlMs": 300000, "cacheScope": "public" }}
Read the inputSchema field again. That is plain JSON Schema 2020-12, the same dialect the Anthropic and OpenAI tool-calling APIs consume. Which means a client can take a tools/list response and hand it almost directly to a model as a tool definition. No adapter, no hand-written schema, no human pasting definitions into a codebase.
ttlMs and cacheScope are new in 2026-07-28. They tell the client how long the list stays fresh and whether a shared intermediary is allowed to cache it. Treat cacheScope: "public" as a promise you are making. If your tool list varies by the caller's granted scopes, it is "private".
Invocation is the boring half, which is exactly right:
Note isError sitting inside a successful JSON-RPC result. That distinction is deliberate. A protocol error means the request itself was malformed or unroutable and comes back as a JSON-RPC error. A tool error means the call was well-formed and the work failed, and it comes back as a normal result with isError: true so the model can read the message and decide what to do next. Return a 500 for "customer not found" and you have hidden the useful information from the only participant that can act on it.
There is a third message worth knowing. server/discover asks the server for its supported protocol versions, capabilities and identity in one round trip. Servers must implement it. Clients may call it before anything else, or skip it and handle an UnsupportedProtocolVersionError inline.
Servers expose three things. The differences are not about data format, they are about who decides when the thing gets used.
Primitive
Methods
Controlled by
Reach for it when
Tools
tools/list, tools/call
The model
The agent should decide, at runtime, whether to do the thing
Resources
resources/list, resources/read
The host application
Context should be attached, not fetched by a decision
Prompts
prompts/list, prompts/get
The user
A person deliberately picks a workflow or template
Tools are model-controlled. The model reads the name, description and schema and chooses. Everything Day 14 said about description quality applies here with more force, because on MCP your descriptions are read by hosts you have never seen, wired to models you did not pick.
Resources are application-driven. The host decides how to surface them: a file picker, a search box, automatic inclusion by heuristic. Each is identified by a URI. A resource read has no side effects and should be cheap, because the host may pull it on every turn.
Prompts are user-controlled. They are templates the user explicitly invokes, which is why they show up as slash commands in most hosts. The server authors the content, the user chooses the moment.
The practical design rule: if the model should decide, make it a tool. If the application should decide, make it a resource. If the human should decide, make it a prompt. Teams that ship read-only lookups as tools end up with a model that burns turns fetching context it could have been handed for free.
A working MCP server in Python
Install the SDK. Version 2.x supports 2026-07-28 and every earlier revision.
uv add "mcp[cli]" # or: pip install "mcp[cli]"
Now server.py. This is complete and runnable.
from typing import Annotated, Literalfrom pydantic import Fieldfrom mcp.server import MCPServerfrom mcp.server.mcpserver.exceptions import ToolErrorfrom mcp.types import ToolAnnotationsmcp = MCPServer( "Bookshop", instructions="Search the catalog before recommending a book.",)CATALOG = [ {"title": "Dune", "author": "Frank Herbert", "year": 1965, "genre": "fiction", "stock": 4}, {"title": "Neuromancer", "author": "William Gibson", "year": 1984, "genre": "fiction", "stock": 0}, {"title": "The Order of Time", "author": "Carlo Rovelli", "year": 2017, "genre": "non-fiction", "stock": 2},]@mcp.tool( title="Search the catalog", annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False),)def search_books( query: Annotated[str, Field(description="Title or author substring to match. Case insensitive.")], genre: Literal["fiction", "non-fiction", "poetry"] | None = None, limit: Annotated[int, Field(ge=1, le=20, description="Maximum number of results.")] = 5,) -> str: """Search the bookshop catalog by title or author. Use this for questions about which books exist. Do not use it to check availability, use check_stock for that. Returns one line per book. """ needle = query.lower() hits = [ book for book in CATALOG if (needle in book["title"].lower() or needle in book["author"].lower()) and (genre is None or book["genre"] == genre) ] if not hits: where = f" in {genre}" if genre else "" return f"No books matching {query!r}{where}. Try a shorter query or drop the genre filter." shown = hits[:limit] lines = [f"Found {len(shown)} of {len(hits)} matches for {query!r}:"] lines += [f"- {b['title']} | {b['author']} | {b['year']}" for b in shown] if len(hits) > limit: lines.append(f"Raise limit above {limit} to see the rest.") return "\n".join(lines)@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))def check_stock(title: Annotated[str, Field(description="Exact book title.")]) -> str: """Check how many copies of an exact title are in stock.""" for book in CATALOG: if book["title"].lower() == title.lower(): count = book["stock"] return f"{book['title']}: {count} in stock." if count else f"{book['title']}: out of stock." raise ToolError(f"No book titled {title!r} in the catalog. Call search_books first.")@mcp.resource("config://shop")def shop_config() -> str: """Opening hours and shipping policy for the shop.""" return "hours=09:00-18:00\ncurrency=GBP\nfree_shipping_over=40"@mcp.prompt()def recommend(mood: str) -> str: """Ask for a recommendation that fits a mood.""" return f"Recommend three books from the catalog that suit someone feeling {mood}. Explain each in one sentence."if __name__ == "__main__": mcp.run()
There is no JSON Schema in that file. The type hints are the schema. Annotated[str, Field(description=...)] becomes a described property, Literal[...] becomes an enum, ge and le become minimum and maximum, and defaults keep fields out of required.
ToolError is the piece people miss. Raising it produces a normal result with isError: true and your message in the content, which is what you want. An unhandled exception gets you a generic failure and the model learns nothing.
Open it in the Inspector:
uv run mcp dev server.py
Or serve it over HTTP:
uv run mcp run server.py --transport streamable-http
Then point a client at it. The same package is a client:
import asynciofrom mcp import Clientasync def main() -> None: async with Client("http://localhost:8000/mcp") as client: listing = await client.list_tools() for tool in listing.tools: print(tool.name, "->", tool.input_schema) result = await client.call_tool("search_books", {"query": "herbert"}) print(result.structured_content) print(result.is_error)0asyncio.run(main())
Those eight lines are the whole argument for MCP. You never wrote a schema, never parsed a request, never touched an auth header, and a client that has never seen your code just learned what you can do and called it correctly.
stdio or Streamable HTTP
The transport decision is the first real architectural choice you make, and it is mostly a question about identity.
stdio
Streamable HTTP
Runs as
Subprocess of the host
Network service on a single endpoint
Who starts it
The host, per user
You, once, for everyone
Caller identity
The OS user who launched it
Whatever the request proves
Credentials
Environment and local files
Must be carried per request
Concurrency
Usually one client
Many clients, many tenants
Scaling
Not applicable
Any instance can serve any request
Debugging
Trivial, it is a local process
Normal HTTP tooling
Pick stdio when the server needs local machine access, when it is a developer tool, and when "the user" and "the process owner" are the same person. Filesystem access, git, local databases, running a build. The spec is explicit that stdio implementations should not follow the HTTP authorization framework and should retrieve credentials from the environment instead. That is a reasonable trade when the process already runs as you.
Pick Streamable HTTP when more than one person will use the server, when the credentials belong to accounts rather than machines, or when you want to ship a capability update without every user reinstalling something. Anything customer-facing ends up here.
On Streamable HTTP the client POSTs to your MCP endpoint and must send three headers alongside the body: MCP-Protocol-Version, Mcp-Method, and for tools/call, resources/read and prompts/get, an Mcp-Name carrying the tool name or resource URI.
POST /mcp HTTP/1.1Content-Type: application/jsonMCP-Protocol-Version: 2026-07-28Mcp-Method: tools/callMcp-Name: search_books
Those headers exist so a gateway, load balancer or rate limiter can route and meter on the operation without parsing the body. Servers must reject requests where the headers and body disagree, with HeaderMismatch (-32020). It is a small change with a large operational payoff: you can now rate limit search_books differently from delete_everything at the edge.
What changed in the 2026-07-28 revision
If you learned MCP from material written against 2025-06-18 or 2025-11-25, several things you know are now wrong. This is the largest revision since the protocol launched.
The protocol is stateless. The initialize and notifications/initialized handshake is gone. Every request carries its own protocol version, client capabilities and client identity in _meta under io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientCapabilities and io.modelcontextprotocol/clientInfo. Servers should identify themselves in each result's _meta under io.modelcontextprotocol/serverInfo. A request missing a required field is rejected with -32602.
Protocol sessions are gone. The Mcp-Session-Id header was removed from Streamable HTTP, and list endpoints must not vary per connection. If your server needs state across calls, it mints an explicit handle and the client passes it back as an ordinary tool argument. In practice this means any instance can serve any request with no shared session store, which is the change that makes MCP servers behave like normal stateless web services.
server/discover is mandatory on servers. It replaces the handshake as the way to learn versions and capabilities, and doubles as the backward-compatibility probe on stdio.
Server-initiated requests became Multi Round-Trip Requests. Instead of the server calling back into the client, it returns a result with resultType: "input_required" and an inputRequests field. The client gathers the input and retries the original request with inputResponses. Every result now carries a required resultType, and clients must treat an absent one from older servers as "complete".
Subscriptions changed shape. The HTTP GET endpoint and resources/subscribe and resources/unsubscribe were replaced by a single subscriptions/listen long-lived POST stream that clients opt into by notification type.
Some things were removed outright.ping, logging/setLevel and notifications/roots/list_changed are gone. Log level is now set per request via io.modelcontextprotocol/logLevel. SSE stream resumability went too, so a broken stream means the client re-issues the request with a new ID rather than replaying from Last-Event-ID.
Roots, Sampling and Logging are deprecated. They keep working for at least twelve months under the new deprecation policy, but new implementations should not adopt them. The suggested migrations are concrete: pass directories through tool parameters or resource URIs instead of Roots, call an LLM provider API directly instead of Sampling, and write to stderr or OpenTelemetry instead of Logging.
If you are using an official SDK at a current version, most of this is handled for you. The parts that leak into your code are the ones about state: stop assuming a connection is a conversation, and stop storing anything against a session ID.
The four problems MCP does not solve
This is the section that saves you a quarter.
1. Authentication is specified, not provided. MCP defines an authorization framework for HTTP transports built on OAuth 2.1, including how clients discover the authorization server and how tokens travel. It does not run an authorization server for you, does not manage consent screens, and does not decide what a scope means in your product. The 2026-07-28 revision also deprecated Dynamic Client Registration in favour of Client ID Metadata Documents, so registration is one more thing to get right. And on stdio there is no auth story at all by design. If your first MCP deployment feels uncomfortable the moment you move from stdio to HTTP, this is why.
2. Multi-tenant credentials are entirely yours. MCP tells you how a caller proves who they are to your server. It says nothing about how your server then acts as that user against Slack, Notion or Salesforce. Per-user token storage, encryption, refresh serialization, rotation, revocation, audit: all of it is the work Day 15 described, unchanged. The single most common production bug survives the move to MCP intact, and it looks like a module-level token that quietly makes every tenant share one identity.
3. Tool sprawl is now easier to create. Runtime discovery is the best thing about the protocol and the fastest way to wreck an agent. Connect six servers with fifteen tools each and you have put ninety tool definitions into a context window before the user has said anything. Selection accuracy falls, cost rises, latency rises. The protocol gives you a list. Deciding which tools a given agent should see for a given task is a gateway concern and it is on you.
4. Versioning your own tools is unsolved. The spec versions itself carefully. It has nothing to say about the day you rename search_books to catalog_search or make limit required. There is no deprecation mechanism for an individual tool, no way to serve v1 and v2 of the same tool to different clients, and listChanged only tells a client that something moved, not what. Pin the behaviour yourself, keep old names alive as aliases, and treat a tool signature as a public API from day one.
Before you ship your first MCP server
Every tool has a description that says what it does, when to use it, when not to, and what comes back
Every parameter is typed and described, closed value sets are enums, and required holds only what is genuinely required
Read-only tools carry read_only_hint, and anything with a side effect is either idempotent or gated behind a confirmation in the host
Tool failures raise a tool error with a readable message instead of throwing a raw exception
Results are compact text, labelled, and truncated with an explicit signal
Nothing depends on the connection. State is an explicit handle passed as an argument
tools/list returns a deterministic order and an honest cacheScope
Secrets resolve per request from the caller's identity, never at import time
The server behaves identically over stdio and Streamable HTTP
You have run it in the Inspector and called every tool by hand
You have tested selection against a set of prompts where the wrong tool is plausibly attractive
Where this leaves you
MCP removes the part of tool integration that was never worth building twice: discovery, the call envelope, the result shape, capability negotiation. It leaves the part that is actually specific to your product, which is exactly the right split for a protocol.
What it also leaves is the operational middle. Hosting servers so they stay up, holding per-user credentials so tenant A never acts as tenant B, putting a gateway in front so an agent sees eight relevant tools instead of ninety, and keeping all of it observable. That middle is what we work on at MewCP. The protocol does not change. It just stops being the thing that keeps you up.
Next: MCP versus APIs, and why "it is just a wrapper" survives contact with exactly one production deployment.