diff --git a/README.md b/README.md
index 903c73ba..96ac7517 100644
--- a/README.md
+++ b/README.md
@@ -333,11 +333,11 @@ Strix can connect to Model Context Protocol (MCP) servers you list and expose th
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
},
{
- "name": "vercel",
+ "name": "github",
"transport": "http",
- "url": "https://mcp.vercel.com",
+ "url": "https://api.githubcopilot.com/mcp/",
"auth": { "kind": "bearer", "token": "your-token" },
- "allowed_tools": ["list_projects"]
+ "allowed_tools": ["list_issues"]
}
]
```
diff --git a/docs/docs.json b/docs/docs.json
index de23c158..7573a0cd 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -47,7 +47,8 @@
"pages": [
"integrations/github-actions",
"integrations/ci-cd",
- "integrations/coding-agents"
+ "integrations/coding-agents",
+ "integrations/mcp"
]
},
{
diff --git a/docs/integrations/mcp.mdx b/docs/integrations/mcp.mdx
new file mode 100644
index 00000000..d2acca18
--- /dev/null
+++ b/docs/integrations/mcp.mdx
@@ -0,0 +1,92 @@
+---
+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 give the agent extra capabilities — reading files, querying an issue tracker, or any other tool a server offers.
+
+## 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
+
+
+ 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.
+
+
+
+ `stdio` for a local subprocess server, or `http` for a remote server.
+
+
+
+ For `stdio` servers: the executable Strix launches (for example `npx`).
+
+
+
+ For `stdio` servers: the arguments passed to `command`.
+
+
+
+ For `http` servers: the server endpoint URL.
+
+
+
+ For `http` servers that need a bearer token:
+ `{ "kind": "bearer", "token": "your-token" }`.
+
+
+
+ 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.
+
+
+## Pointing at a different file
+
+To read the config from another path instead of `~/.strix/mcp-servers.json`, either pass `--mcp-config ` 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.
+
+## 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.
diff --git a/strix/core/runner.py b/strix/core/runner.py
index c2985240..9dee9779 100644
--- a/strix/core/runner.py
+++ b/strix/core/runner.py
@@ -56,6 +56,7 @@ if TYPE_CHECKING:
from agents.result import RunResultBase
from strix.runtime.status import StatusSink
+ from strix.tools.mcp import ConnectedMcpServer
logger = logging.getLogger(__name__)
@@ -63,6 +64,16 @@ 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 _merge_root_prompt_context(
scope_context: dict[str, Any],
extra_system_prompt_context: dict[str, Any] | None,
@@ -301,15 +312,17 @@ async def run_strix_scan(
)
# Connect any MCP servers the user listed in ~/.strix/mcp-servers.json and
- # register their tools before the agent is built. These are the user's own
- # servers, so no result scrub is applied. Fully fail-open: a missing file or
- # a failed connection must never break a normal run (a managed run has no file).
+ # register their tools before the agent is built. Fail-open: a missing
+ # config, or a server that will not connect, must never break a run.
from strix.tools.mcp import connect_mcp_servers, load_user_mcp_configs
try:
user_mcp_configs = load_user_mcp_configs()
if user_mcp_configs:
- mcp_servers = await connect_mcp_servers(user_mcp_configs)
+ connections = await connect_mcp_servers(user_mcp_configs)
+ mcp_servers = [c.server for c in connections]
+ if connections:
+ report(_mcp_startup_summary(connections))
except Exception:
logger.exception("Failed to connect user MCP servers; continuing without them")
diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py
index d354106a..248bcb70 100644
--- a/strix/interface/cli_args.py
+++ b/strix/interface/cli_args.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import argparse
+import os
import sys
from pathlib import Path
@@ -219,6 +220,13 @@ 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(
"--max-budget",
"--max-budget-usd",
@@ -267,6 +275,14 @@ 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)
+
if args.update:
sys.exit(0 if self_update() else 1)
diff --git a/strix/interface/tui/internal/render/registry.go b/strix/interface/tui/internal/render/registry.go
index 3c235b0a..49bff1c8 100644
--- a/strix/interface/tui/internal/render/registry.go
+++ b/strix/interface/tui/internal/render/registry.go
@@ -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/MCP 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()
}
@@ -87,7 +88,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)
}
// ---------------------------------------------------------------------------
diff --git a/strix/interface/tui/internal/render/render_test.go b/strix/interface/tui/internal/render/render_test.go
index f9ed7f84..82b669cb 100644
--- a/strix/interface/tui/internal/render/render_test.go
+++ b/strix/interface/tui/internal/render/render_test.go
@@ -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,18 @@ func TestToolDispatchCoversKnownTools(t *testing.T) {
}
}
+func TestGenericToolOmitsRawResult(t *testing.T) {
+ // The generic/MCP renderer shows tool name, args, and a status line only —
+ // never the raw result payload.
+ 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 TestCollapseToolShellPreviewAndExpand(t *testing.T) {
lines := make([]string, 16)
for i := range lines {
diff --git a/strix/tools/mcp/__init__.py b/strix/tools/mcp/__init__.py
index 2449b5d8..ca204e51 100644
--- a/strix/tools/mcp/__init__.py
+++ b/strix/tools/mcp/__init__.py
@@ -2,9 +2,8 @@
from __future__ import annotations
-from strix.tools.mcp.client import connect_mcp_servers
+from strix.tools.mcp.client import ConnectedMcpServer, connect_mcp_servers
from strix.tools.mcp.config import (
- AwsSigV4Auth,
BearerAuth,
McpAuth,
McpConnectionConfig,
@@ -13,8 +12,8 @@ from strix.tools.mcp.loader import load_user_mcp_configs
__all__ = [
- "AwsSigV4Auth",
"BearerAuth",
+ "ConnectedMcpServer",
"McpAuth",
"McpConnectionConfig",
"connect_mcp_servers",
diff --git a/strix/tools/mcp/client.py b/strix/tools/mcp/client.py
index f4419db4..83aa93f4 100644
--- a/strix/tools/mcp/client.py
+++ b/strix/tools/mcp/client.py
@@ -15,7 +15,7 @@ from __future__ import annotations
import json
import logging
-from typing import TYPE_CHECKING, Any, cast
+from typing import TYPE_CHECKING, Any, NamedTuple, cast
from agents.exceptions import ModelBehaviorError
from agents.mcp import (
@@ -29,7 +29,6 @@ from agents.mcp import (
)
from strix.agents.factory import register_agent_tools
-from strix.tools.mcp.config import BearerAuth, McpConnectionConfig
if TYPE_CHECKING:
@@ -38,6 +37,8 @@ if TYPE_CHECKING:
from agents.tool import FunctionTool, Tool
from mcp.types import Tool as MCPTool
+ from strix.tools.mcp.config import McpConnectionConfig
+
# Runs on each tool's structured result before it reaches the agent. Called
# ``result_transform(namespaced_tool_name, structured_result)`` and its return
# value becomes the tool's output. ``structured_result`` is the parsed
@@ -49,23 +50,24 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
+class ConnectedMcpServer(NamedTuple):
+ """One successfully connected MCP server and how many tools it registered.
+
+ ``server`` is kept so the caller can clean it up when the run ends;
+ ``name`` and ``tool_count`` let the caller show the user a startup summary.
+ """
+
+ server: MCPServer
+ name: str
+ tool_count: int
+
+
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 {}
- if isinstance(auth, BearerAuth):
- return {"Authorization": f"Bearer {auth.token}"}
-
- # The only other variant is AWS SigV4.
- # TODO: AWS SigV4 transport and auth are UNVERIFIED. Confirm how the target
- # AWS MCP server is reached (stdio vs streamable HTTP) and how it accepts
- # SigV4-signed requests before enabling this branch. Do not fabricate request
- # signing here.
- raise NotImplementedError(
- "AWS SigV4 MCP auth is not verified yet; confirm the server's transport "
- "and request signing before connecting an aws_sigv4 connection."
- )
+ return {"Authorization": f"Bearer {auth.token}"}
def _build_server(config: McpConnectionConfig) -> MCPServer:
@@ -209,7 +211,7 @@ async def _register_server_tools(
async def connect_mcp_servers(
configs: list[McpConnectionConfig],
result_transform: ResultTransform | None = None,
-) -> list[MCPServer]:
+) -> list[ConnectedMcpServer]:
"""Connect to each MCP server and register its tools.
When ``result_transform`` is given, every registered tool routes its result
@@ -217,21 +219,28 @@ async def connect_mcp_servers(
:func:`_install_result_transform`). When it is ``None`` the tools behave
exactly as the SDK builds them.
- Returns the servers that connected, so the caller can clean them up when the
- run ends. Connections that fail are skipped rather than raised.
+ Returns one :class:`ConnectedMcpServer` per server that connected, carrying
+ the SDK server (so the caller can clean it up when the run ends) plus the
+ server name and how many tools it registered (so the caller can show the
+ user a startup summary). Connections that fail are skipped rather than
+ raised.
"""
- connected: list[MCPServer] = []
+ connected: list[ConnectedMcpServer] = []
for config in configs:
- server = _build_server(config)
+ server: MCPServer | None = None
try:
+ server = _build_server(config)
await server.connect() # type: ignore[no-untyped-call]
tools = await _register_server_tools(config, server, result_transform)
except Exception:
logger.exception("Skipping MCP connection %r", config.name)
- await server.cleanup() # type: ignore[no-untyped-call]
+ if server is not None:
+ await server.cleanup() # type: ignore[no-untyped-call]
continue
logger.info("Connected MCP server %r (%d tools)", config.name, len(tools))
- connected.append(server)
+ connected.append(
+ ConnectedMcpServer(server=server, name=config.name, tool_count=len(tools))
+ )
return connected
diff --git a/strix/tools/mcp/config.py b/strix/tools/mcp/config.py
index 2df1588b..15acbeaa 100644
--- a/strix/tools/mcp/config.py
+++ b/strix/tools/mcp/config.py
@@ -21,24 +21,7 @@ class BearerAuth(BaseModel):
token: str = Field(min_length=1, repr=False)
-class AwsSigV4Auth(BaseModel):
- """Request-signing auth for AWS.
-
- AWS does not authenticate with a header token; each request is signed. The
- temporary key is minted per run and passed out of band, never read from the
- ambient environment.
- """
-
- model_config = ConfigDict(extra="forbid")
-
- kind: Literal["aws_sigv4"] = "aws_sigv4"
- access_key_id: str = Field(min_length=1, repr=False)
- secret_access_key: str = Field(min_length=1, repr=False)
- session_token: str | None = Field(default=None, repr=False)
- region: str = Field(min_length=1)
-
-
-McpAuth = Annotated[BearerAuth | AwsSigV4Auth, Field(discriminator="kind")]
+McpAuth = Annotated[BearerAuth, Field(discriminator="kind")]
class McpConnectionConfig(BaseModel):
@@ -60,8 +43,8 @@ class McpConnectionConfig(BaseModel):
"""The MCP server endpoint. Required for ``http``."""
auth: McpAuth | None = None
- """Bearer token or AWS SigV4 signing material. Optional; a local stdio
- server usually needs 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``."""
diff --git a/strix/tools/mcp/loader.py b/strix/tools/mcp/loader.py
index 1bc45c18..fa7ccb17 100644
--- a/strix/tools/mcp/loader.py
+++ b/strix/tools/mcp/loader.py
@@ -2,8 +2,8 @@
An open-source user lists the MCP servers they want the agent to reach in a
small JSON file. Strix reads it at the start of a run, connects to each server,
-and registers its tools. The file is optional: a managed/saas run simply won't
-have one, which is fine.
+and registers its tools. The file is optional; without it the run simply gets
+no MCP tools.
Parsing is fail-open. A single malformed entry is logged and skipped rather than
raising, so one bad row never blocks the servers that are valid, and a missing
diff --git a/tests/test_cli_mcp_config.py b/tests/test_cli_mcp_config.py
new file mode 100644
index 00000000..12de41d4
--- /dev/null
+++ b/tests/test_cli_mcp_config.py
@@ -0,0 +1,61 @@
+"""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
diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py
index edece027..19f66426 100644
--- a/tests/test_mcp_client.py
+++ b/tests/test_mcp_client.py
@@ -13,7 +13,6 @@ from pydantic import ValidationError
from strix.agents import factory
from strix.tools.mcp import (
- AwsSigV4Auth,
BearerAuth,
McpConnectionConfig,
load_user_mcp_configs,
@@ -102,39 +101,17 @@ def _reset_registry() -> Any:
def test_bearer_config_parses_from_dict() -> None:
config = McpConnectionConfig.model_validate(
{
- "name": "vercel_main",
+ "name": "files_main",
"transport": "http",
"url": "https://mcp.example.com",
"auth": {"kind": "bearer", "token": "abc"},
- "allowed_tools": ["list_projects"],
+ "allowed_tools": ["list_files"],
}
)
assert isinstance(config.auth, BearerAuth)
assert config.auth.token == "abc"
- assert config.allowed_tools == ["list_projects"]
-
-
-def test_aws_sigv4_config_parses_from_dict() -> None:
- config = McpConnectionConfig.model_validate(
- {
- "name": "aws_production",
- "url": "https://mcp.example.com",
- "auth": {
- "kind": "aws_sigv4",
- "access_key_id": "AKIA",
- "secret_access_key": "secret",
- "session_token": "session",
- "region": "us-east-1",
- },
- }
- )
-
- assert isinstance(config.auth, AwsSigV4Auth)
- assert config.auth.region == "us-east-1"
- # transport defaults to http, allowed_tools to None ("all tools").
- assert config.transport == "http"
- assert config.allowed_tools is None
+ assert config.allowed_tools == ["list_files"]
def test_unknown_auth_kind_is_rejected() -> None:
@@ -216,26 +193,11 @@ def test_unknown_field_is_rejected() -> None:
def test_bearer_auth_builds_authorization_header() -> None:
- headers = _auth_headers(_config("vercel_main", []))
+ headers = _auth_headers(_config("files_main", []))
assert headers == {"Authorization": "Bearer run-token"}
-def test_aws_sigv4_auth_is_not_implemented_yet() -> None:
- config = McpConnectionConfig(
- name="aws_production",
- url="https://mcp.example.com",
- auth=AwsSigV4Auth(
- access_key_id="AKIA",
- secret_access_key="secret",
- region="us-east-1",
- ),
- )
-
- with pytest.raises(NotImplementedError):
- _auth_headers(config)
-
-
# --- namespacing and filtering -----------------------------------------------
@@ -258,15 +220,15 @@ async def test_tools_are_namespaced_per_connection() -> None:
@pytest.mark.asyncio
async def test_disallowed_tool_is_not_registered() -> None:
server = FakeMCPServer(
- "vercel_main",
- [_mcp_tool("list_projects"), _mcp_tool("delete_project")],
+ "files_main",
+ [_mcp_tool("list_files"), _mcp_tool("search")],
)
- await _register_server_tools(_config("vercel_main", ["list_projects"]), server)
+ await _register_server_tools(_config("files_main", ["list_files"]), server)
names = _registered_names()
- assert "vercel_main.list_projects" in names
- assert "vercel_main.delete_project" not in names
+ assert "files_main.list_files" in names
+ assert "files_main.search" not in names
@pytest.mark.asyncio
@@ -299,18 +261,18 @@ async def test_allowed_tools_list_restricts_registration() -> None:
@pytest.mark.asyncio
async def test_registered_tool_routes_to_its_server_with_the_original_name() -> None:
- server = FakeMCPServer("vercel_main", [_mcp_tool("list_projects")])
+ server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
tools: list[Tool] = await _register_server_tools(
- _config("vercel_main", ["list_projects"]), server
+ _config("files_main", ["list_files"]), server
)
tool = tools[0]
output = await tool.on_invoke_tool(None, "{}") # type: ignore[union-attr]
# The call reaches the right server, addressed by the unprefixed remote name.
- assert server.calls == [("list_projects", {})]
- assert output == {"type": "text", "text": "routed:list_projects"}
+ assert server.calls == [("list_files", {})]
+ assert output == {"type": "text", "text": "routed:list_files"}
# --- result transform --------------------------------------------------------
@@ -318,64 +280,64 @@ async def test_registered_tool_routes_to_its_server_with_the_original_name() ->
@pytest.mark.asyncio
async def test_result_transform_receives_namespaced_name_and_structured_result() -> None:
- server = FakeMCPServer("vercel_main", [_mcp_tool("list_projects")])
+ server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
seen: list[tuple[str, Any]] = []
def transform(name: str, structured: Any) -> Any:
seen.append((name, structured))
- return "scrubbed"
+ return {"kept": structured["content"][0]["text"]}
tools: list[Tool] = await _register_server_tools(
- _config("vercel_main", ["list_projects"]), server, result_transform=transform
+ _config("files_main", ["list_files"]), server, result_transform=transform
)
output = await tools[0].on_invoke_tool(None, "{}") # type: ignore[union-attr]
# The underlying MCP call still routes by the unprefixed remote name.
- assert server.calls == [("list_projects", {})]
+ assert server.calls == [("list_files", {})]
# The transform is called with the namespaced name and the parsed result.
assert len(seen) == 1
name, structured = seen[0]
- assert name == "vercel_main.list_projects"
+ assert name == "files_main.list_files"
# A parsed CallToolResult (dict/list), not a pre-serialized string.
- assert structured["content"][0]["text"] == "routed:list_projects"
+ assert structured["content"][0]["text"] == "routed:list_files"
assert structured["isError"] is False
# The transform's return value is exactly what the tool yields.
- assert output == "scrubbed"
+ assert output == {"kept": "routed:list_files"}
@pytest.mark.asyncio
async def test_result_transform_can_rewrite_the_tool_output() -> None:
- server = FakeMCPServer("vercel_main", [_mcp_tool("list_projects")])
+ server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
def transform(_name: str, structured: Any) -> Any:
- # Withhold everything but a redacted view of the text field.
- return f"redacted<{structured['content'][0]['text']}>"
+ # Keep only a truncated view of the text field.
+ return structured["content"][0]["text"][:6]
tools: list[Tool] = await _register_server_tools(
- _config("vercel_main", ["list_projects"]), server, result_transform=transform
+ _config("files_main", ["list_files"]), server, result_transform=transform
)
output = await tools[0].on_invoke_tool(None, "{}") # type: ignore[union-attr]
- assert output == "redacted"
+ assert output == "routed"
@pytest.mark.asyncio
async def test_without_result_transform_output_is_unchanged() -> None:
- server = FakeMCPServer("vercel_main", [_mcp_tool("list_projects")])
+ server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
tools: list[Tool] = await _register_server_tools(
- _config("vercel_main", ["list_projects"]), server, result_transform=None
+ _config("files_main", ["list_files"]), server, result_transform=None
)
output = await tools[0].on_invoke_tool(None, "{}") # type: ignore[union-attr]
# Same shape the SDK produces today: no transform in the path.
- assert server.calls == [("list_projects", {})]
- assert output == {"type": "text", "text": "routed:list_projects"}
+ assert server.calls == [("list_files", {})]
+ assert output == {"type": "text", "text": "routed:list_files"}
# --- server build branch -----------------------------------------------------
@@ -401,10 +363,10 @@ def test_build_server_stdio_branch() -> None:
def test_build_server_http_branch() -> None:
- server = _build_server(_config("vercel_main", ["list_projects"]))
+ server = _build_server(_config("files_main", ["list_files"]))
assert isinstance(server, MCPServerStreamableHttp)
- assert server.name == "vercel_main"
+ assert server.name == "files_main"
# --- loader ------------------------------------------------------------------
@@ -422,11 +384,11 @@ def test_loader_parses_stdio_and_http_entries(tmp_path: Path) -> None:
"args": ["-y", "server-filesystem"],
},
{
- "name": "vercel_main",
+ "name": "files_main",
"transport": "http",
"url": "https://mcp.example.com",
"auth": {"kind": "bearer", "token": "abc"},
- "allowed_tools": ["list_projects"],
+ "allowed_tools": ["list_files"],
},
]
),
@@ -435,9 +397,9 @@ def test_loader_parses_stdio_and_http_entries(tmp_path: Path) -> None:
configs = load_user_mcp_configs(config_file)
- assert [c.name for c in configs] == ["local_fs", "vercel_main"]
+ assert [c.name for c in configs] == ["local_fs", "files_main"]
assert configs[0].transport == "stdio"
- assert configs[1].allowed_tools == ["list_projects"]
+ assert configs[1].allowed_tools == ["list_files"]
def test_loader_skips_bad_entry_but_keeps_good_ones(tmp_path: Path) -> None: