In short
An MCP server exposes tools, resources and prompts to AI applications over JSON-RPC 2.0. In the current specification, revision 2026-07-28, the protocol is stateless: there is no initialize handshake and no session, every request carries its protocol version and client capabilities in _meta, and every server must answer server/discover. Local servers usually run over stdio and take credentials from the environment, while remote servers use Streamable HTTP, validate the Origin header, and act as OAuth 2.1 resource servers that publish Protected Resource Metadata and accept only tokens issued for them.
Key points
- Tools are controlled by the model, resources by the application and prompts by the user. A server advertises each one it implements in its capabilities.
- Since 2026-07-28 MCP has no
initializehandshake and noMcp-Session-Id. Every request carries_metawith the protocol version and client capabilities, and state across calls lives in explicit handles. - Use stdio for a server the client launches on the same machine and Streamable HTTP for a remote one. Streamable HTTP replaced the HTTP+SSE transport in revision 2025-03-26.
- A protected HTTP server is an OAuth 2.1 resource server: it must publish RFC 9728 metadata and check that each token was issued for it. Clients must use PKCE and send the RFC 8707
resourceparameter. - Token passthrough is forbidden,
Originmust be validated, and tool annotations from untrusted servers are hints, never permissions. - The MCP Inspector tests a server from a web UI, a CLI for CI or a terminal UI, all in one package.
What an MCP server exposes
An MCP server is a program that offers context and actions to AI applications. The application, called the host, creates one MCP client per server, and each client talks to exactly one server. The current specification, revision 2026-07-28, defines three things a server can expose, and it separates them by who decides when they are used:
| Primitive | Controlled by | Methods | Typical use |
|---|---|---|---|
| Tools | The model | tools/list, tools/call | Actions and lookups: query a database, call an API, write a file |
| Resources | The application | resources/list, resources/read, resources/templates/list | Context identified by a URI: file contents, a database schema, a record |
| Prompts | The user | prompts/list, prompts/get | Templates the user picks, often shown as slash commands |
A server must advertise each primitive it implements in its capabilities, for example "tools": { "listChanged": true }. Its lists may change over time and may differ by the authorization on the request, so a caller sees only the tools its scopes permit, but they must not vary per connection. Since 2026-07-28 list results also carry ttlMs and cacheScope, so clients know how long they may cache them, and the spec asks servers to return tools in a deterministic order, which helps both client caches and LLM prompt caching.
The spec also defines features a server can ask of the client:
- Elicitation asks the user for input. Form mode collects structured data against a flat JSON Schema; URL mode sends the user to a web page for anything sensitive. Servers must not use form mode to ask for passwords, API keys, access tokens or payment credentials.
- Sampling asks the client’s model for a completion, and roots tell the server which directories the user considers in scope. Both are deprecated in 2026-07-28 and stay in the spec for at least twelve months. The suggested migrations: call an LLM provider’s API directly, and pass directories as tool arguments, resource URIs or server configuration.
- Logging is deprecated too. On stdio, write logs to
stderr; elsewhere, the spec points to OpenTelemetry.
None of these is a request the server sends on its own any more. Under the new multi round-trip pattern, the server answers the original request with resultType: "input_required" and a map of inputRequests, and the client retries that request, with a new ID, carrying the matching inputResponses.
JSON-RPC messages and a lifecycle without a handshake
Every MCP message is JSON-RPC 2.0, encoded as UTF-8. There are three kinds:
- Requests carry a
method, optionalparamsand a string or integeridthat the sender is not already using for another request still waiting for an answer. - Responses repeat the request’s
idand hold either aresultor anerrorwith an integercodeand amessage. In 2026-07-28 every result also has aresultType:"complete", or"input_required"for the multi round-trip case. - Notifications have a
methodand noid, and get no reply.
Earlier revisions, up to 2025-11-25, opened each connection with an initialize request in which client and server agreed a version and exchanged capabilities, followed by a notifications/initialized message. Revision 2026-07-28 removed that handshake and made the protocol stateless. Every request now carries its own context in params._meta:
_meta key | Required | What it holds |
|---|---|---|
io.modelcontextprotocol/protocolVersion | Yes | The protocol version for this request, such as 2026-07-28 |
io.modelcontextprotocol/clientCapabilities | Yes | The client capabilities relevant to this request, such as elicitation |
io.modelcontextprotocol/clientInfo | No, but clients should send it | The client’s name and version, for display and logs, never for security decisions |
A request missing a required key is malformed: the server rejects it with -32602 (Invalid params), with HTTP status 400 on HTTP. If the server does not support the requested version, it returns UnsupportedProtocolVersionError (-32022) listing the versions it does support. If handling the request needs a client capability the request did not declare, it returns MissingRequiredClientCapabilityError (-32021).
Every server must implement server/discover. It returns the supported versions, the server’s capabilities, its name and version in _meta, and optional instructions for the model. A client may call it first or simply send requests and handle a version error. A client that also talks to older servers should probe with server/discover on stdio: a discover result or a recognized modern error means a modern server, and any other error or a timeout means falling back to initialize.
Statelessness comes with rules. A server must not rely on earlier requests on the same connection for the version, the capabilities or the client’s identity, and any state that spans requests must be referenced by an explicit identifier the client passes each time. An open stdio process is not a conversation.
Defining and calling a tool
A tool definition is a name, a description the model reads, and an inputSchema in JSON Schema, which defaults to the 2020-12 dialect. title, outputSchema, icons and annotations are optional. The spec recommends names of 1 to 128 characters using ASCII letters, digits, underscores, hyphens and dots, and for a tool with no arguments it recommends { "type": "object", "additionalProperties": false }.
// One entry in a tools/list result
{
"name": "get_order_status",
"title": "Order status",
"description": "Look up the shipping status of one order by its ID. Use it when the user asks where an order is. Read-only; returns no address or card details.",
"inputSchema": {
"type": "object",
"properties": {
"order_id": { "type": "string", "description": "Order ID as printed on the receipt, e.g. ORD-10422" }
},
"required": ["order_id"],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"status": { "type": "string" },
"updated": { "type": "string", "format": "date-time" }
},
"required": ["status", "updated"]
},
"annotations": { "readOnlyHint": true, "openWorldHint": false }
}A call over Streamable HTTP, with the headers the transport requires and a result that matches the output schema:
POST /mcp HTTP/1.1
Host: mcp.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_order_status
{ "jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": { "name": "get_order_status", "arguments": { "order_id": "ORD-10422" },
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {} } } }
HTTP/1.1 200 OK
Content-Type: application/json
{ "jsonrpc": "2.0", "id": 7,
"result": { "resultType": "complete",
"content": [{ "type": "text", "text": "{\"status\": \"shipped\", \"updated\": \"2026-11-02T09:14:00Z\"}" }],
"structuredContent": { "status": "shipped", "updated": "2026-11-02T09:14:00Z" },
"isError": false } }The headers mirror fields from the body so that load balancers and gateways can route without parsing JSON: Mcp-Method on every request, Mcp-Name on tools/call, resources/read and prompts/get, and MCP-Protocol-Version matching the version in _meta. A server that reads the body must reject any mismatch with 400 and HeaderMismatch (-32020), because a router and a server reading different values is how requests get misrouted. When a tool declares an outputSchema, its structuredContent must conform to it, and the spec suggests also returning the serialized JSON in a text block for older clients.
Errors come in two kinds, and the difference matters to the model:
- Protocol errors are JSON-RPC errors for problems with the request itself, such as an unknown tool or a request that does not match the
tools/callschema. The spec’s example uses-32602. - Tool execution errors are ordinary results with
isError: trueand a message the model can act on, such as "Invalid departure date: must be in the future." API failures, bad argument values and business-rule failures belong here, and clients should pass them to the model so it can correct itself.
For anything that spans calls, such as a cart, a browser context or a database transaction, return a handle from one tool and accept it as an argument on the next. The spec’s security guide calls the attack on this pattern state handle hijacking: a server that implements authorization must never treat possession of a handle as authentication. Generate handles with a cryptographic random generator, check on every call that the handle belongs to the caller, and key stored state by the user ID taken from the verified token.
The spec’s list for every server is short: validate all tool inputs, enforce access controls, rate limit tool invocations and sanitize tool outputs. Clients, in turn, should confirm sensitive operations with the user, show tool inputs before calling, apply timeouts and log tool use. The loop that turns a model’s request into a tools/call lives in the host, as described in how LLM tool calling works.
Tool annotations are hints, not controls
Annotations describe how a tool behaves so that a client can decide, for example, when to ask the user first. The schema defines four boolean hints next to a display title:
| Hint | Default | Meaning when true |
|---|---|---|
readOnlyHint | false | The tool does not modify its environment |
destructiveHint | true | The tool may make destructive updates; false means it only adds. Meaningful only when readOnlyHint is false |
idempotentHint | false | Repeating a call with the same arguments has no further effect. Meaningful only when readOnlyHint is false |
openWorldHint | true | The tool reaches an open set of outside entities, as web search does, rather than a closed domain such as a memory store |
The defaults are cautious on purpose: a tool with no annotations is assumed to write, possibly destructively, and to reach the outside world. Set them honestly on your own tools, since a client may skip a confirmation for a read-only one. But the schema is blunt about their status. Annotations may not describe the tool faithfully, and clients must treat them as untrusted unless they come from trusted servers. A malicious server can label a delete as read-only. Real protection sits in the server’s own permission checks and in the client’s approval rules, not in the label.
Transports: stdio vs Streamable HTTP
Protocol semantics are the same on every transport. A transport only defines framing, delivery, request metadata and cancellation, and the spec defines two.
With stdio, the client launches the server as a subprocess. Messages are newline-delimited JSON-RPC on stdin and stdout and must not contain embedded newlines. The server must not write anything to stdout that is not a valid MCP message, which is why one stray debug print breaks a stdio server, and it may write logs to stderr. To shut down, the client closes the server’s stdin, waits, then escalates to SIGTERM and SIGKILL.
With Streamable HTTP, the server runs as an independent process with a single MCP endpoint, such as https://example.com/mcp, that accepts POST. Every client message is its own POST with an Accept header listing both application/json and text/event-stream. The server answers a request with either one JSON object or a Server-Sent Events stream scoped to that request, which can carry progress notifications before the final response. A notification gets 202 Accepted. Closing the stream cancels the request, and the spec asks servers to send X-Accel-Buffering: no so proxies do not hold events back.
| stdio | Streamable HTTP | |
|---|---|---|
| Who starts the server | The client, as a subprocess | You do; it runs as a service |
| Clients per server process | One | Many |
| Framing | One JSON-RPC message per line | One POST per message; the reply is JSON or a request-scoped SSE stream |
| Request metadata | In _meta only | In _meta, mirrored into the MCP-Protocol-Version, Mcp-Method and Mcp-Name headers |
| Cancellation | A notifications/cancelled message | Close the response stream |
| Change notifications | Lines on stdout tagged with a subscription ID | A long-lived subscriptions/listen response stream |
| Credentials | From the environment; the spec says stdio should not use its OAuth flow | The OAuth 2.1 flow the spec defines |
| Main exposure | Runs with the user’s privileges: files, environment variables, network | Reachable over the network, and from browsers through DNS rebinding when it runs locally |
| Fits | Local tools: files, git, a local database | Remote and shared servers, SaaS integrations |
Streamable HTTP is also the answer to what replaced the old transport. Revision 2025-03-26 introduced it in place of the HTTP+SSE transport from 2024-11-05, which used a GET request for a standing SSE stream and a separate POST endpoint announced in an endpoint event. HTTP+SSE is now formally deprecated and eligible for removal, and the spec describes how a client can detect an old server and fall back to it.
Sessions: removed from the protocol
If you built a server against revision 2025-11-25 or earlier, sessions were part of Streamable HTTP. The server could assign an Mcp-Session-Id header at initialization, the client sent it on every later request and could end the session with HTTP DELETE, clients could open a standalone GET stream for server-initiated messages, and broken streams could resume with Last-Event-ID. Revision 2026-07-28 removed all of it:
- There is no protocol-level session, so any request can go to any server instance and load balancing is plain HTTP.
- A server that supports only 2026-07-28 should answer GET and DELETE on the MCP endpoint with
405 Method Not Allowed, ignore anMcp-Session-Idheader without minting or echoing one, and ignoreLast-Event-ID. - A broken response stream loses the request in flight. The client must re-issue it as a new request with a new ID, so make tools that write idempotent.
- Change notifications, such as
notifications/tools/list_changed, arrive on asubscriptions/listenstream that the client opens and opts into, each tagged with a subscription ID. - State your tools need lives behind explicit handles passed as arguments, as described above.
Many clients and servers in use still speak 2025-11-25, so check which revisions your SDK supports. The spec’s backward-compatibility rules let a modern client detect an older server, from the body of a 400 response on HTTP or from the server/discover probe on stdio, and fall back to initialize.
Security rules from the spec
The specification and its security best practices page name the attacks that matter most to anyone building a server, and one more follows from how tools work:
- Token passthrough is forbidden. A server must not accept tokens issued for another resource, and when it calls an upstream API it must use a separate token issued for that API, never the one the client sent. Passthrough bypasses rate limits and audit trails and makes a stolen token usable through your server.
- Confused deputy. A proxy server that uses one static client ID with a third-party authorization server, while letting MCP clients register dynamically, can be abused: a consent cookie from an earlier login lets an attacker’s crafted link skip the consent screen and send the authorization code to the attacker. Such proxies must get the user’s consent for each dynamically registered client before forwarding.
- DNS rebinding. A web page can point its own domain at 127.0.0.1 and talk to a server on the user’s machine. Streamable HTTP servers must validate the
Originheader on all incoming connections and answer an invalid one with 403, and local servers should bind to 127.0.0.1 rather than 0.0.0.0. The security guide adds that a local server on HTTP should still require an authorization token. - Local server compromise. A stdio server is an ordinary process with the user’s files, environment and network. Clients that install servers in one click must show the exact command and get explicit approval, and the guidance recommends running servers in a sandbox with minimal default privileges.
- Poisoned tool descriptions. This one follows from the design rather than from a named section: tool names and descriptions are text the model reads, and a server’s tool list may change over time. A server can hide instructions in a description, or change it after you approved the server. That is prompt injection through the tool catalog, so treat descriptions and results from servers you do not control as untrusted.
// Runs first on every request to the MCP endpoint, before auth or JSON parsing.
const ALLOWED_ORIGINS = new Set(['https://app.example.com']);
const ALLOWED_HOSTS = new Set(['127.0.0.1:3333', 'localhost:3333']);
function reject(message: string): Response {
// The spec allows a JSON-RPC error response with no id in the 403 body.
const body = { jsonrpc: '2.0', error: { code: -32600, message } };
return Response.json(body, { status: 403 });
}
export function checkOriginAndHost(req: Request): Response | void {
const origin = req.headers.get('origin');
if (origin && !ALLOWED_ORIGINS.has(origin)) {
return reject('Origin not allowed'); // required by the spec when present
}
const host = (req.headers.get('host') ?? '').toLowerCase();
if (!ALLOWED_HOSTS.has(host)) {
return reject('Host not allowed'); // extra defense against DNS rebinding
}
}
// In the handler: const denied = checkOriginAndHost(req); if (denied) return denied;
// And when the server is local, listen on 127.0.0.1, never 0.0.0.0.The spec requires the Origin check when the header is present. Command-line clients usually send none, so authentication still has to stand on its own. The Host allowlist is common extra hardening against rebinding, not a spec requirement.
On the client side, the configuration that decides which servers start is worth reviewing like code. agent-config-audit, an open-source CLI, lists the MCP servers, hooks, skills and other agent config in a repository without running any of it. It flags issues such as npx packages without a pinned version, plain-HTTP remotes and credentials in config, and it can lock the config so later changes show up in review. Its README is clear about the limit: it does not start MCP servers, so it never sees their tool descriptions.
Testing with the MCP Inspector
The MCP Inspector is the reference tool for testing and debugging servers. One package, @modelcontextprotocol/inspector, gives you a web UI, a CLI and a terminal UI on a shared core, and it negotiates both the legacy and the 2026-07-28 protocol eras. It needs Node 22.19.0 or newer.
npx @modelcontextprotocol/inspector node build/index.jsopens the web UI against a local stdio server.npx @modelcontextprotocol/inspector --server-url https://mcp.example.com/mcp --transport httpconnects to a remote server, OAuth included.npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/listprints one result and exits, which suits CI. Add--method tools/call --tool-name get_order_status --tool-arg order_id=ORD-10422to call a tool.--tuiruns the same client in a terminal.
A first test pass for a new server, with the Inspector and a few hand-written HTTP requests:
server/discoverreturns your versions and capabilities, andtools/listreturns every tool, in a stable order, with valid schemas.- A call with bad arguments returns
isError: trueand a message a model can act on; an unknown tool returns a JSON-RPC error. - On stdio, nothing but MCP messages reaches
stdout. - Over HTTP, a request without a token gets 401 with
resource_metadata, a token issued for another audience is rejected, and a foreignOrigingets 403. - Headers that disagree with the body are rejected with
HeaderMismatch.
One caution from the Inspector’s docs: it keeps OAuth tokens and other secrets in the OS keychain, and on a machine without one it falls back to a file that is unencrypted unless you supply a key. For what MCP is and whether a product needs a server at all, see what an MCP server is.
If you want an MCP server or an agent built around your own systems, BYD Tech does AI agent development: the code lives in your GitHub from day one, AI-assisted work is reviewed by an engineer, and the IP is assigned to you.
Sources
Checked when this post was written.
- Model Context Protocol specification (2026-07-28): Key Changes
- MCP specification (2026-07-28): Base Protocol Overview
- MCP specification (2026-07-28): stdio transport
- MCP specification (2026-07-28): Streamable HTTP transport
- MCP specification (2026-07-28): Tools
- MCP specification (2026-07-28): Authorization
- MCP specification (2026-07-28): Authorization Security Considerations
- Model Context Protocol: Security Best Practices
- Model Context Protocol: MCP Inspector
- RFC 9728: OAuth 2.0 Protected Resource Metadata