feat(phase-11/14): modernize MCP protocol contracts

The foundational MCP lesson must no longer teach the removed session lifecycle.
This commit is contained in:
Rohit Ghumare
2026-08-21 15:58:53 +01:00
parent 87809d267c
commit ccaab0c3ad
6 changed files with 649 additions and 299 deletions
@@ -16,7 +16,7 @@
</style>
</defs>
<text x="450" y="30" text-anchor="middle" class="title">one host, one JSON-RPC session, three primitives</text>
<text x="450" y="30" text-anchor="middle" class="title">one host, stateless JSON-RPC requests, three primitives</text>
<!-- Host -->
<rect x="40" y="70" width="240" height="300" class="host"/>
@@ -38,7 +38,7 @@
<!-- Wire -->
<path d="M280 235 L500 235" class="line" marker-end="url(#arrow)"/>
<text x="390" y="220" text-anchor="middle" class="tag">JSON-RPC 2.0 (stdio / HTTP)</text>
<text x="390" y="252" text-anchor="middle" class="tag">initialize &#x2192; list &#x2192; call</text>
<text x="390" y="252" text-anchor="middle" class="tag">_meta each request &#x2022; discover optional</text>
<!-- Server -->
<rect x="500" y="70" width="360" height="300" class="server"/>
@@ -59,7 +59,7 @@
<!-- Caption -->
<text x="450" y="410" text-anchor="middle" class="tag">
spec revision 2025-06-18 adds streamable HTTP transport; every host/server pins a protocol version in initialize.
protocol version and client capabilities travel in params._meta on every request.
</text>
<text x="450" y="435" text-anchor="middle" class="tag">
destructive tools set destructiveHint: true so the host can gate on human approval.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -1,24 +1,24 @@
"""Minimal MCP server + in-process client round-trip.
"""Phase 11 Lesson 14: a stateless MCP server and in-process client.
The reference SDK is `mcp` on PyPI (install with `pip install mcp`). This file
does not import it so the demo runs on any Python 3.10+ without extra deps.
Instead it speaks raw JSON-RPC 2.0 over an in-memory pipe — the same wire
format an MCP stdio host uses — so you can see how tools, resources, and
prompts flow end to end.
Run with:
python main.py
Implements the 2026-07-28 request contract with per-request metadata,
server/discover, typed results, and the three server primitives. The transport
is in memory so the protocol remains visible and the demo stays stdlib-only.
Spec: https://modelcontextprotocol.io/specification/2026-07-28
"""
from __future__ import annotations
import json
import queue
from dataclasses import dataclass
from typing import Any, Callable
PROTOCOL_VERSION = "2025-06-18"
PROTOCOL_VERSION = "2026-07-28"
SUPPORTED_VERSIONS = (PROTOCOL_VERSION,)
PROTOCOL_KEY = "io.modelcontextprotocol/protocolVersion"
CLIENT_CAPABILITIES_KEY = "io.modelcontextprotocol/clientCapabilities"
CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo"
SERVER_INFO_KEY = "io.modelcontextprotocol/serverInfo"
@dataclass
@@ -33,6 +33,7 @@ class Tool:
@dataclass
class Resource:
uri: str
name: str
description: str
handler: Callable[[], str]
@@ -45,118 +46,250 @@ class Prompt:
handler: Callable[..., str]
class MCPServer:
"""Toy MCP server covering the three primitives and the discovery handshake."""
def request_metadata(
*,
client_name: str = "demo-client",
client_version: str = "1.0.0",
capabilities: dict[str, Any] | None = None,
protocol_version: str = PROTOCOL_VERSION,
) -> dict[str, Any]:
return {
PROTOCOL_KEY: protocol_version,
CLIENT_CAPABILITIES_KEY: capabilities or {},
CLIENT_INFO_KEY: {"name": client_name, "version": client_version},
}
class MCPServer:
def __init__(self, name: str) -> None:
self.name = name
self.server_info = {"name": name, "version": "0.2.0"}
self.tools: dict[str, Tool] = {}
self.resources: dict[str, Resource] = {}
self.prompts: dict[str, Prompt] = {}
# Registration helpers -------------------------------------------------
def tool(self, name: str, description: str, schema: dict[str, Any], *, destructive: bool = False):
def tool(
self,
name: str,
description: str,
schema: dict[str, Any],
*,
destructive: bool = False,
):
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
self.tools[name] = Tool(name, description, schema, fn, destructive)
return fn
return decorator
def resource(self, uri: str, description: str):
def resource(self, uri: str, name: str, description: str):
def decorator(fn: Callable[[], str]) -> Callable[[], str]:
self.resources[uri] = Resource(uri, description, fn)
self.resources[uri] = Resource(uri, name, description, fn)
return fn
return decorator
def prompt(self, name: str, description: str, arguments: list[str]):
def decorator(fn: Callable[..., str]) -> Callable[..., str]:
self.prompts[name] = Prompt(name, description, arguments, fn)
return fn
return decorator
# JSON-RPC dispatch ----------------------------------------------------
def _capabilities(self) -> dict[str, Any]:
return {"tools": {}, "resources": {}, "prompts": {}}
def _complete(self, payload: dict[str, Any], *, cacheable: bool = False) -> dict[str, Any]:
result = {
"resultType": "complete",
**payload,
"_meta": {SERVER_INFO_KEY: self.server_info},
}
if cacheable:
result.update({"ttlMs": 30_000, "cacheScope": "private"})
return result
@staticmethod
def _error(request_id: Any, code: int, message: str, data: Any = None) -> dict[str, Any]:
error: dict[str, Any] = {"code": code, "message": message}
if data is not None:
error["data"] = data
return {"jsonrpc": "2.0", "id": request_id, "error": error}
def _validate_metadata(self, params: dict[str, Any], request_id: Any) -> dict[str, Any] | None:
metadata = params.get("_meta")
if not isinstance(metadata, dict):
return self._error(request_id, -32602, "params._meta is required")
if PROTOCOL_KEY not in metadata:
return self._error(request_id, -32602, f"{PROTOCOL_KEY} is required")
version = metadata[PROTOCOL_KEY]
if not isinstance(version, str):
return self._error(request_id, -32602, f"{PROTOCOL_KEY} must be a string")
if version not in SUPPORTED_VERSIONS:
return self._error(
request_id,
-32022,
"Unsupported protocol version",
{"supported": list(SUPPORTED_VERSIONS), "requested": version},
)
if not isinstance(metadata.get(CLIENT_CAPABILITIES_KEY), dict):
return self._error(request_id, -32602, "clientCapabilities must be an object")
client_info = metadata.get(CLIENT_INFO_KEY)
if client_info is not None and (
not isinstance(client_info, dict)
or not isinstance(client_info.get("name"), str)
or not isinstance(client_info.get("version"), str)
):
return self._error(request_id, -32602, "clientInfo must contain name and version")
return None
def handle(self, message: Any) -> dict[str, Any] | None:
if not isinstance(message, dict):
return self._error(None, -32600, "request must be an object")
def handle(self, message: dict[str, Any]) -> dict[str, Any]:
method = message.get("method")
params = message.get("params") or {}
request_id = message.get("id")
if message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"), str):
error_id = request_id if type(request_id) in (str, int) else None
return self._error(error_id, -32600, "invalid JSON-RPC request")
if "id" not in message:
return None
if type(request_id) not in (str, int):
return self._error(None, -32600, "id must be a string or integer")
method = message["method"]
params = message.get("params", {})
if not isinstance(params, dict):
return self._error(request_id, -32602, "params must be an object")
metadata_error = self._validate_metadata(params, request_id)
if metadata_error:
return metadata_error
try:
if method == "initialize":
result: Any = {
"protocolVersion": PROTOCOL_VERSION,
"serverInfo": {"name": self.name, "version": "0.1.0"},
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
}
elif method == "tools/list":
result = {"tools": [
if method == "server/discover":
result = self._complete(
{
"name": t.name,
"description": t.description,
"inputSchema": t.input_schema,
"annotations": {"destructiveHint": t.destructive} if t.destructive else {},
}
for t in self.tools.values()
]}
"supportedVersions": list(SUPPORTED_VERSIONS),
"capabilities": self._capabilities(),
"instructions": "Use add for arithmetic and request approval before delete_user.",
},
cacheable=True,
)
elif method == "tools/list":
result = self._complete(
{
"tools": [
{
"name": tool.name,
"description": tool.description,
"inputSchema": tool.input_schema,
"annotations": (
{"destructiveHint": True} if tool.destructive else {}
),
}
for tool in sorted(self.tools.values(), key=lambda item: item.name)
]
},
cacheable=True,
)
elif method == "tools/call":
tool = self.tools[params["name"]]
output = tool.handler(**params.get("arguments", {}))
result = {"content": [{"type": "text", "text": json.dumps(output)}]}
result = self._complete(
{"content": [{"type": "text", "text": json.dumps(output)}], "isError": False}
)
elif method == "resources/list":
result = {"resources": [
{"uri": r.uri, "description": r.description} for r in self.resources.values()
]}
result = self._complete(
{
"resources": [
{
"uri": item.uri,
"name": item.name,
"description": item.description,
}
for item in sorted(self.resources.values(), key=lambda item: item.uri)
]
},
cacheable=True,
)
elif method == "resources/read":
res = self.resources[params["uri"]]
result = {"contents": [{"uri": res.uri, "mimeType": "text/plain", "text": res.handler()}]}
resource = self.resources[params["uri"]]
result = self._complete(
{
"contents": [
{
"uri": resource.uri,
"mimeType": "text/plain",
"text": resource.handler(),
}
]
},
cacheable=True,
)
elif method == "prompts/list":
result = {"prompts": [
{"name": p.name, "description": p.description, "arguments": [
{"name": a, "required": True} for a in p.arguments
]}
for p in self.prompts.values()
]}
result = self._complete(
{
"prompts": [
{
"name": item.name,
"description": item.description,
"arguments": [
{"name": argument, "required": True}
for argument in item.arguments
],
}
for item in sorted(self.prompts.values(), key=lambda item: item.name)
]
},
cacheable=True,
)
elif method == "prompts/get":
p = self.prompts[params["name"]]
rendered = p.handler(**params.get("arguments", {}))
result = {"messages": [{"role": "user", "content": {"type": "text", "text": rendered}}]}
prompt = self.prompts[params["name"]]
rendered = prompt.handler(**params.get("arguments", {}))
result = self._complete(
{
"messages": [
{
"role": "user",
"content": {"type": "text", "text": rendered},
}
]
}
)
else:
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32601, "message": f"unknown method: {method}"}}
except KeyError as e:
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": f"missing key: {e}"}}
return self._error(request_id, -32601, f"unknown method: {method}")
except KeyError as error:
return self._error(request_id, -32602, f"missing or unknown key: {error}")
return {"jsonrpc": "2.0", "id": request_id, "result": result}
class MCPClient:
"""In-memory client. Real clients read/write framed JSON over stdio or HTTP."""
def __init__(self, server: MCPServer) -> None:
self.server = server
self._id = 0
self.inbox: queue.SimpleQueue[dict[str, Any]] = queue.SimpleQueue()
def _next_id(self) -> int:
def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
self._id += 1
return self._id
def request(self, method: str, params: dict[str, Any] | None = None) -> Any:
message = {"jsonrpc": "2.0", "id": self._next_id(), "method": method, "params": params or {}}
response = self.server.handle(message)
request_params = dict(params or {})
request_params["_meta"] = request_metadata()
response = self.server.handle(
{"jsonrpc": "2.0", "id": self._id, "method": method, "params": request_params}
)
if response is None:
raise RuntimeError("request did not receive a response")
if "error" in response:
raise RuntimeError(response["error"]["message"])
return response["result"]
# Build a demo server ------------------------------------------------------
server = MCPServer("demo-server")
@server.tool(
name="add",
description="Add two integers and return the sum.",
schema={
"add",
"Add two integers and return the sum.",
{
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["a", "b"],
@@ -167,51 +300,54 @@ def add(a: int, b: int) -> dict[str, int]:
@server.tool(
name="delete_user",
description="Delete a user by id. Mutating; requires approval.",
schema={"type": "object", "properties": {"user_id": {"type": "integer"}}, "required": ["user_id"]},
"delete_user",
"Delete a user by id. Mutating; requires approval.",
{
"type": "object",
"properties": {"user_id": {"type": "integer"}},
"required": ["user_id"],
},
destructive=True,
)
def delete_user(user_id: int) -> dict[str, Any]:
return {"deleted": user_id, "note": "simulated; real impl would hit DB"}
return {"deleted": user_id, "note": "simulated"}
@server.resource("config://app", "Application config as JSON text.")
@server.resource("config://app", "app-config", "Application config as JSON text.")
def app_config() -> str:
return json.dumps({"env": "prod", "region": "us-east-1"})
@server.prompt("code_review", "Prompt the model to review code in a language.", ["language", "code"])
@server.prompt("code_review", "Review code in a language.", ["language", "code"])
def code_review(language: str, code: str) -> str:
return f"You are a senior {language} reviewer. Review for correctness and style:\n\n{code}"
# Drive it -----------------------------------------------------------------
def main() -> None:
client = MCPClient(server)
init = client.request("initialize", {"protocolVersion": PROTOCOL_VERSION, "clientInfo": {"name": "demo-client"}})
print(f"Connected to {init['serverInfo']['name']} (protocol {init['protocolVersion']})")
discovery = client.request("server/discover")
info = discovery["_meta"][SERVER_INFO_KEY]
print(f"Discovered {info['name']} (protocol {discovery['supportedVersions'][0]})")
tools = client.request("tools/list")["tools"]
print(f"\n{len(tools)} tool(s) discovered:")
for t in tools:
flag = " [destructive]" if t.get("annotations", {}).get("destructiveHint") else ""
print(f" - {t['name']}{flag}: {t['description']}")
for tool in tools:
flag = " [destructive]" if tool.get("annotations", {}).get("destructiveHint") else ""
print(f" - {tool['name']}{flag}: {tool['description']}")
add_result = client.request("tools/call", {"name": "add", "arguments": {"a": 40, "b": 2}})
print("\nCall add(40, 2) ->", add_result["content"][0]["text"])
resources = client.request("resources/list")["resources"]
print(f"\n{len(resources)} resource(s):")
for r in resources:
print(f" - {r['uri']}: {r['description']}")
print(f"\n{len(resources)} resource(s): {resources[0]['uri']}")
config = client.request("resources/read", {"uri": "config://app"})
print("\nRead config://app ->", config["contents"][0]["text"])
print("Read config://app ->", config["contents"][0]["text"])
prompt = client.request("prompts/get", {"name": "code_review", "arguments": {"language": "Python", "code": "x = 1\n"}})
print("\nRender code_review prompt ->", prompt["messages"][0]["content"]["text"][:80], "...")
prompt = client.request(
"prompts/get",
{"name": "code_review", "arguments": {"language": "Python", "code": "x = 1\n"}},
)
print("\nRender code_review prompt ->", prompt["messages"][0]["content"]["text"][:80])
if __name__ == "__main__":
@@ -0,0 +1,151 @@
import unittest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from main import (
CLIENT_CAPABILITIES_KEY,
CLIENT_INFO_KEY,
PROTOCOL_KEY,
PROTOCOL_VERSION,
SERVER_INFO_KEY,
SUPPORTED_VERSIONS,
MCPClient,
request_metadata,
server,
)
def envelope(method, params=None, request_id=1):
return {
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": params or {},
}
class StatelessMCPTests(unittest.TestCase):
def test_request_metadata_carries_version_capabilities_and_identity(self):
metadata = request_metadata()
self.assertEqual(PROTOCOL_VERSION, metadata[PROTOCOL_KEY])
self.assertEqual({}, metadata[CLIENT_CAPABILITIES_KEY])
self.assertEqual("demo-client", metadata[CLIENT_INFO_KEY]["name"])
def test_discover_returns_identity_capabilities_and_cache_policy(self):
client = MCPClient(server)
result = client.request("server/discover")
self.assertEqual("complete", result["resultType"])
self.assertEqual(list(SUPPORTED_VERSIONS), result["supportedVersions"])
self.assertEqual("demo-server", result["_meta"][SERVER_INFO_KEY]["name"])
self.assertGreater(result["ttlMs"], 0)
self.assertIn(result["cacheScope"], {"public", "private"})
def test_missing_metadata_is_rejected(self):
response = server.handle(envelope("tools/list"))
self.assertEqual(-32602, response["error"]["code"])
def test_missing_protocol_version_is_invalid_params(self):
metadata = request_metadata()
del metadata[PROTOCOL_KEY]
response = server.handle(envelope("tools/list", {"_meta": metadata}))
self.assertEqual(-32602, response["error"]["code"])
self.assertNotIn("data", response["error"])
def test_non_string_protocol_version_is_invalid_params(self):
metadata = request_metadata()
metadata[PROTOCOL_KEY] = 20260728
response = server.handle(envelope("tools/list", {"_meta": metadata}))
self.assertEqual(-32602, response["error"]["code"])
def test_missing_client_capabilities_is_invalid_params(self):
metadata = request_metadata()
del metadata[CLIENT_CAPABILITIES_KEY]
response = server.handle(envelope("tools/list", {"_meta": metadata}))
self.assertEqual(-32602, response["error"]["code"])
def test_unsupported_protocol_version_returns_spec_error(self):
metadata = request_metadata(protocol_version="2025-11-25")
response = server.handle(envelope("tools/list", {"_meta": metadata}))
self.assertEqual(-32022, response["error"]["code"])
self.assertEqual(list(SUPPORTED_VERSIONS), response["error"]["data"]["supported"])
self.assertEqual("2025-11-25", response["error"]["data"]["requested"])
def test_tool_list_is_deterministic_and_cacheable(self):
result = MCPClient(server).request("tools/list")
self.assertEqual(["add", "delete_user"], [tool["name"] for tool in result["tools"]])
self.assertEqual("private", result["cacheScope"])
def test_tool_call_returns_typed_complete_result(self):
result = MCPClient(server).request(
"tools/call", {"name": "add", "arguments": {"a": 20, "b": 22}}
)
self.assertEqual("complete", result["resultType"])
self.assertEqual('{"sum": 42}', result["content"][0]["text"])
self.assertIn(SERVER_INFO_KEY, result["_meta"])
def test_resource_read_is_cacheable(self):
result = MCPClient(server).request("resources/read", {"uri": "config://app"})
self.assertEqual("complete", result["resultType"])
self.assertEqual("private", result["cacheScope"])
def test_resource_list_includes_required_name(self):
result = MCPClient(server).request("resources/list")
self.assertEqual("app-config", result["resources"][0]["name"])
def test_every_success_result_is_typed_and_identifies_server(self):
calls = [
("server/discover", None),
("tools/list", None),
("tools/call", {"name": "add", "arguments": {"a": 1, "b": 2}}),
("resources/list", None),
("resources/read", {"uri": "config://app"}),
("prompts/list", None),
(
"prompts/get",
{"name": "code_review", "arguments": {"language": "Python", "code": "x=1"}},
),
]
client = MCPClient(server)
for method, params in calls:
with self.subTest(method=method):
result = client.request(method, params)
self.assertEqual("complete", result["resultType"])
self.assertEqual("demo-server", result["_meta"][SERVER_INFO_KEY]["name"])
def test_notification_is_ignored_without_a_json_rpc_response(self):
response = server.handle(
{
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": {"requestId": 1},
}
)
self.assertIsNone(response)
def test_null_request_id_is_invalid(self):
response = server.handle(
envelope("tools/list", {"_meta": request_metadata()}, request_id=None)
)
self.assertEqual(-32600, response["error"]["code"])
self.assertIsNone(response["id"])
def test_wrong_json_rpc_version_is_invalid(self):
message = envelope("tools/list", {"_meta": request_metadata()}, request_id=12)
message["jsonrpc"] = "1.0"
response = server.handle(message)
self.assertEqual(12, response["id"])
self.assertEqual(-32600, response["error"]["code"])
def test_unknown_method_uses_json_rpc_method_not_found(self):
response = server.handle(
envelope("unknown/method", {"_meta": request_metadata()}, request_id=9)
)
self.assertEqual(9, response["id"])
self.assertEqual(-32601, response["error"]["code"])
if __name__ == "__main__":
unittest.main()
@@ -1,45 +1,129 @@
# Model Context Protocol (MCP)
> Every LLM app built before 2025 invented its own tool schema. Then Anthropic shipped MCP, Claude adopted it, OpenAI adopted it, and by 2026 it is the default wire format for connecting any LLM to any tool, data source, or agent. Write one MCP server and every host talks to it.
> MCP gives an AI host one protocol for discovering and invoking tools, resources, and prompts. The 2026-07-28 revision makes that protocol stateless: capability and version context travels with every request, not in a connection-bound handshake.
**Type:** Build
**Languages:** Python
**Prerequisites:** Phase 11 · 09 (Function Calling), Phase 11 · 03 (Structured Outputs)
**Time:** ~75 minutes
## Learning Objectives
- Distinguish an MCP host, client, server, transport, and server primitive.
- Build a JSON-RPC request with the metadata required by MCP 2026-07-28.
- Use `server/discover` to inspect versions, identity, and capabilities.
- Return typed and cache-aware results from tools, resources, and prompts.
- Explain how modern stateless MCP interoperates with handshake-era servers.
- Choose safe state, transport, and approval boundaries for a server.
## The Problem
You ship a chatbot that needs three tools: a database query, a calendar API, and a file reader. You write three JSON schemas for Claude. Then sales wants the same tools in ChatGPT — you rewrite them for OpenAI's `tools` parameter. Then you add Cursor, Zed, and Claude Code — three more rewrites, each with subtly different JSON conventions. A week later, Anthropic adds a new field; you update six schemas.
Your application needs a database query, a calendar operation, and a file reader. Without a shared protocol, every AI host needs custom discovery, invocation, errors, transport, and authorization glue for those same capabilities.
This was the pre-2025 reality. Every host (the thing running an LLM) and every server (the thing exposing tools and data) shipped bespoke protocols. Scaling meant an N×M integration matrix.
MCP reduces that integration matrix. A server publishes a standard JSON-RPC surface. A compliant client can discover the surface, present it to a model or user, invoke it, and interpret the result without a server-specific adapter.
Model Context Protocol collapses that matrix. One JSON-RPC-based spec. One server exposes tools, resources, and prompts. Any compliant host — Claude Desktop, ChatGPT, Cursor, Claude Code, Zed, and a long tail of agent frameworks — can discover and call them without custom glue.
As of early 2026, MCP is the default tool-and-context protocol across the big three (Anthropic, OpenAI, Google) and every major agent harness.
The important boundary is easy to miss. MCP standardizes communication. It does not decide which tool the model should call, make untrusted content safe, or turn a stateless request into durable application state. Your host and server still own those decisions.
## The Concept
![MCP: one host, one server, three capabilities](../assets/mcp-architecture.svg)
![MCP host, stateless request, and server primitives](../assets/mcp-architecture.svg)
**The three primitives.** An MCP server exposes exactly three things.
### The three server primitives
1. **Tools** — functions the model can call. Analog of OpenAI's `tools` or Anthropic's `tool_use`. Each has a name, description, JSON Schema input, and a handler.
2. **Resources** — read-only content the model or user can request (files, database rows, API responses). Addressed by URI.
3. **Prompts** — reusable templated prompts the user can invoke as shortcuts.
1. **Tools** are callable actions. Each tool has a name, description, JSON Schema input, and handler.
2. **Resources** are named, URI-addressed content that a client can read.
3. **Prompts** are reusable templates that a host can expose to a user.
**The wire format.** JSON-RPC 2.0 over stdio, WebSocket, or streamable HTTP. Every message is `{"jsonrpc": "2.0", "method": "...", "params": {...}, "id": N}`. Discovery methods are `tools/list`, `resources/list`, `prompts/list`. Invocation methods are `tools/call`, `resources/read`, `prompts/get`.
The host is the AI application. An MCP client inside that host speaks to one server. The transport carries JSON-RPC messages between them.
**Host vs client vs server.** The host is the LLM application (Claude Desktop). The client is a sub-component of the host that speaks to exactly one server. The server is your code. One host can mount many servers simultaneously.
### Stateless requests replace the handshake
### The handshake
MCP 2026-07-28 removes `initialize` and `notifications/initialized`. It also removes protocol-level sessions. Every request carries the context needed to interpret it in `params._meta`:
Every session opens with `initialize`. The client sends protocol version and its capabilities. The server responds with its version, name, and the capability set it supports (`tools`, `resources`, `prompts`, `logging`, `roots`). Everything after is negotiated against those capabilities.
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {
"name": "lesson-client",
"version": "1.0.0"
}
}
}
}
```
### What MCP is not
The protocol version and client capabilities are required. Client identity is recommended. A missing `_meta`, a missing required field, or a required field with the wrong type is malformed and returns Invalid Params (`-32602`). A well-formed version string that the server does not support returns `UnsupportedProtocolVersionError` (`-32022`). A server can process a valid request without recovering a prior negotiation record.
- Not a retrieval API. RAG (Phase 11 · 06) still decides what to pull; MCP is the transport for exposing retrieval results as resources.
- Not an agent framework. MCP is the plumbing; frameworks like LangGraph, PydanticAI, and OpenAI Agents SDK sit above it.
- Not tied to Anthropic. The spec and reference implementations are open source under the `modelcontextprotocol` org.
Stateless does not mean an application can never maintain state. It means that state is not hidden behind an MCP connection or `Mcp-Session-Id`. If a workflow needs continuity, the server mints an opaque handle and the client passes that handle as an ordinary tool argument on later calls. Authorization must still be checked on every request.
### Discovery and version selection
Every modern server implements `server/discover`. The result advertises supported versions, capabilities, and server identity:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": {
"tools": {},
"resources": {},
"prompts": {}
},
"ttlMs": 3600000,
"cacheScope": "public",
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "demo-server",
"version": "1.0.0"
}
}
}
}
```
A client may call another method directly and handle a version error, but discovery makes capability display and version selection explicit. An unsupported version returns `UnsupportedProtocolVersionError` with code `-32022`. Its data contains `supported`, an array of server revisions, and `requested`, the rejected revision.
On stdio, a dual-era client probes with `server/discover`. A discovery result or a recognized modern error such as `UnsupportedProtocolVersionError` identifies a modern server. Any error or timeout that is not recognized as modern permits fallback to the 2025-11-25 `initialize` flow. Legacy behavior is compatibility code, not the modern default.
### Results are explicit
Every core 2026-07-28 result has `resultType`:
- `complete` means the operation finished.
- `input_required` means the server needs another round trip through the Multi Round-Trip Requests pattern. Core servers may return it only from `tools/call`, `resources/read`, or `prompts/get`.
Clients must treat a legacy result that omits `resultType` as complete.
Servers should include `io.modelcontextprotocol/serverInfo` in every result's `_meta`. This identity is self-reported and is for display, logging, and debugging, not for security decisions.
List and read results also carry `ttlMs` and `cacheScope`. A deterministic `tools/list` order plus a freshness hint lets clients cache discovery safely and improves prompt-cache stability. `cacheScope: public` permits shared caching; `private` confines reuse to the calling context.
### The wire format and transport
MCP uses JSON-RPC 2.0 over stdio or Streamable HTTP.
- A request has `jsonrpc`, `id`, `method`, and `params`.
- A response has the matching `id` and either `result` or `error`.
- A notification has no `id` and expects no response.
Modern Streamable HTTP exposes one endpoint that accepts POST. Each JSON-RPC message gets its own POST. A request POST receives either one JSON object or a request-scoped Server-Sent Events stream that ends with the final response. An accepted notification POST receives HTTP 202 with no response body; this core revision defines no client-to-server notifications over Streamable HTTP.
There is no standalone MCP GET stream, DELETE session endpoint, `Mcp-Session-Id`, or `Last-Event-ID` replay in 2026-07-28. Long-lived change notifications use a `subscriptions/listen` POST whose response remains open as an SSE stream.
### Client input without server-initiated requests
Older revisions let a server send requests such as `sampling/createMessage`, `roots/list`, or `elicitation/create` over a stream. The current protocol uses Multi Round-Trip Requests instead. An eligible tool call, resource read, or prompt get returns `resultType: input_required` with at least one of `inputRequests` or `requestState`. The client gathers any requested input, retries the original method with a new JSON-RPC ID and the corresponding `inputResponses`, and echoes the exact `requestState` when one was provided. If no `inputRequests` were present, the retry omits `inputResponses`.
Roots, Sampling, and Logging remain functional but are deprecated, so new implementations should not adopt them. Existing Roots or Sampling requests travel inside MRTR `inputRequests`, never as independent server-to-client JSON-RPC requests. Prefer explicit file or directory parameters, resource URIs, server configuration, and direct model-provider integration. Use stderr for stdio diagnostics and OpenTelemetry for production telemetry.
```figure
mcp-nxm-collapse
@@ -47,164 +131,126 @@ mcp-nxm-collapse
## Build It
### Step 1: a minimal MCP server
### Step 1: register a server surface
The official Python SDK is `mcp` (formerly `mcp-python`). The high-level `FastMCP` helper decorates handlers.
Registration stays simple even though the request contract changed:
```python
from mcp.server.fastmcp import FastMCP
server = MCPServer("demo-server")
mcp = FastMCP("demo-server")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
@mcp.resource("config://app")
def app_config() -> str:
"""Return the app's current JSON config."""
return '{"env": "prod", "region": "us-east-1"}'
@mcp.prompt()
def code_review(language: str, code: str) -> str:
"""Review code for correctness and style."""
return f"You are a senior {language} reviewer. Review:\n\n{code}"
if __name__ == "__main__":
mcp.run(transport="stdio")
```
Three decorators register the three primitives. The type hints become the JSON Schema the host sees. Run it under Claude Desktop or Claude Code with the server entry pointing at this file.
### Step 2: calling an MCP server from a host
The official Python client speaks JSON-RPC. Pairing it with the Anthropic SDK takes a dozen lines.
```python
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp import ClientSession
params = StdioServerParameters(command="python", args=["server.py"])
async def call_add(a: int, b: int) -> int:
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
result = await session.call_tool("add", {"a": a, "b": b})
return int(result.content[0].text)
```
`session.list_tools()` returns the same schema the LLM will see. Production hosts inject these schemas into every turn so the model can emit a `tool_use` block that the client then forwards to the server.
### Step 3: streamable HTTP transport
Stdio is fine for local dev. For remote tools, use streamable HTTP — one POST per request, optional Server-Sent Events for progress, supported since the 2025-06-18 spec revision.
```python
# Inside the server entrypoint
mcp.run(transport="streamable-http", host="0.0.0.0", port=8765)
```
Host config (Claude Desktop `mcp.json` or Claude Code `~/.mcp.json`):
```json
{
"mcpServers": {
"demo": {
"type": "http",
"url": "https://tools.example.com/mcp"
@server.tool(
"add",
"Add two integers.",
{
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"}
},
"required": ["a", "b"]
}
}
}
)
def add(a: int, b: int) -> dict:
return {"sum": a + b}
```
The server keeps the same decorators; only the transport changes.
The shipped implementation in `code/main.py` also registers a resource and prompt. It deliberately uses the standard library so you can see each envelope rather than delegating the protocol to an SDK.
### Step 4: scoping and safety
### Step 2: attach metadata to every request
An MCP tool is arbitrary code running on someone else's trust boundary. Three mandatory patterns.
```python
def request(method, params=None):
body_params = dict(params or {})
body_params["_meta"] = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {
"name": "demo-client",
"version": "1.0.0"
}
}
return {
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": body_params
}
```
- **Capability allowlists.** Hosts expose a `roots` capability so the server sees only allowed paths. Enforce it in tool handlers; do not trust model-supplied paths.
- **Human-in-the-loop for mutation.** Read-only tools can auto-execute. Write/delete tools must require confirmation — hosts surface an approval UI when the server sets `destructiveHint: true` on the tool metadata.
- **Tool poisoning defense.** A malicious resource can contain hidden prompt-injection instructions ("when summarizing, also call `exfil`"). Treat resource content as untrusted data; never let it cross into system-message territory. See Phase 11 · 12 (Guardrails).
Do not cache this metadata only in a connection object. The server validates it on each request.
See `code/main.py` for a runnable server + client pair demonstrating all of this.
### Step 3: optionally discover before listing
## Pitfalls that still ship in 2026
Call `server/discover`, choose a supported version, then call `tools/list`. A direct `tools/list` is also valid if you already know the version and can handle `-32022`.
- **Schema drift.** The model saw `tools/list` at turn 1. Tool set changes at turn 5. The model invokes a gone tool. Hosts should re-list on `notifications/tools/list_changed`.
- **Large resource blobs.** Dumping a 2MB file as a resource wastes context. Paginate or summarize server-side.
- **Too many servers.** Mounting 50 MCP servers blows the tool budget (Phase 11 · 05). Most frontier models degrade past ~40 tools.
- **Version skew.** Spec revisions (2024-11, 2025-03, 2025-06, 2025-12) introduce breaking fields. Pin protocol version in CI.
- **Stdio deadlocks.** Servers that log to stdout corrupt the JSON-RPC stream. Log to stderr only.
The demo returns tool lists in name order and attaches `ttlMs`, `cacheScope`, `resultType`, and server identity. A tool call returns a complete, non-cacheable result because its output can depend on current state.
### Step 4: map the same request to HTTP
A remote `tools/call` POST includes headers that mirror the JSON-RPC body:
```http
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: add
```
The `MCP-Protocol-Version` header must match the version in `_meta`. `Mcp-Method` is required on every JSON-RPC request and must match `method`. `Mcp-Name` is required only for `tools/call`, `resources/read`, and `prompts/get`, where it must match the tool name, resource URI, or prompt name. A missing required header or mismatch returns HTTP 400 with `HeaderMismatch` code `-32020`.
### Step 5: enforce safety outside protocol state
- Validate authorization and audience on every HTTP request.
- Bind local servers to localhost and validate `Origin` on Streamable HTTP.
- Mark mutating tools with `destructiveHint: true` and require host approval.
- Pass directory and file scope explicitly instead of depending on deprecated Roots.
- Treat resources and tool output as untrusted data.
- Keep stdout reserved for JSON-RPC under stdio; write diagnostics to stderr.
## Use It
The 2026 MCP stack:
Run the lesson from its directory:
| Situation | Pick |
|-----------|------|
| Local dev, single-user tools | Python `FastMCP`, stdio transport |
| Remote team tools / SaaS integration | Streamable HTTP, OAuth 2.1 auth |
| TypeScript host (VS Code extension, web app) | `@modelcontextprotocol/sdk` |
| High-throughput server, typed access | Official Rust SDK (`modelcontextprotocol/rust-sdk`) |
| Exploring ecosystem servers | `modelcontextprotocol/servers` monorepo (Filesystem, GitHub, Postgres, Slack, Puppeteer) |
```bash
python3 code/main.py
cd code
python3 -m unittest discover tests -v
```
Rule of thumb: if a tool is read-only, cacheable, and called from two or more hosts, ship it as an MCP server. If it is one-off inline logic, keep it as a local function (Phase 11 · 09).
The first line should report discovery of `demo-server` at protocol `2026-07-28`. Then inspect `MCPClient.request`: it reconstructs `_meta` for every call. Remove the metadata from one request and observe the server reject it.
## Ship It
Save `outputs/skill-mcp-server-designer.md`:
```markdown
---
name: mcp-server-designer
description: Design and scaffold an MCP server with tools, resources, and safety defaults.
version: 1.0.0
phase: 11
lesson: 14
tags: [llm-engineering, mcp, tool-use]
---
Given a domain (internal API, database, file source) and the hosts that will mount the server, output:
1. Primitive map. Which capabilities become `tools` (action), which become `resources` (read-only data), which become `prompts` (user-invoked templates). One line per primitive.
2. Auth plan. Stdio (trusted local), streamable HTTP with API key, or OAuth 2.1 with PKCE. Pick and justify.
3. Schema draft. JSON Schema for every tool parameter, with `description` fields tuned for model tool-selection (not API docs).
4. Destructive-action list. Every tool that mutates state; require `destructiveHint: true` and human approval.
5. Test plan. Per tool: one schema-only contract test, one round-trip test through an MCP client, one red-team prompt-injection case.
Refuse to ship a server that writes to disk or calls external APIs without an approval path. Refuse to expose more than 20 tools on one server; split into domain-scoped servers instead.
```
`outputs/skill-mcp-server-designer.md` turns a domain into a stateless MCP design. Its acceptance gate requires a discovery result, per-request metadata policy, deterministic cache-aware lists, explicit state handles, transport headers, authorization, and approval rules.
## Exercises
1. **Easy.** Extend the `demo-server` with a `subtract` tool. Connect it from Claude Desktop. Confirm the host picks up the new tool without a restart by emitting a `tools/list_changed` notification.
2. **Medium.** Add a `resource` that exposes the last 100 lines of `/var/log/app.log`. Enforce a roots allowlist so `../etc/passwd` is blocked even if the model asks for it.
3. **Hard.** Build an MCP proxy that multiplexes three upstream servers (Filesystem, GitHub, Postgres) into one aggregate surface. Handle name collisions and forward `notifications/tools/list_changed` cleanly.
1. Add a `subtract` tool and confirm `tools/list` remains alphabetically ordered.
2. Remove the protocol-version key and verify Invalid Params (`-32602`). Then send the well-formed but unsupported version `2025-11-25`, verify `-32022`, confirm `requested` echoes that revision, and choose from `supported`.
3. Add a server-minted `draftId` to a create operation, then require it as an argument to update. Explain why that is application state rather than a protocol session.
4. Return `input_required` from a tool that needs user confirmation. Retry the original call with a new ID, an `inputResponses` entry, and the exact `requestState` instead of inventing a server-to-client JSON-RPC request.
5. Sketch a dual-era stdio client. Treat a result or recognized modern error as modern, and permit fallback to `initialize` only for an unrecognized error or timeout.
## Key Terms
| Term | What people say | What it actually means |
|------|-----------------|-----------------------|
| MCP | "Tool protocol for LLMs" | JSON-RPC 2.0 spec for exposing tools, resources, and prompts to any LLM host. |
| Host | "Claude Desktop" | The LLM application — owns the model and user UI, mounts one or more clients. |
| Client | "Connection" | A per-server connection inside the host that speaks JSON-RPC to exactly one server. |
| Server | "The thing with the tools" | Your code; advertises tools/resources/prompts and handles their invocation. |
| Tool | "Function call" | Model-invokable action with a JSON Schema input and a text/JSON result. |
| Resource | "Read-only data" | URI-addressed content (file, row, API response) the host can request. |
| Prompt | "Saved prompt" | User-invokable template (often with arguments) surfaced as a slash-command. |
| Stdio transport | "Local dev mode" | Parent host spawns the server as a child process; JSON-RPC over stdin/stdout. |
| Streamable HTTP | "The 2025-06 remote transport" | POST for requests, optional SSE for server-initiated messages; replaces the older SSE-only transport. |
|------|-----------------|------------------------|
| MCP | "Tool protocol for LLMs" | JSON-RPC protocol for server discovery, tools, resources, prompts, and extensions |
| Host | "The AI app" | Owns the model and UI and mounts one or more MCP clients |
| Client | "The connector" | Speaks MCP to one server on behalf of a host |
| Stateless MCP | "No session" | Every request carries version and capabilities; no protocol state is keyed by a connection |
| `server/discover` | "Capability probe" | Required server method advertising versions, capabilities, and identity |
| `resultType` | "Result state" | Marks a result as `complete` or `input_required` |
| State handle | "Workflow id" | Server-minted application identifier passed as an ordinary argument |
| Streamable HTTP | "Remote transport" | One POST endpoint with JSON or request-scoped SSE responses |
| MRTR | "Ask and retry" | Input request embedded in a result, followed by a retry of the original operation |
## Further Reading
- [Model Context Protocol specification](https://modelcontextprotocol.io/specification) — canonical reference, versioned by date.
- [modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers) — Filesystem, GitHub, Postgres, Slack, Puppeteer reference servers.
- [Anthropic — Introducing MCP (Nov 2024)](https://www.anthropic.com/news/model-context-protocol) — launch post with design rationale.
- [Python SDK](https://github.com/modelcontextprotocol/python-sdk) — official SDK used in this lesson.
- [Security considerations for MCP](https://modelcontextprotocol.io/docs/concepts/security) — roots, destructive hints, tool poisoning.
- [Google A2A specification](https://a2a-protocol.org/latest/) — Agent2Agent protocol; the sibling standard for agent-to-agent communication that complements MCP's agent-to-tool scope.
- [Anthropic — Building effective agents (Dec 2024)](https://www.anthropic.com/research/building-effective-agents) — where MCP sits in the broader pattern library for agent design (augmented LLM, workflows, autonomous agents).
- [MCP 2026-07-28 key changes](https://modelcontextprotocol.io/specification/2026-07-28/changelog)
- [MCP server discovery](https://modelcontextprotocol.io/specification/2026-07-28/server/discover)
- [MCP Streamable HTTP](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http)
- [MCP Multi Round-Trip Requests](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr)
- [MCP deprecated features](https://modelcontextprotocol.io/specification/2026-07-28/deprecated)
@@ -1,18 +1,23 @@
---
name: mcp-server-designer
description: Design and scaffold an MCP server with tools, resources, and safety defaults.
version: 1.0.0
description: Design a stateless MCP 2026-07-28 server with explicit discovery, state, transport, and safety contracts.
version: 2.0.0
phase: 11
lesson: 14
tags: [llm-engineering, mcp, tool-use]
tags: [llm-engineering, mcp, stateless, tool-use]
---
Given a domain (internal API, database, file source) and the hosts that will mount the server, output:
1. Primitive map. Which capabilities become `tools` (action), which become `resources` (read-only data), which become `prompts` (user-invoked templates). One line per primitive.
2. Auth plan. Stdio (trusted local), streamable HTTP with API key, or OAuth 2.1 with PKCE. Pick and justify.
3. Schema draft. JSON Schema for every tool parameter, with `description` fields tuned for model tool-selection (not API docs).
4. Destructive-action list. Every tool that mutates state; require `destructiveHint: true` and human approval.
5. Test plan. Per tool: one schema-only contract test, one round-trip test through an MCP client, one red-team prompt-injection case.
2. Discovery contract. Draft `server/discover` with the exact versions the implementation supports, capabilities, server identity, instructions, `ttlMs`, and `cacheScope`.
3. Request contract. Require a string protocol version and object client capabilities in `params._meta` on every request. Recommend client identity. Return Invalid Params (`-32602`) for missing or ill-typed required metadata. Return `UnsupportedProtocolVersionError` (`-32022`) with `data.supported` and `data.requested` only for a supplied version string the server does not implement.
4. Result contract. Add `resultType`, server identity metadata, deterministic list ordering, and cache policy to every applicable result.
5. MRTR plan. Use `input_required` only for `tools/call`, `resources/read`, or `prompts/get`. Include at least one of `inputRequests` or opaque `requestState`; retry the original method with a new JSON-RPC ID, corresponding input responses when requested, and the exact state value when present.
6. State plan. For every multi-call workflow, define a server-minted opaque handle passed as an ordinary tool argument. Do not hide state behind a connection or protocol session.
7. Transport and auth plan. Choose stdio or the 2026-07-28 Streamable HTTP POST endpoint. For HTTP, define Origin validation and per-request authorization. Require `MCP-Protocol-Version` on POST requests, `Mcp-Method` on JSON-RPC requests, and `Mcp-Name` only for `tools/call`, `resources/read`, and `prompts/get`. An accepted notification POST returns HTTP 202 with no body.
8. Schema draft. Write JSON Schema for every tool parameter, with descriptions tuned for model selection and explicit bounds for untrusted input.
9. Destructive-action list. Mark every mutating tool with `destructiveHint: true` and require human approval.
10. Verification plan. Cover notifications producing no JSON-RPC response, malformed envelopes and request IDs, metadata rejection, discovery, deterministic lists, version mismatch, cache fields, header-to-body mismatch, authorization, approval, and one prompt-injection case.
Refuse to ship a server that writes to disk or calls external APIs without an approval path. Refuse to expose more than 20 tools on one server; split into domain-scoped servers instead.
Reject a design that uses `initialize`, `notifications/initialized`, `Mcp-Session-Id`, standalone HTTP GET, HTTP DELETE, or `Last-Event-ID` as its modern path. Permit those mechanisms only inside a clearly isolated adapter for protocol versions through 2025-11-25. Do not add deprecated Roots, Sampling, or Logging to a new implementation; compatibility support must be labeled and Roots or Sampling input must use MRTR. Refuse a server that writes to disk or calls an external API without authorization, validation, and an approval path.
@@ -3,64 +3,76 @@
"title": "Model Context Protocol",
"questions": [
{
"stage": "post",
"question": "What three primitives does an MCP server expose?",
"stage": "pre",
"question": "In MCP 2026-07-28, where does a client send its protocol version and capabilities?",
"options": [
"Agents, skills, workflows",
"Endpoints, webhooks, queues",
"Functions, types, classes",
"Tools, resources, prompts"
],
"correct": 3,
"explanation": ""
},
{
"stage": "post",
"question": "What wire format does MCP use?",
"options": [
"gRPC with protobuf",
"JSON-RPC 2.0",
"REST with OpenAPI",
"GraphQL over HTTP"
],
"correct": 1,
"explanation": ""
},
{
"stage": "post",
"question": "Which metadata field signals a tool mutates state and should require human approval?",
"options": [
"destructiveHint: true",
"readonly: false",
"mutating: true",
"requiresAuth: true"
],
"correct": 0,
"explanation": ""
},
{
"stage": "post",
"question": "What is the 2025-06-18 transport that replaced the earlier SSE-only remote transport?",
"options": [
"gRPC bidi",
"WebSocket-only",
"Streamable HTTP",
"WebTransport"
"Only in the HTTP cookie",
"Only in an initialize request",
"In params._meta on every request",
"In a server-side session record"
],
"correct": 2,
"explanation": ""
"explanation": "Modern MCP is stateless. Each request carries the protocol version and client capabilities in params._meta."
},
{
"stage": "check",
"question": "What must every modern MCP server implement for up-front version and capability discovery?",
"options": [
"server/discover",
"sessions/create",
"logging/setLevel",
"initialize"
],
"correct": 0,
"explanation": "server/discover advertises supported versions, server capabilities, and server identity."
},
{
"stage": "check",
"question": "Which Streamable HTTP shape is current in MCP 2026-07-28?",
"options": [
"A DELETE request after every tool call",
"A WebSocket session identified by Mcp-Session-Id",
"One POST per JSON-RPC message; request POSTs receive JSON or request-scoped SSE",
"A GET stream plus a separate POST endpoint"
],
"correct": 2,
"explanation": "The current transport has one POST endpoint. Request POSTs receive JSON or request-scoped SSE; an accepted notification POST receives HTTP 202 with no body."
},
{
"stage": "check",
"question": "How should a server distinguish missing protocol-version metadata from a well-formed but unsupported version?",
"options": [
"Return -32022 for both cases",
"Return HTTP 301 for a missing version and mint Mcp-Session-Id for an unsupported version",
"Return -32602 for missing or non-string metadata and -32022 with supported/requested for an unsupported string",
"Accept a missing version and return -32601 for an unsupported version"
],
"correct": 2,
"explanation": "A missing or ill-typed required field is malformed JSON-RPC params. UnsupportedProtocolVersionError applies only when a supplied version string is not implemented."
},
{
"stage": "post",
"question": "When should a tool be split into its own MCP server instead of staying inline?",
"question": "A multi-step tool needs continuity across calls. What is the modern MCP design?",
"options": [
"When it is called from two or more hosts and is read-only/cacheable",
"Never; MCP is only for local dev",
"When it is called fewer than 10 times per day",
"When it returns more than 1KB of data"
"Put mutable state in clientInfo",
"Restore Mcp-Session-Id for this tool only",
"Mint an opaque handle and require it as a later tool argument",
"Store state behind the TCP connection"
],
"correct": 0,
"explanation": ""
"correct": 2,
"explanation": "Cross-call state is explicit application state. A server-minted handle travels as an ordinary, authorized argument."
},
{
"stage": "post",
"question": "How does a modern server ask for user, model, or root input during a tool operation?",
"options": [
"It writes the question to stderr",
"It opens a second SSE connection",
"It returns input_required and the client retries with inputResponses",
"It sends an independent server-to-client JSON-RPC request"
],
"correct": 2,
"explanation": "The Multi Round-Trip Requests pattern embeds inputRequests in an input_required result and retries the original operation."
}
]
}