Module 10 · 35 min

MCP

Add an MCP server to a project, invoke one of its tools, and name that tool at runtime without guessing.

Surface
.mcp.json
Ships to your plugin
.mcp.json

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

your-project/
├─ .claude/
│ ├─ skills/
│ │ └─ triage/
│ ├─ agents/
.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.

One string, written four times. Click .mcp.json first, then follow mcp__github__list_issues through the other files.

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.

typeTalks toConfigured with
stdioa local process Claude Code spawnscommand, args, env
httpa remote endpointurl, headers, headersHelper
ssea remote endpoint, older transporturl, headers
wsa remote endpoint over a persistent socketurl, 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.

ScopeStored inSharedLoads in
local (default)~/.claude.json, under projects["<path>"].mcpServersnothis project only
project.mcp.json at the project rootyes, through version controlthis project only
user~/.claude.json, top-level mcpServersnoevery 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:

WhereField
Settingspermissions.allow / ask / deny
A skillallowed-tools in frontmatter
A subagenttools in frontmatter
A hookthe 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.

The mistake most people make first

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.

Build

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.

Does this travel?

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

  1. Your .mcp.json says "type": "streamable-http". Does the server load?
  2. You add a server with claude mcp add and no --scope. Your teammate clones the repo. Do they get it?
  3. A hook matcher reads postgres. The server is called postgres and exposes query. How many times does the hook fire?