Book a call
LESSON14mVERIFIED 2026-08-02 · CLAUDE CODE 2.1.220 · CODEX CLI 0.146.0 · GITHUB COPILOT CLI 1.0.77 · CURSOR 2026.07.23-e383d2b · KIMI CODE CLI 0.31.1

MCP without the hype: what the protocol actually gives you

The protocol adds no capability your harness didn't have — it adds one integration that every client can call. The whole thing is list, then call, and you can read it in an afternoon.

"A standard for something I could already do"

The thing is that we already have a way to call shell commands and read files with our harness, in all the 6 clients, so a protocol which purpose is allowing models to call functions sounds like a boilerplate.

What Reddit phrased is that using the tool definitions which are not being used pollutes the context window and make the agent prone to hallucinate and choose a wrong tool, and they say that it becomes a mess once there's 30–40 tools. I think we shouldn't be treating it as a bug report, it's just what a list of tool definitions does to a context window. Where the solution lies I'll show you later, but for now — it's not about a protocol in general.

None of that is an argument against having a protocol though, because what MCP offers is not a superpower but a single integration that any client which speaks this protocol can call. Is it reasonable to ask if TCP is a good idea? A protocol is an agreement on who says what to whom, it's not a product, its value lies in not implementing it 6 times.

The more sensible question is what this document prescribes vs what we leave for the harness — I'll answer it quickly:

What's actually in the spec

  • The spec (current version is 2026-07-28) — wire format: JSON-RPC 2.0, that's it.

  • Transports:

    • Stdio: line-separated messages over standard streams of a client-launched subprocess

    • Streamable HTTP: a single POST request per message to a single endpoint with reply being a JSON object or an SSE stream for a single request

    • The older HTTP+SSE transport is deprecated as of March 2025

  • Server-side offerings:

    • Tools (functions the model runs)

    • Resources (context and data for the user or the model)

    • Prompts (templated messages and workflows for the users)

  • Client-side offering:

    • Elicitation (server asking the user about sth during a call)

  • Deprecations: Sampling and roots are still in the document but both are deprecated as of this version

  • The spec is stateless — every request carries its own protocol version and client's capabilities in _meta, servers can't make any assumptions based on previous requests on the same connection and the spec states that an open stdio process isn't a conversation or session

  • There's an auth framework for HTTP only, stdio servers are advised not to use it and read credentials from env

This is what the spec looks like: the table of contents lists 7 sections:

  • Base protocol

  • Versioning

  • Message patterns

  • Authorization

  • Server features

  • Client features

  • Utilities

If you were wondering where to find anything connected with agents, memory, orchestration or budget — here you can see that this document doesn't contain any of these concepts, and if you expected to find sth under MCP and didn't — it's because it's in the harness. The document is very clear on saying that it can't enforce security principles at protocol level and it tells implementors to build consent flows on their own.

List, then call

Let's say you have a stdio server, ~80 lines of Python, and define one tool named word_count. Now let's run it — this is a server, no harness involved, and no model connected.

Discovery step: client asks for the tool list, providing _meta with protocol version and capabilities, as there's no handshake to carry them any more.

JSON
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}

The server responds with a tool definition — name, title, description and inputSchema.

JSON
{"jsonrpc": "2.0", "id": 1, "result": {"resultType": "complete", "tools": [{"name": "word_count", "title": "Word count", "description": "Count the words in a piece of text.", "inputSchema": {"type": "object", "properties": {"text": {"type": "string", "description": "The text to count."}}, "required": ["text"], "additionalProperties": false}}]}}

Invocation step: client names a tool and provides arguments, and gets back the text content from it.

JSON
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"word_count","arguments":{"text":"the demo was the easy part"}}}
JSON
{"jsonrpc": "2.0", "id": 2, "result": {"resultType": "complete", "content": [{"type": "text", "text": "6"}], "isError": false}}

Resources and prompts are following this 2-step pattern too under different names — resources/list then resources/read, prompts/list then prompts/get. So basically if you can write an HTTP handler you can write an MCP server too, the afternoon you spend on your first one is about the SDK rather than the idea.

What the model actually sees

This is what the model sees: tool definitions have fields for name, title (optional), description and inputSchema, plus outputSchema (optional), annotations and icons. The spec calls tools model-controlled so these are the definitions that go into model's context and the entire thing the model works with. The transport, authentication and the process on the other end aren't part of the Tool type so none of them get to the model.

But there are 2 consequences that determine how good the server is:

  • Descriptions are prompt text — the sentence you write for a human becomes the only thing between the model and using a wrong tool. It's not the docs, it's the instruction; I'll show you a lesson about it later in this chapter.

  • Cost of context is a complaint about the list, and the list is genuine — tools/list returns everything the server has upfront, no matter if the session utilises any of it. Also the spec says that it should be ordered deterministically so clients can cache the list for better prompt-cache hits — which means the definitions were always supposed to go into the window.

If you have 5 servers with 15 tools each and a client that fetches them all, the model spends real budget before the user types in anything.

In majority of the 6 clients it's possible to list only a part of the tools. For example in Codex there are enabled_tools and disabled_tools and for Copilot CLI there's an array per server in the tools field, and for Gemini CLI there's --include-tools and --exclude-tools.

Claude Code 2.1.220 has tool search on by default — it fetches only the tool names and the server's instructions at the session start, and the schemas once a task requires them. So if you haven't measured it in a year — measure on your client before you say that MCP's list is bad, as the protocol provides a static list and what happens with it is a harness decision.

What it deliberately doesn't give you

This is what the spec doesn't contain:

  • No permission model — the document says that implementors should build consent and authorization flows into their applications and states outright that MCP can't enforce these at protocol level. Every actual gate is in the harness, for example we had a lesson about 3 gates earlier in this course.

  • No trust — clients are supposed to treat tool annotations as untrusted unless they come from a trusted server, same applies for descriptions (as the spec says). The prompt-injection surface is an untrusted 3rd-party text telling a model what to do, we had a lesson about hostile servers in the security course.

  • No session — the protocol is stateless as of this revision. If a server needs a shopping cart, a browser context or a database transaction open it must return an explicit handle and receive it as an argument during the next call, meaning that the model is the thing that carries state forward.

  • No uniform feature set — there are 3 server features but the harnesses consume different number of them. For example Claude Code's integration with the server asks for tools, prompts and resources and refreshes all three on list_changed, while Codex is about tools-centric integration; if you see "MCP-compatible" in a readme it usually means that it will call your tools.

The spec vs the binary you have installed

The stateless revision is dated 2026-07-28, so chances are your client is behind it, and that's more important than it might seem given the release notes.

2nd of August 2026, capturing what Claude Code 2.1.220 sent to this stdio server:

JSON
{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{"listChanged":true},"elicitation":{}},"clientInfo":{"name":"claude-code","title":"Claude Code","version":"2.1.220","description":"Anthropic's agentic coding tool","websiteUrl":"https://claude.com/claude-code"}},"jsonrpc":"2.0","id":0}

The first message is an initialize carrying protocol version 2025-11-25 — the previous era with an initialize handshake, connection-scoped session and roots as a client's capability (which is now deprecated). And then comes notifications/initialized and tools/list but that's all. This is a documented fall-back so it works, it's just that "MCP is stateless now" is a claim about the document and whether this holds for your binary is a separate question you can easily answer by capturing what your client sends and examining it.

IN YOUR HARNESS

In Claude Code

The claude mcp add command creates the configuration automatically, and you decide where it lands with the --scope flag:

ScopeLoads inSharedFile
local (default)this project onlyno~/.claude.json, under the project's path
projectthis project onlyyes, via git.mcp.json in the repo root
userall your projectsno~/.claude.json
BASH
claude mcp add sentry --transport http --scope user https://mcp.sentry.dev/mcp
claude mcp add wc --scope project -- python3 ./tools/word_count.py
  • Everything after the -- is forwarded to the server unchanged

  • You can list all your servers using claude mcp list. This command connects with every server and runs a health-check on it, which is the cheapest way of making sure if the server is up (doesn't require the LLM call)

  • The tools are accessible via mcp__<server>__<tool> identifier. It's what you need to include in permission rules or hook matchers

By default, a server with project-level scope isn't started until you accept it interactively. For example:

  • claude mcp list will display the server as pending approval

TEXT
wc: python3 ./tools/word_count.py - ⏸ Pending approval (run `claude` to approve)
  • It won't run; this is by design, as .mcp.json file can be committed to the repository by another member of your team, therefore whenever you clone a repository, it doesn't have any tools of your teammates until somebody approves them (as described in the docs). At least the pending state shows up in the list output, so it's not a silent one

  • You can run claude mcp reset-project-choices to get rid of previous choices and do them again.

Two more things:

  • The output limit is 25k tokens by default (you can increase it setting MAX_MCP_OUTPUT_TOKENS env var). Above the 10k threshold it warns you

  • Tool search feature is enabled by default, which means that MCP tool definitions are loaded lazily — on session start, the client just knows the names of the tools and the server instructions, and fetches schemas for them only when needed. If you disable it (ENABLE_TOOL_SEARCH=false), the definitions are preloaded. Alternatively, you can set it to auto, which means that the definitions are preloaded if the tools can fit into 10% of the context window. Finally, you can set alwaysLoad: true for a server in the entry, to exclude it from lazy loading

In Codex CLI

codex mcp add modifies ~/.codex/config.toml, creating a TOML table per server. For example, for a local server that is set up from a python script and env var — it is stored there as command/args/env (the env is a nested table):

BASH
codex mcp add wc --env WC_LOG=/tmp/wc.log -- python3 ./tools/word_count.py
TOML
[mcp_servers.wc]
command = "python3"
args = ["./tools/word_count.py"]

[mcp_servers.wc.env]
WC_LOG = "/tmp/wc.log"

There's another way for remote servers too with url in place of command, and an auth field which can be either "oauth" or with a bearer_token_env_var; sometimes you need to add fixed http headers so there's a place for it too.

Any server can have the following options:

  • startup_timeout_sec (defaults to 10s)

  • tool_timeout_sec (defaults to 60s)

  • enabled_tools/disabled_tools, to only expose a subset of tools from a server

Except for the last one, which is about handling context bloat, the rest are general settings that can be set per server.

The gotcha.

The codex mcp list command shows what is configured; it doesn't connect to anything. After running codex mcp add, you can run codex mcp list and it will show a row like this: name / command / args / env / cwd / status / auth.

TEXT
Name  Command  Args                  Env            Cwd  Status   Auth
wc    python3  ./tools/word_count.py WC_LOG=*****   -    enabled  Unsupported

And the status is enabled while there's no process started — you can check that because the server's log file hasn't appeared. So this is more about saying it's not disabled in the configuration, not that the server works. In contrast, if you run claude mcp list then it checks the servers health, but these are two different commands so just because they look similar doesn't mean that a table is a good indicator of sth working. The env values are masked too so you can safely share the output in a bug report.

In GitHub Copilot CLI

The copilot mcp command is all about the MCP configuration that the GitHub Copilot CLI offers, it integrates with a variety of sources.

3 levels of configuration, and it says so itself:

TEXT
User       ~/.copilot/mcp-config.json
Workspace  .mcp.json or .github/mcp.json
Plugin     Installed plugins with MCP servers

For example: adding a server for stdio and another one for remote (HTTP transport):

BASH
copilot mcp add wc -- python3 ./tools/word_count.py
copilot mcp add --transport http notion https://mcp.notion.com/mcp

At the user level, you need to have this in the ~/.copilot/mcp-config.json file: the "tools" field is a native feature of this configuration format, it's not an additional thing that's been bolted on. The copilot mcp command has this default for it: [""]. So you can run mcp add and pass the --tools flag with either "" (for all tools), a comma-separated list of names, or an empty string if you want to have zero tools. There's also a configuration entry for a server that illustrates this:

JSON
{
  "mcpServers": {
    "wc": {
      "tools": ["*"],
      "type": "local",
      "command": "python3",
      "args": ["./tools/word_count.py"]
    }
  }
}

Permissions use the same names: --allow-tool='MyMCP' for a whole server, --deny-tool='MyMCP(risky_tool)' for one of its tools.

At the workspace level, the .mcp.json file is exactly what Claude Code writes at the project level. If you were to run the GitHub Copilot CLI in a repo that's configured with Claude Code, copilot mcp list --json would return these servers under "source": "workspace" with the file path, including the "type": "stdio" ones which are treated on par with Copilot's "local".

The only downside is that if you have a server with the same name in your user config and in the workspace .mcp.json file, only one of them shows up. In order to avoid confusion, make sure to use different names for servers so that when you have problems you're not wondering why it's not working for one when in fact it's another one.

In Cursor

The configuration is JSON file with the standard structure like for other tools. It's stored either in .cursor/mcp.json in the root of your repo, or in ~/.cursor/mcp.json if you want to set it up globally. For instance:

JSON
{
  "mcpServers": {
    "wc": {
      "command": "python3",
      "args": ["./tools/word_count.py"],
      "env": { "WC_LOG": "/tmp/wc.log" }
    }
  }
}

In case of remote servers, you can use url instead of command, and add headers if you need authentication for example:

JSON
{
  "mcpServers": {
    "server-name": {
      "url": "http://localhost:3000/mcp",
      "headers": { "API_KEY": "value" }
    }
  }
}

The tool will ask for your permission to run it by default. Just like for shell commands, it inherits the same run modes (auto-run etc), so you can use the same auto-run option that you were using for these commands if you wish — personally, I prefer it as it's easy to forget about enabling mcp in the config if you add a separate field for it.

The gotcha. The only catch is that cursor-agent doesn't have any mcp subcommand, so there's no list or get, and you can't use it to connect with any server to report its status. That means it's not possible to check if you can connect with the server without starting a session, unlike every other tool in this switcher.

So if you want to make sure you can connect with a new server, you need to start a session. There is a --approve-mcps option that can be useful in such scenarios, but it's not really suitable as it approves everything and it's not good to approve random servers.

In Gemini CLI

It's worth to mention that gemini mcp add comes with a --scope flag which is set to project by default — the neighbouring CLIs don't all put a bare add in the same place, so it's easy to assume wrong here. So you can define the user-level server like:

BASH
gemini mcp add wc python3 ./tools/word_count.py -e WC_LOG=/tmp/wc.log
gemini mcp add --scope user --transport http notion https://mcp.notion.com/mcp

The storage locations are different, for every scope:

  • project — in mcpServers block of .gemini/settings.json file placed in the current directory

  • user — in mcpServers block of ~/.gemini/settings.json file

This is what the record might look like:

JSON
{
  "mcpServers": {
    "wc": {
      "command": "python3",
      "args": ["./tools/word_count.py"],
      "env": { "WC_LOG": "/tmp/wc.log" }
    }
  }
}

There are also a few more flags you can use to alter behaviour of the server:

  • --include-tools / --exclude-tools — to control what tools the server adds to the context

  • --trust — to skip the confirmation prompt (for this particular server)

  • gemini mcp enable / disable — to turn off and on the server without removing it

The gotcha. If you have a project that is not trusted by the Gemini CLI (eg in case of a fresh checkout), all the servers will be turned off, including user-scope ones; no matter how long time ago you created them. You can see an output like this:

TEXT
Warning: MCP servers are configured but disabled because this folder is untrusted.
User-level servers are also suppressed in untrusted folders to prevent accidental side-effects.

Configured MCP servers:

○ wc: python3 ./tools/word_count.py (stdio) - Disabled

So run gemini mcp list in a new checkout before wondering where your tools went. And one naming trap if you also run Antigravity CLI: it keeps its servers in ~/.gemini/config/mcp_config.json — same directory, different file. That's from the documentation bundled in the agy binary rather than a published page, so check it against your own install before you go editing.

In Kimi Code CLI

There's no mcp subcommand in Kimi Code CLI. You define the servers in a JSON file and manage them from inside a session with /mcp-config, while /mcp shows the connection status of all of them.

You can define the configuration in two places:

  • The ~/.kimi-code/mcp.json or $KIMI_CODE_HOME/mcp.json files for the user-level scope

  • .kimi-code/mcp.json file under the project's working directory, where the project-level entry takes precedence over the user-level one with the same name

Here's an example:

JSON
{
  "mcpServers": {
    "wc": {
      "command": "python3",
      "args": ["./tools/word_count.py"]
    },
    "linear": {
      "url": "https://mcp.linear.app/mcp"
    }
  }
}

In order to log in to the remote server, you need to run /mcp-config login <server-name>. For timeouts, make sure to configure them separately in config.toml, not in the JSON file. Here's an example:

TOML
[mcp]
startup_timeout_ms = 30000

When using tools on the MCP servers, their names will be mcp__<server>__<tool>. This is also the name used in permission rules; you can create wildcards so a single rule could allow for the entire server. For instance, you can do it like this:

TOML
[[permission.rules]]
decision = "allow"
pattern = "mcp__wc__*"

The gotcha. A stdio entry in a project-level .kimi-code/mcp.json — the wc one above — runs python3 ./tools/word_count.py on your machine every time you open a session in that project. Given the above, Kimi Code developers recommend enabling it only in trusted repositories. This is especially important given that Kimi Code doesn't have any isolation on the OS level (there's no data under the "Sandbox" row in this Academy table), so if a server turns out to be something other than what its name suggested there's nothing that could protect you from this.

Is a server justified?

The simplest test is if what you target is already reachable as a file or a shell command — if it is, you already have it and a server is getting you a subprocess and a block of tool definitions in exchange for nothing. It makes sense when the system sits behind an API you'd otherwise be copying out of a browser tab — an issue tracker, an error dashboard, the analytics warehouse, a database you'd rather not expose over a raw connection.

That's what it should look like, the rest of the chapter is about implementing it avoiding the 2 antipatterns we've seen earlier — a description the model misread and a tool list nobody watched.

j / k to move between lessons