Compare commits

...

8 Commits

Author SHA1 Message Date
Jonathan Singer
eaf4853415 name the run's MCP connections in the prompt again, alongside list_mcps 2026-08-26 17:41:08 -04:00
Jonathan Singer
c220e1fd31 Add list_mcps tool and drop the MCP inventory from the prompt 2026-08-26 15:52:34 -04:00
Jonathan Singer
181e83d3d6 let a run take MCP connections from any source and flag failed MCP calls 2026-08-26 15:07:09 -04:00
Jonathan Singer
e3f95d6cfd render MCP tool calls with the connection look again 2026-08-26 14:24:06 -04:00
Jonathan Singer
cb5691d6e2 reach MCP tools on demand instead of registering every one 2026-08-26 13:34:11 -04:00
devin-ai-integration[bot]
bfaaa904f2 fix(update): re-exec runs the new binary after self-update (endless update-prompt loop) (#1168)
Co-authored-by: Ahmed Allam <ahmed39652003@gmail.com>
2026-08-25 15:13:35 -07:00
alex s
187f41f36f Treat literal 'null'/'none' strings as absent for optional tool args (#1164)
* Treat literal 'null'/'none' strings as absent for optional tool args

Models routinely pass the literal string "null" or "none" instead of
omitting an optional argument. Taken at face value it becomes a filter
that matches nothing, so tools like list_notes / list_reports /
list_requests silently return no results.

Coerce such values to None in the central argument-coercion layer, but
only for parameters the schema allows to be null (or that are absent from
a declared "required" list), so required strings keep the literal value.
The list/filter helpers normalize the same values too, so a direct call
can't regress.

* Limit nullish coercion to query tools and keep literal tags

A literal "null"/"none" is only a mistake where the argument is a filter, so
gate the coercion on read-only query tools; a tool that writes keeps the value,
which stops update_note(content="none") from being read as "leave unchanged".

Stop dropping nullish entries from a notes tag filter too: tags are free-form,
so a literal "none" tag stays filterable and mixed tag queries keep every
branch.
2026-08-25 13:21:12 -04:00
yoni-at-strix
f4ef8867f6 Add MCP server support (#1137)
* add a generic MCP client and a config for connecting MCP servers

* Add MCP docs and CLI polish: docs page, startup connect summary, --mcp-config flag, compact tool output

* Add MCP connection notes and per-run selection; clean up on cancel and dedupe names

* Show errored MCP tool calls as failed in the TUI

* Sanitize namespaced tool names so model APIs accept them

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

* Say what MCP servers are worth connecting for

* Correct the notes docstring to match how notes reach the agent

* keep the mcp tests from reading your shell's STRIX_MCP_* vars
2026-08-24 14:00:16 -04:00
43 changed files with 3472 additions and 174 deletions

View File

@@ -320,6 +320,30 @@ strix auth status # show the active 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:**
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`

View File

@@ -47,7 +47,8 @@
"pages": [
"integrations/github-actions",
"integrations/ci-cd",
"integrations/coding-agents"
"integrations/coding-agents",
"integrations/mcp"
]
},
{

131
docs/integrations/mcp.mdx Normal file
View File

@@ -0,0 +1,131 @@
---
title: "MCP Servers"
description: "Connect your own MCP servers and expose their tools to the agent"
---
Strix can connect to [Model Context Protocol (MCP)](https://modelcontextprotocol.io) servers you list and expose their tools to the agent during a run. Use this to let the agent read how your system is actually built instead of inferring it from the outside.
A few things it pays off for:
- **A database server.** The agent can read the schema and access policies and see tables left readable without them, rather than guessing from responses.
- **A hosting or infrastructure server.** Deployments, domains and environment variable names tell it what is really running, so it tests what exists instead of what it discovered by crawling.
- **An issue tracker.** Known and accepted risks stop the agent re-reporting findings you already triaged.
- **A logging server.** Reading logs lets it confirm an exploit attempt actually landed instead of inferring it from a status code.
## Setup
Create the file `~/.strix/mcp-servers.json`. It holds a JSON list of the servers you want the agent to reach. Each entry is either a local `stdio` server that Strix launches as a subprocess, or a remote `http` server.
Create the directory if it does not exist, then write the file:
```bash
mkdir -p ~/.strix
```
Paste the servers you want into `~/.strix/mcp-servers.json`. The example below shows one of each transport: a local filesystem server over `stdio` and a remote GitHub server over `http` with a bearer token:
```json
[
{
"name": "local_fs",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
},
{
"name": "github",
"transport": "http",
"url": "https://api.githubcopilot.com/mcp/",
"auth": { "kind": "bearer", "token": "your-token" },
"allowed_tools": ["list_issues"]
}
]
```
Strix reads this file at the start of each run. There is no default file, so no MCP tools are loaded until you create it. Edit `command`, `args`, `url`, and `token` to match your own servers.
## Fields
<ParamField path="name" type="string" required>
A short label for the connection. Each server's tools are namespaced by
`name` (for example `local_fs_read_file`), so two servers can offer the same
tool name without colliding.
</ParamField>
<ParamField path="transport" type="string">
`stdio` for a local subprocess server, or `http` for a remote server.
</ParamField>
<ParamField path="command" type="string">
For `stdio` servers: the executable Strix launches (for example `npx`).
</ParamField>
<ParamField path="args" type="array">
For `stdio` servers: the arguments passed to `command`.
</ParamField>
<ParamField path="url" type="string">
For `http` servers: the server endpoint URL.
</ParamField>
<ParamField path="auth" type="object">
For `http` servers that need a bearer token:
`{ "kind": "bearer", "token": "your-token" }`.
</ParamField>
<ParamField path="allowed_tools" type="array">
Restrict which tools the agent can call. Omit it to expose every tool the
server offers, or set it to a list of tool names to allow only those. Strix
does not decide for you which of a server's tools only read and which change
things, so run the server in its own read-only mode if it has one.
</ParamField>
<ParamField path="notes" type="string">
Free-text notes for the agent about what this connection is and how you want
it used, for example "Staging analytics database, read-only, prefer aggregate
queries." When set, the notes are given to the agent at the start of the run
as a description of the connection.
</ParamField>
## Choosing connections per run
By default every connection in the file is used on each run. To narrow it for a
single run without editing the file, use either flag (both repeatable):
```bash
strix --mcp-server github -t ... # use only the named connection(s)
strix --mcp-exclude staging-db -t ... # use everything except the named one(s)
```
`--mcp-server` keeps only the connections you name; `--mcp-exclude` drops the
ones you name. Connection names must be unique in the file; if two entries share
a name, the first is kept and the rest are ignored.
## Pointing at a different file
To read the config from another path instead of `~/.strix/mcp-servers.json`, either pass `--mcp-config <path>` on the command line:
```bash
strix --mcp-config ./mcp-servers.json -t ...
```
or set the `STRIX_MCP_CONFIG` environment variable to that path. The flag takes precedence when both are given.
## Startup confirmation
When servers are configured, Strix prints a one-line summary at scan startup, for example `MCP: connected 1 server (14 tools): local_fs`, so you can confirm your servers connected.
## Seeing the calls
Each call the agent makes to one of your servers is shown with its own icon and
labelled with the connection it went out to, in the terminal and in the run
viewer (`strix view`), so a call that left Strix for a server you connected is
easy to pick out of a transcript. The terminal shows the call and its arguments;
results can be large and arbitrary, so read them in the viewer, which shows a
preview you can expand.
## Behavior
- The config file is optional. Without it, a run simply gets no MCP tools.
- A server that fails to connect is skipped and logged, and the run continues without it.
- A single malformed entry is skipped without blocking the valid ones.

View File

@@ -241,6 +241,10 @@ ignore = [
"tests/test_stream_idle_timeout.py" = ["N802", "SLF001"]
"tests/test_unknown_tool_recovery.py" = ["N802"]
"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"]
# MCP connection request in a test carries a dummy bearer token.
"tests/test_runner_root_prompt.py" = ["S106"]
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]

View File

@@ -29,6 +29,7 @@ from strix.tools.agents_graph.tools import (
from strix.tools.coverage.tools import list_coverage, record_coverage, update_coverage
from strix.tools.finish.tool import finish_scan
from strix.tools.load_skill.tool import load_skill
from strix.tools.mcp import call_mcp, describe_mcp, list_mcps
from strix.tools.notes.tools import (
create_note,
delete_note,
@@ -36,6 +37,7 @@ from strix.tools.notes.tools import (
list_notes,
update_note,
)
from strix.tools.nullish import is_nullish
from strix.tools.output_store import bound_and_store, bound_text
from strix.tools.proxy.tools import (
list_requests,
@@ -164,6 +166,28 @@ def _schema_types(spec: dict[str, Any]) -> set[str]:
return types
def _allows_null(spec: dict[str, Any]) -> bool:
raw = spec.get("type")
if raw == "null" or (isinstance(raw, list) and "null" in raw):
return True
return any(
isinstance(variant, dict) and _allows_null(variant) for variant in spec.get("anyOf") or ()
)
def _is_nullable(key: str, spec: dict[str, Any], schema: dict[str, Any]) -> bool:
"""Whether ``key`` may be ``None``.
Strict schemas list every property as required, so nullability shows up as a
``null`` type variant; without a declared one, fall back to the property
being absent from a declared ``required`` list.
"""
if _allows_null(spec):
return True
required = schema.get("required")
return isinstance(required, list) and key not in required
def _decode_structured(value: str, types: set[str]) -> Any:
stripped = value.strip()
if not stripped:
@@ -178,9 +202,14 @@ def _decode_structured(value: str, types: set[str]) -> Any:
return decoded if isinstance(decoded, wanted) else value
def _coerce_argument(value: Any, spec: dict[str, Any]) -> Any:
def _coerce_argument(value: Any, spec: dict[str, Any], *, nullable: bool = False) -> Any:
if value is None:
return value
if nullable and is_nullish(value):
# The model's stand-in for "no value"; as a filter it matches nothing.
return None
types = _schema_types(spec)
if not types or value is None:
if not types:
return value
if isinstance(value, list | dict) and "string" in types and not types & {"array", "object"}:
return json.dumps(value, ensure_ascii=False)
@@ -189,7 +218,12 @@ def _coerce_argument(value: Any, spec: dict[str, Any]) -> Any:
return value
def _coerce_arguments(raw_input: str, schema: dict[str, Any]) -> str:
# Only query tools get nullish coercion: there a literal "null" is a filter that
# matches nothing, while a tool that writes may well be given it as real content.
_QUERY_TOOL_PREFIXES = ("list_", "search_", "view_", "get_")
def _coerce_arguments(raw_input: str, schema: dict[str, Any], *, nullish: bool = False) -> str:
properties = schema.get("properties")
if not isinstance(properties, dict) or not properties:
return raw_input
@@ -205,7 +239,9 @@ def _coerce_arguments(raw_input: str, schema: dict[str, Any]) -> str:
spec = properties.get(key)
if not isinstance(spec, dict):
continue
coerced = _coerce_argument(value, spec)
coerced = _coerce_argument(
value, spec, nullable=nullish and _is_nullable(key, spec, schema)
)
if coerced is not value:
payload[key] = coerced
changed = True
@@ -220,9 +256,10 @@ def _with_coerced_arguments(tool: FunctionTool) -> FunctionTool:
return tool
invoke_tool = tool.on_invoke_tool
schema = tool.params_json_schema
nullish = tool.name.startswith(_QUERY_TOOL_PREFIXES)
async def invoke(ctx: Any, raw_input: str) -> Any:
return await invoke_tool(ctx, _coerce_arguments(raw_input, schema))
return await invoke_tool(ctx, _coerce_arguments(raw_input, schema, nullish=nullish))
tool.on_invoke_tool = invoke
tool._strix_coerced = True # type: ignore[attr-defined]
@@ -551,6 +588,9 @@ _BASE_TOOLS: tuple[Tool, ...] = (
list_sitemap,
view_sitemap_entry,
scope_rules,
list_mcps,
describe_mcp,
call_mcp,
view_agent_graph,
send_message_to_agent,
wait_for_agents,

View File

@@ -75,6 +75,22 @@ AUTHORIZED TARGETS:
{% endfor %}
{% endif %}
{% if system_prompt_context and system_prompt_context.mcp_available %}
MCP CONNECTIONS (available this run):
- The user connected one or more MCP (Model Context Protocol) servers — external tool providers you can reach on demand. Their individual tools do NOT appear in your tool list; three dispatch tools are the only way in.
{% if system_prompt_context.mcp_connections %}
- Connected this run (call describe_mcp on one to see its tools):
{% for connection in system_prompt_context.mcp_connections %}
- {{ connection.name }} ({{ connection.tool_count }} tools){% if connection.purpose %}: {{ connection.purpose }}{% endif %}
{% endfor %}
{% endif %}
- Reach for a connection whenever the target itself cannot give you information a connection could: its database schema and access policies, real deployment or infrastructure configuration, known issues or prior findings, or server logs. In those cases call list_mcps early to see what is available, and prefer a connection's authoritative data over inferring from the target's responses. Do not wait to be told a connection exists.
1. Call list_mcps() to discover the available connections.
2. Call describe_mcp(connection="<name>") to inspect one connection's tools, each with its name, description, and JSON input schema.
3. Call call_mcp(connection="<name>", tool="<tool>", arguments={...}) to run one, passing an arguments object that matches the schema (omit arguments for a tool that takes none).
- Do not assume a connection or tool exists; discover it with list_mcps and describe it with describe_mcp before calling.
{% endif %}
AUTHORIZATION STATUS:
- You have FULL AUTHORIZATION for authorized security validation on in-scope targets to help secure the target systems/app
- All permission checks have been COMPLETED and APPROVED - never question your authority

View File

@@ -53,10 +53,12 @@ from strix.tools.output_store import (
if TYPE_CHECKING:
from agents.mcp import MCPServer
from agents.memory import SQLiteSession
from agents.result import RunResultBase
from strix.runtime.status import StatusSink
from strix.tools.mcp import ConnectedMcpServer, McpConnectionRequest
logger = logging.getLogger(__name__)
@@ -64,6 +66,31 @@ logger = logging.getLogger(__name__)
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 _merge_root_prompt_context(
scope_context: dict[str, Any],
extra_system_prompt_context: dict[str, Any] | None,
@@ -129,6 +156,7 @@ async def run_strix_scan(
root_instructions_override: str | None = None,
extra_system_prompt_context: dict[str, Any] | None = None,
status_sink: StatusSink | None = None,
mcp_connection_requests: list[McpConnectionRequest] | None = None,
) -> RunResultBase | None:
"""Run or resume one Strix scan against a sandbox.
@@ -140,6 +168,11 @@ async def run_strix_scan(
``extra_system_prompt_context`` is merged into the root agent's scan
context before prompt rendering. Child agents keep the standard scan prompt
and context.
``mcp_connection_requests`` supplies the run's MCP connections from any
source: when given, the engine connects those requests; when ``None`` (the
command-line default) it reads ``~/.strix/mcp-servers.json`` itself. Either
way the engine does the connecting, so the caller passes inert configs plus
metadata and never live sessions.
"""
def report(phase: str) -> None:
@@ -262,6 +295,7 @@ async def run_strix_scan(
configure_spill_writer(_spill_to_workspace)
sessions_to_close: list[SQLiteSession] = []
mcp_servers: list[MCPServer] = []
try:
targets = scan_config.get("targets") or []
@@ -299,6 +333,61 @@ async def run_strix_scan(
coordinator.set_budget_extender(hooks.extend_budget)
scope_context = build_scope_context(scan_config)
# Attach the run's MCP connections and hold their live sessions in a
# per-run registry. The connections are source-agnostic: a caller
# (the SaaS/pro product) can supply them as mcp_connection_requests, and
# when it does not the command-line path reads them from
# ~/.strix/mcp-servers.json here. Either way one shared engine routine
# does the connecting and populating. Nothing is registered as an agent
# tool: every agent reaches these connections on demand through the
# list_mcps / describe_mcp / call_mcp tools, guided by brief static prompt
# guidance when any connection exists. Fail-open: a missing config, or a
# server that will not connect, must never break a run.
from strix.tools.mcp import (
McpConnectionRequest,
McpRegistry,
attach_mcp_requests,
load_user_mcp_configs,
)
mcp_registry = McpRegistry()
try:
if mcp_connection_requests is None:
# Command-line default: read the user's file and wrap each config
# in a bare request (no provider or transform), so this path is
# exactly the old behavior.
mcp_requests = [
McpConnectionRequest(config=config) for config in load_user_mcp_configs()
]
else:
mcp_requests = mcp_connection_requests
if mcp_requests:
connections = await attach_mcp_requests(mcp_requests, mcp_registry)
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))
# Name the connected servers in the prompt so every agent
# (root and children, both deriving from scope_context) sees
# what is available at the start; they can still re-list or
# inspect them at run time via list_mcps / describe_mcp. Set
# only when a connection exists, so a run with no MCP leaves
# the prompt context unchanged.
scope_context["mcp_available"] = bool(mcp_registry)
scope_context["mcp_connections"] = [
{
"name": summary.name,
"purpose": summary.purpose,
"tool_count": summary.tool_count,
}
for summary in mcp_registry.summaries()
]
except Exception:
logger.exception("Failed to connect user MCP servers; continuing without them")
root_context = _merge_root_prompt_context(scope_context, extra_system_prompt_context)
root_instructions = _compose_root_instructions_override(
root_instructions_override,
@@ -361,6 +450,7 @@ async def run_strix_scan(
"coordinator": coordinator,
"sandbox_session": bundle["session"],
"caido_client": bundle["caido_client"],
"mcp_registry": mcp_registry,
"agent_id": root_id,
"parent_id": None,
"interactive": interactive,
@@ -489,6 +579,9 @@ async def run_strix_scan(
for s in sessions_to_close:
with contextlib.suppress(Exception):
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):
await coordinator._maybe_snapshot()
if cleanup_on_exit:

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import argparse
import os
import sys
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",
)
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(
"--max-budget",
"--max-budget-usd",
@@ -267,6 +292,20 @@ Examples:
if 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:
sys.exit(0 if self_update() else 1)

View File

@@ -6,7 +6,6 @@ Strix Agent Interface
import argparse
import asyncio
import contextlib
import os
import sys
from pathlib import Path
@@ -36,6 +35,7 @@ from strix.interface.update_check import (
is_binary_install,
notify_update,
prompt_update_if_available,
restart_after_update,
start_background_check,
)
from strix.interface.utils import (
@@ -440,7 +440,7 @@ def main() -> None:
start_background_check()
if not args.non_interactive and prompt_update_if_available(Console()):
if is_binary_install() and sys.platform != "win32":
os.execv(sys.executable, sys.argv) # noqa: S606 # nosec B606
restart_after_update()
sys.exit(0)
check_docker_installed()

View File

@@ -0,0 +1,47 @@
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()
}
// renderMcpInspect renders describe_mcp: a request to inspect one connection's
// catalog rather than a call to a tool on it. There is no underlying tool, so
// the connection is the whole subject and leads. Same icon and colors as a tool
// call so the two read as one family while scrolling a transcript.
func renderMcpInspect(connection, status string) string {
var b strings.Builder
b.WriteString(mcpIcon + Dim().Render("Inspecting MCP server ") + Bold(Mint).Render(connection) + "\n")
icon, style := statusIcon(status)
b.WriteString(style.Render(icon))
return b.String()
}

View File

@@ -22,19 +22,20 @@ func statusIcon(status string) (string, lipgloss.Style) {
return "○ Unknown", Dim()
}
// renderGenericTool ports registry._render_default_tool_widget.
func renderGenericTool(name string, args map[string]any, result any, status string) string {
// renderGenericTool ports registry._render_default_tool_widget. It shows the
// tool name, its arguments, and a status line only. The raw result is
// deliberately not rendered: a generic 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
b.WriteString(Dim().Render("→ Using tool ") + Bold(Blue).Render(name) + "\n")
for _, k := range SortedKeys(args) {
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)
b.WriteString(style.Render(icon))
}
icon, style := statusIcon(status)
b.WriteString(style.Render(icon))
return b.String()
}
@@ -51,6 +52,23 @@ func Tool(data map[string]any) string {
}
result := data["result"]
// A call to a tool from one of the user's MCP servers is tagged with the
// connection it came from, because its name is the server's own and means
// nothing here. The tag is only ever set from the connections the run made,
// so it is the one thing that can tell such a call apart from a built-in.
if connection := StringValue(data["mcp_connection"]); connection != "" {
// describe_mcp inspects a connection's catalog rather than calling a tool
// on it, so there is no underlying tool and the connection is the subject.
if name == "describe_mcp" {
return renderMcpInspect(connection, status)
}
toolName := StringValue(data["mcp_tool"])
if toolName == "" {
toolName = name
}
return renderMcpTool(connection, toolName, args, status)
}
switch name {
case "exec_command":
return renderExecCommand(args, result, status)
@@ -91,7 +109,7 @@ func Tool(data map[string]any) string {
case "list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules":
return renderProxyTool(name, args, result, status)
}
return renderGenericTool(name, args, result, status)
return renderGenericTool(name, args, status)
}
// ---------------------------------------------------------------------------

View File

@@ -203,7 +203,7 @@ func TestToolDispatchCoversKnownTools(t *testing.T) {
{
"unknown tool falls back to generic",
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,60 @@ 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) {
// call_mcp is the dispatch tool; the connection and the server's own tool
// name are tagged onto the event from its arguments.
data := tool("call_mcp", map[string]any{"path": "/etc/hosts"}, "file body", "completed")
data["mcp_connection"] = "local_fs"
data["mcp_tool"] = "read_file"
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 TestMcpToolWithoutTaggedToolFallsBackToDispatchName(t *testing.T) {
// A call_mcp whose underlying tool could not be read still renders as an MCP
// row, falling back to the dispatch tool name.
data := tool("call_mcp", nil, nil, "running")
data["mcp_connection"] = "local_fs"
requireContains(t, ansi.Strip(Tool(data)), mcpIcon+"call_mcp", "local_fs", "In progress")
}
func TestMcpDescribeInspectsConnection(t *testing.T) {
// describe_mcp inspects a connection; the connection is the subject and the
// dispatch tool name is not shown as if it were a server tool.
data := tool("describe_mcp", nil, nil, "completed")
data["mcp_connection"] = "local_fs"
out := ansi.Strip(Tool(data))
requireContains(t, out, mcpIcon, "Inspecting MCP server", "local_fs", "Done")
if strings.Contains(out, "describe_mcp") {
t.Fatalf("describe_mcp must read as inspecting the connection, not name the dispatch tool:\n%s", out)
}
}
func TestCollapseToolShellPreviewAndExpand(t *testing.T) {
lines := make([]string, 16)
for i := range lines {

View File

@@ -14,6 +14,7 @@ from agents.tool import ToolOutputImage
from strix.core.paths import runtime_state_dir
from strix.interface.tui.history import load_session_history
from strix.tools.mcp import resolve_mcp_call
class TuiLiveView:
@@ -27,6 +28,23 @@ class TuiLiveView:
self._user_instruction_at: str | None = None
self._user_instruction_shown = False
def _mcp_tool_fields(self, tool_name: str, args: dict[str, Any]) -> dict[str, str]:
"""Event fields naming the MCP server a tool call went out to, if any.
Delegates to the shared engine resolver :func:`resolve_mcp_call` so a
dispatch call is attributed the same way here and in strix-pro's tracer.
The projection has no live registry, so it passes none: it reports the
connection and tool read from the call's arguments and leaves the provider
out. Empty for every other tool, which is what tells an interface to
render the call as one of its own rather than as a call to a user's
server. ``describe_mcp`` resolves with an empty tool, which tells both
renderers to present the row as inspecting the connection itself.
"""
info = resolve_mcp_call(tool_name, args)
if info is None:
return {}
return {"mcp_connection": info.connection, "mcp_tool": info.tool}
def set_user_instruction(self, text: str | None, *, timestamp: str | None = None) -> None:
"""Open the transcript with what the user asked for.
@@ -73,7 +91,7 @@ class TuiLiveView:
def hydrate_from_run_dir(self, run_dir: Path) -> None:
# Armed before the agents are added so the root agent's arrival puts the
# user's opening message ahead of the replayed history.
self._load_user_instruction(run_dir)
self._load_run_record(run_dir)
state_dir = runtime_state_dir(run_dir)
agents_path = state_dir / "agents.json"
if not agents_path.exists():
@@ -100,8 +118,8 @@ class TuiLiveView:
self.flush_user_instruction()
self._hydrate_sdk_session_history(run_dir, statuses.keys())
def _load_user_instruction(self, run_dir: Path) -> None:
"""Take the user's opening message from the run record, if it has one."""
def _load_run_record(self, run_dir: Path) -> None:
"""Take the user's opening message off the record."""
try:
record = json.loads((run_dir / "run.json").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
@@ -318,6 +336,7 @@ class TuiLiveView:
"status": "running",
"agent_id": agent_id,
"call_id": call_id,
**self._mcp_tool_fields(call["tool_name"], call["args"]),
}
if existing is None:
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
@@ -340,6 +359,10 @@ class TuiLiveView:
event_key = (agent_id, call_id)
event = self._tool_event_by_agent_and_call_id.get(event_key)
if event is None:
# No prior call event to update, so its arguments are gone and the
# connection an MCP call went out to cannot be recovered. The matching
# call event, when there is one, already carries the MCP fields; this
# arrives only when the call was never projected, so it stays generic.
event = self._append_event(
agent_id,
"tool",

View File

@@ -264,6 +264,36 @@ def prompt_update_if_available(console: Console) -> bool:
return run_package_upgrade(console, method)
def restart_env() -> dict[str, str]:
"""Environment for re-exec'ing the binary after a self-update.
The PyInstaller bootloader marks its child process via environment
variables (``_MEIPASS2`` on older versions, ``_PYI_*`` on 6.x) that
point at the already-extracted archive of the *running* version. If
they leak into the re-exec'd process, the new binary skips extraction
and runs the old code, so the update never appears to take effect.
Library-path variables the bootloader overrode are restored from the
``*_ORIG`` copies it saved.
"""
env = {
key: value
for key, value in os.environ.items()
if key != "_MEIPASS2" and not key.startswith("_PYI_")
}
for var in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"):
orig = env.pop(f"{var}_ORIG", None)
if orig is not None:
env[var] = orig
elif var in os.environ:
env.pop(var, None)
return env
def restart_after_update() -> None:
"""Replace the current process with the freshly updated binary."""
os.execve(sys.executable, sys.argv, restart_env()) # noqa: S606 # nosec B606
def _release_target() -> str | None:
raw_os = platform.system().lower()
os_name = {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(raw_os)

View File

@@ -30,7 +30,7 @@ class RendererErrorBoundary extends Component<
}
function SafeToolRenderer(props: ToolRendererProps) {
const Renderer = getToolRenderer(props.toolName);
const Renderer = getToolRenderer(props.toolName, props.mcpConnection);
return (
<RendererErrorBoundary toolName={props.toolName}>
<Renderer {...props} />
@@ -63,6 +63,10 @@ function coerce(value: unknown): unknown {
}
}
function asOptionalString(value: unknown): string | null {
return typeof value === "string" && value ? value : null;
}
function asRecord(value: unknown): Record<string, unknown> {
const c = coerce(value);
if (c && typeof c === "object" && !Array.isArray(c)) return c as Record<string, unknown>;
@@ -244,11 +248,14 @@ export function AgentTranscript({
const isTool = event.type === "tool";
const toolName = isTool ? String(event.data?.tool_name ?? "tool") : "";
const role = !isTool ? String(event.data?.role ?? "assistant") : "";
// Present only on a call to one of the user's own MCP servers.
const mcpConnection = asOptionalString(event.data?.mcp_connection);
const mcpTool = asOptionalString(event.data?.mcp_tool);
let Icon;
let iconColor: string;
if (isTool) {
const meta = getToolIcon(toolName);
const meta = getToolIcon(toolName, mcpConnection);
Icon = meta.icon;
iconColor = meta.color;
} else {
@@ -279,6 +286,8 @@ export function AgentTranscript({
{isTool ? (
<SafeToolRenderer
toolName={toolName}
mcpConnection={mcpConnection}
mcpTool={mcpTool}
args={asRecord(event.data?.args)}
result={coerce(event.data?.result) ?? null}
status={

View File

@@ -0,0 +1,98 @@
"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;
// describe_mcp inspects a connection's catalog rather than calling a tool on
// it, so the connection is the subject and there is no underlying tool.
const inspecting = toolName === "describe_mcp";
return (
<div>
<div className="flex items-center gap-2 flex-wrap">
{inspecting ? (
<>
<span className="text-[13px] text-[#555]">Inspecting MCP server</span>
{mcpConnection && (
<span className="font-mono text-teal-300 font-semibold text-sm">{mcpConnection}</span>
)}
</>
) : (
<>
<span className="font-mono text-teal-300 font-semibold text-sm">
{mcpTool || toolName}
</span>
<span className="text-[13px] text-[#555]">via MCP server</span>
{mcpConnection && <span className="text-[13px] text-teal-400/80">{mcpConnection}</span>}
</>
)}
</div>
{lines.length > 0 && (
<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>
);
}

View File

@@ -3,7 +3,7 @@ import type { ToolRendererProps } from "@/types/events";
import {
Terminal, Globe, FileText, ShieldAlert, ArrowUpRight, Brain,
Bot, MessageCircle, Flag, Eye, Search, Code, StickyNote,
ListTodo, Crosshair, Wrench, Ban, Image, ClipboardList,
ListTodo, Crosshair, Wrench, Ban, Image, ClipboardList, Plug,
} from "lucide-react";
import TerminalRenderer from "./TerminalRenderer";
@@ -27,6 +27,7 @@ import LoadSkillRenderer from "./LoadSkillRenderer";
import RespondRenderer from "./RespondRenderer";
import CoverageRenderer from "./CoverageRenderer";
import ThreatModelRenderer from "./ThreatModelRenderer";
import McpRenderer from "./McpRenderer";
/**
* Tool-renderer mapping — data-driven, keyed by the engine's tool *family*.
@@ -57,7 +58,8 @@ export type ToolCategory =
| "todos"
| "coverage"
| "threatModel"
| "telemetry";
| "telemetry"
| "mcp";
export interface ToolIconMeta {
icon: ComponentType<{ className?: string }>;
@@ -90,6 +92,9 @@ const CATEGORY_META: Record<ToolCategory, CategoryMeta> = {
coverage: { renderer: CoverageRenderer, icon: ClipboardList, color: "text-cyan-400", match: /coverage/ },
threatModel: { renderer: ThreatModelRenderer, icon: Crosshair, color: "text-blue-400", match: /threat_model/ },
telemetry: { renderer: FallbackRenderer, icon: Wrench, color: "text-[#555]" },
// Tools from the user's own MCP servers. Resolved from the connection on the
// event rather than from a tool name, so this family has no names below.
mcp: { renderer: McpRenderer, icon: Plug, color: "text-teal-400" },
};
/**
@@ -123,6 +128,7 @@ const CATEGORY_TOOLS: Record<ToolCategory, readonly string[]> = {
// Per-target threat model, shared across the agent tree
threatModel: ["get_threat_model", "save_threat_model", "amend_threat_model"],
telemetry: ["sandbox_error_details", "llm_error_details"],
mcp: [],
};
/** Reverse index (tool name → family), built once from CATEGORY_TOOLS. */
@@ -173,14 +179,27 @@ function resolveCategory(toolName: string): ToolCategory | null {
return null;
}
export function getToolRenderer(toolName: string): ComponentType<ToolRendererProps> {
/**
* A call to a tool from one of the user's MCP servers is placed by the
* connection it was tagged with, ahead of every name-keyed lookup below. Every
* MCP call goes through the `call_mcp` / `describe_mcp` dispatch tools, so the
* connection tag, not the tool name, is what routes it to the MCP renderer.
*/
export function getToolRenderer(
toolName: string,
mcpConnection?: string | null
): ComponentType<ToolRendererProps> {
if (mcpConnection) return CATEGORY_META.mcp.renderer;
const override = RENDERER_OVERRIDES[toolName];
if (override) return override;
const category = resolveCategory(toolName);
return category ? CATEGORY_META[category].renderer : FallbackRenderer;
}
export function getToolIcon(toolName: string): ToolIconMeta {
export function getToolIcon(toolName: string, mcpConnection?: string | null): ToolIconMeta {
if (mcpConnection) {
return { icon: CATEGORY_META.mcp.icon, color: CATEGORY_META.mcp.color };
}
const override = ICON_OVERRIDES[toolName];
if (override) return override;
const category = resolveCategory(toolName);

View File

@@ -99,4 +99,13 @@ export interface ToolRendererProps {
args: Record<string, unknown>;
result: unknown;
status: "running" | "completed" | "failed" | "error";
/**
* Set only on a call to a tool from an MCP server the user connected: the name
* they gave that connection, and the server's own name for the tool. Every MCP
* call goes through the `call_mcp` / `describe_mcp` dispatch tools, so the
* engine reads both out of the call's arguments; `describe_mcp` inspects a
* connection and leaves `mcpTool` empty.
*/
mcpConnection?: string | null;
mcpTool?: string | null;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -404,6 +404,18 @@ class ReportState:
posthog.end(self, exit_reason="finished_by_tool")
scarf.end(self, exit_reason="finished_by_tool")
def record_mcp_connections(self, names: list[str]) -> None:
"""Note the MCP servers this run connected, and persist it.
Saved as soon as the run connects rather than at the end, so an interface
reading the record mid-run can already attribute a tool call to the
server it went out to.
"""
if self.run_record.get("mcp_connections") == names:
return
self.run_record["mcp_connections"] = names
self.save_run_data()
def set_scan_config(self, config: dict[str, Any]) -> None:
self.scan_config = config
self.run_record["status"] = "running"

View File

@@ -0,0 +1,54 @@
"""Generic MCP client: connect MCP servers and reach their tools on demand."""
from __future__ import annotations
from strix.tools.mcp.agent_tools import call_mcp, describe_mcp, list_mcps
from strix.tools.mcp.client import (
ConnectedMcpServer,
attach_mcp_requests,
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 namespaced_tool_name
from strix.tools.mcp.registry import (
CALL_MCP_TOOL,
DESCRIBE_MCP_TOOL,
MCP_DISPATCH_TOOLS,
MCP_REGISTRY_CONTEXT_KEY,
McpCallInfo,
McpConnectionEntry,
McpConnectionRequest,
McpConnectionSummary,
McpRegistry,
resolve_mcp_call,
)
__all__ = [
"CALL_MCP_TOOL",
"DESCRIBE_MCP_TOOL",
"MCP_DISPATCH_TOOLS",
"MCP_REGISTRY_CONTEXT_KEY",
"BearerAuth",
"ConnectedMcpServer",
"McpAuth",
"McpCallInfo",
"McpConnectionConfig",
"McpConnectionEntry",
"McpConnectionRequest",
"McpConnectionSummary",
"McpRegistry",
"attach_mcp_requests",
"call_mcp",
"connect_mcp_servers",
"describe_mcp",
"list_mcps",
"load_user_mcp_configs",
"namespaced_tool_name",
"resolve_mcp_call",
]

View File

@@ -0,0 +1,173 @@
"""The three generic MCP dispatch tools every agent carries.
Under the generic-dispatch model an agent does not get one tool per MCP tool.
It gets exactly these three and discovers connections on demand:
- ``list_mcps()`` returns the connections available this run — each connection's
id, name, description, and tool count, with no tool schemas — so the model can
discover what it can reach without any inventory in the system prompt.
- ``describe_mcp(connection)`` returns, as text, one connection's tools with
their names, descriptions, and JSON input schemas — the schemas the model
needs, fetched on demand instead of loaded onto every request up front.
- ``call_mcp(connection, tool, arguments)`` dispatches one call to a
connection's tool and returns its result.
All three read the per-run :class:`~strix.tools.mcp.registry.McpRegistry` from the
run context under :data:`~strix.tools.mcp.registry.MCP_REGISTRY_CONTEXT_KEY`. They
are ordinary ``FunctionTool`` objects placed in the agent factory's base tool set,
so the factory's output-bounding and disk-spill wrapping apply to their results
automatically.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from agents import RunContextWrapper, function_tool
from strix.tools.mcp.client import dispatch_mcp_call
from strix.tools.mcp.naming import namespaced_tool_name
from strix.tools.mcp.registry import MCP_REGISTRY_CONTEXT_KEY, McpRegistry
if TYPE_CHECKING:
from mcp.types import Tool as MCPTool
def _registry_from_ctx(ctx: RunContextWrapper) -> McpRegistry | None:
context = ctx.context if isinstance(ctx.context, dict) else {}
registry = context.get(MCP_REGISTRY_CONTEXT_KEY)
return registry if isinstance(registry, McpRegistry) else None
_NO_CONNECTIONS = "No MCP connections are configured for this run."
def _unknown_connection(connection: str, registry: McpRegistry) -> str:
available = ", ".join(registry.names()) or "(none)"
return f"Unknown MCP connection {connection!r}. Available connections: {available}."
def _format_tool(tool: MCPTool) -> str:
schema = json.dumps(tool.inputSchema or {"type": "object"}, indent=2, ensure_ascii=False)
description = (tool.description or "").strip() or "(no description)"
return f"- {tool.name}: {description}\n input schema:\n{schema}"
@function_tool(timeout=60)
async def list_mcps(ctx: RunContextWrapper) -> dict[str, Any]:
"""List the MCP connections available this run, so you can discover them.
Read-only. Returns one entry per connection with its ``id`` (the exact name
you pass to ``describe_mcp`` and ``call_mcp``), ``name``, ``description``, and
``tool_count`` — no tool schemas. The three MCP tools work in order: call
``list_mcps`` to discover the available connections, then ``describe_mcp`` on
one connection to inspect its tools and their input schemas, then ``call_mcp``
to run one of its tools. Returns an empty ``connections`` list when the run has
no MCP connections.
"""
registry = _registry_from_ctx(ctx)
if registry is None or not registry:
return {"connections": []}
return {
"connections": [
{
"id": summary.name,
"name": summary.name,
"description": summary.purpose,
"tool_count": summary.tool_count,
}
for summary in registry.summaries()
]
}
@function_tool(timeout=60)
async def describe_mcp(ctx: RunContextWrapper, connection: str) -> str:
"""List the tools one MCP connection offers, with their input schemas.
Read-only. Look up a connection by the id ``list_mcps`` reported for it; this
returns each of its tools with the tool's name, description, and JSON input
schema — the argument shape you pass to ``call_mcp``. Call this before
``call_mcp`` on any connection you have not used yet. Nothing is fetched from
or run against the connection's data.
Args:
connection: The connection name exactly as reported by ``list_mcps``.
"""
registry = _registry_from_ctx(ctx)
if registry is None or not registry:
return _NO_CONNECTIONS
entry = registry.get(connection)
if entry is None:
return _unknown_connection(connection, registry)
tools = await entry.server.list_tools()
if not tools:
return f"MCP connection {connection!r} offers no tools."
header = f"MCP connection {connection!r} offers {len(tools)} tool(s):"
body = "\n".join(_format_tool(tool) for tool in tools)
return f"{header}\n{body}"
@function_tool(timeout=120, strict_mode=False)
async def call_mcp(
ctx: RunContextWrapper,
connection: str,
tool: str,
arguments: Any = None,
) -> Any:
"""Call one tool on one MCP connection and return its result.
Address the tool by the connection id from ``list_mcps`` and the tool name
from ``describe_mcp`` on that connection. Pass the tool's arguments as an
object matching the input schema ``describe_mcp`` showed for it (omit it, or
pass an empty object, for a tool that takes no arguments).
Args:
connection: The connection name exactly as reported by ``list_mcps``.
tool: The tool name, exactly as reported by ``describe_mcp``.
arguments: The tool's arguments as a JSON object of names to values (for
example ``{"path": "app.py"}``), or omitted/empty for a tool that
takes none. Pass an object, not a stringified one. Its shape is
whatever ``describe_mcp`` showed for the tool rather than a shape this
tool fixes in advance.
"""
registry = _registry_from_ctx(ctx)
if registry is None or not registry:
return _NO_CONNECTIONS
entry = registry.get(connection)
if entry is None:
return _unknown_connection(connection, registry)
invalid_arguments = (
f"Invalid arguments for {connection!r}.{tool}: expected a JSON object of "
"argument names to values, or none. Call describe_mcp for the input schema."
)
if isinstance(arguments, str):
# The ``arguments`` parameter is schema-less (an open object is not
# expressible as a strict tool schema), so some models serialize it as a
# JSON string instead of a bare object. Accept a string that decodes to an
# object so a correct call is not rejected over its encoding.
stripped = arguments.strip()
try:
arguments = json.loads(stripped) if stripped else {}
except json.JSONDecodeError:
return invalid_arguments
if arguments is not None and not isinstance(arguments, dict):
return invalid_arguments
available = await entry.server.list_tools()
valid_names = {mcp_tool.name for mcp_tool in available}
if tool not in valid_names:
offered = ", ".join(sorted(valid_names)) or "(none)"
return (
f"Unknown tool {tool!r} on MCP connection {connection!r}. "
f"Tools this connection offers: {offered}. "
"Call describe_mcp for their input schemas."
)
return await dispatch_mcp_call(
entry.server,
tool,
arguments or {},
label=namespaced_tool_name(connection, tool),
result_transform=entry.result_transform,
)

327
strix/tools/mcp/client.py Normal file
View File

@@ -0,0 +1,327 @@
"""Connect to MCP servers so a run can reach their tools on demand.
Given one :class:`McpConnectionConfig` per server, :func:`connect_mcp_servers`
connects each server, counts the tools it offers (honoring the connection's
allowlist), and returns the live sessions. It does NOT register anything as an
agent tool: under the generic-dispatch model the run holds these sessions in a
per-run :class:`~strix.tools.mcp.registry.McpRegistry`, and the agent reaches
them through the two dispatch tools (``describe_mcp`` / ``call_mcp``), which call
:func:`dispatch_mcp_call` here to run one tool and serialize its result.
A server that cannot connect is logged and skipped, so one bad connection never
fails the run.
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any, NamedTuple, cast
from agents.mcp import (
MCPServer,
MCPServerStdio,
MCPServerStdioParams,
MCPServerStreamableHttp,
MCPServerStreamableHttpParams,
create_static_tool_filter,
)
from mcp.client.stdio import stdio_client
if TYPE_CHECKING:
from collections.abc import Callable
from strix.tools.mcp.config import McpConnectionConfig
from strix.tools.mcp.registry import McpConnectionRequest, McpRegistry
# Runs on one tool call's structured result before it reaches the agent.
# Called ``result_transform(label, structured_result)`` and its return value
# becomes the tool's output. ``label`` is the model-facing
# ``<connection>_<tool>`` name so a transform keyed on names still resolves
# the same way it did under per-tool registration; ``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 offers.
``server`` is kept so the caller can clean it up when the run ends, and so
the caller can hand the live session to the run's
:class:`~strix.tools.mcp.registry.McpRegistry`; ``name`` and ``tool_count``
let the caller show the user a startup summary and fill the prompt inventory;
``notes`` carries the connection's optional free-text description so the
caller can surface it as the connection's purpose in the inventory.
"""
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}"}
@contextlib.asynccontextmanager
async def _quiet_stdio_streams(params: Any) -> Any:
"""Run a stdio MCP server with its stderr sent to the void.
A stdio MCP server chats on stderr as it boots (the filesystem server, for
one, prints ``Allowed directories: [ ... ]``). The mcp library forwards that
stderr to the parent's ``sys.stderr`` by default, which is the terminal the
TUI is drawing on, so the banner corrupts the display. Pointing ``errlog`` at
``os.devnull`` drops that chatter. Connection failures are unaffected: they
still raise from ``connect`` and are logged by :func:`connect_mcp_servers`.
"""
with Path(os.devnull).open("w", encoding="utf-8") as errlog:
async with stdio_client(params, errlog=errlog) as streams:
yield streams
class _QuietMCPServerStdio(MCPServerStdio):
"""``MCPServerStdio`` whose subprocess stderr is kept off the terminal.
The SDK's ``create_streams`` calls ``stdio_client(self.params)`` with no
``errlog``, so the subprocess stderr defaults to ``sys.stderr`` and paints
server banners over the running TUI. Overriding it lets us redirect that
stream; everything else about the stdio transport is unchanged.
"""
def create_streams(self) -> Any:
return _quiet_stdio_streams(self.params)
def _build_server(config: McpConnectionConfig) -> MCPServer:
"""Construct (but do not connect) the SDK server for one connection.
When ``allowed_tools`` is a list the static filter means the server will not
even list tools outside it, so it is the authoritative gate on what
``describe_mcp`` and ``call_mcp`` can see. When it is ``None`` no filter is
applied and every listed tool is reachable.
"""
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 _QuietMCPServerStdio(
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 _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 dispatch tool routes
its own call, this is what makes the agent see byte-identical content to what
the SDK would have produced building the tool itself.
"""
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
async def dispatch_mcp_call(
server: MCPServer,
tool_name: str,
arguments: dict[str, Any],
*,
label: str,
result_transform: ResultTransform | None = None,
) -> Any:
"""Run one MCP tool call and convert its result to a tool output.
Shared single dispatch point for the generic ``call_mcp`` tool. Calls
``server.call_tool`` with the tool's unprefixed name, then:
- with a ``result_transform`` (strix-pro's sanitizer), hands the parsed
:class:`CallToolResult` to it as ``result_transform(label, structured)`` and
returns whatever the transform returns; or
- without one, serializes the result the way the agents SDK does (see
:func:`_mcp_result_to_tool_output`) and, when the result is an MCP error,
normalizes it through :func:`_errored_tool_output` so the failure reaches
the interfaces (see that function for the representation and why it does not
corrupt the content the agent receives).
"""
result = await server.call_tool(tool_name, arguments)
if result_transform is not None:
return result_transform(label, result.model_dump(mode="json"))
tool_output = _mcp_result_to_tool_output(server, result)
if getattr(result, "isError", False):
return _errored_tool_output(tool_output)
return tool_output
def _errored_tool_output(tool_output: Any) -> dict[str, Any]:
"""Tag a serialized MCP error so the interfaces render it as failed.
Both the TUI and the run viewer decide a tool call failed by reading a
``success`` key off a top-level dict in the result (``success is False`` means
failed). :func:`_mcp_result_to_tool_output` returns a dict only for a single
content block; a structured-content result comes back as a string and a
multi-block result as a list, and on those the failure flag had nowhere to
ride, so the interfaces showed a failed call as done. This normalizes every
errored result to a top-level dict carrying ``success: False``:
- a single content block (already a dict) keeps its ``type``/``text`` and gains
``success: False`` alongside. The SDK's ToolOutput projection keeps the known
``type``/``text`` fields and drops ``success`` before the agent sees it, so
the agent still receives exactly the error content;
- a list (multiple blocks) or a string (structured content) is placed under a
stable ``content`` key so the flag has a top-level dict to ride on. The agent
still receives the full error content, under ``content``, rather than losing
it.
"""
if isinstance(tool_output, dict):
return {**tool_output, "success": False}
return {"success": False, "content": tool_output}
async def _count_server_tools(config: McpConnectionConfig, server: MCPServer) -> int:
"""Count a connected server's reachable tools for the startup summary.
``allowed_tools`` of ``None`` counts every listed tool; a list counts only
those names. The count matches what ``describe_mcp`` will show, because the
static tool filter built in :func:`_build_server` restricts the server's own
``list_tools`` to the same allowlist.
"""
allowed = config.allowed_tools
mcp_tools = await server.list_tools()
return sum(1 for mcp_tool in mcp_tools if allowed is None or mcp_tool.name in allowed)
async def connect_mcp_servers(
configs: list[McpConnectionConfig],
) -> list[ConnectedMcpServer]:
"""Connect to each MCP server and return its live session.
Returns one :class:`ConnectedMcpServer` per server that connected, carrying
the SDK server (so the caller can clean it up when the run ends and hand it to
the run's registry) plus the server name, how many tools it offers, and the
connection's notes. Connections that fail are skipped rather than raised.
Nothing is registered as an agent tool: the caller builds a per-run
:class:`~strix.tools.mcp.registry.McpRegistry` from these sessions, and the
agent reaches each tool on demand through ``describe_mcp`` / ``call_mcp``.
"""
connected: list[ConnectedMcpServer] = []
for config in configs:
server: MCPServer | None = None
try:
server = _build_server(config)
await server.connect() # type: ignore[no-untyped-call]
tool_count = await _count_server_tools(config, server)
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, tool_count)
connected.append(
ConnectedMcpServer(
server=server, name=config.name, tool_count=tool_count, notes=config.notes
)
)
return connected
async def attach_mcp_requests(
requests: list[McpConnectionRequest],
registry: McpRegistry,
) -> list[ConnectedMcpServer]:
"""Connect a caller's MCP requests and populate the run's registry.
The one shared attach-and-populate path both the command-line and the
SaaS/pro product go through, so all connecting and cleanup lives in one owner.
The caller supplies inert :class:`McpConnectionRequest` objects (a config plus
a provider label, an optional per-connection ``result_transform``, and an
optional ``purpose``) and never a live session: the engine connects each
config here, reusing :func:`connect_mcp_servers` so the fail-open behavior (a
connection that will not connect is logged and skipped) and the cancellation
cleanup are preserved unchanged.
For each connection that came up, this registers it under its config name with
its tool count, its ``provider`` label, its ``result_transform``, and a purpose
of ``request.purpose`` when set else the connection's notes. Returns the
connected servers (the runner records them and cleans them up when the run
ends).
"""
request_by_name = {request.config.name: request for request in requests}
connections = await connect_mcp_servers([request.config for request in requests])
for connection in connections:
request = request_by_name[connection.name]
registry.add(
name=connection.name,
server=connection.server,
tool_count=connection.tool_count,
purpose=request.purpose or connection.notes,
provider=request.provider,
result_transform=request.result_transform,
)
return connections

74
strix/tools/mcp/config.py Normal file
View File

@@ -0,0 +1,74 @@
"""The connection-config contract for the MCP client.
Describes one MCP server the client can connect to: its transport, endpoint or
launch command, optional auth, and an optional tool allowlist. Field names are
stable; callers build against them.
"""
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class BearerAuth(BaseModel):
"""Header-token auth, sent as ``Authorization: Bearer <token>``."""
model_config = ConfigDict(extra="forbid")
kind: Literal["bearer"] = "bearer"
token: str = Field(min_length=1, repr=False)
McpAuth = Annotated[BearerAuth, Field(discriminator="kind")]
class McpConnectionConfig(BaseModel):
"""One MCP server the client can connect to.
Two transports are supported: streamable ``http`` (a remote endpoint) and
``stdio`` (a local server launched as a subprocess).
"""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1)
"""Namespaced tool prefix, unique per run (e.g. ``github``)."""
transport: Literal["http", "stdio"] = "http"
"""``http`` for a streamable HTTP endpoint, ``stdio`` for a local subprocess."""
url: str | None = Field(default=None, min_length=1)
"""The MCP server endpoint. Required for ``http``."""
auth: McpAuth | None = None
"""Bearer token for the server. Optional; a local stdio server usually
needs none."""
command: str | None = Field(default=None, min_length=1)
"""The executable to launch for ``stdio``. Required for ``stdio``."""
args: list[str] = Field(default_factory=list)
"""Arguments passed to ``command`` (stdio only)."""
env: dict[str, str] = Field(default_factory=dict)
"""Extra environment variables for the stdio subprocess."""
allowed_tools: list[str] | None = None
"""Tool allowlist, applied after the server lists its tools. ``None`` (the
default) exposes every tool the server lists; a list restricts to it."""
notes: str | None = None
"""Free-text notes for the agent describing what this connection is and how
to use it. When set, the note becomes the connection's purpose line in the
MCP inventory every agent renders in its prompt, so it describes the
connection once rather than being repeated onto each of its tools."""
@model_validator(mode="after")
def _check_transport_fields(self) -> McpConnectionConfig:
if self.transport == "http" and not self.url:
raise ValueError("an http MCP connection requires 'url'")
if self.transport == "stdio" and not self.command:
raise ValueError("a stdio MCP connection requires 'command'")
return self

133
strix/tools/mcp/loader.py Normal file
View File

@@ -0,0 +1,133 @@
"""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 and connects to each
server, holding the live sessions in the run's registry for the agent to reach
on demand. The file is optional; without it the run simply gets no MCP
connections.
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.
A connection's name is its key in the run's registry, so two connections
sharing a name would collide and the second would overwrite the first. 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))

29
strix/tools/mcp/naming.py Normal file
View File

@@ -0,0 +1,29 @@
"""How an MCP server's tools are named for the model.
Kept apart from the client, and stdlib-only, so a caller can build the
model-facing name for a connection's tool without importing the MCP client (and
through it the agents SDK and every registered tool).
"""
from __future__ import annotations
import re
# 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}")

216
strix/tools/mcp/registry.py Normal file
View File

@@ -0,0 +1,216 @@
"""Per-run registry of the MCP connections a scan may reach.
Replaces per-tool registration. The old model turned every tool of every
connected MCP server into its own agent tool, so a run with a handful of
connections put dozens of provider tool schemas on the root agent's first LLM
request. Instead, a run holds its live connections here, keyed by the name the
user gave each connection, and every agent reaches them through three generic
dispatch tools: ``list_mcps`` to discover the available connections, ``describe_mcp``
to learn one connection's tool schemas on demand, and ``call_mcp`` to run one of
its tools.
One :class:`McpRegistry` is built per run in :mod:`strix.core.runner`, stored in
the run context under :data:`MCP_REGISTRY_CONTEXT_KEY`, and shared by the root
agent and every child (the child context is a copy of the parent's, so it
carries the same registry object).
strix-pro imports :class:`McpRegistry` to add its cloud connections into the
same registry and to attach a per-connection ``result_transform`` (its
sanitizer), which :func:`strix.tools.mcp.client.dispatch_mcp_call` applies at the
single dispatch point.
"""
from __future__ import annotations
import dataclasses
from typing import TYPE_CHECKING, Any, NamedTuple
if TYPE_CHECKING:
from agents.mcp import MCPServer
from strix.tools.mcp.client import ResultTransform
from strix.tools.mcp.config import McpConnectionConfig
# The run-context key under which the runner stores the per-run registry, and
# the two dispatch tools read it back. Kept here so the tools, the runner, and
# strix-pro all agree on one name.
MCP_REGISTRY_CONTEXT_KEY = "mcp_registry"
# The two connection-scoped dispatch tools an interface attributes to a specific
# MCP connection. ``call_mcp`` runs one tool on a connection; ``describe_mcp``
# lists a connection's tool schemas. (``list_mcps`` is deliberately not here: it
# names no single connection, so it renders as an ordinary tool call.) Kept here
# (not in the interface layer) so the engine, the OSS viewer, and strix-pro's
# tracer all recognise a connection-scoped dispatch call by the same names.
CALL_MCP_TOOL = "call_mcp"
DESCRIBE_MCP_TOOL = "describe_mcp"
MCP_DISPATCH_TOOLS = frozenset({CALL_MCP_TOOL, DESCRIBE_MCP_TOOL})
@dataclasses.dataclass(frozen=True)
class McpConnectionEntry:
"""One live MCP connection a scan may reach, keyed by ``name``.
``server`` is the connected SDK session the dispatch tools list tools on and
call tools through. ``purpose`` is the human label ``list_mcps`` reports as the
connection's description (the user's connection notes, or whatever the caller
supplies). ``tool_count`` is how many tools the connection offers, also
reported by ``list_mcps``. ``result_transform``, when set, runs on each call's structured result
at the single dispatch point (strix-pro's sanitizer uses it). ``provider`` is
an optional source label (e.g. ``"supabase"``) the caller tags the connection
with; the command-line path leaves it ``None``, and event tagging surfaces it
when set.
"""
server: MCPServer
name: str
purpose: str | None = None
tool_count: int = 0
result_transform: ResultTransform | None = None
provider: str | None = None
@dataclasses.dataclass(frozen=True)
class McpConnectionSummary:
"""One connection summary ``list_mcps`` returns: what an agent needs to decide
whether to ``describe_mcp`` a connection, with no tool schemas."""
name: str
purpose: str | None
tool_count: int
provider: str | None = None
@dataclasses.dataclass(frozen=True)
class McpConnectionRequest:
"""A source-agnostic request to attach one MCP connection to a run.
The caller hands the engine an inert ``config`` (how to reach the server, its
name, and any auth token) plus metadata, and never a live session: the engine
owns connecting and cleaning up. ``provider`` is an optional source label
(e.g. ``"supabase"``; empty for the command-line path). ``result_transform``
is an optional per-connection transform run on each call's structured result
at the single dispatch point (strix-pro's sanitizer; empty for the
command-line path). ``purpose`` is the human label ``list_mcps`` reports as the
connection's description; when unset it falls back to ``config.notes``.
"""
config: McpConnectionConfig
provider: str | None = None
result_transform: ResultTransform | None = None
purpose: str | None = None
class McpCallInfo(NamedTuple):
"""What one MCP dispatch call resolved to: the connection name, the
underlying tool (empty for ``describe_mcp``), and the connection's provider
label (``None`` when unknown or untagged)."""
connection: str
tool: str
provider: str | None
class McpRegistry:
"""Connection name -> live MCP connection, built per run and shared by every
agent in the run.
Public API (strix-pro builds against it): the constructor, :meth:`add`,
:meth:`get`, and :meth:`summaries`.
"""
def __init__(self) -> None:
self._entries: dict[str, McpConnectionEntry] = {}
def add(
self,
*,
name: str,
server: MCPServer,
purpose: str | None = None,
tool_count: int = 0,
result_transform: ResultTransform | None = None,
provider: str | None = None,
) -> McpConnectionEntry:
"""Register one connection under ``name`` (last write wins)."""
entry = McpConnectionEntry(
server=server,
name=name,
purpose=purpose,
tool_count=tool_count,
result_transform=result_transform,
provider=provider,
)
self._entries[name] = entry
return entry
def get(self, name: str) -> McpConnectionEntry | None:
"""The connection registered under ``name``, or ``None``."""
return self._entries.get(name)
def names(self) -> list[str]:
"""The registered connection names, in insertion order."""
return list(self._entries)
def summaries(self) -> list[McpConnectionSummary]:
"""One inventory summary per connection, in insertion order."""
return [
McpConnectionSummary(
name=entry.name,
purpose=entry.purpose,
tool_count=entry.tool_count,
provider=entry.provider,
)
for entry in self._entries.values()
]
def clear(self) -> None:
"""Drop every connection (the sessions themselves are closed by the
runner)."""
self._entries.clear()
def __len__(self) -> int:
return len(self._entries)
def __bool__(self) -> bool:
return bool(self._entries)
def resolve_mcp_call(
tool_name: str,
args: dict[str, Any],
registry: McpRegistry | None = None,
) -> McpCallInfo | None:
"""Resolve one tool call to the MCP connection/tool/provider it went out to.
The single resolver both the OSS viewer and strix-pro's tracer read a
dispatch call through, so a call is attributed the same way everywhere. Every
MCP call an agent makes goes through ``call_mcp`` or ``describe_mcp``, and the
connection (and, for ``call_mcp``, the server's own tool name) ride in the
call's ``args`` rather than the tool name, so they are read from there.
Returns ``None`` when ``tool_name`` is not one of the two dispatch tools, when
the call carries no connection name, or when a ``registry`` is supplied and
has no connection under that name. ``tool`` is the underlying tool for
``call_mcp`` and empty for ``describe_mcp`` (which inspects the connection
itself). ``provider`` comes from the registry entry; it is ``None`` when no
``registry`` is supplied (the viewer projects calls without one) or when the
connection carries no provider label.
"""
if tool_name not in MCP_DISPATCH_TOOLS:
return None
connection = args.get("connection")
if not isinstance(connection, str) or not connection:
return None
provider: str | None = None
if registry is not None:
entry = registry.get(connection)
if entry is None:
return None
provider = entry.provider
raw_tool = args.get("tool") if tool_name == CALL_MCP_TOOL else ""
tool = raw_tool if isinstance(raw_tool, str) else ""
return McpCallInfo(connection=connection, tool=tool, provider=provider)

View File

@@ -14,6 +14,8 @@ from typing import Any
from agents import RunContextWrapper, function_tool
from strix.tools.nullish import clean_optional
logger = logging.getLogger(__name__)
@@ -111,6 +113,9 @@ def _filter_notes(
tags: list[str] | None = None,
search_query: str | None = None,
) -> list[dict[str, Any]]:
category = clean_optional(category)
search_query = clean_optional(search_query)
filtered: list[dict[str, Any]] = []
for note_id, note in _notes_storage.items():
if category and note.get("category") != category:

23
strix/tools/nullish.py Normal file
View File

@@ -0,0 +1,23 @@
"""Nullish argument values passed by models in place of omitting an argument.
Models frequently send the literal string ``"null"`` / ``"none"`` for an
optional filter argument instead of leaving it out. Taken at face value it is
a filter that matches nothing, so the call quietly returns no results.
"""
from __future__ import annotations
NULLISH_STRINGS = frozenset({"null", "none", "nil", "undefined"})
def is_nullish(value: object) -> bool:
"""Whether ``value`` is a string standing in for "no value"."""
return isinstance(value, str) and value.strip().lower() in NULLISH_STRINGS
def clean_optional(value: str | None) -> str | None:
"""Normalize an optional filter argument: nullish or blank becomes ``None``."""
if value is None or is_nullish(value):
return None
return value.strip() or None

View File

@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Literal
from agents import RunContextWrapper, function_tool
from strix.runtime.caido_handle import CaidoBootstrapHandle
from strix.tools.nullish import clean_optional
from strix.tools.proxy import caido_api
@@ -167,6 +168,10 @@ async def list_requests(
if client is None:
return _no_client()
httpql_filter = clean_optional(httpql_filter)
after = clean_optional(after)
scope_id = clean_optional(scope_id)
try:
connection = await _call(
client,
@@ -472,6 +477,8 @@ async def list_sitemap(
client = await _ctx_client(ctx)
if client is None:
return _no_client()
scope_id = clean_optional(scope_id)
parent_id = clean_optional(parent_id)
try:
payload = await _call(
client,

View File

@@ -16,6 +16,8 @@ from typing import Any
from agents import RunContextWrapper, function_tool
from strix.tools.nullish import clean_optional
logger = logging.getLogger(__name__)
@@ -1605,12 +1607,12 @@ def _do_list_reports(
caller_agent_id: str | None = None,
) -> dict[str, Any]:
errors: list[str] = []
severity = (severity or "").strip().lower() or None
severity = (clean_optional(severity) or "").lower() or None
if severity and severity not in _VALID_SEVERITIES:
errors.append(
f"Invalid severity: {severity!r}. Must be one of: {sorted(_VALID_SEVERITIES)}"
)
finding_class = (finding_class or "").strip().lower() or None
finding_class = (clean_optional(finding_class) or "").lower() or None
if finding_class and finding_class not in _VALID_FINDING_CLASSES:
errors.append(
f"Invalid finding_class: {finding_class!r}. "
@@ -1640,8 +1642,8 @@ def _do_list_reports(
r,
severity=severity,
finding_class=finding_class,
target=(target or "").strip() or None,
search=(search or "").strip() or None,
target=clean_optional(target),
search=clean_optional(search),
)
]
matched.sort(key=lambda r: (_report_severity_rank(r), str(r.get("id", ""))))

26
tests/conftest.py Normal file
View File

@@ -0,0 +1,26 @@
"""Shared test fixtures."""
from __future__ import annotations
import pytest
@pytest.fixture(autouse=True)
def _isolate_mcp_config(
monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
) -> None:
"""Keep the whole suite from reading the developer's real MCP config.
``run_strix_scan`` connects the MCP servers listed in
``~/.strix/mcp-servers.json`` and threads an inventory of them into the
prompt context. Without isolation, any test that drives the runner on a
machine that has a real config would do real network I/O and see MCP
connections it never asked for. Point the loader at a path that does not
exist so it resolves to "no connections", and clear the per-run selection
env vars. Tests that exercise the loader itself set their own
``STRIX_MCP_CONFIG`` after this runs and so override it.
"""
missing = tmp_path_factory.mktemp("mcp-isolation") / "no-servers.json"
monkeypatch.setenv("STRIX_MCP_CONFIG", str(missing))
monkeypatch.delenv("STRIX_MCP_ONLY", raising=False)
monkeypatch.delenv("STRIX_MCP_EXCLUDE", raising=False)

View File

@@ -9,24 +9,30 @@ import pytest
from agents.tool import FunctionTool
from strix.agents import factory
from strix.tools.notes.tools import list_notes
from strix.tools.reporting.tool import list_reports
def _capturing_tool(captured: dict[str, str], schema: dict[str, Any]) -> FunctionTool:
def _capturing_tool(
captured: dict[str, str], schema: dict[str, Any], name: str = "probe"
) -> FunctionTool:
async def invoke(_ctx: Any, raw_input: str) -> str:
captured["raw_input"] = raw_input
return "ok"
return FunctionTool(
name="probe",
name=name,
description="test tool",
params_json_schema={"type": "object", "properties": schema},
on_invoke_tool=invoke,
)
async def _roundtrip(schema: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
async def _roundtrip(
schema: dict[str, Any], payload: dict[str, Any], name: str = "probe"
) -> dict[str, Any]:
captured: dict[str, str] = {}
wrapped = factory._with_coerced_arguments(_capturing_tool(captured, schema))
wrapped = factory._with_coerced_arguments(_capturing_tool(captured, schema, name))
assert await wrapped.on_invoke_tool(cast("Any", None), json.dumps(payload)) == "ok"
return cast("dict[str, Any]", json.loads(captured["raw_input"]))
@@ -144,3 +150,88 @@ async def test_coercion_is_applied_once_per_tool() -> None:
tool = factory._with_coerced_arguments(_capturing_tool(captured, _ARRAY))
assert factory._with_coerced_arguments(tool) is tool
_NULLABLE_STRING = {"category": {"anyOf": [{"type": "string"}, {"type": "null"}]}}
_NULLABLE_CONTENT = {"content": {"anyOf": [{"type": "string"}, {"type": "null"}]}}
_NULLABLE_STRING_TYPE_LIST = {"category": {"type": ["string", "null"]}}
@pytest.mark.asyncio
@pytest.mark.parametrize("schema", [_NULLABLE_STRING, _NULLABLE_STRING_TYPE_LIST])
@pytest.mark.parametrize("value", ["null", "none", "NULL", " None ", "nil", "undefined"])
async def test_nullish_string_on_a_nullable_parameter_becomes_none(
schema: dict[str, Any], value: str
) -> None:
parsed = await _roundtrip(schema, {"category": value}, "list_probes")
assert parsed["category"] is None
@pytest.mark.asyncio
async def test_nullish_string_on_a_required_parameter_is_untouched() -> None:
schema = {"content": {"type": "string"}}
captured: dict[str, str] = {}
tool = _capturing_tool(captured, schema, "list_probes")
tool.params_json_schema["required"] = ["content"]
wrapped = factory._with_coerced_arguments(tool)
assert await wrapped.on_invoke_tool(cast("Any", None), json.dumps({"content": "none"})) == "ok"
assert json.loads(captured["raw_input"])["content"] == "none"
@pytest.mark.asyncio
async def test_a_parameter_absent_from_required_is_treated_as_nullable() -> None:
captured: dict[str, str] = {}
tool = _capturing_tool(captured, {"category": {"type": "string"}}, "list_probes")
tool.params_json_schema["required"] = []
wrapped = factory._with_coerced_arguments(tool)
assert await wrapped.on_invoke_tool(cast("Any", None), json.dumps({"category": "null"})) == "ok"
assert json.loads(captured["raw_input"])["category"] is None
@pytest.mark.asyncio
async def test_nullish_string_without_a_required_list_is_untouched() -> None:
parsed = await _roundtrip(_STRING, {"todos": "none"}, "list_probes")
assert parsed["todos"] == "none"
@pytest.mark.asyncio
@pytest.mark.parametrize("name", ["update_note", "create_note", "record_coverage"])
@pytest.mark.parametrize("value", ["null", "none"])
async def test_a_nullish_value_survives_on_a_tool_that_writes(name: str, value: str) -> None:
parsed = await _roundtrip(_NULLABLE_CONTENT, {"content": value}, name)
assert parsed["content"] == value
@pytest.mark.asyncio
async def test_nullish_looking_content_is_not_coerced() -> None:
parsed = await _roundtrip(
_NULLABLE_STRING, {"category": "none of the endpoints reflect input"}, "list_probes"
)
assert parsed["category"] == "none of the endpoints reflect input"
@pytest.mark.asyncio
async def test_empty_string_on_a_nullable_string_parameter_is_untouched() -> None:
parsed = await _roundtrip(_NULLABLE_STRING, {"category": ""})
assert parsed["category"] == ""
@pytest.mark.asyncio
async def test_nullish_string_on_a_nullable_array_parameter_becomes_none() -> None:
parsed = await _roundtrip(_NULLABLE_ARRAY, {"tags": "null"}, "list_probes")
assert parsed["tags"] is None
def test_real_tool_schemas_declare_optional_filters_as_nullable() -> None:
for tool, params in ((list_notes, ("category", "search")), (list_reports, ("target",))):
schema = tool.params_json_schema
for param in params:
assert factory._is_nullable(param, schema["properties"][param], schema)

View File

@@ -0,0 +1,88 @@
"""Tests for the --mcp-config CLI flag."""
from __future__ import annotations
import importlib
import os
import sys
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
from pathlib import Path
cli_main: Any = importlib.import_module("strix.interface.main")
def _stub_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
cli_main,
"load_settings",
lambda: SimpleNamespace(runtime=SimpleNamespace(max_local_copy_mb=1024)),
)
def test_mcp_config_flag_sets_loader_override(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = tmp_path / "servers.json"
config.write_text("[]", encoding="utf-8")
_stub_settings(monkeypatch)
# delenv records "originally absent" so monkeypatch removes whatever the
# parser sets, keeping the override from leaking into other tests.
monkeypatch.delenv("STRIX_MCP_CONFIG", raising=False)
monkeypatch.setattr(
sys, "argv", ["strix", "-t", "https://test.com/", "-n", "--mcp-config", str(config)]
)
args = cli_main.parse_arguments()
assert args.mcp_config == str(config)
assert os.environ["STRIX_MCP_CONFIG"] == str(config)
def test_mcp_config_flag_rejects_missing_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
_stub_settings(monkeypatch)
monkeypatch.delenv("STRIX_MCP_CONFIG", raising=False)
missing = tmp_path / "nope.json"
monkeypatch.setattr(
sys, "argv", ["strix", "-t", "https://test.com/", "-n", "--mcp-config", str(missing)]
)
with pytest.raises(SystemExit):
cli_main.parse_arguments()
assert "--mcp-config file not found" in capsys.readouterr().err
def test_mcp_server_flags_set_selection_env(monkeypatch: pytest.MonkeyPatch) -> None:
_stub_settings(monkeypatch)
monkeypatch.delenv("STRIX_MCP_ONLY", raising=False)
monkeypatch.delenv("STRIX_MCP_EXCLUDE", raising=False)
monkeypatch.setattr(
sys,
"argv",
[
"strix",
"-t",
"https://test.com/",
"-n",
"--mcp-server",
"a",
"--mcp-server",
"b",
"--mcp-exclude",
"c",
],
)
cli_main.parse_arguments()
assert os.environ["STRIX_MCP_ONLY"] == "a,b"
assert os.environ["STRIX_MCP_EXCLUDE"] == "c"

View File

@@ -357,3 +357,25 @@ def test_get_report_no_state_returns_error(monkeypatch: pytest.MonkeyPatch) -> N
result = _do_get_report("vuln-0001")
assert result["success"] is False
assert result["report"] is None
@pytest.mark.parametrize("nullish", ["null", "none", "NULL", " None ", "undefined", "nil"])
def test_list_reports_ignores_nullish_filter_strings(
report_state: ReportState, nullish: str
) -> None:
_seed(report_state)
unfiltered = _do_list_reports(
severity=None, finding_class=None, target=None, search=None, include_details=False
)
assert unfiltered["filtered_count"] == 3
assert (
_do_list_reports(
severity=nullish,
finding_class=nullish,
target=nullish,
search=nullish,
include_details=False,
)
== unfiltered
)

1094
tests/test_mcp_client.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -101,3 +101,33 @@ def test_get_note_flags_caller_ownership() -> None:
assert mine["note"]["agent_name"] == "Agent One"
theirs = notes_tools._get_note_impl(note_id, caller_agent_id="agent-9")
assert "by_you" not in theirs["note"]
@pytest.mark.parametrize("nullish", ["null", "none", "NULL", " None ", "undefined", "nil"])
def test_list_notes_ignores_nullish_filter_strings(nullish: str) -> None:
notes_tools._create_note_impl("recon", "content", category="findings", tags=["auth"])
notes_tools._create_note_impl("other", "content", category="general")
unfiltered = notes_tools._list_notes_impl()
assert unfiltered["filtered_count"] == 2
assert notes_tools._list_notes_impl(category=nullish) == unfiltered
assert notes_tools._list_notes_impl(search=nullish) == unfiltered
@pytest.mark.parametrize("tag", ["null", "none"])
def test_list_notes_filters_on_a_literal_nullish_tag(tag: str) -> None:
notes_tools._create_note_impl("tagged", "content", tags=[tag])
notes_tools._create_note_impl("other", "content", tags=["auth"])
assert [n["title"] for n in notes_tools._list_notes_impl(tags=[tag])["notes"]] == ["tagged"]
mixed = notes_tools._list_notes_impl(tags=[tag, "auth"])
assert sorted(n["title"] for n in mixed["notes"]) == ["other", "tagged"]
def test_list_notes_still_filters_on_real_values() -> None:
notes_tools._create_note_impl("recon", "content", category="findings")
notes_tools._create_note_impl("other", "content", category="general")
result = notes_tools._list_notes_impl(category="findings")
assert [n["title"] for n in result["notes"]] == ["recon"]

142
tests/test_runner_mcp.py Normal file
View File

@@ -0,0 +1,142 @@
"""The runner attaches MCP connections source-agnostically.
When a caller supplies ``mcp_connection_requests`` the runner attaches those;
when it does not, the runner reads ``~/.strix/mcp-servers.json`` itself and wraps
each config in a bare request. Either way the one shared ``attach_mcp_requests``
routine does the connecting.
"""
from __future__ import annotations
import types
from typing import Any
import pytest
from agents import ModelSettings
import strix.tools.mcp as mcp_pkg
import strix.tools.notes.tools as notes_tools
import strix.tools.todo.tools as todo_tools
from strix.core import runner
from strix.core.agents import AgentCoordinator
from strix.runtime import session_manager
from strix.tools.mcp import McpConnectionConfig, McpConnectionRequest
def _settings() -> Any:
return types.SimpleNamespace(
llm=types.SimpleNamespace(
model="openai/gpt-4o",
reasoning_effort="high",
force_required_tool_choice=False,
timeout=300,
prompt_cache=True,
extra_headers=None,
),
runtime=types.SimpleNamespace(max_context_images=3),
)
def _wire_runner(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
monkeypatch.setattr(runner, "run_dir_for", lambda _scan_id: tmp_path)
monkeypatch.setattr(runner, "runtime_state_dir", lambda _run_dir: tmp_path)
monkeypatch.setattr(runner, "setup_scan_logging", lambda _run_dir: lambda: None)
monkeypatch.setattr(runner, "set_scan_id", lambda _scan_id: None)
monkeypatch.setattr(runner, "load_settings", _settings)
monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _s: None)
monkeypatch.setattr(runner, "uses_chat_completions_tool_schema", lambda _m, _s: False)
monkeypatch.setattr(todo_tools, "hydrate_todos_from_disk", lambda _d: None)
monkeypatch.setattr(notes_tools, "hydrate_notes_from_disk", lambda _d: None)
async def _create_or_reuse(*_a: Any, **_k: Any) -> dict[str, Any]:
return {"client": object(), "session": object(), "caido_client": None}
async def _cleanup(*_a: Any, **_k: Any) -> None:
return None
monkeypatch.setattr(session_manager, "create_or_reuse", _create_or_reuse)
monkeypatch.setattr(session_manager, "cleanup", _cleanup)
monkeypatch.setattr(runner, "build_root_task", lambda _c: "task")
monkeypatch.setattr(runner, "build_scope_context", lambda _c: {})
monkeypatch.setattr(runner, "make_model_settings", lambda *_a, **_k: ModelSettings())
monkeypatch.setattr(runner, "build_strix_agent", lambda **_k: object())
monkeypatch.setattr(runner, "make_child_factory", lambda **_k: lambda **_kk: object())
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
async def _run_agent_loop(**_kwargs: Any) -> None:
return None
monkeypatch.setattr(runner, "run_agent_loop", _run_agent_loop)
@pytest.mark.asyncio
async def test_none_default_attaches_from_the_user_config_file(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
_wire_runner(monkeypatch, tmp_path)
file_config = McpConnectionConfig(
name="local_fs", transport="stdio", command="npx", notes="local files"
)
monkeypatch.setattr(mcp_pkg, "load_user_mcp_configs", lambda: [file_config])
captured: list[list[McpConnectionRequest]] = []
async def _capture(requests: list[McpConnectionRequest], _registry: Any) -> list[Any]:
captured.append(requests)
return []
monkeypatch.setattr(mcp_pkg, "attach_mcp_requests", _capture)
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-none",
image="img",
coordinator=AgentCoordinator(),
)
# Each config from the file is wrapped in a bare request: no provider, no
# transform, no explicit purpose (purpose falls back to notes at attach time).
(requests,) = captured
assert len(requests) == 1
assert requests[0].config is file_config
assert requests[0].provider is None
assert requests[0].result_transform is None
assert requests[0].purpose is None
@pytest.mark.asyncio
async def test_supplied_requests_are_attached_and_the_user_file_is_not_read(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
_wire_runner(monkeypatch, tmp_path)
def _fail_if_read() -> list[Any]:
raise AssertionError("load_user_mcp_configs must not be read when requests are supplied")
monkeypatch.setattr(mcp_pkg, "load_user_mcp_configs", _fail_if_read)
captured: list[list[McpConnectionRequest]] = []
async def _capture(requests: list[McpConnectionRequest], _registry: Any) -> list[Any]:
captured.append(requests)
return []
monkeypatch.setattr(mcp_pkg, "attach_mcp_requests", _capture)
supplied = [
McpConnectionRequest(
config=McpConnectionConfig(name="db", url="https://mcp.example.com"),
provider="supabase",
)
]
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-supplied",
image="img",
coordinator=AgentCoordinator(),
mcp_connection_requests=supplied,
)
assert captured == [supplied]

View File

@@ -14,11 +14,13 @@ import pytest
from agents import ModelSettings
from openai import RateLimitError
import strix.tools.mcp as mcp_pkg
import strix.tools.notes.tools as notes_tools
import strix.tools.todo.tools as todo_tools
from strix.core import runner
from strix.core.agents import AgentCoordinator
from strix.runtime import session_manager
from strix.tools.mcp import BearerAuth, McpConnectionConfig, McpConnectionRequest
def _make_rate_limit_error() -> RateLimitError:
@@ -180,6 +182,72 @@ async def test_root_prompt_options_default_to_none(
assert kwargs["system_prompt_context"] == {"scope": "built-in"}
@pytest.mark.asyncio
async def test_mcp_available_flag_set_when_a_connection_attaches(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
) -> None:
"""When at least one MCP connection attaches, the runner sets ``mcp_available``
plus a named ``mcp_connections`` inventory into the scan context that reaches
every agent, so each agent sees which connections exist at the start while
still being able to re-list them at run time via list_mcps."""
scope_context: dict[str, Any] = {"scope": "built-in"}
captured = _patch_engine_scaffold(monkeypatch, tmp_path, scope_context)
async def _attach(_requests: Any, registry: Any) -> list[Any]:
registry.add(name="fs", server=object(), purpose="local files", tool_count=2)
return [types.SimpleNamespace(name="fs", tool_count=2, server=object())]
monkeypatch.setattr(mcp_pkg, "attach_mcp_requests", _attach)
request = McpConnectionRequest(
config=McpConnectionConfig(
name="fs",
url="https://mcp.example.com",
auth=BearerAuth(token="run-token"),
)
)
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-mcp-available",
image="img",
coordinator=AgentCoordinator(),
mcp_connection_requests=[request],
)
kwargs = captured["kwargs"]
assert kwargs["system_prompt_context"]["mcp_available"] is True
# The named inventory names each connected server for the prompt.
assert kwargs["system_prompt_context"]["mcp_connections"] == [
{"name": "fs", "purpose": "local files", "tool_count": 2}
]
@pytest.mark.asyncio
async def test_mcp_available_flag_absent_without_a_connection(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
) -> None:
"""With no MCP connection, the scan context carries no MCP key at all, so the
prompt's MCP section stays off."""
scope_context: dict[str, Any] = {"scope": "built-in"}
captured = _patch_engine_scaffold(monkeypatch, tmp_path, scope_context)
monkeypatch.setattr(mcp_pkg, "load_user_mcp_configs", list)
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-mcp-absent",
image="img",
coordinator=AgentCoordinator(),
)
kwargs = captured["kwargs"]
assert "mcp_available" not in kwargs["system_prompt_context"]
assert "mcp_connections" not in kwargs["system_prompt_context"]
@pytest.mark.asyncio
async def test_unknown_tool_calls_are_returned_to_the_model(
monkeypatch: pytest.MonkeyPatch,

View File

@@ -153,6 +153,33 @@ def test_self_update_already_latest(monkeypatch: pytest.MonkeyPatch) -> None:
assert update_check.self_update() is True
def test_restart_env_strips_pyinstaller_vars(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("_MEIPASS2", "/stale/_MEIold")
monkeypatch.setenv("_PYI_APPLICATION_HOME_DIR", "/stale/_MEIold")
monkeypatch.setenv("_PYI_ARCHIVE_FILE", "/old/strix")
monkeypatch.setenv("_PYI_PARENT_PROCESS_LEVEL", "1")
monkeypatch.setenv("SOME_OTHER_VAR", "kept")
env = update_check.restart_env()
assert "SOME_OTHER_VAR" in env
assert "_MEIPASS2" not in env
assert not any(key.startswith("_PYI_") for key in env)
def test_restart_env_restores_library_paths(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LD_LIBRARY_PATH", "/stale/_MEIold/lib")
monkeypatch.setenv("LD_LIBRARY_PATH_ORIG", "/usr/lib/custom")
monkeypatch.setenv("DYLD_LIBRARY_PATH", "/stale/_MEIold/lib")
monkeypatch.delenv("DYLD_LIBRARY_PATH_ORIG", raising=False)
env = update_check.restart_env()
assert env["LD_LIBRARY_PATH"] == "/usr/lib/custom"
assert "LD_LIBRARY_PATH_ORIG" not in env
assert "DYLD_LIBRARY_PATH" not in env
def test_sha256_file(tmp_path: Path) -> None:
path = tmp_path / "blob"
path.write_bytes(b"strix")