mirror of
https://github.com/usestrix/strix.git
synced 2026-08-21 10:48:59 +02:00
Add MCP docs and CLI polish: docs page, startup connect summary, --mcp-config flag, compact tool output
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
+2
-1
@@ -47,7 +47,8 @@
|
||||
"pages": [
|
||||
"integrations/github-actions",
|
||||
"integrations/ci-cd",
|
||||
"integrations/coding-agents"
|
||||
"integrations/coding-agents",
|
||||
"integrations/mcp"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
<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.
|
||||
</ParamField>
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
+17
-4
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+30
-21
@@ -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
|
||||
|
||||
@@ -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``."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+35
-73
@@ -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<routed:list_projects>"
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user