πŸ”Œ Model Context Protocol (MCP) β€” Master Guide

Architecture Β· Tools Β· Resources Β· Prompts Β· Transports Β· Building Servers Β· Security Β· Registry Β· Hands-On Labs

What Is MCP and Why Does It Exist?
MCP is the "USB-C for AI" β€” a single open standard that lets any LLM app talk to any tool or data source without custom wiring.

😩 The Problem MCP Solves β€” The NΓ—M Integration Hell

Before MCP, every AI app had to build a custom integration for every tool. 5 apps Γ— 10 tools = 50 custom integrations to build and maintain.

❌ Before MCP (NΓ—M problem)
Claude
⟷
GitHub
Claude
⟷
Slack
ChatGPT
⟷
GitHub
Each arrow = custom integration = weeks of work Γ— forever maintenance
βœ… After MCP (N+M solution)
Claude
ChatGPT
Cursor
MCP
Protocol
GitHub MCP
Slack MCP
DB MCP
Build server once β†’ every client uses it. Build client once β†’ every server works.

πŸ“… Timeline

  • β–Έ
    Nov 2024 β€” Anthropic launches MCP (open-source, MIT)
  • β–Έ
    Mar 2025 β€” OpenAI & Google adopt MCP
  • β–Έ
    Sep 2025 β€” MCP Registry launches (official server catalog)
  • β–Έ
    Nov 2025 β€” 97M monthly SDK downloads, 10K+ servers
  • β–Έ
    Dec 2025 β€” Donated to Linux Foundation (AAIF)

🧩 Three Primitives

  • πŸ”§
    Tools β€” Actions the model can execute (like POST). Write operations, API calls, computations.
  • πŸ“„
    Resources β€” Read-only data the model can pull in (like GET). Files, DB schemas, logs.
  • πŸ’¬
    Prompts β€” Reusable prompt templates the user can invoke. Structured interaction patterns.

πŸš€ Two Transports

  • ⚑
    stdio β€” Local subprocess. Zero network overhead. Best for dev tools, IDE plugins, local scripts.
  • 🌐
    Streamable HTTP β€” Remote over HTTPS + SSE. Scales to millions of requests. Recommended for production.
⚠️
SSE-only transport was deprecated in the March 2025 spec revision. Use Streamable HTTP for all new remote servers.

πŸ”‘ MCP vs Function Calling β€” They Work Together

ConceptMCPFunction Calling
What it isIntegration layer (the standard)Model API feature (the mechanism)
Where it livesBetween your app and tools/dataInside the LLM API call
Who defines toolsMCP server (external process)You define in the API request
RelationshipMCP uses function calling under the hood β€” they're complementary
πŸ’‘
MCP client discovers tools from MCP server β†’ converts to function-call schema β†’ passes to LLM β†’ LLM decides to call β†’ client routes back to MCP server. MCP is the plumbing; function calling is the trigger.
πŸ—οΈ MCP Architecture β€” The Full Stack
Three roles, two layers, one protocol. Understanding this mental model makes everything else click.

🎭 The Three Roles

πŸ–₯️
HOST
The user-facing app. Owns user consent, credentials, and the allow-list of what tools the model can call.

Examples: Claude Desktop, Cursor, VS Code, your custom app.
πŸ”Œ
CLIENT
Lives inside the host. One client per server connection. Handles the protocol handshake, capability discovery, and routing tool calls.

Role: Translator between host and server.
βš™οΈ
SERVER
The process you build. Exposes tools, resources, prompts. Can run locally (stdio) or remotely (HTTP).

Examples: Your custom server, GitHub MCP, Sentry MCP.

πŸ”„ The Session Lifecycle β€” What Happens When You Connect

1️⃣
Client connects
to server
β†’
2️⃣
initialize
handshake
β†’
3️⃣
Capability
negotiation
β†’
4️⃣
tools/list
resources/list
β†’
5️⃣
Session ready
tools available
β†’
6️⃣
LLM calls
tool/resource
πŸ’‘
Capability negotiation is key β€” the server declares what it supports (tools, resources, prompts, sampling) and the client adapts. Your server only sees features the client agrees to support.

πŸ“‘ The Data Layer β€” JSON-RPC 2.0 Under the Hood

Every MCP message is a JSON-RPC 2.0 frame. Three message types:

Request
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "search", "arguments": { "query": "MCP docs" } } }
Response
{ "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "Results: ..." }], "isError": false } }
Notification
{ "jsonrpc": "2.0", "method": "notifications/ tools/list_changed" // No "id" field // No response expected // Server β†’ Client push }
🧩 The Three Primitives
Tools, Resources, and Prompts are the three things an MCP server can expose. Know when to use each.

πŸ”§ Tools β€” Model-Controlled Actions

Tools are functions the model decides to call. Think of them like POST/PUT endpoints β€” they do things, change state, call APIs. The model sees the tool schema and decides when to invoke it.

from mcp.server.fastmcp import FastMCP mcp = FastMCP("MyServer") @mcp.tool() def search_web(query: str, max_results: int = 5) -> str: """Search the web for a query. Args: query: The search query max_results: Max results to return """ # The docstring becomes the tool description # The type hints become the input schema results = do_search(query, max_results) return results @mcp.tool() async def send_email( to: str, subject: str, body: str ) -> dict: """Send an email. Async tools are supported.""" result = await email_api.send(to, subject, body) return {"status": "sent", "id": result.id}
πŸ”§
What FastMCP does for you:
β€’ Docstring β†’ tool description (shown to LLM)
β€’ Type hints β†’ JSON Schema input validation
β€’ Async support built-in
β€’ Error handling + proper MCP error responses
β€’ No JSON-RPC boilerplate to write
Tool annotations (2025 spec):
AnnotationMeaning
readOnlyHintTool only reads, never writes
destructiveHintMay delete or overwrite data
idempotentHintSafe to call multiple times
openWorldHintMay access external systems

πŸ“„ Resources β€” Client-Controlled Context

Resources are read-only data the client pulls in eagerly. Think GET endpoints β€” no side effects. The client (not the model) decides when to fetch a resource. URI-addressable.

# Static resource β€” same content every time @mcp.resource("config://app") def get_config() -> str: """Application configuration.""" return json.dumps(load_config()) # Dynamic resource β€” URI template with params @mcp.resource("user://{user_id}/profile") def get_user(user_id: str) -> str: """Get user profile by ID.""" user = db.get_user(user_id) return user.to_json() # File resource β€” expose local files @mcp.resource("file://docs/{filename}") def get_doc(filename: str) -> str: """Read a documentation file.""" path = DOCS_DIR / filename return path.read_text()
πŸ“„
Resources vs Tools β€” the key distinction:

Use Resources when: data is read-only, the client should pull it proactively, it's addressable by URI (files, docs, schemas)

Use Tools when: the model needs to decide when to fetch, there's computation involved, or data changes based on arguments
πŸ’‘
In practice, Tools are used far more than Resources. Many use cases that seem like Resources are actually better as Tools because the model can decide when to fetch them.

πŸ’¬ Prompts β€” User-Invoked Templates

Prompts are reusable message templates the user invokes (e.g. via a slash command in the UI). They structure how users interact with your server's capabilities.

@mcp.prompt() def analyze_code( code: str, language: str = "python", focus: str = "security" ) -> str: """Analyze code for issues. Returns a structured prompt that tells the LLM exactly how to analyze the code. The user invokes this template; the LLM then executes the analysis. """ return f"""Please analyze this {language} code for {focus} issues. Code to analyze: ```{language} {code} ``` Provide: 1. Critical issues (severity: HIGH/MEDIUM/LOW) 2. Specific line references 3. Fix recommendations with code examples 4. Overall security score (1-10)"""
πŸš€ Transports β€” How Messages Move
The transport layer decides how bytes move between client and server. Same JSON-RPC protocol on both β€” only the delivery mechanism changes.

⚑ stdio β€” Local Subprocess

The host spawns the server as a child process. Messages flow over stdin/stdout. Zero network overhead. Ideal for developer tools.

# server.py β€” run via stdio from mcp.server.fastmcp import FastMCP mcp = FastMCP("LocalServer") @mcp.tool() def read_file(path: str) -> str: """Read a local file.""" return open(path).read() if __name__ == "__main__": mcp.run() # defaults to stdio
# claude_desktop_config.json { "mcpServers": { "my-server": { "command": "python3", "args": ["/path/to/server.py"], "env": {"API_KEY": "..."} } } }
βœ“ Zero latencyβœ“ Simple setup⚠ Local only

🌐 Streamable HTTP β€” Remote Server

Server runs anywhere (cloud, VPS, serverless). Client connects over HTTPS. Responses stream via Server-Sent Events. Recommended for production deployments.

# server.py β€” run via HTTP from mcp.server.fastmcp import FastMCP mcp = FastMCP( "RemoteServer", stateless_http=True, # best for scale json_response=True, # load-balancer friendly ) @mcp.tool() def query_database(sql: str) -> list: """Run a read-only SQL query.""" return db.execute(sql).fetchall() if __name__ == "__main__": mcp.run( transport="streamable-http", host="0.0.0.0", port=8000 )
βœ“ Multi-clientβœ“ Scalableβœ“ Auth support

βš–οΈ When to Use Which Transport

ScenarioTransportWhy
IDE plugin (Cursor, VS Code)stdioLocal process, no network, low latency
Claude Desktop personal toolstdioEasy config, no server to maintain
Shared team tool / SaaSStreamable HTTPMultiple users, auth, monitoring
Production agent systemStreamable HTTPScale, reliability, load balancing
Dev/testing locallystdioFastest iteration cycle
Existing HTTP microserviceStreamable HTTPFits existing infra patterns
πŸ” Security β€” Critical Patterns
Security in MCP lives primarily in the Host, not the protocol itself. These are the patterns you must implement.
🚨
Key principle: The MCP protocol provides the surface. Security design owns the safety. The host is responsible for user consent, credential scoping, and what the model is allowed to call. Never assume a server is trustworthy just because it's connected.

βœ… Input Validation β€” Always

from pydantic import BaseModel, validator import re class SQLQuery(BaseModel): sql: str max_rows: int = 100 @validator('sql') def no_write_ops(cls, v): forbidden = ['DROP','DELETE','UPDATE','INSERT'] upper = v.upper() for word in forbidden: if word in upper: raise ValueError(f'Write op not allowed') return v @mcp.tool() def query_data(params: SQLQuery) -> list: """Read-only SQL query.""" # Pydantic validates before this runs return db.execute(params.sql, limit=params.max_rows)

πŸ”‘ Auth for Remote Servers

from functools import wraps from mcp.server.fastmcp import FastMCP from mcp.server.auth import BearerAuthProvider # OAuth / Bearer token auth mcp = FastMCP( "SecureServer", auth=BearerAuthProvider( validate_token=validate_jwt_token ) ) # API key auth (simpler) def require_api_key(func): @wraps(func) async def wrapper(*args, ctx=None, **kwargs): key = ctx.request_context.headers.get( 'X-API-Key' ) if key not in VALID_KEYS: raise PermissionError("Invalid API key") return await func(*args, **kwargs) return wrapper

πŸ›‘οΈ Security Checklist for Production MCP Servers

  • βœ“
    Validate all inputs β€” use Pydantic models, never trust LLM-generated args
  • βœ“
    Principle of least privilege β€” tools should only access what they absolutely need
  • βœ“
    No secrets in tool outputs β€” never return API keys, passwords, PII in tool results
  • βœ“
    Rate limit all tools β€” especially anything that writes to external systems
  • βœ“
    Audit log tool calls β€” log who called what with what args and when
  • βœ“
    Destructive tools require confirmation β€” use two-step patterns for delete/modify
  • βœ“
    Sandbox external calls β€” timeout, retry limits, error isolation
  • βœ“
    OAuth for remote servers β€” never pass raw credentials through MCP messages
🌐 The MCP Ecosystem
97M+ monthly SDK downloads. 10,000+ servers. First-class support across every major AI platform. Here's the map.

πŸ–₯️ MCP Clients (Hosts)

ClientBest For
Claude DesktopLocal dev, personal automation
Claude CodeCoding agents in terminal/IDE
CursorAI-native code editor
VS Code (Copilot)Existing VS Code workflows
ChatGPTOpenAI ecosystem
Your custom appVia MCP Python/TS SDK

βš™οΈ Popular MCP Servers

ServerWhat It Exposes
GitHub MCPIssues, PRs, code search, commits
Filesystem MCPRead/write local files
Brave Search MCPWeb search results
Postgres MCPDB queries, schema inspection
Slack MCPMessages, channels, users
Sentry MCPErrors, issues, performance

πŸ—οΈ FastMCP β€” The Standard Way to Build Servers

FastMCP v1 was contributed directly into the official MCP Python SDK. FastMCP v2 (standalone) is the actively developed successor, powering ~70% of all MCP servers.

mcp (official SDK)
from mcp.server.fastmcp import FastMCP

Stable, official, in PyPI. Good starting point.
fastmcp (standalone v2)
pip install fastmcp

More features: client lib, proxy, composition, MCP Apps.
MCP Inspector
npx @modelcontextprotocol/inspector

Visual UI to test any MCP server interactively.

πŸ“¦ MCP Registry β€” The Official Server Catalog

Launched September 2025 at registry.modelcontextprotocol.io. Open-source, community-maintained catalog of all public MCP servers. Standardizes how clients discover and connect to servers.

# Discover servers via the registry API curl https://registry.modelcontextprotocol.io/servers?q=github # Returns: list of server metadata, install instructions, capabilities
🎁
MCP was donated to the Linux Foundation's Agentic AI Foundation (AAIF) in December 2025 β€” ensuring vendor-neutral governance. Anthropic's commitment to the protocol is unchanged.
πŸ§ͺ

Lab 1 β€” Build & Test an MCP Server (live, in browser)

Simulates running a real MCP server. Edit the tool definitions, hit Run, and Claude will actually call the tool and show you the full MCP message exchange β€” JSON-RPC request, tool execution, response. This is the exact protocol flow you'd see in Claude Desktop.

βš™οΈ Define Your MCP Tools

πŸ“‘ MCP Protocol Trace (JSON-RPC messages)idle
Protocol trace will appear here… This shows the actual JSON-RPC 2.0 messages that flow between the MCP client and server during a tool call session: 1. initialize β†’ capability handshake 2. tools/list β†’ server declares available tools 3. tools/call β†’ LLM triggers a tool 4. result β†’ tool output returned to LLM
πŸ’¬ LLM Response (with tool results)
Agent response will appear here…
πŸ”¬

Lab 2 β€” MCP Tool Inspector

Define a custom tool schema and see: how it looks to the LLM, whether Claude will call it for a given query, and what the tool result flows back as. This teaches you how to write good tool descriptions and schemas.

πŸ”§ Design Your Tool Schema

✍️ Writing Good Tool Descriptions β€” Rules

RuleBadGood
Be specific about when to use it"Search for things""Search internal company policies and procedures β€” use when asked about HR, benefits, expenses, or company rules"
Describe the data source"Get information""Query our PostgreSQL product catalog β€” returns name, price, inventory count"
Describe what NOT to use it for(nothing)"Do NOT use for general web questions β€” only for internal knowledge"
Describe output format"Returns results""Returns a JSON array of matching records with fields: id, title, content, score"
πŸ€–

Lab 3 β€” LangGraph Agent Using MCP Tools

This shows how MCP and LangGraph fit together. A LangGraph agent is given a set of MCP-style tools. Watch the full agentic loop: LLM reasons β†’ selects tool β†’ tool executes β†’ result injected back β†’ LLM continues.

πŸ”„ Agent Loop Trace (click steps to expand)

Run the agent to see the loop trace…

πŸ—ΊοΈ How MCP + LangGraph Fit Together

LangGraph
Node runs
β†’
LLM called
with tools
β†’
LLM picks
MCP tool
β†’
MCP Client
routes call
β†’
MCP Server
executes
β†’
Result back
to LLM
β†’
Next node
or done
πŸ’‘
In LangChain/LangGraph, you load MCP tools with langchain_mcp_adapters β€” they become standard LangChain tools automatically. The agent doesn't know or care that the tools come from an MCP server.
πŸ“‹ Copy-Paste Snippets β€” Production Ready
Run these directly. Every snippet is a standalone, complete pattern.
⚑ Minimal FastMCP Server (stdio)
"""Minimal MCP server with tool + resource + prompt."""
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("MyServer")

# Tool β€” model calls this when it needs to act
@mcp.tool()
def add_numbers(a: float, b: float) -> float:
    """Add two numbers together."""
    return a + b

# Resource β€” client pulls this as context
@mcp.resource("config://settings")
def get_settings() -> str:
    """Current application settings."""
    return '{"max_items": 100, "debug": false}'

# Prompt β€” user invokes this as a template
@mcp.prompt()
def analyze(topic: str, depth: str = "brief") -> str:
    """Generate an analysis prompt."""
    return f"Analyze '{topic}' with {depth} depth."

if __name__ == "__main__":
    mcp.run()  # stdio by default

# Run: python server.py
# Test: npx @modelcontextprotocol/inspector python server.py
🌐 Remote HTTP Server (production)
"""Production MCP server over Streamable HTTP."""
import os
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, validator

mcp = FastMCP(
    "ProductionServer",
    stateless_http=True,   # enables horizontal scaling
    json_response=True,    # load-balancer friendly
)

class SearchParams(BaseModel):
    query: str
    max_results: int = 10

    @validator('query')
    def not_empty(cls, v):
        if not v.strip():
            raise ValueError("Query cannot be empty")
        return v.strip()

    @validator('max_results')
    def reasonable_limit(cls, v):
        return min(v, 100)  # hard cap

@mcp.tool(annotations={"readOnlyHint": True})
def search(params: SearchParams) -> list[dict]:
    """Search the knowledge base. Read-only."""
    # Your search logic here
    return [{"id": 1, "content": f"Result for: {params.query}"}]

if __name__ == "__main__":
    mcp.run(
        transport="streamable-http",
        host="0.0.0.0",
        port=int(os.getenv("PORT", 8000)),
    )

# Deploy: docker run -p 8000:8000 -e API_KEY=... your-image
# Client connects to: http://your-host:8000/mcp
πŸ”Œ MCP Client (connect to any server)
"""Connect to an MCP server and call its tools."""
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main():
    # Connect to a local stdio server
    server_params = StdioServerParameters(
        command="python3",
        args=["server.py"],
        env={"API_KEY": "your-key"},
    )

    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            # Step 1: Initialize (capability handshake)
            await session.initialize()

            # Step 2: Discover what tools are available
            tools = await session.list_tools()
            print("Available tools:")
            for tool in tools.tools:
                print(f"  - {tool.name}: {tool.description}")

            # Step 3: Call a specific tool
            result = await session.call_tool(
                "add_numbers",
                arguments={"a": 10, "b": 32}
            )
            print(f"Result: {result.content[0].text}")

            # Step 4: Read a resource
            resource = await session.read_resource("config://settings")
            print(f"Config: {resource.contents[0].text}")

asyncio.run(main())
πŸ€– LangGraph Agent with MCP Tools
"""LangGraph agent that uses MCP tools via langchain-mcp-adapters."""
# pip install langchain-mcp-adapters langgraph langchain-anthropic

import asyncio
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.prebuilt import create_react_agent
from langchain_anthropic import ChatAnthropic

async def run_agent_with_mcp(query: str):
    # Connect to one or more MCP servers
    async with MultiServerMCPClient({
        "filesystem": {
            "command": "uvx",
            "args": ["mcp-server-filesystem", "/tmp"],
            "transport": "stdio",
        },
        "web-search": {
            "url": "http://localhost:8001/mcp",
            "transport": "streamable_http",
        },
    }) as client:
        # Get all tools from all connected servers
        tools = client.get_tools()
        print(f"Loaded {len(tools)} tools from MCP servers")
        for t in tools:
            print(f"  [{t.name}] {t.description[:60]}...")

        # Create a LangGraph agent with these tools
        model = ChatAnthropic(model="claude-sonnet-4-6")
        agent = create_react_agent(model, tools)

        # Run it
        result = await agent.ainvoke({
            "messages": [{"role": "user", "content": query}]
        })
        return result["messages"][-1].content

# Usage:
# answer = asyncio.run(run_agent_with_mcp("Search for recent LangGraph docs"))
# print(answer)
πŸ” Secure Tool with Audit Logging
"""Production tool with validation, rate limiting, audit log."""
import time
import logging
from collections import defaultdict
from functools import wraps
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, validator

mcp = FastMCP("SecureServer")
logger = logging.getLogger("mcp_audit")

# Simple in-memory rate limiter
_call_counts = defaultdict(list)
RATE_LIMIT = 10  # calls per minute

def rate_limit(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        key = func.__name__
        now = time.time()
        # Clean calls older than 60 seconds
        _call_counts[key] = [t for t in _call_counts[key] if now - t < 60]
        if len(_call_counts[key]) >= RATE_LIMIT:
            raise RuntimeError(f"Rate limit exceeded for {key}: {RATE_LIMIT}/min")
        _call_counts[key].append(now)
        return func(*args, **kwargs)
    return wrapper

class WriteParams(BaseModel):
    content: str
    filename: str

    @validator('filename')
    def safe_filename(cls, v):
        # Prevent path traversal
        if '..' in v or '/' in v or '\\' in v:
            raise ValueError("Invalid filename β€” no path separators")
        if not v.endswith(('.txt', '.md', '.json')):
            raise ValueError("Only .txt/.md/.json allowed")
        return v

    @validator('content')
    def reasonable_length(cls, v):
        if len(v) > 10_000:
            raise ValueError("Content too large (max 10KB)")
        return v

@mcp.tool(annotations={"destructiveHint": False, "idempotentHint": False})
@rate_limit
def write_note(params: WriteParams) -> dict:
    """Write a note file. Validated, rate-limited, audit-logged."""
    logger.info(f"AUDIT: write_note called | file={params.filename} | size={len(params.content)}")
    path = f"/tmp/notes/{params.filename}"
    with open(path, 'w') as f:
        f.write(params.content)
    return {"status": "written", "path": path, "bytes": len(params.content)}

if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    mcp.run()
πŸ§ͺ Test Your MCP Server (pytest)
"""Test your MCP server's tools directly β€” no client needed."""
# pip install pytest pytest-asyncio mcp
# pytest test_server.py -v

import pytest
from mcp.server.fastmcp import FastMCP

# ── Import your server ──────────────────────────────────────
from server import mcp  # your FastMCP instance

# ── Unit test individual tool functions ─────────────────────
def test_add_numbers():
    """Test the tool function directly (fastest)."""
    from server import add_numbers
    assert add_numbers(2, 3) == 5
    assert add_numbers(-1, 1) == 0
    assert add_numbers(0.1, 0.2) == pytest.approx(0.3)

def test_add_numbers_validation():
    """Test input validation."""
    from server import add_numbers
    with pytest.raises((TypeError, ValueError)):
        add_numbers("not", "numbers")

# ── Integration test via MCP session ───────────────────────
@pytest.mark.asyncio
async def test_tool_via_mcp_session():
    """Test through the full MCP protocol stack."""
    from mcp import ClientSession
    from mcp.server.fastmcp.testing import create_test_client

    async with create_test_client(mcp) as client:
        # List tools
        tools = await client.list_tools()
        tool_names = [t.name for t in tools.tools]
        assert "add_numbers" in tool_names

        # Call the tool
        result = await client.call_tool(
            "add_numbers", {"a": 10, "b": 20}
        )
        assert not result.isError
        assert "30" in result.content[0].text

@pytest.mark.asyncio
async def test_resource():
    from mcp.server.fastmcp.testing import create_test_client
    async with create_test_client(mcp) as client:
        result = await client.read_resource("config://settings")
        assert result.contents[0].text is not None
🐳 Docker + Claude Desktop Config
# Dockerfile for your MCP server
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "server.py"]

# requirements.txt
# mcp>=1.0.0
# fastmcp>=2.0.0  # standalone v2 (more features)
# pydantic>=2.0.0

# ── Run it ──────────────────────────────────────────────────
# docker build -t my-mcp-server .
# docker run -p 8000:8000 -e API_KEY=... my-mcp-server

# ── Claude Desktop config (~/.config/claude/claude_desktop_config.json) ──
# For a LOCAL server (stdio):
{
  "mcpServers": {
    "my-local-server": {
      "command": "python3",
      "args": ["/absolute/path/to/server.py"],
      "env": {"API_KEY": "your-key-here"}
    }
  }
}

# For a REMOTE server (HTTP) β€” Claude Desktop β‰₯ 0.8:
{
  "mcpServers": {
    "my-remote-server": {
      "type": "streamable-http",
      "url": "https://your-server.com/mcp",
      "headers": {"Authorization": "Bearer your-token"}
    }
  }
}
πŸ“Š MCP + LangGraph Telemetry (combined)
"""Track MCP tool calls in your LangGraph telemetry pipeline.
Adds per-tool span tracking to the tracer from the LangGraph kit."""

import time
from contextlib import asynccontextmanager
from langchain_mcp_adapters.client import MultiServerMCPClient
from telemetry.tracer import get_tracer  # from our LangGraph kit

class TracedMCPClient:
    """Wraps MultiServerMCPClient to emit telemetry spans per tool call."""

    def __init__(self, server_configs: dict):
        self._configs = server_configs
        self._client = None

    async def __aenter__(self):
        self._client = MultiServerMCPClient(self._configs)
        await self._client.__aenter__()
        return self

    async def __aexit__(self, *args):
        await self._client.__aexit__(*args)

    def get_traced_tools(self):
        """Return tools wrapped with telemetry spans."""
        tracer = get_tracer()
        raw_tools = self._client.get_tools()
        traced = []

        for tool in raw_tools:
            original_run = tool._run

            def make_traced(t, orig):
                def traced_run(*args, **kwargs):
                    with tracer.span(f"mcp_tool:{t.name}") as span:
                        t0 = time.perf_counter()
                        try:
                            result = orig(*args, **kwargs)
                            latency = (time.perf_counter() - t0) * 1000
                            span.record(0, 0, latency, status="ok")
                            return result
                        except Exception as e:
                            span.record(0, 0, 0, status="error", error=str(e))
                            raise
                return traced_run

            tool._run = make_traced(tool, original_run)
            traced.append(tool)

        return traced

# Usage with LangGraph agent:
# async with TracedMCPClient({"github": {...}}) as client:
#     tools = client.get_traced_tools()
#     agent = create_react_agent(model, tools)
#     result = await agent.ainvoke({"messages": [...]})