Chapter 12. Claude Code MCP: What the Protocol Does

MCP is a client-server protocol with three nouns, and Claude Code is the client: a server exposes tools the model can call, resources you can attach to a prompt, and prompts that arrive as slash commands. An MCP server is the right instrument when the gap is access: a gap in procedure means a skill, and an outcome that must not depend on the model’s judgment means a hook. Running MCP well means getting four things right: the transport, the scope the definition lives in, the budgets the harness enforces, and the call on whether a server is the right instrument for the job at all.

On my machine, I use MCP servers for desktop control, Chrome, scheduled tasks, iMessages, and iOS simulation. I keep them separate from skills and hooks because each mechanism owns a different part of the action: MCP supplies the external capability, a skill supplies the procedure, and a hook enforces the gate. I grant servers the narrowest roots and credentials their jobs require. I audit the tool list before trusting a server in a production session.

Tools, resources, and prompts

Tools are the part people mean when they say MCP. Each one surfaces under the name mcp__<server>__<tool>, and that string is what permission rules and hook matchers see. Plugin-bundled servers get a longer form: mcp__plugin_<plugin-name>_<server-name>__<tool>, with any character outside A-Z, a-z, 0-9, underscore and hyphen replaced by an underscore. The server itself registers under the scoped name plugin:<plugin-name>:<server-name>. Write a matcher or a rule against the bare server key and it does nothing for a plugin’s tools, a mistake that fails without a message.

Two limits matter if you author servers. Claude Code truncates tool descriptions and server instructions at 2KB each, so a description that front-loads its trigger conditions beats an essay. And a tool whose input schema has a root-level anyOf, oneOf or allOf survives on v2.1.195 or later, where Claude Code flattens the schema into a single object and prepends a sentence describing the parameter groups. Versions before that skip every such tool.

Resources are the pull side. You reference them with @ mentions in the form @server:protocol://resource/path; the docs give @github:issue://123 and @docs:file://api/authentication as the canonical examples. Several fit in one prompt, they arrive as attachments, and the @ autocomplete fuzzy-searches resource paths. The distinction from tools is who decides: I pick a resource, the model picks a tool.

Prompts show up as slash commands in the format /mcp__servername__promptname, arguments space-separated after the command, as in /mcp__github__pr_review 456. Server and prompt names are normalized with spaces converted to underscores, and the result is injected into the conversation rather than run as a side process.

The four transports, and how to add each one

Four transports exist and their support is unequal.

Transport How you add it Status
stdio claude mcp add [options] <name> -- <command> [args...] local process
http claude mcp add --transport http <name> <url> the remote path
sse --transport sse deprecated
ws claude mcp add-json with {"type":"ws",...} --transport does not accept ws

Short flags: -t for --transport, -H for --header, -e/--env, -s/--scope.

In JSON configuration the type field also accepts streamable-http as an alias for http. The config error worth memorizing: an entry with a url but no type fails, and it now fails with a usable message, MCP server "<name>" has a "url" but no "type". Before v2.1.202 the same mistake surfaced as command: expected string, received undefined, which points at a field the entry does not contain.

Stdio servers run with CLAUDE_PROJECT_DIR set in their environment, and Claude Code answers the MCP roots/list request with the session’s launch directory plus every directory granted through --add-dir, /add-dir or the additionalDirectories setting, sending notifications/roots/list_changed when that set changes. Before v2.1.203 roots/list returned the launch directory alone and no change notification went out, so a server built against the older behavior may be under-reading its roots.

Reconnection is asymmetric across transports. HTTP and SSE servers that drop mid-session get exponential backoff: five attempts, starting at one second and doubling, after which the server is marked failed. The initial startup connection gets up to three retries on transient errors (5xx, connection refused, timeout) as of v2.1.121, while authentication and not-found errors get no retry at all. Stdio servers get no automatic reconnection: when the process dies, that server is gone for the session.

Where a server definition lives, and which one wins

A server definition lives in one of three scopes.

Scope Storage Who sees it
local (default) ~/.claude.json, under the project path current project only
project .mcp.json at the project root anyone who clones the repo
user ~/.claude.json all projects on the machine

Older versions called local scope “project” and user scope “global,” so writeups from that era read one scope off from current behavior.

When the same server is defined in more than one place, precedence runs, highest first: local scope, project scope, user scope, plugin-provided servers, claude.ai connectors. The winning source supplies the entire entry; fields do not merge across scopes, so a project-level headers block does not survive a local-level override. The three scopes match duplicates by name; plugins and connectors match by endpoint.

.mcp.json takes a top-level mcpServers object. Stdio entries use command, args, env. Remote entries use type, url, headers, headersHelper, timeout, alwaysLoad, oauth.

{
"mcpServers": {
"<name>": {
"type": "http",
"url": "<server-url>",
"headers": { "<header-name>": "${TOKEN_VAR}" }
}
}
}

Environment expansion supports ${VAR} and ${VAR:-default} in command, args, env, url and headers. An unset variable with no default does not fail the config. The literal ${VAR} text stays in place and claude mcp list reports a missing-variable warning, which means the token you forgot to export presents as an auth failure at the server rather than a startup error on your side. claude mcp list is where that diagnosis lives; go there before you blame the server.

Approval is tracked in two separate pairs of lists, and the distinction trips people. enabledMcpjsonServers and disabledMcpjsonServers in settings files control approval of servers defined in a project’s .mcp.json, the checked-in kind that runs someone else’s config on your machine. disabledMcpServers and enabledMcpServers are recorded per project in ~/.claude.json and cover user, plugin, connector and built-in servers; enabledMcpServers is also the opt-in for default-off built-ins such as computer-use. Claude Code consults one of those latter two lists per server, so neither overrides the other.

The claude mcp command set, and how a server signs in

The full subcommand set: add, add-json <name> '<json>', add-from-claude-desktop (macOS and WSL only), list, get <name>, remove, login <name>, logout <name>, reset-project-choices, and serve, which runs Claude Code itself as a stdio MCP server. Names added through claude mcp commands may contain letters, numbers, hyphens and underscores. Five names are reserved: workspace, claude-in-chrome, computer-use, Claude Preview, and Claude Browser, the last of them reserved as of v2.1.205. A config that defines one is skipped at load time with a rename warning; claude mcp add rejects them outright.

Authentication starts from the server’s side: Claude Code flags a remote server as needing auth when it answers 401 Unauthorized or 403 Forbidden. claude mcp login <name> runs the OAuth flow from the shell as of v2.1.186, and claude mcp logout <name> clears credentials. As of v2.1.191, login detects a missing local browser and prints the authorization URL instead; --no-browser forces that path, which is what you want over SSH.

Servers with pre-registered OAuth clients take --callback-port (pins the redirect URI to http://localhost:PORT/callback), --client-id, and --client-secret behind a masked prompt, with the MCP_CLIENT_SECRET environment variable covering CI. In JSON, the oauth object accepts clientId, callbackPort, authServerMetadataUrl (which must be https://), and scopes as a single space-separated string. These apply to the HTTP and SSE transports only. Metadata discovery checks RFC 9728 protected-resource metadata at /.well-known/oauth-protected-resource first, then falls back to RFC 8414 authorization-server metadata at /.well-known/oauth-authorization-server. One quiet behavior: if the authorization server advertises offline_access in scopes_supported, Claude Code appends it to pinned scopes.

Auth that is not OAuth goes through headersHelper: a shell command run at each connection, session start and reconnect both, with no caching, that must write a JSON object of string key-value pairs to stdout within a 10-second timeout. Dynamic headers override static headers of the same name, and the helper runs with CLAUDE_CODE_MCP_SERVER_NAME, CLAUDE_CODE_MCP_SERVER_URL and, for plugin-provided servers, CLAUDE_PLUGIN_ROOT in its environment.

Output, timeout, and context budgets the harness enforces

A server that returns too much, hangs, or floods the context does not get to. The harness enforces limits on all three axes.

Output first. A warning fires when any MCP tool output exceeds 10,000 tokens; that threshold is fixed. The default maximum is 25,000 tokens, raised with MAX_MCP_OUTPUT_TOKENS. A server author can lift a single tool’s text threshold by setting _meta["anthropic/maxResultSizeChars"] on its tools/list entry, up to a hard ceiling of 500,000 characters. Tools returning image data stay governed by MAX_MCP_OUTPUT_TOKENS regardless.

Time has four layers. MCP_TIMEOUT sets the server startup timeout in milliseconds. Per tool call, a per-server timeout field (milliseconds) is a hard wall-clock limit that overrides MCP_TOOL_TIMEOUT, whose default is about 28 hours; at that default, a hung call outlives any session you would want it in, so set the per-server field for anything remote. Values below 1000 are ignored and fall through to the default; before v2.1.162 they were floored to one second, a behavior flip worth knowing if a config predates it. HTTP, SSE and claude.ai connector servers additionally carry a 60-second per-request timer to the first response byte. And CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT (milliseconds, 0 disables) bounds how long a call may sit with no response or progress notification: five minutes default for HTTP, SSE, WebSocket and connector servers, 30 minutes for stdio. The idle timeout needs v2.1.187 or later, and stdio servers were exempt before v2.1.203.

As of v2.1.212, an MCP call in the main conversation still running at the two-minute mark moves to a background task. CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS moves the threshold (0 turns it off), and CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1 disables it along with the rest of the background-task machinery. Subagent calls, IDE server calls, and non-interactive runs (absent CLAUDE_AUTO_BACKGROUND_TASKS=1) stay in the foreground. The machine I am writing this on runs 2.1.211, one release short of that feature, so this paragraph comes from the docs rather than my own transcripts.

Context is the third budget. ENABLE_TOOL_SEARCH governs whether MCP tool definitions load at session start. Unset, every MCP tool is deferred and fetched on demand through tool search. true defers and sends the beta header on every request; auto loads definitions upfront when they fit within 10% of the context window; auto:N tunes that percentage from 0 to 100; false loads all definitions upfront. Deferral requires a model that supports tool_reference blocks, which means Claude Sonnet 4.5, Haiku 4.5, Opus 4.5 and later, and CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS keeps the feature off no matter what ENABLE_TOOL_SEARCH says. Tool search is off by default on Google Cloud’s Agent Platform and when ANTHROPIC_BASE_URL points at a non-first-party host. You can also block the ToolSearch tool itself with a permissions deny rule, {"permissions": {"deny": ["ToolSearch"]}}; without tool search, Claude Code uses a WaitForMcpServers tool to wait on servers still connecting.

A server that has to be visible from the first turn gets alwaysLoad: true (v2.1.121 or later, any server type). That exempts it from deferral and blocks startup until it connects, capped at the standard 5-second connect timeout, so reserve it for servers you would rather wait on than miss. A server author can pin one tool instead with "anthropic/alwaysLoad": true in the tool’s _meta.

Capabilities can change mid-session. Claude Code honors list_changed notifications to refresh a server’s tools, prompts and resources without reconnecting. As of v2.1.214 a failed refresh keeps the previous capabilities; before that, a transient refresh error replaced them with an empty list, which looked like a server losing its tools for no reason. A server can also push messages into a session by declaring the claude/channel capability, opted in with the --channels flag at startup.

The protocol carries its own consent hook. A server marks a tool with _meta["anthropic/requiresUserInteraction"]: true (a JSON boolean, nothing else counts) and Claude Code prompts on every call to it, with no don’t-ask-again option, even in acceptEdits, auto and bypassPermissions modes; in dontAsk mode the call is denied instead. That needs v2.1.199 or later, and under --permission-prompt-tool an allow result converts to a deny with the message: MCP tool requires user interaction; not supported via –permission-prompt-tool.

claude.ai connectors are their own category. They are fetched only when the active auth method is a claude.ai subscription login; ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, apiKeyHelper, Bedrock, Google Cloud Agent Platform, and a CLAUDE_CODE_OAUTH_TOKEN from claude setup-token do not bring them along. disableClaudeAiConnectors turns them off with any-source-true semantics, as does ENABLE_CLAUDEAI_MCP_SERVERS=false. Organizations get per-tool control on connectors: a tool set to ask prompts on every call with the reason “Your organization requires approval for this tool” (enforced from v2.1.129, and denied outright in dontAsk), and a tool set to blocked is filtered out before Claude sees it.

Full lockdown uses managed-mcp.json, same format as .mcp.json, at /Library/Application Support/ClaudeCode/managed-mcp.json on macOS, /etc/claude-code/managed-mcp.json on Linux and WSL, and C:\Program Files\ClaudeCode\managed-mcp.json on Windows. Its presence is exclusive: only the servers it defines load, plugin servers and connectors included in the suppression, and an empty {"mcpServers": {}} disables MCP entirely. It cannot be delivered through server-managed settings; it has to be a file on disk.

The softer controls are allowedMcpServers and deniedMcpServers, lists whose entries each carry one of three keys: serverUrl (exact or with * wildcards, hostname matching case-insensitive, paths case-sensitive), serverCommand (exact command-and-argument-array match), or serverName (literal, no wildcard expansion). Leaving the allowlist unset allows all servers; an empty array allows none. Deny entries merge from every settings source and beat allow entries. allowManagedMcpServersOnly: true narrows allowlist enforcement to managed settings while denylists still merge, and allowAllClaudeAiMcps: true (v2.1.149 or later) re-admits connectors alongside a managed file; both read from admin policy tiers only. A blocked add attempt gets a specific error string, Cannot add MCP server "<name>": server is explicitly blocked by enterprise policy for a denylist match, with parallel strings for the allowlist miss and the exclusive-control case. The trap is the other direction: a server already configured that a new policy blocks does not error. It drops out of /mcp and claude mcp list with nothing shown, and you get to figure out why.

When to use an MCP server instead of a skill or a hook

These three get conflated because they all live in dotfiles and they all change what a session does. They answer different questions.

An MCP server answers “what can the model reach.” It adds tools and data the session did not have: an issue tracker, a database, a live API. The model decides when to call, and everything that comes back is charged against the output and context budgets above.

A skill answers “how should the model do this.” It adds no capability. It loads instructions, a procedure written once and pulled in when the task matches. When the model has the tools it needs and the failure is order or judgment, the fix is prose, not a server process. A server whose tools mostly return canned instructions should have been a skill.

A hook answers “what must happen regardless.” Hooks fire on events, not on model judgment, and the two systems meet in three documented places. First, MCP tools appear to hook matchers as mcp__<server>__<tool>, and matching a whole server takes mcp__<server>__.*; a bare prefix like mcp__memory contains only exact-match characters and matches no tool. Second, a hook can itself be type mcp_tool, with required server and tool fields and an input whose string values substitute ${path} expressions from the hook’s JSON input, such as ${tool_input.file_path}. That hook runs against a server that is already connected: it never triggers an OAuth or connection flow, and a disconnected server or a tool result with isError: true is a non-blocking error, so an mcp_tool hook can observe and annotate but its server going down cannot brick the session. For a plugin server, the hook’s server field takes the scoped name plugin:<plugin-name>:<server-name>, not the bare key. Third, the Elicitation and ElicitationResult hook events match on the MCP server name, which is how a hook gets a say when a server asks the user a question mid-call.

The sorting rule I run: a gap in access means a server, a gap in procedure means a skill, and an outcome that must not depend on the model’s judgment means a hook. The protocol does one job, at a price the harness defines in tokens, timeouts and prompts, and the sections above are that price list.