MCP (Model Context Protocol)
MCP is an open protocol that standardises how LLMs connect to external tools, data sources, and services. Instead of building custom integrations for every AI tool (Claude Code, Kiro, Cursor, Copilot), you build one MCP server and every compatible client can use it.
Think of MCP as USB for AI tools. Before USB, every device needed its own connector. MCP does the same for AI-to-tool connections.
The Problem MCP Solves
Without MCP, connecting an LLM to your systems requires:
- Custom function definitions per client (OpenAI format differs from Anthropic differs from Google)
- Rebuilding integrations when switching models or tools
- No standard for discovery (how does the LLM know what tools exist?)
- No standard for authentication, permissions, or resource access
MCP provides a single protocol that handles all of this.
Architecture
MCP Primitives
| Primitive | Direction | Purpose | Example |
|---|---|---|---|
| Tools | Client calls server | Actions the LLM can invoke | query_database, create_ticket, send_email |
| Resources | Client reads from server | Data the LLM can access | Database schemas, file contents, API docs |
| Prompts | Server provides to client | Pre-built prompt templates | "Summarise this table", "Explain this error" |
Building an MCP Server
Python (recommended)
from mcp.server import Server
from mcp.types import Tool, TextContent
import mcp.server.stdio
server = Server("my-database-server")
@server.list_tools()
async def list_tools():
return [
Tool(
name="query_database",
description="Execute a read-only SQL query against the analytics database",
inputSchema={
"type": "object",
"properties": {
"sql": {
"type": "string",
"description": "SQL query (SELECT only, no mutations)"
}
},
"required": ["sql"]
}
),
Tool(
name="list_tables",
description="List all tables in the analytics database with row counts",
inputSchema={"type": "object", "properties": {}}
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "query_database":
sql = arguments["sql"]
# Safety: reject mutations
if any(kw in sql.upper() for kw in ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER"]):
return [TextContent(type="text", text="Error: Only SELECT queries are allowed")]
results = execute_query(sql)
return [TextContent(type="text", text=format_results(results))]
elif name == "list_tables":
tables = get_table_list()
return [TextContent(type="text", text=format_tables(tables))]
async def main():
async with mcp.server.stdio.stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())
Configuration (client-side)
{
"mcpServers": {
"analytics-db": {
"command": "python",
"args": ["./mcp_servers/database_server.py"],
"env": {
"DATABASE_URL": "${DATABASE_URL}"
}
}
}
}
Transport Mechanisms
| Transport | How it works | Use when |
|---|---|---|
| stdio | Server runs as subprocess, communicates via stdin/stdout | Local development, CLI tools |
| HTTP/SSE | Server runs as web service, client connects via HTTP | Remote servers, shared infrastructure |
| Streamable HTTP | Bidirectional streaming over HTTP | Real-time updates, long-running operations |
Production Patterns
Security
- Validate all tool inputs (never trust LLM-generated arguments blindly)
- Implement read-only by default, require explicit opt-in for mutations
- Use environment variables for credentials, never hardcode
- Rate limit tool calls to prevent abuse
- Log every tool invocation for audit
Error handling
@server.call_tool()
async def call_tool(name: str, arguments: dict):
try:
result = execute_tool(name, arguments)
return [TextContent(type="text", text=result)]
except ValidationError as e:
return [TextContent(type="text", text=f"Invalid input: {e.message}")]
except PermissionError:
return [TextContent(type="text", text="Permission denied for this operation")]
except TimeoutError:
return [TextContent(type="text", text="Operation timed out. Try a simpler query.")]
except Exception as e:
# Return actionable error, not stack trace
return [TextContent(type="text", text=f"Operation failed: {str(e)}")]
Testing
import pytest
from mcp.client import ClientSession
@pytest.mark.asyncio
async def test_query_tool():
async with create_test_session("./database_server.py") as session:
result = await session.call_tool("query_database", {"sql": "SELECT COUNT(*) FROM orders"})
assert "count" in result.content[0].text.lower()
@pytest.mark.asyncio
async def test_rejects_mutations():
async with create_test_session("./database_server.py") as session:
result = await session.call_tool("query_database", {"sql": "DROP TABLE orders"})
assert "error" in result.content[0].text.lower()
MCP vs Alternatives
| Approach | Standardised | Discovery | Multi-client | Complexity |
|---|---|---|---|---|
| MCP | Yes (open protocol) | Built-in (list_tools) | Yes | Medium |
| OpenAI Function Calling | OpenAI-only format | Manual | No (OpenAI clients only) | Low |
| LangChain Tools | LangChain-specific | Framework-level | LangChain clients only | Low |
| Custom REST API | No standard | Manual docs | Yes (but no AI discovery) | High |
Key Takeaways
- MCP standardises the LLM-to-tool connection. Build once, use from any compatible client.
- Start with stdio transport for local development, move to HTTP for shared/remote servers.
- Security is non-negotiable: validate inputs, default to read-only, log everything.
- Tool descriptions are critical. The LLM reads them to decide when and how to call your tools. Write them like API documentation.
- Test MCP servers like any other service: unit tests for tool logic, integration tests for the protocol layer.
On this page
Title
MCP (Model Context Protocol)