mirror of
https://github.com/usestrix/strix.git
synced 2026-08-22 02:58:39 +02:00
Show MCP tool calls distinctly in the terminal and the run viewer
This commit is contained in:
@@ -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:**
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
+129
-129
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user