From 8fb83f52b14f7454418764f85cf186ae3cd2c4e8 Mon Sep 17 00:00:00 2001 From: Jonathan Singer Date: Thu, 20 Aug 2026 15:39:50 -0400 Subject: [PATCH] Add MCP docs and CLI polish: docs page, startup connect summary, --mcp-config flag, compact tool output --- README.md | 6 +- docs/docs.json | 3 +- docs/integrations/mcp.mdx | 92 +++++++++++++++ strix/core/runner.py | 21 +++- strix/interface/cli_args.py | 16 +++ .../interface/tui/internal/render/registry.go | 19 +-- .../tui/internal/render/render_test.go | 14 ++- strix/tools/mcp/__init__.py | 5 +- strix/tools/mcp/client.py | 51 +++++---- strix/tools/mcp/config.py | 23 +--- strix/tools/mcp/loader.py | 4 +- tests/test_cli_mcp_config.py | 61 ++++++++++ tests/test_mcp_client.py | 108 ++++++------------ 13 files changed, 286 insertions(+), 137 deletions(-) create mode 100644 docs/integrations/mcp.mdx create mode 100644 tests/test_cli_mcp_config.py 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: