Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

528 lines
19 KiB
Python

"""Shared X (Twitter) MCP integration helpers.
X ships an official Model Context Protocol (MCP) server
(https://github.com/xdevplatform/xmcp) that exposes the X API — posting,
search, timelines, likes, retweets, bookmarks, and more — as function-calling
tools. Unlike PostHog/Sentry's always-on hosted MCP servers, XMCP is designed
to run locally (optionally tunneled for remote access): a user clones the
repo, supplies their own X API credentials, and runs the server themselves.
This module centralizes X MCP configuration, validation, and tool-calling so
the onboarding wizard, verify CLI, chat tools, and investigation actions all
share the same transport and parsing logic.
Supported transports:
- streamable-http (default) — HTTP-based MCP, typically http://127.0.0.1:8000/mcp
or a tunneled URL (e.g. ngrok) for remote access
- sse — Server-Sent Events MCP transport
- stdio — subprocess-based MCP (opensre launches the local xmcp
server directly, e.g. ``python server.py``)
Authentication: XMCP itself authenticates to the X API using its own
X_BEARER_TOKEN environment variable at startup. When opensre launches the
server via ``stdio``, that token is forwarded into the subprocess
environment. For ``streamable-http``/``sse`` connections to an
already-running server, an optional bearer token is sent as an Authorization
header only if configured (useful when the endpoint sits behind an
authenticating tunnel/proxy); XMCP does not itself require one for a trusted
local connection.
"""
from __future__ import annotations
import asyncio
import logging
import os
from collections.abc import AsyncIterator, Coroutine, Mapping
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from typing import Any, Literal, cast
import httpx
from mcp import ClientSession, StdioServerParameters, types # type: ignore[import-not-found]
from mcp.client.sse import sse_client # type: ignore[import-not-found]
from mcp.client.stdio import stdio_client # type: ignore[import-not-found]
from pydantic import Field, field_validator, model_validator
from typing_extensions import TypedDict
from config.strict_config import StrictConfigModel
from integrations._validation_helpers import report_classify_failure, report_validation_failure
from integrations.mcp_streamable_http_compat import streamable_http_client
logger = logging.getLogger(__name__)
DEFAULT_X_MCP_URL = "http://127.0.0.1:8000/mcp"
DEFAULT_X_MCP_MODE: Literal["streamable-http", "sse", "stdio"] = "streamable-http"
class XMCPToolDescriptor(TypedDict):
"""A tool exposed by the X MCP server."""
name: str
description: str
input_schema: object | None
class XMCPContentItem(TypedDict, total=False):
"""Normalized content item returned by an MCP tool call."""
type: str
text: str
uri: str
mime_type: str
class XMCPToolCallResult(TypedDict, total=False):
"""Normalized response from an X MCP tool call."""
is_error: bool
text: str
content: list[XMCPContentItem]
structured_content: object | None
tool: str
arguments: dict[str, object]
class XMCPConfig(StrictConfigModel):
"""Normalized X MCP connection settings."""
url: str = DEFAULT_X_MCP_URL
mode: Literal["stdio", "sse", "streamable-http"] = DEFAULT_X_MCP_MODE
auth_token: str = ""
bearer_token: str = ""
command: str = ""
args: tuple[str, ...] = ()
headers: dict[str, str] = Field(default_factory=dict)
timeout_seconds: float = Field(default=20.0, gt=0)
integration_id: str = ""
@field_validator("url", mode="before")
@classmethod
def _normalize_url(cls, value: object) -> str:
normalized = str(value or "").strip()
return normalized.rstrip("/") if normalized else ""
@field_validator("mode", mode="before")
@classmethod
def _normalize_mode(cls, value: object) -> str:
normalized = str(value or DEFAULT_X_MCP_MODE).strip().lower()
normalized = normalized or DEFAULT_X_MCP_MODE
# Generic aliases that callers (env, store, or the planner) may emit
# all map to the default HTTP transport rather than tripping the
# Literal validation. "default" is what the planner tends to guess when
# it has no explicit transport to pass.
if normalized in {"mcp", "default", "http", "https", "streamable_http"}:
return DEFAULT_X_MCP_MODE
return normalized
@field_validator("auth_token", mode="before")
@classmethod
def _normalize_auth_token(cls, value: object) -> str:
token = str(value or "").strip()
if token.lower().startswith("bearer "):
token = token.split(None, 1)[1].strip()
return token
@field_validator("bearer_token", mode="before")
@classmethod
def _normalize_bearer_token(cls, value: object) -> str:
return str(value or "").strip()
@field_validator("command", mode="before")
@classmethod
def _normalize_command(cls, value: object) -> str:
return str(value or "").strip()
@field_validator("args", mode="before")
@classmethod
def _normalize_args(cls, value: object) -> tuple[str, ...]:
if value is None or not isinstance(value, (list, tuple, set)):
return ()
return tuple(str(arg).strip() for arg in value if str(arg).strip())
@field_validator("headers", mode="before")
@classmethod
def _normalize_headers(cls, value: object) -> dict[str, str]:
if not isinstance(value, dict):
return {}
return {str(k): str(v).strip() for k, v in value.items() if str(v).strip()}
@model_validator(mode="after")
def _validate_transport_requirements(self) -> XMCPConfig:
if self.mode == "stdio" and not self.command:
raise ValueError("X MCP mode 'stdio' requires a non-empty command.")
if self.mode != "stdio" and not self.url:
raise ValueError(f"X MCP mode '{self.mode}' requires a non-empty url.")
return self
@property
def is_configured(self) -> bool:
if self.mode == "stdio":
return bool(self.command)
return bool(self.url)
@property
def request_headers(self) -> dict[str, str]:
headers = {k: v for k, v in self.headers.items() if v}
if self.auth_token and "Authorization" not in headers:
headers["Authorization"] = f"Bearer {self.auth_token}"
return headers
@property
def subprocess_env(self) -> dict[str, str]:
"""Extra env vars to forward when launching the server ourselves (stdio)."""
env: dict[str, str] = {}
if self.bearer_token:
env["X_BEARER_TOKEN"] = self.bearer_token
return env
@dataclass(frozen=True)
class XMCPValidationResult:
"""Result of validating an X MCP connection."""
ok: bool
detail: str
tool_names: tuple[str, ...] = ()
def build_x_mcp_config(raw: Mapping[str, object] | None) -> XMCPConfig:
"""Build a normalized X MCP config object from env/store data."""
payload = dict(raw or {})
allowed = set(XMCPConfig.model_fields)
sanitized = {key: value for key, value in payload.items() if key in allowed}
return XMCPConfig.model_validate(sanitized)
def x_mcp_config_from_env() -> XMCPConfig | None:
"""Load an X MCP config from environment variables."""
mode = os.getenv("X_MCP_MODE", DEFAULT_X_MCP_MODE).strip().lower()
url = os.getenv("X_MCP_URL", "").strip()
command = os.getenv("X_MCP_COMMAND", "").strip()
auth_token = os.getenv("X_MCP_AUTH_TOKEN", "").strip()
bearer_token = os.getenv("X_BEARER_TOKEN", "").strip()
args_env = os.getenv("X_MCP_ARGS", "").strip()
mode = mode or DEFAULT_X_MCP_MODE
if mode == "stdio":
if not command:
return None
else:
url = url or DEFAULT_X_MCP_URL
return build_x_mcp_config(
{
"url": url,
"mode": mode,
"command": command,
"args": [part for part in args_env.split() if part],
"auth_token": auth_token,
"bearer_token": bearer_token,
}
)
def x_mcp_runtime_unavailable_reason(config: XMCPConfig) -> str | None:
"""Return a setup error when the config cannot be used."""
if not config.is_configured:
return "X MCP is not configured: provide a URL (HTTP/SSE) or command (stdio)."
if config.mode == "stdio" and not config.bearer_token:
return (
"X MCP stdio mode requires an X API bearer token to launch the local server. "
"Set X_BEARER_TOKEN."
)
return None
@asynccontextmanager
async def _open_x_mcp_session(config: XMCPConfig) -> AsyncIterator[ClientSession]:
"""Open an MCP client session for X using the configured transport."""
stack = AsyncExitStack()
try:
if config.mode == "stdio":
if not config.command:
raise ValueError(
"Invalid X MCP config: mode=stdio requires command "
"(set X_MCP_COMMAND or pass command in config)."
)
server_params = StdioServerParameters(
command=config.command,
args=list(config.args),
env={
**os.environ,
# Suppress terminal control codes so the MCP server's stdout
# stays clean JSON-RPC (mirrors integrations/github/mcp.py mitigation).
"NO_COLOR": "1",
"TERM": "dumb",
**config.subprocess_env,
},
)
read_stream, write_stream = await stack.enter_async_context(stdio_client(server_params))
elif config.mode == "sse":
if not config.url:
raise ValueError(
"Invalid X MCP config: mode=sse requires url "
"(set X_MCP_URL, e.g. http://127.0.0.1:8000/sse)."
)
read_stream, write_stream = await stack.enter_async_context(
sse_client(
config.url,
headers=config.request_headers,
timeout=config.timeout_seconds,
sse_read_timeout=max(60.0, config.timeout_seconds),
)
)
elif config.mode == "streamable-http":
if not config.url:
raise ValueError(
"Invalid X MCP config: mode=streamable-http requires url "
"(set X_MCP_URL, e.g. http://127.0.0.1:8000/mcp)."
)
read_timeout = max(60.0, config.timeout_seconds)
http_client = await stack.enter_async_context(
httpx.AsyncClient(
headers=config.request_headers,
timeout=httpx.Timeout(config.timeout_seconds, read=read_timeout),
)
)
read_stream, write_stream, _ = await stack.enter_async_context(
streamable_http_client(
config.url,
http_client=http_client,
headers=config.request_headers,
timeout=config.timeout_seconds,
sse_read_timeout=read_timeout,
)
)
else:
raise ValueError(
f"Unsupported X MCP mode '{config.mode}'. "
"Supported modes: stdio, sse, streamable-http."
)
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
await session.initialize()
yield session
finally:
await stack.aclose()
def _run_async(coro: Coroutine[object, object, object]) -> object:
try:
return asyncio.run(coro)
except BaseException:
close = getattr(coro, "close", None)
if callable(close):
close()
raise
def _root_cause_message(exc: BaseException) -> str:
"""Best-effort unwrap for ExceptionGroup/TaskGroup chains."""
if isinstance(exc, BaseExceptionGroup) and exc.exceptions:
return _root_cause_message(exc.exceptions[0])
cause = getattr(exc, "__cause__", None)
if isinstance(cause, BaseException):
return _root_cause_message(cause)
context = getattr(exc, "__context__", None)
if isinstance(context, BaseException):
return _root_cause_message(context)
if isinstance(exc, TimeoutError):
return "X MCP tool call timed out"
return str(exc).strip() or exc.__class__.__name__
def describe_x_mcp_error(err: BaseException, config: XMCPConfig) -> str:
"""Render a human-readable error with a setup hint when useful."""
detail = _root_cause_message(err)
hints: list[str] = []
if isinstance(err, httpx.HTTPStatusError) and err.response.status_code in (401, 403):
hints.append(
"Authentication failed. If the endpoint is tunneled behind an "
"authenticating proxy, set X_MCP_AUTH_TOKEN; otherwise check the "
"local xmcp server's own X API credentials (X_BEARER_TOKEN)."
)
elif isinstance(err, (httpx.ConnectError, httpx.ConnectTimeout)):
hints.append(
f"Could not reach {config.url}. Confirm the local xmcp server "
"(https://github.com/xdevplatform/xmcp) is running and reachable."
)
if "timed out" in detail.lower():
hints.append(
f"The tool did not return within {config.timeout_seconds:.1f}s. "
"Raise XMCPConfig.timeout_seconds if the tool is expected to be slow."
)
if hints:
return f"{detail} Hint: {' '.join(hints)}"
return detail
def _tool_result_to_dict(result: types.CallToolResult) -> XMCPToolCallResult:
text_parts: list[str] = []
content_items: list[XMCPContentItem] = []
for item in result.content:
if isinstance(item, types.TextContent):
text_parts.append(item.text)
content_items.append({"type": "text", "text": item.text})
elif isinstance(item, types.EmbeddedResource):
resource = item.resource
if isinstance(resource, types.TextResourceContents):
content_items.append(
{
"type": "resource_text",
"uri": str(resource.uri),
"text": resource.text,
}
)
text_parts.append(resource.text)
elif isinstance(resource, types.BlobResourceContents):
content_items.append(
{
"type": "resource_blob",
"uri": str(resource.uri),
"mime_type": resource.mimeType or "",
}
)
else:
content_items.append({"type": getattr(item, "type", "unknown")})
structured = getattr(result, "structuredContent", None)
text_output = "\n".join(part.strip() for part in text_parts if part.strip()).strip()
return {
"is_error": bool(result.isError),
"text": text_output,
"content": content_items,
"structured_content": structured,
}
async def _list_tools_async(config: XMCPConfig) -> list[types.Tool]:
async with _open_x_mcp_session(config) as session:
result = await session.list_tools()
return list(result.tools)
def _list_tools_sync(config: XMCPConfig) -> list[types.Tool]:
# Bound session open (transport connect + MCP `initialize` handshake) and
# the list_tools RPC together, so a server that accepts a connection but
# never completes the handshake or responds cannot hang the pipeline.
return cast(
list[types.Tool],
_run_async(asyncio.wait_for(_list_tools_async(config), timeout=config.timeout_seconds)),
)
def list_x_mcp_tools(config: XMCPConfig) -> list[XMCPToolDescriptor]:
"""List available tools from the configured X MCP server."""
tools = _list_tools_sync(config)
return [
{
"name": tool.name,
"description": tool.description or "",
"input_schema": getattr(tool, "inputSchema", None),
}
for tool in tools
]
async def _call_tool_async(
config: XMCPConfig,
tool_name: str,
arguments: dict[str, object] | None = None,
) -> XMCPToolCallResult:
async with _open_x_mcp_session(config) as session:
result = await session.call_tool(tool_name, arguments or {})
payload = _tool_result_to_dict(result)
payload["tool"] = tool_name
payload["arguments"] = arguments or {}
return payload
def call_x_mcp_tool(
config: XMCPConfig,
tool_name: str,
arguments: dict[str, object] | None = None,
) -> XMCPToolCallResult:
"""Call an X MCP tool and normalize the result."""
# Bound session open (transport connect + MCP `initialize` handshake) and
# the call_tool RPC together, so a server that accepts a connection but
# never completes the handshake or responds cannot hang the pipeline.
return cast(
XMCPToolCallResult,
_run_async(
asyncio.wait_for(
_call_tool_async(config, tool_name, arguments), timeout=config.timeout_seconds
)
),
)
def validate_x_mcp_config(config: XMCPConfig) -> XMCPValidationResult:
"""Validate X MCP connectivity by listing available tools."""
runtime_error = x_mcp_runtime_unavailable_reason(config)
if runtime_error is not None:
return XMCPValidationResult(
ok=False,
detail=f"X MCP validation failed: {runtime_error}",
)
try:
tools = list_x_mcp_tools(config)
tool_names = tuple(sorted(t["name"] for t in tools))
endpoint = config.command if config.mode == "stdio" else config.url
if not tool_names:
return XMCPValidationResult(
ok=False,
detail=(
f"X MCP connected via {config.mode} ({endpoint}) but exposed no tools. "
"Check the server's X API credentials or tool allowlist."
),
)
return XMCPValidationResult(
ok=True,
detail=(
f"X MCP connected via {config.mode} ({endpoint}); "
f"discovered {len(tool_names)} tool(s)."
),
tool_names=tool_names,
)
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="x_mcp",
method="validate_x_mcp_config",
)
return XMCPValidationResult(
ok=False,
detail=f"X MCP validation failed: {describe_x_mcp_error(err, config)}",
)
def classify(credentials: dict[str, Any], record_id: str) -> tuple[XMCPConfig | None, str | None]:
try:
cfg = build_x_mcp_config(
{
"url": credentials.get("url", ""),
"mode": credentials.get("mode", "streamable-http"),
"command": credentials.get("command", ""),
"args": credentials.get("args", []),
"auth_token": credentials.get("auth_token", ""),
"bearer_token": credentials.get("bearer_token", ""),
"integration_id": record_id,
}
)
except Exception as exc:
report_classify_failure(exc, logger=logger, integration="x_mcp", record_id=record_id)
return None, None
if cfg.is_configured:
return cfg, "x_mcp"
return None, None