By this point the agent worked, but running it was exhausting. Every answer was buried under echoed prompts, raw JSON and log lines from the MCP servers. This part fixes the output in two steps: a small display module that prints one line per tool call, and a wrapper that silences the servers' logging without editing them.
Prerequisites: Part 3 for the graph, Part 4 for the route, Part 6 for approvals.
The problem
My first version printed each message with pretty_print() while streaming the graph in stream_mode="values". A stream mode controls what the graph hands you as it runs. In values mode you get the entire state after each step, so a loop that prints "the latest message" will print some messages twice, and the first thing you see is your own prompt echoed back. In updates mode you get only what each node added, keyed by node name, so every message appears exactly once.
On top of that, pretty_print() shows everything. A directory listing came back as a dict of more than 4,000 characters. Every tool call listed every argument, including defaults I never set (recursive: False, glob: '', exclude_globs: [], limit: 100, cursor: 0). Banners of ===== separated each message. And the child MCP servers wrote their own log lines into the same terminal.
Reconstructed from the old format (not a fresh run), one simple question looked roughly like this:
================================ Human Message =================================
List the files in ~/project
================================== Ai Message ==================================
Tool Calls:
list_dir (call_1)
Call ID: call_1
Args:
path: ~/project
recursive: False
glob:
exclude_globs: []
limit: 100
cursor: 0
================================= Tool Message =================================
Name: list_dir
{ "entries": [ { "name": ".claude", ... 4,000 more characters ... } ] }
2026-11-10 17:58:56,302 - mcp.server.lowlevel.server - INFO - Processing request of type CallToolRequest
The final answer was somewhere below that.
Step 1: a display module
display.py has two jobs: format one message, and run the graph while printing only what is new.
display.py · text_of and show
PREVIEW_CHARS = 160
def text_of(content) -> str:
if isinstance(content, str):
return content
return "".join(b.get("text", "") for b in content if isinstance(b, dict))
def show(message: BaseMessage) -> None:
if isinstance(message, AIMessage):
for call in message.tool_calls:
# Drop empty/default-looking args (None, "", [], {}, False, 0) to cut noise.
args = ", ".join(f"{k}={v!r}" for k, v in call["args"].items() if v)
print(f" → {call['name']}({args})")
if answer := text_of(message.content).strip():
print(f"\n{answer}\n")
elif isinstance(message, ToolMessage):
text = re.sub(r"\s+", " ", text_of(message.content)).strip()
more = f" … ({len(text)} chars)" if len(text) > PREVIEW_CHARS else ""
mark = "✗" if message.status == "error" else "←"
print(f" {mark} {text[:PREVIEW_CHARS]}{more}")
The rules, in plain words:
- A tool call prints as
→ name(args). Arguments that are falsy (None,"",[],{},False,0) are dropped, which removes the defaults noise. The cost is that an argument you set to0orFalseon purpose is hidden too. For a display that is fine; for debugging, look at the state. - A tool result prints as
←plus a one-line preview, whitespace collapsed, cut at 160 characters, with the full length in parentheses. Errors get✗instead. - The model's final answer prints in full.
text_offlattens message content, which can be a string or a list of blocks, into one string. Part 7'scompact()reuses it.
Symptom: stray blank lines between outputs. Cause: some messages had content that was only whitespace, and I printed it. Fix: .strip() the text before testing it, which is the answer := text_of(...).strip() check above.
Streaming with updates
display.py · run
async def run(graph, user_input: str, config: dict | None = None) -> None:
"""Stream a graph run, printing only the messages each step adds.
If the graph pauses with interrupt() it asks for approval, then resumes the same run.
"""
payload = {"messages": [("user", user_input)]}
while payload is not None:
paused = None
async for update in graph.astream(payload, config, stream_mode="updates"):
for node, node_output in update.items():
if node == "__interrupt__": # a tuple of Interrupt objects
paused = node_output[0].value
continue
node_output = node_output or {}
if route := node_output.get("route"):
print(f" ⇢ route: {route}")
for message in node_output.get("messages", []):
show(message)
payload = Command(resume=confirm(paused)) if paused else None
Each update is a dict like {"router": {"route": "geoip"}} or {"tools": {"messages": [...]}}. The loop prints a ⇢ route: line when the router node returns one (which is how I see Part 4's decision on every turn), then calls show on each new message. Because only new messages arrive, nothing is echoed and nothing prints twice.
The outer while is for approvals from Part 6. If the graph pauses with interrupt(), the stream yields an __interrupt__ entry; the loop remembers it, ends the stream, asks via confirm() (which clips long arguments with _clip() so a path is never silently truncated), and runs again with Command(resume=...). That is all this part needs to say about it.
Here is the result in a real run (one run; model output varies). I asked it to list this project's directory and to fetch a page:
you> ⇢ route: filesystem
→ list_dir(path='~/langgraph-starter')
← { "entries": [ { "name": ".claude", "path": "~/langgraph-starter/.claude", "type": "directory", "size": null, "modified": 1794358459. … (3553 chars)
The files and directories in ~/langgraph-starter are as follows:
- .claude (directory)
- .git (directory)
- README.md (file, 2019 bytes)
- agent.py (file, 3657 bytes)
- agent_graph.py (file, 8444 bytes)
…
you> ⇢ route: webcrawler
→ get_page_content(url='https://example.com')
← ## Example Domain **URL:** https://example.com/ **Type:** generic | **Words:** 131 | **Reading time:** 0.5min **Language:** en ### Content This domain is for us … (1039 chars)
The page at https://example.com is a documentation example domain that should not be used for testing or monitoring purposes.
(I trimmed the middle of the file list with …. The you> prompt shares a line with the first output only because I piped the input; in a terminal your Enter key supplies the newline.) Four lines per request instead of a screenful, and I can still see which tool ran, with which arguments, and how big the result was.
The preview hides detail on purpose. When the model gets something wrong, I can't tell from this display whether the tool returned bad data or the model misread good data. In that case I inspect the saved state. In the listing above, the model also reported some file sizes I didn't check against the tool output; a one-line preview won't catch that.
Step 2: silencing the MCP servers
The servers' log lines were the second source of noise. The three MCP servers are my own separate projects. They call logging.basicConfig(level=INFO) and write to stderr, and because the agent launches them as child processes, that stderr lands in my terminal as lines like:
2026-11-10 17:58:56,302 - mcp.server.lowlevel.server - INFO - Processing request of type CallToolRequest
One of those per request adds up quickly. I did not want to edit the servers, since they are used by other things. And the stdio configuration in langchain-mcp-adapters has no option to redirect a child's stderr.
So I wrote a wrapper that launches the server script in-process after turning logging down:
quiet_server.py
import logging
import os
import runpy
import sys
# Discards INFO and below, even after the server's own basicConfig.
logging.disable(logging.INFO)
script = os.path.abspath(sys.argv[1])
sys.argv = sys.argv[1:]
sys.path.insert(0, os.path.dirname(script)) # so `import webcrawler` etc. resolve
runpy.run_path(script, run_name="__main__")
How it works:
logging.disable(logging.INFO)is a global switch: it discards every record at INFO or below, no matter what handlers or levels are configured afterward. That is why it still works after the server's ownbasicConfig(level=INFO)runs. Warnings and errors still get through.runpy.run_path(script, run_name="__main__")runs the server script as if you had typedpython script.py, so itsif __name__ == "__main__":block fires.- Setting
sys.argvand putting the script's folder first onsys.pathmakes the server see the arguments and imports it expects. Without thesys.pathline, a server that doesimport webcrawlerfrom its own folder fails.
_server() in agent.py builds the stdio config so that every MCP server starts through this wrapper. Any stdio MCP server can go through it the same way, since the wrapper only needs a script path.
This only works for Python servers you can run by script path, and it silences INFO for the whole process, including anything useful. If a server misbehaves, I run it directly, without the wrapper, to see its logs.
Recap
stream_mode="values"hands you the whole state each step;"updates"hands you only what each node added, so each message prints once.show()prints→ name(args)with falsy arguments dropped and← preview (N chars)cut at 160 characters, with✗for errors.run()also prints the⇢ route:line and loops to resume after an approval interrupt.- Whitespace-only message content caused stray blank lines; stripping before printing fixed it.
quiet_server.pyuseslogging.disable(logging.INFO)plusrunpyto quiet a server without editing it.
Next: Part 9 collects every failure from the build in one place.
langgraph 1.2.20, langchain-mcp-adapters 0.3.2, mcp 1.30.0, Python 3.14.0, qwen2.5-7b-instruct-mlx.