Integrating MCP (Model Context Protocol) in LLM Agents

The Model Context Protocol (MCP) provides a standardized way for LLM agents to interact with external tools and data sources. This guide covers how both Claude Code and Codex integrate MCP, and how to build similar capabilities.

What is MCP?

MCP (Model Context Protocol) is a protocol for:

MCP Architecture

┌─────────────────┐         ┌─────────────────┐
│   LLM Agent     │◀───────▶│   MCP Server    │
│   (Client)      │  JSON   │   (Provider)    │
└─────────────────┘  RPC    └─────────────────┘
        │                           │
        │                           │
   ┌────▼────┐               ┌──────▼──────┐
   │ Tools   │               │ External    │
   │ Router  │               │ Services    │
   └─────────┘               └─────────────┘

Claude Code's MCP Integration

Plugin-Scoped MCP Servers

Claude Code allows plugins to define MCP servers in their configuration:

// plugin/.mcp.json
{
  "mcpServers": {
    "asana": {
      "command": "npx",
      "args": ["-y", "@asana/mcp-server"],
      "env": {
        "ASANA_ACCESS_TOKEN": "${ASANA_TOKEN}"
      }
    },
    "database": {
      "command": "node",
      "args": ["${CLAUDE_PLUGIN_ROOT}/servers/db-server.js"],
      "env": {
        "DB_URL": "${DATABASE_URL}"
      }
    }
  }
}

Tool Naming Convention

MCP tools are namespaced to prevent collisions:

mcp__plugin_<plugin-name>_<server-name>__<tool-name>

Examples:

Using MCP Tools in Commands

Commands declare MCP tools in their allowed-tools frontmatter:

---
description: Create Asana task from issue
allowed-tools: [
  "mcp__plugin_asana_asana__asana_create_task",
  "mcp__plugin_asana_asana__asana_search_tasks"
]
---

Create an Asana task for this issue:
1. Search for existing tasks with similar titles
2. If not found, create new task with details
3. Return task URL

Agents and MCP Tools

Agents have broader MCP access without explicit allowlists:

---
name: asana-manager
description: Use when user asks to manage Asana tasks
model: inherit
color: blue
---

You manage Asana tasks autonomously.

**Available MCP Tools:**
- asana_search_tasks: Find existing tasks
- asana_create_task: Create new tasks
- asana_update_task: Modify task properties
- asana_create_comment: Add comments

Use these tools as needed without asking for permission.

Codex's MCP Integration

Native Protocol Support

Codex integrates MCP at the protocol level with full type support:

use mcp_types::{Tool, ToolInputSchema, CallToolResult};

pub async fn handle_mcp_tool_call(
    sess: &Session,
    turn_context: &TurnContext,
    call_id: String,
    server: String,
    tool_name: String,
    arguments: String,
) -> ResponseInputItem {
    // Parse arguments
    let arguments_value = if arguments.trim().is_empty() {
        None
    } else {
        match serde_json::from_str::<serde_json::Value>(&arguments) {
            Ok(value) => Some(value),
            Err(e) => {
                return ResponseInputItem::FunctionCallOutput {
                    call_id,
                    output: FunctionCallOutputPayload {
                        content: format!("err: {e}"),
                        success: Some(false),
                        ..Default::default()
                    },
                };
            }
        }
    };

    // Emit begin event
    let invocation = McpInvocation {
        server: server.clone(),
        tool: tool_name.clone(),
        arguments: arguments_value.clone(),
    };
    sess.send_event(&turn_context, EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
        call_id: call_id.clone(),
        invocation: invocation.clone(),
    })).await;

    // Execute tool call
    let start = Instant::now();
    let result = sess
        .call_tool(&server, &tool_name, arguments_value)
        .await
        .map_err(|e| format!("tool call error: {e:?}"));

    // Emit end event
    sess.send_event(&turn_context, EventMsg::McpToolCallEnd(McpToolCallEndEvent {
        call_id: call_id.clone(),
        invocation,
        duration: start.elapsed(),
        result: result.clone(),
    })).await;

    ResponseInputItem::McpToolCallOutput { call_id, result }
}

Schema Conversion

Codex converts MCP tool schemas to OpenAI function format:

pub fn mcp_tool_to_openai_tool(
    fully_qualified_name: String,
    tool: mcp_types::Tool,
) -> Result<ResponsesApiTool, serde_json::Error> {
    let mcp_types::Tool {
        description,
        mut input_schema,
        ..
    } = tool;

    // Ensure properties field exists (OpenAI requirement)
    if input_schema.properties.is_none() {
        input_schema.properties = Some(serde_json::Value::Object(serde_json::Map::new()));
    }

    // Sanitize schema for compatibility
    let mut serialized = serde_json::to_value(input_schema)?;
    sanitize_json_schema(&mut serialized);
    let input_schema = serde_json::from_value::<JsonSchema>(serialized)?;

    Ok(ResponsesApiTool {
        name: fully_qualified_name,
        description: description.unwrap_or_default(),
        strict: false,
        parameters: input_schema,
    })
}

Schema Sanitization

MCP servers may provide schemas that don't match OpenAI's expectations:

fn sanitize_json_schema(value: &mut JsonValue) {
    match value {
        JsonValue::Object(map) => {
            // Recursively sanitize nested schemas
            if let Some(props) = map.get_mut("properties") {
                if let Some(props_map) = props.as_object_mut() {
                    for v in props_map.values_mut() {
                        sanitize_json_schema(v);
                    }
                }
            }

            // Ensure type exists
            let ty = map.get("type").and_then(|v| v.as_str()).map(str::to_string);
            
            // Infer type if missing
            let ty = ty.unwrap_or_else(|| {
                if map.contains_key("properties") { "object".to_string() }
                else if map.contains_key("items") { "array".to_string() }
                else if map.contains_key("enum") { "string".to_string() }
                else { "string".to_string() }  // Default to string
            });
            
            map.insert("type".to_string(), JsonValue::String(ty.clone()));

            // Ensure object schemas have properties
            if ty == "object" && !map.contains_key("properties") {
                map.insert("properties".to_string(), JsonValue::Object(serde_json::Map::new()));
            }

            // Ensure array schemas have items
            if ty == "array" && !map.contains_key("items") {
                map.insert("items".to_string(), json!({ "type": "string" }));
            }
        }
        _ => {}
    }
}

MCP Resources

Codex provides tools for accessing MCP resources:

// List available resources
fn create_list_mcp_resources_tool() -> ToolSpec {
    ToolSpec::Function(ResponsesApiTool {
        name: "list_mcp_resources".to_string(),
        description: "Lists resources provided by MCP servers.".to_string(),
        parameters: JsonSchema::Object {
            properties: btreemap! {
                "server".to_string() => JsonSchema::String {
                    description: Some("Optional MCP server name filter".to_string()),
                },
                "cursor".to_string() => JsonSchema::String {
                    description: Some("Pagination cursor".to_string()),
                },
            },
            required: None,
            additional_properties: Some(false.into()),
        },
        strict: false,
    })
}

// Read a specific resource
fn create_read_mcp_resource_tool() -> ToolSpec {
    ToolSpec::Function(ResponsesApiTool {
        name: "read_mcp_resource".to_string(),
        description: "Read a specific resource from an MCP server.".to_string(),
        parameters: JsonSchema::Object {
            properties: btreemap! {
                "server".to_string() => JsonSchema::String {
                    description: Some("MCP server name".to_string()),
                },
                "uri".to_string() => JsonSchema::String {
                    description: Some("Resource URI to read".to_string()),
                },
            },
            required: Some(vec!["server".to_string(), "uri".to_string()]),
            additional_properties: Some(false.into()),
        },
        strict: false,
    })
}

Building MCP Integration

Step 1: MCP Client Implementation

Create a client that can connect to MCP servers:

interface McpClient {
  connect(config: McpServerConfig): Promise<void>;
  listTools(): Promise<McpTool[]>;
  callTool(name: string, args: Record<string, unknown>): Promise<McpResult>;
  listResources(cursor?: string): Promise<McpResource[]>;
  readResource(uri: string): Promise<McpResourceContent>;
  disconnect(): Promise<void>;
}

class StdioMcpClient implements McpClient {
  private process: ChildProcess | null = null;
  private pendingRequests: Map<string, { resolve: Function; reject: Function }> = new Map();

  async connect(config: McpServerConfig): Promise<void> {
    this.process = spawn(config.command, config.args, {
      env: { ...process.env, ...config.env },
      stdio: ['pipe', 'pipe', 'pipe'],
    });

    // Handle JSON-RPC messages
    this.process.stdout?.on('data', (data) => {
      const message = JSON.parse(data.toString());
      const pending = this.pendingRequests.get(message.id);
      if (pending) {
        if (message.error) {
          pending.reject(new Error(message.error.message));
        } else {
          pending.resolve(message.result);
        }
        this.pendingRequests.delete(message.id);
      }
    });
  }

  async callTool(name: string, args: Record<string, unknown>): Promise<McpResult> {
    return this.sendRequest('tools/call', { name, arguments: args });
  }

  private async sendRequest(method: string, params: unknown): Promise<unknown> {
    const id = crypto.randomUUID();
    const message = JSON.stringify({ jsonrpc: '2.0', id, method, params });
    
    return new Promise((resolve, reject) => {
      this.pendingRequests.set(id, { resolve, reject });
      this.process?.stdin?.write(message + '\n');
    });
  }
}

Step 2: Connection Manager

Manage multiple MCP server connections:

class McpConnectionManager {
  private clients: Map<string, McpClient> = new Map();
  private tools: Map<string, { server: string; tool: McpTool }> = new Map();

  async connectServer(name: string, config: McpServerConfig): Promise<void> {
    const client = new StdioMcpClient();
    await client.connect(config);
    this.clients.set(name, client);

    // Discover and register tools
    const tools = await client.listTools();
    for (const tool of tools) {
      const fullName = `${name}/${tool.name}`;
      this.tools.set(fullName, { server: name, tool });
    }
  }

  getToolSpecs(): ToolSpec[] {
    return Array.from(this.tools.entries()).map(([name, { tool }]) => ({
      name,
      description: tool.description || '',
      parameters: tool.inputSchema,
    }));
  }

  async callTool(fullName: string, args: Record<string, unknown>): Promise<McpResult> {
    const toolInfo = this.tools.get(fullName);
    if (!toolInfo) {
      throw new Error(`Unknown MCP tool: ${fullName}`);
    }
    
    const client = this.clients.get(toolInfo.server);
    if (!client) {
      throw new Error(`Disconnected server: ${toolInfo.server}`);
    }
    
    return client.callTool(toolInfo.tool.name, args);
  }
}

Step 3: MCP Tool Handler

Create a handler that routes to MCP servers:

pub struct McpHandler;

#[async_trait]
impl ToolHandler for McpHandler {
    fn kind(&self) -> ToolKind {
        ToolKind::Mcp
    }

    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Mcp { .. })
    }

    async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
        let ToolPayload::Mcp { server, tool, raw_arguments } = &invocation.payload else {
            return Err(FunctionCallError::Fatal("Expected MCP payload".into()));
        };

        let result = handle_mcp_tool_call(
            invocation.session.as_ref(),
            invocation.turn.as_ref(),
            invocation.call_id.clone(),
            server.clone(),
            tool.clone(),
            raw_arguments.clone(),
        ).await;

        match result {
            ResponseInputItem::McpToolCallOutput { result, .. } => {
                Ok(ToolOutput::Mcp { result })
            }
            _ => Err(FunctionCallError::Fatal("Unexpected response type".into())),
        }
    }
}

Step 4: Tool Discovery and Registration

Integrate MCP tools into your registry:

pub fn build_specs_with_mcp(
    config: &ToolsConfig,
    mcp_manager: &McpConnectionManager,
) -> ToolRegistryBuilder {
    let mut builder = ToolRegistryBuilder::new();
    
    // Register built-in tools...
    
    // Register MCP tools
    let mcp_handler = Arc::new(McpHandler);
    for (name, tool) in mcp_manager.get_tools() {
        match mcp_tool_to_openai_tool(name.clone(), tool.clone()) {
            Ok(spec) => {
                builder.push_spec(ToolSpec::Function(spec));
                builder.register_handler(name, mcp_handler.clone());
            }
            Err(e) => {
                tracing::error!("Failed to convert MCP tool {name}: {e}");
            }
        }
    }
    
    // Register MCP resource tools
    builder.push_spec(create_list_mcp_resources_tool());
    builder.push_spec(create_read_mcp_resource_tool());
    builder.register_handler("list_mcp_resources", Arc::new(McpResourceHandler));
    builder.register_handler("read_mcp_resource", Arc::new(McpResourceHandler));
    
    builder
}

MCP Tool Patterns

Pattern 1: CRUD Operations

---
allowed-tools: [
  "mcp__myserver__create_item",
  "mcp__myserver__read_item",
  "mcp__myserver__update_item",
  "mcp__myserver__delete_item"
]
---

Manage items using MCP tools:
1. Create: mcp__myserver__create_item with data
2. Read: mcp__myserver__read_item with ID
3. Update: mcp__myserver__update_item with ID and changes
4. Delete: mcp__myserver__delete_item with ID (confirm first)

Pattern 2: Search and Process

Steps:
1. Search: mcp__api__search with query
2. Filter: Apply local filtering
3. Transform: Process results
4. Present: Format for user

Pattern 3: Multi-Server Workflow

Workflow combining multiple MCP servers:
1. GitHub: mcp__github__get_issue for issue details
2. Asana: mcp__asana__create_task with issue data
3. Slack: mcp__slack__post_message with task link

Error Handling

Connection Errors

async callTool(name: string, args: unknown): Promise<McpResult> {
  try {
    const result = await this.client.callTool(name, args);
    return result;
  } catch (error) {
    if (error.code === 'ECONNRESET') {
      // Server disconnected, attempt reconnect
      await this.reconnect();
      return this.callTool(name, args);
    }
    throw error;
  }
}

Schema Validation Errors

match mcp_tool_to_openai_tool(name.clone(), tool) {
    Ok(spec) => {
        builder.push_spec(ToolSpec::Function(spec));
    }
    Err(e) => {
        // Log but don't fail registration
        tracing::warn!("Skipping MCP tool {name}: schema error {e}");
    }
}

Tool Execution Errors

let result = sess.call_tool(&server, &tool_name, arguments).await;

match result {
    Ok(output) => ResponseInputItem::McpToolCallOutput {
        call_id,
        result: Ok(output),
    },
    Err(e) => ResponseInputItem::McpToolCallOutput {
        call_id,
        result: Err(format!("MCP error: {e}")),
    },
}

Best Practices

  1. Namespace tools to prevent collisions between servers
  2. Sanitize schemas to handle non-standard MCP servers
  3. Handle disconnections gracefully with reconnection logic
  4. Timeout long operations to prevent hanging
  5. Log tool invocations for debugging and audit
  6. Validate arguments before sending to MCP servers
  7. Provide meaningful errors the model can act on
  8. Support pagination for resource listing

Summary

MCP integration enables LLM agents to access a rich ecosystem of external tools:

Aspect Implementation
Connection Stdio, HTTP, or SSE transport
Discovery List tools and resources at connect
Namespacing Prefix tools with server name
Schema Convert to model's expected format
Execution Route calls through connection manager
Resources Expose via list/read tools
Errors Handle gracefully, inform model

The next guide covers sandboxing and safety mechanisms for tool execution.