CCLearn

MCP — transports, scopes and OAuth

verified against Claude Code 2.1.261 · 2026-09-0915 min

By the end you can

  • Add a server at the scope that matches who should have it
  • Predict which definition wins when the same server is defined twice
  • Write a .mcp.json entry with a type and a ${VAR} that expands
  • Authenticate a remote server and know where that flow is unavailable
  • Read claude mcp list and /mcp status instead of guessing
  • Account for what a connected server costs your context window

Lessons 9.1 to 9.4 intercepted things Claude already does. This one hands it something it could not do at all — an issue tracker, a database, a browser — and the failure modes point the same direction as the hook ones: nothing errors.

claude mcp add answers with an Added ... line. That line means the configuration was written to a file. It does not mean the command runs, that the URL answers, or that this project is the project the server will load in. The docs say so directly about the local stdio case: "The Added confirmation means the entry was saved, not that the command runs."

Three questions decide whether the thing you just added exists tomorrow: which scope, which transport, and who signs in.

Three scopes, two files

ScopeAdd it withLoads inShared with teamStored in
Localnothing, or --scope localCurrent project onlyNo~/.claude.json
Project--scope projectCurrent project onlyYes, via version control.mcp.json in project root
User--scope userAll your projectsNo~/.claude.json

Two of the three live in the same file, which is why the mental model has to be scope-first rather than file-first:

The claude mcp add command writes the server to one of three scopes, stored across two files, depending on the --scope flag. — Connect to MCP servers

Local and user scope both land in ~/.claude.json — local under the entry for this project's path, user under the top-level mcpServers key. Project scope is the only one that produces a file you can commit.

And local is what you get when you say nothing:

Local scope is the default. A local-scoped server loads only in the project where you added it and stays private to you. Claude Code stores it in ~/.claude.json under that project's path, so the same server won't appear in your other projects. — Connect Claude Code to tools via MCP

So /mcp reporting No MCP servers configured usually has nothing to do with the server. The docs list running claude mcp add from a different project first among its causes. The second is editing a file at a path Claude Code does not read: it reads ~/.claude.json and <project>/.mcp.json, and it does not read ~/.claude/.mcp.json, ~/.claude/mcp.json, ~/.claude/config/mcp.json, or %APPDATA%\Claude\mcp.json.

Choose from who should have the server, not from what is easiest to type: a throwaway or a credentialled server nobody else should get is local, a server everyone who clones the repo needs is project, and a tool you want in every project of your own is user.

Precedence: the whole entry, not merged fields

Nothing stops the same server name existing at two scopes at once. What happens then is the rule most worth memorising here:

When the same server is defined in more than one place, Claude Code connects to it once, using the definition from the highest-precedence source. The entire server entry from that source is used; fields are not merged across scopes. — Connect Claude Code to tools via MCP

The order, highest first: local scope, project scope, user scope, plugin-provided servers, claude.ai connectors. The three scopes match duplicates by name; plugins and connectors match by endpoint, so one pointing at the same URL or command as a server above counts as a duplicate too.

Read that precedence order in the direction that bites. Your local entry wins over the team's committed one. An experimental github server you added six months ago, pointing at a stale URL, shadows the .mcp.json your team maintains — and because entries are taken whole, the team's headers and env are not merged into yours. They are simply not used.

Claude Code does warn: defining the same name at more than one scope with different endpoints produces a conflict warning in claude mcp list output and in /mcp. The warning quotes each scope's endpoint as written, with ${VAR} references unexpanded, so it never prints a resolved secret. There is a second consequence buried in that warning that costs people an afternoon: OAuth sign-ins are stored per endpoint, so authenticating the definition that loads in one project leaves you signed out in a project where the other definition wins.

Transports, and the type you have to write

TransportAdd it withNotes
httpclaude mcp add --transport http <name> <url>Recommended for remote servers; supports OAuth and the --transport flag
sseclaude mcp add --transport sse <name> <url>Deprecated
stdioclaude mcp add <name> -- <command> [args]Local process; the default when no --transport is given
wsclaude mcp add-json or .mcp.json only--transport does not accept ws; header-only auth

The SSE (Server-Sent Events) transport is deprecated. Use HTTP servers instead, where available. — Connect Claude Code to tools via MCP

bash
# Remote HTTP server
claude mcp add --transport http notion https://mcp.notion.com/mcp

# Local stdio server: everything after -- is the server's own command line
claude mcp add --env AIRTABLE_API_KEY=YOUR_KEY --transport stdio airtable \
  -- npx -y airtable-mcp-server

The -- matters. Without it Claude Code tries to parse the server's own flags, such as --port, as its own options. And --env takes multiple KEY=value pairs, so if the server name comes directly after --env, the CLI reads the name as another pair and rejects it — put at least one other option between them, as above.

Writing the JSON by hand is where the trap lives. In .mcp.json, type is not decoration:

A JSON entry that has a url but no type is a configuration error, because Claude Code reads an entry with no type as a stdio server. — Connect Claude Code to tools via MCP

Claude Code skips that server and reports MCP server "<name>" has a "url" but no "type". The rest of the file still loads. This is exactly what you get when you paste an mcpServers block from a server's setup instructions written for another client — repair it by adding "type": "http", "sse" or "ws" to match the endpoint. In JSON, streamable-http is accepted as an alias for http, so blocks copied from the MCP specification's naming work unmodified.

That message is newer than it reads, and the one it replaced is worth recognising, because older builds are still out there on colleagues' machines. "Before v2.1.202, Claude Code reported this misconfiguration as command: expected string, received undefined" (MCP) — a line that names a field your entry does not have, never says type or url, and describes a broken stdio server while you are looking at an HTTP one. Nothing in it points at the fix. The rule to carry is that expected string, received undefined next to an entry that has a url means the missing type, every time.

${VAR} expansion, and the warning you will miss

.mcp.json is committed, so machine-specific paths and secrets go in through environment variables. Two forms: ${VAR}, and ${VAR:-default}. They expand in command, args, env, url and headers.

json
{
  "mcpServers": {
    "api-server": {
      "type": "http",
      "url": "${API_BASE_URL:-https://api.example.com}/mcp",
      "headers": {
        "Authorization": "Bearer ${API_KEY}"
      }
    }
  }
}

Two related warnings appear in the same claude mcp list output and are just as easy to scroll past. Claude Code flags hidden leading or trailing whitespace in command, url, args, env and headers values — usually a token pasted with a trailing newline — naming the field without echoing its value, and it does not trim it. And ${CLAUDE_PROJECT_DIR} is set in the server's environment, not in Claude Code's, so referencing it as ${VAR} inside a .mcp.json entry needs a default such as ${CLAUDE_PROJECT_DIR:-.} to expand to anything.

OAuth, approval, and the sessions that show neither

Two different dialogs stand between a configured server and a working one, and both are interactive-only.

The OAuth flow. A remote server that answers 401 or 403 is flagged in /mcp as ! Needs authentication; you select it, choose Authenticate, and sign in through the browser. From v2.1.186 you can do it from the shell instead with claude mcp login <name>, and claude mcp logout <name> clears the credentials. When no local browser is available — an SSH session, a Linux box with no display server — claude mcp login detects that and prints the authorization URL instead of trying to open one; you open it on your own machine and paste the full redirect URL back at the prompt. That paste needs an interactive terminal, so connect with ssh -t. Pass --no-browser to force the same prompt when a browser is detected.

One exception is worth knowing because it sends people round in circles: if you configured the Authorization header yourself, a 401 or 403 does not flag the server for sign-in. Claude Code reports the connection as failed instead, because the credential to fix is the one you wrote.

The project-server approval. A .mcp.json in a repository you cloned is a list of processes someone else wants started on your machine, so:

For security reasons, Claude Code prompts for approval in interactive sessions before using project-scoped servers from .mcp.json files. To reset those approval choices, run claude mcp reset-project-choices. — Connect Claude Code to tools via MCP

Read the entry before you approve it — the habit Lesson 4.5 asked for with a diff, applied to a config file. For a stdio entry, command and args are what will run as you.

Now the part that carries into the next two lessons: that prompt is interactive-only. "In claude -p runs, Agent SDK sessions, and cloud sessions, Claude Code can't show that prompt: it loads project-scoped servers without asking." The SDK is the one qualified case — it loads them only when settingSources includes project settings — and claude mcp list in that same folder still reports the servers as pending, which is usually how you find out. The approval that protects you interactively is absent exactly where nobody is watching. The one control that keeps a server out of every session type regardless is the disabledMcpjsonServers setting. Lesson 9.6 takes up what a connected server can then do to you, and 9.7 builds the whole -p trust table and the rest of the flags that stop the discovery.

Reading status instead of guessing

claude mcp list prints a health status per server; claude mcp get <name> shows one server's detail, including which scope holds its definition; /mcp is the in-session panel where you authenticate, reconnect and toggle.

StatusWhat it means
✔ ConnectedReady to use
! Connected · tools fetch failedConnected, could not list tools — claude mcp get has the detail
! Needs authenticationReachable, needs a browser sign-in or a token header
✘ Failed to connectThe server did not respond, or rejected the token you configured
✘ Connection errorThe connection attempt threw
⏸ Pending approvalA project-scoped server you have not approved
⊘ Disabled for this projectTurned off in /mcp; re-enable there

The last two are a different kind of statement from the rest:

The statuses in this list report a configuration decision rather than a connection attempt, so Claude Code prints them without connecting to the server. — Connect Claude Code to tools via MCP

Which means curl-ing the URL of a ⏸ Pending approval server proves nothing and fixes nothing. Nobody tried to connect. Run claude in the project and approve it.

The row below it joined that rule late. Printing ⊘ Disabled for this project without connecting arrived in v2.1.238; "before v2.1.238, both commands connected to a disabled server to health-check it and reported the connection result." Which was beside the point in either era — a disabled server is off whatever the network says, and the fix is the /mcp toggle.

For the statuses that are connection attempts, the documented order is: read the failure detail first — from v2.1.219 claude mcp list appends the HTTP status or error code and the server's error text to the status line, and claude mcp get shows the same on an Issue: line — then check the claude mcp list warnings, then curl -I <url> for an HTTP server or run the configured command directly for a stdio one. A 404 or 405 from curl means the server is up; many MCP endpoints answer only POST. ✘ Connection error never carries detail on any version, because the exception text can embed the expanded URL, which can carry secrets.

One failure is not a failure at all: a stdio server whose first run is slow while npx downloads the package can exceed the "default 30-second startup timeout" and show ✘ Failed to connect. Wait and re-run claude mcp list, or raise it with MCP_TIMEOUT=60000 claude. Note also that WebSocket servers never appear in claude mcp list; check them with claude mcp get or /mcp.

What a server costs your window

Lesson 3.1 put MCP tool names in the startup table and Lesson 3.2 sent you to /context to find servers you are paying for and not using. Here is the reason they are cheap-but-not-free: tool search is on by default and defers tool definitions until Claude needs them, so "only tool names and server instructions load at session start". Claude Code imposes no fixed per-server tool cap; your context window budget is the limit.

The output side is capped rather than deferred:

Claude Code displays a warning when MCP tool output exceeds 10,000 tokens and limits output to 25,000 tokens by default. To raise the limit, set the MAX_MCP_OUTPUT_TOKENS environment variable (for example, MAX_MCP_OUTPUT_TOKENS=50000); the warning threshold is fixed. — Connect Claude Code to tools via MCP

A server you added once and never use is a standing charge on every session in its scope — which is the argument for local scope over user scope for anything experimental, and for toggling a server off in /mcp rather than leaving it connected. Toggling is recorded per project in ~/.claude.json, so the configuration survives.

Checkpoint

  1. A teammate commits .mcp.json with a github server. You added a github server at local scope months ago, pointing at a different URL. Which one connects, what does claude mcp list tell you, and why does the sign-in you completed last week not apply?

    Show answer

    Yours connects, and the team's is not used at all. Precedence runs local scope, project scope, user scope, plugin-provided servers, claude.ai connectors — highest first — and Claude Code connects once, using the entire server entry from the highest-precedence source. Fields are not merged across scopes, so the team's URL, headers and env are not blended into your definition; they are simply ignored.

    claude mcp list tells you there is a conflict: the same name defined at more than one scope with different endpoints produces a conflict warning there and in /mcp, quoting each scope's endpoint as written, with ${VAR} references left unexpanded so no resolved secret is printed. And last week's sign-in does not apply because OAuth sign-ins are stored per endpoint — you authenticated the endpoint of the definition that wins somewhere else, so in the project where this one wins you are signed out. Scope is fixed when a server is added, so handing the win back to .mcp.json means removing your local entry and re-adding it at another scope if you still want it; while the name exists at several, claude mcp remove reports exists in multiple scopes and wants --scope.

  2. Your .mcp.json has two entries: one with a url and no type, one with "Authorization": "Bearer ${API_KEY}" where API_KEY is unset in your shell. Say what happens to each entry, and which of the two you would find faster.

    Show answer

    The first entry is skipped; the second connects and fails. An entry with a url and no type is a configuration error, because Claude Code reads a typeless entry as a stdio server — it reports MCP server "<name>" has a "url" but no "type", skips that server, and loads the rest of the file. Add "type": "http", "sse" or "ws" to match the endpoint (streamable-http is accepted as an alias for http). The unset variable does not fail the config at all: you get a missing-variable warning for that server in claude mcp list and the unexpanded ${VAR} text is used as-is, so the server presents the literal string Bearer ${API_KEY}, gets a 401, and reports a failed connection.

    You would find the first one faster. It names the field and implies the fix. The second surfaces as a connection failure that looks nothing like its cause, and because you configured the Authorization header yourself a 401 or 403 does not flag the server for sign-in — Claude Code reports the connection as failed instead, since the credential to fix is the one you wrote. The missing-variable warning in claude mcp list is the thing that actually tells you, which is the argument for reading those warnings rather than scrolling past them. On builds older than v2.1.202 even the first would have been slower: it reported command: expected string, received undefined, naming a field the entry does not have.

  3. CI runs claude -p in a cloned repository. claude mcp list on that machine shows the repo's server as ⏸ Pending approval. What does the -p run actually do with that server, and which setting — as opposed to a startup flag — keeps it out in every permission mode?

    Show answer

    It connects the server, without asking. The approval prompt for project-scoped .mcp.json servers exists only in interactive sessions: in claude -p runs, Agent SDK sessions and cloud sessions Claude Code can't show that prompt, so it loads project-scoped servers without asking. (The SDK is the qualified case — it loads them only when settingSources includes project settings.)

    The ⏸ Pending approval you see is not a report that the run declined the server. That status, like ⊘ Disabled for this project, reports a configuration decision rather than a connection attempt, so Claude Code prints it without connecting — which is usually how you discover the approval is still outstanding in the first place. The control that keeps the server out of every session type regardless is the disabledMcpjsonServers setting; the startup-flag route is 9.7's subject, along with the rest of the flags that stop the discovery.

  4. You add a stdio server through npx and claude mcp list immediately reports ✘ Failed to connect. Name two causes with completely different fixes, and the single check that tells them apart.

    Show answer

    One: nothing is wrong yet. A stdio server whose first run is slow while npx downloads the package can exceed the default 30-second startup timeout and show ✘ Failed to connect. The fix is to wait and re-run claude mcp list, or raise the budget with MCP_TIMEOUT=60000 claude. Two: the command genuinely does not run — the Added confirmation means the entry was saved, not that the command works — so the package name, the args after --, or an --env value is wrong, and the fix is to correct the entry.

    The single check that tells them apart is running the configured command directly in your shell, which is the documented step for a stdio server: if it starts and sits there, you were watching a slow first download; if it errors out, you have the real fault. Two things to read before you get there, because they often answer it for you: from v2.1.219 claude mcp list appends the error code and the server's error text to the status line and claude mcp get shows the same on an Issue: line, and the claude mcp list warnings flag hidden leading or trailing whitespace in command, args or env — a token pasted with a trailing newline — which Claude Code names but does not trim.

Sources