Debug Production Issues with AI Agents: Uber's MCP Blueprint

• Tapovan

In a September 2026 talk, Uber engineer Kriti Dangi said Uber's engineers receive about 50,000 issues a year from its in-app bug reporter and its crash pipeline. She said 90% of those issues affect customers, and an average issue took almost 28 days to resolve. Her team built Debug Assist, a LangGraph harness that calls MCP servers, to get a root cause in under 30 minutes. A small team can debug production issues with AI agents too, because what makes it work is the design rather than Uber's internal tools. Collect the evidence deterministically, give the model a pruned slice, cap its turns, and require human approval before every write.

This post walks through that pipeline node by node and maps each signal to an open MCP server. It then builds a working version in Python that I ran end to end. If MCP is new to you, start with this MCP servers course for beginners.

What an AI debugging agent should actually do

When you debug production issues with AI agents, the agent can work at four levels of autonomy. Each level needs more trust than the one before it.

  1. Triage: classify the issue, set a priority and find the owning team and on-call engineer.
  2. Root cause: return a hypothesis with evidence, such as log lines, a trace, a deploy or a commit.
  3. Mitigation: turn off a feature flag or roll back a bad deployment, but only after a human approves.
  4. Validated fix: write a patch, reproduce the failure in a test, show the test now passes and open a PR.

The build below reaches level 3. Level 4 is where Uber spends its most expensive model, and that part is covered at the end of the worked example.

How Uber's Debug Assist pipeline works, node by node

All of this comes from the talk, and Uber has published no engineering write-up of it. According to the Architecture chapter (from 9:05), Debug Assist is a LangGraph pipeline that mixes deterministic nodes with LLM nodes. Dangi's phrase for this was "we control the plan". The model does not decide the overall workflow. It reasons inside the steps it is given.

Deterministic context collector

The first node does not use an LLM. Uber gives cost, latency and context bloat as the reasons. The collector pulls logs, which can run to gigabytes, prunes them, and then passes the result on. Fetching logs is a query you can write in code, so asking a model to work out how to fetch them wastes turns. The build below copies this pattern.

Classification and an AI agent for root cause analysis

The next node classifies the crash as Uber code, third-party, infrastructure or network, and gives it a confidence score. It then calls MCP servers for Jaeger traces, logs and incident data. The node starts parallel subagents. One builds a breadcrumb timeline. Another correlates the crash with a release. In the Q&A (from 22:10), Dangi explained that the offending-commit subagent only searches between the known good and bad versions and within the relevant module (rider, driver or Eats), not the whole git history. The RCA node runs on Sonnet with a turn limit. Her rule of thumb was that an agent taking more than about 20 turns is usually heading in the wrong direction.

If the classification feeds into routing, make it a typed LLM decision with guardrails rather than free text, so the router never has to parse prose.

Mitigate first: roll back the flag before fixing the code

The pipeline does not wait for a code fix. Uber correlates the crash with one of its tens of thousands of feature flags through a feature-flag MCP server and rolls that flag back. One demo in the talk (from 6:03) shows a P1 push-notification crash that was caught at 5% rollout and fixed on the same day. For most teams, the equivalent is turning off the flag or rolling back a bad deployment, and both are writes that need approval.

Fix, validate in a loop, open the PR

The fix node runs on Opus with a higher turn limit. Validation runs a Bazel test that reproduces the failure, either as a unit test or as a mobile end-to-end test on simulators. The Q&A describes a repro ladder: unit, then integration or component, then a simulator seeded with the captured network strength, battery level and feature flags. Each rung has a fixed retry cap. The resulting PR is linked to the Jira ticket and the original issue, then posted to Slack. On a large codebase, narrow the search before the expensive model starts editing. One way is to rank candidate code files in parallel with a cheap model.

Debugging agent skills: fixed harness, swappable knowledge

Dangi said the harness itself never changes. Each node loads skills and plugins at runtime based on the agent type. These are markdown files that describe domain knowledge, and they come from an internal marketplace of about 3,000 plugins. Uber runs about 8 agents across 5 monorepos, packaged as PEX binaries in per-agent Docker images. When developers correct the Ask-AI chat, those corrections are fed back into the skills. Jaeger now offers a public version of this idea: its MCP endpoint has a read_skill tool that serves built-in markdown skills such as error-root-cause and detect-n-plus-one.

What Uber reports, and where the numbers disagree

These figures are Uber's own claims and cannot be verified independently. The talk and the published description do not agree, so the table lists both.

MetricIn the talk (2026-09-11)In the video description and conference abstract
RCAs per monthAbout 9,000 (Impact chapter, from 19:25)5,000
Average time to fix before Debug Assist"Almost 28 days" (chapter from 3:02)15 days
Success metricPR merge rate rose from 5% to about 48% (from 19:25)"Acceptance rate" rose from 5% to 30%. This may be a different metric.
RCA accuracyAbout 50% matched the developer's actual fix, and about 40% of the rest were "in the right direction"Not stated
Jira resolutionAbout 2x better, per UberNot stated
ScaleAbout 30 unique subagents and a marketplace of about 3,000 plugins11 MCP servers, 5 plugins, six languages

Watch: Building a Debugging Agent Harness at Uber - Kriti Dangi

The talk explains the reasoning behind the design: why context collection is deterministic, why each node has a turn limit, and why Uber mitigates before it fixes. Watch the pipeline overview at 5:13, the battery-drain and push-crash demos at 6:03, the architecture walkthrough at 9:05 and the metrics at 19:25. The Q&A at 22:10 covers the repro ladder and how subagents are scoped.

Video: "Building a Debugging Agent Harness at Uber - Kriti Dangi", published by the Agentic AI Foundation channel, which hosts more talks from AGNTCon + MCPCon.

Choosing an MCP server for debugging, one per signal

Uber's tools (Healthline, Wisdom, its flag service) are internal, but each signal they provide has a public MCP server. Use one server per signal, and keep each one read-only unless a write is the whole point of the server.

SignalServer (version checked)Run itRead-only control
Logs, metrics, traces (Loki, Prometheus, Tempo)grafana/mcp-grafana v2.0.0uvx mcp-grafana with GRAFANA_URL, GRAFANA_SERVICE_ACCOUNT_TOKEN--disable-write and least-privilege RBAC scopes for each tool
Traces (Jaeger)Built into Jaeger v2.21.0 (MCP tools since v2.15.0)ai: mcp: {} under jaeger_query, served at <basePath>/api/ai/mcp/Query tools: search_traces, get_trace_errors, get_critical_path
Errors and crashesgetsentry/sentry-mcp 0.47.0https://mcp.sentry.dev/mcp or npx @sentry/mcp-server@latest--skills=inspect,seer or --disable-skills=project-management
Code, PRs, Actionsgithub/github-mcp-server v1.14.0https://api.githubcopilot.com/mcp/ or the Docker image--read-only / GITHUB_READ_ONLY=1, plus --toolsets
Local git historymcp-server-gituvx mcp-server-git --repository pathNone. It includes write tools such as git_reset.
Code searchSourcegraph (Enterprise)https://<your-sourcegraph>/.api/mcpOAuth or tokens, and the MCP#ACCESS permission
TicketsAtlassian Rovo, Linearhttps://mcp.atlassian.com/v2/mcp, https://mcp.linear.app/mcpLinear: /mcp/readonly or the read OAuth scope
Datadog (logs, metrics, traces, incidents)datadog-labs/mcp-serverhttps://mcp.datadoghq.com/v1/mcp (US1)Check the Datadog docs
Feature flagslaunchdarkly/mcp-server v0.6.2npx -y --package @launchdarkly/mcp-server -- mcp start --api-key ...Its job is writing flags, so put it behind approval

Here is a combined config in the mcpServers shape that MCPAdapter accepts. Untested: the syntax is copied from each project's README, and I had no live Grafana, Sentry or GitHub instances to try it against. Adding --disable-write to the uvx args combines two separate parts of the Grafana README, so check that it works before you rely on it.

{
  "mcpServers": {
    "github":  { "url": "https://api.githubcopilot.com/mcp/" },
    "grafana": { "command": "uvx", "args": ["mcp-grafana", "--disable-write"],
                 "env": { "GRAFANA_URL": "http://localhost:3000", "GRAFANA_SERVICE_ACCOUNT_TOKEN": "<token>" } },
    "sentry":  { "command": "npx", "args": ["@sentry/mcp-server@latest", "--access-token=<token>"] },
    "git":     { "command": "uvx", "args": ["mcp-server-git", "--repository", "/path/to/repo"] },
    "linear":  { "url": "https://mcp.linear.app/mcp/readonly" }
  }
}

Two more snippets, also untested and copied from the GitHub and Jaeger READMEs:

docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN=<token> -e GITHUB_READ_ONLY=1 \
  -e GITHUB_TOOLSETS="repos,issues,pull_requests,actions" ghcr.io/github/github-mcp-server
# Jaeger v2.21 config: enable the built-in MCP endpoint at <basePath>/api/ai/mcp/
extensions:
  jaeger_query:
    ai:
      mcp: {}

When Code Mode beats one tool per endpoint: some MCP servers expose only search() and execute(), and the model writes code against the API spec. Cloudflare's API has more than 2,500 endpoints. Cloudflare says Code Mode needs roughly 1,000 tokens of tool definitions, compared with 1.17 million without it. Use Code Mode for very large APIs. A debugging agent with five or six focused tools is better served by plain tools, because they are easier to scope as read-only.

Build it: debug production issues with AI agents using LangGraph and MCP

One important change happened recently. langchain-mcp-adapters is no longer actively maintained, and since langchain 1.4.0 (2026-09-03) MCP support lives in langchain.mcp. That namespace is still in beta, so its API may change.

langchain.mcpMCPAdapter (current)MultiServerMCPClient (legacy)
StatusBeta, emits LangChainBetaWarning on importNo longer actively maintained
Packagelangchain[mcp] (langchain>=1.4.0)langchain-mcp-adapters 0.3.2
MCP Python SDK2.x (2.3.0 in my install)Pinned mcp<2 (1.30.0)
TransportInferred: command/args means stdio, url means HTTPA "transport" key is required
Tool errorsisError becomes ToolMessage(status="error"), and transport failures raisehandle_tool_errors flag

Install

Tested on Windows 11, Python 3.12.10 and uv 0.11.14. The first line resolved fastmcp 4.0.11 and mcp 2.3.0.

# current path (langchain.mcp)
uv venv && uv pip install "langchain[mcp]==1.4.3" "langgraph==1.2.13" langchain-ollama
# legacy path (MultiServerMCPClient) - keeps you on MCP SDK 1.x
uv pip install "langgraph==1.2.13" "langchain==1.4.3" "langchain-mcp-adapters==0.3.2"

A mock MCP server with logs, traces, deploys, code search and flags

This server lets you run the whole flow without credentials. It contains 503 log lines, of which only two matter. Each tool declares whether it is read-only.

from fastmcp import FastMCP

mcp = FastMCP("debug-signals")
LOGS = [
    {"ts": "2026-10-05T09:58:01Z", "level": "INFO", "msg": "GET / 200"},
    {"ts": "2026-10-05T10:02:13Z", "level": "INFO", "msg": "deploy d-42 live (commit a1b2c3)"},
    {"ts": "2026-10-05T10:02:40Z", "level": "ERROR", "trace_id": "t-9",
     "msg": "Internal Server Error: / ZeroDivisionError: division by zero at tasks/views.py:16"},
] + [{"ts": "2026-10-05T10:03:00Z", "level": "INFO", "msg": "healthcheck ok"}] * 500

@mcp.tool(annotations={"readOnlyHint": True})
def search_logs(service: str, level: str | None = None, limit: int = 50) -> list[dict]:
    """Return recent log lines for a service, optionally filtered by level."""
    rows = [r for r in LOGS if level is None or r["level"] == level]
    return rows[-limit:]

@mcp.tool(annotations={"readOnlyHint": True})
def get_trace(trace_id: str) -> dict:
    """Return spans for a trace id."""
    return {"trace_id": trace_id, "spans": [
        {"name": "GET /", "status": "ERROR", "ms": 12},
        {"name": "tasks.index_view", "status": "ERROR", "error": "ZeroDivisionError"}]}

@mcp.tool(annotations={"readOnlyHint": True})
def recent_deploys(service: str) -> list[dict]:
    """Return recent deployments, newest first."""
    return [{"id": "d-42", "commit": "a1b2c3", "msg": "show success rate on home", "at": "10:02:13Z"},
            {"id": "d-41", "commit": "9f8e7d", "msg": "bump deps", "at": "yesterday"}]

@mcp.tool(annotations={"readOnlyHint": True})
def code_search(query: str) -> list[dict]:
    """Search the code base."""
    return [{"file": "tasks/views.py", "line": 16, "text": "success_rate = success / total"}]

@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": True})
def set_feature_flag(flag: str, enabled: bool) -> str:
    """Flip a feature flag (WRITE)."""
    return f"flag {flag} set to {enabled}"

if __name__ == "__main__":
    mcp.run(show_banner=False)  # stdio

Tested on fastmcp 4.0.11 and mcp 2.3.0. Without show_banner=False, FastMCP prints a large banner to stderr. Both the SDK 2.x client and the legacy SDK 1.30 client listed and called all five tools, so a server built on the new SDK still works with old clients.

The LangGraph debugging agent example

The graph runs collect context (deterministic), then an RCA loop with read-only tools and a turn limit, then an approval interrupt, then the write. Save it as debug_agent.py next to the server.

import asyncio, json, sys, warnings
from typing import Annotated, TypedDict
from langchain_core._api import LangChainBetaWarning
warnings.filterwarnings("ignore", category=LangChainBetaWarning)

from langchain.mcp import MCPAdapter
from langchain_core.messages import AnyMessage, HumanMessage, SystemMessage
from langchain_ollama import ChatOllama
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from langgraph.types import Command, interrupt

MAX_RCA_TURNS = 6  # Uber's talk caps its RCA node at ~20 turns; small here for the demo
CONFIG = {"mcpServers": {"signals": {"command": sys.executable, "args": ["debug_server.py"]}}}

class State(TypedDict):
    alert: str
    context: str
    messages: Annotated[list[AnyMessage], add_messages]
    rca_turns: int
    decision: dict

async def main():
    async with MCPAdapter(CONFIG) as adapter:
        tools = await adapter.list_tools()
        ann = lambda t: t.metadata["mcp"]["tool"].get("annotations") or {}
        print("MCP tools:", [(t.name, ann(t)) for t in tools])
        by_name = {t.name: t for t in tools}
        read_only = [t for t in tools if ann(t).get("read_only_hint")]
        write_tool = next(t for t in tools if t.name.endswith("set_feature_flag"))
        name = lambda short: next(n for n in by_name if n.endswith(short))
        model = ChatOllama(model="qwen2.5:14b-instruct-q4_K_M", temperature=0).bind_tools(read_only)

        async def collect_context(state: State):
            # Deterministic node: no LLM, fetch + prune before the model sees anything.
            blocks = await by_name[name("search_logs")].ainvoke({"service": "web", "limit": 1000})
            rows = json.loads(blocks[0]["text"])  # MCP tools return a list of content blocks
            deploys = (await by_name[name("recent_deploys")].ainvoke({"service": "web"}))[0]["text"]
            pruned = [r for r in rows if r.get("level") != "INFO" or "deploy" in r.get("msg", "")]
            print(f"collect_context: {len(rows)} log lines -> {len(pruned)} after pruning")
            ctx = f"ALERT: {state['alert']}\nLOGS: {json.dumps(pruned)}\nDEPLOYS: {deploys}"
            return {"context": ctx, "rca_turns": 0, "messages": [
                SystemMessage("You are an on-call RCA agent. Use tools only if needed. "
                              "Finish with: ROOT CAUSE, EVIDENCE, SUGGESTED MITIGATION."),
                HumanMessage(ctx)]}

        async def rca(state: State):
            msg = await model.ainvoke(state["messages"])
            return {"messages": [msg], "rca_turns": state["rca_turns"] + 1}

        def route_rca(state: State):
            last = state["messages"][-1]
            if state["rca_turns"] >= MAX_RCA_TURNS:
                return "approve"  # turn cap reached: stop digging
            return "tools" if getattr(last, "tool_calls", None) else "approve"

        def approve(state: State):
            proposal = {"tool": write_tool.name, "args": {"flag": "home_success_rate", "enabled": False},
                        "rca": state["messages"][-1].content[:400]}
            decision = interrupt(proposal)  # pauses; resumes with Command(resume=...)
            return {"decision": decision}

        async def mitigate(state: State):
            if not state["decision"].get("approved"):
                print("mitigate: rejected by human, no write performed"); return {}
            print("mitigate:", await write_tool.ainvoke({"flag": "home_success_rate", "enabled": False}))
            return {}

        g = StateGraph(State)
        g.add_node("collect_context", collect_context); g.add_node("rca", rca)
        g.add_node("tools", ToolNode(read_only)); g.add_node("approve", approve); g.add_node("mitigate", mitigate)
        g.add_edge(START, "collect_context"); g.add_edge("collect_context", "rca")
        g.add_conditional_edges("rca", route_rca, ["tools", "approve"])
        g.add_edge("tools", "rca"); g.add_edge("approve", "mitigate"); g.add_edge("mitigate", END)
        graph = g.compile(checkpointer=InMemorySaver())

        cfg = {"configurable": {"thread_id": "incident-1"}, "recursion_limit": 25}
        result = await graph.ainvoke({"alert": "web: HTTP 500 rate 38% on GET / since 10:02Z"}, cfg)
        print("INTERRUPT:", result["__interrupt__"][0].value["tool"], result["__interrupt__"][0].value["args"])
        await graph.ainvoke(Command(resume={"approved": True}), cfg)

asyncio.run(main())

Tested on langgraph 1.2.13, langchain 1.4.3, fastmcp 4.0.11 and mcp 2.3.0, using a local Ollama qwen2.5:14b-instruct-q4_K_M model through langchain-ollama 1.1.0. For production, swap in a hosted model with init_chat_model("anthropic:...") or similar. Three details came out of the run:

  • The tool annotations appear in tool.metadata["mcp"]["tool"]["annotations"] in snake_case (read_only_hint), even though the server declared readOnlyHint. If you filter on the camelCase name, you silently get no read-only tools.
  • tool.ainvoke() returns a list of content blocks, not the Python return value. That is why collect_context calls json.loads(blocks[0]["text"]).
  • approve does nothing before interrupt(). When LangGraph resumes, it runs the node again from the top, so any write placed before the interrupt would run twice.

Model tiering, turn limits and the recursion limit change

Uber uses Sonnet for RCA with about 20 turns and Opus for the fix with more. Copy the idea of tiers: give investigation a cheaper model and a strict limit, and save the expensive model for writing code. To see what each LLM decision really costs, multiply calls per RCA by the price per call.

Don't rely on LangGraph's defaults to stop a runaway loop. Many tutorials still say the default recursion limit is 25. It was raised to 10000 in 1.0.6 and to 10007 in 1.1.4. That is why the code sets "recursion_limit": 25 explicitly and also keeps its own rca_turns counter. When the limit is reached, LangGraph raises GraphRecursionError with the message Recursion limit of {n} reached without hitting a stop condition.

The same flow with create_agent and built-in middleware

If you don't need explicit nodes, create_agent with middleware gives you human approval and call limits. This is an excerpt: it runs inside an async function, and model and sys are set up as in the previous script.

from langchain.mcp import MCPAdapter
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware, ModelCallLimitMiddleware, ToolCallLimitMiddleware
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

async with MCPAdapter({"mcpServers": {"signals": {"command": sys.executable, "args": ["debug_server.py"]}}}) as adapter:
    tools = await adapter.list_tools()
    agent = create_agent(model, tools,
        system_prompt="You are an on-call agent. Investigate with read tools, then disable the feature flag 'home_success_rate' with set_feature_flag.",
        middleware=[
            HumanInTheLoopMiddleware(interrupt_on={"set_feature_flag": {"allowed_decisions": ["approve", "reject"]}}),
            ModelCallLimitMiddleware(run_limit=8),
            ToolCallLimitMiddleware(run_limit=10),
        ],
        checkpointer=InMemorySaver())
    cfg = {"configurable": {"thread_id": "inc-2"}}
    r = await agent.ainvoke({"messages": [{"role": "user", "content": "HTTP 500s on GET / of service web since 10:02Z."}]}, cfg)
    r = await agent.ainvoke(Command(resume={"decisions": [{"type": "reject", "message": "Roll back the deploy instead."}]}), cfg)

Tested on the same stack. HumanInTheLoopMiddleware and thread-level limits both need a checkpointer.

Legacy: MultiServerMCPClient

Use this only if you are stuck on MCP SDK 1.x. Tested on langchain-mcp-adapters 0.3.2 with mcp 1.30.0. With tool_name_prefix=True, the tools are named signals_search_logs and so on. Without session(), every tool call opens a new MCP session. This is an excerpt: PY is sys.executable, and the calls run inside an async function.

from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient({
    "signals": {"command": PY, "args": ["debug_server.py"], "transport": "stdio"},
}, tool_name_prefix=True)
tools = await client.get_tools()
async with client.session("signals") as session:
    ...

Worked example: how to debug production issues from a 500 alert

This is what the graph did with the alert web: HTTP 500 rate 38% on GET / since 10:02Z (abridged output from the tested run):

  1. Collect:collect_context: 503 log lines -> 2 after pruning. The model saw the ZeroDivisionError line and the deploy line, and none of the 500 health checks.
  2. Investigate: in a single turn, the model called code_search(query='tasks/views.py:16 ZeroDivisionError') and get_trace(trace_id='t-9') in parallel. The trace showed tasks.index_view failing with ZeroDivisionError, and the code search returned success_rate = success / total.
  3. Conclude: "ROOT CAUSE: The recent deployment introduced a change in tasks/views.py where the total variable is zero". It got there in 2 RCA turns, well under the limit of 6.
  4. Pause:INTERRUPT: set_feature_flag {'flag': 'home_success_rate', 'enabled': False}. Nothing had been written yet.
  5. Mitigate: after Command(resume={"approved": True}), the flag was set to False.

In the create_agent run I rejected the flag flip with the note "Roll back the deploy instead." The agent then read recent_deploys and identified deploy d-42 ("show success rate on home") as the deployment to roll back. Rejecting with a reason is a useful control in its own right, because the reason redirects the agent.

From mitigation to a validated PR (design, not tested here). Add a fix node that is the only node allowed GitHub write tools, give it a stronger model and a larger turn limit, and follow Uber's repro ladder with a fixed retry cap for each rung. Test runs and repro builds take a while. The MCP 2026-07-28 spec moved long-running work into the io.modelcontextprotocol/tasks extension, which you poll with tasks/get. Build long-running test tools on that extension rather than holding a request open.

Guardrails for an AI incident response agent

Once you debug production issues with AI agents that can write, the guardrails matter more than which model you pick.

  • Read-only by default. Turn on --read-only for GitHub, --disable-write for Grafana and the readonly endpoint for Linear. Filter tools on read_only_hint before binding them to the RCA model. Remember that mcp-server-git includes git_reset.
  • Each write in its own interrupt node, with a checkpointer and a thread_id. Do not wrap interrupt() in try/except. It works by raising a special exception, so a bare except catches that exception and the graph never pauses.
  • Treat log content as untrusted input. Log lines, issue bodies and PR comments are all text that an attacker can control. GitHub's lockdown mode is a best-effort filter against prompt injection, and its README states that it is "not an authorization boundary". The approval step is the real boundary.
  • Enforce scope at a gateway. The 2026-07-28 spec adds Mcp-Method and Mcp-Name HTTP headers so a proxy can allow only read tools without parsing request bodies.
  • Use current auth. The new spec validates the RFC 9207 iss parameter and deprecates Dynamic Client Registration in favour of Client ID Metadata Documents. Avoid HTTP+SSE endpoints. Atlassian's /v1/sse endpoint stopped being supported after 2026-06-30.
  • Narrow the git history the agent can search. Follow Uber's example and limit commit search to the range between the good and bad versions.
  • Keep an audit trail. Uber traces every LLM call, tool call, MCP call and bash command with Arize. MCP Python SDK 2.0 ships with OpenTelemetry tracing turned on by default.

How to measure it

Before your team trusts agents to debug production issues, track four numbers from day one.

  • RCA accuracy: compare the agent's root cause with the fix a human eventually merged. This is how Uber scores its RCAs, and it uses a second bucket for answers that were "in the right direction".
  • PR merge rate: the share of agent PRs merged without being rewritten.
  • MTTR: the time from alert to mitigation and from alert to fix, compared with your own baseline from before the agent.
  • Cost per RCA: add up the model calls and tokens per thread_id from your traces.

Troubleshooting

SymptomLikely causeFix
A log query filtered to ERROR returns nothingThe error was logged at another level, or the level field has a different nameQuery a time window without the level filter in the deterministic collector, then prune in code
The approval prompt appears but resuming does nothingNo checkpointer or no thread_id. Without a checkpointer, __interrupt__ is returned with no error and no saved state.Compile with InMemorySaver() (or a persistent saver) and pass the same thread_id when you resume
The agent loops and costs pile upThe default recursion limit is now 10007Set recursion_limit and per-node turn limits, or use the call-limit middleware
A confident root cause that has nothing to do with the logsThe collector dropped data silently. My first attempt mishandled the content blocks, kept 1 line out of 503, and the model analysed noise.Assert on counts after pruning and use json.loads(blocks[0]["text"])
No read-only tools are boundFiltering on readOnlyHint instead of read_only_hintRead the annotations from metadata["mcp"]["tool"] using snake_case keys
ModuleNotFoundError: No module named 'mcp.server.fastmcp'mcp 2.x renamed FastMCP to MCPServerImport from mcp.server.mcpserver import MCPServer, pin mcp<2, or use the standalone fastmcp package
Tool names don't match your codeSingle-server configs are not prefixed. Multi-server configs and tool_name_prefix=True add {server}_Look tools up by suffix, as name() does above
UnsupportedProtocolVersionErrorThe server does not support the protocol version your client requestedThe error lists the versions the server supports. Upgrade the side that is behind.

FAQ

Can an AI agent find the root cause of a production issue on its own?

Partly. In the talk, Uber said its RCA matched the developer's actual fix about 50% of the time, and that about 40% of the rest pointed in the right direction. Treat the agent's RCA as a ranked hypothesis with evidence, not a final answer.

Which MCP server should I use for debugging?

Use one per signal, read-only by default. That means mcp-grafana with --disable-write for Loki, Prometheus and Tempo, Jaeger's built-in endpoint for traces, Sentry's server for errors and GitHub's server with --read-only for code. The table above covers each one.

How do I connect MCP servers to a LangGraph agent in 2026?

Install langchain[mcp], open async with MCPAdapter({"mcpServers": {...}}) as adapter, call await adapter.list_tools() and pass the tools to ToolNode or create_agent. MultiServerMCPClient still works but is no longer maintained.

How do I stop the agent rolling back or merging without approval?

Bind only read-only tools to the investigation model. Put every write behind interrupt() in its own node, or use HumanInTheLoopMiddleware, and use a checkpointer in either case.

What changed in MCP in 2026 that matters here?

The 2026-07-28 spec made the protocol stateless: there is no initialize handshake and no Mcp-Session-Id. It moved long-running work into the Tasks extension and hardened OAuth. It also deprecated protocol-level Logging in favour of stderr and OpenTelemetry. That deprecation does not affect log-search servers. Deprecated features stay in the spec for at least twelve months, and older protocol versions can still be negotiated.

Debug Assist is one consumer of Uber's MCP platform. For the control plane underneath it, see MCP registry vs MCP gateway and how Uber runs both.

Last updated: 2026-10-06; tested on Python 3.12.10, langgraph 1.2.13, langchain 1.4.3, fastmcp 4.0.11, mcp 2.3.0 (legacy path: langchain-mcp-adapters 0.3.2 / mcp 1.30.0), Ollama 0.35.0 with qwen2.5:14b, Windows 11.

Last updated: October 06, 2026
an "open and free" initiative. Powered by Blogger.