Building MCP Clients and Hosts
This guide explains the responsibilities and design considerations when implementing an MCP client or host application. Server-side implementation is covered in Building MCP Servers, authorization details in MCP Authorization, strategies for giving tools to the model in The Context Bloat Problem and Mitigations, and the security perspective in MCP Security Best Practices and AI Agent-Specific Threats. This document focuses on what clients and hosts implement, and how, on top of the stateless protocol of 2026-07-28.
1. What This Document Covers
- Division of responsibilities between client and host
- Building requests
- When to use
server/discover - Implementing the authorization flow
- How to present tools to the model
- The user consent UI
- Caching and refresh
- Subscriptions
- Failure handling
- SSRF and URL validation
- Managing multiple servers
Division of responsibilities between client and host
In the official terminology, the host is the application the user interacts with (such as Claude.ai or an IDE), and it manages the overall experience and coordinates multiple clients. A client is a protocol-level component the host creates to communicate with a particular MCP server, and each client handles one connection to one server.
This division matters because it decides which side owns the safety decisions. Obtaining user consent, presenting tools to the model, and controlling data flow between servers are the host's job, because the host sees both the user and the model. The client is responsible for protocol correctness (building requests, interpreting results, caching, retries). SDKs mainly provide the client; the host implementer decides the consent UI and the policy for presenting tools to the model.
A client offers the following features to servers.
| Feature | Description | Status in 2026-07-28 |
|---|---|---|
| Elicitation | The server asks the user for additional input. It has a form mode (structured data following a schema) and a URL mode (an out-of-band interaction whose data does not pass through the client) | Active. Delivered through the MRTR pattern |
| Sampling | The server requests an LLM completion through the client | Deprecated and scheduled for removal. New implementations use the LLM provider's API directly |
| Roots | The client tells the server which directories to focus on | Deprecated and scheduled for removal. Pass them through tool arguments, resource URIs, or server configuration |
Building requests
2026-07-28 has no initialize handshake. A client builds every request so that it stands on its own.
Required _meta fields: Include the following fields in the _meta of every request.
| Key | Required | Content |
|---|---|---|
io.modelcontextprotocol/protocolVersion | Yes | Protocol version for this request (for example, "2026-07-28") |
io.modelcontextprotocol/clientCapabilities | Yes | Client capabilities relevant to this request. Extensions are also declared here |
io.modelcontextprotocol/clientInfo | No (recommended) | Client name and version. Self-reported and not verified |
io.modelcontextprotocol/logLevel | No | Minimum log level the server should emit |
progressToken | No | Token for receiving progress notifications |
traceparent and others | No | OpenTelemetry trace context |
If a required field is missing, the server rejects the request with -32602 (400 Bad Request on HTTP). If the request needs a capability the client did not declare, the server returns MissingRequiredClientCapability (-32021). clientInfo, and the serverInfo the server returns, are self-reported values for display, logging, and debugging, and must not be used to change behavior or for security decisions.
Streamable HTTP headers: On HTTP, in addition to the JSON body, some values are mirrored into headers so that gateways and WAFs can process them without parsing the body.
- Put both
application/jsonandtext/event-streaminAccept. The server may return either per request, and the client must handle both - Include
MCP-Protocol-Version. Its value must matchprotocolVersionin the body's_meta - Put the method name in
Mcp-Method - For
tools/call,resources/read, andprompts/get, putparams.nameorparams.uriinMcp-Name - If a tool argument has
x-mcp-header, mirror it asMcp-Param-{name}. Clients must support this
When a header value cannot be represented in the visible ASCII range (non-ASCII characters, control characters, leading or trailing whitespace), or when it looks like the =?base64?...?= form, encode it as =?base64?{Base64 of the UTF-8 value}?=. The server validates that header and body values match, and rejects a mismatch with 400 and HeaderMismatch (-32020). If you receive HeaderMismatch because a required Mcp-Param-* header is missing or differs from the body, call tools/list to check for changes to the inputSchema and retry with the correct headers. A tool definition whose x-mcp-header violates the specification's constraints (an empty name, a number parameter, or an annotation placed behind an array or $ref) is excluded from the tools/list result with a logged warning, so that one malformed definition does not make the other tools unusable. Outside HTTP, such as stdio, x-mcp-header may be ignored.
Request IDs: An ID is a string or an integer and cannot be null. Do not use the same ID as another request that has not received a response. Use a new ID for an MRTR retry and for a resend after a failure.
When to use server/discover
server/discover is a request that returns the server's supported versions (supportedVersions), capabilities (capabilities), identity (serverInfo), and instructions. Servers must implement it, and clients may call it. The result carries ttlMs and cacheScope and can be cached. It is used in two scenarios.
- Up-front version selection and showing server information: When you want to know the supported versions before sending other requests. For a server list screen, one call shows the capabilities instead of separate
tools/list,prompts/list, andresources/listcalls. It is not required, though. A client may simply send any request and, if the server does not support the version, choose a common version fromdata.supportedof the returnedUnsupportedProtocolVersion(-32022) and retry - Backward-compatibility probe on stdio: stdio has no equivalent of an HTTP status code to drive fallback. A client that also talks to legacy servers (the
2025-11-25and earlier versions that useinitialize) sendsserver/discoverfirst. If it returns aDiscoverResultor a recognized modern error, the server is modern. Any other error, or a timeout, means a legacy server, and the client falls back toinitialize
On HTTP, instead of a probe, the client sends a modern request first. On 400 Bad Request, it inspects the body. A recognized modern JSON-RPC error (version mismatch, missing required capability, header mismatch) means a modern server, so the client corrects the request and retries without falling back. If the body is empty or not recognized, it falls back to initialize. The server's era is a property of the server (the process on stdio, the origin on HTTP) rather than of each request, so cache the result for the lifetime of the server (or origin) and re-probe only when the assumption breaks. Note that a client that supports only the legacy era cannot connect to a modern server.
Implementing the authorization flow
A client that connects to an HTTP server follows the authorization specification. The details and implementation examples are in MCP Authorization (enterprise extensions are in MCP Authorization Extensions and Enterprise Adoption), so this section lists only what the client must implement.
- When an unauthenticated request is rejected with
401andWWW-Authenticate(resource_metadata), fetch the Protected Resource Metadata and discover the authorization server metadata. Support bothWWW-Authenticateand the Well-Known URI - Prefer Client ID Metadata Documents (CIMD) for client registration
- Always use PKCE in the authorization code flow, and specify the MCP server that will use the token with the
resourceparameter - Validate
issin the authorization response to prevent mix-up attacks - On a
403withinsufficient_scope, widen the scope and reauthorize (step-up) - Store tokens per issuing authorization server, and never forward them to other servers (no token passthrough)
This specification is not applied to stdio servers; they obtain credentials from the environment.
How to present tools to the model
Whether to put the tool definitions fetched with tools/list into the model's context is the host's decision. The naive implementation gives the model every tool of every connected server at the start of the conversation. For a handful of tools this is fine, but with dozens of servers and hundreds of tools, the definitions alone use most of the context.
The official Client Best Practices recommend progressive discovery. The host calls tools/list as usual but defers injecting the definitions into the model's context, and gives the model a lightweight search_tools meta-tool. The model searches and loads only the definitions it needs. A guide for when to switch is the share of the context window that tool definitions take; use a threshold of about 1 to 5%. Choose the search strategy from keywords (BM25 or regular expressions), embeddings, a subagent, or a combination. If the model provider offers built-in tool search, using it is simpler. For details of progressive discovery, dynamic server connection, and programmatic tool calling (Code Mode), see The Context Bloat Problem and Mitigations.
Points to keep in mind when implementing.
- Cache (memoize) definitions on the host side. Independently of whether they are in the model's context, you avoid calling
tools/listevery time you re-inject them - Reindex the search catalog when you receive
notifications/tools/list_changed - Present tools grouped by their source server
- A tool name is unique only within a single server. A client that aggregates tools from several servers can see collisions such as
search. Disambiguate with a server identifier as a prefix. ThenameinserverInfois not guaranteed to be unique across servers and must not be used for identification - Changing the tools array midway invalidates the prompt cache. Append newly discovered definitions after the cache breakpoint, or route every call through a meta-tool such as
call_tool({name, args})so the array stays fixed
The user consent UI
Tools are designed to be discovered and invoked automatically by the model (model-controlled), but for trust and safety the specification says there should always be a human in the loop who can deny tool invocations (SHOULD). The host implements the following.
- Make clear in the UI which tools are exposed to the model
- Show a clear visual indicator when a tool is invoked
- Present a confirmation prompt. Show the full set of arguments that will actually be sent before calling the server. A confirmation that shows only part of the arguments misses data exfiltration
- Do not trust tool annotations (such as
readOnlyHint) unless they come from a trusted server (MUST). Do not use them as grounds to skip confirmation - Limit the number of confirmations. For read-only operations that send nothing externally, use blanket per-tool approval, and confirm every time for destructive operations, billing, and external sends. Too many confirmations lead users to approve without reading (approval fatigue)
For the detailed design, see the Human-in-the-loop section of AI Agent-Specific Threats.
Adding a local server: A client that can install a local server with one click must implement a consent mechanism before running the command (SEP-1024). Display the command to be executed in full, including arguments and without truncation, state that this is a potentially dangerous operation that runs code on the user's system, require explicit approval, and let the user cancel the setup. For details, see Operating Local MCP Servers Safely.
Responding to elicitation: When a server needs user input, the response to a request such as tools/call becomes an InputRequiredResult (resultType: "input_required"), and inputRequests holds one or more elicitation/create requests. The client collects the user's input and resends the original request with a new ID, attaching the collected inputResponses and the requestState the server returned (unchanged) (MRTR).
- When presenting a request, show which server is asking, why, and for what purpose
- The user can provide the information, decline (with an optional reason), or cancel the entire operation. The client validates the response against the schema before returning it to the server
- In URL mode, show the full URL and obtain explicit consent before opening it. The client must not fetch the URL automatically, and it learns only whether the user consented
- Warn about a form-mode request for sensitive information such as passwords, API keys, access tokens, or payment credentials, as suspicious. Such input belongs in URL mode, out of band
- Do not cache the result of a retry that carries
inputResponsesorrequestState
Caching and refresh
The results (resultType: "complete") of server/discover, tools/list, prompts/list, resources/list, resources/templates/list, and resources/read carry the caching hints ttlMs and cacheScope. A client handles them as follows.
- Freshness:
ttlMsis a hint for how many milliseconds the result may be considered fresh, similar to HTTPCache-Control: max-age. A result is fresh withinttlMsof the time it was received, and with0it is immediately stale. IfttlMsis absent, assume0; treat a negative value as0. It is not a guarantee, and the data may change within the TTL - Do not use TTL as a polling interval: Check freshness when the data is needed and refetch if it is stale. If you do poll in the background, add jitter and backoff
- Invalidation by
list_changed: When you receive a relevant notification, treat the cache as immediately stale even before the TTL expires, and refetch. Notifications are best-effort and can be lost, so also refetch based on the TTL cacheScope:publiccontains no user-specific data, and anyone, including shared gateways and proxies, may store and reuse it.privatemay be reused only within the same authorization context (the same access token) and must not be shared across authorization contexts. Even withpublic, do not rely oncacheScopealone for authorization decisions- Cache key: The pair of the request's method and the parameters that affect the result (
uriforresources/read,cursorfor pagination). Never serve a result for a different method or different parameters - Pagination: Each page is cached independently and has its own
ttlMs. Consistency across pages is not guaranteed, so if you need a consistent snapshot, refetch from the start without acursor. If acursorbecomes invalid, discard all cached pages and refetch from the start - On failure: If refetching fails (a network failure or the server being down), you may keep using the stale cache (MAY)
Subscriptions
Receive server-initiated change notifications with subscriptions/listen. It is a request whose response is a long-lived SSE stream, and it replaces the former resources/subscribe and the HTTP GET endpoint.
- The request's
notificationsfilter selects the kinds to receive:toolsListChanged,promptsListChanged, andresourcesListChanged(booleans), andresourceSubscriptions(an array of the resource URIs you want updates for). A kind you omit is not subscribed. The server must not send kinds that were not requested - The server's first message is
notifications/subscriptions/acknowledged, which shows the subset it actually accepted. The client checks it against the request and tolerates kinds the server does not support - Later notifications carry
io.modelcontextprotocol/subscriptionIdin_meta. The value is the JSON-RPC ID of thesubscriptions/listenrequest. One client can hold multiple subscriptions at once. On stdio, where all messages share one channel, use this field to match a notification to its subscription - A subscription ends when the client closes the stream (HTTP) or sends
notifications/cancelled(stdio). When the server ends it on its own initiative, it returns a success response and then closes. A close without this response is an unexpected disconnect and a trigger to reconnect. After re-establishing a stdio connection, resend the subscription. The server holds no subscription state across reconnections - Resumption through
Last-Event-IDis not supported. Changes may have happened during the disconnect, so refetch the relevant lists on every reconnect
A subscription occupies a connection, so choose only the kinds you need. Even without notifications, TTL-based refetching keeps data fresh. Caching and subscriptions are complementary.
Failure handling
Timeouts and cancellation: Set a timeout on every outgoing request and cancel it if no response arrives. On HTTP, closing that request's response stream is the cancellation, and on stdio, send notifications/cancelled. You may reset the timeout clock on a progress notification, but always apply a maximum timeout regardless of progress. Ignore a response that arrives after cancellation.
Retries: A broken stream cannot be resumed, so the client resends the same content from the start with a new request ID. To the server this is a different request, so automatically retrying a tool with side effects can cause a double execution. There is no guarantee that the server is designed to be idempotent (for the design, see Building MCP Servers), so automatically retry only read-only calls, and for calls with side effects either confirm with the user or do not retry. Give retries a count limit and exponential backoff with jitter.
Handling errors:
| Error | Response |
|---|---|
Tool execution error (isError: true) | Arrives inside a successful response. Return it to the model to prompt self-correction |
-32602 | A missing required _meta or an invalid parameter. Fix how the request is built |
-32021 MissingRequiredClientCapability | Declare the required capability and retry |
-32022 UnsupportedProtocolVersion | Choose a common version from data.supported and retry. If there is none, show the user an error |
-32020 HeaderMismatch | Refetch the definition with tools/list and retry with the correct headers |
You may show protocol errors to the model (MAY), but they rarely lead to self-correction. You should return tool execution errors to the model (SHOULD).
SSRF and URL validation
A client fetches URLs that an MCP server returns: the resource_metadata in WWW-Authenticate, the authorization_servers in the Protected Resource Metadata, and the token_endpoint and others in the authorization server metadata. A malicious server can plant internal destinations in these (internal IPs, cloud metadata, localhost services, DNS rebinding, redirect chains). A client deployed on a server implements the following.
- Require HTTPS for OAuth-related URLs. Allow
http://only for development loopback - Block private IPs, loopback, and link-local addresses. Validate IPs with a standard library, not hand-written string processing
- Do not follow redirects automatically, and apply the same validation at each hop
- Pin the resolved IP and connect to it (a countermeasure for the DNS TOCTOU). Block internal destinations with an egress proxy
Details and implementation examples are in the SSRF section of MCP Security Best Practices. The protocol has other places where a client fetches external resources.
$ref: Even if a JSON Schema$refpoints to a network URI, do not dereference it automatically (MUST NOT). Keep it disabled by default, and if you enable it, provide a host allowlist or rejection of loopback, link-local, and private networks, timeouts and size limits, and logging. Reject a schema whose external$reffails to resolve rather than treating it permissively- Icons: Allow only HTTPS or
data:URIs foricons, and rejectjavascript:,file:,ftp:, and similar. Fetch without credentials, reject redirects to a different origin, and check that the origin is the same as the server's. Set limits on size and frame count. SVG can contain executable content, so sanitize it or do not allow it. Verify the MIME type from the leading bytes
Managing multiple servers
A host can connect and disconnect servers dynamically as needed, rather than connecting them statically from a configured list. Keep servers and their summaries in a registry, connect when the model decides it needs one, and disconnect when it is no longer needed to free context.
This is effective when the user's intent is not known up front, as with a general-purpose agent. Start with a minimal set of always-on servers and add others as needed. Combined with Agent Skills, a skill file can declare the MCP servers it needs, and the host connects them only when the skill is invoked. Disconnection invalidates the prompt cache, so do it at a conversation boundary, not on every turn.
The host maintains a trust boundary per server.
- Every configured server shares the same model context. A malicious server can influence how the tools of other servers are used, so each added server widens the attack surface overall
- A tool result from one server is untrusted input to another. Let the user review server A's output before it is passed as an argument to a tool of server B. Truncating the output alone does not prevent exfiltration
- Manage authorization tokens per issuing authorization server and resource, and do not mix them
- When Code Mode calls several servers from a sandbox, the host acting as broker applies the same confirmation policy to each call from the sandbox. Approving the script does not mean approving every call it makes at runtime. Give the sandbox no network permissions, and keep credentials with the host
2. Host and Client Responsibilities
| Responsibility | Owner | Notes |
|---|---|---|
| Obtaining user consent | Host | Tool execution, data sharing, starting local servers |
| Presenting tools to the LLM | Host | Load everything, or search-based progressive loading |
| Protocol communication | Client | Building requests, interpreting results, retries |
| Storing authorization tokens | Client / Host | Manage credentials per issuing authorization server |
| Data isolation between servers | Host | Confirmation when passing one server's output to another |
3. Common Pitfalls
Assuming the 2025-era handshake
An implementation that sends initialize first and depends on connection state does not work with a 2026-07-28 server. Attach _meta to every request, and the HTTP headers too. If you also connect to legacy servers, detect the era and cache it.
Missing _meta or HTTP headers
A missing protocolVersion or clientCapabilities gives -32602, and a header that disagrees with the body gives HeaderMismatch with 400. Generate headers mechanically from the body, and do not forget the encoding of Mcp-Name and Mcp-Param-*.
Writing only one of the types in Accept
The server chooses JSON or SSE per request. Write both application/json and text/event-stream, and be able to handle both.
Using TTL as a polling interval
ttlMs is a freshness hint. Refetching periodically in the background puts needless load on the server. Check when the data is needed.
Not treating the cache as stale on list_changed
A notification invalidates the cache even before the TTL expires. Conversely, relying on notifications alone leaves data stale when one is lost, so use the TTL as well.
Reusing a cacheScope: private result across authorization contexts
Serving a result fetched with a different access token from a shared cache exposes another user's data. Include the authorization context in the cache key.
Automatically retrying tools with side effects
When a stream breaks, the server may already have finished the work. A retry is a new request, so it can cause a double execution. Automatically retry only read-only calls.
Showing only part of the arguments on the approval screen
A malicious tool can put data in the arguments you cannot see. Show the full arguments that will actually be sent.
Skipping confirmation based on annotations
readOnlyHint cannot be trusted unless it comes from a trusted server. Decide whether to confirm using the list of servers the host trusts and the user's settings.
Fetching OAuth URLs without validation
resource_metadata and token_endpoint are URLs a malicious server can return. Enforce HTTPS, block private IPs, validate redirects, and pin DNS.
Giving the model every tool of every server from the start
The definitions take most of the context, and tool selection accuracy also drops. Set a threshold and switch to progressive discovery when it is exceeded.
Ignoring tool name collisions
Another server's search collides and calls an unintended server. Use the server identifier as a prefix.
4. Code Examples
A single tool call (Streamable 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: execute_sql
Mcp-Param-Region: us-west1
Authorization: Bearer <access token>
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "execute_sql",
"arguments": { "region": "us-west1", "query": "SELECT 1" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleHost", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {},
"progressToken": "p-1"
}
}
}
This example is for a server that puts x-mcp-header: "Region" on the region parameter of execute_sql. Send Authorization on every request, as described in MCP Authorization.
Retry with elicitation (MRTR)
The response in which the server asks for input.
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required",
"inputRequests": {
"confirm": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Placing the order. The total is 3,000 yen.",
"requestedSchema": {
"type": "object",
"properties": { "confirmed": { "type": "boolean" } },
"required": ["confirmed"]
}
}
}
},
"requestState": "eyJzdGVwIjoxfQ..."
}
}
The client collects the user's input and resends the original request with a new ID. It returns requestState exactly as the server sent it.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "place_order",
"arguments": { "basket_id": "bsk_a1b2c3", "idempotency_key": "k-001" },
"inputResponses": {
"confirm": { "action": "accept", "content": { "confirmed": true } }
},
"requestState": "eyJzdGVwIjoxfQ...",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": { "elicitation": { "form": {} } }
}
}
}
Check the SDK documentation for the exact form in which clientCapabilities declares elicitation. This example only shows that a declaration is needed.
Opening a subscription
{
"jsonrpc": "2.0",
"id": 7,
"method": "subscriptions/listen",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
},
"notifications": {
"toolsListChanged": true,
"resourceSubscriptions": ["file:///project/config.json"]
}
}
}
notifications/subscriptions/acknowledged arrives first on the response stream, and later notifications carry io.modelcontextprotocol/subscriptionId: 7 in _meta.
Caching lists (TypeScript, SDK-independent parts)
A host-side cache that handles ttlMs, cacheScope, and invalidation by list_changed. Pass a function that calls tools/list through an SDK or HTTP as fetchPage.
interface ListResult<T> {
items: T[];
nextCursor?: string;
ttlMs?: number;
cacheScope?: "public" | "private";
}
interface Entry<T> {
value: ListResult<T>;
receivedAt: number;
authContext: string; // Include in the key for private (for example, an access token identifier)
}
class ListCache<T> {
private entries = new Map<string, Entry<T>>();
constructor(
private fetchPage: (cursor?: string) => Promise<ListResult<T>>,
) {}
private key(method: string, cursor: string | undefined, auth: string, scope: string) {
// public can share across authorization contexts. private cannot.
const a = scope === "private" ? auth : "*";
return `${method}|${cursor ?? ""}|${a}`;
}
async get(method: string, auth: string, cursor?: string): Promise<ListResult<T>> {
for (const scope of ["public", "private"] as const) {
const hit = this.entries.get(this.key(method, cursor, auth, scope));
if (hit && Date.now() < hit.receivedAt + Math.max(0, hit.value.ttlMs ?? 0)) {
return hit.value; // Fresh
}
}
try {
const value = await this.fetchPage(cursor);
const scope = value.cacheScope ?? "private"; // When unknown, fall back to the narrower scope
this.entries.set(this.key(method, cursor, auth, scope), {
value,
receivedAt: Date.now(),
authContext: auth,
});
return value;
} catch (e) {
// If refetching fails, you may use the stale cache.
for (const scope of ["public", "private"] as const) {
const stale = this.entries.get(this.key(method, cursor, auth, scope));
if (stale) return stale.value;
}
throw e;
}
}
// Call when notifications/tools/list_changed arrives. Treat as stale even before the TTL expires.
invalidate(method: string) {
for (const k of [...this.entries.keys()]) {
if (k.startsWith(`${method}|`)) this.entries.delete(k);
}
}
}
Connecting to a server with the official SDK (TypeScript)
The skeleton of the official quickstart client (it connects over stdio, fetches the tools, and calls one).
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
const mcp = new Client({ name: "mcp-client-cli", version: "1.0.0" });
await mcp.connect(
new StdioClientTransport({ command: process.execPath, args: ["build/index.js"] }),
);
const { tools } = await mcp.listTools();
console.log(tools.map((t) => t.name));
// Before running, the host shows the full arguments to the user and obtains approval.
const result = await mcp.callTool({
name: "search_orders",
arguments: { status: "pending" },
});
console.log(result.content);
await mcp.close();
5. Summary
- The host owns user consent, presenting tools to the model, and data flow between servers, and the client owns protocol correctness
- Attach
_meta(version and capabilities) to every request and, on HTTP, headers such asMcp-Method,Mcp-Name, andMcp-Param-*, and keep them consistent with the body server/discoveris optional. Use it for up-front version selection and the stdio backward-compatibility probe, and cache the determination of the server's era- For authorization, implement 401 handling, metadata discovery, PKCE,
resource,issvalidation, and step-up. See the separate documents for details - When there are many tools, set a threshold and switch to progressive discovery. Avoid tool name collisions with prefixes
- The consent UI shows the full arguments, does not trust annotations, and also shows the launch command of a local server. Elicitation goes through MRTR and is retried with a new ID
- Follow
ttlMsandcacheScopeand invalidate onlist_changed. Do not use TTL as a polling interval, and do not shareprivateacross authorization contexts - Subscribe with
subscriptions/listen, choosing only the kinds you need. Refetch when you reconnect - On failure, cancel on timeout and retry with a new ID. Do not automatically retry calls with side effects
- Validate OAuth URLs,
$ref, and icon fetches as an SSRF countermeasure - Keep a trust boundary per server, and review one server's output before passing it to another
Related Documents
- Building MCP Servers
- MCP Authorization
- The Context Bloat Problem and Mitigations
- MCP Security Best Practices
- AI Agent-Specific Threats
- Operating Local MCP Servers Safely