Chapter 13. Claude Code MCP in Practice: Scopes, Auth, Limits, and Failure Modes
When the same MCP server is defined in more than one place,
precedence runs highest first: local scope, project scope, user scope,
plugin-provided servers, claude.ai connectors. A server that never
appears is a different problem, and on this Mac (Claude Code 2.1.211,
native install) most of the servers my sessions use have no disk
footprint at all. Before drafting this chapter I audited it: no
~/.mcp.json exists, one repo in the entire
~/code tree carries a .mcp.json, and zero of
the 133 project entries inside ~/.claude.json hold a
non-empty mcpServers map. Sessions here still run with
servers for desktop control, Chrome, iMessages, scheduled tasks, and an
iOS simulator, and none of those appear in any of those files. Each
layer of the stack has its own registration format, its own auth story,
and its own failure signature.
MCP scope precedence: which definition wins
Claude Code defines three installation scopes. local is
the default, stored in ~/.claude.json under the project’s
path and visible in that one project. project writes
.mcp.json at the repo root and travels with version
control. user sits in ~/.claude.json too and
applies across projects. Older releases used other names (local scope
was “project”, user scope was “global”), so a pre-rename blog post will
point you at the wrong flag.
When the same server is defined more than once, precedence runs
highest first: local scope, project scope, user scope, plugin-provided
servers, claude.ai connectors. The winning entry is used whole; fields
are not merged across scopes. The three scopes match duplicates by name,
while plugins and connectors match by endpoint. That is the
deduplication rule, and it carries a trap: a stale local-scope entry
shadows the committed .mcp.json, and edits to the shared
file change nothing until someone removes the local entry.
The audit shows how little of this machine’s stack the three named
scopes carry. ~/.claude.json has no top-level
mcpServers key. Its projects map holds 133
entries and not one has a non-empty mcpServers,
enabledMcpjsonServers, or mcpContextUris.
There is no ~/.mcp.json. One repo,
suede-creator-skills, has a project-scope
.mcp.json, plus copies in two of its worktrees. The servers
this machine uses day to day come from the bottom two rungs of the
ladder: plugins and the host application.
That one repo file is worth reading because it shows the stdio shape and a pattern I reuse, two servers from one script:
{
"mcpServers": {
"suede_creator_mcp": {
"title": "...",
"description": "...",
"cwd": "${CLAUDE_PLUGIN_ROOT}",
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/mcp/suede-skills-mcp.mjs", "--profile", "creator"]
}
}
}suede_workflow_mcp sits beside it in the same file: same
script, --profile workflow instead of
--profile creator. ${CLAUDE_PLUGIN_ROOT}
appears in both cwd and args, so the server
resolves wherever the plugin cache lands. Expansion supports
${VAR} and ${VAR:-default} in command, args,
env, url and headers. An unset variable with no default does not error
at parse time: the literal ${VAR} text stays in the config
and claude mcp list reports a missing-variable warning.
That warning is the first place to look when a server starts and then
behaves as if half-configured.
Plugins register servers by shipping a .mcp.json at the
root of their cached install directory. On this disk those exist for
vercel (0.43.0 and 0.44.0), context7 (three version directories), and
suede-skills (0.6.0, 0.6.1, 0.6.2). The vercel entry is the remote
shape, {"type": "http", "url": "https://mcp.vercel.com"}
plus a free-form note field. Plugin servers register under
a three-part id,
plugin:<plugin-name>:<server-name>, so the
Vercel server is plugin:vercel:vercel.
| Server | Registered by | Transport |
|---|---|---|
suede_creator_mcp |
suede-creator-skills/.mcp.json, also shipped in the
suede-skills plugin cache |
stdio:
node mcp/suede-skills-mcp.mjs --profile creator |
suede_workflow_mcp |
same .mcp.json |
stdio: same script, --profile workflow |
plugin:vercel:vercel |
vercel plugin .mcp.json |
HTTP: https://mcp.vercel.com |
| context7 | context7 plugin cache .mcp.json |
not declared on this disk; installed as the
context7@claude-plugins-official plugin, usage count 0 |
That table is the complete set of MCP servers registered in files on this disk. It is not the set of servers my sessions see. The difference is the subject of the last section.
The four
transports and the claude mcp commands
| Transport | How to add | Notes |
|---|---|---|
| HTTP | claude mcp add --transport http <name> <url> |
JSON accepts streamable-http as an alias for
http |
| SSE | --transport sse |
deprecated transport |
| stdio | claude mcp add [options] <name> -- <command> [args...] |
the command follows the -- |
| WebSocket | claude mcp add-json with
{"type":"ws",...} |
--transport does not accept ws |
Short flags: -t for --transport,
-H for --header,
-e/--env,
-s/--scope. Remote entries in
.mcp.json take type, url,
headers, headersHelper, timeout,
alwaysLoad, and oauth. The full
claude mcp roster: 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 these
commands may contain letters, numbers, hyphens and underscores, nothing
else. Five names are reserved and rejected outright:
workspace, claude-in-chrome,
computer-use, Claude Preview, and
Claude Browser (the last reserved from v2.1.205).
One config error is worth memorizing because its old form lied. An
entry with a url but no type now reports: MCP
server “command: expected string, received undefined, which sends
you hunting a command field that was never the problem.
OAuth login,
headersHelper, and connector auth
A remote server answering 401 Unauthorized or 403 Forbidden gets
flagged as needing authentication. The interactive fix is
claude mcp login <name>, a shell-driven OAuth flow
available since v2.1.186, with
claude mcp logout <name> to clear credentials. Since
v2.1.191 a missing local browser is detected and the authorization URL
printed instead; --no-browser forces that behavior, which
is the path for a box you reach over SSH.
Pending auth is visible on disk.
~/.claude/mcp-needs-auth-cache.json maps server ids to
{timestamp}, and mine holds three entries:
plugin:marketing:notion,
plugin:legal:docusign, plugin:vercel:vercel.
Names and timestamps, no credentials. When a server’s tools have gone
missing, this file answers “is it waiting on OAuth” in one read.
For automation you pre-configure the OAuth client instead of clicking
through it: --callback-port pins the redirect URI to
http://localhost:PORT/callback, --client-id
names the client, --client-secret prompts masked, and
MCP_CLIENT_SECRET carries the secret in CI. In JSON the
oauth object accepts clientId,
callbackPort, authServerMetadataUrl (must be
https://), and scopes as a single
space-separated string. These apply to HTTP and SSE transports only.
Discovery checks RFC 9728 protected-resource metadata at
/.well-known/oauth-protected-resource first, then falls
back to RFC 8414 at
/.well-known/oauth-authorization-server. If the
authorization server advertises offline_access in
scopes_supported, Claude Code appends it to your pinned
scopes, which is how you end up with refresh tokens you did not
request.
Not everything speaks OAuth. headersHelper runs a shell
command at each connection (session start and reconnect, no caching)
that must print a JSON object of string key-value pairs to stdout within
a 10-second timeout; dynamic headers override static
headers of the same name. The helper receives
CLAUDE_CODE_MCP_SERVER_NAME,
CLAUDE_CODE_MCP_SERVER_URL, and, for plugin servers,
CLAUDE_PLUGIN_ROOT. Kerberos tickets, short-lived signed
tokens, vault reads: this is their hook, and the 10-second budget is
where it breaks under load.
Connectors are the layer most people forget has an auth precondition.
claude.ai connectors are fetched only when the active authentication
method is a claude.ai subscription login. Runs on
ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN,
apiKeyHelper, Bedrock, Google Cloud Agent Platform, or a
CLAUDE_CODE_OAUTH_TOKEN from
claude setup-token get zero connectors. My
.claude.json records six under
claudeAiMcpEverConnected: Google Drive, Gmail,
Malwarebytes, Vercel, Stripe, and Google Calendar. The key name is
honest: it records that a connection happened at some point, not that
one is live now, and the disk offers no way to tell the two apart.
Output limits: token ceilings and the 2KB description cut
The default maximum for an MCP tool result is 25,000 tokens, and
MAX_MCP_OUTPUT_TOKENS raises it. A warning fires when any
MCP tool result exceeds 10,000 tokens; that threshold is fixed. A server
author can lift a single tool’s text ceiling by setting
_meta["anthropic/maxResultSizeChars"] on its tools/list
entry, up to a hard cap of 500,000 characters, though tools returning
image data stay bound by MAX_MCP_OUTPUT_TOKENS
regardless.
A quieter limit bites on the input side: Claude Code truncates MCP tool descriptions and server instructions at 2KB each. A 5KB description does not error. It gets cut, and the model routes calls using whichever half survived.
Deferred tools and
ENABLE_TOOL_SEARCH
By default MCP tools are deferred: the model sees names, and schemas
load on demand through the ToolSearch tool.
ENABLE_TOOL_SEARCH controls this. Unset means deferred;
true means deferred with the beta header always sent;
auto loads tools upfront if they fit within 10% of the
context window; auto:N takes N from 0 to 100;
false loads all tools upfront. Deferral requires a model
that supports tool_reference blocks (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
also off by default on Google Cloud’s Agent Platform and whenever
ANTHROPIC_BASE_URL points at a non-first-party host, so the
same configuration produces different loading behavior on a proxy than
against the first-party API. Check that before debugging “missing” tools
in a proxied environment. ToolSearch itself can be shut off with a
permissions rule,
{"permissions": {"deny": ["ToolSearch"]}}, and without tool
search Claude Code falls back to a WaitForMcpServers tool
for servers still connecting.
Two escape hatches exist. alwaysLoad: true on a server
(v2.1.121 or later) exempts it from deferral and loads its tools at
session start; the cost is that startup blocks until the server
connects, capped at the standard 5-second connect timeout. A server can
also mark single tools with "anthropic/alwaysLoad": true in
the tool’s _meta.
One schema rule doubles as a version trap. Tools whose input schema
has a root-level anyOf, oneOf or
allOf are flattened into a single object as of v2.1.195,
with a generated sentence describing the parameter groups. Earlier
versions skip those tools with no notice. When a teammate sees a tool
you do not, compare Claude Code versions before comparing configs.
How to disable a server: two setting pairs that do not overlap
Two unrelated pairs of settings turn servers off, and which pair
applies depends on where the server came from.
disabledMcpServers and enabledMcpServers are
recorded per project in ~/.claude.json and govern user,
plugin, connector, and built-in servers; the enabled list is also how
default-off built-ins such as computer-use get opted in.
enabledMcpjsonServers and
disabledMcpjsonServers live in settings files and control
approval of servers defined in a project’s .mcp.json.
Claude Code consults one pair per server based on where that server came
from, and neither pair overrides the other. Disable a
.mcp.json server in disabledMcpServers and it
keeps loading, with no error to tell you why.
Timeouts, reconnection, and what a dead server looks like
| Knob | Unit | Default | Applies to |
|---|---|---|---|
MCP_TIMEOUT |
ms | not documented (docs show MCP_TIMEOUT=10000 as an
example) |
server startup |
MCP_TOOL_TIMEOUT |
ms | about 28 hours | per tool call |
per-server timeout |
ms | unset | hard wall-clock per call, overrides
MCP_TOOL_TIMEOUT |
| per-request first-byte timer | s | 60 | HTTP, SSE, connectors |
CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT |
ms | 5 min remote, 30 min stdio | idle window; 0 disables |
Fine print that has cost me time reading other people’s configs:
per-server timeout values below 1000 are ignored and fall
through to the environment default (before v2.1.162 they were floored to
one second, a different wrong). The idle timeout requires v2.1.187 or
later, and stdio servers were exempt from it before v2.1.203.
Automatic backgrounding is the newest wrinkle: an MCP call in the
main conversation still running after two minutes moves to a background
task. That requires v2.1.212. This machine runs 2.1.211, so the feature
is documented and absent here, along with the v2.1.214 behaviors.
Version-gating cuts both ways: the docs describe the current release,
your machine runs whatever it runs. Where backgrounding does exist,
subagent calls, IDE server calls, and non-interactive runs are never
moved (unless CLAUDE_AUTO_BACKGROUND_TASKS=1),
CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS shifts the threshold,
and CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1 removes the
feature class outright.
Reconnection behavior splits by transport. HTTP and SSE servers that drop mid-session reconnect with exponential backoff, five attempts starting at one second and doubling, after which the server is marked failed. Initial startup connections retry up to three times on transient errors (5xx, connection refused, timeout) as of v2.1.121. Authentication and not-found errors are never retried, which is correct: replaying a 401 produces nothing but log noise. Stdio servers get no reconnection at all. A crashed stdio server stays dead for the rest of the session, and the announcement is tool calls erroring, nothing louder.
The list_changed path has its own version trap. As of
v2.1.214 a failed capability refresh keeps the previous tool list.
Before that, and on this machine at 2.1.211, a transient refresh error
replaces the list with an empty one. A server that “lost all its tools”
mid-session may have hit one bad refresh, and a session restart brings
them back.
The short list of signals I check, in order:
claude mcp list for missing-variable warnings and
connection state; ~/.claude/mcp-needs-auth-cache.json for
pending OAuth; the machine’s Claude Code version against whatever docs
page describes the behavior I expected; and, in managed environments,
whether the server vanished from /mcp without an error,
which is what a policy block looks like from the user’s chair.
Host-provisioned servers and what a headless run inherits
The sharpest audit result: the largest group of servers on this
machine has no disk footprint at all. Sessions here surface
computer-use, Control_Chrome, Read_and_Send_iMessages, the ccd_session
tools, scheduled-tasks, mcp-registry, visualize, Claude_Browser, and
Claude_Code_iOS_Simulator. Not one has an mcpServers
definition in ~/.claude.json, in a home-level
.mcp.json (which does not exist), or in any repo
.mcp.json. A marketing plugin sits in the same bucket: its
skills and plugin:marketing:* servers appear in sessions
while no file matching *marketing* exists under
~/.claude/plugins and no marketing marketplace is
registered in known_marketplaces.json. The host application
provisions these through a channel local config does not show. I could
not confirm the mechanism from disk, and I am not going to pretend
otherwise.
The operational consequence lands on scheduled and scripted work. A
claude -p job inherits none of the guarantees an
interactive session taught you to expect. Host-provisioned servers may
not be present. Connectors are absent unless the run authenticates by
subscription login. OAuth cannot complete mid-run: nothing pops a
browser, and an mcp_tool hook never triggers an OAuth or
connection flow, so a disconnected server there degrades to a
non-blocking error the job may not even log. What survives headless is
the boring layer: stdio and remote servers registered in files, with
auth arranged ahead of time through MCP_CLIENT_SECRET, a
pre-configured OAuth client, or a headersHelper that mints
its own credentials.
The rule I run now: enumerate with claude mcp list under
the same auth method and the same directory the job will use, before
trusting one tool call. An interactive session’s tool list is evidence
about that session and nothing else.