Skip to content
💻 🧠 Code 1001 > LLMs: Articles, Practical Materials, and Resources > A Practical Guide to Building AI Agents with LangGraph and MCP

A Practical Guide to Building AI Agents with LangGraph and MCP

Goal: To build two projects from scratch:

  1. A Classifier Agent: A multi-step agent with managed state, but without external tools.
  2. An Assistant Agent: A full-fledged agent with access to the file system and web search via the MCP protocol, built on cyclical logic.

We will cover best practices: configuration management, model selection, and error handling to create robust systems.

A Brief on the Concepts: The Agent and the MCP Bridge

Before diving into the code, let’s establish two concepts:

  • AI Agent: A program built around a “reason-action” loop. It receives a task, uses an LLM to decide what to do next (e.g., call a tool), executes the action, and repeats the cycle until the task is complete.
  • MCP (Model Context Protocol): A standard that acts as a bridge between the agent’s logic and external tools. It allows the agent to work with files, APIs, or search in a unified way, without worrying about their implementation details.

Part 1: Setting up a Robust Environment

Step 1: Virtual Environment and Dependencies

Create and activate a virtual environment. Then, create a requirements.txt file:

# Core Frameworks
langchain
langgraph

# Model Adapters
langchain-openai
langchain-google-genai
langchain-mistralai
langchain-community # For Ollama

# Tools and Protocols
langchain-mcp-adapters
mcp
ollama

# Utilities
python-dotenv
tenacity # For robust error handling

Install the dependencies:

pip install -r requirements.txt```

#### Step 2: API Key Configuration

Create a `.env` file to store your keys:

OPENAI_API_KEY=”sk-…”
GOOGLE_API_KEY=”AIzaSy…”
MISTRAL_API_KEY=”…”
BRAVE_API_KEY=”…” # For the web search tool via MCP

#### Step 3: The "Model Factory" Pattern

To flexibly switch between cloud and local models without changing the agent's code, we'll use the factory pattern.

python

llm_factory.py

import os
from enum import Enum
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_mistralai import ChatMistralAI
from langchain_community.chat_models import ChatOllama

load_dotenv()

class ModelProvider(Enum):
OPENAI = “openai”
GEMINI = “gemini”
MISTRAL_API = “mistral_api”
OLLAMA = “ollama”

def get_llm(provider: ModelProvider, model_name: str = None):
“””A factory for creating LLM instances.”””
if provider == ModelProvider.OPENAI:
return ChatOpenAI(model=model_name or “gpt-4o-mini”, temperature=0)
elif provider == ModelProvider.GEMINI:
return ChatGoogleGenerativeAI(model=model_name or “gemini-1.5-flash”, temperature=0)
elif provider == ModelProvider.MISTRAL_API:
return ChatMistralAI(model=model_name or “mistral-large-latest”, temperature=0)
elif provider == ModelProvider.OLLAMA:
# Ensure you have Ollama running with the required model
# docker exec -it ollama ollama pull mistral
return ChatOllama(model=model_name or “mistral”, temperature=0)
raise ValueError(f”Unknown model provider: {provider}”)

Example usage

if name == “main“:
# local_llm = get_llm(ModelProvider.OLLAMA)
openai_llm = get_llm(ModelProvider.OPENAI)
response = openai_llm.invoke(“Explain the concept of RAG in three sentences.”)
print(response.content)

### Part 2: Project 1 — Vacancy Classification Agent

This agent demonstrates how to use LangGraph to create a **linear graph** with managed state. It will take a job description and sequentially classify it based on three parameters.

#### Step 1: Defining the State

The state is the "memory" of our graph, passed from one node to the next.

python

vacancy_classifier.py

from typing import TypedDict, Dict

class ClassificationState(TypedDict):
“””State for the classifier agent.”””
description: str # Source text
job_type: str # Job type (project/permanent)
category: str # Profession
search_type: str # Goal (looking for a job/performer)
classification_log: list # Debug log

#### Step 2: Implementing the Graph Nodes

Each node is a function that takes the state, performs its part of the work, and returns the updated state.

python
import asyncio
import json
from langchain_core.prompts import ChatPromptTemplate
from llm_factory import get_llm, ModelProvider

class VacancyClassifierAgent:
def init(self):
self.llm = get_llm(ModelProvider.OPENAI, model_name=”gpt-4o-mini”)

async def _classify_job_type(self, state: ClassificationState) -> ClassificationState:
    """Node 1: Determines the job type."""
    prompt = ChatPromptTemplate.from_messages([
        ("system", "Determine the job type. The answer must be 'project-based' or 'permanent'."),
        ("human", "Job description:\n\n{description}")
    ])
    chain = prompt | self.llm
    result = await chain.ainvoke({"description": state["description"]})

    state["job_type"] = result.content.strip()
    state["classification_log"].append("Determined job type.")
    return state

async def _classify_category(self, state: ClassificationState) -> ClassificationState:
    """Node 2: Determines the profession category."""
    # Categories can be loaded from a file or database
    categories = ["Python Developer", "Designer", "Marketer", "3D Animator"]
    prompt = ChatPromptTemplate.from_messages([
        ("system", f"Choose the most suitable category from the list: {', '.join(categories)}."),
        ("human", "Job description:\n\n{description}")
    ])
    chain = prompt | self.llm
    result = await chain.ainvoke({"description": state["description"]})

    state["category"] = result.content.strip()
    state["classification_log"].append("Determined category.")
    return state

async def _classify_search_type(self, state: ClassificationState) -> ClassificationState:
    """Node 3: Determines the search goal."""
    prompt = ChatPromptTemplate.from_messages([
        ("system", "Determine the author's goal. The answer must be 'looking for a job' or 'looking for a performer'."),
        ("human", "Job description:\n\n{description}")
    ])
    chain = prompt | self.llm
    result = await chain.ainvoke({"description": state["description"]})

    state["search_type"] = result.content.strip()
    state["classification_log"].append("Determined search goal.")
    return state
#### Step 3: Assembling and Running the Graph

We assemble the nodes into a single workflow.

python

… continuation of the VacancyClassifierAgent class …

from langgraph.graph import StateGraph, END

def build_graph(self):
    """Assembles the state graph."""
    workflow = StateGraph(ClassificationState)

    workflow.add_node("job_type_classifier", self._classify_job_type)
    workflow.add_node("category_classifier", self._classify_category)
    workflow.add_node("search_type_classifier", self._classify_search_type)

    workflow.set_entry_point("job_type_classifier")
    workflow.add_edge("job_type_classifier", "category_classifier")
    workflow.add_edge("category_classifier", "search_type_classifier")
    workflow.add_edge("search_type_classifier", END)

    return workflow.compile()

async def main():
agent = VacancyClassifierAgent()
graph = agent.build_graph()

description = "We are looking for an experienced Python developer to join our team full-time to work on a fintech project."

initial_state = ClassificationState(
    description=description,
    job_type="", category="", search_type="",
    classification_log=[]
)

final_state = await graph.ainvoke(initial_state)

print("--- Classification Result ---")
print(json.dumps(final_state, indent=2, ensure_ascii=False))

if name == “main“:
asyncio.run(main())

### Part 3: Project 2 — Assistant Agent with Tools (MCP)

This agent demonstrates **cyclical logic**, where it can repeatedly call tools to solve a task.

#### Step 1: Configuration Management

For agents interacting with the external world, robust configuration is essential.

python

mcp_agent_config.py

from dataclasses import dataclass, field
import os
from llm_factory import ModelProvider

@dataclass
class AgentConfig:
workdir: str = “./agent_workdir”
model_provider: ModelProvider = ModelProvider.OLLAMA

def __post_init__(self):
    """Post-initialization validation."""
    os.makedirs(self.workdir, exist_ok=True)
#### Step 2: Defining the State for Dialogue

The state will now store the message history.

python

mcp_agent.py

from typing import TypedDict, Annotated, Sequence
from langchain_core.messages import BaseMessage
import operator

class AgentState(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add]

#### Step 3: Implementing the Cyclical Graph

The graph will consist of two main nodes and a conditional edge that creates the "reason-action" loop.

python
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolExecutor
from langchain_mcp_adapters.langchain import V1ToolExecutor
from langchain_mcp_adapters.clients import MultiServerMCPClient
from llm_factory import get_llm
from mcp_agent_config import AgentConfig

class MCPAgent:
def init(self, config: AgentConfig):
self.config = config
self.llm = get_llm(config.model_provider)
self.tools = []
self.tool_executor = None

async def setup_tools(self):
    """Initializes tools via MCP."""
    mcp_config = {
        "filesystem": {
            "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", self.config.workdir],
            "transport": "stdio"
        },
        # Add brave-search if you have a BRAVE_API_KEY
    }
    mcp_client = MultiServerMCPClient(mcp_config)
    self.tools = await mcp_client.get_tools()
    self.tool_executor = ToolExecutor([V1ToolExecutor(tool) for tool in self.tools])

    # Bind the tools to the model
    self.llm = self.llm.bind_tools(self.tools)

def _should_continue(self, state: AgentState):
    """Conditional edge: decides whether to call a tool."""
    last_message = state['messages'][-1]
    if not last_message.tool_calls:
        return "end"
    return "continue"

def _call_model(self, state: AgentState):
    """Node 1: Call the LLM to make a decision."""
    response = self.llm.invoke(state['messages'])
    return {"messages": [response]}

def _call_tool(self, state: AgentState):
    """Node 2: Execute the tool call."""
    last_message = state['messages'][-1]
    tool_call = last_message.tool_calls[0]

    action = {"tool": tool_call["name"], "tool_input": tool_call["args"], "log": ""}
    response = self.tool_executor.invoke(action)

    return {"messages": [response]}

def build_graph(self):
    workflow = StateGraph(AgentState)
    workflow.add_node("agent", self._call_model)
    workflow.add_node("action", self._call_tool)

    workflow.set_entry_point("agent")
    workflow.add_conditional_edges(
        "agent",
        self._should_continue,
        {"continue": "action", "end": END}
    )
    workflow.add_edge("action", "agent")

    return workflow.compile()
#### Step 4: Running and Interacting

python

… continuation of mcp_agent.py …

import asyncio
from langchain_core.messages import HumanMessage
from tenacity import retry, stop_after_attempt, wait_fixed

@retry(stop=stop_after_attempt(3), wait=wait_fixed(1))
async def run_agent_task(graph, task):
“””Runs a task with error handling.”””
return await graph.ainvoke({“messages”: [HumanMessage(content=task)]})

async def main():
config = AgentConfig(model_provider=ModelProvider.OPENAI) # or OLLAMA
agent = MCPAgent(config)
await agent.setup_tools()
graph = agent.build_graph()

task = "Create a file named 'hello.txt' in the working directory and write 'Hello, world!' into it."
result = await run_agent_task(graph, task)

print("\n--- Agent's Final Response ---")
print(result['messages'][-1].content)

if name == “main“:
asyncio.run(main())
`` Here, we've added thetenacity` decorator for robustness—if the agent call fails due to a temporary network error, it will be automatically retried.

Conclusion

We have built two types of agents using modern practices:

  • A linear graph is excellent for tasks with a clear sequence of steps, such as ETL processes or multi-stage analysis.
  • A cyclical graph is the foundation for creating interactive assistants and autonomous agents capable of solving complex problems with tools.

The architectural patterns presented—the model factory, configuration management, separating logic into nodes, and using state graphs—are the fundamentals for building scalable and robust AI systems.

Leave a Reply

Your email address will not be published. Required fields are marked *