MCP — transports, scopes and OAuth
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
| Scope | Add it with | Loads in | Shared with team | Stored in |
|---|---|---|---|---|
| Local | nothing, or --scope local | Current project only | No | ~/.claude.json |
| Project | --scope project | Current project only | Yes, via version control | .mcp.json in project root |
| User | --scope user | All your projects | No | ~/.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 addcommand writes the server to one of three scopes, stored across two files, depending on the--scopeflag. — 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.jsonunder 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
| Transport | Add it with | Notes |
|---|---|---|
http | claude mcp add --transport http <name> <url> | Recommended for remote servers; supports OAuth and the --transport flag |
sse | claude mcp add --transport sse <name> <url> | Deprecated |
stdio | claude mcp add <name> -- <command> [args] | Local process; the default when no --transport is given |
ws | claude 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
# 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-serverThe -- 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
urlbut notypeis a configuration error, because Claude Code reads an entry with notypeas 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.
{
"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.jsonfiles. To reset those approval choices, runclaude 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.
| Status | What it means |
|---|---|
✔ Connected | Ready to use |
! Connected · tools fetch failed | Connected, could not list tools — claude mcp get has the detail |
! Needs authentication | Reachable, needs a browser sign-in or a token header |
✘ Failed to connect | The server did not respond, or rejected the token you configured |
✘ Connection error | The connection attempt threw |
⏸ Pending approval | A project-scoped server you have not approved |
⊘ Disabled for this project | Turned 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_TOKENSenvironment 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
-
A teammate commits
.mcp.jsonwith agithubserver. You added agithubserver at local scope months ago, pointing at a different URL. Which one connects, what doesclaude mcp listtell you, and why does the sign-in you completed last week not apply?Show answerAnswer
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 listtells 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.jsonmeans removing your local entry and re-adding it at another scope if you still want it; while the name exists at several,claude mcp removereportsexists in multiple scopesand wants--scope. -
Your
.mcp.jsonhas two entries: one with aurland notype, one with"Authorization": "Bearer ${API_KEY}"whereAPI_KEYis unset in your shell. Say what happens to each entry, and which of the two you would find faster.Show answerAnswer
The first entry is skipped; the second connects and fails. An entry with a
urland notypeis a configuration error, because Claude Code reads a typeless entry as a stdio server — it reportsMCP 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-httpis accepted as an alias forhttp). The unset variable does not fail the config at all: you get a missing-variable warning for that server inclaude mcp listand the unexpanded${VAR}text is used as-is, so the server presents the literal stringBearer ${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
Authorizationheader 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 inclaude mcp listis 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 reportedcommand: expected string, received undefined, naming a field the entry does not have. -
CI runs
claude -pin a cloned repository.claude mcp liston that machine shows the repo's server as⏸ Pending approval. What does the-prun actually do with that server, and which setting — as opposed to a startup flag — keeps it out in every permission mode?Show answerAnswer
It connects the server, without asking. The approval prompt for project-scoped
.mcp.jsonservers exists only in interactive sessions: inclaude -pruns, 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 whensettingSourcesincludes project settings.)The
⏸ Pending approvalyou 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 thedisabledMcpjsonServerssetting; the startup-flag route is 9.7's subject, along with the rest of the flags that stop the discovery. -
You add a stdio server through
npxandclaude mcp listimmediately reports✘ Failed to connect. Name two causes with completely different fixes, and the single check that tells them apart.Show answerAnswer
One: nothing is wrong yet. A stdio server whose first run is slow while
npxdownloads the package can exceed the default 30-second startup timeout and show✘ Failed to connect. The fix is to wait and re-runclaude mcp list, or raise the budget withMCP_TIMEOUT=60000 claude. Two: the command genuinely does not run — theAddedconfirmation means the entry was saved, not that the command works — so the package name, theargsafter--, or an--envvalue 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 listappends the error code and the server's error text to the status line andclaude mcp getshows the same on anIssue:line, and theclaude mcp listwarnings flag hidden leading or trailing whitespace incommand,argsorenv— a token pasted with a trailing newline — which Claude Code names but does not trim.