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.
MCP (Model Context Protocol) is a protocol for:
┌─────────────────┐ ┌─────────────────┐
│ LLM Agent │◀───────▶│ MCP Server │
│ (Client) │ JSON │ (Provider) │
└─────────────────┘ RPC └─────────────────┘
│ │
│ │
┌────▼────┐ ┌──────▼──────┐
│ Tools │ │ External │
│ Router │ │ Services │
└─────────┘ └─────────────┘
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}"
}
}
}
}
MCP tools are namespaced to prevent collisions:
mcp__plugin_<plugin-name>_<server-name>__<tool-name>
Examples:
mcp__plugin_asana_asana__asana_create_taskmcp__plugin_myplug_database__querymcp__plugin_github_gh__create_issueCommands 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 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 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 }
}
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,
})
}
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" }));
}
}
_ => {}
}
}
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,
})
}
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');
});
}
}
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);
}
}
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())),
}
}
}
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
}
---
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)
Steps:
1. Search: mcp__api__search with query
2. Filter: Apply local filtering
3. Transform: Process results
4. Present: Format for user
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
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;
}
}
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}");
}
}
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}")),
},
}
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.