4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
187 lines
7.0 KiB
Python
187 lines
7.0 KiB
Python
"""Unit tests for the X (Twitter) MCP integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from integrations.catalog import classify_integrations as _classify_integrations
|
|
from integrations.x_mcp import (
|
|
DEFAULT_X_MCP_URL,
|
|
XMCPConfig,
|
|
build_x_mcp_config,
|
|
describe_x_mcp_error,
|
|
validate_x_mcp_config,
|
|
x_mcp_config_from_env,
|
|
x_mcp_runtime_unavailable_reason,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# XMCPConfig
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestXMCPConfig:
|
|
def test_defaults_to_local_streamable_http(self) -> None:
|
|
config = XMCPConfig()
|
|
assert config.mode == "streamable-http"
|
|
assert config.url == DEFAULT_X_MCP_URL
|
|
assert config.is_configured is True
|
|
|
|
def test_streamable_http_requires_url(self) -> None:
|
|
with pytest.raises(ValidationError, match="requires a non-empty url"):
|
|
XMCPConfig(mode="streamable-http", url="")
|
|
|
|
def test_stdio_requires_command(self) -> None:
|
|
with pytest.raises(ValidationError, match="requires a non-empty command"):
|
|
XMCPConfig(mode="stdio", command="", url="")
|
|
|
|
def test_url_trailing_slash_stripped(self) -> None:
|
|
config = XMCPConfig(url="http://127.0.0.1:8000/mcp/")
|
|
assert config.url == "http://127.0.0.1:8000/mcp"
|
|
|
|
def test_mode_mcp_alias_maps_to_streamable_http(self) -> None:
|
|
config = XMCPConfig(mode="mcp")
|
|
assert config.mode == "streamable-http"
|
|
|
|
@pytest.mark.parametrize("alias", ["default", "http", "https", "streamable_http"])
|
|
def test_mode_generic_aliases_map_to_streamable_http(self, alias: str) -> None:
|
|
config = XMCPConfig(mode=alias)
|
|
assert config.mode == "streamable-http"
|
|
|
|
def test_bearer_prefix_stripped_from_auth_token(self) -> None:
|
|
config = XMCPConfig(auth_token="Bearer secret")
|
|
assert config.auth_token == "secret"
|
|
|
|
def test_request_headers_include_auth_only_when_set(self) -> None:
|
|
config = XMCPConfig(auth_token="secret")
|
|
assert config.request_headers["Authorization"] == "Bearer secret"
|
|
|
|
def test_request_headers_empty_without_auth_token(self) -> None:
|
|
config = XMCPConfig()
|
|
assert "Authorization" not in config.request_headers
|
|
|
|
def test_subprocess_env_forwards_bearer_token(self) -> None:
|
|
config = XMCPConfig(bearer_token="x_secret", mode="stdio", command="python")
|
|
assert config.subprocess_env == {"X_BEARER_TOKEN": "x_secret"}
|
|
|
|
def test_subprocess_env_empty_without_bearer_token(self) -> None:
|
|
config = XMCPConfig()
|
|
assert config.subprocess_env == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Env loading / runtime gating
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEnvLoading:
|
|
def test_returns_none_for_stdio_without_command(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("X_MCP_MODE", "stdio")
|
|
monkeypatch.delenv("X_MCP_COMMAND", raising=False)
|
|
assert x_mcp_config_from_env() is None
|
|
|
|
def test_loads_default_local_config_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
for var in ("X_MCP_URL", "X_MCP_MODE", "X_MCP_AUTH_TOKEN", "X_BEARER_TOKEN"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
config = x_mcp_config_from_env()
|
|
assert config is not None
|
|
assert config.url == DEFAULT_X_MCP_URL
|
|
assert config.mode == "streamable-http"
|
|
|
|
def test_runtime_reason_ok_for_http_without_token(self) -> None:
|
|
config = build_x_mcp_config({"url": DEFAULT_X_MCP_URL})
|
|
assert x_mcp_runtime_unavailable_reason(config) is None
|
|
|
|
def test_runtime_reason_requires_bearer_token_for_stdio(self) -> None:
|
|
config = build_x_mcp_config({"mode": "stdio", "command": "python", "bearer_token": ""})
|
|
reason = x_mcp_runtime_unavailable_reason(config)
|
|
assert reason is not None
|
|
assert "bearer token" in reason
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidation:
|
|
def test_validation_passes_when_tools_listed(self) -> None:
|
|
config = build_x_mcp_config({"url": DEFAULT_X_MCP_URL})
|
|
fake_tools = [
|
|
{"name": "search-tweets", "description": "Search tweets", "input_schema": {}},
|
|
{"name": "get-timeline", "description": "Get a user timeline", "input_schema": {}},
|
|
]
|
|
with patch(
|
|
"integrations.x_mcp.list_x_mcp_tools",
|
|
return_value=fake_tools,
|
|
):
|
|
result = validate_x_mcp_config(config)
|
|
assert result.ok is True
|
|
assert result.tool_names == ("get-timeline", "search-tweets")
|
|
assert "discovered 2 tool(s)" in result.detail
|
|
|
|
def test_validation_fails_when_no_tools(self) -> None:
|
|
config = build_x_mcp_config({"url": DEFAULT_X_MCP_URL})
|
|
with patch(
|
|
"integrations.x_mcp.list_x_mcp_tools",
|
|
return_value=[],
|
|
):
|
|
result = validate_x_mcp_config(config)
|
|
assert result.ok is False
|
|
assert "no tools" in result.detail
|
|
|
|
def test_validation_handles_exception(self) -> None:
|
|
config = build_x_mcp_config({"url": DEFAULT_X_MCP_URL})
|
|
with patch(
|
|
"integrations.x_mcp.list_x_mcp_tools",
|
|
side_effect=RuntimeError("boom"),
|
|
):
|
|
result = validate_x_mcp_config(config)
|
|
assert result.ok is False
|
|
assert "validation failed" in result.detail
|
|
|
|
def test_validation_fails_for_stdio_without_bearer_token(self) -> None:
|
|
config = build_x_mcp_config({"mode": "stdio", "command": "python"})
|
|
result = validate_x_mcp_config(config)
|
|
assert result.ok is False
|
|
assert "bearer token" in result.detail
|
|
|
|
|
|
def test_describe_error_includes_connect_hint() -> None:
|
|
import httpx
|
|
|
|
config = build_x_mcp_config({"url": DEFAULT_X_MCP_URL})
|
|
err = httpx.ConnectError("connection refused")
|
|
detail = describe_x_mcp_error(err, config)
|
|
assert "Could not reach" in detail
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Catalog classification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_classify_x_mcp_credentials() -> None:
|
|
records = [
|
|
{
|
|
"id": "x-mcp-local",
|
|
"service": "x_mcp",
|
|
"status": "active",
|
|
"credentials": {
|
|
"url": "http://127.0.0.1:8000/mcp",
|
|
"mode": "streamable-http",
|
|
},
|
|
}
|
|
]
|
|
from tools.investigation.stages.gather_evidence.tools import availability_view
|
|
|
|
resolved = _classify_integrations(records)
|
|
assert "x_mcp" in resolved
|
|
assert resolved["x_mcp"].url == "http://127.0.0.1:8000/mcp"
|
|
# connection_verified is set at the tool-availability boundary
|
|
view = availability_view(resolved)
|
|
assert view["x_mcp"]["connection_verified"] is True
|