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

PrimitiveDirectionPurposeExample
ToolsClient calls serverActions the LLM can invokequery_database, create_ticket, send_email
ResourcesClient reads from serverData the LLM can accessDatabase schemas, file contents, API docs
PromptsServer provides to clientPre-built prompt templates"Summarise this table", "Explain this error"

Building an MCP Server

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

TransportHow it worksUse when
stdioServer runs as subprocess, communicates via stdin/stdoutLocal development, CLI tools
HTTP/SSEServer runs as web service, client connects via HTTPRemote servers, shared infrastructure
Streamable HTTPBidirectional streaming over HTTPReal-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

ApproachStandardisedDiscoveryMulti-clientComplexity
MCPYes (open protocol)Built-in (list_tools)YesMedium
OpenAI Function CallingOpenAI-only formatManualNo (OpenAI clients only)Low
LangChain ToolsLangChain-specificFramework-levelLangChain clients onlyLow
Custom REST APINo standardManual docsYes (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.