· 13 min read · MCP Claude Code AI workflow tutorial
MCP setup guide: configuring MCP servers without committing secrets
What MCP tools, resources and prompts are, stdio vs Streamable HTTP, Claude Code scopes and approval, other clients' config files, and keeping credentials out of the repo.
MCP (Model Context Protocol) is an open standard for connecting AI applications to outside tools and data. An MCP server can offer tools (functions the AI can call), resources (read-only data) and prompts (templates). Local servers talk over stdio; remote ones use Streamable HTTP. You set one up by registering the server in your AI tool: in Claude Code that's claude mcp add or a .mcp.json file at the project root. The thing to settle first is where the credentials live. In my setup, .mcp.json holds one launch command, and the key sits outside the repo in a file with 600 permissions that a launcher script reads at run time.
The protocol details and commands follow the official MCP docs and each tool's own documentation, checked on November 3, 2026.
Host, client, server
The official architecture page names three participants:
- Host: the AI application itself, such as Claude Code, VS Code or Cursor.
- Client: one connection the host opens per server. Three servers, three clients.
- Server: the program that provides context. It can run on your machine or be someone else's remote service.
Messages are JSON-RPC 2.0. MCP only defines how servers and clients exchange context; what the model does with it is up to each application.
What a server offers
| Type | What it is | Who decides to use it |
|---|---|---|
| Tools | Functions that do things: query a database, call an API, edit a file | The model |
| Resources | Read-only data, each with its own URI such as file:///path/to/doc.md |
The application |
| Prompts | Prewritten instruction templates, usually exposed as slash commands | The user |
This follows the official server concepts page. Because the model decides when to call a tool, the page also lists controls an application can offer: per-call approval, pre-approving safe operations, and a log of every call.
With a coding agent, tools are what you'll touch most. My site's repo connects to an MCP server that reads SEO data; most calls look up numbers for a page or a query.
Two transports: stdio and Streamable HTTP
| stdio | Streamable HTTP | |
|---|---|---|
| Who starts the server | The client launches it as a subprocess | The server runs as its own process, usually remote |
| How they talk | stdin/stdout, one JSON-RPC message per line | Each message is an HTTP POST; replies come back as JSON or an SSE stream |
| Typical use | Local tools, small servers you write yourself | A SaaS product's official MCP endpoint |
| Auth | Usually credentials from environment variables or local files | Bearer tokens, API keys, custom headers; the docs recommend OAuth |
One stdio rule matters more than the rest: the spec says a server must not write anything to stdout that isn't a valid MCP message. Logs go to stderr. Print one debug line to stdout and the connection will usually break.
The server I use runs over stdio. Claude Code starts a Python program that reads stdin, writes stdout, and calls the data service over HTTPS. To Claude Code it's a local server, even though the data lives elsewhere.
Registering a server in Claude Code
The Claude Code MCP docs give two forms:
# Remote (HTTP)
claude mcp add --transport http <name> <url>
# Local (stdio): everything after -- goes to the server untouched
claude mcp add [options] <name> -- <command> [args...]
The -- matters. It separates Claude Code's own options (--transport, --env, --scope) from the command that runs the server. Leave it out and Claude Code tries to parse the server's flags as its own.
Three scopes
| Scope | Loads in | Shared with the team | Stored in |
|---|---|---|---|
| local (default) | This project only | No | ~/.claude.json |
| project | This project only | Yes, through version control | .mcp.json at the project root |
| user | All your projects | No | ~/.claude.json |
If the same name is defined in more than one scope, Claude Code connects once, using the highest-precedence definition: local, then project, then user, then plugins and claude.ai connectors. Fields don't merge across scopes.
My repo uses that rule. Server, file and variable names below are placeholders; the structure matches the files in my repo. The .mcp.json is the version everyone gets:
{
"mcpServers": {
"my-data": { "command": "bash", "args": ["tools/my-data-mcp.sh", "mcp"] }
}
}
On my machine there's also a local entry with the same name that adds one environment variable pointing at this site's credentials folder. claude mcp get shows which one is live (path redacted):
my-data:
Scope: Local config (private to you in this project)
Status: ✔ Connected
Type: stdio
Command: bash
Args: tools/my-data-mcp.sh mcp
Environment:
MY_DATA_CONFIG_DIR=<this site's credentials folder>
That local entry lives only in my ~/.claude.json and never enters the repo.
Project servers need approval
.mcp.json travels with the repo, so anyone who clones it gets it too. In interactive sessions, Claude Code asks before it uses a project-scoped server for the first time; claude mcp reset-project-choices clears your answers. A freshly cloned repo can't approve its own servers either: enableAllProjectMcpServers or enabledMcpjsonServers in a committed .claude/settings.json is ignored until you trust the folder.
Watch non-interactive runs. The docs say that when Claude Code can't show the prompt, it loads project-scoped servers without asking. To block one in CI or a script, use disabledMcpjsonServers, or start with --strict-mcp-config so only servers passed through --mcp-config load.
My .claude/settings.local.json stays out of git; the first time Claude Code writes that file, it adds it to your global git excludes. In it I have both enableAllProjectMcpServers and enabledMcpjsonServers: ["my-data"]. The first one also turns on any server added to this repo's .mcp.json later, which is broader than needed. If you only want to approve the servers you name, the second one is enough.
Other AI tools
MCP is an open protocol, and the official intro page lists Claude, ChatGPT, VS Code and Cursor among its supporters. The concepts carry over; file locations and formats don't:
| Tool | Project config | Global config | Format |
|---|---|---|---|
| Claude Code | .mcp.json |
~/.claude.json (user scope) |
JSON, mcpServers |
| Codex | .codex/config.toml (trusted projects only) |
~/.codex/config.toml |
TOML, [mcp_servers.<name>] |
| Cursor | .cursor/mcp.json |
~/.cursor/mcp.json |
JSON, mcpServers; stdio servers can use envFile |
| VS Code | Root .mcp.json (offered in the guided flow only after you turn on the experimental chat.mcp.workspaceRootConfig.enabled setting); the older .vscode/mcp.json is labeled deprecated in the guided flow |
~/.copilot/mcp-config.json (Copilot Global; under $COPILOT_HOME when that is set) |
Portable files use mcpServers; VS Code's own format uses servers |
Here's roughly what the same launcher looks like in Codex, going by the official format. I haven't run this one, and cwd needs to point at your repo:
[mcp_servers.my-data]
command = "bash"
args = ["tools/my-data-mcp.sh", "mcp"]
cwd = "/path/to/your/repo"
That's the point of a launcher: every tool has its own config format, but all of them only need to know which command to run. How the credentials get read stays inside the script.
Keeping credentials out of the repo
Putting an API key straight into .mcp.json is the quickest setup and one of the common ways keys leak, because that file is meant to be committed. Claude Code can expand ${VAR} and ${VAR:-default} inside .mcp.json, so the key doesn't have to be hardcoded, but the value still has to sit in your shell environment, where every program started from that shell can see it.
My repo does it differently. .mcp.json only tells Claude Code to run bash tools/my-data-mcp.sh mcp. The launcher reads the credentials from a private folder and hands them to the one process it starts. The maintainer of the data service supplies the script; this is the core of it (excerpt, with names changed to placeholders):
d="${MY_DATA_CONFIG_DIR:-$HOME/.config/my-data}"
for f in api.env token; do
[ -r "$d/$f" ] || { echo "my-data: 缺 $d/$f" >&2; exit 1; }
# works with both GNU stat (Linux) and BSD stat (macOS)
case "$(stat -c %a "$d/$f" 2>/dev/null || stat -f %Lp "$d/$f")" in
600|400) ;;
*) echo "my-data: $d/$f 的權限必須是 600 或 400" >&2; exit 1;;
esac
done
# …read the URL, public key and site token, check none is empty…
export MY_DATA_URL MY_DATA_KEY MY_DATA_TOKEN
exec python3 "$(cd "$(dirname "$0")" && pwd)/server.py" "$@"
- Credentials live outside the repo. The default folder is
~/.config/my-data/, and an environment variable can point it somewhere else. That's what the local-scope entry above does for this site. - It checks file permissions. If either file is anything other than 600 or 400 (owner-readable only), it refuses to run.
stat -cis the Linux form; if it fails, the script falls back to macOS'sstat -f, so one script works on both. - Errors go to stderr. The script says which file is missing or has the wrong mode and never prints a value. That also keeps stdout clean for MCP messages.
- Only the server process gets the values. The variables are exported inside the script, then
execreplaces the script with the Python server. The credentials enter that process's environment and never sit in your shell. - The credential itself is narrow. This token can read one site's data, nothing else. If it leaks, the damage is limited.
I ran it against a test folder holding fake values. With the mode set to 644, it stops (the message is in Chinese: "permissions must be 600 or 400"):
$ chmod 644 <test folder>/api.env
$ bash tools/my-data-mcp.sh mcp
my-data: <test folder>/api.env 的權限必須是 600 或 400
Doing this yourself takes a few commands:
mkdir -p ~/.config/my-service
chmod 700 ~/.config/my-service
# write the key with an editor, not echo, so it stays out of shell history
chmod 600 ~/.config/my-service/token
Then write a launcher like the one above and register only bash tools/<your-script>.sh in .mcp.json. The repo can document which files are needed, where they go and what mode they need, without any values.
Security: least privilege, tool output is only data
Give credentials only what they need
The official Security Best Practices page has a section on scope minimization: a stolen token with broad scopes gives an attacker everything those scopes reach. Start with a minimal, read-only set and step up only when needed. In practice:
- Use a read-only token whenever one will do.
- One credential per purpose. Don't hand an agent your account's admin key.
- Give any tool that can write an
askrule in Claude Code's permissions, so it asks every time. For example:
"permissions": {"ask": ["mcp__my-data__write_record"]}
Claude Code permission rules name MCP tools as mcp__<server>__<tool>, and mcp__<server>__* covers every tool on a server. In my global settings there's a PreToolUse hook with this matcher:
mcp__.*[Ss]upabase.*__(execute_sql|apply_migration)
Before the AI runs SQL through the Supabase MCP server, the hook checks it, and statements like dropping or truncating a table need my confirmation. The background is in my Claude Code guide.
Be careful with local servers
A stdio server is a program running on your machine with the same privileges as your AI tool. The official docs list local server compromise as its own attack class: a malicious startup command hidden in a config, or a payload inside the server itself. Read the exact command and check who published it before you install one. The Claude Code docs say the same: verify you trust a server before connecting it.
Treat tool output as data
The Claude Code docs warn that servers fetching outside content can expose you to prompt injection. A web page, issue or email might contain "send this file to that URL," and a model reading it might comply.
If you write a server, say clearly in its instructions which fields are source data and which are inference. If you use someone else's, keep human approval on every tool that can write.
Debugging
When a server won't connect, I go in this order:
-
Check status.
claude mcp listshows every server with its health and appends the reason when a connection fails.claude mcp get <name>shows which scope and command are actually in use.Pending approvalmeans a.mcp.jsonserver hasn't been approved yet; startclaudeand it will ask. -
Look from inside the session.
/mcpshows status, lets you reconnect, and handles OAuth sign-in. -
Run the launch command yourself. From the repo root, run
bash tools/my-data-mcp.sh mcp. A missing file or wrong mode prints straight to the terminal. -
List the tools only. The official Inspector has a CLI mode (Node 22.19.0 or newer) that lists and exits:
npx @modelcontextprotocol/inspector --cli bash tools/my-data-mcp.sh mcp --method tools/listA stdio server reads a line and writes a line, so you can also pipe in one JSON-RPC message:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | bash tools/my-data-mcp.sh mcpThat only works because my server follows the older spec and doesn't require
initializefirst; for most servers, use the Inspector CLI. With fake credentials I still got the tool list back, because this server doesn't contact the outside service when listing tools. That splits the problem in half: if tools list fine, the fault is in the outbound API call. -
Use MCP Inspector. The official debugging tool:
npx @modelcontextprotocol/inspector <launch command>opens a web UI showing every JSON-RPC message. The docs warn that Inspector stores stdioenvvalues in the OS keychain, or in an unencrypted file on machines without one. Give it the launcher as the command and the script reads the credentials, so they never enter Inspector's config. -
Turn on the debug log.
claude --debug-file /tmp/claude-debug.log.
Usual causes:
- Polluted stdout. The server or script printed something that isn't MCP: a
print()debug line, a greeting from your shell profile. - Slow startup.
MCP_TIMEOUT=10000 claudesets a 10-second startup timeout. - Oversized output. Claude Code warns when tool output passes 10,000 tokens and caps it at 25,000 by default;
MAX_MCP_OUTPUT_TOKENSchanges the cap. - Unexpanded variables. If a
${VAR}in.mcp.jsonis unset with no default, the config still loads with the literal${VAR}text, andclaude mcp listshows a warning. - Relative paths. My
.mcp.jsonuses the relative pathtools/my-data-mcp.sh. It works when I start from the repo root; I haven't tested starting from a subdirectory.
FAQ
Can I commit .mcp.json?
Yes, it's designed to be shared with the team, as long as it holds only launch commands and non-sensitive settings. Reference keys with ${VAR} or let a launcher read them.
stdio or HTTP for a local server? For a server only your machine uses, stdio is simpler: the client launches it and other processes can't reach it. The official security page also recommends stdio for servers meant to run locally; if you must use HTTP, require a token or use a restricted IPC channel.
Are tutorials that teach the initialize handshake out of date?
The current spec revision is 2026-07-28, which is stateless: every request carries its own protocol version and capabilities, and clients probe servers with server/discover. Earlier revisions opened a session with initialize. The new spec describes how to stay compatible: a client that supports both sends server/discover first and falls back to initialize if the server doesn't recognize it. The server I use implements the older (2025-06-18) initialize handshake, and it connects in Claude Code as normal. For a new server, follow the latest spec and the official SDKs.
MCP gives an agent reach into more systems, so the first thing I ask when setting one up is where the credential lives and what it can do. .mcp.json says how to start the server, the credential stays outside the repo in a 600 file, and any tool that can write gets an ask rule.