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.
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/awaitin 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.
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.
npm install -g @modelcontextprotocol/server-filesystem
Create a scratch directory the filesystem MCP server will expose:
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.
# 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:
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.
# 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:
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.
# 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.
# 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.
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:
# 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:
- The MCP server starts and returns tools.
- The graph compiles with those tools.
- Console tracing emits spans synchronously.
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.
# 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
MultiServerMCPClientat 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_modelandcall_toolsnodes to set customgen_ai.*attributes (model name, token counts, tool latency) on the active span viatrace.get_current_span().set_attribute(...). - Persist traces to Tempo: Replace
ConsoleSpanExporterwithOTLPSpanExporterpointed at a local Grafana Tempo instance to retain traces across runs and query them with TraceQL. - Add a retry policy: Wrap
ToolNodein a node that catchesToolException, increments a retry counter inAgentState, 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.