Technology Apr 27, 2026 · 12 min read

Deep Dive into Open Agent SDK (Part 3): MCP Integration in Practice

The previous article looked at the SDK's 34 built-in tools — file read/write, Bash execution, code search — covering common development scenarios. But an Agent's capabilities can't rely solely on built-in tools. You need to connect to databases, call enterprise APIs, and operate internal systems. Th...

DE
DEV Community
by NEE
Deep Dive into Open Agent SDK (Part 3): MCP Integration in Practice

The previous article looked at the SDK's 34 built-in tools — file read/write, Bash execution, code search — covering common development scenarios. But an Agent's capabilities can't rely solely on built-in tools. You need to connect to databases, call enterprise APIs, and operate internal systems. These require a standardized integration approach.

MCP (Model Context Protocol) is designed for exactly this. This article examines how Open Agent SDK uses the MCP protocol to bring external tools into the Agent Loop.

What Is the MCP Protocol

MCP is an open protocol proposed by Anthropic that defines communication standards between LLM applications and external tools/data sources. The concept:

  • Tool side (MCP Server) exposes a set of tools, each with a name, description, and input schema
  • Calling side (MCP Client) discovers tools, invokes them, and gets results through a standard protocol
  • Communication is based on JSON-RPC with swappable transports

Why does an Agent need this? Because it's impossible to build all tools into the SDK. With MCP, anyone can write an MCP Server (e.g., @modelcontextprotocol/server-filesystem), and any Agent can connect — no SDK code changes needed, no adapter to write, just one line of configuration.

Open Agent SDK's MCP integration has two paths:

  1. External MCP servers: Connect to third-party MCP Servers via stdio/HTTP/SSE, running the full MCP protocol
  2. In-process MCP servers: Use InProcessMCPServer to wrap SDK tools as an MCP Server with zero protocol overhead

Let's examine each.

Five Transport Configurations

The SDK uses the McpServerConfig enum to unify all transport methods:

public enum McpServerConfig: Sendable, Equatable {
    case stdio(McpStdioConfig)       // Child process stdin/stdout
    case sse(McpTransportConfig)     // Server-Sent Events
    case http(McpTransportConfig)    // HTTP POST
    case sdk(McpSdkServerConfig)     // In-process, zero overhead
    case claudeAIProxy(McpClaudeAIProxyConfig) // ClaudeAI proxy
}

Stdio: Launching Child Processes

The most common approach. The Agent launches a child process and exchanges JSON-RPC messages via stdin/stdout. Suitable for MCP Servers written in Node.js/Python:

let servers: [String: McpServerConfig] = [
    "filesystem": .stdio(McpStdioConfig(
        command: "npx",
        args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
    )),
    "git": .stdio(McpStdioConfig(
        command: "uvx",
        args: ["mcp-server-git"],
        env: ["GIT_REPO_PATH": "/my/repo"]
    ))
]

MCPStdioTransport internally uses Foundation's Process to launch child processes and FileDescriptor for low-level I/O. A few details:

  • Command resolution: If the command isn't an absolute path, it's looked up via which first. Falls back to treating it as a file path if not found
  • Message delimiting: Each JSON-RPC message is delimited by newlines, with CRLF support
  • Security filtering: CODEANY_API_KEY is not passed to child processes by default unless explicitly specified in env
  • Reconnection: MCPClient is configured with up to 2 automatic retries, initial interval 1 second, exponential backoff up to max 10 seconds

SSE and HTTP: Connecting to Remote Services

Remote MCP Servers connect via HTTP, in two modes:

// SSE mode (long connection, server push)
let sseServer: [String: McpServerConfig] = [
    "remote-tools": .sse(McpTransportConfig(
        url: "https://mcp.example.com/sse",
        headers: ["Authorization": "Bearer token123"]
    ))
]

// HTTP mode (request-response)
let httpServer: [String: McpServerConfig] = [
    "api-tools": .http(McpTransportConfig(
        url: "https://mcp.example.com/api"
    ))
]

SSE is for scenarios requiring server push; HTTP for simple request-response. Both use HTTPClientTransport internally, differing in the streaming parameter. McpSseConfig and McpHttpConfig are actually type aliases for McpTransportConfig:

public typealias McpSseConfig = McpTransportConfig
public typealias McpHttpConfig = McpTransportConfig

SDK: In-Process Zero Overhead

No network protocol at all — tools are registered directly in-process. Covered in detail in section six below.

ClaudeAI Proxy

Connects to ClaudeAI's proxy endpoint, using server ID for authentication:

let proxyServer: [String: McpServerConfig] = [
    "claude-tools": .claudeAIProxy(McpClaudeAIProxyConfig(
        url: "https://claudeai.example.com/proxy",
        id: "server-abc-123"
    ))
]

Internally, this is just HTTP transport with an added X-ClaudeAI-Server-ID header.

Connection Flow: From Configuration to Tool Pool

How does the Agent merge MCP tools into its own tool pool? Tracing from assembleFullToolPool():

func assembleFullToolPool() async -> ([ToolProtocol], MCPClientManager?) {
    let baseTools = options.tools ?? []

    guard let mcpServers = options.mcpServers, !mcpServers.isEmpty else {
        return (baseTools, nil)
    }

    // Step 1: Separate SDK configs from external configs
    let (sdkTools, externalServers) = await Self.processMcpConfigs(mcpServers)

    // Step 2: Connect to external MCP servers
    var externalTools: [ToolProtocol] = []
    var manager: MCPClientManager? = nil

    if !externalServers.isEmpty {
        let mcpManager = MCPClientManager()
        await mcpManager.connectAll(servers: externalServers)
        externalTools = await mcpManager.getMCPTools()
        manager = mcpManager
    }

    // Step 3: Merge all tools
    let allMCPTools = sdkTools + externalTools
    let pool = assembleToolPool(
        baseTools: getAllBaseTools(tier: .core) + getAllBaseTools(tier: .specialist),
        customTools: baseTools,
        mcpTools: allMCPTools,
        allowed: options.allowedTools,
        disallowed: options.disallowedTools
    )

    return (pool, manager)
}

Three steps:

1. Separate configurations. processMcpConfigs() splits .sdk configs from external configs (stdio/sse/http). SDK configs directly extract tools from InProcessMCPServer, adding namespace prefixes via SdkToolWrapper; external configs are left for MCPClientManager.

2. Connect to external servers. MCPClientManager is an actor that uses withTaskGroup to connect to all servers concurrently. Each connection goes through four steps:

Create Transport → Start Connection → MCP Handshake (initialize) → listTools() to discover tools

Discovered tools are wrapped as MCPToolDefinition — a struct conforming to ToolProtocol. Tool names follow the mcp__{serverName}__{toolName} format to avoid conflicts with built-in tools. For example, the read_file tool on the filesystem server becomes mcp__filesystem__read_file.

3. Assemble tool pool. MCP tools merge with built-in and custom tools, pass through allowedTools/disallowedTools filtering, and form the final tool pool. The LLM sees the filtered complete tool list.

Complete end-to-end usage code:

let agent = createAgent(options: AgentOptions(
    apiKey: "sk-...",
    model: "claude-sonnet-4-6",
    permissionMode: .bypassPermissions,
    mcpServers: [
        "filesystem": .stdio(McpStdioConfig(
            command: "npx",
            args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
        ))
    ]
))

// Agent Loop auto-connects MCP servers, discovers tools, merges into tool pool on startup
let result = await agent.prompt("List all files in /tmp and read the first one")

Runtime Management

MCP servers aren't just connect-and-forget. During runtime, you may need to check status, reconnect, toggle, or even dynamically swap server sets. The SDK provides four methods.

Check Status: mcpServerStatus()

let status = await agent.mcpServerStatus()
for (name, info) in status {
    print("\(name): \(info.status.rawValue)")  // connected / failed / pending / disabled / needsAuth
    print("  tools: \(info.tools)")             // ["read_file", "write_file", ...]
    if let error = info.error {
        print("  error: \(error)")
    }
}

McpServerStatus has five states (aligned with the TypeScript SDK):

State Meaning
connected Connected, tools available
failed Connection failed
pending Connecting
disabled Disabled by user
needsAuth Requires authentication

Reconnect: reconnectMcpServer()

Manually reconnect a server after network issues or server restarts:

try await agent.reconnectMcpServer(name: "filesystem")

Implementation: disconnect old connection → clean up state → re-run the connection flow with the initial config. MCPClientManager saves the original config at first connection (originalConfigs), using it directly for reconnection.

Toggle: toggleMcpServer()

Temporarily disable a server (disconnect but keep config), can be re-enabled later:

// Disable
try await agent.toggleMcpServer(name: "filesystem", enabled: false)

// Re-enable
try await agent.toggleMcpServer(name: "filesystem", enabled: true)

Dynamic Replacement: setMcpServers()

Replace the entire MCP server set at runtime. The SDK diffs: new ones get connected, removed ones get disconnected, changed ones get reconnected:

let result = try await agent.setMcpServers([
    "filesystem": .stdio(McpStdioConfig(
        command: "npx",
        args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
    )),
    "database": .stdio(McpStdioConfig(
        command: "python3",
        args: ["-m", "my_db_server"]
    ))
])

print("Added: \(result.added)")      // ["database"]
print("Removed: \(result.removed)")  // Previously existing but now absent
print("Errors: \(result.errors)")    // Failed connections

MCPClientManager.setServers() diff logic:

public func setServers(_ servers: [String: McpServerConfig]) async -> McpServerUpdateResult {
    let existingNames = Set(originalConfigs.keys)
    let newNames = Set(servers.keys)

    let addedNames = newNames.subtracting(existingNames)
    let removedNames = existingNames.subtracting(newNames)

    // Changed configs are treated as remove + add
    let changedNames = newNames.intersection(existingNames).filter { name in
        originalConfigs[name] != servers[name]
    }

    let effectiveAdded = addedNames.union(changedNames)
    // ...execute connections and disconnections
}

Removes unneeded servers first, then connects new and changed ones. Changed servers are completely rebuilt, not hot-updated. This matters for long-running Agent applications — you can adjust MCP configuration without restarting the Agent.

MCP Resources: Beyond Tools

The MCP protocol includes resources in addition to tools. Tools "do things"; resources "read data" — e.g., a database MCP Server can expose a query tool alongside a tables resource letting the Agent see which tables exist.

The SDK has two resource-related tools built in: ListMcpResources and ReadMcpResource.

ListMcpResources

Lists available resources from all connected MCP servers:

// Tool description as seen by LLM:
// "List available resources from connected MCP servers.
//  Resources can include files, databases, and other data sources."

// Optional parameter: server — filter by server name

Internal implementation queries each connection via the MCPResourceProvider protocol:

public protocol MCPResourceProvider: Sendable {
    func listResources() async -> [MCPResourceItem]?
    func readResource(uri: String) async throws -> MCPReadResult
}

Resources are represented as MCPResourceItem — with name, description, and URI.

ReadMcpResource

Reads the content of a specified URI resource:

// LLM sees the tool:
// "Read a specific resource from an MCP server."
// Parameters: server (server name), uri (resource URI)

Both tools are read-only, accessing connection info through ToolContext.mcpConnections — no global variables, thread-safe.

In-Process MCP: InProcessMCPServer

InProcessMCPServer is a unique design in the SDK. It lets you create tools with defineTool(), then wrap them as an MCP Server — but without actually running the MCP protocol.

Why? Because sometimes you just want to add your own tools to the Agent's tool pool without cross-process communication. Calling a function directly is far more efficient than going through JSON-RPC serialization.

Basic Usage

// Create tool with defineTool
struct WeatherInput: Codable {
    let city: String
}

let weatherTool = defineTool(
    name: "get_weather",
    description: "Get the current weather for a given city.",
    inputSchema: [
        "type": "object",
        "properties": [
            "city": ["type": "string", "description": "The city name"]
        ],
        "required": ["city"]
    ],
    isReadOnly: true
) { (input: WeatherInput, context: ToolContext) -> String in
    let data: [String: String] = [
        "Beijing": "Sunny, 22C",
        "Tokyo": "Cloudy, 18C",
    ]
    return data[input.city] ?? "No data for \(input.city)"
}

// Wrap as InProcessMCPServer
let server = InProcessMCPServer(
    name: "weather",       // Tool name will be mcp__weather__get_weather
    version: "1.0.0",
    tools: [weatherTool],
    cwd: "/tmp"
)

// Generate config via asConfig(), inject into Agent
let agent = createAgent(options: AgentOptions(
    apiKey: "sk-...",
    model: "claude-sonnet-4-6",
    mcpServers: ["weather": await server.asConfig()]
))

Internal Implementation

InProcessMCPServer is an actor with two working modes:

SDK internal mode (common): When processMcpConfigs() detects a .sdk config, it calls server.getTools() directly and adds namespace prefixes via SdkToolWrapper. Throughout this process, the tool's call() method is invoked directly with zero serialization overhead:

private struct SdkToolWrapper: ToolProtocol, Sendable {
    let serverName: String
    let innerTool: ToolProtocol

    var name: String { "mcp__\(serverName)__\(innerTool.name)" }

    func call(input: Any, context: ToolContext) async -> ToolResult {
        return await innerTool.call(input: input, context: context)
    }
}

Note that SdkToolWrapper's call() directly forwards to innerTool — no JSON-RPC, no Value conversion, just a direct function call.

External client mode: If an external MCP Client wants to connect in, createSession() creates an InMemoryTransport pair, running the full MCP handshake. Protocol overhead exists only in this scenario:

public func createSession() async throws -> (Server, InMemoryTransport) {
    let mcpServer = await getOrCreateMCPServer()
    let session = await mcpServer.createSession()
    let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
    try await session.start(transport: serverTransport)
    return (session, clientTransport)
}

InProcessMCPServer internally maintains an MCPServer instance (lazy-loaded). When registering tools, each ToolProtocol's call() is wrapped as an MCP handler closure — handling parameter format conversion ([String: Value] to [String: Any]), building ToolContext, and processing error results.

Caveats

  • Naming restriction: Server name cannot contain __ (double underscore), as it would conflict with the namespace prefix mcp__{server}__{tool}. A precondition check exists in the constructor.
  • Error handling: When a tool returns isError: true, the MCP layer throws ToolExecutionError, causing the MCP protocol to return isError: true.
  • Tool registration failure: Triggers assertionFailure, indicating a code bug (e.g., duplicate tool names).

Complete Example: Multi-Tool MCP Server

This is the core of the AdvancedMCPExample, demonstrating multi-tool registration and error handling:

// Weather tool — returns String
let weatherTool = defineTool(
    name: "get_weather",
    description: "Get the current weather for a given city.",
    inputSchema: [
        "type": "object",
        "properties": [
            "city": ["type": "string", "description": "The city name"]
        ],
        "required": ["city"]
    ],
    isReadOnly: true
) { (input: WeatherInput, context: ToolContext) -> String in
    let data: [String: String] = [
        "Beijing": "Sunny, 22C, humidity 45%",
        "Tokyo": "Cloudy, 18C, humidity 65%",
    ]
    return data[input.city] ?? "No data for \(input.city)"
}

// Email validation — returns ToolExecuteResult with error handling
let validationTool = defineTool(
    name: "validate_email",
    description: "Validate an email address.",
    inputSchema: [
        "type": "object",
        "properties": [
            "email": ["type": "string", "description": "The email address"]
        ],
        "required": ["email"]
    ],
    isReadOnly: true
) { (input: ValidateInput, context: ToolContext) -> ToolExecuteResult in
    if !input.email.contains("@") {
        return ToolExecuteResult(
            content: "Invalid email: '\(input.email)' missing '@'",
            isError: true
        )
    }
    return ToolExecuteResult(content: "Email '\(input.email)' is valid.", isError: false)
}

// Package as MCP server
let utilityServer = InProcessMCPServer(
    name: "utility",
    version: "1.0.0",
    tools: [weatherTool, validationTool],
    cwd: "/tmp"
)

// Create Agent
let agent = createAgent(options: AgentOptions(
    apiKey: apiKey,
    model: "claude-sonnet-4-6",
    systemPrompt: "You have weather and email validation tools.",
    permissionMode: .bypassPermissions,
    mcpServers: ["utility": await utilityServer.asConfig()]
))

// LLM will automatically call mcp__utility__get_weather or mcp__utility__validate_email
let result = await agent.prompt("Check weather in Tokyo and validate test@example.com")
print(result.text)

When a tool returns an error, the Agent doesn't crash. The error message is fed back to the LLM, which sees it and adjusts strategy — for example, telling the user the email format is invalid.

Practical Recommendations

Choose the right transport. Use InProcessMCPServer (SDK mode) for in-process tools, stdio for external local tools, and HTTP/SSE for remote tools. Don't use stdio to connect to remote services or HTTP for local CLI tools.

Naming conventions. MCP tool names follow the mcp__{server}__{tool} three-segment format. Keep server names short and meaningful, avoiding double underscores. filesystem is better than fs-tools-v2, because the LLM can directly guess the meaning of mcp__filesystem__read_file.

Error tolerance. MCPClientManager connection failures don't crash the Agent — failed servers get their status marked as error, contributing zero tools. The Agent Loop continues running, just without those tools. Design your system with the same principle: degrade gracefully when external services are unavailable, don't crash entirely.

Use runtime management. Long-running Agent applications should check mcpServerStatus() after startup, retrying failures with reconnectMcpServer(). Use setMcpServers() for dynamic adjustments rather than rebuilding the Agent.

Deep Dive into Open Agent SDK (Swift) Series:

GitHub: terryso/open-agent-sdk-swift

DE
Source

This article was originally published by DEV Community and written by NEE.

Read original article on DEV Community
Back to Discover

Reading List