← Back to Blog
For: AI Engineers, ML Engineers, Platform Engineers, AI Systems Architects

MCP with LangGraph: A Practical 2026 Walkthrough

Your checkpoint is durable. The handle inside it is not. Nothing in either system compares the two.

#model-context-protocol#langgraph#context-management#agent-orchestration#production-patterns#state-machines#llm-systems#observability#stateless-mcp#handle-lifetime

Updated 2026-09-10. Written on 2026-01-23 against MCP protocol 2025-11-25, which it never named. Two things have changed the answer since: the 2026-07-28 revision deleted the session, and LangChain 1.4.0 shipped MCP support in the box on 2026-09-03. Separating the layers, which this article argued for, held up. Building your own MCPContextManager, which it told you to do, did not, and the code it shipped had three defects. Both are below, quoted rather than quietly deleted.

A Resumed Graph Holds a Handle Nobody Checked

A support agent resumes from a checkpoint written four hours ago. A scheduled sweep picked the thread up to finish an order parked mid-flight, so execution re-enters at the node that was interrupted rather than at the top of the graph. State carries basket_id: "bsk_a1b2c3" and nothing else about the basket, exactly as the advice says it should: a reference, not the data. Six hours ago the server minted that handle, and at the four-hour mark it expired. Nothing crashes. Instead the call comes back with isError, a plain Python node treats an empty basket as an empty basket, and the agent tells a customer their order is gone.

That failure needs three ingredients. Two of them are things this blog told you to do.

Ingredient one is keeping references in graph state instead of data. That advice is still right, and the 2026-07-28 revision pushes the same way: cross-call server state is a handle by construction now, so what you hold is a reference whether you chose one or not. A LangGraph checkpointer is the second: it serializes whatever sits in state and hands it back on resume without inspecting any of it, which is why the resume is the dangerous part of any long-lived graph. Ingredient three arrived in July, when the protocol deleted its own session and made the replacement a server-minted handle passed as an ordinary tool argument. Put those together and a durable object now carries a perishable one. Graph state is built to survive a process restart, a redeploy, and a week in Postgres. A handle is built by whoever wrote the server, under no protocol rule at all, and may not survive lunch.

Thesis: The Layer Was Right, the Mechanism Inverted, and the Gap Moved

Every version of this advice, mine included, told you two things. Keep references in state. Resolve them through a context layer you build yourself. Half of that survived the spec revision. Resolving them yourself is now wrong, because langchain.mcp ships that layer as of 2026-09-03, and a hand-rolled one is worse than the packaged one at caching, transport, authorization, and mid-call input.

What nobody ships is the reconciliation.

I call it Checkpoint-Handle Skew: a LangGraph checkpoint is durable, an MCP handle is not, and no component compares the two lifetimes. Skew in lifetime, not in clocks. Nothing here is offset by some measurable amount the way clock skew or write skew is offset; one of the two objects is gone, and the other still names it. Your checkpointer does not know that a string is a handle, and the protocol, in its own words, does not know either.

sequenceDiagram
    participant N as Graph node
    participant CP as Checkpointer
    participant S as MCP server

    N->>S: tools/call create_basket
    S-->>N: structuredContent {basket_id: "bsk_a1b2c3"}
    Note over S: retention policy lives in<br/>the tool description, as prose
    N->>CP: save state, handle included
    Note over CP: durable. survives restart,<br/>redeploy, a week in Postgres

    Note over S: t+4h the server expires bsk_a1b2c3

    CP-->>N: resume from checkpoint
    N->>S: tools/call add_item(basket_id "bsk_a1b2c3")
    S-->>N: isError true, unknown handle
    Note over N: the error arrives as content,<br/>and a new basket would be empty
    N->>N: routes on an empty basket

Two lifetimes, and the moment they diverge. No component in the diagram is watching for it.

That gap is not something I am inferring. Under "Stateful Tools" the specification marks its own section non-normative, then opens by disclaiming the entire concept:

The protocol has no concept of a state handle; from the wire's perspective a handle is an ordinary string in a tool result and an ordinary argument to subsequent tool calls.

Two consequences follow. Both are checkable.

A handle's lifetime is prose. Spec guidance asks servers to publish a retention policy "in the creation tool's description (e.g., 'baskets expire after 24 hours of inactivity') so the model can see it when deciding to create state." That sentence is addressed to a model reading English. No field carries it. Your orchestrator cannot read a tool description and derive a deadline from it.

Sanctioned recovery assumes both a model watching and a fresh handle being enough. On an expired handle, the spec says the server "should return a tool execution error that says so, so the model can recover by creating a new one." In a chat loop that mostly holds. On a checkpoint resume it fails twice over, and the second failure survives even when a model is in the loop.

Re-minting is not recovery. A new basket is an empty basket, and six downstream nodes were written against the state the old handle addressed. Nothing in the graph knows the twelve items added before the interrupt are gone, so this lands as silent data loss rather than as an error a model could narrate its way out of.

Then there is how the error arrives. Through the LangChain tool wrapper an MCP failure comes back as tool content, not as a raised exception, so a model is free to read "unknown handle" as an ordinary answer and carry on. Failures shaped like answers are an observability problem before they are a correctness one. I hit exactly that later in this article with a failed elicitation call.

Both of those get worse when nothing is reading at all, which is common. Mint in one node and spend in another. Post-process structuredContent deterministically. Fan out to subgraphs. Resume parked threads on a schedule. Call a tool from a node you wrote instead of from an agent's tool loop. Every one of those spends a handle with no model anywhere near the result.

So the claim, narrowed to what it is: even with a model in the loop, re-minting is not recovery, and an error is not guaranteed to be read as one.

Compare that with how the same revision treats a cheap, idempotent read. Six operations now carry ttlMs and cacheScope under a new CacheableResult interface, and the verb is normative. Servers "MUST include caching hints" on complete results from server/discover, tools/list, prompts/list, resources/list, resources/templates/list, and resources/read. In the schema, CallToolResult extends Result and nothing else. It has content, structuredContent, and isError. No ttlMs. The protocol grew machine-readable freshness metadata for the responses you could always just fetch again, and shipped none for the one response that carries state you cannot refetch. Spec authors did not get this wrong. Handle lifetime is genuinely the server's business. My point is narrower, and it is about who is left holding the consequence: your orchestrator is the one component that persists handles across time, and it is the component the protocol gives the least to work with.

What this is not

Four neighbouring problems are already covered. This is none of them.

Server-side handle validation has been written up repeatedly since July, and the spec's own Security Best Practices carries it: servers "MUST NOT treat possession of a state handle as authentication" and "SHOULD bind handles server-side to the authenticated user." Every normative sentence in that section is addressed to servers. That is someone else's article, and a correctly built server already does it. Revalidating a cached credential before reuse is older than MCP and belongs to identity engineering. Teams who apply it rigorously to access tokens mostly do not apply it to a basket_id, because a basket_id does not look like a credential. That habit gap is worth naming. The discipline is not new.

Closest of all, and worth reading, is Naveen's argument from August that a reliable agent framework checkpoints task state while protocol session state follows a separate path, "resulting in two distinct state recovery frameworks." That piece presents statelessness as the cure. My claim is that the divergence survived the cure. What the session became was a handle, and a handle inside checkpointed state has the same lifetime mismatch, minus the transport affordances that at least used to make session loss loud.

Older prior art deserves naming too, because a reader will reach for it. A stale NFS filehandle is this exact shape from the 1980s: an opaque server-minted handle, persisted by a client, invalidated on the server, and surfaced as a typed error the client has to route on. NFS4ERR_STALE on the wire, ESTALE at the application. Leases, in Gray and Cheriton's 1989 sense, are the canonical fix for a durable holder and a perishable grant. Three differences make this a live problem rather than a solved one. A lease carries a machine-readable expiry and an MCP handle carries none. ESTALE is a typed error code, and isError plus English prose is not. Neither precedent has a component that pauses for four hours and then replays the handle out of durable storage as though no time had passed. This is a stale filehandle with no error code, no lease, and a database in front of it.

Checkpoint-Handle Skew is the orchestrator-side version of the question. Not "is this handle valid for this caller," which the server answers. Rather: is this handle, which my checkpointer preserved perfectly, still a thing at all, and does my graph have anywhere to go when it is not.

What Changed for MCP and LangGraph Since January

Piece Then Now
Protocol 2025-11-25, sessions and an initialize handshake 2026-07-28, stateless, no session, no handshake
Cross-call state Mcp-Session-Id, held by the transport a server-minted handle passed as an ordinary tool argument
Your MCP client whatever you wrote langchain.mcp, in langchain 1.4.0, released 2026-09-03
Freshness your own TTL ttlMs and cacheScope, required on six operations
Mid-call input server calls back into the client resultType: "input_required", answered on a retry

Two of those rows deserve more than a cell.

Sessions did not move. They were deleted. Specification Enhancement Proposal (SEP) 2567 removed protocol-level sessions and the Mcp-Session-Id header. SEP-2575 removed the handshake, so every request now carries its own protocol version and capabilities in _meta. What replaces them is stated plainly: servers needing cross-call state use "explicit, server-minted handles passed as ordinary tool arguments."

Your context layer shipped. LangChain 1.4.0 put MCP into langchain.mcp, built on FastMCP. The MultiServerMCPClient role is now filled by a single MCPAdapter, in a different package. It is beta, and importing it prints exactly this:

bash
LangChainBetaWarning: `langchain.mcp` is in beta. It is actively being worked on,so the API may change.

Notice also what did not happen. langchain-mcp-adapters carries no deprecation notice, its last release is 0.3.2 from 2026-08-06, and its README still cites the 2025-03-26 transport spec. Superseded for new Python work is accurate. Replaced is not.

Versions this article was written and tested against, on 2026-09-10: protocol 2026-07-28, langchain 1.4.0, langgraph 1.2.11, langchain-core 1.6.2, fastmcp 4.0.3, mcp 2.2.0, Python 3.13.9.

What Went Wrong: The Context Manager I Told You To Build

January's article shipped an MCPContextManager with a TTL cache and sha256 content-hash versioning. I re-ran that code before writing this. Three things are wrong with it, and each one contradicts a claim the article makes about itself. Its _analyze_node carried this comment: "This node DOES NOT fetch data - it creates references." That node calls create_context_reference, which fetches the entire payload in order to hash it. One reference, one full fetch, and then _fetch_data_node fetches it all over again. Its own listing refutes the article's central performance argument.

refresh_if_stale compares a reference's version against self._versions, a dictionary that only ever holds the version that same object last minted. It asks the server nothing. Replace the source data completely and it still reports no change:

bash
server data changed; refresh_if_stale returned a new ref? Falseversion before: 236dffe80963f2b2  after: 236dffe80963f2b2

That was the article's named contribution. It is dead logic, and it takes the latest strategy, the hybrid strategy, and detect_context_drift down with it.

Defect three matters most here, because it has the same shape as the failure this revision is about. _store_result_in_mcp writes a large result into the manager's private _cache and hands back a reference whose source is results. Once the 300-second TTL lapses, resolving that reference looks for an MCP client registered under results. There is none:

bash
ValueError: Unknown MCP source: results

The resume-after-checkpoint path, the thing the article sells hardest, breaks after five minutes. A reference outlived the thing that could resolve it. In January that was a bug in my cache. In September it is the protocol's design, and it deserves a name.

For anyone still running that code, datetime.utcnow() has been deprecated since Python 3.12 and appears at four call sites.

Three Layers, and Only One of Them Is Still Yours

direction: down

l1: "1. LangGraph execution layer - yours, and unchanged since January" {
  grid-columns: 3
  style: { fill: "#EAF2FB"; stroke: "#2C6FB0"; font-color: "#2C2C2A" }
  a: "routing and conditional edges" { style: { fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF" } }
  b: "state holds handles, never payloads" { style: { fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF" } }
  c: "checkpointer serializes state verbatim" { style: { fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF" } }
}

l2: "2. langchain.mcp on FastMCP - this was your MCPContextManager. Delete it." {
  grid-columns: 4
  style: { fill: "#EAF7F3"; stroke: "#4E9E8B"; font-color: "#2C2C2A" }
  a: "transport inference" { style: { fill: "#98D8C8"; stroke: "#4E9E8B"; font-color: "#2C2C2A" } }
  b: "tool-list cache, honours ttlMs and cacheScope" { style: { fill: "#98D8C8"; stroke: "#4E9E8B"; font-color: "#2C2C2A" } }
  c: "OAuth 2.1, validated per request" { style: { fill: "#98D8C8"; stroke: "#4E9E8B"; font-color: "#2C2C2A" } }
  d: "input_required, surfaced as an interrupt" { style: { fill: "#98D8C8"; stroke: "#4E9E8B"; font-color: "#2C2C2A" } }
}

l3: "3. Handle custody - nothing ships this wired to handles, so it is yours" {
  grid-columns: 3
  style: { fill: "#FFF8DC"; stroke: "#B8860B"; font-color: "#2C2C2A" }
  a: "registry: what was minted, when, and for whom" { style: { fill: "#FFD93D"; stroke: "#B8860B"; font-color: "#2C2C2A" } }
  b: "revalidate on resume, before the first node runs" { style: { fill: "#FFD93D"; stroke: "#B8860B"; font-color: "#2C2C2A" } }
  c: "a named edge for handle-is-gone" { style: { fill: "#FFD93D"; stroke: "#B8860B"; font-color: "#2C2C2A" } }
}

l1 -> l2: "tools/call"
l2 -> l3: "handle arrives in structuredContent, with no expiry field"
l3 -> l1: "verdict routes the graph"

Caching was absorbed. Handle lifetime was abandoned. Those are different fates, and only the second one leaves you with work.

Step 1: Delete the context manager, MCPAdapter replaces it

MCPAdapter takes one positional argument and infers the transport from it.

python
from pathlib import Pathfrom langchain.mcp import MCPAdapterremote = MCPAdapter("https://billing.internal/mcp")   # streamable HTTPlocal = MCPAdapter(Path("weather_server.py"))         # launched over stdio

Tools come back ready to bind:

python
from langchain.agents import create_agentasync with MCPAdapter("https://billing.internal/mcp") as adapter:    agent = create_agent(model="...", tools=await adapter.list_tools())

Use create_agent, not create_react_agent. create_react_agent is deprecated in LangGraph v1 and scheduled for removal in v2.0, and create_agent renamed that prebuilt's prompt argument to system_prompt.

Caching is not configured on the adapter. It belongs to the FastMCP client you pass in, and the reason to prefer it over your own is one sentence in FastMCP's documentation: a client created with cache=True "respects the ttlMs and cacheScope hints the server attaches to each response." That sentence covers the boolean form. Passing a CacheConfig instead is the same switch with its defaults exposed, and while the import resolves on mcp 2.2.0, the documented claim above is the one written about cache=True.

python
from fastmcp import Clientfrom mcp.client.caching import CacheConfigclient = Client(    "https://billing.internal/mcp",    cache=CacheConfig(target_id="billing", default_ttl_ms=60_000),)async with MCPAdapter(client) as adapter:    tools = await adapter.list_tools(cache_mode="use")

cache_mode accepts "use", "refresh", or "bypass". Compare that against January's cache_ttl: int = 300, a guess applied uniformly to every source. Now the number you pass is a floor, and the server's ttlMs is the authority. cacheScope is the part you could not have invented: a value of private means the result varied by the caller's authorization, so caching it where another tenant can read it discloses one tenant's capability list to another.

One correction worth printing, because secondary coverage got it wrong. There is no elicitation= keyword argument. Here is the real signature, one parameter:

python
MCPAdapter.__init__(self, target: 'MCPAdapterTarget') -> None

Interrupt-based elicitation is armed for you. Code written as MCPAdapter(url, elicitation="interrupt") raises TypeError.

While you are here, tool annotations are worth wiring into your approval logic. A server that marks a tool destructiveHint surfaces it in snake_case on the LangChain tool:

python
annotations = tool.metadata.get("mcp", {}).get("tool", {}).get("annotations") or {}if annotations.get("destructive_hint"):    ...   # route to approval

Walk it with .get, because most tools carry no annotations at all and a chain of bracket lookups raises KeyError on them. Approval logic that crashes on an unannotated tool is an availability bug you built yourself. Treat the hint as a hint and not a control either way: the spec is explicit that clients "MUST consider tool annotations to be untrusted unless they come from trusted servers."

Step 2: Give a handle a custody record

A handle arrives as a string inside structuredContent with no metadata attached, because CallToolResult carries none. Anything you will later want to know about that handle has to be recorded at the moment it is minted.

python
from __future__ import annotationsfrom datetime import datetime, timedelta, timezonefrom typing import Any, TypedDictdef _now_iso() -> str:    return datetime.now(timezone.utc).isoformat()class HandleRecord(TypedDict):    """Everything the graph knows about a handle.    The protocol carries none of it. A tools/call result returns the string and no    metadata, so every field here is something you recorded at mint time. Plain types    only, because this goes through the checkpointer's serializer.    """    value: str    server: str    tool: str    principal: str    minted_at: str    assumed_ttl_s: int | Nonedef record_handle(    value: str,    *,    server: str,    tool: str,    principal: str,    assumed_ttl_s: int | None = None,) -> HandleRecord:    return HandleRecord(        value=value,        server=server,        tool=tool,        principal=principal,        minted_at=_now_iso(),        assumed_ttl_s=assumed_ttl_s,    )def age(record: HandleRecord, now: datetime | None = None) -> timedelta:    if now is not None and now.tzinfo is None:        raise ValueError("now must be timezone-aware")    minted = datetime.fromisoformat(record["minted_at"])    return (now or datetime.now(timezone.utc)) - minteddef looks_fresh(record: HandleRecord, now: datetime | None = None) -> bool | None:    """True, False, or None for "the server never told us".    None is the common case and the honest one. A server's retention policy lives in    its creation tool's description as English prose, so unless someone read that    description and wrote the number down, there is nothing here to compare against.    """    ttl = record["assumed_ttl_s"]    if ttl is None:        return None    return age(record, now) < timedelta(seconds=ttl)def extract_handles(result: Any, keys: tuple[str, ...]) -> dict[str, str]:    """Pull named handle fields out of a tool result's structured content.    The spec's own example returns {"basket_id": "bsk_a1b2c3"}. Nothing in the payload    marks a string as a handle, so the caller names the keys it expects to find.    Two attribute names, because two libraries disagree: a FastMCP client result    exposes structured_content, mcp.types.CallToolResult exposes structuredContent.    Never return an empty dict quietly. A handle that silently fails to register is    the failure this whole article is about.    """    structured = (        getattr(result, "structured_content", None)        or getattr(result, "structuredContent", None)    )    if not isinstance(structured, dict):        raise TypeError(            f"expected a tool result carrying structured content, "            f"got {type(result).__name__}"        )    found = {}    for k in keys:        v = structured.get(k)        if isinstance(v, str):            found[k] = v    if not found:        raise KeyError(f"no handle among {keys!r}; payload had {sorted(structured)}")    return found

Run that against a handle with no declared policy and it tells you so:

bash
no declared ttl ->  Nonefresh at mint   ->  True25h later       ->  Falseextracted       ->  {'basket_id': 'bsk_a1b2c3'}

looks_fresh returns None more often than it returns a boolean. That is the honest outcome, not a gap in the implementation. Unless a human read a tool description and wrote the number into your call, your code has nothing to compare against.

Store the record in graph state, next to the handle. January's article got that principle right and this extends it one step: a reference in state should carry its own provenance, because the checkpoint is the only place that provenance can survive.

Step 3: Check the handle where it gets spent

Obviously the check belongs in a gate node wired to START. That placement works on some resumes and not others, and which kind you get is not yours to choose. I tested both on langgraph 1.2.11, with a gate at START and an interrupt in the second node:

bash
first invoke          : ['gate', 'work:enter']   -> interruptedCommand(resume="yes") : ['work:enter']           <- gate skippedinvoke({"n": 5})      : ['gate', 'work:enter']   <- gate ran

Resuming into a pending task re-enters at the node that was interrupted, and nothing traverses the edge from START again. A new turn on an existing thread does enter from START, so a gate there runs. Both of those are "resume" to whoever calls your graph: an interrupt answered, a crash recovered mid-superstep, a scheduled sweep collecting parked threads, a customer replying six hours later. Since the caller picks and you do not, the check has to live where the handle is spent. A consuming node restarts from its first line either way.

Worth noticing that this is the same durability behaving in two opposite ways. Writing about checkpoints and recomputed guards, I called it a Self-Restoring Bound: a limit you wanted remembered gets re-derived on entry and arrives back at full. A handle is that story inverted. Something you wanted to decay is preserved perfectly instead, and handed to the next node as though nothing had happened. One checkpointer, one serialization rule, and two failures that look nothing alike.

python
import functoolsfrom collections.abc import Awaitable, Callablefrom typing import Literal, TypedDictfrom langchain_core.runnables import RunnableConfigfrom langgraph.graph import ENDVerdict = Literal["ok", "gone", "absent"]Probe = Callable[[HandleRecord], Awaitable[bool]]class OrderState(TypedDict, total=False):    basket: HandleRecord | None    verdict: Verdict    note: strasync def confirm_handle(    record: HandleRecord | None,    probe: Probe,    *,    current_principal: str,) -> Verdict:    """A fast path, not a proof. Read the two paragraphs under this block."""    if record is None:        # A node that needs a handle and has none is in the same position as one        # holding a dead handle. Absence is not "ok".        return "absent"    if record["principal"] != current_principal:        # A resumed thread can run for a different caller than the one it was        # minted for. Spend no round trip; a correct server rejects it anyway.        return "gone"    if looks_fresh(record) is True:        # A declared TTL that has not elapsed buys the right to skip a round trip.        # It does not prove the handle is alive; revocation does not wait for a TTL.        return "ok"    return "ok" if await probe(record) else "gone"async def add_item_node(    state: OrderState, config: RunnableConfig, *, probe: Probe) -> dict:    verdict = await confirm_handle(        state.get("basket"),        probe,        current_principal=config["configurable"]["principal"],    )    if verdict != "ok":        return {"verdict": verdict, "note": f"basket handle {verdict} before spend"}    # ... the real work. Route on its own isError as well, for the reason below.    return {"verdict": "ok", "note": "item added"}def route_on_verdict(state: OrderState) -> Literal["handle_gone", "done"]:    return "done" if state.get("verdict") == "ok" else "handle_gone"

LangGraph injects config into a node's second parameter, so the probe has to be bound rather than passed as one. Wire it, and the rejection edge, like this:

python
builder.add_node("add_item", functools.partial(add_item_node, probe=billing_probe))builder.add_node("handle_gone", handle_gone_node)builder.add_conditional_edges(    "add_item", route_on_verdict, {"handle_gone": "handle_gone", "done": END})

Principal arrives per run rather than per graph: config={"configurable": {"thread_id": tid, "principal": caller}}. Run that graph against four states and every rejection lands on the same edge:

bash
live handle     -> ok      | item addeddead handle     -> gone    | routed to handle_gonemissing handle  -> absent  | routed to handle_goneother principal -> gone    | routed to handle_gone

Two things that block makes no claim to. confirm_handle is check-then-use with a real gap in the middle, and between a probe returning true and the spend call reaching the server, an expiry, a revocation, or a principal change reproduces the original failure exactly. What a probe buys is turning a late silent failure into an early routable one, and skipping work a doomed node would have done anyway. Completeness comes from somewhere else: treat the spend call's own isError as a routing outcome too, so handle_gone has two entrances.

Probing also costs something, and on some servers it is not available at all. It needs an operation that validates a handle without changing it, and plenty of MCP servers expose nothing of the kind, because the only call that touches a basket is the one that mutates it. Probing there means attempting the mutation you were trying to avoid, which can corrupt the state you were checking. Where a safe read does exist, you are buying a network round trip per handle per node entry, on the resume path, under the same rate limits and outages as the real call. Keep one distinction sharp while you are there. A probe that returns False and a probe that raises are different verdicts, and conflating them routes a perfectly good handle to handle_gone on a transient network error, discarding recoverable work.

handle_gone is where the design work lands, and "decide where it goes" is too vague to act on. Three honest destinations exist for a basket. Re-mint and replay, which means keeping the minting inputs in state next to the handle so a new basket can be refilled from what you recorded, and which is the only path that ends with the order intact. Escalate, which means an interrupt and a human, and is the right answer when those inputs were never kept. Or fail loudly, ending the thread with something an operator sees, which beats every silent alternative. Choose per handle class in advance, because the branch you did not write is the one that runs.

One more thing the same test showed, worth knowing if you put side effects before an interrupt. A counter incremented inside the node ended at 1, not 2, across an interrupt and a resume. Work done before the interrupt is discarded, because the node never returned. LangGraph's own documentation says the quiet part directly: side effects before an interrupt "should (ideally) be idempotent."

Where custody can live besides every node

Three extension points already exist, and none of them is a complete answer. LangChain 1.x agent middleware gives one interception point around tool calls, so custody can be written once per agent instead of at the top of every node, and it covers only the model-driven tool loop, which is the half of the problem that at least has a model reading the errors. A BaseCheckpointSaver subclass overriding get_tuple sees restored state on every resume, which is the closest thing to the on-resume hook I said does not exist, and it is a storage method: calling an MCP server synchronously inside it is not viable in every deployment. BaseStore is the natural home for a registry meant to outlive a thread, and it validates nothing on its own. So the accurate claim is narrower than "no layer ships this". Nothing ships it wired to handles, and two of those three are places you can wire it yourself today.

When a Tool Call Needs a Human Mid-Flight

Under 2025-11-25 a server called back into the client. Under 2026-07-28 it returns resultType: "input_required" carrying inputRequests and an opaque requestState, and the client answers by retrying the original call with inputResponses. At least one of inputRequests or requestState must be present, and the JSON-RPC id must differ between the first attempt and the retry. langchain.mcp maps that onto a LangGraph interrupt, which is the most useful thing in the release for anyone already running graphs. The payload is typed:

python
from typing import Any, Literal, NotRequired, TypedDictclass MCPElicitationRequest(TypedDict):    key: str                            # what you answer under, on resume    mode: Literal["form", "url"]    message: str    requested_schema: NotRequired[dict[str, Any]]   # form mode    url: NotRequired[str]                           # url modeclass MCPElicitationInterrupt(TypedDict):    type: Literal["mcp_elicitation"]    tool_name: str    requests: list[MCPElicitationRequest]

A request is either a form, carrying a requested_schema, or a URL for the human to visit. Resuming looks like any other interrupt. Both payloads are typed dictionaries, so the access is by key rather than by attribute:

python
from langgraph.types import Commandpaused = await agent.ainvoke(    {"messages": [{"role": "user", "content": "Book a table for 4."}]}, config)question = paused["__interrupt__"][0].value["requests"][0]answer = {"action": "accept", "content": {"date": "2026-09-14"}}result = await agent.ainvoke(    Command(resume={"responses": {question["key"]: answer}}), config)

Label that block honestly: I read it off the source and the launch post, and I could not execute it, for the reason in the second caveat below. Every other block in this article ran. Answers are keyed by request key, and every request must be answered. Miss one and the adapter raises, naming the keys you left out. The other two actions are decline and cancel.

Two caveats, one documentary and one that cost me an afternoon.

Neither elicitation nor caching appears on the official langchain.mcp documentation page as of today. The only narrative account of either is the launch blog post and the source, which tells you something real about how new this is.

And the server-side helper does not work on this protocol revision, at least not the way I reached for it. A FastMCP tool that calls ctx.elicit() against a 2026-07-28 connection fails, because that helper is a server-initiated request and this revision removed those in favour of the retry pattern above:

bash
ToolError: elicitation via server-initiated requests is unavailable on2026-07-28 connections.

Through the LangChain tool wrapper that arrives as tool content rather than a raised exception, which means a model reads it as an ordinary answer. Scope this correctly: I hit it with an in-process server and an in-memory client that declared no elicitation handler, on fastmcp 4.0.3 with mcp 2.2.0. It does not prove elicitation is broken over real Streamable HTTP against a server that returns a proper InputRequiredResult.

It does mean the in-process shortcut is not a way to test your elicitation path.

What You Still Own

LangChain's library took caching, transport, authorization, and mid-call input. It did not take handle lifetime, and there is no component to point at that did. Checkpoint-Handle Skew is the residue, and everything below is what closing it costs you.

  • One entry point for handles. A handle reaches state through a function that records the mint time, the server, the tool, and the principal. If a handle can reach state any other way, the registry is decorative.
  • A probe per server, not a global TTL. The only component that knows whether a handle lives is the server that minted it. A TTL you wrote down lets you skip a probe. It never substitutes for one.
  • A check at the top of every node that spends a handle. Not only at START: that edge is skipped whenever a thread resumes into a pending task, as the test above shows.
  • A named edge for rejection. handle_gone is a routing outcome, not an exception. Decide where it goes before you need it.
  • Revalidation against the current principal. A resumed thread may be running for a different caller than the one the handle was minted for, and a correctly built server will reject it. Compare the principal in the record against the one on the run, before you spend a round trip.

Pitfalls and Failure Modes

Skew starts with a handle you never registered

A handle reaches graph state the same way any other string does, and the checkpointer has no opinion about it. Nothing marks bsk_a1b2c3 as different from "pending".

Symptom: resumed threads fail against one specific server, and only threads older than some interval nobody has measured.

Detection: read your state schema and find the fields whose values you did not compute. Every one of them came from somewhere with its own lifetime.

Prevention: the single entry point above.

Reading a retention policy that only a model can read

Servers are asked to state expiry in the creation tool's description. That is a natural-language string aimed at a model, not a field aimed at your code.

Symptom: your revalidation interval is a guess, and it was a guess when you wrote it.

Detection: ask what value your code uses for a given server's handle lifetime, and where that number came from. If the answer is a constant someone picked, you have found it.

Prevention: treat an unknown lifetime as unknown rather than as a default. Probe instead of assuming a TTL you invented.

Trusting a handle because your own database returned it

A handle that comes out of a checkpoint has the same shape as one that just came off the wire. On this the spec is direct: a handle is a name rather than a capability, and servers must not treat possession as authentication.

Symptom: none, until a thread is resumed under a different caller than the one that created it.

Prevention: revalidate against the current caller's authorization, not against the fact that the string was in your own storage.

Caching a private list where another tenant can read it

cacheScope exists because tool and resource lists legitimately vary by the authorization on the request. A private result cached in a shared tier is one tenant's capability list disclosed to another.

Prevention: honour cacheScope rather than reimplementing it.

Failing the whole fan-out on one bad server

If tool lists are gathered in parallel and one server raises, an implementation without return_exceptions=True loses every sibling result, healthy servers included. Your tool list becomes empty, the graph does not change shape, and the agent answers from the model alone. This is not hypothetical. langchain-mcp-adapters issue #492, opened 2026-04-24, reports exactly this: one missing npx for a stdio server empties the tool list for every server. As of 2026-09-10 the issue is open, the linked fix in pull request #521 is unmerged, and get_tools() on main still ends with a bare asyncio.gather(*load_mcp_tool_tasks).

langchain.mcp's own list_tools builds its result with a sequential comprehension, so the bug is not in that code. Multi-server fan-out now happens one layer lower, inside FastMCP's ClientGroup, where issue #595 reports what looks like the same failure. I did not test ClientGroup on 4.0.3. Treat the relocation as a test to run against your own multi-server setup rather than as a finding, and note that most people will reach ClientGroup precisely by handing MCPAdapter a multi-server client.

Prevention: decide per server. Classify each source as required or optional before an outage does it for you.

What to Watch

Three things would change the advice here, and all three are cheap to check.

A machine-readable stateless handle expiry field would remove most of this article. It would need a SEP, and none exists today.

An orchestrator-side hook in LangGraph would remove the rest. BaseCheckpointSaver currently exposes get, get_tuple, list, put, put_writes, delete_thread, copy_thread, prune and their async twins. get_tuple is the one a wrapper checkpointer can hang custody off, as above, and it is a storage method rather than a validation one. A Checkpoint carries a ts and no other time field.

And langchain.mcp is beta, one week old, and its elicitation and caching surfaces are not yet in the official docs. Pin the versions listed above, and re-read the source before you trust a signature you found in a blog post, including this one.

Summary

Keep references in state. That was right in January, and the protocol has since removed most of the ways to do anything else. Delete the context manager you built around them, because langchain.mcp does that job better and honours freshness hints you would otherwise have to invent.

Then write the layer nobody shipped. Record what a handle is, check it in the node that spends it, and give the graph somewhere to go when the answer is no. Protocol language says a handle is an ordinary string. Your checkpointer agrees.

Between those two positions sits Checkpoint-Handle Skew, with nothing watching it, and until something does, the watching is yours.

References

More Articles

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:

Get the next article by email

One email when a new piece goes up. No digest, no drip sequence.

One email per new article. Unsubscribe in one click.

Books by Ranjan Kumar

The 7 GenAI Architectures cover

The 7 GenAI Architectures

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The ChatML Handbook cover

The ChatML Handbook

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments