Skip to content
Journal

AI Integration · Engineering

Multi-Agent Orchestration with MCP: Patterns and SDK Code

Use multiple agents when one agent has too many tools or too much context. The core patterns, MCP as the tool layer, and real code for three agent SDKs.

Anurag Verma

Anurag Verma

11 min read · Updated Oct 19, 2026

Multi-Agent Orchestration with Claude Agent SDK and MCP

Sponsored

Share

A single agent with a dozen tools works fine until it doesn’t. It picks the code search tool when it should have opened a ticket, or it forgets the first half of a long task because its context filled with logs. Multi-agent orchestration is the fix for those specific problems, and it’s also an easy way to build a distributed system you can’t debug. This guide covers when the split is worth it, the patterns that hold up, MCP as the tool layer, and the actual primitives in three SDKs, since they’re not the same.

When multiple agents are worth it

Split work across agents when one agent has too many tools to choose from reliably, when unrelated material crowds its context, or when independent subtasks can run in parallel. Each specialist gets a narrow job, a small tool set and its own context. If none of those problems exists yet, one agent is simpler.

The LangChain multi-agent docs open with the same caution: not every complex task needs this, and a single agent with the right tools and prompt often gets similar results. They list three real reasons teams reach for multiple agents: context management, distributed development (separate teams owning separate capabilities), and parallelization.

There’s a third benefit people underrate: blast radius. A documentation agent that only has read tools can’t delete a namespace, however confused it gets.

Four orchestration patterns

PatternHow it worksGood for
RouterA classification step sends each request to one or more specialistsClear domains, such as billing vs. technical support
Orchestrator with subagents as toolsA main agent calls specialists like functions and keeps controlMulti-step tasks that need the results combined
HandoffsControl transfers to a specialist, which then talks to the userConversations that move fully into one domain
Parallel fan-outIndependent subtasks run at the same time, then mergeResearch across several sources, per-file review
                    ┌──────────────────────┐
                    │     User request     │
                    └──────────┬───────────┘
                               ▼
                    ┌──────────────────────┐
                    │     Orchestrator     │
                    │ classify, delegate,  │
                    │ combine results      │
                    └──┬────────┬────────┬─┘
                       ▼        ▼        ▼
                 ┌────────┐ ┌────────┐ ┌────────┐
                 │ Review │ │ Infra  │ │  Docs  │
                 │ agent  │ │ agent  │ │ agent  │
                 └───┬────┘ └───┬────┘ └───┬────┘
                     ▼          ▼          ▼
                 GitHub MCP   K8s MCP   Docs MCP

My default is the orchestrator with subagents as tools. The orchestrator keeps the thread of the task, which matters for anything like “review this PR, then deploy it if it passes,” where step two depends on step one. Pure handoffs are simpler but leave the user stuck in one specialist. Fan-out is right when subtasks are independent, and wrong when they aren’t, because merging conflicting outputs is harder than it looks.

Multi-agent system architecture showing orchestrator coordinating specialized worker agents Orchestrator-worker topology: the orchestrator routes tasks to specialized agents, each with their own tool set and context

MCP is the tool layer

The Model Context Protocol standardizes how an agent discovers and calls tools on a server. The current spec (revision 2026-07-28) defines two transports: stdio for a local process speaking newline-delimited JSON-RPC, and Streamable HTTP for remote servers. The older HTTP+SSE transport is deprecated; the OpenAI Agents SDK docs say its SSE client is only suitable for legacy servers.

The practical win is that tool code stops being framework code. A GitHub MCP server you write once attaches to an OpenAI agent, a Claude agent or a LangChain agent unchanged. That also means you can run each MCP server as its own service with its own credentials, scale it separately, and give each specialist only the servers it needs. Our MCP server guide covers building one, and the MCP security checklist covers locking it down. If tool definitions start eating your context, MCP tool pruning is the next fix.

The same ideas in three SDKs

The concepts transfer. The names and mechanics don’t, and blog posts that describe one SDK in another’s vocabulary cause real bugs. Here’s how each one does the job, from its own docs:

ConceptOpenAI Agents SDKClaude Agent SDKLangChain
Delegate and keep controlAgent.as_tool()Subagents via agents and AgentDefinition, invoked through the Agent toolWrap a subagent in a @tool function
Transfer controlhandoffs=[...] and handoff()No handoff primitiveHandoffs pattern via state changes
Validate or blockInput, output and tool guardrailsHooks (PreToolUse and others) and permissionsMiddleware (guardrails, call limits, PII detection)
Attach MCPmcp_servers=[...], or HostedMCPToolmcp_servers option (mcpServers in TypeScript)MCPAdapter
Hard limitsmax_turns, MaxTurnsExceededmax_turns, max_budget_usd, spawn depth and concurrency env varsLangGraph recursion_limit, GraphRecursionError

OpenAI Agents SDK

The SDK docs name three core primitives: agents, agents as tools or handoffs, and guardrails. Handoffs transfer control, and the receiving agent sees the conversation history by default:

from agents import Agent, handoff

billing_agent = Agent(name="Billing agent")
refund_agent = Agent(name="Refund agent")

triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refund_agent)])

For the orchestrator pattern, use as_tool so the caller stays in charge:

orchestrator_agent = Agent(
    name="orchestrator_agent",
    instructions=(
        "You are a translation agent. You use the tools given to you to translate. "
        "If asked for multiple translations, you call the relevant tools."
    ),
    tools=[
        spanish_agent.as_tool(
            tool_name="translate_to_spanish",
            tool_description="Translate the user's message to Spanish",
        ),
        french_agent.as_tool(
            tool_name="translate_to_french",
            tool_description="Translate the user's message to French",
        ),
    ],
)

Attach a Streamable HTTP MCP server per the MCP docs:

from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp

async with MCPServerStreamableHttp(
    name="Streamable HTTP Python Server",
    params={
        "url": "http://localhost:8000/mcp",
        "headers": {"Authorization": f"Bearer {token}"},
        "timeout": 10,
    },
    cache_tools_list=True,
    max_retry_attempts=3,
) as server:
    agent = Agent(
        name="Assistant",
        instructions="Use the MCP tools to answer the questions.",
        mcp_servers=[server],
    )
    result = await Runner.run(agent, "Add 7 and 22.")

Local MCP servers also accept require_approval, for example {"always": {"tool_names": ["delete_file"]}}, which pauses the run for a human. Guardrails are functions returning a GuardrailFunctionOutput; input guardrails run on the first agent in a chain, output guardrails on the agent that produces the final output, and tool guardrails wrap individual function-tool calls. A tripped input guardrail raises InputGuardrailTripwireTriggered. Tracing is on by default and records generations, tool calls, handoffs and guardrails, though the docs note it’s unavailable to organizations under Zero Data Retention.

Claude Agent SDK

The Claude Agent SDK is Claude Code packaged as a library, with built-in tools like Read, Grep, Bash and Edit. It has no handoffs or guardrails primitives. Delegation goes through subagents, which the subagents docs define programmatically:

import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, AgentDefinition


async def main():
    async for message in query(
        prompt="Review the authentication module for security issues",
        options=ClaudeAgentOptions(
            allowed_tools=["Read", "Grep", "Glob", "Agent"],
            agents={
                "code-reviewer": AgentDefinition(
                    description="Expert code review specialist. Use for quality, security, and maintainability reviews.",
                    prompt="You are a code review specialist. Identify security vulnerabilities and suggest specific fixes.",
                    tools=["Read", "Grep", "Glob"],  # read-only
                    model="sonnet",
                ),
                "test-runner": AgentDefinition(
                    description="Runs and analyzes test suites. Use for test execution and coverage analysis.",
                    prompt="You are a test execution specialist. Run tests and explain failures.",
                    tools=["Bash", "Read", "Grep"],
                ),
            },
        ),
    ):
        if hasattr(message, "result"):
            print(message.result)


asyncio.run(main())

The description is what the main agent reads when deciding whom to call, so write it like a routing rule. Each subagent starts with a fresh context, and only its final message returns to the parent, which is the context-isolation benefit in one sentence. Subagents can spawn their own subagents, up to three layers by default.

MCP servers go in the mcp_servers option, and their tools are named mcp__<server>__<tool>, per the MCP docs:

options = ClaudeAgentOptions(
    mcp_servers={
        "claude-code-docs": {
            "type": "http",
            "url": "https://code.claude.com/docs/mcp",
        }
    },
    allowed_tools=["mcp__claude-code-docs__*"],
)

Per-subagent MCP access goes on AgentDefinition.mcpServers (camelCase even in Python). For the guardrail job, use hooks. A PreToolUse hook sees each tool call before it runs and can deny it:

from claude_agent_sdk import ClaudeAgentOptions, HookMatcher

async def protect_env_files(input_data, tool_use_id, context):
    file_path = input_data["tool_input"].get("file_path", "")
    if file_path.split("/")[-1].lower() == ".env":
        return {
            "hookSpecificOutput": {
                "hookEventName": input_data["hook_event_name"],
                "permissionDecision": "deny",
                "permissionDecisionReason": "Cannot modify .env files",
            }
        }
    return {}

options = ClaudeAgentOptions(
    hooks={"PreToolUse": [HookMatcher(matcher="Write|Edit", hooks=[protect_env_files])]}
)

LangChain

LangChain’s subagents pattern is the orchestrator pattern with no special API, just a subagent wrapped in a tool:

from langchain.tools import tool
from langchain.agents import create_agent

subagent = create_agent(model="google_genai:gemini-3.6-flash", tools=[...])

@tool("research", description="Research a topic and return findings")
def call_research_agent(query: str):
    result = subagent.invoke({"messages": [{"role": "user", "content": query}]})
    return result["messages"][-1].content

main_agent = create_agent(model="google_genai:gemini-3.6-flash", tools=[call_research_agent])

Handoffs in LangChain are a pattern rather than a primitive: tool calls update a state variable that switches the active agent or its tools and prompt. For deterministic steps mixed with agent steps, the docs point to custom LangGraph workflows. MCP tools load through MCPAdapter:

from langchain.agents import create_agent
from langchain.mcp import MCPAdapter

async def main():
    async with MCPAdapter("https://example.com/mcp") as adapter:
        tools = await adapter.list_tools()
        agent = create_agent("claude-sonnet-5", tools)
        return await agent.ainvoke({"messages": [{"role": "user", "content": "..."}]})

Our LangChain MCPAdapter tutorial goes further. Hosted harnesses are a fourth option. OpenAI’s Agents API, for instance, has a multi_agent setting with a cap on concurrent subagents, covered in our Agents API guide.

Error handling that survives production

Agents fail in ways that look like success. They run the wrong plan confidently, or call a tool with plausible but wrong arguments. Three layers help, whatever SDK you use.

Tools return structured errors

Validate inputs inside the tool and return an error the model can act on, instead of throwing. This is plain Python and works as the body of an MCP tool or a function tool:

import json
import re

PROTECTED_NAMESPACES = {"kube-system", "production"}

def provision_namespace(name: str, cpu_limit: str, memory_limit: str) -> str:
    """Provision a Kubernetes namespace with resource limits. Idempotent."""
    if not re.match(r"^[a-z][a-z0-9-]{2,62}$", name):
        return json.dumps({
            "status": "error",
            "message": f"Invalid namespace '{name}'. Use 3-63 lowercase letters, digits or hyphens.",
        })
    if name in PROTECTED_NAMESPACES:
        return json.dumps({"status": "error", "message": f"'{name}' is protected."})
    if k8s.namespace_exists(name):  # safe to retry
        return json.dumps({"status": "ok", "namespace": name, "created": False})
    k8s.create_namespace(name, cpu_limit, memory_limit)
    return json.dumps({"status": "ok", "namespace": name, "created": True})

Make every mutating tool idempotent. Agents retry, and a retry that creates a second resource is worse than a failure.

A policy layer blocks dangerous actions

Instructions alone don’t stop an agent from running DROP TABLE. Put the rule in code: an OpenAI tool guardrail or MCP require_approval, a Claude PreToolUse hook, or LangChain middleware or a check in your LangGraph node. Route anything that touches production through a human approval step.

The runner enforces hard limits

Cap turns, spend and depth. The OpenAI Agents SDK raises MaxTurnsExceeded when a run passes max_turns, and error_handlers can turn that into a controlled final output. The Claude Agent SDK’s Python options include max_turns and max_budget_usd, and the subagents docs add CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH (default 3) and CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS (default 20). In LangGraph, recursion_limit caps super-steps per run (the default is 1000 since version 1.0.6) and raises GraphRecursionError. Durable work that outlives a request belongs in a job system; our BullMQ, Inngest and Temporal comparison covers that layer.

Observability

You can’t run this without traces. At minimum, record which agent handled each step, every tool call with arguments and result status, turn counts, and tokens per task type. The OpenAI Agents SDK traces runs by default and lets you group them with trace(workflow_name=..., group_id=...). With the Claude Agent SDK, PreToolUse and PostToolUse hooks are the natural place to log and audit tool calls. Whichever you use, watch for agents bouncing a task back and forth. It usually means two descriptions overlap.

Monitoring dashboard showing agent execution traces and error rates Trace every agent turn, tool call and delegation so failures can be traced back to a cause

Design rules worth keeping

  • Start with two agents, an orchestrator and one specialist. Add the next only when the first pair is reliable.
  • Write tool and subagent descriptions as routing rules. The model chooses based on them, so say when not to use each one.
  • Keep instructions short. Long prompts create ambiguity faster than they remove it.
  • Give each specialist the smallest tool set that does the job, and read-only access wherever possible.
  • Use a cheaper model or lower effort for routing and simple workers. Anthropic’s effort docs, for example, suggest low effort for subagents. Measure before you assume quality holds.
  • Test with bad inputs: ambiguous requests, requests that span domains, and requests that should be refused.

For a broader comparison of agent frameworks, see LangGraph vs CrewAI vs AutoGen. If you’re scoping a multi-agent system and want a second opinion on the architecture, reach out to us.

Frequently asked questions

Why use multiple agents instead of one agent with many tools?
A single agent with a large tool set has more chances to pick the wrong tool, fills its context with material unrelated to the current step, and gives one bad decision access to every system. Specialized agents with small tool sets limit all three. If none of those problems shows up, stay with one agent.
What does MCP do in a multi-agent system?
The Model Context Protocol standardizes how an agent discovers and calls tools on an external server. You build an MCP server once and attach it to whichever agent framework you use. The current specification defines stdio for local processes and Streamable HTTP for remote servers.
Does the Claude Agent SDK have handoffs and guardrails?
Not under those names. Handoffs and guardrails are primitives of the OpenAI Agents SDK. The Claude Agent SDK delegates to subagents through its Agent tool, where only the subagent's final message returns to the parent, and it blocks or modifies actions with hooks such as PreToolUse plus permission settings.
What is the difference between a handoff and calling an agent as a tool?
In the OpenAI Agents SDK, a handoff transfers the conversation to another agent, which then owns the reply. Agent.as_tool keeps the calling agent in control: the specialist runs, returns a result, and the caller decides what to do next. Use as_tool when an orchestrator needs to combine results from several specialists.
How do I stop a multi-agent system from looping?
Set hard limits in the runner. The OpenAI Agents SDK raises MaxTurnsExceeded when a run passes max_turns. The Claude Agent SDK has max_turns and max_budget_usd options plus environment variables that cap subagent nesting depth and concurrency. Keep handoff graphs acyclic where you can.

Sources

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored