mirror of
https://github.com/usestrix/strix.git
synced 2026-08-28 05:25:00 +00:00
Add MCP server support (#1137)
* add a generic MCP client and a config for connecting MCP servers * Add MCP docs and CLI polish: docs page, startup connect summary, --mcp-config flag, compact tool output * Add MCP connection notes and per-run selection; clean up on cancel and dedupe names * Show errored MCP tool calls as failed in the TUI * Sanitize namespaced tool names so model APIs accept them * Show MCP tool calls distinctly in the terminal and the run viewer * Say what MCP servers are worth connecting for * Correct the notes docstring to match how notes reach the agent * keep the mcp tests from reading your shell's STRIX_MCP_* vars
This commit is contained in:
parent
391d81bea7
commit
f4ef8867f6
27 changed files with 2149 additions and 160 deletions
24
README.md
24
README.md
|
|
@ -320,6 +320,30 @@ strix auth status # show the active sign-in
|
||||||
strix auth logout # forget the sign-in
|
strix auth logout # forget the sign-in
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Connect your own MCP servers
|
||||||
|
|
||||||
|
Strix can connect to Model Context Protocol (MCP) servers you list and expose their tools to the agent during a run. Create `~/.strix/mcp-servers.json` with a JSON list of servers. Each entry is either a local `stdio` server that Strix launches as a subprocess, or a remote `http` server:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "local_fs",
|
||||||
|
"transport": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "github",
|
||||||
|
"transport": "http",
|
||||||
|
"url": "https://api.githubcopilot.com/mcp/",
|
||||||
|
"auth": { "kind": "bearer", "token": "your-token" },
|
||||||
|
"allowed_tools": ["list_issues"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
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:**
|
**Recommended models for best results:**
|
||||||
|
|
||||||
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
|
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,8 @@
|
||||||
"pages": [
|
"pages": [
|
||||||
"integrations/github-actions",
|
"integrations/github-actions",
|
||||||
"integrations/ci-cd",
|
"integrations/ci-cd",
|
||||||
"integrations/coding-agents"
|
"integrations/coding-agents",
|
||||||
|
"integrations/mcp"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
131
docs/integrations/mcp.mdx
Normal file
131
docs/integrations/mcp.mdx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
---
|
||||||
|
title: "MCP Servers"
|
||||||
|
description: "Connect your own MCP servers and expose their tools to the agent"
|
||||||
|
---
|
||||||
|
|
||||||
|
Strix can connect to [Model Context Protocol (MCP)](https://modelcontextprotocol.io) servers you list and expose their tools to the agent during a run. Use this to let the agent read how your system is actually built instead of inferring it from the outside.
|
||||||
|
|
||||||
|
A few things it pays off for:
|
||||||
|
|
||||||
|
- **A database server.** The agent can read the schema and access policies and see tables left readable without them, rather than guessing from responses.
|
||||||
|
- **A hosting or infrastructure server.** Deployments, domains and environment variable names tell it what is really running, so it tests what exists instead of what it discovered by crawling.
|
||||||
|
- **An issue tracker.** Known and accepted risks stop the agent re-reporting findings you already triaged.
|
||||||
|
- **A logging server.** Reading logs lets it confirm an exploit attempt actually landed instead of inferring it from a status code.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Create the file `~/.strix/mcp-servers.json`. It holds a JSON list of the servers you want the agent to reach. Each entry is either a local `stdio` server that Strix launches as a subprocess, or a remote `http` server.
|
||||||
|
|
||||||
|
Create the directory if it does not exist, then write the file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.strix
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the servers you want into `~/.strix/mcp-servers.json`. The example below shows one of each transport: a local filesystem server over `stdio` and a remote GitHub server over `http` with a bearer token:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "local_fs",
|
||||||
|
"transport": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "github",
|
||||||
|
"transport": "http",
|
||||||
|
"url": "https://api.githubcopilot.com/mcp/",
|
||||||
|
"auth": { "kind": "bearer", "token": "your-token" },
|
||||||
|
"allowed_tools": ["list_issues"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Strix reads this file at the start of each run. There is no default file, so no MCP tools are loaded until you create it. Edit `command`, `args`, `url`, and `token` to match your own servers.
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
<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
|
||||||
|
tool name without colliding.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="transport" type="string">
|
||||||
|
`stdio` for a local subprocess server, or `http` for a remote server.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="command" type="string">
|
||||||
|
For `stdio` servers: the executable Strix launches (for example `npx`).
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="args" type="array">
|
||||||
|
For `stdio` servers: the arguments passed to `command`.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="url" type="string">
|
||||||
|
For `http` servers: the server endpoint URL.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="auth" type="object">
|
||||||
|
For `http` servers that need a bearer token:
|
||||||
|
`{ "kind": "bearer", "token": "your-token" }`.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="allowed_tools" type="array">
|
||||||
|
Restrict which tools the agent can call. Omit it to expose every tool the
|
||||||
|
server offers, or set it to a list of tool names to allow only those. Strix
|
||||||
|
does not decide for you which of a server's tools only read and which change
|
||||||
|
things, so run the server in its own read-only mode if it has one.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="notes" type="string">
|
||||||
|
Free-text notes for the agent about what this connection is and how you want
|
||||||
|
it used, for example "Staging analytics database, read-only, prefer aggregate
|
||||||
|
queries." When set, the notes are given to the agent at the start of the run
|
||||||
|
as a description of the connection.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
## Choosing connections per run
|
||||||
|
|
||||||
|
By default every connection in the file is used on each run. To narrow it for a
|
||||||
|
single run without editing the file, use either flag (both repeatable):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
strix --mcp-server github -t ... # use only the named connection(s)
|
||||||
|
strix --mcp-exclude staging-db -t ... # use everything except the named one(s)
|
||||||
|
```
|
||||||
|
|
||||||
|
`--mcp-server` keeps only the connections you name; `--mcp-exclude` drops the
|
||||||
|
ones you name. Connection names must be unique in the file; if two entries share
|
||||||
|
a name, the first is kept and the rest are ignored.
|
||||||
|
|
||||||
|
## Pointing at a different file
|
||||||
|
|
||||||
|
To read the config from another path instead of `~/.strix/mcp-servers.json`, either pass `--mcp-config <path>` on the command line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
strix --mcp-config ./mcp-servers.json -t ...
|
||||||
|
```
|
||||||
|
|
||||||
|
or set the `STRIX_MCP_CONFIG` environment variable to that path. The flag takes precedence when both are given.
|
||||||
|
|
||||||
|
## Startup confirmation
|
||||||
|
|
||||||
|
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.
|
||||||
|
- A server that fails to connect is skipped and logged, and the run continues without it.
|
||||||
|
- A single malformed entry is skipped without blocking the valid ones.
|
||||||
|
|
@ -241,6 +241,8 @@ ignore = [
|
||||||
"tests/test_stream_idle_timeout.py" = ["N802", "SLF001"]
|
"tests/test_stream_idle_timeout.py" = ["N802", "SLF001"]
|
||||||
"tests/test_unknown_tool_recovery.py" = ["N802"]
|
"tests/test_unknown_tool_recovery.py" = ["N802"]
|
||||||
"tests/test_report_pdf.py" = ["S105", "S106"]
|
"tests/test_report_pdf.py" = ["S105", "S106"]
|
||||||
|
# Fake MCP server matches the SDK's MCPServer signature; its args are unused.
|
||||||
|
"tests/test_mcp_client.py" = ["S105", "S106", "ARG002"]
|
||||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||||
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
||||||
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]
|
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,12 @@ from strix.tools.output_store import (
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from agents.mcp import MCPServer
|
||||||
from agents.memory import SQLiteSession
|
from agents.memory import SQLiteSession
|
||||||
from agents.result import RunResultBase
|
from agents.result import RunResultBase
|
||||||
|
|
||||||
from strix.runtime.status import StatusSink
|
from strix.runtime.status import StatusSink
|
||||||
|
from strix.tools.mcp import ConnectedMcpServer
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -64,6 +66,48 @@ logger = logging.getLogger(__name__)
|
||||||
StreamEventSink = Callable[[str, Any], None]
|
StreamEventSink = Callable[[str, Any], None]
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_startup_summary(connections: list[ConnectedMcpServer]) -> str:
|
||||||
|
"""One user-facing line summarizing the MCP servers that connected."""
|
||||||
|
server_count = len(connections)
|
||||||
|
tool_count = sum(c.tool_count for c in connections)
|
||||||
|
servers_word = "server" if server_count == 1 else "servers"
|
||||||
|
tools_word = "tool" if tool_count == 1 else "tools"
|
||||||
|
names = ", ".join(c.name for c in connections)
|
||||||
|
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.
|
||||||
|
|
||||||
|
Only connections with notes are listed, so the note describes the connection
|
||||||
|
once rather than being repeated onto every tool. Returns ``None`` when no
|
||||||
|
connection has notes.
|
||||||
|
"""
|
||||||
|
noted = [(c.name, c.notes) for c in connections if c.notes]
|
||||||
|
if not noted:
|
||||||
|
return None
|
||||||
|
lines = "\n".join(f"- `{name}.*` tools: {notes}" for name, notes in noted)
|
||||||
|
return (
|
||||||
|
"The user connected these MCP servers for this run and left notes on how "
|
||||||
|
f"to use each:\n{lines}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _merge_root_prompt_context(
|
def _merge_root_prompt_context(
|
||||||
scope_context: dict[str, Any],
|
scope_context: dict[str, Any],
|
||||||
extra_system_prompt_context: dict[str, Any] | None,
|
extra_system_prompt_context: dict[str, Any] | None,
|
||||||
|
|
@ -262,6 +306,7 @@ async def run_strix_scan(
|
||||||
configure_spill_writer(_spill_to_workspace)
|
configure_spill_writer(_spill_to_workspace)
|
||||||
|
|
||||||
sessions_to_close: list[SQLiteSession] = []
|
sessions_to_close: list[SQLiteSession] = []
|
||||||
|
mcp_servers: list[MCPServer] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
targets = scan_config.get("targets") or []
|
targets = scan_config.get("targets") or []
|
||||||
|
|
@ -310,6 +355,27 @@ async def run_strix_scan(
|
||||||
system_prompt_context=root_context,
|
system_prompt_context=root_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Connect any MCP servers the user listed in ~/.strix/mcp-servers.json and
|
||||||
|
# register their tools before the agent is built. Fail-open: a missing
|
||||||
|
# config, or a server that will not connect, must never break a run.
|
||||||
|
from strix.tools.mcp import connect_mcp_servers, load_user_mcp_configs
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_mcp_configs = load_user_mcp_configs()
|
||||||
|
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)
|
||||||
|
if notes_block:
|
||||||
|
root_task = f"{root_task}\n\n{notes_block}"
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to connect user MCP servers; continuing without them")
|
||||||
|
|
||||||
root_agent = build_strix_agent(
|
root_agent = build_strix_agent(
|
||||||
name="Root Agent",
|
name="Root Agent",
|
||||||
skills=skills,
|
skills=skills,
|
||||||
|
|
@ -489,6 +555,9 @@ async def run_strix_scan(
|
||||||
for s in sessions_to_close:
|
for s in sessions_to_close:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
s.close()
|
s.close()
|
||||||
|
for mcp_server in mcp_servers:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await mcp_server.cleanup() # type: ignore[no-untyped-call]
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await coordinator._maybe_snapshot()
|
await coordinator._maybe_snapshot()
|
||||||
if cleanup_on_exit:
|
if cleanup_on_exit:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -219,6 +220,30 @@ Examples:
|
||||||
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
|
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--mcp-config",
|
||||||
|
type=str,
|
||||||
|
metavar="PATH",
|
||||||
|
help="Path to an MCP servers JSON file to use instead of ~/.strix/mcp-servers.json.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--mcp-server",
|
||||||
|
dest="mcp_server",
|
||||||
|
action="append",
|
||||||
|
metavar="NAME",
|
||||||
|
help="Use only this MCP connection for the run, by its config name "
|
||||||
|
"(repeatable). Every other configured connection is skipped.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--mcp-exclude",
|
||||||
|
dest="mcp_exclude",
|
||||||
|
action="append",
|
||||||
|
metavar="NAME",
|
||||||
|
help="Skip this MCP connection for the run, by its config name (repeatable).",
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--max-budget",
|
"--max-budget",
|
||||||
"--max-budget-usd",
|
"--max-budget-usd",
|
||||||
|
|
@ -267,6 +292,20 @@ Examples:
|
||||||
if args.config:
|
if args.config:
|
||||||
apply_config_override(validate_config_file(args.config))
|
apply_config_override(validate_config_file(args.config))
|
||||||
|
|
||||||
|
if args.mcp_config:
|
||||||
|
mcp_config_path = Path(args.mcp_config).expanduser()
|
||||||
|
if not mcp_config_path.is_file():
|
||||||
|
parser.error(f"--mcp-config file not found: {args.mcp_config}")
|
||||||
|
# The MCP loader reads this env var as its config-path override, so
|
||||||
|
# setting it here makes the flag win over the default location.
|
||||||
|
os.environ["STRIX_MCP_CONFIG"] = str(mcp_config_path)
|
||||||
|
|
||||||
|
# The MCP loader reads these as its per-run include/exclude selection.
|
||||||
|
if args.mcp_server:
|
||||||
|
os.environ["STRIX_MCP_ONLY"] = ",".join(args.mcp_server)
|
||||||
|
if args.mcp_exclude:
|
||||||
|
os.environ["STRIX_MCP_EXCLUDE"] = ",".join(args.mcp_exclude)
|
||||||
|
|
||||||
if args.update:
|
if args.update:
|
||||||
sys.exit(0 if self_update() else 1)
|
sys.exit(0 if self_update() else 1)
|
||||||
|
|
||||||
|
|
|
||||||
35
strix/interface/tui/internal/render/mcp.go
Normal file
35
strix/interface/tui/internal/render/mcp.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -22,19 +22,20 @@ func statusIcon(status string) (string, lipgloss.Style) {
|
||||||
return "○ Unknown", Dim()
|
return "○ Unknown", Dim()
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderGenericTool ports registry._render_default_tool_widget.
|
// renderGenericTool ports registry._render_default_tool_widget. It shows the
|
||||||
func renderGenericTool(name string, args map[string]any, result any, status string) string {
|
// tool name, its arguments, and a status line only. The raw result is
|
||||||
|
// 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.
|
||||||
|
func renderGenericTool(name string, args map[string]any, status string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(Dim().Render("→ Using tool ") + Bold(Blue).Render(name) + "\n")
|
b.WriteString(Dim().Render("→ Using tool ") + Bold(Blue).Render(name) + "\n")
|
||||||
for _, k := range SortedKeys(args) {
|
for _, k := range SortedKeys(args) {
|
||||||
b.WriteString(" " + Dim().Render(k) + ": " + StringValue(args[k]) + "\n")
|
b.WriteString(" " + Dim().Render(k) + ": " + StringValue(args[k]) + "\n")
|
||||||
}
|
}
|
||||||
if (status == "completed" || status == "failed" || status == "error") && result != nil {
|
|
||||||
b.WriteString(lipgloss.NewStyle().Bold(true).Render("Result: ") + StringValue(result))
|
|
||||||
} else {
|
|
||||||
icon, style := statusIcon(status)
|
icon, style := statusIcon(status)
|
||||||
b.WriteString(style.Render(icon))
|
b.WriteString(style.Render(icon))
|
||||||
}
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,6 +52,18 @@ func Tool(data map[string]any) string {
|
||||||
}
|
}
|
||||||
result := data["result"]
|
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 {
|
switch name {
|
||||||
case "exec_command":
|
case "exec_command":
|
||||||
return renderExecCommand(args, result, status)
|
return renderExecCommand(args, result, status)
|
||||||
|
|
@ -91,7 +104,7 @@ func Tool(data map[string]any) string {
|
||||||
case "list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules":
|
case "list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules":
|
||||||
return renderProxyTool(name, args, result, status)
|
return renderProxyTool(name, args, result, status)
|
||||||
}
|
}
|
||||||
return renderGenericTool(name, args, result, status)
|
return renderGenericTool(name, args, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,7 @@ func TestToolDispatchCoversKnownTools(t *testing.T) {
|
||||||
{
|
{
|
||||||
"unknown tool falls back to generic",
|
"unknown tool falls back to generic",
|
||||||
tool("brand_new_tool", map[string]any{"alpha": "1"}, "done", "completed"),
|
tool("brand_new_tool", map[string]any{"alpha": "1"}, "done", "completed"),
|
||||||
[]string{"brand_new_tool", "alpha", "Result:", "done"},
|
[]string{"brand_new_tool", "alpha", "Done"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,6 +214,43 @@ func TestToolDispatchCoversKnownTools(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenericToolOmitsRawResult(t *testing.T) {
|
||||||
|
// 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")))
|
||||||
|
|
||||||
|
requireContains(t, out, "db_query", "query", "Done")
|
||||||
|
if strings.Contains(out, "Result:") || strings.Contains(out, strings.Repeat("x", 20)) {
|
||||||
|
t.Fatalf("generic result body must not be rendered:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestCollapseToolShellPreviewAndExpand(t *testing.T) {
|
||||||
lines := make([]string, 16)
|
lines := make([]string, 16)
|
||||||
for i := range lines {
|
for i := range lines {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from agents.tool import ToolOutputImage
|
from agents.tool import ToolOutputImage
|
||||||
|
|
@ -15,6 +16,10 @@ from agents.tool import ToolOutputImage
|
||||||
from strix.core.paths import runtime_state_dir
|
from strix.core.paths import runtime_state_dir
|
||||||
from strix.interface.tui.history import load_session_history
|
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:
|
class TuiLiveView:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
|
@ -26,6 +31,27 @@ class TuiLiveView:
|
||||||
self._user_instruction: str | None = None
|
self._user_instruction: str | None = None
|
||||||
self._user_instruction_at: str | None = None
|
self._user_instruction_at: str | None = None
|
||||||
self._user_instruction_shown = False
|
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:
|
def set_user_instruction(self, text: str | None, *, timestamp: str | None = None) -> None:
|
||||||
"""Open the transcript with what the user asked for.
|
"""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:
|
def hydrate_from_run_dir(self, run_dir: Path) -> None:
|
||||||
# Armed before the agents are added so the root agent's arrival puts the
|
# Armed before the agents are added so the root agent's arrival puts the
|
||||||
# user's opening message ahead of the replayed history.
|
# user's opening message ahead of the replayed history, and before the
|
||||||
self._load_user_instruction(run_dir)
|
# history is replayed so its MCP tool calls are attributed too.
|
||||||
|
self._load_run_record(run_dir)
|
||||||
state_dir = runtime_state_dir(run_dir)
|
state_dir = runtime_state_dir(run_dir)
|
||||||
agents_path = state_dir / "agents.json"
|
agents_path = state_dir / "agents.json"
|
||||||
if not agents_path.exists():
|
if not agents_path.exists():
|
||||||
|
|
@ -100,14 +127,17 @@ class TuiLiveView:
|
||||||
self.flush_user_instruction()
|
self.flush_user_instruction()
|
||||||
self._hydrate_sdk_session_history(run_dir, statuses.keys())
|
self._hydrate_sdk_session_history(run_dir, statuses.keys())
|
||||||
|
|
||||||
def _load_user_instruction(self, run_dir: Path) -> None:
|
def _load_run_record(self, run_dir: Path) -> None:
|
||||||
"""Take the user's opening message from the run record, if it has one."""
|
"""Take the user's opening message and the run's MCP servers off the record."""
|
||||||
try:
|
try:
|
||||||
record = json.loads((run_dir / "run.json").read_text(encoding="utf-8"))
|
record = json.loads((run_dir / "run.json").read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
return
|
return
|
||||||
if not isinstance(record, dict):
|
if not isinstance(record, dict):
|
||||||
return
|
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")
|
instruction = record.get("user_instruction")
|
||||||
if not isinstance(instruction, str):
|
if not isinstance(instruction, str):
|
||||||
return
|
return
|
||||||
|
|
@ -318,6 +348,7 @@ class TuiLiveView:
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"call_id": call_id,
|
"call_id": call_id,
|
||||||
|
**self._mcp_tool_fields(call["tool_name"]),
|
||||||
}
|
}
|
||||||
if existing is None:
|
if existing is None:
|
||||||
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
|
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
|
||||||
|
|
@ -349,6 +380,7 @@ class TuiLiveView:
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"call_id": call_id,
|
"call_id": call_id,
|
||||||
|
**self._mcp_tool_fields(output["tool_name"]),
|
||||||
},
|
},
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -207,9 +207,23 @@ class GoTuiRuntime:
|
||||||
self.controller.notify_changed()
|
self.controller.notify_changed()
|
||||||
|
|
||||||
def capture_event(self, agent_id: str, event: Any) -> None:
|
def capture_event(self, agent_id: str, event: Any) -> None:
|
||||||
|
self._refresh_mcp_connections()
|
||||||
self.live_view.ingest_sdk_event(agent_id, event)
|
self.live_view.ingest_sdk_event(agent_id, event)
|
||||||
self.controller.notify_changed()
|
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:
|
async def _sync_agent_state(self) -> bool:
|
||||||
parent_of, statuses, names, errors = await self.coordinator.graph_snapshot()
|
parent_of, statuses, names, errors = await self.coordinator.graph_snapshot()
|
||||||
changed = False
|
changed = False
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class RendererErrorBoundary extends Component<
|
||||||
}
|
}
|
||||||
|
|
||||||
function SafeToolRenderer(props: ToolRendererProps) {
|
function SafeToolRenderer(props: ToolRendererProps) {
|
||||||
const Renderer = getToolRenderer(props.toolName);
|
const Renderer = getToolRenderer(props.toolName, props.mcpConnection);
|
||||||
return (
|
return (
|
||||||
<RendererErrorBoundary toolName={props.toolName}>
|
<RendererErrorBoundary toolName={props.toolName}>
|
||||||
<Renderer {...props} />
|
<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> {
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
const c = coerce(value);
|
const c = coerce(value);
|
||||||
if (c && typeof c === "object" && !Array.isArray(c)) return c as Record<string, unknown>;
|
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 isTool = event.type === "tool";
|
||||||
const toolName = isTool ? String(event.data?.tool_name ?? "tool") : "";
|
const toolName = isTool ? String(event.data?.tool_name ?? "tool") : "";
|
||||||
const role = !isTool ? String(event.data?.role ?? "assistant") : "";
|
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 Icon;
|
||||||
let iconColor: string;
|
let iconColor: string;
|
||||||
if (isTool) {
|
if (isTool) {
|
||||||
const meta = getToolIcon(toolName);
|
const meta = getToolIcon(toolName, mcpConnection);
|
||||||
Icon = meta.icon;
|
Icon = meta.icon;
|
||||||
iconColor = meta.color;
|
iconColor = meta.color;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -279,6 +286,8 @@ export function AgentTranscript({
|
||||||
{isTool ? (
|
{isTool ? (
|
||||||
<SafeToolRenderer
|
<SafeToolRenderer
|
||||||
toolName={toolName}
|
toolName={toolName}
|
||||||
|
mcpConnection={mcpConnection}
|
||||||
|
mcpTool={mcpTool}
|
||||||
args={asRecord(event.data?.args)}
|
args={asRecord(event.data?.args)}
|
||||||
result={coerce(event.data?.result) ?? null}
|
result={coerce(event.data?.result) ?? null}
|
||||||
status={
|
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 {
|
import {
|
||||||
Terminal, Globe, FileText, ShieldAlert, ArrowUpRight, Brain,
|
Terminal, Globe, FileText, ShieldAlert, ArrowUpRight, Brain,
|
||||||
Bot, MessageCircle, Flag, Eye, Search, Code, StickyNote,
|
Bot, MessageCircle, Flag, Eye, Search, Code, StickyNote,
|
||||||
ListTodo, Crosshair, Wrench, Ban, Image, ClipboardList,
|
ListTodo, Crosshair, Wrench, Ban, Image, ClipboardList, Plug,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import TerminalRenderer from "./TerminalRenderer";
|
import TerminalRenderer from "./TerminalRenderer";
|
||||||
|
|
@ -27,6 +27,7 @@ import LoadSkillRenderer from "./LoadSkillRenderer";
|
||||||
import RespondRenderer from "./RespondRenderer";
|
import RespondRenderer from "./RespondRenderer";
|
||||||
import CoverageRenderer from "./CoverageRenderer";
|
import CoverageRenderer from "./CoverageRenderer";
|
||||||
import ThreatModelRenderer from "./ThreatModelRenderer";
|
import ThreatModelRenderer from "./ThreatModelRenderer";
|
||||||
|
import McpRenderer from "./McpRenderer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool-renderer mapping — data-driven, keyed by the engine's tool *family*.
|
* Tool-renderer mapping — data-driven, keyed by the engine's tool *family*.
|
||||||
|
|
@ -57,7 +58,8 @@ export type ToolCategory =
|
||||||
| "todos"
|
| "todos"
|
||||||
| "coverage"
|
| "coverage"
|
||||||
| "threatModel"
|
| "threatModel"
|
||||||
| "telemetry";
|
| "telemetry"
|
||||||
|
| "mcp";
|
||||||
|
|
||||||
export interface ToolIconMeta {
|
export interface ToolIconMeta {
|
||||||
icon: ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
|
@ -90,6 +92,9 @@ const CATEGORY_META: Record<ToolCategory, CategoryMeta> = {
|
||||||
coverage: { renderer: CoverageRenderer, icon: ClipboardList, color: "text-cyan-400", match: /coverage/ },
|
coverage: { renderer: CoverageRenderer, icon: ClipboardList, color: "text-cyan-400", match: /coverage/ },
|
||||||
threatModel: { renderer: ThreatModelRenderer, icon: Crosshair, color: "text-blue-400", match: /threat_model/ },
|
threatModel: { renderer: ThreatModelRenderer, icon: Crosshair, color: "text-blue-400", match: /threat_model/ },
|
||||||
telemetry: { renderer: FallbackRenderer, icon: Wrench, color: "text-[#555]" },
|
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" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -123,6 +128,7 @@ const CATEGORY_TOOLS: Record<ToolCategory, readonly string[]> = {
|
||||||
// Per-target threat model, shared across the agent tree
|
// Per-target threat model, shared across the agent tree
|
||||||
threatModel: ["get_threat_model", "save_threat_model", "amend_threat_model"],
|
threatModel: ["get_threat_model", "save_threat_model", "amend_threat_model"],
|
||||||
telemetry: ["sandbox_error_details", "llm_error_details"],
|
telemetry: ["sandbox_error_details", "llm_error_details"],
|
||||||
|
mcp: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Reverse index (tool name → family), built once from CATEGORY_TOOLS. */
|
/** Reverse index (tool name → family), built once from CATEGORY_TOOLS. */
|
||||||
|
|
@ -173,14 +179,26 @@ function resolveCategory(toolName: string): ToolCategory | null {
|
||||||
return 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];
|
const override = RENDERER_OVERRIDES[toolName];
|
||||||
if (override) return override;
|
if (override) return override;
|
||||||
const category = resolveCategory(toolName);
|
const category = resolveCategory(toolName);
|
||||||
return category ? CATEGORY_META[category].renderer : FallbackRenderer;
|
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];
|
const override = ICON_OVERRIDES[toolName];
|
||||||
if (override) return override;
|
if (override) return override;
|
||||||
const category = resolveCategory(toolName);
|
const category = resolveCategory(toolName);
|
||||||
|
|
|
||||||
|
|
@ -99,4 +99,12 @@ export interface ToolRendererProps {
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
result: unknown;
|
result: unknown;
|
||||||
status: "running" | "completed" | "failed" | "error";
|
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
10
strix/interface/viewer/static/assets/index-D0453ODW.css
Normal file
10
strix/interface/viewer/static/assets/index-D0453ODW.css
Normal file
File diff suppressed because one or more lines are too long
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="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<title>Strix Results</title>
|
<title>Strix Results</title>
|
||||||
<script type="module" crossorigin src="./assets/index-Bi_X6kI3.js"></script>
|
<script type="module" crossorigin src="./assets/index-C9c1WbvP.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-g-_6CcwH.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-D0453ODW.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -404,6 +404,18 @@ class ReportState:
|
||||||
posthog.end(self, exit_reason="finished_by_tool")
|
posthog.end(self, exit_reason="finished_by_tool")
|
||||||
scarf.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:
|
def set_scan_config(self, config: dict[str, Any]) -> None:
|
||||||
self.scan_config = config
|
self.scan_config = config
|
||||||
self.run_record["status"] = "running"
|
self.run_record["status"] = "running"
|
||||||
|
|
|
||||||
25
strix/tools/mcp/__init__.py
Normal file
25
strix/tools/mcp/__init__.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""Generic MCP client: connect MCP servers and expose their tools."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from strix.tools.mcp.client import ConnectedMcpServer, connect_mcp_servers
|
||||||
|
from strix.tools.mcp.config import (
|
||||||
|
BearerAuth,
|
||||||
|
McpAuth,
|
||||||
|
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__ = [
|
||||||
|
"BearerAuth",
|
||||||
|
"ConnectedMcpServer",
|
||||||
|
"McpAuth",
|
||||||
|
"McpConnectionConfig",
|
||||||
|
"McpToolOrigin",
|
||||||
|
"connect_mcp_servers",
|
||||||
|
"load_user_mcp_configs",
|
||||||
|
"namespaced_tool_name",
|
||||||
|
"resolve_mcp_tool",
|
||||||
|
]
|
||||||
349
strix/tools/mcp/client.py
Normal file
349
strix/tools/mcp/client.py
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
"""Connect to MCP servers and expose their tools to the agent.
|
||||||
|
|
||||||
|
Given one :class:`McpConnectionConfig` per server, :func:`connect_mcp_servers`
|
||||||
|
lists each server's tools, keeps the ones on the connection's allowlist (or all
|
||||||
|
of them when none is set), prefixes each with the connection name so servers do
|
||||||
|
not collide, and registers them through the agent factory. The factory applies
|
||||||
|
output bounding, per-call timeouts, and structured errors to every registered
|
||||||
|
tool, so this layer does not reimplement them.
|
||||||
|
|
||||||
|
A server that cannot connect, or a tool set that cannot be registered, is logged
|
||||||
|
and skipped, so one bad connection never fails the run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any, NamedTuple, cast
|
||||||
|
|
||||||
|
from agents.exceptions import ModelBehaviorError
|
||||||
|
from agents.mcp import (
|
||||||
|
MCPServer,
|
||||||
|
MCPServerStdio,
|
||||||
|
MCPServerStdioParams,
|
||||||
|
MCPServerStreamableHttp,
|
||||||
|
MCPServerStreamableHttpParams,
|
||||||
|
MCPUtil,
|
||||||
|
create_static_tool_filter,
|
||||||
|
)
|
||||||
|
|
||||||
|
from strix.agents.factory import register_agent_tools
|
||||||
|
from strix.tools.mcp.naming import namespaced_tool_name
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from agents.tool import FunctionTool, Tool
|
||||||
|
from mcp.types import Tool as MCPTool
|
||||||
|
|
||||||
|
from strix.tools.mcp.config import McpConnectionConfig
|
||||||
|
|
||||||
|
# Runs on each tool's structured result before it reaches the agent. Called
|
||||||
|
# ``result_transform(namespaced_tool_name, structured_result)`` and its return
|
||||||
|
# value becomes the tool's output. ``structured_result`` is the parsed
|
||||||
|
# ``CallToolResult`` as a dict (not a serialized string), so the transform can
|
||||||
|
# project or drop individual fields.
|
||||||
|
ResultTransform = Callable[[str, Any], Any]
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectedMcpServer(NamedTuple):
|
||||||
|
"""One successfully connected MCP server and how many tools it registered.
|
||||||
|
|
||||||
|
``server`` is kept so the caller can clean it up when the run ends;
|
||||||
|
``name`` and ``tool_count`` let the caller show the user a startup summary;
|
||||||
|
``notes`` carries the connection's optional free-text description so the
|
||||||
|
caller can surface it to the agent as context about the connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
server: MCPServer
|
||||||
|
name: str
|
||||||
|
tool_count: int
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(config: McpConnectionConfig) -> dict[str, str]:
|
||||||
|
"""Build the per-server request headers from the connection's auth."""
|
||||||
|
auth = config.auth
|
||||||
|
if auth is None:
|
||||||
|
return {}
|
||||||
|
return {"Authorization": f"Bearer {auth.token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_server(config: McpConnectionConfig) -> MCPServer:
|
||||||
|
"""Construct (but do not connect) the SDK server for one connection.
|
||||||
|
|
||||||
|
When ``allowed_tools`` is a list the static filter means the server will not
|
||||||
|
even list tools outside it; :func:`_register_server_tools` re-applies the
|
||||||
|
same allowlist as the authoritative gate on what gets registered. When it is
|
||||||
|
``None`` no filter is applied and every listed tool is registered.
|
||||||
|
"""
|
||||||
|
tool_filter = (
|
||||||
|
create_static_tool_filter(allowed_tool_names=config.allowed_tools)
|
||||||
|
if config.allowed_tools is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.transport == "stdio":
|
||||||
|
stdio_params: MCPServerStdioParams = {
|
||||||
|
"command": cast("str", config.command),
|
||||||
|
"args": config.args,
|
||||||
|
"env": config.env,
|
||||||
|
}
|
||||||
|
return MCPServerStdio(
|
||||||
|
params=stdio_params,
|
||||||
|
name=config.name,
|
||||||
|
tool_filter=tool_filter,
|
||||||
|
cache_tools_list=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
http_params: MCPServerStreamableHttpParams = {
|
||||||
|
"url": cast("str", config.url),
|
||||||
|
"headers": _auth_headers(config),
|
||||||
|
}
|
||||||
|
return MCPServerStreamableHttp(
|
||||||
|
params=http_params,
|
||||||
|
name=config.name,
|
||||||
|
tool_filter=tool_filter,
|
||||||
|
cache_tools_list=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tool(
|
||||||
|
config: McpConnectionConfig,
|
||||||
|
server: MCPServer,
|
||||||
|
mcp_tool: MCPTool,
|
||||||
|
result_transform: ResultTransform | None,
|
||||||
|
) -> FunctionTool:
|
||||||
|
"""Build one namespaced FunctionTool from a listed MCP tool.
|
||||||
|
|
||||||
|
The SDK builds the tool (so name override, input schema, approval policy,
|
||||||
|
error-as-result handling, and tool-origin metadata are unchanged). With a
|
||||||
|
``result_transform`` we route the underlying MCP call through
|
||||||
|
:func:`_install_result_transform` so the transform sees the structured result
|
||||||
|
and decides the tool's output. Without one (the stock path), we still route
|
||||||
|
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)
|
||||||
|
tool = MCPUtil.to_function_tool(
|
||||||
|
mcp_tool,
|
||||||
|
server,
|
||||||
|
convert_schemas_to_strict=False,
|
||||||
|
tool_name_override=namespaced_name,
|
||||||
|
)
|
||||||
|
if result_transform is not None:
|
||||||
|
_install_result_transform(tool, server, mcp_tool.name, namespaced_name, result_transform)
|
||||||
|
else:
|
||||||
|
_install_error_status_capture(tool, server, mcp_tool.name, namespaced_name)
|
||||||
|
return tool
|
||||||
|
|
||||||
|
|
||||||
|
def _install_result_transform(
|
||||||
|
tool: FunctionTool,
|
||||||
|
server: MCPServer,
|
||||||
|
base_tool_name: str,
|
||||||
|
namespaced_name: str,
|
||||||
|
result_transform: ResultTransform,
|
||||||
|
) -> None:
|
||||||
|
"""Route a tool's MCP call through ``result_transform``, innermost.
|
||||||
|
|
||||||
|
``MCPUtil.to_function_tool`` serializes the result inside its own invoke, so
|
||||||
|
the structured result cannot be intercepted through it. Instead we call
|
||||||
|
``server.call_tool`` ourselves, hand the parsed :class:`CallToolResult` to the
|
||||||
|
transform, and return the transform's output as the tool result.
|
||||||
|
|
||||||
|
This runs INSIDE the tool's invoke. The agent factory wraps a registered
|
||||||
|
tool's ``on_invoke_tool`` with output bounding, disk spill, and tracing at
|
||||||
|
agent-build time, which is OUTSIDE this invoke, so the transform is genuinely
|
||||||
|
the innermost step: nothing sees the raw result before the transform does.
|
||||||
|
|
||||||
|
``to_function_tool`` wraps the real invoke in the SDK's failure-handling
|
||||||
|
invoker, which stores the inner coroutine on ``_invoke_tool_impl`` and calls
|
||||||
|
it inside its try/except. Swapping that inner impl keeps the SDK's
|
||||||
|
error-as-result handling and all tool metadata while inserting the transform.
|
||||||
|
If the SDK ever renames that attribute we fail loudly rather than silently
|
||||||
|
skip the transform.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _invoke(_ctx: Any, input_json: str) -> Any:
|
||||||
|
parsed: Any = json.loads(input_json) if input_json else {}
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ModelBehaviorError(
|
||||||
|
f"Invalid JSON input for tool {namespaced_name}: expected a JSON object"
|
||||||
|
)
|
||||||
|
args = cast("dict[str, Any]", parsed)
|
||||||
|
result = await server.call_tool(base_tool_name, args)
|
||||||
|
structured_result = result.model_dump(mode="json")
|
||||||
|
return result_transform(namespaced_name, structured_result)
|
||||||
|
|
||||||
|
_replace_tool_invoke(tool, _invoke)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_tool_invoke(tool: FunctionTool, invoke: Callable[[Any, str], Any]) -> None:
|
||||||
|
"""Swap a FunctionTool's inner invoke, failing loudly if the SDK shape changed.
|
||||||
|
|
||||||
|
``to_function_tool`` wraps the real invoke in the SDK's failure-handling
|
||||||
|
invoker, which stores the inner coroutine on ``_invoke_tool_impl`` and calls
|
||||||
|
it inside its own try/except. Swapping that inner impl keeps the SDK's
|
||||||
|
error-as-result handling and every piece of tool metadata intact. It is a
|
||||||
|
plain object with the coroutine as an attribute, not a function, so we treat
|
||||||
|
it as untyped to swap it. If the SDK ever renames that attribute we raise
|
||||||
|
rather than silently leave the swap un-applied.
|
||||||
|
"""
|
||||||
|
invoker = cast("Any", tool.on_invoke_tool)
|
||||||
|
if not hasattr(invoker, "_invoke_tool_impl"):
|
||||||
|
raise RuntimeError(
|
||||||
|
"agents SDK FunctionTool invoker shape changed: cannot swap the tool "
|
||||||
|
"invoke without risking it being silently skipped."
|
||||||
|
)
|
||||||
|
invoker._invoke_tool_impl = invoke
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_result_to_tool_output(server: MCPServer, result: Any) -> Any:
|
||||||
|
"""Serialize a ``CallToolResult`` to a tool output, mirroring the agents SDK.
|
||||||
|
|
||||||
|
This reproduces the serialization in ``agents.mcp.util.MCPUtil.invoke_mcp_tool``
|
||||||
|
(structured-content JSON when the server asks for it, otherwise text/image
|
||||||
|
content blocks, unwrapping a single block). Because the stock path now routes
|
||||||
|
its own call, this is what makes the agent see byte-identical content to what
|
||||||
|
the SDK would have produced on its own.
|
||||||
|
"""
|
||||||
|
if getattr(server, "use_structured_content", False) and result.structuredContent:
|
||||||
|
return json.dumps(result.structuredContent)
|
||||||
|
|
||||||
|
outputs: list[dict[str, Any]] = []
|
||||||
|
for item in result.content:
|
||||||
|
if item.type == "text":
|
||||||
|
outputs.append({"type": "text", "text": item.text})
|
||||||
|
elif item.type == "image":
|
||||||
|
outputs.append(
|
||||||
|
{"type": "image", "image_url": f"data:{item.mimeType};base64,{item.data}"}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
outputs.append({"type": "text", "text": str(item.model_dump(mode="json"))})
|
||||||
|
if len(outputs) == 1:
|
||||||
|
return outputs[0]
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
|
def _install_error_status_capture(
|
||||||
|
tool: FunctionTool,
|
||||||
|
server: MCPServer,
|
||||||
|
base_tool_name: str,
|
||||||
|
namespaced_name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Make an errored MCP result read as failed in the TUI, agent content unchanged.
|
||||||
|
|
||||||
|
The stock SDK invoke returns only the text/image tool output and drops the
|
||||||
|
``CallToolResult.isError`` flag, so the TUI cannot tell an errored MCP call
|
||||||
|
(which it renders as a green "done") from a successful one. We route the call
|
||||||
|
the same way :func:`_install_result_transform` does, read ``isError`` off the
|
||||||
|
full result, and on an error tag the returned output dict with
|
||||||
|
``success: False``.
|
||||||
|
|
||||||
|
That tag reaches the human-facing status but not the agent. The SDK stores the
|
||||||
|
raw return value on the run item's ``output`` (which the TUI reads to derive a
|
||||||
|
tool's status), but hands the agent the value re-projected through its
|
||||||
|
ToolOutput schema, which keeps only the known ``type``/``text`` fields and
|
||||||
|
drops the extra ``success`` key. So the status flips to failed while the agent
|
||||||
|
still receives exactly the same error content it does today. Non-error calls
|
||||||
|
return the stock output unchanged and keep rendering as done.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _invoke(_ctx: Any, input_json: str) -> Any:
|
||||||
|
parsed: Any = json.loads(input_json) if input_json else {}
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ModelBehaviorError(
|
||||||
|
f"Invalid JSON input for tool {namespaced_name}: expected a JSON object"
|
||||||
|
)
|
||||||
|
args = cast("dict[str, Any]", parsed)
|
||||||
|
result = await server.call_tool(base_tool_name, args)
|
||||||
|
tool_output = _mcp_result_to_tool_output(server, result)
|
||||||
|
if getattr(result, "isError", False) and isinstance(tool_output, dict):
|
||||||
|
return {**tool_output, "success": False}
|
||||||
|
return tool_output
|
||||||
|
|
||||||
|
_replace_tool_invoke(tool, _invoke)
|
||||||
|
|
||||||
|
|
||||||
|
async def _register_server_tools(
|
||||||
|
config: McpConnectionConfig,
|
||||||
|
server: MCPServer,
|
||||||
|
result_transform: ResultTransform | None = None,
|
||||||
|
) -> list[Tool]:
|
||||||
|
"""List a connected server's tools, prefix + filter them, and register them.
|
||||||
|
|
||||||
|
``allowed_tools`` of ``None`` registers every listed tool; a list restricts
|
||||||
|
to exactly those names.
|
||||||
|
"""
|
||||||
|
allowed = config.allowed_tools
|
||||||
|
mcp_tools = await server.list_tools()
|
||||||
|
|
||||||
|
tools: list[Tool] = [
|
||||||
|
_build_tool(config, server, mcp_tool, result_transform)
|
||||||
|
for mcp_tool in mcp_tools
|
||||||
|
if allowed is None or mcp_tool.name in allowed
|
||||||
|
]
|
||||||
|
|
||||||
|
register_agent_tools(*tools)
|
||||||
|
return tools
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_mcp_servers(
|
||||||
|
configs: list[McpConnectionConfig],
|
||||||
|
result_transform: ResultTransform | None = None,
|
||||||
|
) -> list[ConnectedMcpServer]:
|
||||||
|
"""Connect to each MCP server and register its tools.
|
||||||
|
|
||||||
|
When ``result_transform`` is given, every registered tool routes its result
|
||||||
|
through it before the result reaches the agent (see
|
||||||
|
:func:`_install_result_transform`). When it is ``None`` the tools behave
|
||||||
|
exactly as the SDK builds them.
|
||||||
|
|
||||||
|
Returns one :class:`ConnectedMcpServer` per server that connected, carrying
|
||||||
|
the SDK server (so the caller can clean it up when the run ends) plus the
|
||||||
|
server name and how many tools it registered (so the caller can show the
|
||||||
|
user a startup summary). Connections that fail are skipped rather than
|
||||||
|
raised.
|
||||||
|
"""
|
||||||
|
connected: list[ConnectedMcpServer] = []
|
||||||
|
for config in configs:
|
||||||
|
server: MCPServer | None = None
|
||||||
|
try:
|
||||||
|
server = _build_server(config)
|
||||||
|
await server.connect() # type: ignore[no-untyped-call]
|
||||||
|
tools = await _register_server_tools(config, server, result_transform)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Skipping MCP connection %r", config.name)
|
||||||
|
if server is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await server.cleanup() # type: ignore[no-untyped-call]
|
||||||
|
continue
|
||||||
|
except BaseException:
|
||||||
|
# A cancellation (or other non-Exception failure) mid-connect must not
|
||||||
|
# orphan MCP subprocesses or HTTP sessions. Clean up the server being
|
||||||
|
# connected and every server already connected, then re-raise so the
|
||||||
|
# caller still stops. The runner only receives the list on a clean
|
||||||
|
# return, so on an abnormal exit this function owns the cleanup.
|
||||||
|
if server is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await server.cleanup() # type: ignore[no-untyped-call]
|
||||||
|
for established in connected:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await established.server.cleanup() # type: ignore[no-untyped-call]
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("Connected MCP server %r (%d tools)", config.name, len(tools))
|
||||||
|
connected.append(
|
||||||
|
ConnectedMcpServer(
|
||||||
|
server=server, name=config.name, tool_count=len(tools), notes=config.notes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return connected
|
||||||
74
strix/tools/mcp/config.py
Normal file
74
strix/tools/mcp/config.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""The connection-config contract for the MCP client.
|
||||||
|
|
||||||
|
Describes one MCP server the client can connect to: its transport, endpoint or
|
||||||
|
launch command, optional auth, and an optional tool allowlist. Field names are
|
||||||
|
stable; callers build against them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class BearerAuth(BaseModel):
|
||||||
|
"""Header-token auth, sent as ``Authorization: Bearer <token>``."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
kind: Literal["bearer"] = "bearer"
|
||||||
|
token: str = Field(min_length=1, repr=False)
|
||||||
|
|
||||||
|
|
||||||
|
McpAuth = Annotated[BearerAuth, Field(discriminator="kind")]
|
||||||
|
|
||||||
|
|
||||||
|
class McpConnectionConfig(BaseModel):
|
||||||
|
"""One MCP server the client can connect to.
|
||||||
|
|
||||||
|
Two transports are supported: streamable ``http`` (a remote endpoint) and
|
||||||
|
``stdio`` (a local server launched as a subprocess).
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
"""Namespaced tool prefix, unique per run (e.g. ``github``)."""
|
||||||
|
|
||||||
|
transport: Literal["http", "stdio"] = "http"
|
||||||
|
"""``http`` for a streamable HTTP endpoint, ``stdio`` for a local subprocess."""
|
||||||
|
|
||||||
|
url: str | None = Field(default=None, min_length=1)
|
||||||
|
"""The MCP server endpoint. Required for ``http``."""
|
||||||
|
|
||||||
|
auth: McpAuth | None = None
|
||||||
|
"""Bearer token for the server. Optional; a local stdio server usually
|
||||||
|
needs none."""
|
||||||
|
|
||||||
|
command: str | None = Field(default=None, min_length=1)
|
||||||
|
"""The executable to launch for ``stdio``. Required for ``stdio``."""
|
||||||
|
|
||||||
|
args: list[str] = Field(default_factory=list)
|
||||||
|
"""Arguments passed to ``command`` (stdio only)."""
|
||||||
|
|
||||||
|
env: dict[str, str] = Field(default_factory=dict)
|
||||||
|
"""Extra environment variables for the stdio subprocess."""
|
||||||
|
|
||||||
|
allowed_tools: list[str] | None = None
|
||||||
|
"""Tool allowlist, applied after the server lists its tools. ``None`` (the
|
||||||
|
default) exposes every tool the server lists; a list restricts to it."""
|
||||||
|
|
||||||
|
notes: str | None = None
|
||||||
|
"""Free-text notes for the agent describing what this connection is and how
|
||||||
|
to use it. When set, the runner collects the notes of every connection into
|
||||||
|
a single block on the root task, so a note describes its connection once
|
||||||
|
rather than being repeated onto each of its tools."""
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_transport_fields(self) -> McpConnectionConfig:
|
||||||
|
if self.transport == "http" and not self.url:
|
||||||
|
raise ValueError("an http MCP connection requires 'url'")
|
||||||
|
if self.transport == "stdio" and not self.command:
|
||||||
|
raise ValueError("a stdio MCP connection requires 'command'")
|
||||||
|
return self
|
||||||
132
strix/tools/mcp/loader.py
Normal file
132
strix/tools/mcp/loader.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"""Read the open-source user's MCP servers from ``~/.strix/mcp-servers.json``.
|
||||||
|
|
||||||
|
An open-source user lists the MCP servers they want the agent to reach in a
|
||||||
|
small JSON file. Strix reads it at the start of a run, connects to each server,
|
||||||
|
and registers its tools. The file is optional; without it the run simply gets
|
||||||
|
no MCP tools.
|
||||||
|
|
||||||
|
Parsing is fail-open. A single malformed entry is logged and skipped rather than
|
||||||
|
raising, so one bad row never blocks the servers that are valid, and a missing
|
||||||
|
or unreadable file yields an empty list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from strix.tools.mcp.config import McpConnectionConfig
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_PATH: Path = Path.home() / ".strix" / "mcp-servers.json"
|
||||||
|
_PATH_ENV_VAR = "STRIX_MCP_CONFIG"
|
||||||
|
# Per-run selection, set by the --mcp-server / --mcp-exclude CLI flags. Each is a
|
||||||
|
# comma-separated list of connection names.
|
||||||
|
_ONLY_ENV_VAR = "STRIX_MCP_ONLY"
|
||||||
|
_EXCLUDE_ENV_VAR = "STRIX_MCP_EXCLUDE"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(path: Path | None) -> Path:
|
||||||
|
if path is not None:
|
||||||
|
return path
|
||||||
|
override = os.environ.get(_PATH_ENV_VAR)
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
return _DEFAULT_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_by_name(configs: list[McpConnectionConfig]) -> list[McpConnectionConfig]:
|
||||||
|
"""Keep the first connection of each name, dropping later duplicates.
|
||||||
|
|
||||||
|
Names namespace a server's tools (``<name>.<tool>``), so two connections
|
||||||
|
sharing a name would collide and the second's tools would be silently
|
||||||
|
rejected at registration. Drop the duplicate here, with a warning, instead.
|
||||||
|
"""
|
||||||
|
seen: set[str] = set()
|
||||||
|
unique: list[McpConnectionConfig] = []
|
||||||
|
for config in configs:
|
||||||
|
if config.name in seen:
|
||||||
|
logger.warning(
|
||||||
|
"Ignoring MCP server %r: another connection already uses that name "
|
||||||
|
"(names must be unique because they namespace the server's tools).",
|
||||||
|
config.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
seen.add(config.name)
|
||||||
|
unique.append(config)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_names(env_var: str) -> set[str]:
|
||||||
|
return {name.strip() for name in os.environ.get(env_var, "").split(",") if name.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_run_selection(configs: list[McpConnectionConfig]) -> list[McpConnectionConfig]:
|
||||||
|
"""Restrict this run's connections to an optional include/exclude selection.
|
||||||
|
|
||||||
|
``STRIX_MCP_ONLY`` (if set) keeps only the named connections; then
|
||||||
|
``STRIX_MCP_EXCLUDE`` drops any named connection. With neither set, every
|
||||||
|
connection is kept.
|
||||||
|
"""
|
||||||
|
only = _parse_names(_ONLY_ENV_VAR)
|
||||||
|
exclude = _parse_names(_EXCLUDE_ENV_VAR)
|
||||||
|
if not only and not exclude:
|
||||||
|
return configs
|
||||||
|
|
||||||
|
available = {config.name for config in configs}
|
||||||
|
for name in sorted((only | exclude) - available):
|
||||||
|
logger.warning(
|
||||||
|
"MCP connection selection named %r, which is not configured; ignoring it", name
|
||||||
|
)
|
||||||
|
|
||||||
|
selected: list[McpConnectionConfig] = []
|
||||||
|
for config in configs:
|
||||||
|
if only and config.name not in only:
|
||||||
|
continue
|
||||||
|
if config.name in exclude:
|
||||||
|
continue
|
||||||
|
selected.append(config)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def load_user_mcp_configs(path: Path | None = None) -> list[McpConnectionConfig]:
|
||||||
|
"""Load MCP connection configs from the user's JSON file.
|
||||||
|
|
||||||
|
The path is ``path`` if given, else ``$STRIX_MCP_CONFIG``, else
|
||||||
|
``~/.strix/mcp-servers.json``. The file is a JSON list of server entries.
|
||||||
|
A missing file returns ``[]``; an unreadable or non-list file is logged and
|
||||||
|
returns ``[]``; individual entries that fail validation are logged and
|
||||||
|
skipped. Connections sharing a name are de-duplicated (first wins), and an
|
||||||
|
optional per-run include/exclude selection is applied last.
|
||||||
|
"""
|
||||||
|
source = _resolve_path(path)
|
||||||
|
if not source.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = json.loads(source.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
logger.exception("Could not read MCP config at %s; ignoring it", source)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
logger.warning("MCP config at %s is not a JSON list; ignoring it", source)
|
||||||
|
return []
|
||||||
|
|
||||||
|
entries = cast("list[object]", raw)
|
||||||
|
configs: list[McpConnectionConfig] = []
|
||||||
|
for index, entry in enumerate(entries):
|
||||||
|
try:
|
||||||
|
configs.append(McpConnectionConfig.model_validate(entry))
|
||||||
|
except ValidationError as exc:
|
||||||
|
logger.warning("Skipping invalid MCP server entry #%d in %s: %s", index, source, exc)
|
||||||
|
|
||||||
|
return _apply_run_selection(_dedupe_by_name(configs))
|
||||||
80
strix/tools/mcp/naming.py
Normal file
80
strix/tools/mcp/naming.py
Normal 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
|
||||||
88
tests/test_cli_mcp_config.py
Normal file
88
tests/test_cli_mcp_config.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""Tests for the --mcp-config CLI flag."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
cli_main: Any = importlib.import_module("strix.interface.main")
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_main,
|
||||||
|
"load_settings",
|
||||||
|
lambda: SimpleNamespace(runtime=SimpleNamespace(max_local_copy_mb=1024)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_config_flag_sets_loader_override(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config = tmp_path / "servers.json"
|
||||||
|
config.write_text("[]", encoding="utf-8")
|
||||||
|
_stub_settings(monkeypatch)
|
||||||
|
# delenv records "originally absent" so monkeypatch removes whatever the
|
||||||
|
# parser sets, keeping the override from leaking into other tests.
|
||||||
|
monkeypatch.delenv("STRIX_MCP_CONFIG", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys, "argv", ["strix", "-t", "https://test.com/", "-n", "--mcp-config", str(config)]
|
||||||
|
)
|
||||||
|
|
||||||
|
args = cli_main.parse_arguments()
|
||||||
|
|
||||||
|
assert args.mcp_config == str(config)
|
||||||
|
assert os.environ["STRIX_MCP_CONFIG"] == str(config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_config_flag_rejects_missing_file(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
_stub_settings(monkeypatch)
|
||||||
|
monkeypatch.delenv("STRIX_MCP_CONFIG", raising=False)
|
||||||
|
missing = tmp_path / "nope.json"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys, "argv", ["strix", "-t", "https://test.com/", "-n", "--mcp-config", str(missing)]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
cli_main.parse_arguments()
|
||||||
|
|
||||||
|
assert "--mcp-config file not found" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_server_flags_set_selection_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_stub_settings(monkeypatch)
|
||||||
|
monkeypatch.delenv("STRIX_MCP_ONLY", raising=False)
|
||||||
|
monkeypatch.delenv("STRIX_MCP_EXCLUDE", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
"strix",
|
||||||
|
"-t",
|
||||||
|
"https://test.com/",
|
||||||
|
"-n",
|
||||||
|
"--mcp-server",
|
||||||
|
"a",
|
||||||
|
"--mcp-server",
|
||||||
|
"b",
|
||||||
|
"--mcp-exclude",
|
||||||
|
"c",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
cli_main.parse_arguments()
|
||||||
|
|
||||||
|
assert os.environ["STRIX_MCP_ONLY"] == "a,b"
|
||||||
|
assert os.environ["STRIX_MCP_EXCLUDE"] == "c"
|
||||||
715
tests/test_mcp_client.py
Normal file
715
tests/test_mcp_client.py
Normal file
|
|
@ -0,0 +1,715 @@
|
||||||
|
"""Tests for the generic MCP client: config contract, namespacing, and filtering."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from agents.mcp import MCPServer, MCPServerStdio, MCPServerStreamableHttp
|
||||||
|
from mcp.types import CallToolResult, TextContent
|
||||||
|
from mcp.types import Tool as MCPTool
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from strix.agents import factory
|
||||||
|
from strix.core.runner import _mcp_connection_notes
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from agents.tool import Tool
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMCPServer(MCPServer):
|
||||||
|
"""A connected MCP server stand-in, so tests never touch the network."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, tools: list[MCPTool]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._name = name
|
||||||
|
self._tools = tools
|
||||||
|
self.calls: list[tuple[str, dict[str, Any] | None]] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def cleanup(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def list_tools(
|
||||||
|
self,
|
||||||
|
run_context: Any = None,
|
||||||
|
agent: Any = None,
|
||||||
|
) -> list[MCPTool]:
|
||||||
|
return list(self._tools)
|
||||||
|
|
||||||
|
async def call_tool(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
arguments: dict[str, Any] | None,
|
||||||
|
meta: dict[str, Any] | None = None,
|
||||||
|
) -> CallToolResult:
|
||||||
|
self.calls.append((tool_name, arguments))
|
||||||
|
return CallToolResult(content=[TextContent(type="text", text=f"routed:{tool_name}")])
|
||||||
|
|
||||||
|
async def list_prompts(self) -> Any:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_tool(name: str) -> MCPTool:
|
||||||
|
return MCPTool(
|
||||||
|
name=name,
|
||||||
|
description=f"remote tool {name}",
|
||||||
|
inputSchema={"type": "object", "properties": {}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _config(name: str, allowed_tools: list[str]) -> McpConnectionConfig:
|
||||||
|
return McpConnectionConfig(
|
||||||
|
name=name,
|
||||||
|
url="https://mcp.example.com",
|
||||||
|
auth=BearerAuth(token="run-token"),
|
||||||
|
allowed_tools=allowed_tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_mcp_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Hide any MCP settings the developer has exported in their own shell.
|
||||||
|
|
||||||
|
The loader reads these to resolve the config path and the per-run
|
||||||
|
include/exclude selection, so a shell that has them set (from using
|
||||||
|
--mcp-config or --mcp-server) would otherwise filter what these tests see.
|
||||||
|
"""
|
||||||
|
for name in ("STRIX_MCP_CONFIG", "STRIX_MCP_ONLY", "STRIX_MCP_EXCLUDE"):
|
||||||
|
monkeypatch.delenv(name, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_registry() -> Any:
|
||||||
|
saved = list(factory._EXTRA_TOOLS)
|
||||||
|
factory._EXTRA_TOOLS.clear()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
factory._EXTRA_TOOLS[:] = saved
|
||||||
|
|
||||||
|
|
||||||
|
# --- config contract ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_config_parses_from_dict() -> None:
|
||||||
|
config = McpConnectionConfig.model_validate(
|
||||||
|
{
|
||||||
|
"name": "files_main",
|
||||||
|
"transport": "http",
|
||||||
|
"url": "https://mcp.example.com",
|
||||||
|
"auth": {"kind": "bearer", "token": "abc"},
|
||||||
|
"allowed_tools": ["list_files"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(config.auth, BearerAuth)
|
||||||
|
assert config.auth.token == "abc"
|
||||||
|
assert config.allowed_tools == ["list_files"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_auth_kind_is_rejected() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
McpConnectionConfig.model_validate(
|
||||||
|
{
|
||||||
|
"name": "x",
|
||||||
|
"url": "https://mcp.example.com",
|
||||||
|
"auth": {"kind": "oauth", "token": "abc"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stdio_config_parses_from_dict() -> None:
|
||||||
|
config = McpConnectionConfig.model_validate(
|
||||||
|
{
|
||||||
|
"name": "local_fs",
|
||||||
|
"transport": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/srv/data"],
|
||||||
|
"env": {"FOO": "bar"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.transport == "stdio"
|
||||||
|
assert config.command == "npx"
|
||||||
|
assert config.args == ["-y", "@modelcontextprotocol/server-filesystem", "/srv/data"]
|
||||||
|
assert config.env == {"FOO": "bar"}
|
||||||
|
# A local stdio server needs no auth, and omitting allowed_tools means "all".
|
||||||
|
assert config.auth is None
|
||||||
|
assert config.allowed_tools is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_config_without_url_is_rejected() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
McpConnectionConfig.model_validate(
|
||||||
|
{
|
||||||
|
"name": "x",
|
||||||
|
"transport": "http",
|
||||||
|
"auth": {"kind": "bearer", "token": "abc"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stdio_config_without_command_is_rejected() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
McpConnectionConfig.model_validate(
|
||||||
|
{
|
||||||
|
"name": "x",
|
||||||
|
"transport": "stdio",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_name_is_rejected() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
McpConnectionConfig.model_validate(
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"url": "https://mcp.example.com",
|
||||||
|
"auth": {"kind": "bearer", "token": "abc"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_field_is_rejected() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
McpConnectionConfig.model_validate(
|
||||||
|
{
|
||||||
|
"name": "x",
|
||||||
|
"url": "https://mcp.example.com",
|
||||||
|
"auth": {"kind": "bearer", "token": "abc"},
|
||||||
|
"surprise": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- auth headers ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_auth_builds_authorization_header() -> None:
|
||||||
|
headers = _auth_headers(_config("files_main", []))
|
||||||
|
|
||||||
|
assert headers == {"Authorization": "Bearer run-token"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- namespacing and filtering -----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _registered_names() -> list[str]:
|
||||||
|
return [tool.name for tool in factory.registered_agent_tools()]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tools_are_namespaced_per_connection() -> None:
|
||||||
|
server_a = FakeMCPServer("conn_a", [_mcp_tool("describe")])
|
||||||
|
server_b = FakeMCPServer("conn_b", [_mcp_tool("describe")])
|
||||||
|
|
||||||
|
await _register_server_tools(_config("conn_a", ["describe"]), server_a)
|
||||||
|
await _register_server_tools(_config("conn_b", ["describe"]), server_b)
|
||||||
|
|
||||||
|
# Same remote tool name on two connections does not collide.
|
||||||
|
assert _registered_names() == ["conn_a_describe", "conn_b_describe"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_registered_names_are_valid_tool_names() -> None:
|
||||||
|
# Model APIs reject a tool name containing anything but letters, digits,
|
||||||
|
# underscores and hyphens, and reject the whole request rather than the one
|
||||||
|
# tool. A server naming its own tools with dots, or a connection named with
|
||||||
|
# a space in the user's config, must not be able to break a run.
|
||||||
|
server = FakeMCPServer("my server", [_mcp_tool("db.query"), _mcp_tool("ok_tool")])
|
||||||
|
|
||||||
|
await _register_server_tools(_config("my server", None), server)
|
||||||
|
|
||||||
|
names = _registered_names()
|
||||||
|
assert names == ["my_server_db_query", "my_server_ok_tool"]
|
||||||
|
assert all(re.fullmatch(r"[a-zA-Z0-9_-]{1,128}", name) for name in names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_rename_does_not_change_which_tool_is_called() -> None:
|
||||||
|
# Only the model-facing name is sanitized; the server is always asked for the
|
||||||
|
# tool name it reported.
|
||||||
|
server = FakeMCPServer("my server", [_mcp_tool("db.query")])
|
||||||
|
|
||||||
|
tools = await _register_server_tools(_config("my server", None), server)
|
||||||
|
|
||||||
|
assert tools[0].name == "my_server_db_query"
|
||||||
|
await tools[0].on_invoke_tool(None, "{}")
|
||||||
|
assert server.calls == [("db.query", {})]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disallowed_tool_is_not_registered() -> None:
|
||||||
|
server = FakeMCPServer(
|
||||||
|
"files_main",
|
||||||
|
[_mcp_tool("list_files"), _mcp_tool("search")],
|
||||||
|
)
|
||||||
|
|
||||||
|
await _register_server_tools(_config("files_main", ["list_files"]), server)
|
||||||
|
|
||||||
|
names = _registered_names()
|
||||||
|
assert "files_main_list_files" in names
|
||||||
|
assert "files_main_search" not in names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allowed_tools_none_registers_every_listed_tool() -> None:
|
||||||
|
server = FakeMCPServer(
|
||||||
|
"local_fs",
|
||||||
|
[_mcp_tool("read_file"), _mcp_tool("write_file")],
|
||||||
|
)
|
||||||
|
config = McpConnectionConfig(name="local_fs", url="https://mcp.example.com", allowed_tools=None)
|
||||||
|
|
||||||
|
await _register_server_tools(config, server)
|
||||||
|
|
||||||
|
names = _registered_names()
|
||||||
|
assert "local_fs_read_file" in names
|
||||||
|
assert "local_fs_write_file" in names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allowed_tools_list_restricts_registration() -> None:
|
||||||
|
server = FakeMCPServer(
|
||||||
|
"local_fs",
|
||||||
|
[_mcp_tool("read_file"), _mcp_tool("write_file")],
|
||||||
|
)
|
||||||
|
|
||||||
|
await _register_server_tools(_config("local_fs", ["read_file"]), server)
|
||||||
|
|
||||||
|
names = _registered_names()
|
||||||
|
assert names == ["local_fs_read_file"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_registered_tool_routes_to_its_server_with_the_original_name() -> None:
|
||||||
|
server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
|
||||||
|
|
||||||
|
tools: list[Tool] = await _register_server_tools(_config("files_main", ["list_files"]), server)
|
||||||
|
tool = tools[0]
|
||||||
|
|
||||||
|
output = await tool.on_invoke_tool(None, "{}") # type: ignore[union-attr]
|
||||||
|
|
||||||
|
# The call reaches the right server, addressed by the unprefixed remote name.
|
||||||
|
assert server.calls == [("list_files", {})]
|
||||||
|
assert output == {"type": "text", "text": "routed:list_files"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- result transform --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_result_transform_receives_namespaced_name_and_structured_result() -> None:
|
||||||
|
server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
|
||||||
|
seen: list[tuple[str, Any]] = []
|
||||||
|
|
||||||
|
def transform(name: str, structured: Any) -> Any:
|
||||||
|
seen.append((name, structured))
|
||||||
|
return {"kept": structured["content"][0]["text"]}
|
||||||
|
|
||||||
|
tools: list[Tool] = await _register_server_tools(
|
||||||
|
_config("files_main", ["list_files"]), server, result_transform=transform
|
||||||
|
)
|
||||||
|
|
||||||
|
output = await tools[0].on_invoke_tool(None, "{}") # type: ignore[union-attr]
|
||||||
|
|
||||||
|
# The underlying MCP call still routes by the unprefixed remote name.
|
||||||
|
assert server.calls == [("list_files", {})]
|
||||||
|
|
||||||
|
# The transform is called with the namespaced name and the parsed result.
|
||||||
|
assert len(seen) == 1
|
||||||
|
name, structured = seen[0]
|
||||||
|
assert name == "files_main_list_files"
|
||||||
|
# A parsed CallToolResult (dict/list), not a pre-serialized string.
|
||||||
|
assert structured["content"][0]["text"] == "routed:list_files"
|
||||||
|
assert structured["isError"] is False
|
||||||
|
|
||||||
|
# The transform's return value is exactly what the tool yields.
|
||||||
|
assert output == {"kept": "routed:list_files"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_result_transform_can_rewrite_the_tool_output() -> None:
|
||||||
|
server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
|
||||||
|
|
||||||
|
def transform(_name: str, structured: Any) -> Any:
|
||||||
|
# Keep only a truncated view of the text field.
|
||||||
|
return structured["content"][0]["text"][:6]
|
||||||
|
|
||||||
|
tools: list[Tool] = await _register_server_tools(
|
||||||
|
_config("files_main", ["list_files"]), server, result_transform=transform
|
||||||
|
)
|
||||||
|
|
||||||
|
output = await tools[0].on_invoke_tool(None, "{}") # type: ignore[union-attr]
|
||||||
|
|
||||||
|
assert output == "routed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_without_result_transform_output_is_unchanged() -> None:
|
||||||
|
server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
|
||||||
|
|
||||||
|
tools: list[Tool] = await _register_server_tools(
|
||||||
|
_config("files_main", ["list_files"]), server, result_transform=None
|
||||||
|
)
|
||||||
|
|
||||||
|
output = await tools[0].on_invoke_tool(None, "{}") # type: ignore[union-attr]
|
||||||
|
|
||||||
|
# Same shape the SDK produces today: no transform in the path.
|
||||||
|
assert server.calls == [("list_files", {})]
|
||||||
|
assert output == {"type": "text", "text": "routed:list_files"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- error status capture ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ErroringMCPServer(FakeMCPServer):
|
||||||
|
"""A connected server whose calls come back as MCP errors (isError=True)."""
|
||||||
|
|
||||||
|
async def call_tool(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
arguments: dict[str, Any] | None,
|
||||||
|
meta: dict[str, Any] | None = None,
|
||||||
|
) -> CallToolResult:
|
||||||
|
self.calls.append((tool_name, arguments))
|
||||||
|
return CallToolResult(
|
||||||
|
content=[TextContent(type="text", text=f"boom:{tool_name}")],
|
||||||
|
isError=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_errored_mcp_result_is_flagged_failed_for_the_tui() -> None:
|
||||||
|
server = ErroringMCPServer("files_main", [_mcp_tool("list_files")])
|
||||||
|
|
||||||
|
tools: list[Tool] = await _register_server_tools(_config("files_main", ["list_files"]), server)
|
||||||
|
output = await tools[0].on_invoke_tool(None, "{}") # type: ignore[union-attr]
|
||||||
|
|
||||||
|
# The error text stays exactly what the agent gets today; a success:False tag
|
||||||
|
# rides alongside it purely so the TUI can tell the call apart from a success.
|
||||||
|
assert output == {"type": "text", "text": "boom:list_files", "success": False}
|
||||||
|
assert _tool_status_from_result(output) == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_successful_mcp_result_stays_completed_for_the_tui() -> None:
|
||||||
|
server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
|
||||||
|
|
||||||
|
tools: list[Tool] = await _register_server_tools(_config("files_main", ["list_files"]), server)
|
||||||
|
output = await tools[0].on_invoke_tool(None, "{}") # type: ignore[union-attr]
|
||||||
|
|
||||||
|
# A non-error result is untouched and keeps rendering as done.
|
||||||
|
assert output == {"type": "text", "text": "routed:list_files"}
|
||||||
|
assert _tool_status_from_result(output) == "completed"
|
||||||
|
|
||||||
|
|
||||||
|
# --- server build branch -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_server_stdio_branch() -> None:
|
||||||
|
config = McpConnectionConfig(
|
||||||
|
name="local_fs",
|
||||||
|
transport="stdio",
|
||||||
|
command="my-server",
|
||||||
|
args=["--flag", "value"],
|
||||||
|
env={"TOKEN": "x"},
|
||||||
|
)
|
||||||
|
|
||||||
|
server = _build_server(config)
|
||||||
|
|
||||||
|
# Built, not connected: no subprocess is launched here.
|
||||||
|
assert isinstance(server, MCPServerStdio)
|
||||||
|
assert server.name == "local_fs"
|
||||||
|
assert server.params.command == "my-server"
|
||||||
|
assert server.params.args == ["--flag", "value"]
|
||||||
|
assert server.params.env == {"TOKEN": "x"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_server_http_branch() -> None:
|
||||||
|
server = _build_server(_config("files_main", ["list_files"]))
|
||||||
|
|
||||||
|
assert isinstance(server, MCPServerStreamableHttp)
|
||||||
|
assert server.name == "files_main"
|
||||||
|
|
||||||
|
|
||||||
|
# --- loader ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_loader_parses_stdio_and_http_entries(tmp_path: Path) -> None:
|
||||||
|
config_file = tmp_path / "mcp-servers.json"
|
||||||
|
config_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "local_fs",
|
||||||
|
"transport": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "server-filesystem"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "files_main",
|
||||||
|
"transport": "http",
|
||||||
|
"url": "https://mcp.example.com",
|
||||||
|
"auth": {"kind": "bearer", "token": "abc"},
|
||||||
|
"allowed_tools": ["list_files"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
configs = load_user_mcp_configs(config_file)
|
||||||
|
|
||||||
|
assert [c.name for c in configs] == ["local_fs", "files_main"]
|
||||||
|
assert configs[0].transport == "stdio"
|
||||||
|
assert configs[1].allowed_tools == ["list_files"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_loader_skips_bad_entry_but_keeps_good_ones(tmp_path: Path) -> None:
|
||||||
|
config_file = tmp_path / "mcp-servers.json"
|
||||||
|
config_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{"name": "broken", "transport": "http"}, # missing url
|
||||||
|
{
|
||||||
|
"name": "local_fs",
|
||||||
|
"transport": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
configs = load_user_mcp_configs(config_file)
|
||||||
|
|
||||||
|
assert [c.name for c in configs] == ["local_fs"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_loader_returns_empty_when_file_absent(tmp_path: Path) -> None:
|
||||||
|
assert load_user_mcp_configs(tmp_path / "does-not-exist.json") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_loader_reads_env_var_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
config_file = tmp_path / "from-env.json"
|
||||||
|
config_file.write_text(
|
||||||
|
json.dumps([{"name": "local_fs", "transport": "stdio", "command": "npx"}]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("STRIX_MCP_CONFIG", str(config_file))
|
||||||
|
|
||||||
|
configs = load_user_mcp_configs()
|
||||||
|
|
||||||
|
assert [c.name for c in configs] == ["local_fs"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- connection notes --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connection_notes_are_carried_on_the_connection(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
server = FakeMCPServer("db", [_mcp_tool("query")])
|
||||||
|
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: server)
|
||||||
|
config = McpConnectionConfig(
|
||||||
|
name="db",
|
||||||
|
url="https://mcp.example.com",
|
||||||
|
notes="Staging analytics DB; read-only.",
|
||||||
|
allowed_tools=["query"],
|
||||||
|
)
|
||||||
|
|
||||||
|
connections = await mcp_client.connect_mcp_servers([config])
|
||||||
|
|
||||||
|
# Notes ride on the connection (surfaced once), not stapled onto each tool.
|
||||||
|
assert connections[0].notes == "Staging analytics DB; read-only."
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_notes_block_lists_only_noted_connections() -> None:
|
||||||
|
connections = [
|
||||||
|
ConnectedMcpServer(
|
||||||
|
server=FakeMCPServer("db", []), name="db", tool_count=2, notes="staging, read-only"
|
||||||
|
),
|
||||||
|
ConnectedMcpServer(server=FakeMCPServer("fs", []), name="fs", tool_count=1, notes=None),
|
||||||
|
]
|
||||||
|
|
||||||
|
block = _mcp_connection_notes(connections)
|
||||||
|
|
||||||
|
assert block is not None
|
||||||
|
assert "db" in block
|
||||||
|
assert "staging, read-only" in block
|
||||||
|
# A connection without notes is not listed.
|
||||||
|
assert "fs" not in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_notes_block_is_none_without_notes() -> None:
|
||||||
|
connections = [
|
||||||
|
ConnectedMcpServer(server=FakeMCPServer("db", []), name="db", tool_count=1, notes=None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert _mcp_connection_notes(connections) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- cancellation cleanup ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_cleans_up_when_cancelled_mid_connect(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
cleaned: list[str] = []
|
||||||
|
|
||||||
|
class _Tracking(FakeMCPServer):
|
||||||
|
def __init__(self, name: str, *, fail_connect: bool = False) -> None:
|
||||||
|
super().__init__(name, [_mcp_tool("t")])
|
||||||
|
self._fail_connect = fail_connect
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
if self._fail_connect:
|
||||||
|
raise asyncio.CancelledError
|
||||||
|
|
||||||
|
async def cleanup(self) -> None:
|
||||||
|
cleaned.append(self._name)
|
||||||
|
|
||||||
|
servers = {"good": _Tracking("good"), "bad": _Tracking("bad", fail_connect=True)}
|
||||||
|
monkeypatch.setattr(mcp_client, "_build_server", lambda config: servers[config.name])
|
||||||
|
|
||||||
|
configs = [
|
||||||
|
McpConnectionConfig(name="good", url="https://mcp.example.com", allowed_tools=["t"]),
|
||||||
|
McpConnectionConfig(name="bad", url="https://mcp.example.com", allowed_tools=["t"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await mcp_client.connect_mcp_servers(configs)
|
||||||
|
|
||||||
|
# The server being connected when cancelled, and the one already connected,
|
||||||
|
# are both cleaned up rather than orphaned.
|
||||||
|
assert cleaned == ["bad", "good"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- duplicate names and run selection ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _names_file(tmp_path: Path, *names: str) -> Path:
|
||||||
|
config_file = tmp_path / "mcp-servers.json"
|
||||||
|
config_file.write_text(
|
||||||
|
json.dumps([{"name": n, "transport": "stdio", "command": "npx"} for n in names]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return config_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_loader_drops_duplicate_named_connections(tmp_path: Path) -> None:
|
||||||
|
config_file = tmp_path / "mcp-servers.json"
|
||||||
|
config_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{"name": "dup", "transport": "stdio", "command": "first"},
|
||||||
|
{"name": "dup", "transport": "stdio", "command": "second"},
|
||||||
|
{"name": "other", "transport": "stdio", "command": "npx"},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
configs = load_user_mcp_configs(config_file)
|
||||||
|
|
||||||
|
# Duplicate name is dropped; the first entry wins.
|
||||||
|
assert [c.name for c in configs] == ["dup", "other"]
|
||||||
|
assert configs[0].command == "first"
|
||||||
|
|
||||||
|
|
||||||
|
def test_loader_include_selection_keeps_only_named(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config_file = _names_file(tmp_path, "a", "b", "c")
|
||||||
|
monkeypatch.setenv("STRIX_MCP_ONLY", "a,c")
|
||||||
|
|
||||||
|
configs = load_user_mcp_configs(config_file)
|
||||||
|
|
||||||
|
assert [c.name for c in configs] == ["a", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_loader_exclude_selection_drops_named(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config_file = _names_file(tmp_path, "a", "b", "c")
|
||||||
|
monkeypatch.setenv("STRIX_MCP_EXCLUDE", "b")
|
||||||
|
|
||||||
|
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
|
||||||
Loading…
Add table
Reference in a new issue