Claude Agent SDKclaude-agent-sdk 0.2.157 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
46 small wins to finish your pathNext lesson →

External MCP servers

An external MCP server is a server Claude Code connects to rather than one running inside your program, over stdio to a local command, or over HTTP or SSE to a URL.

Last updated: 20 Oct, 2026 · Claude Agent SDK

The in-process server put your own Python functions in front of Claude. Most of the useful servers in the world are already written: a filesystem server, a Postgres server, GitHub's own. Those you configure rather than write, and the configuration is where everything interesting happens.

The three transports, as configuration

The MCP page gives one rule for choosing: if the server's docs hand you a command to run, use stdio; if they hand you a URL, use HTTP or SSE; if you are writing the tools yourself, use an in-process server. Each is a dict under a name in mcp_servers:

python
servers = {
    # stdio: a local command Claude Code starts and talks to over its pipes
    "filesystem": {"command": "npx",
                   "args": ["-y", "@modelcontextprotocol/server-filesystem", "/srv/orders"]},

    # http: a URL. "streamable-http" is accepted in JSON config files as an alias
    "docs": {"type": "http", "url": "https://code.claude.com/docs/mcp"},

    # sse: the older event-stream transport, same shape
    "events": {"type": "sse", "url": "https://api.example.com/mcp/sse"},
}

The types are declared in the SDK as McpStdioServerConfig, McpHttpServerConfig, McpSSEServerConfig and McpSdkServerConfig, which together make the McpServerConfig union in the Python reference. On the HTTP type only "http" is declared, so use that spelling in code and keep "streamable-http" for .mcp.json files.

A stdio server in thirty lines

To watch a real connection happen, here is a server small enough to read. It answers the three MCP methods a client needs, one JSON-RPC request per line in, one reply per line out:

python
import json
import sys

TOOLS = [{"name": "stock_level", "description": "How many of an order's part are in stock.",
          "inputSchema": {"type": "object", "properties": {"order_id": {"type": "string"}},
                          "required": ["order_id"]}}]
STOCK = {"A17": 4}
python
def answer(request):
    # the three methods a client needs: who you are, what you have, run one
    if request["method"] == "initialize":
        return {"protocolVersion": "2025-06-18", "capabilities": {},
                "serverInfo": {"name": "stock", "version": "1"}}
    if request["method"] == "tools/list":
        return {"tools": TOOLS}

    order = request["params"]["arguments"]["order_id"]
    return {"content": [{"type": "text",
                         "text": f"{STOCK.get(order, 0)} of order {order} in stock."}]}
python
# one JSON-RPC request per line in, one reply per line out
for line in sys.stdin:
    message = json.loads(line)
    print(json.dumps({"jsonrpc": "2.0", "id": message["id"],
                      "result": answer(message)}), flush=True)

A server you would keep uses the mcp package rather than writing JSON-RPC by hand. This one is written by hand so the wire is visible: the same three methods the in-process server answers, over a pipe instead of inside your process.

The desk starts the command

Claude Code is the side that connects, so the stand-in grows that part. shop_desk.py gains connect_stdio, stdio and list_from, and its find_tools, start and run are replaced:

python
    async def connect_stdio(self, name, config):
        # Claude Code starts the command and talks MCP over its standard input
        process = await asyncio.create_subprocess_exec(
            config["command"], *config.get("args", []),
            stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
            env={**os.environ, **config.get("env", {})})
        self.external[name] = process

        hello = await self.stdio(name, "initialize", {"protocolVersion": "2025-06-18"})
        if hello is None:
            return {"name": name, "status": "failed",
                    "error": "the server gave no answer to initialize"}
        return {"name": name, "status": "connected", "serverInfo": hello["serverInfo"]}
python
    async def stdio(self, name, method, params):
        process = self.external[name]
        request = {"jsonrpc": "2.0", "id": next(self.ids), "method": method,
                   "params": params}
        process.stdin.write((json.dumps(request) + "\n").encode())
        await process.stdin.drain()

        # a server that died writes nothing and its pipe reaches end of file
        line = await process.stdout.readline()
        return json.loads(line)["result"] if line else None

A server that died writes nothing, so its pipe reaches end of file and the read returns an empty line rather than hanging. That is the one branch that turns into a status below.

python
    async def list_from(self, name, config):
        # an in-process server is handed over by the SDK; a stdio one is a command to run
        if not isinstance(config, dict) or config.get("type") == "sdk":
            await self.mcp(name, "initialize", {"protocolVersion": "2025-06-18",
                           "capabilities": {}, "clientInfo": {"name": "desk", "version": "1"}})
            listed = await self.mcp(name, "tools/list", {})
            return {"name": name, "status": "connected", "tools": listed["tools"]}

        status = await self.connect_stdio(name, config)
        if status["status"] == "connected":
            status["tools"] = (await self.stdio(name, "tools/list", {}))["tools"]
        return status

list_from is where the two kinds part company: an in-process server is handed over by the SDK and reached with mcp_message control requests, while a stdio server is a command to start. Both answer the same tools/list.

python
        # one running process per stdio server, and a status line per configured server
        self.external, self.servers = {}, []
python
    async def find_tools(self):
        tools = {name: ("built-in", name) for name in self.offered()}
        self.servers = []

        # one status per configured server, whichever kind of server it is
        for name, config in self.options.mcp_servers.items():
            status = await self.list_from(name, config)
            self.servers.append(status)
            for tool in status.pop("tools", []):
                tools[f"mcp__{name}__{tool['name']}"] = (name, tool["name"])

        return {name: where for name, where in tools.items()
                if name not in self.options.disallowed_tools}

Every configured server now produces a status, whichever kind it is, and start puts the list of them in the init message where a run can read it:

python
    async def start(self, prompt):
        self.said.append(prompt)
        if not self.tools:
            self.tools = await self.find_tools()

        # the init message reports what each configured server is doing
        await self.send({"type": "system", "subtype": "init", "model": "shop-desk",
                         "session_id": self.session_id, "tools": list(self.tools),
                         "mcp_servers": self.servers})

run changes by one line: a tool from a stdio server goes back down its pipe, and everything else is still an mcp_message through the SDK.

python
    async def run(self, name, args, call):
        where, tool = self.tools[name]
        if where == "built-in":
            try:
                return BUILT_IN[tool](args), False
            except Exception as error:
                return f"{type(error).__name__}: {error}", True

        # the same tools/call either way, over the channel that server came in on
        call_args = {"name": tool, "arguments": args}
        result = (await self.stdio(where, "tools/call", call_args) if where in self.external
                  else await self.mcp(where, "tools/call", call_args))
        return result["content"][0]["text"], result.get("isError", False)

A tool from another process

The config for it is three keys, and the command is this interpreter so that the example runs anywhere:

python
import sys

# the same interpreter that runs this file, so the command works on any machine
stock = {"type": "stdio", "command": sys.executable, "args": ["stock_server.py"]}
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports these files. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep them in the same folder.
View the code here
shop_desk.py
import asyncio
import itertools
import json
import os
import re

from claude_agent_sdk import (AssistantMessage, ClaudeAgentOptions, ResultMessage,
                              SystemMessage, TextBlock, ToolResultBlock, ToolUseBlock,
                              Transport, UserMessage, query)

ORDER = re.compile(r"\b[A-Z]\d+\b")
SESSIONS = itertools.count(1)


def read_file(args):
    """What the desk serves for a Read call: the file's lines, numbered."""
    lines = open(args["file_path"]).read().splitlines()
    return "\n".join(f"{number:>5}  {line}" for number, line in enumerate(lines, 1))


BUILT_IN = {"Read": read_file}


class ShopModel:
    """Stands in for Claude: reads the conversation, picks the next step."""

    def __init__(self, script=None):
        self.script = list(script or [])

    def reply(self, said, results, tools):
        # a scripted reply lets a lesson force a step the stand-in would not pick
        if self.script:
            return self.script.pop(0)
        if results:
            return {"text": " ".join(results)}

        # the newest message first, then the last id named earlier in the chat
        orders = ORDER.findall(said[-1]) or ORDER.findall(" ".join(said))[-1:]
        wanted = "refund_order" if "refund" in said[-1].lower() else "lookup_order"
        tool = next((name for name in tools if name.endswith(wanted)), None)
        if orders and tool:
            return {"calls": [(tool, {"order_id": order}) for order in orders]}
        return {"text": f"I cannot help with order {orders[0]} yet." if orders
                else "Which order is this about?"}


class ShopDesk(Transport):
    """Stands in for the Claude Code binary: it runs the loop over JSON lines."""

    def __init__(self, options=None, model=None):
        self.options = options or ClaudeAgentOptions()
        self.model = model or ShopModel()
        self.session_id = f"session-{next(SESSIONS)}"
        self.inbox, self.outbox = asyncio.Queue(), asyncio.Queue()
        self.prompts, self.said = asyncio.Queue(), []
        self.ids, self.setup = itertools.count(1), {}
        self.ready, self.tools = False, {}
        self.waiting = {}

        # one running process per stdio server, and a status line per configured server
        self.external, self.servers = {}, []

    async def connect(self):
        self.ready = True

    def is_ready(self):
        return self.ready

    async def write(self, data):
        # one JSON line at a time, which is what the SDK writes to a real process
        await self.inbox.put(json.loads(data))

    async def end_input(self):
        # for a real process this would close its standard input
        await self.inbox.put(None)

    async def close(self):
        self.ready = False

    async def send(self, message):
        await self.outbox.put(message)

    async def read_messages(self):
        # the listener and the worker run alongside the SDK's own read loop
        asyncio.create_task(self.listen())
        self.worker = asyncio.create_task(self.work())

        while (message := await self.outbox.get()) is not None:
            if isinstance(message, Exception):
                raise message
            yield message

    async def listen(self):
        while (message := await self.inbox.get()) is not None:
            # a control request is the SDK asking a question; initialize is first
            if message["type"] == "control_request":
                if message["request"]["subtype"] == "initialize":
                    self.setup = message["request"]
                await self.respond(message["request_id"])

            # a control response answers a question the desk itself asked
            elif message["type"] == "control_response":
                answer = message["response"]
                self.waiting.pop(answer["request_id"]).set_result(answer)
            elif message["type"] == "user":
                await self.prompts.put(message["message"]["content"])

        # the input has ended: finish the work in hand, then close the stream
        await self.prompts.put(None)
        await self.worker
        await self.send(None)

    async def respond(self, request_id):
        # the stand-in says yes to every request the SDK makes of it
        await self.send({"type": "control_response", "response": {
            "subtype": "success", "request_id": request_id, "response": {}}})

    async def ask(self, request):
        # a future per request, so the answer can be awaited where it was asked
        request_id = f"desk_{next(self.ids)}"
        self.waiting[request_id] = asyncio.get_running_loop().create_future()
        await self.send({"type": "control_request", "request_id": request_id,
                         "request": request})
        return await self.waiting[request_id]

    async def work(self):
        while (prompt := await self.prompts.get()) is not None:
            await self.turn(prompt)

    async def start(self, prompt):
        self.said.append(prompt)
        if not self.tools:
            self.tools = await self.find_tools()

        # the init message reports what each configured server is doing
        await self.send({"type": "system", "subtype": "init", "model": "shop-desk",
                         "session_id": self.session_id, "tools": list(self.tools),
                         "mcp_servers": self.servers})

    async def turn(self, prompt):
        await self.start(prompt)

        # keep asking the model until it answers instead of calling a tool
        results, turns = [], 0
        while "calls" in (step := self.model.reply(self.said, results, self.tools)):
            turns += 1
            results = [await self.use(name, args) for name, args in step["calls"]]

        await self.say(step["text"])
        await self.finish("success", turns + 1, step["text"])

    def offered(self):
        # the tools option is the availability layer: None means every built-in the
        # desk has, a list names the ones to keep, and [] leaves Claude none
        asked = self.options.tools
        if asked is None or isinstance(asked, dict):
            return list(BUILT_IN)
        return [name for name in asked if name in BUILT_IN]

    async def mcp(self, server, method, params):
        message = {"jsonrpc": "2.0", "id": next(self.ids), "method": method,
                   "params": params}
        answer = await self.ask({"subtype": "mcp_message", "server_name": server,
                                 "message": message})
        return answer["response"]["mcp_response"]["result"]

    async def connect_stdio(self, name, config):
        # Claude Code starts the command and talks MCP over its standard input
        process = await asyncio.create_subprocess_exec(
            config["command"], *config.get("args", []),
            stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
            env={**os.environ, **config.get("env", {})})
        self.external[name] = process

        hello = await self.stdio(name, "initialize", {"protocolVersion": "2025-06-18"})
        if hello is None:
            return {"name": name, "status": "failed",
                    "error": "the server gave no answer to initialize"}
        return {"name": name, "status": "connected", "serverInfo": hello["serverInfo"]}

    async def stdio(self, name, method, params):
        process = self.external[name]
        request = {"jsonrpc": "2.0", "id": next(self.ids), "method": method,
                   "params": params}
        process.stdin.write((json.dumps(request) + "\n").encode())
        await process.stdin.drain()

        # a server that died writes nothing and its pipe reaches end of file
        line = await process.stdout.readline()
        return json.loads(line)["result"] if line else None

    async def list_from(self, name, config):
        # an in-process server is handed over by the SDK; a stdio one is a command to run
        if not isinstance(config, dict) or config.get("type") == "sdk":
            await self.mcp(name, "initialize", {"protocolVersion": "2025-06-18",
                           "capabilities": {}, "clientInfo": {"name": "desk", "version": "1"}})
            listed = await self.mcp(name, "tools/list", {})
            return {"name": name, "status": "connected", "tools": listed["tools"]}

        status = await self.connect_stdio(name, config)
        if status["status"] == "connected":
            status["tools"] = (await self.stdio(name, "tools/list", {}))["tools"]
        return status

    async def find_tools(self):
        tools = {name: ("built-in", name) for name in self.offered()}
        self.servers = []

        # one status per configured server, whichever kind of server it is
        for name, config in self.options.mcp_servers.items():
            status = await self.list_from(name, config)
            self.servers.append(status)
            for tool in status.pop("tools", []):
                tools[f"mcp__{name}__{tool['name']}"] = (name, tool["name"])

        return {name: where for name, where in tools.items()
                if name not in self.options.disallowed_tools}

    async def use(self, name, args, parent=None):
        # one id ties the request and its result together, as the message pair does
        call = f"toolu_{next(self.ids)}"
        block = {"type": "tool_use", "id": call, "name": name, "input": args}
        await self.send(self.message("assistant", [block], parent))

        text, failed = await self.run(name, args, call)
        result = {"type": "tool_result", "tool_use_id": call, "content": text,
                  "is_error": failed}
        await self.send(self.message("user", [result], parent))
        return text

    async def run(self, name, args, call):
        where, tool = self.tools[name]
        if where == "built-in":
            try:
                return BUILT_IN[tool](args), False
            except Exception as error:
                return f"{type(error).__name__}: {error}", True

        # the same tools/call either way, over the channel that server came in on
        call_args = {"name": tool, "arguments": args}
        result = (await self.stdio(where, "tools/call", call_args) if where in self.external
                  else await self.mcp(where, "tools/call", call_args))
        return result["content"][0]["text"], result.get("isError", False)

    async def say(self, text):
        # with include_partial_messages on, the text goes out a word at a time
        if self.options.include_partial_messages:
            for word in text.split(" "):
                delta = {"type": "text_delta", "text": word + " "}
                await self.send({"type": "stream_event", "uuid": f"ev_{next(self.ids)}",
                                 "session_id": self.session_id, "event": {
                                     "type": "content_block_delta", "index": 0,
                                     "delta": delta}})

        # the whole message still follows, so code written earlier keeps working
        await self.send(self.message("assistant", [{"type": "text", "text": text}]))

    async def finish(self, subtype, turns, text):
        ok = subtype == "success"
        result = {"type": "result", "subtype": subtype, "is_error": not ok,
                  "duration_ms": 0, "duration_api_ms": 0, "num_turns": turns,
                  "session_id": self.session_id, "total_cost_usd": 0.0,
                  "usage": {"input_tokens": len(" ".join(self.said).split()),
                            "output_tokens": len(text.split())}}

        # cost is zero and usage counts words, because no model was called
        result.update({"result": text} if ok else {"errors": [text]})
        await self.send(result)

    def message(self, kind, content, parent=None):
        body = {"role": kind, "content": content}
        if kind == "assistant":
            body.update(model="shop-desk", id=f"msg_{next(self.ids)}")
        return {"type": kind, "message": body, "parent_tool_use_id": parent,
                "session_id": self.session_id}


def describe(message):
    # the two messages with no content blocks are read from their own fields
    if isinstance(message, SystemMessage):
        return f"system     {message.subtype}"
    if isinstance(message, ResultMessage):
        return f"result     {message.subtype}: {message.result or message.errors}"

    # a type this course does not use prints its name rather than failing
    if not isinstance(message, (AssistantMessage, UserMessage)):
        return type(message).__name__
    return describe_block(message.content[0])


def describe_block(block):
    if isinstance(block, TextBlock):
        return f"assistant  {block.text}"
    if isinstance(block, ToolUseBlock):
        return f"assistant  calls {block.name} {block.input}"

    # a tool result sits on the user's side, and its error flag is worth seeing
    if isinstance(block, ToolResultBlock):
        flag = " (error)" if block.is_error else ""
        return f"user       result{flag}: {block.content}"
    return type(block).__name__


async def watch(prompt, options, model=None):
    """Run one question through a fresh desk and print every message that comes back."""
    async for message in query(prompt=prompt, options=options,
                               transport=ShopDesk(options, model)):
        print(describe(message))
shop_tools.py
from claude_agent_sdk import create_sdk_mcp_server, tool

ORDERS = {"A17": "Order A17 shipped on 3 March."}


# the description is what Claude reads to decide this is the right tool
@tool("lookup_order", "Look up an order's shipping status by its id, such as A17.",
      {"order_id": str})
async def lookup_order(args):
    order = args["order_id"]

    # an unknown id is answered in words, not raised, so Claude can pass it on
    status = ORDERS.get(order, f"No order {order} was found.")
    return {"content": [{"type": "text", "text": status}]}


shop = create_sdk_mcp_server("shop", tools=[lookup_order])
Example
from claude_agent_sdk import ClaudeAgentOptions
from shop_desk import ShopModel, watch

options = ClaudeAgentOptions(mcp_servers={"stock": stock},
                             allowed_tools=["mcp__stock__stock_level"])
asks = ShopModel(script=[{"calls": [("mcp__stock__stock_level", {"order_id": "A17"})]}])

await watch("How much stock of A17 is there?", options, model=asks)

The name follows the same rule as an in-process tool: the key in mcp_servers, then the tool's own name, so mcp__stock__stock_level. The answer came from a Python process that knows nothing about this one.

Wildcards in an allow rule

A server with thirty tools would be tedious to list. An allow rule takes a glob after a literal server prefix:

python
allowed = [
    "mcp__stock__*",       # every tool from the stock server
    "mcp__github__get_*",  # its read-only tools, by their shared prefix
]

The permissions page is strict about where the glob may sit: the server segment must be glob-free, so the rule names a server you configured. An unanchored entry such as "mcp__*" or "*" is ignored with a startup warning and approves nothing.

Credentials, and where they go

A server that reaches a real service needs a token, and the two transports take it in different places. Read it from the environment rather than writing it down:

python
import os

servers = {
    # stdio: the env dict is handed to the process the command starts
    "orders": {"command": "npx", "args": ["-y", "@your-org/orders-mcp"],
               "env": {"ORDERS_TOKEN": os.environ["ORDERS_TOKEN"]}},

    # http and sse: an Authorization header on every request
    "github": {"type": "http", "url": "https://api.githubcopilot.com/mcp/",
               "headers": {"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}"}},
}

The SDK runs no OAuth flow of its own. When a server answers an authorization challenge and there is no stored token, the MCP page says the run carries on without that server's tools and the server reports needs-auth. Finish the flow in your own application and pass the resulting token in headers.

Reading each server's status

A server can fail to start, and the run does not raise when it does. The init message carries one status per configured server, which is the thing to check before trusting an answer:

Example
import sys

from claude_agent_sdk import ClaudeAgentOptions, SystemMessage, query
from shop_desk import ShopDesk

# a command that exits at once stands in for a server that is not installed
dead = {"type": "stdio", "command": sys.executable, "args": ["-c", "raise SystemExit(1)"]}
options = ClaudeAgentOptions(mcp_servers={"stock": stock, "parts": dead})

async for message in query(prompt="Hello", options=options,
                           transport=ShopDesk(options)):
    if isinstance(message, SystemMessage):
        for server in message.data["mcp_servers"]:
            print(server["name"], server["status"], server.get("error", ""))

What a status means, and what it does not

  • connected means the handshake finished and the server's tools are in the list.
  • failed means it will not be usable. The error field says why, which here is the stand-in's own sentence about a server that answered nothing.
  • needs-auth means a token is missing or stale.
  • pending is not a failure on its own. It can mean the server has not connected yet, or that its tool list came from Claude Code's cache and the connection happens on first use, or that the deadline expired.
  • Later in the session a status can change, so the MCP page points at ClaudeSDKClient.get_mcp_status(), which returns the same list on demand and is typed as McpServerStatus.

The four server kinds side by side

In-processstdioHTTP or SSE
Configcreate_sdk_mcp_servercommand and argstype and url
CredentialsYour own process has themenvheaders
Delays the first turnUntil it lists its toolsUntil it connectsUntil it connects, unless the tool list is cached
Fails withAn exception in your codeA process that will not startA network or auth error

Where external servers come in

  • Something already written. A filesystem, a database, a ticket tracker: the server exists, and configuring it is an afternoon rather than a project.
  • A boundary you want. A server in its own process can run as another user, in another container, with its own credentials and its own limits.
Watch out. Settings files and plugins can add MCP servers of their own, so a session may carry servers you did not pass. Set strict_mcp_config=True to use only the servers in mcp_servers and ignore .mcp.json, user settings, plugin servers and claude.ai connectors.
Try it yourself
  • Add a second tool to stock_server.py and print the init message's tool list.
  • Change the dead server's args to ["-c", "import time; time.sleep(2)"] and watch what the status becomes.
  • Set strict_mcp_config=True and print the flag flags(options) builds.

Every expert started right here.