First Principles · 11 min mission
MCP Protocol Deep Dive: The Standard Behind Every Server
Read the Model Context Protocol itself — architecture, primitives, transports, and auth — so every tool's MCP screen becomes a thin skin over machinery you understand.
The Model Context Protocol (MCP) is an open, JSON-RPC 2.0 standard for connecting AI applications to external tools and data. This guide covers the protocol itself — architecture, the six primitives, the session lifecycle, transports, and OAuth 2.1 auth — so you can build one server that runs in every MCP host instead of a bespoke connector per tool.
MCP uses a client-host-server architecture and is a stateful session protocol. Build one server and any MCP-compatible host can use it (N+M integrations instead of N×M).
| Role | What it is | Responsibilities |
|---|---|---|
| Host | The AI application (container + coordinator) | Creates/manages clients, controls connection permissions and lifecycle, enforces consent, handles user authorization, coordinates LLM sampling, aggregates context across all clients |
| Client | Created by the host; 1:1 with a single server | Runs one stateful session per server, negotiates the protocol, exchanges capabilities, routes messages bidirectionally, maintains the security boundary |
| Server | Exposes a focused set of capabilities; local process or remote service | Serves resources, tools, prompts; operates independently; can request an LLM completion back *through* its client via sampling |
| Primitive | Side | Key methods | Control model |
|---|---|---|---|
| Tools | Server | tools/list, tools/call | Model-controlled — the model decides when to call |
| Resources | Server | resources/list, resources/read, resources/subscribe | Application-driven — the app chooses what to attach |
| Prompts | Server | prompts/list, prompts/get | User-controlled — surfaced for explicit selection (slash commands) |
| Sampling | Client | sampling/createMessage | Server asks the client for an LLM completion |
| Elicitation | Client | elicitation/create | Server asks the user for input mid-task |
| Roots | Client | roots/list | Client exposes workspace `file://` boundaries to the server |
| Primitive | Fields | Identifier / result | Rule to remember |
|---|---|---|---|
| Tools | name, title, description, inputSchema, outputSchema, annotations, icons, execution.taskSupport | Result carries content[] (unstructured) and/or structuredContent (validated against outputSchema) | Business failures return isError: true in the result so the model self-corrects; only unknown-tool / malformed-request is a JSON-RPC protocol error |
| Resources | subscribe + listChanged sub-capabilities; templates use RFC 6570 URI templates | Addressed by RFC 3986 URI (https://, file://, git://, custom); content is text or base64 blob | Application-driven: the host decides which resources to pull into context |
| Prompts | name, title, description, arguments[], icons | prompts/get returns messages[] with roles (user/assistant) + text/image/audio/embedded-resource content | Published for explicit user selection — typically surfaced as slash commands |
| Primitive | Method | Direction | Details |
|---|---|---|---|
| Sampling | sampling/createMessage | server → client → LLM | Server borrows the host's model (no own API keys). modelPreferences = hints[] + costPriority/speedPriority/intelligencePriority (0–1). New in 2025-11-25: tool calling via tools[] + toolChoice {mode: "auto"\|"required"\|"none"} (client must declare sampling.tools); stopReason can be toolUse or endTurn. Human SHOULD be able to deny. |
| Elicitation | elicitation/create | server → client → user | Two modes, capability {form, url}. Form mode: flat primitive requestedSchema, MUST NOT request secrets. URL mode (new in 2025-11-25): out-of-band navigation for auth/payment — adds elicitationId, notifications/elicitation/complete, and URLElicitationRequiredError (code -32042). Responses: accept, decline, cancel. |
| Roots | roots/list | server → client | Client tells the server which filesystem locations are in scope. A root is { uri, name? }; uri MUST be a `file://` URI. |
Build a minimal server
The Python SDK ships a high-level FastMCP class whose decorators hide the JSON-RPC plumbing — a function's type hints become the tool's inputSchema automatically. Install with pip install "mcp[cli]" or uv add "mcp[cli]"; the mcp package is 1.27.2 on PyPI (v2 is alpha). The server below exposes one tool, one resource, and one prompt over stdio. The TypeScript SDK (@modelcontextprotocol/sdk, 1.29.0 on npm) models the same shape with a Zod input schema.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather-demo")
# A TOOL: model-controlled action. The type hints below become the
# tool's inputSchema; the return value becomes its result content.
@mcp.tool()
def get_forecast(city: str, days: int = 1) -> str:
"""Return a short weather forecast for a city."""
# Real code would call a weather API here.
return f"{city}: sunny, 24C for the next {days} day(s)."
# A RESOURCE: application-driven data, addressed by an RFC 3986 URI.
@mcp.resource("weather://stations")
def list_stations() -> str:
"""The set of stations this server knows about."""
return "KSFO, KJFK, EGLL"
# A PROMPT: user-controlled workflow, surfaced as a slash command.
@mcp.prompt()
def trip_briefing(city: str) -> str:
"""Draft a travel weather briefing for a city."""
return f"Write a concise weather briefing for a trip to {city}."
if __name__ == "__main__":
# stdio transport: the host launches this file as a subprocess and
# speaks newline-delimited JSON-RPC over stdin/stdout.
mcp.run(transport="stdio")import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// @modelcontextprotocol/sdk is 1.29.0 on npm as of 2026-06-15.
const server = new McpServer({ name: "weather-demo", version: "1.0.0" });
// A tool. The Zod schema is published to clients as the JSON-Schema
// inputSchema, so the model knows exactly what arguments to send.
server.registerTool(
"get_forecast",
{
title: "Get forecast",
description: "Return a short weather forecast for a city.",
inputSchema: { city: z.string(), days: z.number().default(1) },
},
async ({ city, days }) => ({
content: [{ type: "text", text: `${city}: sunny, 24C for ${days} day(s).` }],
}),
);
// stdio transport — connect and start serving.
await server.connect(new StdioServerTransport());Session lifecycle: initialize → operation → shutdown
Client sends `initialize`
Carries
protocolVersion,capabilities, andclientInfo(name/title/version/description/icons/websiteUrl). The session is born here — no other request may precede it.Negotiate the version
The client sends its latest supported version. If the server supports it, the server echoes the same version; otherwise it replies with another version it supports (SHOULD be its latest). If the client cannot speak the server's response, it SHOULD disconnect.
Server returns capabilities
Server responds with
protocolVersion,capabilities,serverInfo, and optionalinstructions. Client capability keys:roots,sampling,elicitation,tasks,experimental. Server keys:prompts,resources,tools,logging,completions,tasks,experimental. Sub-capabilities:listChanged(prompts/resources/tools) andsubscribe(resources only).Client sends `notifications/initialized`
The session enters the operation phase, where
tools/call,resources/read,sampling/createMessage, and the rest flow.Shut down cleanly
For stdio, close the subprocess. For Streamable HTTP, the client can
DELETEthe session (the server MAY answer405if it disallows client-initiated termination).
The two standard transports
stdio (local)
Client launches the server as a subprocess and exchanges newline-delimited JSON-RPC over stdin/stdout — no embedded newlines, nothing but valid MCP messages on those streams. stderr is free-form logging; clients MUST NOT treat stderr output as a failure signal. Clients SHOULD support stdio whenever possible. Right transport for local tools needing direct system access.
Streamable HTTP (remote)
A single MCP endpoint (e.g. https://example.com/mcp) serves both POST and GET. The client POSTs each JSON-RPC message with Accept: application/json, text/event-stream; a request gets either one JSON response or an SSE stream, and a notification/response yields HTTP 202 Accepted with no body. A server-initiated SSE stream opens via GET (server returns text/event-stream, or 405 if unsupported). Introduced 2025-03-26; production transport from 2025-06-18.
Authorization: OAuth 2.1, HTTP only
Authorization is OPTIONAL and applies only to HTTP-based transports. stdio servers SHOULD NOT use it — they take credentials from the environment they launched in. The roles are fixed: the MCP server is an OAuth 2.1 Resource Server, the MCP client is the OAuth 2.1 client, and the Authorization Server is separate (may be co-hosted).
| Item | Spec / value | Rule |
|---|---|---|
| Core grant | OAuth 2.1 (draft-ietf-oauth-v2-1-13) | PKCE with S256 is a MUST |
| Discovery | RFC 9728 Protected Resource Metadata; RFC 8414 / OIDC Discovery 1.0 for the AS | Server returns 401 with WWW-Authenticate pointing at PRM, or client falls back to /.well-known/oauth-protected-resource |
| Client registration | OAuth Client ID Metadata Documents; RFC 7591 Dynamic Client Registration | Priority: pre-registration → Client ID Metadata Documents (recommended) → DCR (backwards compat) |
| Audience binding | `resource` parameter (RFC 8707) | MUST be sent on both authorization and token requests, naming the canonical server URI — even if the AS ignores it |
| Token transport | Authorization: Bearer <token> | Never in a query string |
Knowledge check
A teammate says: "My MCP server calls the GitHub API, so I just forward the OAuth token the client sent me — one token, less code." What is the correct response?
Registry and ecosystem
The official MCP Registry (https://registry.modelcontextprotocol.io/) is a community catalog built around a server.json manifest — $schema, name, description, version, and a packages[] array declaring each package's registry type (npm, pypi, …), identifiers, versions, and transport. It launched in preview on 2025-09-08 and remains preview as of 2026-06-15 (breaking changes and data resets possible; GA unannounced). Its OpenAPI spec is open so other registries can implement a compatible interface; forking the official codebase to self-host is not a supported pattern.
Reach the end and this star joins your charted sky.