An in-process MCP server
An in-process MCP server is a Model Context Protocol server that create_sdk_mcp_server runs inside your own program, with no extra process. It is how Claude Code reaches the tools you wrote.
Last updated: 20 Oct, 2026 · Claude Agent SDK
The tool from the @tool lesson is a Python object in your program. Claude Code is a separate program. The SDK connects the two: it hosts an MCP server in your process and relays Claude Code's MCP messages to it over the control channel the stand-in's transport opens when a session starts.
A server for the shop
shop = create_sdk_mcp_server("shop", tools=[lookup_order])You register the server under a name in mcp_servers. The MCP page gives the naming rule: a tool's full name is mcp__{server}__{tool}, so this one is mcp__shop__lookup_order. A dot or a space in the server name is replaced by an underscore, so a key of my.server gives mcp__my_server__lookup_order.
Pick one to watch it run, step by step.
The rest of this lesson builds the left half of that drawing: the part of the desk that sends those messages. Six methods are added to shop_desk.py, ask, mcp, find_tools, use, run and start, its turn is replaced with one that runs a loop, and ShopModel.reply takes a third argument, the tools it can see.
Asking the SDK to carry one MCP message
Until now only the SDK asked questions. Claude Code also asks the SDK: to run an MCP message, to check a permission, to call a hook. The desk needs a way to ask one and wait for the answer, which the control protocol matches up by request id.
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]The answer arrives on the same channel the SDK's own messages come in on, so listen gains a branch that hands a control_response to whoever is waiting for it. mcp then uses ask for one MCP message:
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"]mcp sends one JSON-RPC message to a named server. The SDK's side of this is real: it passes the message to your server through the mcp library and sends back what the server answers.
Finding out which tools there are
async def find_tools(self):
tools = {}
for server in self.options.mcp_servers:
# MCP wants a handshake before a server will list anything
await self.mcp(server, "initialize", {"protocolVersion": "2025-06-18",
"capabilities": {}, "clientInfo": {"name": "desk", "version": "1"}})
for tool in (await self.mcp(server, "tools/list", {}))["tools"]:
tools[f"mcp__{server}__{tool['name']}"] = (server, tool["name"])
return toolsfind_tools does what Claude Code does with each server: an MCP initialize, then tools/list, keeping each tool under its full name.
Making one tool call
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):
# the full name carries the server it came from, so the call goes back there
server, tool = self.tools[name]
result = await self.mcp(server, "tools/call", {"name": tool, "arguments": args})
return result["content"][0]["text"], result.get("isError", False)use sends the tool request as an assistant message, runs it, and sends the result back as a user message, the pair from the messages lesson. run is an MCP tools/call, sent to the server the full name came from.
The turn becomes a loop
async def start(self, prompt):
self.said.append(prompt)
# the tool list is fetched once per desk, not once per question
if not self.tools:
self.tools = await self.find_tools()
await self.send({"type": "system", "subtype": "init", "model": "shop-desk",
"session_id": self.session_id, "tools": list(self.tools)}) 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"])start opens the turn: it fetches the tool list once per desk, not once per question, and sends the init message with the names in it. turn is then the agent loop in plain Python: call the model until it answers with text instead of a tool call.
The stand-in this part runs against
Those methods belong to one file. Here it is in full, as every runnable example from here to the end of this part uses it: the scripted model, the transport that stands in for the Claude Code binary, and a small watch helper that prints each message a run produces. Save it as shop_desk.py beside your lesson files.
import asyncio
import itertools
import json
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)
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()
# a counted id, not a UUID, so the printed output is the same every run
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 = {}
async def connect(self):
self.ready = TrueNothing in it calls a model or starts a process, which is why every example in this course runs with no API key and costs nothing.
View the code here
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])
The desk with a tool, on two orders
from claude_agent_sdk import ClaudeAgentOptions
from shop_desk import watch
from shop_tools import shop
options = ClaudeAgentOptions(mcp_servers={"shop": shop})
await watch("Where are my orders A17 and B22?", options)system init
assistant calls mcp__shop__lookup_order {'order_id': 'A17'}
user result: Order A17 shipped on 3 March.
assistant calls mcp__shop__lookup_order {'order_id': 'B22'}
user result: No order B22 was found.
assistant Order A17 shipped on 3 March. No order B22 was found.
result success: Order A17 shipped on 3 March. No order B22 was found.What the two round trips show
- The init message carries the tool names this session has, which is where
mcp__shop__lookup_orderfirst appears. - Two round trips, one per order: a tool request, then its result, each pair sharing one
tool_use_id. - The final answer joins both results. Every call went from the desk, through the SDK, to
lookup_orderin the same process, and back.
In-process against a separate process
| In-process server | A server you run | |
|---|---|---|
| Written with | create_sdk_mcp_server | Any MCP server, in any language |
| Runs in | Your Python process | Its own process, or a remote host |
| Config | mcp_servers={"shop": shop} | A command or a URL, as the external servers lesson shows |
| Shares | Your variables, connections and imports | Nothing; it talks over a transport |
@tool function later changes nothing until the server and the options are built again.Related
- Previous: Custom tools with @tool
- Next: A tool that raises
- Reference: the custom tools page and the MCP page
- Print
desk.toolsafter a run to see the full names and where each one lives. - Register the server as
{"orders": shop}and check the tool's new name. - Ask a question with no order id in it and count the messages.
You understood something today that you didn't yesterday.