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.
Per-tool
consent, and locking MCP down with managed-mcp.json
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.