MCP Protocol Architecture: JSON-RPC 2.0, Transports & Capabilities

13 min read Module 1 of 10 Topic 2 of 30

What you'll learn

  • Describe the JSON-RPC 2.0 message types MCP uses: requests, responses, notifications, and batches
  • Walk through the initialize handshake and capability negotiation sequence in full, including version mismatch handling
  • Distinguish tools, resources, and prompts and know when to use each primitive, including edge cases
  • Explain how list-changed notifications keep long-lived client sessions synchronized with a changing server
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

MCP messages are JSON-RPC 2.0 over whichever transport the server uses (stdio or HTTP-based). JSON-RPC defines three message shapes: requests (have an id, expect a response), responses (correlate to a request id, contain result or error), and notifications (no id, fire-and-forget, no response expected). Understanding these three shapes, and a handful of edge cases around them, is enough to read any MCP wire trace and to reason correctly about session lifecycle.

// Request: client asks for the tool list
{"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}

// Response: server replies with matching id
{"jsonrpc": "2.0", "id": 1, "result": {"tools": [{"name": "kb_query", "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}}}]}}

// Notification: no id, no response expected
{"jsonrpc": "2.0", "method": "notifications/tools/list_changed"}

Error Responses and Why They Matter for Client Reliability

JSON-RPC defines a standard error object shape that every conforming client can parse identically, regardless of which MCP server produced it: {"jsonrpc": "2.0", "id": 1, "error": {"code": -32602, "message": "Invalid params"}}. The standard reserves a range of codes for protocol-level errors (-32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal error), leaving the rest of the negative integer space and all of the non-negative space open for server-defined errors. This matters in practice: a client library can implement one generic retry/backoff policy for -32603 (internal error, might be transient) that is entirely different from its handling of -32601 (method not found, retrying is pointless, the client asked for a capability the server does not have). Getting this distinction right in your own server’s error responses, covered concretely in Lesson 6, is what lets client-side retry logic behave sensibly without server-specific special-casing.

// A malformed tools/call gets a standard, parseable error, not a stack trace
{"jsonrpc": "2.0", "id": 5, "error": {"code": -32602, "message": "Invalid params: 'top_k' must be a positive integer"}}

The Initialization Handshake, in Full

Every MCP session begins with a capability negotiation. The client tells the server what protocol version and client capabilities it supports; the server responds with its own version and the capabilities (tools, resources, prompts, logging, sampling) it actually implements. Only capabilities both sides agree on are used for the rest of the session, this is what lets old clients and new servers (or vice versa) interoperate without crashing.

sequenceDiagram
    participant C as Client
    participant S as MCP Server

    C->>S: initialize {protocolVersion, capabilities, clientInfo}
    S-->>C: result {protocolVersion, capabilities, serverInfo}
    C->>S: notifications/initialized
    Note over C,S: Session established, normal operation begins
    C->>S: tools/list
    S-->>C: result {tools: [...]}
    C->>S: tools/call {name, arguments}
    S-->>C: result {content: [...]}

    style C fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style S fill:#f0fdf9,stroke:#0D9488,color:#0F172A
from mcp.server.fastmcp import FastMCP

mcp = FastMCP(
    name="policy-docs-server",
    version="1.0.0",
)
# FastMCP negotiates protocolVersion and advertises capabilities
# automatically based on what you register below (tools, resources, prompts).

What happens when the client and server support different protocol versions is worth spelling out, since it is the most common integration failure teams hit when connecting an older client to a newer server or vice versa. The server responds during initialize with the highest protocol version it supports that is not newer than what the client requested. If the client cannot work with that version at all, it is expected to close the connection cleanly rather than proceed and risk sending messages the server cannot parse. In practice, this means a well-behaved MCP server should be conservative about dropping support for older protocol versions until telemetry (Lesson 25’s tracing, or simple version-header logging) confirms no clients still negotiate down to them.

The Three MCP Primitives, and Their Edge Cases

Tools are callable functions with a typed input schema, invoked via tools/call, they are for actions: querying a database, sending a message, triggering a workflow. Resources are addressable, readable data, identified by URIs (file:///docs/policy.pdf, postgres://reports/q3), fetched via resources/read, they are for read-only context the agent can pull in without “calling” anything. Prompts are reusable, parameterized prompt templates the server exposes, fetched via prompts/get, useful for standardizing how an organization asks a model to perform a recurring task (e.g., “summarize-incident” with a severity parameter).

@mcp.resource("policy://{doc_id}")
async def get_policy_doc(doc_id: str) -> str:
    """Resources are fetched by URI, not "called" like tools."""
    return await fetch_policy_text(doc_id)

@mcp.tool()
async def search_policies(query: str, top_k: int = 5) -> list[dict]:
    """Tools perform an action, here, a semantic search, and return structured results."""
    return await semantic_search(query, top_k)

@mcp.prompt()
def summarize_incident(severity: str) -> str:
    """Prompts are reusable templates the client can request and fill in."""
    return f"Summarize this {severity}-severity incident in three sentences, focusing on root cause and remediation."

A common design question is what to do with a capability that seems to straddle two primitives, for example, a “search” operation that both performs computation (ranking) and returns read-only data with no side effects. The deciding factor is not whether a side effect occurs, it is whether the client needs to call the capability with structured arguments each time (a tool) or address a specific, identifiable piece of data by a stable URI (a resource). A search operation almost always belongs as a tool, because its arguments (the query, filters, pagination) vary per invocation and there is no single stable URI identifying “the search”; a specific document found by that search, however, is naturally a resource, addressable at policy://{doc_id} once you have its ID. Choosing the right primitive matters beyond taste: MCP clients often present tools and resources differently in their UI (tools as invokable actions, resources as browsable/attachable context), and getting the mapping wrong produces a confusing experience for both the model and any human using the client.

List-Changed Notifications: Keeping Long-Lived Sessions in Sync

A subtlety that matters once servers move past a fixed, static tool catalog: what happens when the set of available tools changes while a client is already connected, for instance, a new integration is deployed, or a caller’s permissions change mid-session (a scenario explored fully in Module 4). Rather than forcing every client to poll tools/list on a timer, MCP defines list-changed notifications, notifications/tools/list_changed, notifications/resources/list_changed, and notifications/prompts/list_changed, that the server emits once when its catalog changes. The client is then expected to re-issue the corresponding list request to refresh its view.

# Server-side: notify connected clients when a new tool becomes available,
# e.g. after a hot-reload of a plugin module
async def register_new_tool_and_notify(tool_definition):
    mcp.register_tool(tool_definition)
    await mcp.send_notification("notifications/tools/list_changed")
sequenceDiagram
    participant Client
    participant Server as MCP Server

    Note over Client,Server: Session already established, tools/list previously called
    Server->>Server: New tool deployed at runtime
    Server-->>Client: notifications/tools/list_changed
    Client->>Server: tools/list (re-fetch)
    Server-->>Client: result {tools: [...updated catalog]}

This pattern avoids two failure modes at once: a client that never refreshes and silently misses new capabilities for the life of the session, and a client that polls constantly and wastes bandwidth and server load on a catalog that rarely changes. The next lesson surveys the SDK and registry ecosystem that has formed around these primitives and notifications through 2025 and into 2026.

Knowledge Check

3 questions to test your understanding

1 During MCP session setup, why does the client send an 'initialized' notification after receiving the server's response to 'initialize'?

2 A team wants to expose read-only company policy documents to an agent so it can look them up by ID, without the agent needing to trigger any action. Which MCP primitive is the best fit?

3 A server adds three new tools while a client session is already connected and mid-conversation. How does the client find out about them without the connection being torn down and re-established?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan