Skip to content

MCP in the Agent SDK

Connect Model Context Protocol servers to an Agent SDK agent over stdio, HTTP or SSE, approve their tools, handle authentication and diagnose failed connections.

The Model Context Protocol (MCP) is an open standard for plugging tools and data into AI agents. Rather than writing a GitHub integration or a database client yourself, you point the agent at an MCP server that already speaks the protocol, and its tools appear alongside the built-ins. Servers can run as local processes, sit behind a URL, or live inside your own application.

This page is about MCP in SDK agents. To add servers to the Claude Code CLI for every project, see MCP in Claude Code.

A quick example

Connect a remote server over HTTP and allow all its tools with a wildcard. Here I use the GitHub MCP server, which needs a personal access token with read access to the repositories you want to query:

export GITHUB_TOKEN=github_pat_...
import { query } from "@anthropic-ai/claude-agent-sdk";

for await (const msg of query({
  prompt: "Summarise the five newest open issues in my-org/web-app and suggest which to tackle first",
  options: {
    mcpServers: {
      github: {
        type: "http",
        url: "https://api.githubcopilot.com/mcp/",
        headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` },
      },
    },
    allowedTools: ["mcp__github__*"],
  },
})) {
  if (msg.type === "system" && msg.subtype === "init") console.log("servers:", msg.mcp_servers);
  if (msg.type === "result" && msg.subtype === "success") console.log(msg.result);
}
import os
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage, SystemMessage

opts = ClaudeAgentOptions(
    mcp_servers={
        "github": {
            "type": "http",
            "url": "https://api.githubcopilot.com/mcp/",
            "headers": {"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}"},
        }
    },
    allowed_tools=["mcp__github__*"],
)

If the init message shows github with status connected, the token works. A status of pending can also be fine (more on that below). If it says failed or needs-auth, do not trust the answer: Claude may have fallen back to other tools.

Adding servers

In code

Pass servers in mcpServers (Python: mcp_servers). The key is the server's name.

options: {
  mcpServers: {
    files: {
      command: "npx",
      args: ["-y", "@modelcontextprotocol/server-filesystem", "/srv/shared/contracts"],
    },
  },
  allowedTools: ["mcp__files__*"],
}

From .mcp.json

Put a .mcp.json at the project root. It loads through the project setting source, which default options include. If you set settingSources yourself, include "project".

{
  "mcpServers": {
    "files": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/srv/shared/contracts"]
    }
  }
}

Choosing a transport

Check the server's documentation:

  • It gives you a command to run: use stdio.
  • It gives you a URL: use HTTP (streamable) or SSE.
  • You are writing the tools yourself in your app: use an SDK server.
TransportConfig fieldsNotes
stdiocommand, args, optional envA local child process talking over stdin and stdout
HTTPtype: "http", url, optional headersStreamable HTTP. In JSON config files "streamable-http" is accepted as an alias, but the SDK's McpHttpServerConfig type only declares "http", so use that in code.
SSEtype: "sse", url, optional headersServer-sent events, for older remote servers
SDKA server from createSdkMcpServer / create_sdk_mcp_serverRuns inside your process; see custom tools

An SDK server registered through an initialize control request starts connecting as soon as Claude Code processes that request.

Approving MCP tools

Claude can see MCP tools without permission, but cannot call them until something approves the call.

Names

MCP tools are named mcp__<server>__<tool>. A server keyed crm with a find_contact tool gives mcp__crm__find_contact.

allowedTools

List exact tools, or use * for everything on one server:

allowed_tools=[
    "mcp__github__*",           # every GitHub tool
    "mcp__warehouse__query",    # just one tool from the warehouse server
    "mcp__slack__post_message", # just one Slack tool
]

Note: Use allowedTools rather than a permission mode to grant MCP access. acceptEdits does not approve MCP tools at all; it only covers file edits and filesystem commands. bypassPermissions does approve them, but also switches off most other safety prompts. A wildcard for one server grants exactly what you intend and nothing more. See SDK permissions.

Finding out what a server offers

Read the server's docs, ask Claude to list them, or inspect the tools array on the init system message and filter for the mcp__ prefix:

async for msg in query(prompt="...", options=opts):
    if isinstance(msg, SystemMessage) and msg.subtype == "init":
        print([t for t in msg.data.get("tools", []) if t.startswith("mcp__")])

In TypeScript, msg.tools is directly on the init message. The list includes tools from servers that have connected by the time init is sent, plus servers with a cached tool list; tools from other servers are missing until they connect.

Connection timing

Servers you pass in options.mcpServers are registered at startup, and the init message is sent once the first-turn wait (if any) has finished. Whether a server holds up the first turn depends on its type:

ServerHolds up the first turn?Deadline
stdio, or HTTP/SSE with no cached tool listYes, until connectedMCP_TIMEOUT (30 seconds by default); the connection fails at that point
Remote server with a cached tool list from a previous connectionNo; cached tools are usable immediatelyConnects on its first tool call, with its own timeout
In-process SDK serverYes, until connected and its tools are listedMCP_TIMEOUT per attempt

Servers from settings files (such as .mcp.json) or plugins often show pending at init. If options.mcpServers holds any stdio, HTTP or SSE server, the first turn waits for those pending servers too, up to MCP_TIMEOUT. If it is empty or holds only SDK servers, the wait is 2 seconds:

  • With tool search on (the default), the wait only covers pending servers configured with alwaysLoad: true. The rest keep connecting in the background.
  • With tool search off, the wait covers every pending server. Removing the ToolSearch tool (for example via disallowedTools) also turns tool search off. See tool search.

If you set permissionPromptToolName, the first turn always waits for that tool's server, up to MCP_TIMEOUT.

Tuning the wait

SettingEffect
CLAUDE_CODE_MCP_STARTUP_WAIT_MS in envWaits up to this many milliseconds for every pending server, with or without tool search, and replaces the MCP_TIMEOUT first-turn wait for stdio, HTTP and SSE servers in options.mcpServers. 0 skips the wait. A permissionPromptToolName server keeps its own wait. Needs Claude Code v2.1.274 or later.
MCP_CONNECTION_NONBLOCKING=0Blocks startup itself, before init is sent, on the whole connection batch, capped at 5 seconds by default
MCP_CONNECT_TIMEOUT_MSChanges that startup cap, in milliseconds
alwaysLoad: true on a serverIts tools are available at full schema from the first turn, exempt from tool search deferral. Startup waits for it (same cap); a remote server with a cached list supplies tools without connecting.

Servers still pending when a wait ends carry on connecting in the background.

With many MCP tools, their definitions alone can eat a big slice of context. Tool search, on by default, keeps definitions out of context and loads only those Claude needs. Configuration and best practice are on the tool search page.

Authentication

Environment variables for local servers

Pass secrets to a stdio server through its env field:

mcpServers: {
  accounts: {
    command: "npx",
    args: ["-y", "@acme/accounts-mcp"],
    env: { ACCOUNTS_API_KEY: process.env.ACCOUNTS_API_KEY! },
  },
},
allowedTools: ["mcp__accounts__*"],

In .mcp.json, ${VAR} is expanded from the environment at runtime, which keeps secrets out of the file:

{
  "mcpServers": {
    "accounts": {
      "command": "npx",
      "args": ["-y", "@acme/accounts-mcp"],
      "env": { "ACCOUNTS_API_KEY": "${ACCOUNTS_API_KEY}" }
    }
  }
}

Headers for remote servers

For HTTP and SSE servers, put credentials in headers, in code or in .mcp.json (again with ${VAR} expansion):

{
  "mcpServers": {
    "accounts": {
      "type": "http",
      "url": "https://mcp.accounts.example.com/mcp",
      "headers": { "Authorization": "Bearer ${ACCOUNTS_TOKEN}" }
    }
  }
}

OAuth

MCP supports OAuth 2.1, but the SDK never opens a browser or runs an interactive flow. If a server issues an auth challenge and no stored token exists, the run carries on without that server's tools and the server's status becomes needs-auth. The init message may still say pending at that moment, so poll mcpServerStatus() (TypeScript) or get_mcp_status() on ClaudeSDKClient (Python) to be sure.

To supply credentials, run the OAuth flow in your own app and pass the access token as a header:

token = await my_oauth.get_access_token(user_id)
opts = ClaudeAgentOptions(
    mcp_servers={"crm": {"type": "http", "url": "https://mcp.crm.example.com/mcp",
                         "headers": {"Authorization": f"Bearer {token}"}}},
    allowed_tools=["mcp__crm__*"],
)

Example: read-only database questions

DBHub exposes a database over MCP with an execute_sql tool. Out of the box that tool runs whatever SQL the agent writes, including writes, so lock it down in DBHub's TOML config:

[[sources]]
id = "reporting"
dsn = "${DATABASE_URL}"

[[tools]]
name = "execute_sql"
source = "reporting"
readonly = true

With readonly = true, DBHub refuses INSERT, UPDATE, DELETE and DDL. ${DATABASE_URL} is read from the environment, so the connection string never sits in the file.

for await (const msg of query({
  prompt: "Which five products had the most returns last month, and what share of sales does that represent?",
  options: {
    mcpServers: {
      reporting: { command: "npx", args: ["-y", "@bytebase/dbhub", "--config", "dbhub.toml"] },
    },
    allowedTools: ["mcp__reporting__execute_sql"],
  },
})) {
  if (msg.type === "result" && msg.subtype === "success") console.log(msg.result);
}

Claude discovers the schema, writes the SQL and explains the result. I would still point it at a read replica rather than production.

When things go wrong

Reading server status

Every query starts with an init system message listing each MCP server with a status of pending, connected, failed, needs-auth or disabled. Servers from options.mcpServers that connected during the first-turn wait show connected.

Do not treat pending as failure on its own. It can mean the server is still connecting, that its tools came from the cache and it will connect on first use, or that its deadline passed (in which case it shows pending or failed depending on timing). Treat failed and needs-auth as unusable:

async for msg in query(prompt="Reconcile last week's payouts", options=opts):
    if isinstance(msg, SystemMessage) and msg.subtype == "init":
        broken = [s for s in msg.data.get("mcp_servers", []) if s.get("status") in ("failed", "needs-auth")]
        if broken:
            log.warning("MCP servers unavailable: %s", broken)

A server failing to connect never raises an exception; you have to check status. A single-message query() raises only after an error result, or if Claude Code itself cannot start.

Status can also change mid-session. If a remote server's connection drops, it goes back to pending while Claude Code reconnects, so a later status check can show pending for a server that was connected. After five failed reconnection attempts it becomes failed (or needs-auth if it needs authorising again). Retry manually with reconnectMcpServer() in TypeScript or ClaudeSDKClient.reconnect_mcp_server() in Python.

Server shows failed

Usual causes:

  • Missing environment variables. Check the server's env has everything it expects.
  • Not installed. For npx servers, confirm the package name and that Node.js is on the PATH of the process running your agent.
  • Bad connection string. For database servers, check the format and that the database is reachable.
  • Network. For remote servers, confirm the URL is reachable and no firewall is in the way.

Claude sees the tools but never calls them

Almost always a permission issue. Add mcp__<server>__* (or the specific tool names) to allowedTools.

A tool is missing from your SDK server

In TypeScript, if a tool's input schema cannot be converted to JSON Schema, createSdkMcpServer() leaves that tool out of its listing and emits a warning. Under Node.js it is a process warning with code CLAUDE_SDK_MCP_TOOL_SCHEMA_UNCONVERTIBLE, beginning Tool "<name>" on SDK MCP server "<server>" was left out of the server's tool list, because its input schema cannot be converted to JSON Schema, followed by the conversion error and advice. Before TypeScript Agent SDK v0.3.286 a single bad schema made the whole server's listing fail silently.

Timeouts

VariableControlsDefault
MCP_TIMEOUTHow long a server has to connect (milliseconds)30 seconds
MCP_TOOL_TIMEOUTHow long a running tool call may take

For slow-starting servers, raise MCP_TIMEOUT, use a lighter server if one exists, warm the server up before starting the agent, or check its logs for what is slow. In TypeScript you can also set a per-server tool-call limit by passing timeout to createSdkMcpServer().

Output too large

The SDK uses the same output limits as Claude Code. A successful, image-free result over 25,000 tokens is saved to a file, and Claude receives an error naming that file so it can read it in pieces. Change the limit with MAX_MCP_OUTPUT_TOKENS. Separately, unless the tool declares anthropic/maxResultSizeChars, any successful text result over 50,000 characters is saved to a file regardless of the token limit. MCP in Claude Code covers how a server declares that annotation.