Skip to content

MCP — Model Context Protocol

Build a tool once — issue a refund, roll out a deploy, enrich an IOC — and let any agent call it. MCP is the wire for that: wrap a function like lookup_ioc or isolate_host in an MCP server, and any MCP client (Claude Desktop, Cline, a Tulip agent, your own tooling) invokes it without bespoke glue. The same SDK also consumes existing MCP servers — a threat-intel feed, a billing service, a case-management server — so your agent can reach tools it didn't ship.

The SDK speaks MCP both ways. Most agent frameworks consume MCP servers but don't expose their own. Round-trip means a Tulip-built triage agent can be either side: pull enrichment from a TI server and serve its own containment tools back to the analyst's desktop.

When to use MCP

You want… Use MCP
Your SDK agent to use an external threat-intel / case-management MCP server ✓ — MCPClient
Your lookup_ioc / isolate_host library callable by Claude Desktop / Cline / other agents ✓ — TulipMCPServer
Two SDK agents to share containment tools across processes / machines ✓ — works, but A2A is the better protocol
In-process multi-agent — share tools by importing use the tools directly, not MCP
Reproducible tests use a mock model + plain @tool — MCP adds I/O

Getting started — consume an MCP server

1. Install the MCP extras

pip install "tulip-agents[mcp]"

2. Spawn the server and wrap it with MCPClient

from tulip.integrations.fastmcp import MCPClient

# Point MCPClient at a threat-intel MCP server launched over stdio:
ti = MCPClient(server_command=["python", "-m", "ti_feed.mcp_server"])
await ti.connect()

MCPClient(server_command=[...]) describes a stdio MCP server; await ti.connect() spawns the subprocess, opens an MCP session over its stdin/stdout, and prepares tool discovery. (For an HTTP server, pass base_url= instead of server_command=.)

3. Pass the tools straight into an Agent

from tulip.agent import Agent

# Discover the server's tools and convert them to SDK tools:
mcp_tools = ti.to_tulip_tools(await ti.list_tools())

agent = Agent(
    model="anthropic:claude-sonnet-4-6",
    tools=[*mcp_tools],           # MCP tools become SDK tools
    system_prompt="Triage the alert. Enrich every indicator before you act.",
)
result = agent.run_sync("Is 198.51.100.23 a known C2 endpoint?")

await ti.list_tools() returns the server's tool descriptors; passing them through ti.to_tulip_tools(...) produces SDK Tool objects with full schemas, descriptions, and call-through plumbing. The agent doesn't know they're MCP — they look like any other @tool.

Side effects in the host process — use hooks, not wrappers

A common shape for MCP integrators: the real effect of a containment call lives in the host process (an incident audit log, a ticketing batch, a SOC console command stream), not inside the tool body that returns a string to the model. The instinct is to wrap each MCP tool with a per-tool @tool that calls _audit_log().append(...) before returning.

Don't. Use a single HookProvider instead — one audit trail over every tool, so a post-incident review can replay exactly what the agent did:

from tulip.hooks.provider import HookPriority, HookProvider

class MCPAuditTrailHook(HookProvider):
    """Mirror every tool call into an incident audit log, keyed by call id."""

    priority = HookPriority.BUSINESS_DEFAULT

    def __init__(self, audit_log: list[dict]) -> None:
        self._audit_log = audit_log

    async def on_after_tool_call(self, event):
        if event.error is None:
            self._audit_log.append({
                "id": event.tool_call_id,
                "tool": event.tool_name,
                "args": event.arguments,
                "result": event.result,
            })

agent = Agent(
    model=...,
    # every MCP-sourced TI/EDR tool, untouched
    tools=[*mcp_client.to_tulip_tools(await mcp_client.list_tools())],
    hooks=[MCPAuditTrailHook(audit_log)],
)

One hook covers every MCP-sourced tool. The tool_call_id correlates with the model's tool_calls[].id, so parallel enrichments don't get mixed up. See hooks for the full event surface.

Getting started — expose your tools as MCP

1. Wrap an agent in TulipMCPServer

TulipMCPServer exposes a Tulip agent (and the tools registered on it) over MCP. Build the agent with the tools you want to publish:

from tulip.agent import Agent
from tulip.integrations.fastmcp import TulipMCPServer

agent = Agent(model="anthropic:claude-sonnet-4-6", tools=[lookup_ioc, isolate_host])
server = TulipMCPServer(agent=agent, name="soc-tools")

2. Pick a transport

server.run(transport="stdio")         # for desktop clients (default)
server.run(transport="http")          # for HTTP MCP clients

run(transport="stdio") is what Claude Desktop, Cline, and most MCP clients expect. run(transport="http") runs an HTTP MCP server (transport + JSON-RPC) that any HTTP MCP client can reach. The supported transports are "stdio", "http", "sse", and "streamable-http".

3. Point a client at it

For Claude Desktop, edit ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "soc-tools": {
      "command": "python",
      "args": ["-m", "soc_tools.mcp_server"]
    }
  }
}

Restart Claude Desktop. Your lookup_ioc and isolate_host tools appear in the model's tool list.

What you get out of the box

Schema preservation

@tool's docstring + type hints become the MCP tool's name, description, and JSON schema — losslessly. The MCP client sees the same parameter types, defaults, and descriptions an SDK agent would.

Both transports

Transport Use case
stdio — process pipes Desktop clients (Claude Desktop, Cline). The MCP server is spawned as a subprocess.
HTTP — JSON-RPC over POST Networked clients. Good for a shared containment-tool server the whole SOC reaches.

Idempotency is per-run, not cross-client

The idempotent=True dedup is a property of a single agent's ReAct loop — it suppresses a repeated call within one run (see idempotency). It does not carry across MCP: tools the SDK consumes from a remote MCP server are wrapped with idempotent=False, and the MCP server wrapper invokes each tool directly, so two separate clients firing the same call will each run it. If you need exactly-once across clients, enforce it in the tool body (e.g. an idempotency key checked against your own store).

Round-trip example

A common SOC shape: a triage agent A consumes an external threat-intel MCP server, and exposes its own containment tools as MCP for an incident-response agent B to consume:

# Agent A — consumes threat intel, exposes its own containment tools
ti = MCPClient(server_command=[...])     # consumer side
await ti.connect()

agent_a = Agent(
    model="anthropic:claude-sonnet-4-6",
    tools=[*ti.to_tulip_tools(await ti.list_tools()), lookup_ioc, isolate_host],
)

# Producer side — publish agent_a's own tools back over MCP.
# server.run(transport="http") blocks, so run it on its own thread/process.
containment = TulipMCPServer(agent=agent_a, name="containment")
containment.run(transport="http")

Same MCPClient API on the consumer side, same TulipMCPServer on the producer side, same tool definitions. The transport is an implementation detail.

Common gotchas

Symptom Likely cause
MCP server failed to start The MCP server subprocess crashed before establishing the session. Run the command manually to see the error.
Tool 'X' not found in MCP discovery The server exposes a different name than you expected. Print [t["name"] for t in await ti.list_tools()] to see the actual list.
Schema validation failed on call MCP tool returned an arg type that doesn't match its declared schema. Common with hand-written MCP servers; the standard ones are fine.
Claude Desktop doesn't show your SDK tools claude_desktop_config.json not picked up — check the file lives at the right path and Claude has been restarted.
Hangs on await ti.connect() The MCP subprocess is waiting for input on stdin (some servers expect a handshake) or never finished its init. Run the server_command manually to see what it prints, and wrap connect() in asyncio.wait_for(..., timeout=...).

Source and notebook

See also

  • Tools — the @tool decorator MCP wraps.
  • A2A — purpose-built protocol for cross-process SDK-to-SDK agent meshes.