(opens in a new tab) Skip to main content
AI & Machine Learning

Making the Terminal Usable

Lex Gaines, , 8 min read

Replace pretty_print with a compact streaming display and silence noisy MCP server logs with a small wrapper, without touching the servers.

Contents

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 to 0 or False on 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_of flattens message content, which can be a string or a list of blocks, into one string. Part 7's compact() reuses it.
What went wrong

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.

Honest limits

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 own basicConfig(level=INFO) runs. Warnings and errors still get through.
  • runpy.run_path(script, run_name="__main__") runs the server script as if you had typed python script.py, so its if __name__ == "__main__": block fires.
  • Setting sys.argv and putting the script's folder first on sys.path makes the server see the arguments and imports it expects. Without the sys.path line, a server that does import webcrawler from 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.

Honest limits

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.py uses logging.disable(logging.INFO) plus runpy to quiet a server without editing it.

Next: Part 9 collects every failure from the build in one place.

Tested with

langgraph 1.2.20, langchain-mcp-adapters 0.3.2, mcp 1.30.0, Python 3.14.0, qwen2.5-7b-instruct-mlx.

  • langgraph
  • langchain
  • mcp
  • local-llm
  • lm-studio