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:
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:
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}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."}]}# 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:
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 NoneA 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.
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 statuslist_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.
# one running process per stdio server, and a status line per configured server
self.external, self.servers = {}, [] 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:
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.
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:
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"]}View the code here
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))
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])
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)system init
assistant calls mcp__stock__stock_level {'order_id': 'A17'}
user result: 4 of order A17 in stock.
assistant 4 of order A17 in stock.
result success: 4 of order A17 in stock.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:
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:
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:
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", ""))stock connected parts failed the server gave no answer to initialize
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
errorfield 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 asMcpServerStatus.
The four server kinds side by side
| In-process | stdio | HTTP or SSE | |
|---|---|---|---|
| Config | create_sdk_mcp_server | command and args | type and url |
| Credentials | Your own process has them | env | headers |
| Delays the first turn | Until it lists its tools | Until it connects | Until it connects, unless the tool list is cached |
| Fails with | An exception in your code | A process that will not start | A 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.
strict_mcp_config=True to use only the servers in mcp_servers and ignore .mcp.json, user settings, plugin servers and claude.ai connectors.Related
- Previous: Which tools Claude can see
- Next: Tool search for large tool sets
- Reference: the MCP page and McpServerConfig in the Python reference
- Add a second tool to
stock_server.pyand 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=Trueand print the flagflags(options)builds.
Every expert started right here.