A file called .mcp.json at the root of your project, one top-level key called mcpServers, and one object under it per server. That is the entire configuration surface. Everything else in this module is either a field inside one of those objects or a consequence of what the server hands back once it connects.
A server here is usually a small program running on your own computer, started by Claude Code when the session opens, rather than a machine in a data centre somewhere. It offers Claude a set of tools. The rules those programs follow when they talk to Claude Code are the Model Context Protocol, MCP, and a protocol is an agreed format two programs use to understand each other. One format, so one server works under any tool that speaks it.
A server object is one of two shapes. A local process, which Claude Code starts itself and talks to by writing to the program’s input and reading its output, needs command and usually args and env. A remote endpoint, which Claude Code reaches across the network over HTTP, needs url and usually headers. The type field says which. Get type wrong and the other fields are read against the wrong set of expected keys, which is the first thing to check when a server refuses to connect.
The file, and the three other places its tools show up
.claude/skills/triage/SKILL.md A skill pre-approving the same tool for its invoking turn.
---
description: Pull open issues and group them by area.
allowed-tools: mcp__github__list_issues
---
List the open issues, then group them by the area of the
codebase they touch. .claude/agents/triager.md A subagent whose tool pool is that one MCP tool and nothing else.
---
name: triager
description: Reads GitHub issues. Writes nothing.
tools: mcp__github__list_issues
---
You read issues and report. You do not open, close, or
comment on anything. .claude/settings.json A permission rule naming one tool, and one naming a whole server.
{
"permissions": {
"allow": ["mcp__github__list_issues"],
"deny": ["mcp__postgres"]
}
} Read more
mcp__github on its own means every tool on that server, and mcp__github__* means the same thing. A rule with parentheses, such as mcp__github__list_issues(state:open), is skipped from a settings file and listed in the invalid-settings dialog and in claude doctor; parameter rules for MCP tools go through --disallowedTools instead.
.mcp.json ships to your plugin Project scope. This is the one you commit, and the one your teammates get on clone.
{
"mcpServers": {
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer ${GITHUB_TOKEN}"
}
},
"postgres": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres"],
"env": {
"DATABASE_URL": "${DATABASE_URL:-postgres://localhost/dev}"
}
}
}
} Read more
Claude Code sets CLAUDE_PROJECT_DIR in the environment of any stdio server it spawns, so a server that needs to know where the repo is can read it rather than being told. Note the two spellings of expansion: ${GITHUB_TOKEN} fails loudly with a startup warning if the variable is unset, while ${DATABASE_URL:-postgres://localhost/dev} falls back to the default and says nothing.
The four server types
The route the messages travel is called the transport, and type is where you name it. stdio is short for standard input and output, the two streams every program already has for reading text in and printing text out. The other three send the messages over a network instead.
type | Talks to | Configured with |
|---|---|---|
stdio | a local process Claude Code spawns | command, args, env |
http | a remote endpoint | url, headers, headersHelper |
sse | a remote endpoint, older transport | url, headers |
ws | a remote endpoint over a persistent socket | url, headers, JSON config only |
stdio is the default when you add a server from the command line without saying otherwise. sse is deprecated, meaning it still works but is on its way out, and you will still meet it, because servers that shipped against it have not all moved. streamable-http is accepted as another name for http, which matters more than it sounds: it is the name the MCP specification uses, so a config block copied out of a server’s own documentation works unmodified.
ws has one wrinkle worth knowing before it costs you an afternoon. It has no --transport ws path in the documented add table, so you write it in JSON by hand, and it does not appear in claude mcp list at all. Check it with claude mcp get <name> or the /mcp panel.
Expansion
Write ${VAR} in this file and Claude Code swaps in the value your shell holds for that name before the server starts. Swapping a name for its value like that is expansion. ${VAR} and ${VAR:-default} both work, in command, args, env, url, and headers. That is what makes a committed .mcp.json safe to share: the file names the variable, and each developer’s shell supplies the value. A ${VAR} with no value and no :-default produces a startup warning rather than a silent empty string, so a missing token announces itself.
Three scopes, and the one you commit
A server’s scope is how far it reaches: which projects it loads in, and whether anyone else on your team gets it.
| Scope | Stored in | Shared | Loads in |
|---|---|---|---|
local (default) | ~/.claude.json, under projects["<path>"].mcpServers | no | this project only |
project | .mcp.json at the project root | yes, through version control | this project only |
user | ~/.claude.json, top-level mcpServers | no | every project |
Project is the scope this course cares about, because it is the only one that travels with the repo. Local is the default, which means a server you add without thinking about scope lands somewhere your teammates will never see.
Scope is fixed when you add a server. Changing it is a remove and a re-add:
# stdio, project scope. Everything after -- is the command.
claude mcp add --scope project postgres -- npx -y @modelcontextprotocol/server-postgres
# remote, with a static token instead of OAuth
claude mcp add --transport http --scope project github https://api.githubcopilot.com/mcp/ \
--header "Authorization: Bearer $GITHUB_TOKEN"
# moving a server from local to project scope
claude mcp remove github --scope local
When the same server name appears in more than one place, the order is local, then project, then user, then plugin-provided, then claude.ai connectors. First one found wins.
The naming rule
A server called github exposing a tool called list_issues appears at runtime as:
mcp__github__list_issues
Two underscores between each part. That string, in full, is what you write in every place Claude Code asks you to name a tool:
| Where | Field |
|---|---|
| Settings | permissions.allow / ask / deny |
| A skill | allowed-tools in frontmatter |
| A subagent | tools in frontmatter |
| A hook | the matcher |
A server shipped inside a plugin gets a longer form, because the plugin name is folded in to keep two plugins from colliding:
mcp__plugin_<plugin-name>_<server-name>__<tool-name>
Any character outside A-Za-z0-9_- becomes an underscore. And the server itself registers under a third spelling, plugin:<plugin>:<server>, which is what you write where a server rather than a tool is expected, such as the server field of an mcp_tool hook. Three spellings of one thing is a lot to hold, so the practical move is to open /mcp, read the tool names off the panel, and copy them.
Resources and prompts
Tools are the loudest part of MCP and not the only part.
A server can expose resources, which you pull into a prompt with an @ reference shaped @server:protocol://path, where the middle word names the kind of thing you are asking for. @github:issue://123, @postgres:schema://users, @docs:file://api/authentication. Type @ and the list is fuzzy-searchable. Referenced resources are fetched and attached for you, and you can put several in one prompt.
A server can also expose prompts, which surface as slash commands named /mcp__<server>__<prompt>. A server called code-helper with a prompt called explain gives you /mcp__code-helper__explain. Names normalise to underscores, same as tools.
Limits worth knowing before you hit them
Everything a server returns has to fit in the same context the rest of your conversation lives in. Models count that text in tokens, the small chunks they read text as, each one shorter than a word. MAX_MCP_OUTPUT_TOKENS defaults to 25,000, and a warning fires once a single result crosses 10,000 tokens. Server startup times out at 30,000 ms by default, adjustable with MCP_TIMEOUT.
Tool search is on by default, so a session starts by loading tool names and server instructions only, and fetches the full description of each tool’s arguments when a tool is actually reached for. Tool descriptions and server instructions are each truncated at 2 KB. A server that must be loaded upfront regardless sets alwaysLoad: true.
You add the github server, decide you want a hook to run whenever Claude touches it, and write the matcher as github. Or you go to your settings and add "deny": ["postgres"] to keep Claude out of the database.
Neither fires. Nothing errors, either, which is the bad part: the hook stays quiet and the deny rule sits in the file looking like protection while every call sails through.
The reason is that the bare server name is not a tool name. Matchers and permission rules are evaluated against the runtime tool name, and the runtime tool name is mcp__postgres__query. The docs put it flatly: a matcher written against the bare server key never fires. Write mcp__postgres to mean the whole server, mcp__postgres__query to mean one tool, or mcp__* to mean every MCP tool at once in a deny or ask rule, where the * stands in for any text that follows.
Two neighbouring versions of the same mistake, both named traps in the docs: putting .mcp.json inside .claude/ rather than at the project root, and using VS Code’s servers key instead of mcpServers. Both produce a file that parses and loads nothing.
Add one server to your project at project scope. A filesystem or a Postgres server is enough; pick something whose output you can verify by eye. Run claude mcp list and confirm ✔ Connected, then open /mcp and read the tool list.
Now use the names you just read. Invoke one tool, and check the transcript for the exact string Claude called. Copy that string into a permissions.allow rule in .claude/settings.json, run the tool again, and confirm the prompt is gone. Then break it deliberately: change the rule to the bare server name, restart, and watch the prompt come back.
Commit .mcp.json. It ships to your plugin as .mcp.json at the plugin root, and it is the only artifact in this course that a teammate on a different tool can use without editing.
This is the strongest transfer story in the series, and the reason to write your team’s internal tooling as MCP servers rather than as anything harness-specific.
The protocol is the same everywhere and so is the server program itself. Nine of the surveyed harnesses support MCP; the one that does not is Aider. What changes between them is the wrapper: which file holds the config, and what the top-level key is called.
Copilot CLI reads your .mcp.json verbatim. Cursor takes the same object at .cursor/mcp.json, unchanged. After that you are renaming a key: Zed calls the map context_servers, OpenCode calls it mcp, Amp calls it amp.mcpServers, and Copilot in VS Code calls it servers in .vscode/mcp.json. Five names for one map is the whole difficulty.
A few edges. Codex CLI wants TOML rather than JSON, under [mcp_servers.NAME], with the same field names. Gemini CLI keys the transport off which field you use rather than a type field: command means stdio, url means SSE, httpUrl means streamable HTTP. Cline defaults an omitted type to legacy SSE rather than inferring stdio, so set it explicitly. Cline and Windsurf are user-global only, so the checked-into-the-repo sharing pattern you just built does not survive the port to either.
Write your tools as MCP servers and you write them once.
Check yourself
- Your
.mcp.jsonsays"type": "streamable-http". Does the server load? - You add a server with
claude mcp addand no--scope. Your teammate clones the repo. Do they get it? - A hook matcher reads
postgres. The server is calledpostgresand exposesquery. How many times does the hook fire?