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 →

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

python
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.

Where a custom tool call goes
Your processShopDeskasks for a toolThe Agent SDKroutes mcp_messageshop servercreated in your processlookup_orderyour async function
Hover or tap a piece to see what it is and which lesson built it.
Follow a tool call

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.

python
    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:

python
    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

python
    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 tools

find_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

python
    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
python
    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

python
    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)})
python
    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 = True

Nothing 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.

Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
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])

The desk with a tool, on two orders

Example
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)

What the two round trips show

  • The init message carries the tool names this session has, which is where mcp__shop__lookup_order first 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_order in the same process, and back.

In-process against a separate process

In-process serverA server you run
Written withcreate_sdk_mcp_serverAny MCP server, in any language
Runs inYour Python processIts own process, or a remote host
Configmcp_servers={"shop": shop}A command or a URL, as the external servers lesson shows
SharesYour variables, connections and importsNothing; it talks over a transport
Watch out. The server holds the tool objects it was given when it was created. Redefining a @tool function later changes nothing until the server and the options are built again.
Try it yourself
  • Print desk.tools after 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.