mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/usestrix/strix.git
synced 2026-09-20 16:13:44 +08:00
Compare commits
8 Commits
mcp-suppor
...
mcp-generi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaf4853415 | ||
|
|
c220e1fd31 | ||
|
|
181e83d3d6 | ||
|
|
e3f95d6cfd | ||
|
|
cb5691d6e2 | ||
|
|
bfaaa904f2 | ||
|
|
187f41f36f | ||
|
|
f4ef8867f6 |
24
README.md
24
README.md
@@ -320,6 +320,30 @@ strix auth status # show the active sign-in
|
||||
strix auth logout # forget the sign-in
|
||||
```
|
||||
|
||||
#### 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`
|
||||
|
||||
@@ -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
131
docs/integrations/mcp.mdx
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: "MCP Servers"
|
||||
description: "Connect your own MCP servers and expose their tools to the agent"
|
||||
---
|
||||
|
||||
Strix can connect to [Model Context Protocol (MCP)](https://modelcontextprotocol.io) servers you list and expose their tools to the agent during a run. Use this to let the agent read how your system is actually built instead of inferring it from the outside.
|
||||
|
||||
A few things it pays off for:
|
||||
|
||||
- **A database server.** The agent can read the schema and access policies and see tables left readable without them, rather than guessing from responses.
|
||||
- **A hosting or infrastructure server.** Deployments, domains and environment variable names tell it what is really running, so it tests what exists instead of what it discovered by crawling.
|
||||
- **An issue tracker.** Known and accepted risks stop the agent re-reporting findings you already triaged.
|
||||
- **A logging server.** Reading logs lets it confirm an exploit attempt actually landed instead of inferring it from a status code.
|
||||
|
||||
## Setup
|
||||
|
||||
Create the file `~/.strix/mcp-servers.json`. It holds a JSON list of the servers you want the agent to reach. Each entry is either a local `stdio` server that Strix launches as a subprocess, or a remote `http` server.
|
||||
|
||||
Create the directory if it does not exist, then write the file:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.strix
|
||||
```
|
||||
|
||||
Paste the servers you want into `~/.strix/mcp-servers.json`. The example below shows one of each transport: a local filesystem server over `stdio` and a remote GitHub server over `http` with a bearer token:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "local_fs",
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
|
||||
},
|
||||
{
|
||||
"name": "github",
|
||||
"transport": "http",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"auth": { "kind": "bearer", "token": "your-token" },
|
||||
"allowed_tools": ["list_issues"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Strix reads this file at the start of each run. There is no default file, so no MCP tools are loaded until you create it. Edit `command`, `args`, `url`, and `token` to match your own servers.
|
||||
|
||||
## Fields
|
||||
|
||||
<ParamField path="name" type="string" required>
|
||||
A short label for the connection. Each server's tools are namespaced by
|
||||
`name` (for example `local_fs_read_file`), so two servers can offer the same
|
||||
tool name without colliding.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="transport" type="string">
|
||||
`stdio` for a local subprocess server, or `http` for a remote server.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="command" type="string">
|
||||
For `stdio` servers: the executable Strix launches (for example `npx`).
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="args" type="array">
|
||||
For `stdio` servers: the arguments passed to `command`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="url" type="string">
|
||||
For `http` servers: the server endpoint URL.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="auth" type="object">
|
||||
For `http` servers that need a bearer token:
|
||||
`{ "kind": "bearer", "token": "your-token" }`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="allowed_tools" type="array">
|
||||
Restrict which tools the agent can call. Omit it to expose every tool the
|
||||
server offers, or set it to a list of tool names to allow only those. Strix
|
||||
does not decide for you which of a server's tools only read and which change
|
||||
things, so run the server in its own read-only mode if it has one.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="notes" type="string">
|
||||
Free-text notes for the agent about what this connection is and how you want
|
||||
it used, for example "Staging analytics database, read-only, prefer aggregate
|
||||
queries." When set, the notes are given to the agent at the start of the run
|
||||
as a description of the connection.
|
||||
</ParamField>
|
||||
|
||||
## Choosing connections per run
|
||||
|
||||
By default every connection in the file is used on each run. To narrow it for a
|
||||
single run without editing the file, use either flag (both repeatable):
|
||||
|
||||
```bash
|
||||
strix --mcp-server github -t ... # use only the named connection(s)
|
||||
strix --mcp-exclude staging-db -t ... # use everything except the named one(s)
|
||||
```
|
||||
|
||||
`--mcp-server` keeps only the connections you name; `--mcp-exclude` drops the
|
||||
ones you name. Connection names must be unique in the file; if two entries share
|
||||
a name, the first is kept and the rest are ignored.
|
||||
|
||||
## Pointing at a different file
|
||||
|
||||
To read the config from another path instead of `~/.strix/mcp-servers.json`, either pass `--mcp-config <path>` on the command line:
|
||||
|
||||
```bash
|
||||
strix --mcp-config ./mcp-servers.json -t ...
|
||||
```
|
||||
|
||||
or set the `STRIX_MCP_CONFIG` environment variable to that path. The flag takes precedence when both are given.
|
||||
|
||||
## Startup confirmation
|
||||
|
||||
When servers are configured, Strix prints a one-line summary at scan startup, for example `MCP: connected 1 server (14 tools): local_fs`, so you can confirm your servers connected.
|
||||
|
||||
## Seeing the calls
|
||||
|
||||
Each call the agent makes to one of your servers is shown with its own icon and
|
||||
labelled with the connection it went out to, in the terminal and in the run
|
||||
viewer (`strix view`), so a call that left Strix for a server you connected is
|
||||
easy to pick out of a transcript. The terminal shows the call and its arguments;
|
||||
results can be large and arbitrary, so read them in the viewer, which shows a
|
||||
preview you can expand.
|
||||
|
||||
## Behavior
|
||||
|
||||
- The config file is optional. Without it, a run simply gets no MCP tools.
|
||||
- A server that fails to connect is skipped and logged, and the run continues without it.
|
||||
- A single malformed entry is skipped without blocking the valid ones.
|
||||
@@ -241,6 +241,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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
47
strix/interface/tui/internal/render/mcp.go
Normal file
47
strix/interface/tui/internal/render/mcp.go
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -30,7 +30,7 @@ class RendererErrorBoundary extends Component<
|
||||
}
|
||||
|
||||
function SafeToolRenderer(props: ToolRendererProps) {
|
||||
const Renderer = getToolRenderer(props.toolName);
|
||||
const Renderer = getToolRenderer(props.toolName, props.mcpConnection);
|
||||
return (
|
||||
<RendererErrorBoundary toolName={props.toolName}>
|
||||
<Renderer {...props} />
|
||||
@@ -63,6 +63,10 @@ function coerce(value: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function asOptionalString(value: unknown): string | null {
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
const c = coerce(value);
|
||||
if (c && typeof c === "object" && !Array.isArray(c)) return c as Record<string, unknown>;
|
||||
@@ -244,11 +248,14 @@ export function AgentTranscript({
|
||||
const isTool = event.type === "tool";
|
||||
const toolName = isTool ? String(event.data?.tool_name ?? "tool") : "";
|
||||
const role = !isTool ? String(event.data?.role ?? "assistant") : "";
|
||||
// Present only on a call to one of the user's own MCP servers.
|
||||
const mcpConnection = asOptionalString(event.data?.mcp_connection);
|
||||
const mcpTool = asOptionalString(event.data?.mcp_tool);
|
||||
|
||||
let Icon;
|
||||
let iconColor: string;
|
||||
if (isTool) {
|
||||
const meta = getToolIcon(toolName);
|
||||
const meta = getToolIcon(toolName, mcpConnection);
|
||||
Icon = meta.icon;
|
||||
iconColor = meta.color;
|
||||
} else {
|
||||
@@ -279,6 +286,8 @@ export function AgentTranscript({
|
||||
{isTool ? (
|
||||
<SafeToolRenderer
|
||||
toolName={toolName}
|
||||
mcpConnection={mcpConnection}
|
||||
mcpTool={mcpTool}
|
||||
args={asRecord(event.data?.args)}
|
||||
result={coerce(event.data?.result) ?? null}
|
||||
status={
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
10
strix/interface/viewer/static/assets/index-D0453ODW.css
Normal file
10
strix/interface/viewer/static/assets/index-D0453ODW.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
54
strix/tools/mcp/__init__.py
Normal file
54
strix/tools/mcp/__init__.py
Normal 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",
|
||||
]
|
||||
173
strix/tools/mcp/agent_tools.py
Normal file
173
strix/tools/mcp/agent_tools.py
Normal 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
327
strix/tools/mcp/client.py
Normal 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
74
strix/tools/mcp/config.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""The connection-config contract for the MCP client.
|
||||
|
||||
Describes one MCP server the client can connect to: its transport, endpoint or
|
||||
launch command, optional auth, and an optional tool allowlist. Field names are
|
||||
stable; callers build against them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class BearerAuth(BaseModel):
|
||||
"""Header-token auth, sent as ``Authorization: Bearer <token>``."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: Literal["bearer"] = "bearer"
|
||||
token: str = Field(min_length=1, repr=False)
|
||||
|
||||
|
||||
McpAuth = Annotated[BearerAuth, Field(discriminator="kind")]
|
||||
|
||||
|
||||
class McpConnectionConfig(BaseModel):
|
||||
"""One MCP server the client can connect to.
|
||||
|
||||
Two transports are supported: streamable ``http`` (a remote endpoint) and
|
||||
``stdio`` (a local server launched as a subprocess).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1)
|
||||
"""Namespaced tool prefix, unique per run (e.g. ``github``)."""
|
||||
|
||||
transport: Literal["http", "stdio"] = "http"
|
||||
"""``http`` for a streamable HTTP endpoint, ``stdio`` for a local subprocess."""
|
||||
|
||||
url: str | None = Field(default=None, min_length=1)
|
||||
"""The MCP server endpoint. Required for ``http``."""
|
||||
|
||||
auth: McpAuth | None = None
|
||||
"""Bearer token for the server. Optional; a local stdio server usually
|
||||
needs none."""
|
||||
|
||||
command: str | None = Field(default=None, min_length=1)
|
||||
"""The executable to launch for ``stdio``. Required for ``stdio``."""
|
||||
|
||||
args: list[str] = Field(default_factory=list)
|
||||
"""Arguments passed to ``command`` (stdio only)."""
|
||||
|
||||
env: dict[str, str] = Field(default_factory=dict)
|
||||
"""Extra environment variables for the stdio subprocess."""
|
||||
|
||||
allowed_tools: list[str] | None = None
|
||||
"""Tool allowlist, applied after the server lists its tools. ``None`` (the
|
||||
default) exposes every tool the server lists; a list restricts to it."""
|
||||
|
||||
notes: str | None = None
|
||||
"""Free-text notes for the agent describing what this connection is and how
|
||||
to use it. When set, the 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
133
strix/tools/mcp/loader.py
Normal 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
29
strix/tools/mcp/naming.py
Normal 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
216
strix/tools/mcp/registry.py
Normal 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)
|
||||
@@ -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
23
strix/tools/nullish.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
26
tests/conftest.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
88
tests/test_cli_mcp_config.py
Normal file
88
tests/test_cli_mcp_config.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Tests for the --mcp-config CLI flag."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
cli_main: Any = importlib.import_module("strix.interface.main")
|
||||
|
||||
|
||||
def _stub_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
cli_main,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(runtime=SimpleNamespace(max_local_copy_mb=1024)),
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_config_flag_sets_loader_override(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = tmp_path / "servers.json"
|
||||
config.write_text("[]", encoding="utf-8")
|
||||
_stub_settings(monkeypatch)
|
||||
# delenv records "originally absent" so monkeypatch removes whatever the
|
||||
# parser sets, keeping the override from leaking into other tests.
|
||||
monkeypatch.delenv("STRIX_MCP_CONFIG", raising=False)
|
||||
monkeypatch.setattr(
|
||||
sys, "argv", ["strix", "-t", "https://test.com/", "-n", "--mcp-config", str(config)]
|
||||
)
|
||||
|
||||
args = cli_main.parse_arguments()
|
||||
|
||||
assert args.mcp_config == str(config)
|
||||
assert os.environ["STRIX_MCP_CONFIG"] == str(config)
|
||||
|
||||
|
||||
def test_mcp_config_flag_rejects_missing_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
_stub_settings(monkeypatch)
|
||||
monkeypatch.delenv("STRIX_MCP_CONFIG", raising=False)
|
||||
missing = tmp_path / "nope.json"
|
||||
monkeypatch.setattr(
|
||||
sys, "argv", ["strix", "-t", "https://test.com/", "-n", "--mcp-config", str(missing)]
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli_main.parse_arguments()
|
||||
|
||||
assert "--mcp-config file not found" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_mcp_server_flags_set_selection_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_stub_settings(monkeypatch)
|
||||
monkeypatch.delenv("STRIX_MCP_ONLY", raising=False)
|
||||
monkeypatch.delenv("STRIX_MCP_EXCLUDE", raising=False)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"strix",
|
||||
"-t",
|
||||
"https://test.com/",
|
||||
"-n",
|
||||
"--mcp-server",
|
||||
"a",
|
||||
"--mcp-server",
|
||||
"b",
|
||||
"--mcp-exclude",
|
||||
"c",
|
||||
],
|
||||
)
|
||||
|
||||
cli_main.parse_arguments()
|
||||
|
||||
assert os.environ["STRIX_MCP_ONLY"] == "a,b"
|
||||
assert os.environ["STRIX_MCP_EXCLUDE"] == "c"
|
||||
@@ -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
1094
tests/test_mcp_client.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
142
tests/test_runner_mcp.py
Normal 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]
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user