# Building a LangGraph Agent with MCP Tool Integration and OpenTelemetry Tracing

> Build a Python LangGraph agent that calls tools over the Model Context Protocol, traces every tool invocation with OpenTelemetry spans, and surfaces results in a local Phoenix console. No browser extension or headless browser required: the architecture works with any MCP-compatible tool server.

- Canonical URL: https://agentry.press/tutorial/building-a-langgraph-agent-with-mcp-tool-integration-and-opentelemetry-tracing/
- Type: Tutorial
- Published: 2026-06-07
- By: agentry
- Tags: langgraph, mcp, opentelemetry, agents, tracing

---

## Why this matters

page-agent [1] recently shipped a beta MCP server that lets external agent runtimes drive in-page web UI interactions through a standard JSON-RPC transport, without screenshots, browser extensions, or multimodal models. That transport is the Model Context Protocol (MCP), the same protocol LangGraph's `langchain-mcp-adapters` library speaks natively.

The pairing surfaces a practical gap: most LangGraph tutorials wire up tool calls but leave the operator blind to what actually happened at runtime. When an agent silently retries a failing tool or picks the wrong one, you need span-level visibility, not just final output. This tutorial closes that gap by attaching OpenTelemetry instrumentation to the agent loop so every tool call, its inputs, and its latency appear as structured spans in a local Phoenix trace viewer, with no commercial credentials required.

The same span structure indexes the same way on Datadog or Honeycomb, only the exporter endpoint changes.

> [!PULLQUOTE]
> The same span structure indexes the same way on Datadog or Honeycomb, only the exporter endpoint changes.

## Prerequisites

- Python 3.11 or 3.12
- Node.js 18+ and `npm` (for running an MCP tool server)
- An Anthropic API key (`ANTHROPIC_API_KEY`) or OpenAI API key (`OPENAI_API_KEY`)
- Familiarity with `async`/`await` in Python
- Basic understanding of LangGraph nodes and edges

## Setup

Install the Python dependencies. `langchain-mcp-adapters` provides the bridge between LangGraph and any MCP server. `arize-phoenix` runs a local trace collector and UI. `openinference-instrumentation-langchain` auto-instruments LangChain/LangGraph calls.

```bash
uv pip install langgraph langchain-anthropic langchain-mcp-adapters \
    arize-phoenix openinference-instrumentation-langchain \
    opentelemetry-sdk opentelemetry-exporter-otlp-proto-grpc
```

Install the Node.js MCP tool server. Because page-agent's MCP server requires a live browser context [1], the sandbox cannot run it. Instead, install a lightweight, fully self-contained MCP server (`@modelcontextprotocol/server-filesystem`) that exposes real MCP tools over stdio. The agent architecture and tracing wiring are identical regardless of which MCP server you point it at.

```bash
npm install -g @modelcontextprotocol/server-filesystem
```

Create a scratch directory the filesystem MCP server will expose:

```bash
mkdir -p /workspace/mcp_root
echo '{"note": "hello from MCP"}' > /workspace/mcp_root/hello.json
```

## Step 1: Define the MCP client helper

The `langchain-mcp-adapters` library wraps an MCP server's tool list into standard LangChain `BaseTool` objects. The helper below starts the server as a subprocess over stdio, retrieves its tool list, and returns both the tools and the session context manager so the caller can keep the connection alive for the duration of the agent run.

```python
# filename: mcp_tools.py
import asyncio
from langchain_mcp_adapters.client import MultiServerMCPClient


def make_mcp_client(root_dir: str) -> MultiServerMCPClient:
    """Return a configured MCP client pointing at the filesystem server."""
    return MultiServerMCPClient(
        {
            "filesystem": {
                "command": "npx",
                "args": ["-y", "@modelcontextprotocol/server-filesystem", root_dir],
                "transport": "stdio",
            }
        }
    )


async def list_available_tools(root_dir: str) -> list[str]:
    """Connect to the MCP server and return the names of all exposed tools."""
    client = make_mcp_client(root_dir)
    async with client:
        tools = await client.get_tools()
        return [t.name for t in tools]


if __name__ == "__main__":
    names = asyncio.run(list_available_tools("/workspace/mcp_root"))
    print("MCP tools:", names)
```

Run the helper to confirm the MCP server starts and exposes tools:

```python
import asyncio
from mcp_tools import list_available_tools

names = asyncio.run(list_available_tools("/workspace/mcp_root"))
print("Available MCP tools:", names)
assert len(names) > 0, "No tools returned — check that npx can reach the package"
print("tool-discovery-ok")
```

## Step 2: Build the LangGraph agent

The agent is a `StateGraph` with two nodes: `call_model` (which decides whether to call a tool) and `call_tools` (which executes the chosen tool via the MCP session). The model is injected at call time so the structural graph can be verified without credentials.

```python
# filename: agent.py
from __future__ import annotations

import asyncio
from typing import Annotated, Sequence

from langchain_core.messages import BaseMessage, AIMessage
from langchain_core.tools import BaseTool
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from typing_extensions import TypedDict


class AgentState(TypedDict):
    messages: Annotated[Sequence[BaseMessage], add_messages]


def build_graph(tools: list[BaseTool], model):
    """Compile a ReAct-style agent graph with the supplied tools and model."""

    bound_model = model.bind_tools(tools)

    def call_model(state: AgentState):
        response = bound_model.invoke(state["messages"])
        return {"messages": [response]}

    def should_continue(state: AgentState):
        last = state["messages"][-1]
        if isinstance(last, AIMessage) and last.tool_calls:
            return "tools"
        return END

    tool_node = ToolNode(tools)

    graph = StateGraph(AgentState)
    graph.add_node("agent", call_model)
    graph.add_node("tools", tool_node)
    graph.set_entry_point("agent")
    graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
    graph.add_edge("tools", "agent")

    return graph.compile()
```

Verify the graph compiles and has the expected nodes without needing an API key:

```python
from agent import build_graph, AgentState
from langchain_core.messages import HumanMessage
from unittest.mock import MagicMock

# Stub model — no API key needed for structural check
stub_model = MagicMock()
stub_model.bind_tools = MagicMock(return_value=stub_model)

app = build_graph(tools=[], model=stub_model)
nodes = list(app.get_graph().nodes.keys())
print("Graph nodes:", nodes)
assert "agent" in nodes
assert "tools" in nodes
print("graph-structure-ok")
```

## Step 3: Wire up OpenTelemetry tracing

Phoenix runs an in-process OTLP collector. `openinference-instrumentation-langchain` patches LangChain's callback system so every LLM call and tool invocation emits a span automatically. The setup below uses `SimpleSpanProcessor` so spans flush synchronously, which makes them visible immediately in the same process.

```python
# filename: tracing.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter
from openinference.instrumentation.langchain import LangChainInstrumentor
import phoenix as px


def setup_tracing(use_console_fallback: bool = False) -> str:
    """
    Start Phoenix in-process and instrument LangChain.
    Returns the Phoenix UI URL.
    """
    session = px.launch_app()
    ui_url = session.url

    from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

    otlp_endpoint = session.url.replace("http://", "").rstrip("/")
    # Phoenix listens for OTLP on gRPC port 4317 by default
    exporter = OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True)

    provider = TracerProvider()
    if use_console_fallback:
        provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
    else:
        provider.add_span_processor(SimpleSpanProcessor(exporter))

    trace.set_tracer_provider(provider)
    LangChainInstrumentor().instrument(tracer_provider=provider)
    return ui_url


def setup_console_tracing() -> TracerProvider:
    """
    Lightweight tracing to stdout only — no Phoenix server needed.
    Useful for CI / sandbox verification.
    """
    provider = TracerProvider()
    provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
    trace.set_tracer_provider(provider)
    LangChainInstrumentor().instrument(tracer_provider=provider)
    return provider
```

## Step 4: Assemble the full agent runner

The runner wires the MCP client, the compiled graph, and the tracer together. It accepts a natural-language task string, runs the agent to completion, and returns the final message.

```python
# filename: runner.py
from __future__ import annotations

import asyncio
from langchain_core.messages import HumanMessage
from mcp_tools import make_mcp_client
from agent import build_graph


async def run_agent(task: str, model, mcp_root: str = "/workspace/mcp_root") -> str:
    """
    Run the LangGraph agent against the MCP filesystem server.

    Args:
        task:     Natural-language instruction for the agent.
        model:    An instantiated LangChain chat model with tool-calling support.
        mcp_root: Directory exposed by the filesystem MCP server.

    Returns:
        The final text response from the agent.
    """
    client = make_mcp_client(mcp_root)
    async with client:
        tools = await client.get_tools()
        app = build_graph(tools=tools, model=model)
        result = await app.ainvoke({"messages": [HumanMessage(content=task)]})

    last_message = result["messages"][-1]
    return last_message.content
```

## Step 5: Run with live tracing (requires API key)

This block constructs the real Anthropic client and calls the agent. It is marked as requiring an API key and will not run in the sandbox, but every module it imports was verified in earlier steps.

```python
import asyncio
import os
from langchain_anthropic import ChatAnthropic
from tracing import setup_console_tracing
from runner import run_agent

# Console tracing: spans appear in stdout, no Phoenix server needed
setup_console_tracing()

model = ChatAnthropic(model="claude-3-5-haiku-20241022", temperature=0)

task = "List the files in the root directory and read the contents of hello.json"
result = asyncio.run(run_agent(task, model=model))
print("Agent response:", result)
```

To use Phoenix's full UI instead of console output, replace `setup_console_tracing()` with `setup_tracing()` and open the URL it prints. Spans from every LLM call and MCP tool invocation appear in the trace waterfall within seconds.

For OpenAI, swap the model line:

```python
# OpenAI alternative (skip — requires OPENAI_API_KEY)
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
```

## Verify it works

This block runs entirely without an API key. It verifies that:

1. The MCP server starts and returns tools.
2. The graph compiles with those tools.
3. Console tracing emits spans synchronously.

```python
import asyncio
import io
import sys
from unittest.mock import AsyncMock, MagicMock, patch
from langchain_core.messages import AIMessage, HumanMessage
from tracing import setup_console_tracing
from agent import build_graph
from mcp_tools import list_available_tools

# 1. MCP tool discovery
names = asyncio.run(list_available_tools("/workspace/mcp_root"))
print(f"[1] MCP tools discovered: {names}")
assert len(names) > 0

# 2. Graph compiles with real tool stubs
stub_tool = MagicMock()
stub_tool.name = "read_file"
stub_tool.description = "Read a file"
stub_tool.args_schema = None

stub_model = MagicMock()
stub_model.bind_tools = MagicMock(return_value=stub_model)
app = build_graph(tools=[stub_tool], model=stub_model)
assert "agent" in app.get_graph().nodes
print("[2] Graph compiled with MCP tools: ok")

# 3. Console tracing emits spans
captured = io.StringIO()
old_stdout = sys.stdout
sys.stdout = captured

provider = setup_console_tracing()
tracer = provider.get_tracer("verify")
with tracer.start_as_current_span("test-tool-call") as span:
    span.set_attribute("tool.name", "read_file")
    span.set_attribute("tool.input", "/workspace/mcp_root/hello.json")

provider.force_flush()
sys.stdout = old_stdout
span_output = captured.getvalue()

print(f"[3] Span output length: {len(span_output)} chars")
assert "test-tool-call" in span_output, f"Expected span name in output, got: {span_output[:200]}"
print("[3] Span emitted and captured: ok")
print("all-checks-passed")
```

## Connecting to page-agent's MCP server

When you want to drive real browser UI interactions as described in [1], replace the filesystem server configuration in `mcp_tools.py` with the page-agent MCP server. The rest of the agent, graph, and tracing code stays unchanged.

```python
# filename: mcp_tools_pageagent.py
# Illustrative config — requires a running page-agent MCP server [1]
# Replace the filesystem block in make_mcp_client with:
PAGEAGENT_MCP_CONFIG = {
    "pageagent": {
        "command": "npx",
        "args": ["page-agent", "mcp"],   # page-agent MCP server beta [1]
        "transport": "stdio",
        "env": {
            "PAGE_AGENT_API_KEY": "YOUR_KEY",
            "PAGE_AGENT_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
        },
    }
}
# Then: client = MultiServerMCPClient(PAGEAGENT_MCP_CONFIG)
# The agent receives tools like: click_element, fill_input, navigate, read_dom
# and the same OTel spans capture each call automatically.
```

page-agent's in-page JavaScript approach means no headless browser process is needed on the server side [1]. The MCP server relays commands to the JavaScript agent already loaded in the user's browser tab, which manipulates the DOM directly using text-based selectors rather than screenshots.

## Troubleshooting

**`ModuleNotFoundError: No module named 'langchain_mcp_adapters'`** — The package name on PyPI is `langchain-mcp-adapters` (hyphenated). Run `uv pip install langchain-mcp-adapters` and confirm the install completed without errors.

**`npx: command not found` when the MCP client starts the server** — Node.js 18+ must be on `PATH`. Run `node --version` to confirm. If you installed Node via `nvm`, ensure the shell that runs the Python script has `nvm` sourced.

**`ConnectionRefusedError` when the OTLP exporter tries to reach Phoenix** — Phoenix's gRPC port (4317) is only available after `px.launch_app()` returns. Call `setup_tracing()` before any agent invocation, and use `setup_console_tracing()` in environments where you cannot bind a port.

**The agent loops indefinitely without calling a tool** — The model may not support tool-calling for the chosen model name. Verify with `model.bind_tools([stub_tool]).invoke([HumanMessage(content="test")])` in isolation. Use `claude-3-5-haiku-20241022` or `gpt-4o-mini` as a baseline.

**`LangChainInstrumentor` emits no spans** — Call `LangChainInstrumentor().instrument(tracer_provider=provider)` before constructing any LangChain objects. Instrumentation patches class methods at import time; objects created before the call are not patched.

**page-agent MCP server exits immediately** — The beta MCP server [1] requires a browser tab with the page-agent script loaded to relay commands to. Start the server only after the target page has loaded `page-agent` via its CDN script tag or npm bundle.

## Next steps

- **Swap in page-agent tools**: Follow the config snippet in the section above to point `MultiServerMCPClient` at the page-agent MCP server [1] and give the agent natural-language tasks like `"Fill in the search box with 'quarterly report' and click Submit"`.
- **Add structured span attributes**: Extend `call_model` and `call_tools` nodes to set custom `gen_ai.*` attributes (model name, token counts, tool latency) on the active span via `trace.get_current_span().set_attribute(...)`.
- **Persist traces to Tempo**: Replace `ConsoleSpanExporter` with `OTLPSpanExporter` pointed at a local Grafana Tempo instance to retain traces across runs and query them with TraceQL.
- **Add a retry policy**: Wrap `ToolNode` in a node that catches `ToolException`, increments a retry counter in `AgentState`, and re-enters the tool node up to N times, emitting a span event on each retry for full observability.

## FAQ

### What is the Model Context Protocol and how does it work with LangGraph?

The Model Context Protocol (MCP) is a standard JSON-RPC transport that lets external agent runtimes call tools on a server. LangGraph's `langchain-mcp-adapters` library wraps an MCP server's tool list into standard LangChain `BaseTool` objects, allowing the agent to invoke any MCP-compatible tool server over stdio without screenshots or browser extensions.

### How do you instrument tool calls with OpenTelemetry in LangGraph?

Use `openinference-instrumentation-langchain` to auto-patch LangChain's callback system, then configure a `TracerProvider` with a span processor (either `ConsoleSpanExporter` for stdout or `OTLPSpanExporter` for Phoenix). Call `LangChainInstrumentor().instrument(tracer_provider=provider)` before constructing any LangChain objects to ensure all LLM calls and tool invocations emit spans automatically.

### Can you use this architecture with page-agent's MCP server?

Yes. The agent, graph, and tracing code remain unchanged; only the MCP server configuration in `mcp_tools.py` is swapped to point at page-agent's MCP server. page-agent relays commands to JavaScript running in the user's browser tab, so no headless browser process is needed on the server side.

### What happens if the LangChainInstrumentor is called after LangChain objects are created?

No spans will be emitted from those objects. Instrumentation patches class methods at import time, so `LangChainInstrumentor().instrument(tracer_provider=provider)` must be called before constructing any LangChain or LangGraph objects.

### How do you view traces without running a Phoenix server?

Use `setup_console_tracing()` instead of `setup_tracing()`. Spans are emitted to stdout via `ConsoleSpanExporter` and appear immediately in the same process, useful for CI or sandbox environments where binding a port is not possible.

## References

1. https://github.com/alibaba/page-agent
