Show MCP tool calls distinctly in the terminal and the run viewer

This commit is contained in:
Jonathan Singer
2026-08-21 13:09:24 -04:00
parent afda373f55
commit 01ea94920d
21 changed files with 566 additions and 177 deletions
+1 -1
View File
@@ -342,7 +342,7 @@ Strix can connect to Model Context Protocol (MCP) servers you list and expose th
]
```
Each server's tools are namespaced by `name` (for example `local_fs.read_file`). Omit `allowed_tools` to expose every tool the server offers, or set it to a list to restrict which tools the agent can call. The file is optional, and a server that fails to connect is skipped without failing the run. You can point Strix at a different file with `STRIX_MCP_CONFIG`.
Each server's tools are namespaced by `name` (for example `local_fs_read_file`). Omit `allowed_tools` to expose every tool the server offers, or set it to a list to restrict which tools the agent can call. The file is optional, and a server that fails to connect is skipped without failing the run. You can point Strix at a different file with `STRIX_MCP_CONFIG`.
**Recommended models for best results:**
+10 -1
View File
@@ -41,7 +41,7 @@ Strix reads this file at the start of each run. There is no default file, so no
<ParamField path="name" type="string" required>
A short label for the connection. Each server's tools are namespaced by
`name` (for example `local_fs.read_file`), so two servers can offer the same
`name` (for example `local_fs_read_file`), so two servers can offer the same
tool name without colliding.
</ParamField>
@@ -106,6 +106,15 @@ or set the `STRIX_MCP_CONFIG` environment variable to that path. The flag takes
When servers are configured, Strix prints a one-line summary at scan startup, for example `MCP: connected 1 server (14 tools): local_fs`, so you can confirm your servers connected.
## Seeing the calls
Each call the agent makes to one of your servers is shown with its own icon and
labelled with the connection it went out to, in the terminal and in the run
viewer (`strix view`), so a call that left Strix for a server you connected is
easy to pick out of a transcript. The terminal shows the call and its arguments;
results can be large and arbitrary, so read them in the viewer, which shows a
preview you can expand.
## Behavior
- The config file is optional. Without it, a run simply gets no MCP tools.
+18
View File
@@ -74,6 +74,21 @@ def _mcp_startup_summary(connections: list[ConnectedMcpServer]) -> str:
return f"MCP: connected {server_count} {servers_word} ({tool_count} {tools_word}): {names}"
def _record_mcp_connections(connections: list[ConnectedMcpServer]) -> None:
"""Record which MCP servers this run connected, for the interfaces.
A server's tools are offered to the model under a name built from the
connection name and the tool's own name, which cannot be split back apart, so
the TUI and the run viewer need the names to match a tool call against before
they can show which server it went out to. Kept on the run record because the
viewer reads a finished run from disk.
"""
report_state = get_global_report_state()
if report_state is None:
return
report_state.record_mcp_connections([connection.name for connection in connections])
def _mcp_connection_notes(connections: list[ConnectedMcpServer]) -> str | None:
"""A block describing the connections the user left notes on, for the agent.
@@ -338,6 +353,9 @@ async def run_strix_scan(
if user_mcp_configs:
connections = await connect_mcp_servers(user_mcp_configs)
mcp_servers = [c.server for c in connections]
# Recorded even when nothing connected, so a resumed run does not
# keep attributing tool calls to servers it no longer has.
_record_mcp_connections(connections)
if connections:
report(_mcp_startup_summary(connections))
notes_block = _mcp_connection_notes(connections)
@@ -0,0 +1,35 @@
package render
import (
"strings"
)
// ---------------------------------------------------------------------------
// MCP tools (tools from the servers the user connected)
// ---------------------------------------------------------------------------
const mcpIcon = "🔌 "
// renderMcpTool renders a call to a tool from one of the user's MCP servers.
//
// Its own icon and color so a call that left Strix for a server the user
// connected is obvious while scrolling a transcript. The action leads and the
// server trails: the model-facing name is the connection name and the tool name
// stuck together, so leading with the whole name buries the part a reader wants
// behind a connection name that can be long or opaque.
//
// The result is deliberately not rendered, for the same reason
// renderGenericTool leaves it out: an MCP result is whatever an outside server
// chose to return, often multi-kilobyte JSON, and it floods the screen. The full
// result is in the event data, the run log, and the `strix view` viewer.
func renderMcpTool(connection, toolName string, args map[string]any, status string) string {
var b strings.Builder
b.WriteString(mcpIcon + Bold(Mint).Render(toolName))
b.WriteString(Dim().Render(" via MCP server ") + Col(Slate).Render(connection) + "\n")
for _, k := range SortedKeys(args) {
b.WriteString(" " + Dim().Render(k) + ": " + StringValue(args[k]) + "\n")
}
icon, style := statusIcon(status)
b.WriteString(style.Render(icon))
return b.String()
}
@@ -24,7 +24,7 @@ func statusIcon(status string) (string, lipgloss.Style) {
// renderGenericTool ports registry._render_default_tool_widget. It shows the
// tool name, its arguments, and a status line only. The raw result is
// deliberately not rendered: a generic/MCP result (e.g. a multi-kilobyte JSON
// deliberately not rendered: a generic result (e.g. a multi-kilobyte JSON
// payload from a database query tool) is noise on screen, and the agent narrates
// what it got in its next message. The full result still lives in the event
// data, the run log, and the `strix view` viewer.
@@ -52,6 +52,18 @@ func Tool(data map[string]any) string {
}
result := data["result"]
// A call to a tool from one of the user's MCP servers is tagged with the
// connection it came from, because its name is the server's own and means
// 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 != "" {
toolName := StringValue(data["mcp_tool"])
if toolName == "" {
toolName = name
}
return renderMcpTool(connection, toolName, args, status)
}
switch name {
case "exec_command":
return renderExecCommand(args, result, status)
@@ -215,8 +215,8 @@ func TestToolDispatchCoversKnownTools(t *testing.T) {
}
func TestGenericToolOmitsRawResult(t *testing.T) {
// The generic/MCP renderer shows tool name, args, and a status line only
// never the raw result payload.
// The generic renderer shows tool name, args, and a status line only, never
// the raw result payload.
long := strings.Repeat("x", 5000)
out := ansi.Strip(Tool(tool("db_query", map[string]any{"query": "select 1"}, long, "completed")))
@@ -226,6 +226,31 @@ 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")
data["mcp_connection"] = "local_fs"
data["mcp_tool"] = "read_file"
out := ansi.Strip(Tool(data))
// The action leads; the server is context that trails it.
if !strings.HasPrefix(out, mcpIcon+"read_file") {
t.Fatalf("MCP render must lead with the tool's own name:\n%s", out)
}
requireContains(t, out, "local_fs", "path", "/etc/hosts", "Done")
// Untrusted server output stays off the terminal, as for the generic render.
if strings.Contains(out, "file body") {
t.Fatalf("MCP result body must not be rendered:\n%s", out)
}
}
func TestMcpToolWithoutTaggedNameFallsBackToFullName(t *testing.T) {
data := tool("local_fs_read_file", nil, nil, "running")
data["mcp_connection"] = "local_fs"
requireContains(t, ansi.Strip(Tool(data)), "local_fs_read_file", "In progress")
}
func TestCollapseToolShellPreviewAndExpand(t *testing.T) {
lines := make([]string, 16)
for i := range lines {
+36 -4
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
from agents.tool import ToolOutputImage
@@ -15,6 +16,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
class TuiLiveView:
def __init__(self) -> None:
@@ -26,6 +31,27 @@ 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]:
"""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.
"""
origin = resolve_mcp_tool(tool_name, self._mcp_connections)
if origin is None:
return {}
return {"mcp_connection": origin.connection, "mcp_tool": origin.tool}
def set_user_instruction(self, text: str | None, *, timestamp: str | None = None) -> None:
"""Open the transcript with what the user asked for.
@@ -72,8 +98,9 @@ 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.
self._load_user_instruction(run_dir)
# user's opening message ahead of the replayed history, and before the
# history is replayed so its MCP tool calls are attributed too.
self._load_run_record(run_dir)
state_dir = runtime_state_dir(run_dir)
agents_path = state_dir / "agents.json"
if not agents_path.exists():
@@ -100,14 +127,17 @@ class TuiLiveView:
self.flush_user_instruction()
self._hydrate_sdk_session_history(run_dir, statuses.keys())
def _load_user_instruction(self, run_dir: Path) -> None:
"""Take the user's opening message from the run record, if it has one."""
def _load_run_record(self, run_dir: Path) -> None:
"""Take the user's opening message and the run's MCP servers 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
@@ -318,6 +348,7 @@ class TuiLiveView:
"status": "running",
"agent_id": agent_id,
"call_id": call_id,
**self._mcp_tool_fields(call["tool_name"]),
}
if existing is None:
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
@@ -349,6 +380,7 @@ class TuiLiveView:
"status": "completed",
"agent_id": agent_id,
"call_id": call_id,
**self._mcp_tool_fields(output["tool_name"]),
},
timestamp=timestamp,
)
+14
View File
@@ -207,9 +207,23 @@ 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
@@ -30,7 +30,7 @@ class RendererErrorBoundary extends Component<
}
function SafeToolRenderer(props: ToolRendererProps) {
const Renderer = getToolRenderer(props.toolName);
const Renderer = getToolRenderer(props.toolName, props.mcpConnection);
return (
<RendererErrorBoundary toolName={props.toolName}>
<Renderer {...props} />
@@ -63,6 +63,10 @@ function coerce(value: unknown): unknown {
}
}
function asOptionalString(value: unknown): string | null {
return typeof value === "string" && value ? value : null;
}
function asRecord(value: unknown): Record<string, unknown> {
const c = coerce(value);
if (c && typeof c === "object" && !Array.isArray(c)) return c as Record<string, unknown>;
@@ -244,11 +248,14 @@ export function AgentTranscript({
const isTool = event.type === "tool";
const toolName = isTool ? String(event.data?.tool_name ?? "tool") : "";
const role = !isTool ? String(event.data?.role ?? "assistant") : "";
// Present only on a call to one of the user's own MCP servers.
const mcpConnection = asOptionalString(event.data?.mcp_connection);
const mcpTool = asOptionalString(event.data?.mcp_tool);
let Icon;
let iconColor: string;
if (isTool) {
const meta = getToolIcon(toolName);
const meta = getToolIcon(toolName, mcpConnection);
Icon = meta.icon;
iconColor = meta.color;
} else {
@@ -279,6 +286,8 @@ export function AgentTranscript({
{isTool ? (
<SafeToolRenderer
toolName={toolName}
mcpConnection={mcpConnection}
mcpTool={mcpTool}
args={asRecord(event.data?.args)}
result={coerce(event.data?.result) ?? null}
status={
@@ -0,0 +1,82 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
/**
* A call to a tool from one of the MCP servers the user connected.
*
* Deliberately the same shape as the terminal: the tool's own name, the server
* it went to, the arguments one per line, and a status. The result is not shown.
* These payloads are routinely thousands of characters of JSON that say nothing a
* reader wants at this point in the transcript, and the agent narrates what it
* learned in its next message. A failure is the exception, because that is what
* someone is looking for when a step did not work; it renders as inert text,
* never as markdown, since it came from a server outside Strix.
*
* The full result is still in the run's event data on disk either way.
*/
/** Arguments one line each, as the terminal prints them. */
function argLines(args: unknown): string[] {
if (!args || typeof args !== "object" || Array.isArray(args)) return [];
return Object.entries(args as Record<string, unknown>).map(([key, value]) => {
const rendered = typeof value === "string" ? value : JSON.stringify(value);
return `${key}: ${rendered ?? String(value)}`;
});
}
const MAX_ERROR_CHARS = 600;
function errorText(result: unknown): string | null {
if (typeof result === "string") {
const trimmed = result.trim();
if (!trimmed) return null;
return trimmed.length > MAX_ERROR_CHARS ? `${trimmed.slice(0, MAX_ERROR_CHARS)}` : trimmed;
}
return null;
}
export default function McpRenderer({
toolName,
mcpTool,
mcpConnection,
args,
result,
status,
}: ToolRendererProps) {
const lines = argLines(args);
const failed = status === "failed" || status === "error";
const error = failed ? errorText(result) : null;
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>}
</div>
{lines.length > 0 && (
<div className="mt-1 font-mono text-[13px] leading-relaxed">
{lines.map((line) => (
<div key={line} className="text-[#777] break-all">
{line}
</div>
))}
</div>
)}
<div className="mt-1 text-[13px]">
{status === "running" && <span className="text-[#666]">Running</span>}
{status === "completed" && <span className="text-emerald-400/80"> Done</span>}
{failed && <span className="text-red-400/80"> Failed</span>}
</div>
{error && (
<pre className="mt-1 font-mono text-[13px] leading-relaxed whitespace-pre-wrap break-words text-red-400/70">
{error}
</pre>
)}
</div>
);
}
@@ -3,7 +3,7 @@ import type { ToolRendererProps } from "@/types/events";
import {
Terminal, Globe, FileText, ShieldAlert, ArrowUpRight, Brain,
Bot, MessageCircle, Flag, Eye, Search, Code, StickyNote,
ListTodo, Crosshair, Wrench, Ban, Image,
ListTodo, Crosshair, Wrench, Ban, Image, Plug,
} from "lucide-react";
import TerminalRenderer from "./TerminalRenderer";
@@ -25,6 +25,7 @@ import TodoRenderer from "./TodoRenderer";
import FallbackRenderer from "./FallbackRenderer";
import LoadSkillRenderer from "./LoadSkillRenderer";
import RespondRenderer from "./RespondRenderer";
import McpRenderer from "./McpRenderer";
/**
* Tool-renderer mapping — data-driven, keyed by the engine's tool *family*.
@@ -53,7 +54,8 @@ export type ToolCategory =
| "notes"
| "skills"
| "todos"
| "telemetry";
| "telemetry"
| "mcp";
export interface ToolIconMeta {
icon: ComponentType<{ className?: string }>;
@@ -84,6 +86,9 @@ const CATEGORY_META: Record<ToolCategory, CategoryMeta> = {
skills: { renderer: LoadSkillRenderer, icon: Wrench, color: "text-emerald-400" },
todos: { renderer: TodoRenderer, icon: ListTodo, color: "text-purple-400", match: /todo/ },
telemetry: { renderer: FallbackRenderer, icon: Wrench, color: "text-[#555]" },
// Tools from the user's own MCP servers. Resolved from the connection on the
// event rather than from a tool name, so this family has no names below.
mcp: { renderer: McpRenderer, icon: Plug, color: "text-teal-400" },
};
/**
@@ -113,6 +118,7 @@ const CATEGORY_TOOLS: Record<ToolCategory, readonly string[]> = {
skills: ["load_skill"],
todos: ["create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo"],
telemetry: ["sandbox_error_details", "llm_error_details"],
mcp: [],
};
/** Reverse index (tool name → family), built once from CATEGORY_TOOLS. */
@@ -163,14 +169,26 @@ function resolveCategory(toolName: string): ToolCategory | null {
return null;
}
export function getToolRenderer(toolName: string): ComponentType<ToolRendererProps> {
/**
* 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.
*/
export function getToolRenderer(
toolName: string,
mcpConnection?: string | null
): ComponentType<ToolRendererProps> {
if (mcpConnection) return CATEGORY_META.mcp.renderer;
const override = RENDERER_OVERRIDES[toolName];
if (override) return override;
const category = resolveCategory(toolName);
return category ? CATEGORY_META[category].renderer : FallbackRenderer;
}
export function getToolIcon(toolName: string): ToolIconMeta {
export function getToolIcon(toolName: string, mcpConnection?: string | null): ToolIconMeta {
if (mcpConnection) {
return { icon: CATEGORY_META.mcp.icon, color: CATEGORY_META.mcp.color };
}
const override = ICON_OVERRIDES[toolName];
if (override) return override;
const category = resolveCategory(toolName);
@@ -99,4 +99,12 @@ export interface ToolRendererProps {
args: Record<string, unknown>;
result: unknown;
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.
*/
mcpConnection?: string | null;
mcpTool?: string | null;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<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-DBJ-RJqo.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DKbLYAbP.css">
<script type="module" crossorigin src="./assets/index-gEZK6bjO.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DS3GeJId.css">
</head>
<body>
<div id="root"></div>
+12
View File
@@ -388,6 +388,18 @@ class ReportState:
posthog.end(self, exit_reason="finished_by_tool")
scarf.end(self, exit_reason="finished_by_tool")
def record_mcp_connections(self, names: list[str]) -> None:
"""Note the MCP servers this run connected, and persist it.
Saved as soon as the run connects rather than at the end, so an interface
reading the record mid-run can already attribute a tool call to the
server it went out to.
"""
if self.run_record.get("mcp_connections") == names:
return
self.run_record["mcp_connections"] = names
self.save_run_data()
def set_scan_config(self, config: dict[str, Any]) -> None:
self.scan_config = config
self.run_record["status"] = "running"
+4
View File
@@ -9,6 +9,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
__all__ = [
@@ -16,6 +17,9 @@ __all__ = [
"ConnectedMcpServer",
"McpAuth",
"McpConnectionConfig",
"McpToolOrigin",
"connect_mcp_servers",
"load_user_mcp_configs",
"namespaced_tool_name",
"resolve_mcp_tool",
]
+2 -20
View File
@@ -16,7 +16,6 @@ from __future__ import annotations
import contextlib
import json
import logging
import re
from typing import TYPE_CHECKING, Any, NamedTuple, cast
from agents.exceptions import ModelBehaviorError
@@ -31,6 +30,7 @@ from agents.mcp import (
)
from strix.agents.factory import register_agent_tools
from strix.tools.mcp.naming import namespaced_tool_name
if TYPE_CHECKING:
@@ -51,24 +51,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# A tool name offered to a model has to be letters, digits, underscores or
# hyphens; anything else is rejected outright by the model APIs. Three things can
# put a stray character in one: the separator between the connection and the tool
# name, a name the server chose for its own tool (servers commonly namespace
# theirs), and the connection name out of the user's config file. Sanitizing the
# finished name covers all three rather than only the separator.
_INVALID_TOOL_NAME_CHARS = re.compile(r"[^a-zA-Z0-9_-]")
def _namespaced_tool_name(connection: str, tool: str) -> str:
"""The name a connection's tool is offered to the model under.
Only the model-facing name is rewritten. Every call to the server uses the
tool name the server itself reported, so sanitizing here can never change
which tool is invoked.
"""
return _INVALID_TOOL_NAME_CHARS.sub("_", f"{connection}_{tool}")
class ConnectedMcpServer(NamedTuple):
"""One successfully connected MCP server and how many tools it registered.
@@ -148,7 +130,7 @@ def _build_tool(
the call, through :func:`_install_error_status_capture`, so an errored result
reads as failed in the TUI while the agent's content is unchanged.
"""
namespaced_name = _namespaced_tool_name(config.name, mcp_tool.name)
namespaced_name = namespaced_tool_name(config.name, mcp_tool.name)
tool = MCPUtil.to_function_tool(
mcp_tool,
server,
+80
View File
@@ -0,0 +1,80 @@
"""How an MCP server's tools are named for the model, and how to read that back.
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).
"""
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
# hyphens; anything else is rejected outright by the model APIs. Three things can
# put a stray character in one: the separator between the connection and the tool
# name, a name the server chose for its own tool (servers commonly namespace
# theirs), and the connection name out of the user's config file. Sanitizing the
# finished name covers all three rather than only the separator.
_INVALID_TOOL_NAME_CHARS = re.compile(r"[^a-zA-Z0-9_-]")
def namespaced_tool_name(connection: str, tool: str) -> str:
"""The name a connection's tool is offered to the model under.
Only the model-facing name is rewritten. Every call to the server uses the
tool name the server itself reported, so sanitizing here can never change
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
+50 -1
View File
@@ -16,12 +16,14 @@ from pydantic import ValidationError
from strix.agents import factory
from strix.core.runner import _mcp_connection_notes
from strix.interface.tui.live_view import _tool_status_from_result
from strix.interface.tui.live_view import TuiLiveView, _tool_status_from_result
from strix.tools.mcp import (
BearerAuth,
ConnectedMcpServer,
McpConnectionConfig,
load_user_mcp_configs,
namespaced_tool_name,
resolve_mcp_tool,
)
from strix.tools.mcp import client as mcp_client
from strix.tools.mcp.client import _auth_headers, _build_server, _register_server_tools
@@ -659,3 +661,50 @@ def test_loader_exclude_selection_drops_named(
configs = load_user_mcp_configs(config_file)
assert [c.name for c in configs] == ["a", "c"]
# --- reading a tool call back to the server it went out to -------------------
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:
# One connection's name being a prefix of another's must not misattribute.
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:
# "my server" reaches the model as "my_server_db_query".
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
# A name that merely starts like a connection is not one of its tools.
assert resolve_mcp_tool("local_fsx", ["local_fs"]) is None
def test_projected_tool_call_names_the_server_it_went_out_to() -> 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"}},
)
view._record_tool_call_data(
"agent-1",
{"call_id": "c2", "tool_name": "exec_command", "args": {"cmd": "ls"}},
)
mcp_call, built_in = (event["data"] for event in view.events)
assert (mcp_call["mcp_connection"], mcp_call["mcp_tool"]) == ("local_fs", "read_file")
# A built-in call carries no connection, which is what keeps it rendering as one.
assert "mcp_connection" not in built_in