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 “” has a “url” but no “type”. Before v2.1.202 the same mistake surfaced as 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.

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.