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

588 lines
21 KiB
Python

"""OpenClaw MCP-backed bridge tools."""
from __future__ import annotations
from core.tool_framework.telemetry import report_run_error
from core.tool_framework.tool_decorator import tool
from core.tool_framework.utils.mcp_params import first_list, first_string
from core.tool_framework.utils.mcp_tool_listing import build_mcp_tool_listing
from integrations.openclaw import (
OpenClawConfig,
OpenClawToolCallResult,
build_openclaw_config,
describe_openclaw_error,
openclaw_config_from_env,
openclaw_runtime_unavailable_reason,
)
from integrations.openclaw import (
call_openclaw_tool as invoke_openclaw_mcp_tool,
)
from integrations.openclaw import (
list_openclaw_tools as list_openclaw_mcp_tools,
)
OpenClawParams = dict[str, object]
OpenClawBridgeResponse = dict[str, object]
OpenClawConversationRow = dict[str, object]
def _openclaw_unavailable_response(
error: str,
*,
tool_name: str | None = None,
arguments: OpenClawParams | None = None,
) -> OpenClawBridgeResponse:
payload: OpenClawBridgeResponse = {
"source": "openclaw",
"available": False,
"error": error,
}
if tool_name:
payload["tool"] = tool_name
if arguments is not None:
payload["arguments"] = arguments
return payload
def _resolve_config(
openclaw_url: str | None,
openclaw_mode: str | None,
openclaw_token: str | None,
openclaw_command: str | None = None,
openclaw_args: list[str] | None = None,
) -> OpenClawConfig | None:
env_config = openclaw_config_from_env()
if any((openclaw_url, openclaw_mode, openclaw_token, openclaw_command, openclaw_args)):
inferred_mode = (
openclaw_mode
or ("stdio" if openclaw_command else "")
or ("streamable-http" if openclaw_url else "")
or (env_config.mode if env_config else "")
)
raw_config: OpenClawParams = {
"url": openclaw_url or (env_config.url if env_config else ""),
"mode": inferred_mode,
"auth_token": openclaw_token or (env_config.auth_token if env_config else ""),
"command": openclaw_command or (env_config.command if env_config else ""),
"args": openclaw_args or (list(env_config.args) if env_config else []),
"headers": env_config.headers if env_config else {},
}
return build_openclaw_config(raw_config)
return env_config
def _openclaw_available(sources: dict[str, dict]) -> bool:
return bool(sources.get("openclaw", {}).get("connection_verified"))
def _openclaw_extract_params(sources: dict[str, dict]) -> OpenClawParams:
openclaw = sources.get("openclaw", {})
if not openclaw:
return {}
return {
"openclaw_url": first_string(openclaw, "openclaw_url", "url"),
"openclaw_mode": first_string(openclaw, "openclaw_mode", "mode"),
"openclaw_token": first_string(openclaw, "openclaw_token", "auth_token"),
"openclaw_command": first_string(openclaw, "openclaw_command", "command"),
"openclaw_args": first_list(openclaw, "openclaw_args", "args"),
}
def _openclaw_conversation_id(sources: dict[str, dict]) -> str:
openclaw = sources.get("openclaw", {})
return str(
openclaw.get("openclaw_conversation_id") or openclaw.get("conversation_id") or ""
).strip()
def _openclaw_conversation_params(sources: dict[str, dict]) -> OpenClawParams:
params = _openclaw_extract_params(sources)
openclaw = sources.get("openclaw", {})
params["search"] = (
openclaw.get("openclaw_search_query")
or openclaw.get("search_query")
or openclaw.get("search")
or ""
)
params["limit"] = 10
return params
def _openclaw_conversation_detail_params(sources: dict[str, dict]) -> OpenClawParams:
params = _openclaw_extract_params(sources)
conversation_id = _openclaw_conversation_id(sources)
if conversation_id:
params["conversation_id"] = conversation_id
return params
def _normalize_tool_result(result: OpenClawToolCallResult) -> OpenClawBridgeResponse:
if result.get("is_error"):
return _openclaw_unavailable_response(
str(result.get("text") or "OpenClaw MCP tool call failed."),
tool_name=str(result.get("tool", "")).strip() or None,
arguments=result.get("arguments", {}),
)
return {
"source": "openclaw",
"available": True,
"tool": result.get("tool"),
"arguments": result.get("arguments", {}),
"text": result.get("text", ""),
"structured_content": result.get("structured_content"),
"content": result.get("content", []),
}
def _conversation_rows_from_result(result: OpenClawToolCallResult) -> list[OpenClawConversationRow]:
structured = result.get("structured_content")
if isinstance(structured, list):
return [item for item in structured if isinstance(item, dict)]
if isinstance(structured, dict):
conversations = structured.get("conversations")
if isinstance(conversations, list):
return [item for item in conversations if isinstance(item, dict)]
return [structured]
return []
def _normalize_named_bridge_call(
config: OpenClawConfig,
*,
tool_name: str,
arguments: OpenClawParams,
surface_tool_name: str,
) -> OpenClawBridgeResponse:
"""Invoke a named MCP tool and normalise its result.
``tool_name`` is the MCP-side tool identifier (e.g. ``conversations_get``);
``surface_tool_name`` is the OpenSRE registered tool name that this call
is running on behalf of (e.g. ``get_openclaw_conversation``) so the Sentry
``tool_name`` tag matches the tool's declared metadata.
"""
try:
result = invoke_openclaw_mcp_tool(config, tool_name, arguments)
except Exception as err:
report_run_error(
err,
tool_name=surface_tool_name,
source="openclaw",
component="integrations.openclaw.tools.openclaw_mcp_tool",
method=f"invoke_openclaw_mcp_tool('{tool_name}')",
extras={"mcp_tool": tool_name, "transport": config.mode},
)
return _openclaw_unavailable_response(
describe_openclaw_error(err, config),
tool_name=tool_name,
arguments=arguments,
)
payload = _normalize_tool_result(result)
if payload.get("available") is False:
payload.setdefault("tool", tool_name)
payload.setdefault("arguments", arguments)
return payload
@tool(
name="list_openclaw_tools",
source="openclaw",
description=(
"List tools exposed by the configured OpenClaw MCP bridge. Returns a "
"compact, bounded listing (names + short descriptions, no schemas) so it "
"never overflows the agent's context budget. Pass name_filter (e.g. "
"'conversation event permission') to narrow the list, and include_schema=true "
"on a narrowed list to fetch the input schema of the tool you intend to call."
),
use_cases=[
"Inspecting which OpenClaw bridge tools are available before making a call",
"Finding the right tool by passing a name_filter (e.g. 'conversation event permission')",
"Fetching the input schema of a specific tool with include_schema before calling it",
],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"name_filter": {
"type": "string",
"description": (
"Optional space- or comma-separated terms; tools whose name or "
"description contains any term are returned (e.g. 'conversation event')."
),
},
"include_schema": {
"type": "boolean",
"description": (
"Include each tool's full input_schema. Only honored when the "
"(filtered) result set is small; narrow with name_filter first."
),
},
"openclaw_url": {"type": "string"},
"openclaw_mode": {"type": "string"},
"openclaw_token": {"type": "string"},
"openclaw_command": {"type": "string"},
"openclaw_args": {"type": "array", "items": {"type": "string"}},
},
"required": [],
},
is_available=_openclaw_available,
extract_params=_openclaw_extract_params,
)
def list_openclaw_bridge_tools(
name_filter: str | None = None,
include_schema: bool = False,
openclaw_url: str | None = None,
openclaw_mode: str | None = None,
openclaw_token: str | None = None,
openclaw_command: str | None = None,
openclaw_args: list[str] | None = None,
**_kwargs: object,
) -> OpenClawBridgeResponse:
"""List tools available from the configured OpenClaw MCP bridge.
Returns a compact, bounded view by default so the listing never overflows the
agent's context budget.
"""
config = _resolve_config(
openclaw_url,
openclaw_mode,
openclaw_token,
openclaw_command,
openclaw_args,
)
if config is None:
payload = _openclaw_unavailable_response("OpenClaw MCP integration is not configured.")
payload["tools"] = []
return payload
runtime_error = openclaw_runtime_unavailable_reason(config)
if runtime_error is not None:
payload = _openclaw_unavailable_response(runtime_error)
payload["tools"] = []
return payload
try:
tools = list_openclaw_mcp_tools(config)
except Exception as err:
report_run_error(
err,
tool_name="list_openclaw_tools",
source="openclaw",
component="integrations.openclaw.tools.openclaw_mcp_tool",
method="list_openclaw_mcp_tools",
extras={"transport": config.mode},
)
payload = _openclaw_unavailable_response(describe_openclaw_error(err, config))
payload["tools"] = []
return payload
listing = build_mcp_tool_listing(
[dict(descriptor) for descriptor in tools],
name_filter=(name_filter or "").strip() or None,
include_schema=bool(include_schema),
filter_example="conversation event permission",
)
return {
"source": "openclaw",
"available": True,
"transport": config.mode,
"endpoint": config.command if config.mode == "stdio" else config.url,
**listing,
}
@tool(
name="search_openclaw_conversations",
source="openclaw",
description="Search recent OpenClaw conversations through the configured MCP bridge.",
use_cases=[
"Checking whether an engineer already discussed the failing service in OpenClaw",
"Pulling recent OpenClaw context before querying external systems",
],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"search": {"type": "string"},
"limit": {"type": "integer"},
"openclaw_url": {"type": "string"},
"openclaw_mode": {"type": "string"},
"openclaw_token": {"type": "string"},
"openclaw_command": {"type": "string"},
"openclaw_args": {"type": "array", "items": {"type": "string"}},
},
"required": [],
},
is_available=_openclaw_available,
extract_params=_openclaw_conversation_params,
)
def search_openclaw_conversations(
search: str = "",
limit: int = 10,
openclaw_url: str | None = None,
openclaw_mode: str | None = None,
openclaw_token: str | None = None,
openclaw_command: str | None = None,
openclaw_args: list[str] | None = None,
**_kwargs: object,
) -> OpenClawBridgeResponse:
"""Search recent OpenClaw conversations through the MCP bridge."""
config = _resolve_config(
openclaw_url,
openclaw_mode,
openclaw_token,
openclaw_command,
openclaw_args,
)
if config is None:
payload = _openclaw_unavailable_response("OpenClaw MCP integration is not configured.")
payload["conversations"] = []
return payload
runtime_error = openclaw_runtime_unavailable_reason(config)
if runtime_error is not None:
payload = _openclaw_unavailable_response(runtime_error)
payload["conversations"] = []
return payload
arguments: OpenClawParams = {
"limit": max(1, min(limit, 25)),
"includeDerivedTitles": True,
"includeLastMessage": True,
}
if search.strip():
arguments["search"] = search.strip()
try:
result = invoke_openclaw_mcp_tool(config, "conversations_list", arguments)
except Exception as err:
report_run_error(
err,
tool_name="search_openclaw_conversations",
source="openclaw",
component="integrations.openclaw.tools.openclaw_mcp_tool",
method="invoke_openclaw_mcp_tool('conversations_list')",
extras={"transport": config.mode},
)
payload = _openclaw_unavailable_response(describe_openclaw_error(err, config))
payload["conversations"] = []
return payload
payload = _normalize_tool_result(result)
payload["search"] = search.strip()
payload["conversations"] = _conversation_rows_from_result(result)
return payload
@tool(
name="get_openclaw_conversation",
source="openclaw",
description="Fetch one OpenClaw conversation by id through the configured MCP bridge.",
use_cases=[
"Reading the full context of an OpenClaw conversation that may explain the active alert",
"Pulling the latest assistant and engineer messages before continuing an investigation",
],
requires=["conversation_id"],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"conversation_id": {"type": "string"},
"openclaw_url": {"type": "string"},
"openclaw_mode": {"type": "string"},
"openclaw_token": {"type": "string"},
"openclaw_command": {"type": "string"},
"openclaw_args": {"type": "array", "items": {"type": "string"}},
},
"required": ["conversation_id"],
},
is_available=_openclaw_available,
extract_params=_openclaw_conversation_detail_params,
)
def get_openclaw_conversation(
conversation_id: str | None = None,
openclaw_url: str | None = None,
openclaw_mode: str | None = None,
openclaw_token: str | None = None,
openclaw_command: str | None = None,
openclaw_args: list[str] | None = None,
**_kwargs: object,
) -> OpenClawBridgeResponse:
"""Fetch a specific OpenClaw conversation."""
normalized_conversation_id = (conversation_id or "").strip()
if not normalized_conversation_id:
return _openclaw_unavailable_response("conversation_id is required.")
config = _resolve_config(
openclaw_url,
openclaw_mode,
openclaw_token,
openclaw_command,
openclaw_args,
)
if config is None:
return _openclaw_unavailable_response("OpenClaw MCP integration is not configured.")
runtime_error = openclaw_runtime_unavailable_reason(config)
if runtime_error is not None:
return _openclaw_unavailable_response(runtime_error)
return _normalize_named_bridge_call(
config,
tool_name="conversations_get",
arguments={"conversationId": normalized_conversation_id},
surface_tool_name="get_openclaw_conversation",
)
@tool(
name="send_openclaw_message",
source="openclaw",
description="Send a message into an existing OpenClaw conversation.",
use_cases=[
"Writing investigation findings back into a conversation an engineer is already using",
"Appending a short remediation note or next-step summary to an OpenClaw thread",
],
requires=["conversation_id"],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"conversation_id": {"type": "string"},
"content": {"type": "string"},
"openclaw_url": {"type": "string"},
"openclaw_mode": {"type": "string"},
"openclaw_token": {"type": "string"},
"openclaw_command": {"type": "string"},
"openclaw_args": {"type": "array", "items": {"type": "string"}},
},
"required": ["conversation_id", "content"],
},
is_available=_openclaw_available,
extract_params=_openclaw_conversation_detail_params,
)
def send_openclaw_message(
conversation_id: str | None = None,
content: str | None = None,
openclaw_url: str | None = None,
openclaw_mode: str | None = None,
openclaw_token: str | None = None,
openclaw_command: str | None = None,
openclaw_args: list[str] | None = None,
**_kwargs: object,
) -> OpenClawBridgeResponse:
"""Send a message into an OpenClaw conversation."""
normalized_conversation_id = (conversation_id or "").strip()
normalized_content = (content or "").strip()
if not normalized_conversation_id:
return _openclaw_unavailable_response("conversation_id is required.")
if not normalized_content:
return _openclaw_unavailable_response("content is required.")
config = _resolve_config(
openclaw_url,
openclaw_mode,
openclaw_token,
openclaw_command,
openclaw_args,
)
if config is None:
return _openclaw_unavailable_response("OpenClaw MCP integration is not configured.")
runtime_error = openclaw_runtime_unavailable_reason(config)
if runtime_error is not None:
return _openclaw_unavailable_response(runtime_error)
return _normalize_named_bridge_call(
config,
tool_name="message_send",
arguments={"conversationId": normalized_conversation_id, "content": normalized_content},
surface_tool_name="send_openclaw_message",
)
@tool(
name="call_openclaw_tool",
source="openclaw",
description="Call a named tool exposed by the configured OpenClaw MCP bridge.",
use_cases=[
"Reading OpenClaw conversations and recent transcript history",
"Polling OpenClaw event queues or responding through an existing route",
],
requires=["tool_name"],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"tool_name": {"type": "string"},
"arguments": {"type": "object"},
"openclaw_url": {"type": "string"},
"openclaw_mode": {"type": "string"},
"openclaw_token": {"type": "string"},
"openclaw_command": {"type": "string"},
"openclaw_args": {"type": "array", "items": {"type": "string"}},
},
"required": ["tool_name"],
},
is_available=_openclaw_available,
extract_params=_openclaw_extract_params,
)
def call_openclaw_bridge_tool(
tool_name: str | None = None,
arguments: OpenClawParams | None = None,
openclaw_url: str | None = None,
openclaw_mode: str | None = None,
openclaw_token: str | None = None,
openclaw_command: str | None = None,
openclaw_args: list[str] | None = None,
**_kwargs: object,
) -> OpenClawBridgeResponse:
"""Call a specific OpenClaw MCP bridge tool."""
normalized_tool_name = (tool_name or "").strip()
if not normalized_tool_name:
return _openclaw_unavailable_response(
"tool_name is required to call an OpenClaw MCP tool.",
arguments=arguments or {},
)
config = _resolve_config(
openclaw_url,
openclaw_mode,
openclaw_token,
openclaw_command,
openclaw_args,
)
if config is None:
return _openclaw_unavailable_response(
"OpenClaw MCP integration is not configured.",
tool_name=normalized_tool_name or None,
arguments=arguments or {},
)
runtime_error = openclaw_runtime_unavailable_reason(config)
if runtime_error is not None:
return _openclaw_unavailable_response(
runtime_error,
tool_name=normalized_tool_name,
arguments=arguments or {},
)
try:
result = invoke_openclaw_mcp_tool(config, normalized_tool_name, arguments or {})
except Exception as err:
report_run_error(
err,
tool_name="call_openclaw_tool",
source="openclaw",
component="integrations.openclaw.tools.openclaw_mcp_tool",
method="invoke_openclaw_mcp_tool",
extras={"mcp_tool": normalized_tool_name, "transport": config.mode},
)
return _openclaw_unavailable_response(
describe_openclaw_error(err, config),
tool_name=normalized_tool_name,
arguments=arguments or {},
)
return _normalize_tool_result(result)