render MCP tool calls with the connection look again

This commit is contained in:
Jonathan Singer 2026-08-26 14:24:06 -04:00
parent cb5691d6e2
commit e3f95d6cfd
15 changed files with 212 additions and 152 deletions

View file

@ -33,3 +33,15 @@ func renderMcpTool(connection, toolName string, args map[string]any, status stri
b.WriteString(style.Render(icon))
return b.String()
}
// renderMcpInspect renders describe_mcp: a request to inspect one connection's
// catalog rather than a call to a tool on it. There is no underlying tool, so
// the connection is the whole subject and leads. Same icon and colors as a tool
// call so the two read as one family while scrolling a transcript.
func renderMcpInspect(connection, status string) string {
var b strings.Builder
b.WriteString(mcpIcon + Dim().Render("Inspecting MCP server ") + Bold(Mint).Render(connection) + "\n")
icon, style := statusIcon(status)
b.WriteString(style.Render(icon))
return b.String()
}

View file

@ -57,6 +57,11 @@ func Tool(data map[string]any) string {
// nothing here. The tag is only ever set from the connections the run made,
// so it is the one thing that can tell such a call apart from a built-in.
if connection := StringValue(data["mcp_connection"]); connection != "" {
// describe_mcp inspects a connection's catalog rather than calling a tool
// on it, so there is no underlying tool and the connection is the subject.
if name == "describe_mcp" {
return renderMcpInspect(connection, status)
}
toolName := StringValue(data["mcp_tool"])
if toolName == "" {
toolName = name

View file

@ -227,7 +227,9 @@ func TestGenericToolOmitsRawResult(t *testing.T) {
}
func TestMcpToolLeadsWithActionAndNamesTheServer(t *testing.T) {
data := tool("local_fs_read_file", map[string]any{"path": "/etc/hosts"}, "file body", "completed")
// call_mcp is the dispatch tool; the connection and the server's own tool
// name are tagged onto the event from its arguments.
data := tool("call_mcp", map[string]any{"path": "/etc/hosts"}, "file body", "completed")
data["mcp_connection"] = "local_fs"
data["mcp_tool"] = "read_file"
@ -244,11 +246,26 @@ func TestMcpToolLeadsWithActionAndNamesTheServer(t *testing.T) {
}
}
func TestMcpToolWithoutTaggedNameFallsBackToFullName(t *testing.T) {
data := tool("local_fs_read_file", nil, nil, "running")
func TestMcpToolWithoutTaggedToolFallsBackToDispatchName(t *testing.T) {
// A call_mcp whose underlying tool could not be read still renders as an MCP
// row, falling back to the dispatch tool name.
data := tool("call_mcp", nil, nil, "running")
data["mcp_connection"] = "local_fs"
requireContains(t, ansi.Strip(Tool(data)), "local_fs_read_file", "In progress")
requireContains(t, ansi.Strip(Tool(data)), mcpIcon+"call_mcp", "local_fs", "In progress")
}
func TestMcpDescribeInspectsConnection(t *testing.T) {
// describe_mcp inspects a connection; the connection is the subject and the
// dispatch tool name is not shown as if it were a server tool.
data := tool("describe_mcp", nil, nil, "completed")
data["mcp_connection"] = "local_fs"
out := ansi.Strip(Tool(data))
requireContains(t, out, mcpIcon, "Inspecting MCP server", "local_fs", "Done")
if strings.Contains(out, "describe_mcp") {
t.Fatalf("describe_mcp must read as inspecting the connection, not name the dispatch tool:\n%s", out)
}
}
func TestCollapseToolShellPreviewAndExpand(t *testing.T) {

View file

@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
from agents.tool import ToolOutputImage
@ -16,9 +15,10 @@ from agents.tool import ToolOutputImage
from strix.core.paths import runtime_state_dir
from strix.interface.tui.history import load_session_history
# Imported from the naming module rather than the mcp package so a projection
# never pulls in the MCP client and the agents SDK behind it.
from strix.tools.mcp.naming import resolve_mcp_tool
# Every MCP call the model makes goes through one of two dispatch tools, so a
# call to a user's server is recognised by the tool's name alone.
_MCP_DISPATCH_TOOLS = frozenset({"call_mcp", "describe_mcp"})
class TuiLiveView:
@ -31,27 +31,30 @@ class TuiLiveView:
self._user_instruction: str | None = None
self._user_instruction_at: str | None = None
self._user_instruction_shown = False
self._mcp_connections: tuple[str, ...] = ()
def set_mcp_connections(self, names: Iterable[str]) -> None:
"""The MCP servers this run connected, so its tool calls can name theirs.
A server's tools are offered to the model under a name built from the
connection name and the tool's own name. That name cannot be split back
apart on its own, so tool calls are matched against these names instead.
"""
self._mcp_connections = tuple(str(name) for name in names)
def _mcp_tool_fields(self, tool_name: str) -> dict[str, str]:
def _mcp_tool_fields(self, tool_name: str, args: dict[str, Any]) -> dict[str, str]:
"""Event fields naming the MCP server a tool call went out to, if any.
Empty for every built-in tool, which is what tells an interface to render
the call as one of its own rather than as a call to a user's server.
Every MCP call the model makes goes through one of two dispatch tools:
``call_mcp`` runs a named tool on a connection, and ``describe_mcp``
inspects a connection's catalog. The connection, and for ``call_mcp`` the
server's own name for the tool, ride in the call's arguments rather than
in the tool name, so they are read from there. Empty for every other tool,
which is what tells an interface to render the call as one of its own
rather than as a call to a user's server.
"""
origin = resolve_mcp_tool(tool_name, self._mcp_connections)
if origin is None:
if tool_name not in _MCP_DISPATCH_TOOLS:
return {}
return {"mcp_connection": origin.connection, "mcp_tool": origin.tool}
connection = args.get("connection")
if not isinstance(connection, str) or not connection:
return {}
# describe_mcp has no underlying tool; an empty tool tells both renderers
# to present the row as inspecting the connection itself.
tool = args.get("tool") if tool_name == "call_mcp" else ""
return {
"mcp_connection": connection,
"mcp_tool": tool if isinstance(tool, str) else "",
}
def set_user_instruction(self, text: str | None, *, timestamp: str | None = None) -> None:
"""Open the transcript with what the user asked for.
@ -98,8 +101,7 @@ class TuiLiveView:
def hydrate_from_run_dir(self, run_dir: Path) -> None:
# Armed before the agents are added so the root agent's arrival puts the
# user's opening message ahead of the replayed history, and before the
# history is replayed so its MCP tool calls are attributed too.
# user's opening message ahead of the replayed history.
self._load_run_record(run_dir)
state_dir = runtime_state_dir(run_dir)
agents_path = state_dir / "agents.json"
@ -128,16 +130,13 @@ class TuiLiveView:
self._hydrate_sdk_session_history(run_dir, statuses.keys())
def _load_run_record(self, run_dir: Path) -> None:
"""Take the user's opening message and the run's MCP servers off the record."""
"""Take the user's opening message off the record."""
try:
record = json.loads((run_dir / "run.json").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
if not isinstance(record, dict):
return
connections = record.get("mcp_connections")
if isinstance(connections, list):
self.set_mcp_connections(name for name in connections if isinstance(name, str))
instruction = record.get("user_instruction")
if not isinstance(instruction, str):
return
@ -348,7 +347,7 @@ class TuiLiveView:
"status": "running",
"agent_id": agent_id,
"call_id": call_id,
**self._mcp_tool_fields(call["tool_name"]),
**self._mcp_tool_fields(call["tool_name"], call["args"]),
}
if existing is None:
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
@ -371,6 +370,10 @@ class TuiLiveView:
event_key = (agent_id, call_id)
event = self._tool_event_by_agent_and_call_id.get(event_key)
if event is None:
# No prior call event to update, so its arguments are gone and the
# connection an MCP call went out to cannot be recovered. The matching
# call event, when there is one, already carries the MCP fields; this
# arrives only when the call was never projected, so it stays generic.
event = self._append_event(
agent_id,
"tool",
@ -380,7 +383,6 @@ class TuiLiveView:
"status": "completed",
"agent_id": agent_id,
"call_id": call_id,
**self._mcp_tool_fields(output["tool_name"]),
},
timestamp=timestamp,
)

View file

@ -207,23 +207,9 @@ class GoTuiRuntime:
self.controller.notify_changed()
def capture_event(self, agent_id: str, event: Any) -> None:
self._refresh_mcp_connections()
self.live_view.ingest_sdk_event(agent_id, event)
self.controller.notify_changed()
def _refresh_mcp_connections(self) -> None:
"""Hand the projection the MCP servers the scan connected.
The scan records them as it connects, which is before the agent can call
anything, and the projection needs them to say which server a tool call
went out to. Read on the way in rather than pushed, so no tool call can
be projected before they arrive.
"""
if self.report_state is None:
return
connections = self.report_state.run_record.get("mcp_connections") or []
self.live_view.set_mcp_connections(connections)
async def _sync_agent_state(self) -> bool:
parent_of, statuses, names, errors = await self.coordinator.graph_snapshot()
changed = False

View file

@ -47,13 +47,29 @@ export default function McpRenderer({
const lines = argLines(args);
const failed = status === "failed" || status === "error";
const error = failed ? errorText(result) : null;
// describe_mcp inspects a connection's catalog rather than calling a tool on
// it, so the connection is the subject and there is no underlying tool.
const inspecting = toolName === "describe_mcp";
return (
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-teal-300 font-semibold text-sm">{mcpTool || toolName}</span>
<span className="text-[13px] text-[#555]">via MCP server</span>
{mcpConnection && <span className="text-[13px] text-teal-400/80">{mcpConnection}</span>}
{inspecting ? (
<>
<span className="text-[13px] text-[#555]">Inspecting MCP server</span>
{mcpConnection && (
<span className="font-mono text-teal-300 font-semibold text-sm">{mcpConnection}</span>
)}
</>
) : (
<>
<span className="font-mono text-teal-300 font-semibold text-sm">
{mcpTool || toolName}
</span>
<span className="text-[13px] text-[#555]">via MCP server</span>
{mcpConnection && <span className="text-[13px] text-teal-400/80">{mcpConnection}</span>}
</>
)}
</div>
{lines.length > 0 && (

View file

@ -181,8 +181,9 @@ function resolveCategory(toolName: string): ToolCategory | null {
/**
* A call to a tool from one of the user's MCP servers is placed by the
* connection it was tagged with, ahead of every name-keyed lookup below: its
* name belongs to that server and matches nothing in this table.
* connection it was tagged with, ahead of every name-keyed lookup below. Every
* MCP call goes through the `call_mcp` / `describe_mcp` dispatch tools, so the
* connection tag, not the tool name, is what routes it to the MCP renderer.
*/
export function getToolRenderer(
toolName: string,

View file

@ -101,9 +101,10 @@ export interface ToolRendererProps {
status: "running" | "completed" | "failed" | "error";
/**
* Set only on a call to a tool from an MCP server the user connected: the name
* they gave that connection, and the server's own name for the tool. The
* engine resolves both, because `toolName` is the two glued together and
* cannot be split back apart here.
* they gave that connection, and the server's own name for the tool. Every MCP
* call goes through the `call_mcp` / `describe_mcp` dispatch tools, so the
* engine reads both out of the call's arguments; `describe_mcp` inspects a
* connection and leaves `mcpTool` empty.
*/
mcpConnection?: string | null;
mcpTool?: string | null;

View file

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Strix Results</title>
<script type="module" crossorigin src="./assets/index-C9c1WbvP.js"></script>
<script type="module" crossorigin src="./assets/index-CYf9nnT3.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D0453ODW.css">
</head>
<body>

View file

@ -10,7 +10,7 @@ from strix.tools.mcp.config import (
McpConnectionConfig,
)
from strix.tools.mcp.loader import load_user_mcp_configs
from strix.tools.mcp.naming import McpToolOrigin, namespaced_tool_name, resolve_mcp_tool
from strix.tools.mcp.naming import namespaced_tool_name
from strix.tools.mcp.registry import (
MCP_REGISTRY_CONTEXT_KEY,
McpConnectionEntry,
@ -29,12 +29,10 @@ __all__ = [
"McpConnectionEntry",
"McpConnectionSummary",
"McpRegistry",
"McpToolOrigin",
"call_mcp",
"connect_mcp_servers",
"describe_mcp",
"load_user_mcp_configs",
"mcp_inventory_context",
"namespaced_tool_name",
"resolve_mcp_tool",
]

View file

@ -97,10 +97,11 @@ async def call_mcp(
Args:
connection: The connection name exactly as shown in the MCP inventory.
tool: The tool name, exactly as reported by ``describe_mcp``.
arguments: The tool's arguments as an object of names to values, or
omitted/empty for a tool that takes none. ``arguments`` is passed
through as-is, so its shape is whatever ``describe_mcp`` showed for
the tool rather than a shape this tool fixes in advance.
arguments: The tool's arguments as a JSON object of names to values (for
example ``{"path": "app.py"}``), or omitted/empty for a tool that
takes none. Pass an object, not a stringified one. Its shape is
whatever ``describe_mcp`` showed for the tool rather than a shape this
tool fixes in advance.
"""
registry = _registry_from_ctx(ctx)
if registry is None or not registry:
@ -108,11 +109,22 @@ async def call_mcp(
entry = registry.get(connection)
if entry is None:
return _unknown_connection(connection, registry)
invalid_arguments = (
f"Invalid arguments for {connection!r}.{tool}: expected a JSON object of "
"argument names to values, or none. Call describe_mcp for the input schema."
)
if isinstance(arguments, str):
# The ``arguments`` parameter is schema-less (an open object is not
# expressible as a strict tool schema), so some models serialize it as a
# JSON string instead of a bare object. Accept a string that decodes to an
# object so a correct call is not rejected over its encoding.
stripped = arguments.strip()
try:
arguments = json.loads(stripped) if stripped else {}
except json.JSONDecodeError:
return invalid_arguments
if arguments is not None and not isinstance(arguments, dict):
return (
f"Invalid arguments for {connection!r}.{tool}: expected a JSON object of "
"argument names to values, or none. Call describe_mcp for the input schema."
)
return invalid_arguments
available = await entry.server.list_tools()
valid_names = {mcp_tool.name for mcp_tool in available}
if tool not in valid_names:

View file

@ -17,6 +17,8 @@ from __future__ import annotations
import contextlib
import json
import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any, NamedTuple, cast
from agents.mcp import (
@ -27,6 +29,7 @@ from agents.mcp import (
MCPServerStreamableHttpParams,
create_static_tool_filter,
)
from mcp.client.stdio import stdio_client
if TYPE_CHECKING:
@ -72,6 +75,35 @@ def _auth_headers(config: McpConnectionConfig) -> dict[str, str]:
return {"Authorization": f"Bearer {auth.token}"}
@contextlib.asynccontextmanager
async def _quiet_stdio_streams(params: Any) -> Any:
"""Run a stdio MCP server with its stderr sent to the void.
A stdio MCP server chats on stderr as it boots (the filesystem server, for
one, prints ``Allowed directories: [ ... ]``). The mcp library forwards that
stderr to the parent's ``sys.stderr`` by default, which is the terminal the
TUI is drawing on, so the banner corrupts the display. Pointing ``errlog`` at
``os.devnull`` drops that chatter. Connection failures are unaffected: they
still raise from ``connect`` and are logged by :func:`connect_mcp_servers`.
"""
with Path(os.devnull).open("w", encoding="utf-8") as errlog:
async with stdio_client(params, errlog=errlog) as streams:
yield streams
class _QuietMCPServerStdio(MCPServerStdio):
"""``MCPServerStdio`` whose subprocess stderr is kept off the terminal.
The SDK's ``create_streams`` calls ``stdio_client(self.params)`` with no
``errlog``, so the subprocess stderr defaults to ``sys.stderr`` and paints
server banners over the running TUI. Overriding it lets us redirect that
stream; everything else about the stdio transport is unchanged.
"""
def create_streams(self) -> Any:
return _quiet_stdio_streams(self.params)
def _build_server(config: McpConnectionConfig) -> MCPServer:
"""Construct (but do not connect) the SDK server for one connection.
@ -92,7 +124,7 @@ def _build_server(config: McpConnectionConfig) -> MCPServer:
"args": config.args,
"env": config.env,
}
return MCPServerStdio(
return _QuietMCPServerStdio(
params=stdio_params,
name=config.name,
tool_filter=tool_filter,

View file

@ -1,18 +1,13 @@
"""How an MCP server's tools are named for the model, and how to read that back.
"""How an MCP server's tools are named for the model.
Kept apart from the client, and stdlib-only, so the interfaces can resolve which
connection a tool call went to without importing the MCP client (and through it
the agents SDK and every registered tool).
Kept apart from the client, and stdlib-only, so a caller can build the
model-facing name for a connection's tool without importing the MCP client (and
through it the agents SDK and every registered tool).
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, NamedTuple
if TYPE_CHECKING:
from collections.abc import Iterable
# A tool name offered to a model has to be letters, digits, underscores or
@ -32,49 +27,3 @@ def namespaced_tool_name(connection: str, tool: str) -> str:
which tool is invoked.
"""
return _INVALID_TOOL_NAME_CHARS.sub("_", f"{connection}_{tool}")
class McpToolOrigin(NamedTuple):
"""Where a model-facing tool name came from, for showing the user.
``connection`` is the name the user gave the connection in their config, so
it reads the way they wrote it. ``tool`` is what is left of the model-facing
name once the connection prefix is removed, which is the server's own name
for the tool and the part a reader cares about.
"""
connection: str
tool: str
def resolve_mcp_tool(tool_name: str, connections: Iterable[str]) -> McpToolOrigin | None:
"""Split a model-facing tool name against the run's connections, or ``None``.
Matched against the connections the run actually made rather than by
splitting the name on the separator: the connection name and the server's own
tool name can both contain underscores, so a split is ambiguous and would
attribute calls to a connection that does not exist. Each connection name is
sanitized the same way :func:`namespaced_tool_name` sanitizes it before
comparing, so a connection whose name has characters a model-facing name
cannot carry still matches.
The longest match wins, so one connection whose name is a prefix of another's
still resolves to the right one. The character after the prefix has to be a
separator rather than more of a name, which any non-alphanumeric satisfies,
so this holds whichever separator :func:`namespaced_tool_name` uses.
"""
best: McpToolOrigin | None = None
best_length = 0
for connection in connections:
prefix = _INVALID_TOOL_NAME_CHARS.sub("_", connection)
if not prefix or len(tool_name) <= len(prefix) or not tool_name.startswith(prefix):
continue
if tool_name[len(prefix)].isalnum():
continue
if len(prefix) > best_length:
# Past the prefix and its single separator character is the tool's
# own name; if a server named a tool nothing but separators, fall
# back to the whole name so the row still says something.
tool = tool_name[len(prefix) + 1 :] or tool_name
best, best_length = McpToolOrigin(connection, tool), len(prefix)
return best

View file

@ -31,7 +31,6 @@ from strix.tools.mcp import (
load_user_mcp_configs,
mcp_inventory_context,
namespaced_tool_name,
resolve_mcp_tool,
)
from strix.tools.mcp import client as mcp_client
@ -417,6 +416,40 @@ async def test_call_mcp_defaults_missing_arguments_to_empty_object() -> None:
assert server.calls == [("ping", {})]
@pytest.mark.asyncio
async def test_call_mcp_coerces_json_string_arguments() -> None:
# Some models serialize the schema-less ``arguments`` object as a JSON string;
# a correct call must not be rejected over that encoding.
registry = McpRegistry()
server = FakeMCPServer("fs", [_mcp_tool("read_file")])
registry.add(name="fs", server=server, purpose=None, tool_count=1)
out = await call_mcp.on_invoke_tool(
_ctx(registry),
json.dumps(
{"connection": "fs", "tool": "read_file", "arguments": '{"path": "/etc/hosts"}'}
),
)
assert server.calls == [("read_file", {"path": "/etc/hosts"})]
assert out == {"type": "text", "text": "routed:read_file"}
@pytest.mark.asyncio
async def test_call_mcp_errors_on_unparseable_string_arguments() -> None:
registry = McpRegistry()
server = FakeMCPServer("fs", [_mcp_tool("read_file")])
registry.add(name="fs", server=server, purpose=None, tool_count=1)
out = await call_mcp.on_invoke_tool(
_ctx(registry),
json.dumps({"connection": "fs", "tool": "read_file", "arguments": "not json"}),
)
assert "expected a JSON object" in out
assert server.calls == []
@pytest.mark.asyncio
async def test_call_mcp_errors_on_unknown_connection() -> None:
registry = McpRegistry()
@ -685,31 +718,9 @@ async def test_connect_cleans_up_when_cancelled_mid_connect(
# --- reading a tool call back to the server it went out to -------------------
# resolve_mcp_tool / namespaced_tool_name stay in strix.tools.mcp.naming: the
# TUI reads them to attribute a call to its connection, and call_mcp builds the
# result_transform label with namespaced_tool_name.
def test_resolve_mcp_tool_splits_against_the_run_connections() -> None:
assert resolve_mcp_tool("local_fs_read_file", ["github", "local_fs"]) == (
"local_fs",
"read_file",
)
def test_resolve_mcp_tool_prefers_the_longest_matching_connection() -> None:
assert resolve_mcp_tool("files_main_list", ["files", "files_main"]) == ("files_main", "list")
def test_resolve_mcp_tool_matches_a_connection_name_it_had_to_sanitize() -> None:
tool_name = namespaced_tool_name("my server", "db.query")
assert resolve_mcp_tool(tool_name, ["my server"]) == ("my server", "db_query")
def test_resolve_mcp_tool_ignores_tools_that_are_not_a_connection_s() -> None:
assert resolve_mcp_tool("exec_command", ["local_fs"]) is None
assert resolve_mcp_tool("local_fsx", ["local_fs"]) is None
# namespaced_tool_name stays in strix.tools.mcp.naming so call_mcp can build the
# result_transform label. The connection a call went out to is read off the
# call's arguments by the TUI projection, not off the tool name.
def test_namespaced_name_is_a_valid_tool_name() -> None:
@ -721,13 +732,16 @@ def test_namespaced_name_is_a_valid_tool_name() -> None:
assert re.fullmatch(r"[a-zA-Z0-9_-]{1,128}", name)
def test_projected_tool_call_names_the_server_it_went_out_to() -> None:
def test_projected_call_mcp_names_the_server_and_tool_from_its_args() -> None:
view = TuiLiveView()
view.set_mcp_connections(["local_fs"])
view._record_tool_call_data(
"agent-1",
{"call_id": "c1", "tool_name": "local_fs_read_file", "args": {"path": "/etc/hosts"}},
{
"call_id": "c1",
"tool_name": "call_mcp",
"args": {"connection": "local_fs", "tool": "read_file", "arguments": {"path": "/x"}},
},
)
view._record_tool_call_data(
"agent-1",
@ -737,3 +751,18 @@ def test_projected_tool_call_names_the_server_it_went_out_to() -> None:
mcp_call, built_in = (event["data"] for event in view.events)
assert (mcp_call["mcp_connection"], mcp_call["mcp_tool"]) == ("local_fs", "read_file")
assert "mcp_connection" not in built_in
def test_projected_describe_mcp_names_the_connection_with_no_tool() -> None:
view = TuiLiveView()
view._record_tool_call_data(
"agent-1",
{"call_id": "c1", "tool_name": "describe_mcp", "args": {"connection": "local_fs"}},
)
(describe,) = (event["data"] for event in view.events)
# An empty tool is what tells both renderers to present the row as inspecting
# the connection rather than as a call to a tool on it.
assert describe["mcp_connection"] == "local_fs"
assert describe["mcp_tool"] == ""