Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 14: Claude & Anthropic

MCP started at Anthropic, so Claude’s clients are usually the first to ship a new MCP feature, and that is the trap. A server tuned to how Claude happens to behave this quarter can work perfectly in Claude and fail quietly everywhere else. Everything below is organised around one question: which of these behaviours is MCP, and which is Claude? Build to the first, and treat the second as a bonus.

This chapter’s rule: if a Claude feature changes what your server returns, the server has stopped being portable; if it only changes how Claude presents or configures the server, use it freely. The notes server from Chapter 5 is the example throughout. You will configure it in Claude Desktop, add it to Claude Code, connect it as a remote connector, and call it through the Connector API, without changing a line of it.

Currency note. Product details in this chapter (auth types, plan availability, limits, beta headers) were checked against Anthropic’s documentation on 2026-09-27. They change often. The linked docs are the source of truth for those facts; the design decisions here do not depend on them.

Claude Desktop Shows You What the Model Sees

Claude Desktop is the cheapest place to watch a model use your server. You configure a local server, Claude launches it over stdio, and the conversation shows which tool the model picked, with what arguments, and what came back. That visibility is what you want while tuning descriptions, schemas and error messages.

Claude Desktop reads claude_desktop_config.json, at ~/Library/Application Support/Claude/ on macOS and %APPDATA%\Claude\ on Windows. The first attempt usually looks like this:

{
  "mcpServers": {
    "notes": {
      "command": "uv",
      "args": ["run", "server.py"],
      "env": {
        "NOTES_DB_PATH": "/Users/me/notes.db"
      }
    }
  }
}

It often fails. Claude Desktop launches servers from its own application context, not your terminal, so PATH changes in .zshrc or a version manager don’t apply, and uv isn’t found. The fix is straightforward but annoying. Use absolute paths:

{
  "mcpServers": {
    "notes": {
      "command": "/Users/me/.local/bin/uv",
      "args": ["run", "--directory", "/Users/me/projects/mcp-notes", "server.py"],
      "env": {
        "NOTES_DB_PATH": "/Users/me/notes.db"
      }
    }
  }
}

which uv gives you the path. Editing the file requires quitting and relaunching Claude Desktop; there is no hot reload.

When the server still doesn’t appear, the answer is in the logs, not the UI: tail -n 20 -F ~/Library/Logs/Claude/mcp*.log on macOS (%APPDATA%\Claude\logs on Windows). A missing binary shows up there as a failed launch within a second. A server that starts and then prints a banner to stdout shows up as a parse error on the first message. Those two cases cover most first-day failures, and neither appears in the conversation.

What to Watch in the Loop

Tool selection. Does the model pick search_notes on the first try, or hesitate between similar tools? The model decides from names and descriptions alone. Frequent wrong picks mean the descriptions need work, not the model.

Approval friction. Five calls in a row can mean five “Allow” clicks. That is a signal about granularity and annotations. The directory section below gives the rule Claude applies; design as if every write will prompt.

Error recovery. When a tool fails, does the model retry with corrected input or give up? The message decides. "Invalid date format" gets a guess. "Invalid date format. Expected ISO 8601 (e.g., 2026-04-25). Received: 'next Tuesday'" gets a fix.

Output size. Claude shows results in the conversation and truncates large ones. Design to the tightest documented limit on Claude’s surfaces, Claude Code’s (below): a warning at 10,000 tokens and a cap at 25,000. A 50 KB JSON dump burns context on data the model can’t use. Return summaries.

Stdio Servers Are Single-User

  • Stdio servers are single-user. No shared team servers or central credentials. For those, use a remote connector (next section).
  • No OAuth for local servers. A stdio server that calls remote APIs manages its own credentials: environment variables, credential files or the system keychain.
  • Feature support follows the capability floor. Claude Desktop supports tools, resources and prompts. For newer features, the Part V opener has the table; build the server’s value on tools.

Remote Connectors: Traffic Comes From Anthropic, Not the User

Remote servers reach Claude through connectors, available on Claude.ai, the mobile apps, Claude Desktop and Cowork. They speak Streamable HTTP (SSE is deprecated but still accepted) and share one authentication stack across every Claude surface.

The detail that decides your network design: the connection to your server comes from Anthropic’s cloud (160.79.104.0/21), not from the user’s machine, even when the user runs Claude Desktop or Cowork locally. A server behind a VPN or corporate firewall won’t connect unless it allowlists that range. “It works from my laptop” proves nothing here.

Pick CIMD, and Know What Claude Checks

Claude lists six authentication types. Three decisions cover almost every server:

If your server…UseWhy
Has users and an authorization server you controloauth_cimd (Client ID Metadata Document)The 2026-07-28 spec’s preferred registration; no registration endpoint, no client per connection
Has users, but you won’t implement CIMDoauth_anthropic_credsYou create one client ID and secret and send them to Anthropic
Serves public data onlynone (or lazy authentication, where some tools require sign-in)No identity to protect

The rest (oauth_dcr, custom_connection, static_headers) are in the authentication doc. DCR works, but it is deprecated in the current spec, and it registers a client per user connection, which swamps an authorization server at scale.

“Prefer CIMD” is only useful if you know what Claude looks for. Claude selects CIMD only when your authorization server’s metadata advertises two values. Without either one, it falls back to DCR:

{
  "issuer": "https://auth.example.com",
  "authorization_endpoint": "https://auth.example.com/authorize",
  "token_endpoint": "https://auth.example.com/token",
  "code_challenge_methods_supported": ["S256"],
  "client_id_metadata_document_supported": true,
  "token_endpoint_auth_methods_supported": ["none", "private_key_jwt"]
}

"none" must be present because Claude’s CIMD client is a public client at your token endpoint. Chapter 19 shows the client’s side: the metadata document your authorization server fetches.

Three more facts shape the design:

  • One OAuth app for everyone. Claude uses a single shared OAuth application per connector. There are no tenant-level or enterprise-specific clients, so access is scoped only by each user’s own permissions on your service. A multi-tenant server must enforce tenancy from the token’s claims, never from which client connected.
  • A human always consents. Pure machine-to-machine client_credentials grants aren’t supported.
  • Two redirect shapes. Hosted surfaces use https://claude.ai/api/mcp/auth_callback. Claude Code uses an RFC 8252 loopback redirect on an ephemeral port, so your authorization server must accept both localhost and 127.0.0.1 with port-agnostic matching.

Treat the Directory Rules as a Design Checklist

Anthropic’s Connectors Directory gives listed servers one-click install. Its review criteria are worth meeting even if you never submit.

Annotations are mandatory. Every tool needs a title and the applicable readOnlyHint or destructiveHint. Directory review requires these annotations, and Claude uses them when deciding what to auto-approve. The exact rule changes, so design as if every write will prompt. For notes, that is one line per tool:

from mcp.types import ToolAnnotations

@mcp.tool(annotations=ToolAnnotations(title="Search notes", readOnlyHint=True))
def search_notes(query: str, tag: str | None = None, limit: int = 20) -> SearchResult: ...

@mcp.tool(annotations=ToolAnnotations(title="Create a note",
                                      readOnlyHint=False, destructiveHint=False))
def create_note(title: str, body: str, tag: str = "general") -> dict: ...

@mcp.tool(annotations=ToolAnnotations(title="Delete a note",
                                      destructiveHint=True, idempotentHint=True))
def delete_note(note_id: int, confirm: bool = False) -> dict: ...

create_note sets destructiveHint=False explicitly because the spec’s default for a non-read-only tool is true, and an additive tool that inherits it prompts like a delete. The title is the human-facing name, so write it as UI copy, not a docstring.

Read and write tools are separate. A catch-all api_request tool with a method parameter is rejected. search_notes and delete_note pass; notes_api(method=...) does not.

Descriptions describe. They must be narrow, accurate (reviewers check them against behaviour), and must not tell Claude to call other tools or promote anything. Tool names are 64 characters or fewer.

Remote servers use OAuth, validate Origin, document a privacy policy, and give reviewers a working test account.

Submission goes through claude.ai/directory/manage. Anthropic scans automatically and lists the server as a Community connector by default; some submissions also get human review.

Users on any plan can skip the directory and add a custom connector by URL in Settings > Connectors (Free is limited to one; on Team and Enterprise, an Owner adds it for the organisation first). Custom connectors can be switched on and off per conversation.

MCP Apps and Bundles

MCP Apps return interactive HTML rendered in a sandbox inside the conversation. They are an official MCP extension, not a Claude feature, so an App built with @modelcontextprotocol/ext-apps also renders in the other hosts in the opener’s table. Build one when the interaction is visual (a sortable table, a calendar view). Don’t wrap a text answer in a widget: you pay in rendering, cross-host testing and security surface for nothing.

MCP Bundles (MCPB) package a local server and its dependencies as a desktop extension. The directory no longer accepts new MCPB listings. To distribute a local server through it, ship it inside a Claude plugin’s .mcp.json.

Claude Code: The Same Server From a Terminal

Claude Code supports tools, resources (as @ mentions), prompts (as commands), elicitation, listChanged updates, and reconnection for remote servers. Two claude mcp add cases cover almost everything:

# The remote notes server from Chapter 7
claude mcp add --transport http notes https://notes.example.com/mcp

# The local notes server, with its database path
claude mcp add --transport stdio --env NOTES_DB_PATH=/Users/me/notes.db notes \
  -- /Users/me/.local/bin/uv run --directory /Users/me/projects/mcp-notes server.py

Watch the ordering: every flag (--transport, --env, --scope, --header) comes before the server name, and -- separates Claude Code’s flags from the server command. Put --env after the name and it is passed to your server as an argument.

Scopes are local (default, you in this project), user (you, everywhere) and project, which writes a .mcp.json at the repository root for the team to commit. Chapter 16 shows one portable .mcp.json that Claude Code, VS Code and Cursor all read.

What changes for the server:

  • Tool lists can change live. Claude Code refreshes tools, prompts and resources on listChanged, and keeps the previous list if a refresh fails.
  • Remote servers reconnect; stdio servers don’t. An HTTP server that drops is retried with exponential backoff, up to five attempts starting at one second, then marked failed.
  • Output is capped. A warning at 10,000 tokens and a default cap of 25,000 (MAX_MCP_OUTPUT_TOKENS raises it; the warning threshold is fixed). MCP_TOOL_TIMEOUT sets the timeout.
  • Plugins can bundle servers. A Claude Code plugin can ship your server’s config, using ${CLAUDE_PLUGIN_ROOT} for bundled files and ${CLAUDE_PLUGIN_DATA} for state.

The Connector API: Tools Only, and No ZDR

The MCP Connector API lets your own application use MCP servers through Anthropic’s Messages API. You declare servers in the request; Anthropic’s infrastructure connects to them, lists their tools and calls them.

import anthropic

client = anthropic.Anthropic()

response = client.beta.messages.create(
    model="claude-opus-5-5",  # any current model that supports the MCP connector
    max_tokens=1000,
    messages=[{"role": "user", "content": "Find my notes tagged 'launch'."}],
    mcp_servers=[
        {
            "type": "url",
            "url": "https://notes.example.com/mcp",
            "name": "notes",
            "authorization_token": notes_token,  # audience: https://notes.example.com/mcp
        }
    ],
    tools=[{"type": "mcp_toolset", "mcp_server_name": "notes"}],
    betas=["mcp-client-2026-09-15"],
)

The beta header is required. mcp-client-2026-09-15 includes everything in mcp-client-2025-11-20 and adds tool-list pinning; the original mcp-client-2025-04-04 is deprecated. Your application refreshes notes_token before each call. An expired token fails the server connection, not just the tool call.

Why Pin the Tool List

Tool-list pinning is a security property, not a performance feature. Without it, a server can change its tool definitions partway through a conversation: the search_notes the user’s application vetted on turn one can carry a different description, and different instructions to the model, on turn five. That is the rug-pull attack Chapter 20 describes. With mcp-client-2026-09-15, the API records each server’s tool list as an mcp_tool_listing block in the response. You send that block back unchanged, and later turns use the recorded list instead of asking the server again. You can go further and copy a vetted list into the toolset’s tools field, so the API never asks the server at all. Pin when the server isn’t yours. For your own server it costs you a redeploy to change tools mid-conversation, and that is the point.

Allowlist, Then Defer

Enable tools by name:

{
  "type": "mcp_toolset",
  "mcp_server_name": "notes",
  "default_config": {"enabled": false},
  "configs": {
    "search_notes": {"enabled": true},
    "create_note": {"enabled": true}
  }
}

The same shape works as a denylist (leave default_config alone and disable delete_note), or with no configs at all to enable everything. Use the allowlist in production. You explicitly opt in to each tool rather than hoping you remembered to opt out of the dangerous ones. A name in configs that the server doesn’t have logs a warning rather than failing, which keeps the allowlist valid when a server’s tools change.

When the session crosses the opener’s ~40-tool line, add tool search. Set "default_config": {"defer_loading": true}, leave the one tool nearly every conversation needs (search_notes) undeferred, and the model finds the rest through a regex or BM25 Tool Search Tool. The undeferred choice matters more than it looks. Every deferred tool costs a search step before its first call, so deferring the tool used in nine conversations out of ten adds a round trip to nine conversations to save a few hundred tokens in each. Defer the long tail; keep the head loaded.

The Constraints That Change the Design

  • Only tools. Resources, prompts and elicitation are unavailable. notes://summary doesn’t exist on this surface; search_notes does.
  • HTTPS only. No http://, no localhost, no stdio.
  • Not covered by ZDR. Tool definitions and results are retained under Anthropic’s standard policy. If a customer requires Zero Data Retention, this surface is out.
  • Different content blocks. Calls arrive as mcp_tool_use and mcp_tool_result, not tool_use and tool_result. Code that handles both native and MCP tools must handle both.
  • Limited platforms. The Claude API, Claude Platform on AWS (beta) and Microsoft Foundry (beta) at the time of writing; not Amazon Bedrock or Google Vertex AI.

Two Servers, Two Tokens

One request can list several servers; each needs exactly one mcp_toolset, and every toolset must name a server in the list. Here notes sits next to the tickets server that Part VI secures:

mcp_servers=[
    {"type": "url", "name": "notes",
     "url": "https://notes.example.com/mcp",
     "authorization_token": notes_token},    # aud: https://notes.example.com/mcp
    {"type": "url", "name": "tickets",
     "url": "https://mcp.example.com/mcp",
     "authorization_token": tickets_token},  # aud: https://mcp.example.com/mcp, scope tickets:read
],
tools=[
    {"type": "mcp_toolset", "mcp_server_name": "notes"},
    {"type": "mcp_toolset", "mcp_server_name": "tickets",
     "default_config": {"enabled": False},
     "configs": {"get_ticket": {"enabled": True}}},
],

Two tokens, not one. Each is minted for its own server’s audience (Chapter 19), so a compromised notes server that replays its token at tickets gets a 401. Sending one token to both would work on the first try, and it is exactly the passthrough Part VI rules out. The catalog is the sum of both servers, so the opener’s threshold applies to the total.

Reading the Response

Your application code sees MCP activity as its own block types, next to the model’s text:

for block in response.content:
    if block.type == "mcp_tool_use":
        log.info("mcp call", server=block.server_name, tool=block.name, args=block.input)
    elif block.type == "mcp_tool_result":
        if block.is_error:
            log.warning("mcp tool error", tool_use_id=block.tool_use_id)
    elif block.type == "text":
        print(block.text)
    # mcp_tool_listing blocks (2026-09-15 header) are skipped here
    # and sent back unchanged with the next turn.

The server_name field is what lets an incident review answer “which server did this”, which is why it belongs in the log line.

Skills Carry Method; Servers Carry Access

Claude has three extension mechanisms. MCP servers give access: notes can search and write notes. Skills give method: a directory with a SKILL.md that tells Claude how to do a task, such as “turn this week’s meeting notes into a status update”. Plugins bundle both, plus commands, for distribution; a skill reaches the directory only inside a plugin.

Keep the layers separate. Process logic in a tool description makes the tool harder to select and the process harder to change. Skills follow the open Agent Skills specification, so a skill travels to any host that implements it, as the extension matrix in the opener tracks. The server still works in hosts that don’t. That is the portability argument for keeping method out of the server.

Failure Modes

1. Assuming resources work everywhere. notes://summary works in Claude Desktop and Claude Code, then a customer connects through the Connector API and it silently disappears. Anything the model needs must also be a tool.

2. Config syntax errors. A trailing comma in claude_desktop_config.json stops every server from loading, and Claude Desktop doesn’t always say why. Validate the JSON before relaunching.

3. Stdout contamination. A dependency that prints to stdout corrupts the stdio stream. All logs go to stderr.

4. Token expiry without refresh. The Connector API leaves refresh to your application. Claude’s hosted surfaces refresh on 401 and up to five minutes before expiry, and they need RFC 6749 error codes (invalid_grant, not a custom code) to know when a refresh token is dead.

5. Catalog bloat through the Connector API. Three servers with 20 tools each put 60 tools in front of the model on every request, whether used or not. Allowlist or defer.

6. Building Claude-only behaviour. The server branches on client identity or leans on a Claude-specific calling pattern, and breaks subtly in the next host.

7. Allowlisting the wrong network. The firewall admits the office range and the user’s VPN, the connector fails from Claude.ai, and every manual test from a laptop passes. Remote connector traffic comes from 160.79.104.0/21.

8. ZDR discovered late. An enterprise customer requires Zero Data Retention, and the application routes MCP traffic through the Connector API. This is a compliance failure that is discovered in procurement review, not in testing.

Implementation Checklist

Ordered by when you meet each item: local, remote, directory, API.

  • Claude Desktop config uses absolute paths and passes secrets through env
  • Every result stays under Claude Code’s 10,000-token warning
  • Authorization server metadata advertises client_id_metadata_document_supported: true and "none" in token_endpoint_auth_methods_supported
  • Redirects accepted: https://claude.ai/api/mcp/auth_callback and port-agnostic loopback on localhost and 127.0.0.1
  • Every tool has a title and an honest readOnlyHint or destructiveHint; reads and writes are separate tools
  • Connector API calls send mcp-client-2026-09-15, allowlist tools, and pin lists from servers you don’t run
  • Customer ZDR requirements checked before choosing the Connector API

What Comes Next

Every decision above came back to the same test: does this change what the server returns, or only how Claude presents it? OpenAI’s surfaces put that test under more pressure, because there someone other than you writes the server’s description, decides its approvals and caches its tool list. Chapter 15 is about designing for that.