mirror of
https://github.com/usestrix/strix.git
synced 2026-08-22 11:02:08 +02:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5eac30bf0 | ||
|
|
01ea94920d | ||
|
|
afda373f55 | ||
|
|
305cb13998 | ||
|
|
b30ed45ed1 | ||
|
|
8fb83f52b1 | ||
|
|
209584e7fd | ||
|
|
6f88b7d7d5 | ||
|
|
8d3693df8c |
@@ -167,10 +167,15 @@ strix view
|
|||||||
|
|
||||||
# ...or open a specific run by name
|
# ...or open a specific run by name
|
||||||
strix view my-run-name
|
strix view my-run-name
|
||||||
|
|
||||||
|
# Expose the viewer on all IPv4 interfaces at a fixed port
|
||||||
|
strix view --host 0.0.0.0 --port 8080 --no-open
|
||||||
```
|
```
|
||||||
|
|
||||||
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
|
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
|
||||||
|
|
||||||
|
Use `--host 0.0.0.0` to make the viewer reachable from other machines. Replace `0.0.0.0` in the printed URL with the server's reachable IP or hostname. The token in that URL grants access to the selected run's scan data, history, and steering, so only share it with trusted users and restrict the port with your firewall. Requests without the token-derived session cannot read run data.
|
||||||
|
|
||||||
### What's in the dashboard
|
### What's in the dashboard
|
||||||
|
|
||||||
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
||||||
@@ -315,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`
|
||||||
|
|||||||
+2
-1
@@ -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"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -51,10 +51,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__)
|
||||||
@@ -62,6 +64,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,
|
||||||
@@ -253,6 +297,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 []
|
||||||
@@ -298,6 +343,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,
|
||||||
@@ -472,6 +538,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -87,7 +100,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
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ def run_view(argv: list[str]) -> None:
|
|||||||
default=0,
|
default=0,
|
||||||
help="Port to serve on (default: an available ephemeral port).",
|
help="Port to serve on (default: an available ephemeral port).",
|
||||||
)
|
)
|
||||||
parser.add_argument("--host", default="127.0.0.1", help=argparse.SUPPRESS)
|
parser.add_argument(
|
||||||
|
"--host",
|
||||||
|
default="127.0.0.1",
|
||||||
|
help="Host to bind to (default: 127.0.0.1; use 0.0.0.0 for all IPv4 interfaces).",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-open",
|
"--no-open",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|||||||
@@ -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,
|
ListTodo, Crosshair, Wrench, Ban, Image, Plug,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import TerminalRenderer from "./TerminalRenderer";
|
import TerminalRenderer from "./TerminalRenderer";
|
||||||
@@ -25,6 +25,7 @@ import TodoRenderer from "./TodoRenderer";
|
|||||||
import FallbackRenderer from "./FallbackRenderer";
|
import FallbackRenderer from "./FallbackRenderer";
|
||||||
import LoadSkillRenderer from "./LoadSkillRenderer";
|
import LoadSkillRenderer from "./LoadSkillRenderer";
|
||||||
import RespondRenderer from "./RespondRenderer";
|
import RespondRenderer from "./RespondRenderer";
|
||||||
|
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*.
|
||||||
@@ -53,7 +54,8 @@ export type ToolCategory =
|
|||||||
| "notes"
|
| "notes"
|
||||||
| "skills"
|
| "skills"
|
||||||
| "todos"
|
| "todos"
|
||||||
| "telemetry";
|
| "telemetry"
|
||||||
|
| "mcp";
|
||||||
|
|
||||||
export interface ToolIconMeta {
|
export interface ToolIconMeta {
|
||||||
icon: ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>;
|
||||||
@@ -84,6 +86,9 @@ const CATEGORY_META: Record<ToolCategory, CategoryMeta> = {
|
|||||||
skills: { renderer: LoadSkillRenderer, icon: Wrench, color: "text-emerald-400" },
|
skills: { renderer: LoadSkillRenderer, icon: Wrench, color: "text-emerald-400" },
|
||||||
todos: { renderer: TodoRenderer, icon: ListTodo, color: "text-purple-400", match: /todo/ },
|
todos: { renderer: TodoRenderer, icon: ListTodo, color: "text-purple-400", match: /todo/ },
|
||||||
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" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,6 +118,7 @@ const CATEGORY_TOOLS: Record<ToolCategory, readonly string[]> = {
|
|||||||
skills: ["load_skill"],
|
skills: ["load_skill"],
|
||||||
todos: ["create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo"],
|
todos: ["create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo"],
|
||||||
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. */
|
||||||
@@ -163,14 +169,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,8 +135,9 @@ class _ViewerState:
|
|||||||
# exchanged for a session cookie only when presented on the initial page
|
# exchanged for a session cookie only when presented on the initial page
|
||||||
# load. It is the request-level authorization the review asked for:
|
# load. It is the request-level authorization the review asked for:
|
||||||
# reachability of the port (e.g. when bound with ``--host``) is not
|
# reachability of the port (e.g. when bound with ``--host``) is not
|
||||||
# enough to steer a live scan, trigger a report, or browse history --
|
# enough to read run data, steer a live scan, trigger a report, or
|
||||||
# the token is never handed to a caller who merely reaches ``/``.
|
# browse history -- the token is never handed to a caller who merely
|
||||||
|
# reaches ``/``.
|
||||||
self.session_token = secrets.token_urlsafe(32)
|
self.session_token = secrets.token_urlsafe(32)
|
||||||
# Finalized in ``serve()`` once the port is known (the server binds
|
# Finalized in ``serve()`` once the port is known (the server binds
|
||||||
# after this state is constructed); see SESSION_COOKIE_PREFIX.
|
# after this state is constructed); see SESSION_COOKIE_PREFIX.
|
||||||
@@ -234,11 +235,11 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
|
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
|
||||||
# The launched run is always viewable with no verification. The
|
# The cross-run history list (/api/runs) unlocks its entries only for
|
||||||
# cross-run history list (/api/runs) unlocks its entries only for a
|
# a caller that holds this process's session capability *and* is
|
||||||
# caller that holds this process's session capability *and* is email
|
# email verified, so merely reaching an exposed --host port never
|
||||||
# verified, so merely reaching an exposed --host port never leaks the
|
# leaks the run list (the payload still advertises the count as a
|
||||||
# run list (the payload still advertises the count as a teaser).
|
# teaser).
|
||||||
if path == "/api/runs":
|
if path == "/api/runs":
|
||||||
unlocked = self._has_session() and auth.is_verified()
|
unlocked = self._has_session() and auth.is_verified()
|
||||||
payload = build_runs_payload(state.base_dir, verified=unlocked)
|
payload = build_runs_payload(state.base_dir, verified=unlocked)
|
||||||
@@ -253,6 +254,13 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
self._handle_auth_status()
|
self._handle_auth_status()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# All remaining GET endpoints expose run metadata or scan output.
|
||||||
|
# Require the capability even for the run used to launch the viewer;
|
||||||
|
# reachability of an exposed --host port must not grant data access.
|
||||||
|
if not self._has_session():
|
||||||
|
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||||
|
return
|
||||||
|
|
||||||
run_values = query.get("run")
|
run_values = query.get("run")
|
||||||
run_param = run_values[0] if run_values else None
|
run_param = run_values[0] if run_values else None
|
||||||
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
|
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
|
||||||
@@ -260,16 +268,10 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
|
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
|
||||||
return
|
return
|
||||||
|
|
||||||
# The launched run is always viewable. Any *other* run's data is part
|
# Any run other than the one used to launch the viewer is part of the
|
||||||
# of the gated history: it needs this process's session capability
|
# email-gated history. The session check above applies to both paths;
|
||||||
# (so merely reaching an exposed --host port is not enough) *and*
|
# verification adds a second gate for historical run data.
|
||||||
# email verification -- otherwise knowing a run name would leak its
|
if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified():
|
||||||
# metadata, vulnerabilities, report, and transcript.
|
|
||||||
if run_dir.resolve() != state.run_dir.resolve():
|
|
||||||
if not self._has_session():
|
|
||||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
|
||||||
return
|
|
||||||
if not auth.is_verified():
|
|
||||||
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -385,7 +387,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
except auth.RelayError as exc:
|
except auth.RelayError as exc:
|
||||||
self._send_relay_error(exc)
|
self._send_relay_error(exc)
|
||||||
return
|
return
|
||||||
# The password is returned only to the local (127.0.0.1) browser.
|
# The password is returned only to a session-authorized browser.
|
||||||
self._send_json(
|
self._send_json(
|
||||||
HTTPStatus.OK,
|
HTTPStatus.OK,
|
||||||
{"ok": True, "password": password, "filename": filename},
|
{"ok": True, "password": password, "filename": filename},
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+129
-129
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="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-DBJ-RJqo.js"></script>
|
<script type="module" crossorigin src="./assets/index-gEZK6bjO.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-DKbLYAbP.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-DS3GeJId.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -388,6 +388,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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""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, they are prefixed onto each of the connection's tool
|
||||||
|
descriptions so the agent sees them."""
|
||||||
|
|
||||||
|
@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
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
"""Tests for the generic MCP client: config contract, namespacing, and filtering."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
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 _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
|
||||||
+49
-7
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
|
|||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from strix.core.paths import latest_run_dir, runs_base_dir
|
from strix.core.paths import latest_run_dir, runs_base_dir
|
||||||
|
from strix.interface.viewer.cli import run_view
|
||||||
from strix.interface.viewer.server import serve
|
from strix.interface.viewer.server import serve
|
||||||
from strix.interface.viewer.transcript import (
|
from strix.interface.viewer.transcript import (
|
||||||
build_run_state,
|
build_run_state,
|
||||||
@@ -48,6 +49,31 @@ def test_latest_run_dir_none_when_no_runs(tmp_path: Path, monkeypatch: pytest.Mo
|
|||||||
assert runs_base_dir() == tmp_path / "strix_runs"
|
assert runs_base_dir() == tmp_path / "strix_runs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_cli_help_includes_host(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
try:
|
||||||
|
run_view(["--help"])
|
||||||
|
except SystemExit as exc:
|
||||||
|
assert exc.code == 0
|
||||||
|
else:
|
||||||
|
raise AssertionError("--help should exit")
|
||||||
|
|
||||||
|
help_text = capsys.readouterr().out
|
||||||
|
assert "--host HOST" in help_text
|
||||||
|
assert "0.0.0.0" in help_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_can_bind_all_ipv4_interfaces(tmp_path: Path) -> None:
|
||||||
|
run_dir = _make_run(tmp_path, "remote", status="running", end_time=None)
|
||||||
|
|
||||||
|
httpd, url, _ = serve(run_dir, host="0.0.0.0", open_browser=False)
|
||||||
|
try:
|
||||||
|
assert httpd.server_address[0] == "0.0.0.0"
|
||||||
|
assert url == f"http://0.0.0.0:{httpd.server_address[1]}"
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
httpd.server_close()
|
||||||
|
|
||||||
|
|
||||||
def test_latest_run_dir_picks_newest_by_record_mtime(
|
def test_latest_run_dir_picks_newest_by_record_mtime(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -173,14 +199,15 @@ def test_server_serves_api_and_static(tmp_path: Path, monkeypatch: pytest.Monkey
|
|||||||
(assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
|
(assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
|
||||||
monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
|
monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
|
||||||
|
|
||||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
httpd, url, token = serve(run_dir, open_browser=False)
|
||||||
try:
|
try:
|
||||||
status, ctype, body = _get(f"{url}/api/run")
|
cookie = _session_cookie(url, token)
|
||||||
|
status, ctype, body = _get(f"{url}/api/run", cookie=cookie)
|
||||||
assert status == 200
|
assert status == 200
|
||||||
assert "application/json" in ctype
|
assert "application/json" in ctype
|
||||||
assert json.loads(body)["finished"] is True
|
assert json.loads(body)["finished"] is True
|
||||||
|
|
||||||
status, _, body = _get(f"{url}/api/transcript")
|
status, _, body = _get(f"{url}/api/transcript", cookie=cookie)
|
||||||
assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"}
|
assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"}
|
||||||
|
|
||||||
# Real asset is served.
|
# Real asset is served.
|
||||||
@@ -429,6 +456,22 @@ def test_unauthorized_client_cannot_acquire_capability(
|
|||||||
httpd.server_close()
|
httpd.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_data_requires_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
run_dir = _make_run(tmp_path, "private", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||||
|
_bundle(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
httpd, url, token = serve(run_dir, open_browser=False)
|
||||||
|
try:
|
||||||
|
cookie = _session_cookie(url, token)
|
||||||
|
for path in ("/api/run", "/api/vulnerabilities", "/api/report", "/api/transcript"):
|
||||||
|
assert _get_status(url + path) == 403, path
|
||||||
|
assert _get_status(url + path, cookie=f"{_cookie_name(url)}=wrong") == 403, path
|
||||||
|
assert _get_status(url + path, cookie=cookie) == 200, path
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
httpd.server_close()
|
||||||
|
|
||||||
|
|
||||||
def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
run_dir = _make_run(tmp_path, "status", status="running", end_time=None)
|
run_dir = _make_run(tmp_path, "status", status="running", end_time=None)
|
||||||
_bundle(tmp_path, monkeypatch)
|
_bundle(tmp_path, monkeypatch)
|
||||||
@@ -561,11 +604,10 @@ def test_historical_run_data_requires_verification(
|
|||||||
|
|
||||||
httpd, url, token = serve(launched, open_browser=False)
|
httpd, url, token = serve(launched, open_browser=False)
|
||||||
try:
|
try:
|
||||||
# The launched run is always viewable, no verification and no cookie.
|
# The launched run needs the session capability, but not email verification.
|
||||||
status, _, _ = _get(f"{url}/api/run")
|
assert _get_status(f"{url}/api/run") == 403
|
||||||
assert status == 200
|
|
||||||
|
|
||||||
cookie = _session_cookie(url, token)
|
cookie = _session_cookie(url, token)
|
||||||
|
assert _get_status(f"{url}/api/run", cookie=cookie) == 200
|
||||||
|
|
||||||
# A different run needs the session capability first: a cookie-less
|
# A different run needs the session capability first: a cookie-less
|
||||||
# caller is forbidden even once the machine is verified.
|
# caller is forbidden even once the machine is verified.
|
||||||
|
|||||||
Reference in New Issue
Block a user