Model Context Protocol (MCP)

9 min read Module 9 of 10 Topic 25 of 30

What you'll learn

  • Understand MCP's architecture: host, client, server, and the transport layer
  • Build an MCP server that exposes tools and resources to any MCP-compatible client
  • Connect a LangGraph agent to an MCP server to consume third-party tools
  • Understand the difference between MCP tools, resources, and prompts
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

What Is MCP?

Model Context Protocol (MCP) is an open standard introduced by Anthropic in late 2024 and rapidly adopted by OpenAI, Google, and the entire ecosystem. It defines a standard protocol for AI agents to communicate with external tools and data sources.

flowchart LR
    subgraph Host["MCP Host"]
        AG["Your Agent\nClaude / LangGraph / any app"]
        CL["MCP Client\nmanages server lifecycle"]
    end
    subgraph Server["MCP Server\nGitHub · Postgres · Slack · custom"]
        T["Tools\nactions"]
        R["Resources\ndata"]
        P["Prompts\ntemplates"]
    end
    AG --- CL
    CL <-->|"MCP protocol\nstdio or HTTP+SSE"| Server
    style AG fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style CL fill:#EEF0F7,stroke:#818CF8,color:#0F172A
    style T fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style R fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style P fill:#f0fdf9,stroke:#0D9488,color:#0F172A

The key insight: once you build an MCP server, it works with any MCP-compatible agent without modification.


Building an MCP Server

# src/mcp_server/server.py
from mcp.server import Server
from mcp.server.models import InitializationOptions
from mcp.server.stdio import stdio_server   # stdio transport for local subprocess communication
from mcp import types
import httpx
import json
from datetime import datetime

# Server("company-data-server") creates the MCP server with a name that MCP clients use to identify it
server = Server("company-data-server")

# ─── TOOLS ──────────────────────────────────────────────────────

# @server.list_tools() decorator registers this as the tool discovery handler: MCP clients call it first
# to learn what actions are available before asking the agent to do anything
@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
    """Return available tools to the MCP client."""
    return [
        types.Tool(
            name="search_products",
            description="Search the product catalog by name, category, or SKU",
            # inputSchema is a JSON Schema object: MCP clients use it to validate arguments
            # and LLMs use the description to decide when and how to call this tool
            inputSchema={
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Search query (product name, category, or SKU)"
                    },
                    "limit": {
                        "type": "integer",
                        "description": "Maximum results to return (1-50)",
                        "default": 10,
                    },
                    "in_stock_only": {
                        "type": "boolean",
                        "description": "Filter to in-stock items only",
                        "default": False,
                    }
                },
                "required": ["query"],   # only query is required; limit and in_stock_only have defaults
            }
        ),
        types.Tool(
            name="get_customer",
            description="Retrieve customer information by email or customer ID",
            inputSchema={
                "type": "object",
                "properties": {
                    "identifier": {"type": "string", "description": "Customer email or ID"},
                },
                "required": ["identifier"],
            }
        ),
        types.Tool(
            name="create_support_ticket",
            description="Create a customer support ticket. Use when the issue cannot be resolved immediately.",
            inputSchema={
                "type": "object",
                "properties": {
                    "customer_email": {"type": "string"},
                    "subject": {"type": "string"},
                    "description": {"type": "string"},
                    "priority": {"type": "string", "enum": ["low", "medium", "high"]},  # enum restricts valid values
                },
                "required": ["customer_email", "subject", "description"],
            }
        ),
    ]

# @server.call_tool() handles all tool invocations: routes by name to the right implementation
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    """Execute a tool call and return results."""
    
    if name == "search_products":
        results = await search_product_database(
            query=arguments["query"],
            limit=arguments.get("limit", 10),            # .get() with default handles optional args safely
            in_stock_only=arguments.get("in_stock_only", False),
        )
        # MCP tool results must be returned as a list of content objects; TextContent wraps JSON strings
        return [types.TextContent(type="text", text=json.dumps(results, indent=2))]
    
    elif name == "get_customer":
        customer = await get_customer_from_db(arguments["identifier"])
        if not customer:
            # return a structured error in the content rather than raising: lets the LLM handle "not found"
            return [types.TextContent(type="text", text=json.dumps({"error": "Customer not found"}))]
        return [types.TextContent(type="text", text=json.dumps(customer, indent=2))]
    
    elif name == "create_support_ticket":
        ticket_id = await create_ticket_in_crm(
            email=arguments["customer_email"],
            subject=arguments["subject"],
            description=arguments["description"],
            priority=arguments.get("priority", "medium"),  # default to medium if priority not specified
        )
        return [types.TextContent(type="text", text=json.dumps({"ticket_id": ticket_id, "status": "created"}))]
    
    else:
        # raise for truly unknown tools: this is a server error, not a user error
        raise ValueError(f"Unknown tool: {name}")

# ─── RESOURCES ──────────────────────────────────────────────────

# @server.list_resources() registers the resource discovery handler: resources are read-only context
# the model can read but not modify, unlike tools which perform actions
@server.list_resources()
async def handle_list_resources() -> list[types.Resource]:
    return [
        types.Resource(
            uri="company://policy/return-policy",   # custom URI scheme identifies where the resource lives
            name="Return Policy",
            description="The company's official return and refund policy",
            mimeType="text/plain",
        ),
        types.Resource(
            uri="company://catalog/categories",
            name="Product Categories",
            description="Full list of product categories and subcategories",
            mimeType="application/json",
        ),
    ]

# @server.read_resource() is called when the MCP client requests a specific resource by URI
@server.read_resource()
async def handle_read_resource(uri: str) -> str:
    if uri == "company://policy/return-policy":
        return "Our return policy: Items can be returned within 30 days..."
    elif uri == "company://catalog/categories":
        categories = await get_all_categories()
        return json.dumps(categories)  # return JSON string; mimeType="application/json" hints how to parse it
    raise ValueError(f"Resource not found: {uri}")

# ─── RUN SERVER ─────────────────────────────────────────────────

async def main():
    # stdio_server() manages stdin/stdout as bidirectional streams for local MCP communication
    async with stdio_server() as (read_stream, write_stream):
        await server.run(
            read_stream,
            write_stream,
            InitializationOptions(
                server_name="company-data-server",
                server_version="1.0.0",   # version is surfaced to the MCP client for compatibility checks
            )
        )

if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

HTTP Transport for Network MCP Servers

For MCP servers that need to be accessed over the network:

# src/mcp_server/http_server.py
from mcp.server.sse import SseServerTransport   # SSE transport for network MCP, supports auth headers and load balancers
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse

app = FastAPI(title="Company Data MCP Server")
# SseServerTransport("/messages"): "/messages" is the POST endpoint path for client-to-server messages
sse_transport = SseServerTransport("/messages")

@app.get("/sse")
async def sse_endpoint(request: Request):
    """SSE endpoint for MCP client connections."""
    # connect_sse opens a persistent SSE connection; server.run handles the MCP protocol over it
    async with sse_transport.connect_sse(request.scope, request.receive, request._send) as streams:
        await server.run(*streams, InitializationOptions(
            server_name="company-data-server",
            server_version="1.0.0",
        ))

@app.post("/messages")
async def messages_endpoint(request: Request):
    # MCP clients POST tool calls and resource requests here; SSE sends responses back on the /sse stream
    await sse_transport.handle_post_message(request.scope, request.receive, request._send)

Consuming MCP Servers in LangGraph

# src/agents/mcp_agent.py
from langchain_mcp_adapters.client import MultiServerMCPClient   # bridges MCP servers to LangChain tools

async def create_mcp_agent():
    """Create an agent that uses tools from MCP servers."""
    
    # MultiServerMCPClient connects to multiple MCP servers simultaneously
    # each key is a logical name; the dict value specifies transport and connection details
    client = MultiServerMCPClient(
        {
            "company-data": {
                "command": "python",                          # launch as a local subprocess
                "args": ["src/mcp_server/server.py"],
                "transport": "stdio",                         # communicate via stdin/stdout
            },
            "github": {
                "url": "https://mcp-github.example.com/sse",
                "transport": "sse",                           # network transport, works across machines
                "headers": {"Authorization": f"Bearer {settings.github_mcp_token}"},  # auth header for the remote server
            },
            "filesystem": {
                "command": "npx",
                "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],  # official MCP filesystem server
                "transport": "stdio",
            }
        }
    )
    
    # get_tools() discovers all tools from all connected servers and wraps them as LangChain BaseTool objects
    tools = await client.get_tools()
    
    print(f"Loaded {len(tools)} tools from MCP servers:")
    for tool in tools:
        print(f"  - {tool.name}: {tool.description[:60]}")
    
    # create_react_agent accepts the MCP-sourced tools exactly like hand-written LangChain tools
    agent = create_react_agent(
        model=ChatOpenAI(model="gpt-5.6-sol"),
        tools=tools,   # all tools from all MCP servers are available to the agent
    )
    
    return agent, client

# Usage
async def main():
    agent, mcp_client = await create_mcp_agent()
    
    # async with mcp_client ensures MCP subprocess connections are cleaned up on exit
    async with mcp_client:
        result = await agent.ainvoke({
            "messages": [HumanMessage(content="Search for all MacBook products in stock")]
        })
        
        print(result["messages"][-1].content)

MCP Server Configuration (mcp.json)

Projects that consume multiple MCP servers use a configuration file:

{
  "mcpServers": {
    "company-data": {
      "command": "python",
      "args": ["src/mcp_server/server.py"],
      "env": {
        "DATABASE_URL": "${DATABASE_URL}"   // env var substitution, secrets stay in shell environment
      }
    },
    "brave-search": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-brave-search"],   // -y auto-accepts npx install prompt
      "env": {
        "BRAVE_API_KEY": "${BRAVE_API_KEY}"
      }
    },
    "postgres": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-postgres", "${DATABASE_URL}"]   // DATABASE_URL passed as a CLI arg
    }
  }
}

MCP is still evolving rapidly, new MCP servers for every major platform (Slack, Notion, Jira, Salesforce, AWS) are being published weekly. Your agent can consume all of them through the same standard protocol, without writing any provider-specific integration code.

Knowledge Check

3 questions to test your understanding

1 What problem does MCP solve that individual tool API integrations don't?

2 What is the difference between an MCP 'tool' and an MCP 'resource'?

3 Which transport layer should you use for a production MCP server that needs to be called over the network?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan