Home / Articles

From a One-Node Chatbot to an MCP-Backed Agent in LangGraph

Build a LangGraph app one layer at a time: state and reducers, edges, tool loops, checkpointed threads, three streaming modes, and tools served over MCP.

Publisher: REACTAPP.TOP18 min read4252 words

This article is published in English.

Sources: Building AI Applications with LangGraph: From a Basic Chatbot to Tools, Memory, Streaming and MCP

Most LLM prototypes start as a single call: send a prompt, print the reply. That shape breaks down as soon as the application needs a web search, a decision about which step runs next, memory of earlier turns, or live progress in the UI. LangGraph addresses this by making the control flow an explicit graph over a shared state. This walkthrough grows one application from a single-node chatbot into a tool-using ReAct agent with checkpointed memory, streaming, and tools served by a separate MCP server, and it points out the details that tend to trip people up along the way.

The mental model: state flows through nodes along edges

A plain LLM integration follows a straight line from prompt to model to answer. LangGraph asks you to think in a cycle instead: a state object enters the graph, nodes read it and return changes, edges decide which node runs next, and the updated state comes out at the end. The three building blocks are therefore:

  • State: the data that travels between steps, such as the message history.
  • Nodes: ordinary functions that do one piece of work and return a partial update.
  • Edges: the rules that connect nodes, including the start and end of a run.

The simplest possible graph has one node between the entry point and the exit:

                ┌─────────────┐
                │    State    │
                └──────┬──────┘
                       │
                       ▼
                ┌─────────────┐
                │    Node     │
                │   chatbot   │
                └──────┬──────┘
                       │
                       ▼
                ┌─────────────┐
                │     END     │
                └─────────────┘

Keeping "what data exists" separate from "what runs in which order" is the core design choice. You can change the flow without touching how data is stored, and you can inspect the state at any point without reverse-engineering a chain of function calls.

Why a graph instead of a chain of calls

A minimal chatbot is a straight pipe from the user to the model and back:

User
 ↓
LLM
 ↓
Response

Now add real requirements: a web search, several tools, branching logic, memory, a human approval step, more than one agent, streamed output, and different behavior depending on what the model returns. Each of these adds a branch or a loop, and nested if statements around API calls quickly become hard to follow. Even the most common case, "call a tool if the model asks for one", already contains a cycle:

                         ┌─────────────┐
                         │     LLM     │
                         └──────┬──────┘
                                │
                         Need a tool?
                         /           \
                       Yes            No
                        ↓              ↓
                     Tool          Response
                        │
                        ↓
                       LLM

Once the flow is a graph, branches and loops become visible objects you can name, test and draw, rather than control flow hidden inside helper functions.

Step 1: a single-node chatbot with StateGraph

The first version is deliberately trivial, with one node wired between the start and end markers:

START
  ↓
Chatbot Node
  ↓
END

Defining the state and its reducer

For a chatbot the state mostly needs the conversation history. A first attempt is a TypedDict with a messages list:

from typing import TypedDict

class State(TypedDict):
    messages: list

Conceptually, that list accumulates the alternating human and AI turns:

State
 └── messages
      ├── HumanMessage
      ├── AIMessage
      ├── HumanMessage
      └── AIMessage

One detail matters a lot here. In LangGraph, each key in the state can have a reducer that decides how a node's return value is merged into the existing value. A bare list annotation has no reducer, so a node that returns {"messages": [reply]} replaces the whole list instead of appending to it. For chat history you almost always want the add_messages reducer, declared as Annotated[list, add_messages] (or inherit from the prebuilt MessagesState). With it, returned messages are appended, and messages with a matching ID are updated in place. The snippet above is fine as a mental picture, but use the annotated form in real code.

Writing the chatbot node

A node is just a function that receives the current state and returns the keys it wants to change. The chatbot node passes the history to the model and returns the reply:

def chatbot(state: State):
    return {
        "messages": [
            llm.invoke(state["messages"])
        ]
    }

Read it from the inside out. The function is handed the whole state:

state

It pulls out the conversation so far:

state["messages"]

It sends that history to the model:

llm.invoke(state["messages"])

And it wraps the model's answer in a dictionary keyed by the field it updates. Note that the node does not mutate the state directly; it returns a partial update and lets LangGraph merge it:

{
    "messages": [
        llm.invoke(state["messages"])
    ]
}

As a data-flow picture, the node sits between two versions of the same state:

              State
                │
                │ messages
                ▼
          ┌─────────────┐
          │  Chatbot    │
          │    Node     │
          └──────┬──────┘
                 │
                 │ LLM Response
                 ▼
               State

Registering the node

The function becomes part of the graph once it is registered on the builder under a name:

graph_builder.add_node(
    "llmchatbot",
    chatbot
)

The first argument is the node's identifier, which edges and logs will refer to:

"llmchatbot"

The second argument is the callable that runs whenever execution reaches that node:

chatbot

Step 2: wiring edges and compiling

A node on its own never runs; edges tell LangGraph how to get there and where to go afterwards. This graph needs two. The first connects the special START marker to the chatbot:

graph_builder.add_edge(
    START,
    "llmchatbot"
)

In diagram form, execution enters the graph and goes straight to the node:

START
  ↓
llmchatbot

The second edge sends control from the chatbot to END, which finishes the run:

graph_builder.add_edge(
    "llmchatbot",
    END
)

That corresponds to:

llmchatbot
  ↓
 END

Together they form the complete, if minimal, graph:

       START
         │
         ▼
 ┌────────────────┐
 │   llmchatbot   │
 │      Node      │
 └────────┬───────┘
          │
          ▼
         END

Compiling and invoking

Compiling validates the structure (for example, that every edge points to a known node) and produces a runnable object:

graph = graph_builder.compile()

You run it by passing an initial state. Plain role/content dictionaries are accepted and converted into LangChain message objects:

result = graph.invoke({
    "messages": [
        {
            "role": "user",
            "content": "What is LangGraph?"
        }
    ]
})

Under the hood, the input state enters at START, the chatbot node calls the model, the reply is merged into the state, and the run ends:

Input State
    ↓
START
    ↓
llmchatbot
    ↓
LLM
    ↓
Updated State
    ↓
END

The return value of invoke is the final state, so the model's answer is the last entry in result["messages"].

Step 3: drawing the graph

A compiled graph can render itself. In a Jupyter notebook, IPython's display helpers show a PNG generated from a Mermaid description of the graph:

from IPython.display import Image, display

display(
    Image(
        graph.get_graph().draw_mermaid_png()
    )
)

For this graph the picture is short:

   ┌───────┐
   │ START │
   └───┬───┘
       │
       ▼
┌──────────────┐
│  llmchatbot  │
└──────┬───────┘
       │
       ▼
   ┌───────┐
   │  END  │
   └───────┘

The value is not obvious with three boxes, but it grows quickly. Once you have conditional edges, tool nodes and loops, a rendered diagram is the fastest way to confirm that the graph you built is the graph you meant to build. Be aware that draw_mermaid_png() renders through an external Mermaid service by default, so it needs network access; if that is not available, draw_mermaid() returns the Mermaid text, which you can render elsewhere.

Step 4: giving the model a search tool

The chatbot can only answer from what the model learned during training. A question about recent developments in agentic AI needs fresh information, so the next step adds a web search tool. The example uses Tavily Search, and the intended flow looks like this:

User
 ↓
LLM
 ↓
Search required?
 ↓
Tavily Search
 ↓
Search Results
 ↓
LLM
 ↓
Final Answer

Binding tools is not the same as running them

The first half of the job is tool binding: telling the model which tools exist and what arguments they take.

llm_with_tools = llm.bind_tools(
    [tavily_search]
)

After binding, the model can respond with a structured tool call ("call tavily_search with this query") instead of plain text. It cannot execute that call itself. Something in your application has to notice the tool call, run the tool, append the result as a ToolMessage, and send everything back to the model. Giving a model access to a tool does not make it an agent; the surrounding execution flow does. That flow is exactly what the graph provides.

Step 5: turning it into a ReAct loop

ReAct combines reasoning and acting: the model thinks about the task, decides whether it needs to act, performs the action through a tool, observes the result, and continues. In graph terms, the straight line from before:

START
  ↓
LLM
  ↓
END

becomes a graph with a decision point and a path that leads back to the model:

                 START
                   │
                   ▼
                 Agent
                   │
                   ▼
              ┌─────────┐
              │   LLM   │
              └────┬────┘
                   │
             Need a tool?
              /         \
            Yes          No
             │            │
             ▼            ▼
          Tool          END
             │
             ▼
           Agent
             │
             ▼
            LLM

In LangGraph code, this decision is a conditional edge. The prebuilt ToolNode executes whatever tool calls the last AI message contains, and tools_condition routes to that node when tool calls are present or to END when they are not. An edge from the tool node back to the model closes the loop. The prebuilt create_react_agent helper assembles the same structure for you; check the current LangGraph docs for its exact location, since prebuilt agent helpers have moved between packages over time.

Tracing one pass through the loop

Suppose the user asks for the latest news about LangGraph. One run of the loop produces this sequence of messages and steps:

HumanMessage
     ↓
    Agent
     ↓
    LLM
     ↓
"I need current information."
     ↓
Tool Call
     ↓
Tavily Search
     ↓
Search Results
     ↓
ToolMessage
     ↓
    Agent
     ↓
    LLM
     ↓
Final Answer

The important structural difference is the cycle. The first chatbot was a straight line:

START → LLM → END

The ReAct graph can revisit the model as many times as it needs:

  START
    ↓
  Agent
    ↓
  LLM
    ↓
  Tool?
 ┌───────┐
 │       │
Yes      No
 │       │
 ↓       ↓
Tool    END
 │
 ↓
Agent

Because loops can, in principle, run forever, LangGraph enforces a recursion limit on each run (configurable via the recursion_limit setting). If a model keeps requesting tools, you get an error instead of an endless bill.

Step 6: remembering conversations with MemorySaver

So far every call to invoke starts from scratch. Consider two turns. First the user introduces themselves:

User:
My name is Himanshi.

Then, in a separate request, asks a follow-up:

User:
What is my name?

The second turn can only be answered if the graph still has the first one. LangGraph solves this with a checkpointer: after each step it saves a snapshot of the state, and the next run on the same conversation resumes from that snapshot. MemorySaver is the simplest implementation:

             ┌─────────────┐
             │    Agent    │
             └──────┬──────┘
                    │
                    ▼
              ┌───────────┐
              │   State   │
              └─────┬─────┘
                    │
                    ▼
             ┌─────────────┐
             │ MemorySaver │
             └─────────────┘

You attach it at compile time with graph_builder.compile(checkpointer=memory). Instead of every call being an isolated execution, each run now belongs to a conversation, and its state is persisted between runs.

Threads keep conversations apart

The key that ties runs together is a thread ID, passed in the config as {"configurable": {"thread_id": "..."}}. Each thread has its own history and its own current state:

Thread 1
 ├── Message 1
 ├── Message 2
 ├── Message 3
 └── Current State

Thread 2
 ├── Message 1
 ├── Message 2
 └── Current State

So two users on two threads never see each other's messages:

User A → Thread A → State A

User B → Thread B → State B

Two caveats apply. MemorySaver keeps everything in process memory, so all history disappears on restart and is not shared across server instances; production deployments use a persistent checkpointer such as the Postgres or SQLite ones. In recent releases the class is also exported as InMemorySaver. For a closer look at what a checkpoint actually stores, see how checkpoints, writes and blobs fit together in InMemorySaver.

Step 7: streaming state as the graph runs

Everything above used the blocking call:

graph.invoke(...)

It waits for the whole graph to finish before returning. With multi-step agents that can mean several seconds of nothing on screen, which feels broken to users. The compiled graph also exposes stream, which yields output while execution is still in progress.

stream_mode="values": the full state after each step

The first mode to know is:

stream_mode="values"

Used in a loop, it yields a snapshot of the entire state after every step:

for chunk in graph.stream(
    input_state,
    stream_mode="values"
):
    print(chunk)

Each chunk shows what the whole state looks like at that moment, so you see it grow as nodes and tools run:

START
  ↓
State Update #1
  ↓
Node Execution
  ↓
State Update #2
  ↓
Tool Execution
  ↓
State Update #3
  ↓
END

This is convenient when the state is small and you simply want to render "the latest version" each time.

stream_mode="updates": only what each node changed

The second mode is:

stream_mode="updates"

The difference is what each chunk contains. In one mode you receive the complete state:

values

In the other you receive only the delta, keyed by the name of the node that produced it:

updates

A quick way to remember the distinction:

  • values answers "what does the state look like right now?"
  • updates answers "what did this particular node just change?"

updates is usually the better choice for debugging and logging, because it tells you which node was responsible for each change and keeps chunks small when the state is large. Neither mode streams individual tokens from the model; for token-by-token output there is a separate messages stream mode, and you can pass a list of modes to receive several at once.

Step 8: fine-grained visibility with astream_events

State snapshots and node deltas tell you what changed, but not everything that happened. astream_events goes one level deeper and emits an event for each lifecycle moment of every component inside the run:

Graph Execution
      │
      ├── Graph Started
      │
      ├── Node Started
      │
      ├── LLM Started
      │
      ├── LLM Streaming
      │
      ├── LLM Completed
      │
      ├── Tool Started
      │
      ├── Tool Completed
      │
      ├── Node Completed
      │
      └── Graph Completed

You consume it asynchronously. Note that the print call in the snippet below needs to be indented inside the loop body; as shown, Python would reject it with an indentation error:

async for event in graph.astream_events(
    input_state,
    version="v2"
):

print(event)

Each event carries a type (for example on_chat_model_stream or on_tool_start), the name of the component, and its data. That makes the event stream useful for:

  • debugging and structured logging
  • general observability of agent runs
  • tracking every LLM call and every tool call
  • building a custom streaming UI that shows tokens and tool activity
  • understanding where time is spent

Engineers with a backend background will recognize the pattern: it is closer to tracing a distributed request than to reading a single function's return value. The question shifts from "did the agent work?" to "what exactly did the agent do, in what order, and how long did each part take?"

How the pieces stack up

Looking back, each step added one capability to the same skeleton. It started with a single model call:

START
 ↓
LLM
 ↓
END

Tools added a round trip to the outside world:

START
 ↓
LLM
 ↓
Tool
 ↓
LLM
 ↓
END

Checkpointing made the loop remember:

START
 ↓
Agent
 ↓
LLM ↔ Tools
 ↓
Memory / Checkpoint
 ↓
END

Streaming and events exposed the run to the UI and to monitoring:

Graph
 ↓
Events
 ↓
UI / Logs / Observability

None of these steps required rewriting the earlier ones; each is a new node, edge, compile option or method call on the same graph.

Step 9: moving tools behind an MCP server

Up to now the tools lived inside the application. The Model Context Protocol (MCP) takes a different approach: a separate server exposes tools (and resources), and any MCP client can discover and call them. The application no longer needs to contain every tool implementation:

                 ┌──────────────┐
                 │ MCP Client   │
                 └──────┬───────┘
                        │
                        │
                        ▼
                 ┌──────────────┐
                 │ MCP Server   │
                 └──────┬───────┘
                        │
             ┌──────────┼──────────┐
             ↓          ↓          ↓
          add()     multiply()   weather()

A minimal server with three tools

A small learning server is enough to see the architecture. It exposes an addition tool:

add(a, b)

a multiplication tool:

multiply(a, b)

and a weather tool that takes a city name:

weather(city)

The weather tool does not call a real weather service. It returns a fixed string, which keeps the focus on the protocol rather than on API keys:

"It's sunny in Mumbai."

The point is the round trip, not the forecast:

Client
  ↓
MCP Server
  ↓
Tool
  ↓
Result
  ↓
Client

Tools no longer have to live in the consuming app

The larger idea is that tools can be hosted independently of the applications that use them. The application talks to an MCP client, the client speaks the protocol to the server, and the server owns the implementations:

                Application
                     │
                     ▼
                 MCP Client
                     │
               MCP Protocol
                     │
                     ▼
                 MCP Server
                /     |      \
               /      |       \
              ↓       ↓        ↓
            Add    Multiply   Weather

That makes reusable tool servers possible. One team might run a server that exposes a whole category of capabilities:

Database Tools
File Tools
Git Tools
Search Tools
Weather Tools
Internal APIs

and several different agents, chat clients or IDE integrations could consume the same server without duplicating code.

Step 10: combining LangGraph and MCP

The two technologies solve different halves of the problem. LangGraph manages the workflow: state, routing, loops, memory and streaming. MCP standardizes how tools and resources are exposed. In a combined design, the agent's tool node talks to an MCP client instead of calling local functions:

                          User
                           │
                           ▼
                     LangGraph Agent
                           │
                           ▼
                          LLM
                           │
                    Need a tool?
                           │
                           ▼
                      MCP Client
                           │
                           ▼
                      MCP Server
                    /      |       \
                   ↓       ↓        ↓
                 Math    Search   Weather

The agent does not need to know how a tool is implemented. It only needs to know which capabilities exist and how to call them, and the MCP server takes care of the rest. In Python, the langchain-mcp-adapters package is a common way to load MCP tools as LangChain tools so they can be bound to a model and used in a ToolNode. The existing article on standardizing tool integration in LangGraph with MCP covers that integration in more depth.

A complete mental model

Putting everything together, a LangGraph agent looks like this: the user's input becomes state, nodes operate on it, the model decides whether a tool is needed, tool results feed back into the model, and the run ends with a response:

                          USER
                           │
                           ▼
                    ┌────────────┐
                    │ LangGraph  │
                    │   Agent    │
                    └──────┬─────┘
                           │
                           ▼
                         STATE
                           │
             ┌─────────────┼─────────────┐
             │             │             │
             ▼             ▼             ▼
           NODE          NODE          NODE
             │             │             │
             └─────────────┼─────────────┘
                           │
                           ▼
                         LLM
                           │
                     Need a tool?
                      /          \
                    Yes           No
                     │             │
                     ▼             ▼
                  TOOL          RESPONSE
                     │
                     ▼
                  RESULT
                     │
                     └──────► LLM
                               │
                               ▼
                              END

Around that core loop sit the capabilities you switch on as needed:

              ┌──────────────────────────────┐
              │          Capabilities        │
              │                              │
              │  Memory / Checkpointing      │
              │  Streaming                   │
              │  Event Tracking              │
              │  MCP                         │
              │  Tools                       │
              └──────────────────────────────┘

Where LangChain ends and LangGraph begins

Since LangGraph is usually learned after LangChain, it helps to separate their roles. LangChain supplies the building blocks for LLM applications: model wrappers, messages, tools, prompts and structured output. LangGraph orchestrates those blocks into explicit, stateful workflows. They are complementary layers, not competing choices, and most LangGraph code uses LangChain components inside its nodes.

Lessons that carry over to larger graphs

A handful of principles from this small build apply unchanged to much bigger systems.

State is the backbone

Rather than threading data through function arguments, every node reads from and writes to one shared state, and reducers define how updates merge:

State
 ↓
Node
 ↓
Updated State
 ↓
Next Node

Nodes are units of work

Each node should do one recognizable job, which makes complex workflows easier to decompose and test:

chatbot node
search node
tool node
validation node
human approval node

Edges encode the workflow

An edge answers one question: once this node finishes, where does execution go next? Plain edges give sequences; conditional edges give branches and loops.

Diagrams beat code reading

For a graph with many nodes and conditional paths, a rendered diagram communicates the architecture faster than reading the builder code:

Node → Decision → Tool → Agent → END

Checkpoints are more than chat history

A checkpointer persists the full graph state, not just messages. That is what enables multi-turn conversations, and it is also the foundation for long-running agents, human-in-the-loop pauses and resuming after failures.

Streaming serves both users and developers

Streaming improves perceived responsiveness, and the same output helps you debug and observe runs.

An agent run is many operations

With astream_events it becomes clear that one agent call is a nested sequence of graph, node, model and tool operations:

Graph
 ↓
Node
 ↓
LLM
 ↓
Tool
 ↓
LLM
 ↓
Node
 ↓
Graph

The more complex the application, the more you need that visibility.

MCP decouples tools from applications

Tools exposed through MCP can be shared across applications and evolved independently of the agents that call them.

Where to go next

This foundation opens several directions worth exploring: conditional edges and parallel branches, human-in-the-loop interrupts, long-term memory and persistent checkpoints, retrieval-augmented generation inside a graph, multi-agent systems, deeper MCP integrations, agent observability and evaluation, production deployment, and fault tolerance with retries.

The common thread is that an AI application is not just a model producing text. It is a system designed around the model, one that can take in a request, decide, act, observe the result, remember it, and continue:

Understand
    ↓
Decide
    ↓
Act
    ↓
Observe
    ↓
Remember
    ↓
Continue

A graph is a natural way to express that loop.

Wrapping up

The whole journey started from the smallest possible graph:

START → Chatbot → END

and grew, one layer at a time, into a system with tools, an agent loop, memory, streaming and MCP:

              ┌─────────────┐
              │    LLM      │
              └──────┬──────┘
                     │
                     ▼
              ┌─────────────┐
              │    Tools    │
              └──────┬──────┘
                     │
                     ▼
              ┌─────────────┐
              │    Agent    │
              └──────┬──────┘
                     │
          ┌──────────┼──────────┐
          ↓          ↓          ↓
       Memory    Streaming     MCP
          │          │          │
          └──────────┼──────────┘
                     ↓
                AI Application

Seen together, the features stop looking like a list and start looking like one architecture:

  • State carries data and, through checkpoints, memory; give message lists the add_messages reducer.
  • Nodes do the work and return partial updates.
  • Edges define the flow, and conditional edges turn it into a loop.
  • The model supplies reasoning and language; tools let it act on the outside world.
  • Streaming and events make every run observable, from UI updates to traces.
  • MCP standardizes how tools are exposed, so they can live outside the agent that uses them.