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

651 lines
23 KiB
Python

"""Shared OpenClaw bridge integration helpers.
OpenClaw is an AI coding assistant that communicates via the Model Context Protocol (MCP).
This module centralizes OpenClaw bridge configuration, validation, and tool-calling so the
onboarding wizard, verify CLI, and investigation flows all share the same transport logic.
Supported transports:
- streamable-http (default) — HTTP-based MCP via Streamable HTTP
- sse — Server-Sent Events MCP transport
- stdio — subprocess-based MCP (local dev)
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import shutil
import subprocess
from collections.abc import AsyncIterator, Coroutine, Mapping
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, cast
from urllib.parse import urlparse
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_OPENCLAW_MCP_MODE: Literal["streamable-http", "sse", "stdio"] = "streamable-http"
_OPENCLAW_CONTROL_UI_HOSTS = frozenset({"127.0.0.1", "localhost", "0.0.0.0"})
_OPENCLAW_CONTROL_UI_PORT = 18789
_OPENCLAW_STDIO_COMMAND = "openclaw"
_OPENCLAW_STDIO_ARGS = ("mcp", "serve")
_NODE_REQUIREMENT_PATTERN = re.compile(
r"Node\.js\s+(?P<required>v[0-9][0-9A-Za-z.+-]*)\s+is required\s+\(current:\s*"
r"(?P<current>v[0-9][0-9A-Za-z.+-]*)\)",
re.IGNORECASE,
)
class OpenClawToolDescriptor(TypedDict):
"""A tool exposed by the OpenClaw MCP bridge."""
name: str
description: str
input_schema: object | None
class OpenClawContentItem(TypedDict, total=False):
"""Normalized content item returned by an MCP tool call."""
type: str
text: str
uri: str
mime_type: str
class OpenClawToolCallResult(TypedDict, total=False):
"""Normalized response from an OpenClaw MCP tool call."""
is_error: bool
text: str
content: list[OpenClawContentItem]
structured_content: object | None
tool: str
arguments: dict[str, object]
class OpenClawConfig(StrictConfigModel):
"""Normalized OpenClaw bridge connection settings."""
url: str = ""
mode: Literal["stdio", "sse", "streamable-http"] = DEFAULT_OPENCLAW_MCP_MODE
auth_token: str = ""
command: str = ""
args: tuple[str, ...] = ()
headers: dict[str, str] = Field(default_factory=dict)
timeout_seconds: float = Field(default=15.0, gt=0)
integration_id: str = ""
connection_verified: bool = True
@field_validator("url", mode="before")
@classmethod
def _normalize_url(cls, value: object) -> str:
return str(value or "").strip().rstrip("/")
@field_validator("mode", mode="before")
@classmethod
def _normalize_mode(cls, value: object) -> str:
normalized = str(value or DEFAULT_OPENCLAW_MCP_MODE).strip().lower()
return normalized or DEFAULT_OPENCLAW_MCP_MODE
@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("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:
return ()
if 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) -> OpenClawConfig:
if self.mode == "stdio" and not self.command:
raise ValueError("OpenClaw MCP mode 'stdio' requires a non-empty command.")
if self.mode != "stdio" and not self.url:
raise ValueError(f"OpenClaw 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
@dataclass(frozen=True)
class OpenClawValidationResult:
"""Result of validating an OpenClaw bridge integration."""
ok: bool
detail: str
tool_names: tuple[str, ...] = ()
def _is_probable_openclaw_control_ui_url(url: str) -> bool:
parsed = urlparse(url.strip())
host = (parsed.hostname or "").strip().lower()
if host not in _OPENCLAW_CONTROL_UI_HOSTS:
return False
port = parsed.port
if port is None:
port = 443 if parsed.scheme == "https" else 80
normalized_path = parsed.path.rstrip("/")
return port == _OPENCLAW_CONTROL_UI_PORT and normalized_path == ""
def _dedupe_preserving_order(items: list[str]) -> list[str]:
seen: set[str] = set()
unique: list[str] = []
for item in items:
normalized = item.strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
unique.append(normalized)
return unique
def _uses_openclaw_cli_mcp_bridge(config: OpenClawConfig) -> bool:
command_name = Path(config.command or "").name.lower()
return (
config.mode == "stdio"
and command_name == "openclaw"
and tuple(config.args[:2]) == _OPENCLAW_STDIO_ARGS
)
def _looks_like_openclaw_gateway_unavailable(messages: list[str]) -> bool:
indicators = (
"connection closed",
"econnrefused",
"connect failed",
"could not connect",
"closed before connect",
)
return any(indicator in message.lower() for message in messages for indicator in indicators)
def _format_setup_steps(summary: str, steps: tuple[str, ...]) -> str:
rendered_steps = "\n".join(f"{index}. {step}" for index, step in enumerate(steps, start=1))
return f"{summary}\nNext steps:\n{rendered_steps}"
def _openclaw_cli_preflight_output(command: str) -> str:
try:
result = subprocess.run(
[command, "--help"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError):
return ""
output_parts = [result.stdout.strip(), result.stderr.strip()]
return "\n".join(part for part in output_parts if part).strip()
def _openclaw_cli_preflight_issue(config: OpenClawConfig) -> str | None:
if not _uses_openclaw_cli_mcp_bridge(config):
return None
command = os.path.expanduser((config.command or "").strip())
if not command:
return None
output = _openclaw_cli_preflight_output(command)
if not output:
return None
match = _NODE_REQUIREMENT_PATTERN.search(output)
if match is None:
return None
required = match.group("required")
current = match.group("current")
return _format_setup_steps(
f"OpenClaw CLI requires Node.js {required} but the current shell is using {current}.",
(
"Run `nvm install 22` if Node 22 is not installed yet.",
"Run `nvm use 22` in the same shell where you launch OpenSRE.",
"Verify `node -v` shows 22.12 or newer and `openclaw --help` succeeds.",
"Then run `openclaw gateway status` and `uv run opensre integrations verify openclaw` again.",
"`nvm alias default 22` only affects future shells; it does not switch the current shell.",
),
)
def _describe_exception(err: BaseException) -> list[str]:
if isinstance(err, BaseExceptionGroup):
messages: list[str] = []
for sub_exception in err.exceptions:
messages.extend(_describe_exception(sub_exception))
return messages
if isinstance(err, FileNotFoundError):
command = err.filename or str(err).split(":", 1)[0].strip()
return [f"Command not found: {command or 'unknown command'}"]
if isinstance(err, httpx.HTTPStatusError):
return [f"HTTP {err.response.status_code} from {err.request.method} {err.request.url}"]
if isinstance(err, httpx.ConnectError):
request = getattr(err, "request", None)
if request is not None:
return [f"Could not connect to {request.url}: {err}"]
return [str(err) or err.__class__.__name__]
# ``asyncio.wait_for`` raises ``asyncio.TimeoutError`` which is
# ``TimeoutError`` in 3.11+. ``str()`` is empty, so the default
# branch below would surface just ``"TimeoutError"`` — useless for
# a user trying to debug a hung MCP tool. Spell it out.
if isinstance(err, TimeoutError):
return ["OpenClaw MCP tool call timed out"]
return [str(err).strip() or err.__class__.__name__]
def describe_openclaw_error(
err: BaseException,
config: OpenClawConfig,
) -> str:
messages = _dedupe_preserving_order(_describe_exception(err))
detail = "; ".join(messages) if messages else (str(err).strip() or err.__class__.__name__)
hints: list[str] = []
if _uses_openclaw_cli_mcp_bridge(config) and _looks_like_openclaw_gateway_unavailable(messages):
preflight_issue = _openclaw_cli_preflight_issue(config)
if preflight_issue is not None:
return preflight_issue
if config.mode != "stdio" and _is_probable_openclaw_control_ui_url(config.url):
hints.append(
"The local OpenClaw URL on port 18789 is the Control UI/Gateway, not the MCP "
f"bridge. Use mode `stdio` with command `{_OPENCLAW_STDIO_COMMAND}` and args "
f"`{' '.join(_OPENCLAW_STDIO_ARGS)}`."
)
if config.mode == "stdio" and config.command == "openclaw-mcp":
hints.append(
"OpenClaw's current MCP bridge is exposed via `openclaw mcp serve`, not `openclaw-mcp`."
)
if config.mode == "stdio" and any(
message.startswith("Command not found:") for message in messages
):
hints.append(
"Install the OpenClaw CLI or set `OPENCLAW_MCP_COMMAND` to the full executable path."
)
if _uses_openclaw_cli_mcp_bridge(config) and _looks_like_openclaw_gateway_unavailable(messages):
hints.append(
_format_setup_steps(
"The `openclaw mcp serve` bridge needs a running OpenClaw Gateway.",
(
"Check `openclaw gateway status`.",
"Start it with `openclaw gateway run` for a foreground session.",
"Or install/start the background service with `openclaw gateway install` then `openclaw gateway start`.",
"Re-run `uv run opensre integrations verify openclaw` after the gateway is healthy.",
),
)
)
if any("timed out" in message.lower() for message in messages):
hints.append(
f"The tool did not return within {config.timeout_seconds:.1f}s. "
"Check whether the OpenClaw Gateway is responsive (`openclaw gateway health`) "
"or raise `OpenClawConfig.timeout_seconds` if the tool is expected to be slow."
)
if hints:
return f"{detail} Hint: {' '.join(hints)}"
return detail
def build_openclaw_config(raw: Mapping[str, object] | None) -> OpenClawConfig:
"""Build a normalized OpenClaw config object from env/store data."""
payload = dict(raw or {})
allowed = set(OpenClawConfig.model_fields)
sanitized = {key: value for key, value in payload.items() if key in allowed}
return OpenClawConfig.model_validate(sanitized)
def openclaw_config_from_env() -> OpenClawConfig | None:
"""Load an OpenClaw bridge config from environment variables."""
mode = os.getenv("OPENCLAW_MCP_MODE", DEFAULT_OPENCLAW_MCP_MODE).strip().lower()
url = os.getenv("OPENCLAW_MCP_URL", "").strip()
command = os.getenv("OPENCLAW_MCP_COMMAND", "").strip()
auth_token = os.getenv("OPENCLAW_MCP_AUTH_TOKEN", "").strip()
args_env = os.getenv("OPENCLAW_MCP_ARGS", "").strip()
if mode == "stdio":
if not command:
return None
elif not url:
return None
return build_openclaw_config(
{
"url": url,
"mode": mode or DEFAULT_OPENCLAW_MCP_MODE,
"command": command,
"args": [part for part in args_env.split() if part],
"auth_token": auth_token,
}
)
def openclaw_runtime_unavailable_reason(config: OpenClawConfig) -> str | None:
"""Return a setup/runtime error when the config cannot be used locally."""
if not config.is_configured:
return "OpenClaw is not configured: provide a URL (HTTP/SSE) or command (stdio)."
if config.mode != "stdio":
return None
command = os.path.expanduser((config.command or "").strip())
if not command:
return "OpenClaw is not configured: provide a URL (HTTP/SSE) or command (stdio)."
if shutil.which(command) is None:
return describe_openclaw_error(FileNotFoundError(2, "No such file", command), config)
preflight_issue = _openclaw_cli_preflight_issue(config)
if preflight_issue is not None:
return preflight_issue
return None
@asynccontextmanager
async def _open_openclaw_session(config: OpenClawConfig) -> AsyncIterator[ClientSession]:
"""Open an MCP client session for OpenClaw using the configured transport."""
stack = AsyncExitStack()
try:
if config.mode == "stdio":
if not config.command:
raise ValueError(
"Invalid OpenClaw config: mode=stdio requires command "
"(set OPENCLAW_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",
**({"OPENCLAW_AUTH_TOKEN": config.auth_token} if config.auth_token else {}),
},
)
read_stream, write_stream = await stack.enter_async_context(stdio_client(server_params))
elif config.mode == "sse":
if not config.url:
raise ValueError(
"Invalid OpenClaw config: mode=sse requires url "
"(set OPENCLAW_MCP_URL, e.g. https://.../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 OpenClaw config: mode=streamable-http requires url "
"(set OPENCLAW_MCP_URL)."
)
http_client = await stack.enter_async_context(
httpx.AsyncClient(
headers=config.request_headers,
timeout=config.timeout_seconds,
)
)
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=max(60.0, config.timeout_seconds),
)
)
else:
raise ValueError(
f"Unsupported OpenClaw 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:
return asyncio.run(coro)
def _tool_result_to_dict(result: types.CallToolResult) -> OpenClawToolCallResult:
text_parts: list[str] = []
content_items: list[OpenClawContentItem] = []
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: OpenClawConfig) -> list[types.Tool]:
async with _open_openclaw_session(config) as session:
result = await session.list_tools()
return list(result.tools)
def list_openclaw_tools(config: OpenClawConfig) -> list[OpenClawToolDescriptor]:
"""List available tools from the OpenClaw bridge."""
tools = _list_tools_sync(config)
return [
{
"name": tool.name,
"description": tool.description or "",
"input_schema": getattr(tool, "inputSchema", None),
}
for tool in tools
]
def _list_tools_sync(config: OpenClawConfig) -> list[types.Tool]:
return cast(list[types.Tool], _run_async(_list_tools_async(config)))
async def _call_tool_async(
config: OpenClawConfig,
tool_name: str,
arguments: dict[str, object] | None = None,
) -> OpenClawToolCallResult:
async with _open_openclaw_session(config) as session:
# ``OpenClawConfig.timeout_seconds`` previously bounded only the
# SSE / streamable-http transport handshake; ``session.call_tool``
# itself was unbounded, so a hung MCP tool over stdio would
# block the investigation pipeline indefinitely. Wrap the call
# with ``asyncio.wait_for`` so the same timeout governs all
# transport modes uniformly. :func:`describe_openclaw_error`
# surfaces a "timed out" hint when ``TimeoutError`` propagates.
result = await asyncio.wait_for(
session.call_tool(tool_name, arguments or {}),
timeout=config.timeout_seconds,
)
payload = _tool_result_to_dict(result)
payload["tool"] = tool_name
payload["arguments"] = arguments or {}
return payload
def call_openclaw_tool(
config: OpenClawConfig,
tool_name: str,
arguments: dict[str, object] | None = None,
) -> OpenClawToolCallResult:
"""Call an OpenClaw MCP tool and normalize the result."""
return cast(OpenClawToolCallResult, _run_async(_call_tool_async(config, tool_name, arguments)))
def validate_openclaw_config(config: OpenClawConfig) -> OpenClawValidationResult:
"""Validate OpenClaw bridge connectivity by listing available tools."""
if not config.is_configured:
return OpenClawValidationResult(
ok=False,
detail="OpenClaw is not configured: provide a URL (HTTP/SSE) or command (stdio).",
)
if config.mode != "stdio" and _is_probable_openclaw_control_ui_url(config.url):
return OpenClawValidationResult(
ok=False,
detail=(
"OpenClaw bridge validation failed: the local URL on port 18789 is OpenClaw's "
"Control UI/Gateway, not its MCP bridge. Use mode `stdio` with command "
f"`{_OPENCLAW_STDIO_COMMAND}` and args `{' '.join(_OPENCLAW_STDIO_ARGS)}`."
),
)
runtime_error = openclaw_runtime_unavailable_reason(config)
if runtime_error is not None:
return OpenClawValidationResult(
ok=False,
detail=f"OpenClaw bridge validation failed: {runtime_error}",
)
try:
tools = list_openclaw_tools(config)
tool_names = tuple(sorted(t["name"] for t in tools))
endpoint = config.url if config.mode != "stdio" else config.command
return OpenClawValidationResult(
ok=True,
detail=(
f"OpenClaw bridge 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="openclaw",
method="validate_openclaw_config",
)
return OpenClawValidationResult(
ok=False,
detail=f"OpenClaw bridge validation failed: {describe_openclaw_error(err, config)}",
)
def classify(
credentials: dict[str, Any], record_id: str
) -> tuple[OpenClawConfig | None, str | None]:
try:
cfg = build_openclaw_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", ""),
"integration_id": record_id,
}
)
except Exception as exc:
report_classify_failure(exc, logger=logger, integration="openclaw", record_id=record_id)
return None, None
if cfg.is_configured:
return cfg, "openclaw"
return None, None