Files
synapbus/tests/e2e/lib/mcp_client.py
T
Algis DumbrisandClaude Opus 4.6 b88d52276a feat: add admin CLI, API keys, threads, and Slack-like UI redesign
Major feature additions across backend and frontend:

Backend:
- Unix domain socket admin server with JSON-RPC protocol
- CLI subcommands: user/agent management, audit, backup, messages, channels
- Managed API keys (sb_ prefix) with permissions, channel limits, expiry
- Thread/reply support in messaging core (reply_to column)
- Context-based trace owner_id propagation for proper audit filtering
- Two-step auth middleware supporting both agent keys and managed API keys

Frontend:
- Complete Slack-like dark theme redesign with custom CSS properties
- Sidebar with Channels, Direct Messages, and Admin sections
- Thread panel (slide-in) for viewing message replies
- API key management page with create form, key display, and
  ready-to-use MCP/Claude Code config snippets with copy-to-clipboard
- All pages restyled: login, dashboard, agents, conversations, settings

E2E Tests:
- Fixed test runner binary path resolution
- Added pyproject.toml for test dependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:10:51 +02:00

116 lines
3.7 KiB
Python

"""SynapBus MCP Streamable HTTP client with request/response logging."""
from __future__ import annotations
import json
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import httpx
@dataclass
class MCPLog:
"""Single MCP request/response log entry."""
method: str
params: Optional[Dict[str, Any]]
response: Any
error: Optional[str]
duration: float
timestamp: float
class SynapBusMCP:
"""Thin MCP client for SynapBus Streamable HTTP transport with logging."""
def __init__(self, base_url: str, api_key: Optional[str] = None):
self.url = "{}/mcp".format(base_url)
self.api_key = api_key
self.session_id: Optional[str] = None
self._req_id = 0
self._client = httpx.Client(timeout=30)
self.logs: List[MCPLog] = []
def _next_id(self) -> int:
self._req_id += 1
return self._req_id
def _headers(self) -> Dict[str, str]:
h: Dict[str, str] = {"Content-Type": "application/json"}
if self.api_key:
h["Authorization"] = "Bearer {}".format(self.api_key)
if self.session_id:
h["Mcp-Session-Id"] = self.session_id
return h
def _rpc(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
body: Dict[str, Any] = {
"jsonrpc": "2.0",
"id": self._next_id(),
"method": method,
}
if params:
body["params"] = params
start = time.time()
error_str: Optional[str] = None
response: Any = None
try:
resp = self._client.post(self.url, json=body, headers=self._headers())
resp.raise_for_status()
if sid := resp.headers.get("Mcp-Session-Id"):
self.session_id = sid
response = resp.json()
return response
except Exception as e:
error_str = str(e)
raise
finally:
self.logs.append(MCPLog(
method=method,
params=params,
response=response,
error=error_str,
duration=time.time() - start,
timestamp=time.time(),
))
def initialize(self) -> Dict[str, Any]:
return self._rpc("initialize", {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "synapbus-e2e-test", "version": "2.0"},
})
def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Call an MCP tool. Returns parsed JSON result or error dict."""
result = self._rpc("tools/call", {"name": name, "arguments": arguments})
if "error" in result:
return result
content = result.get("result", {}).get("content", [])
# Check for isError flag in result
is_error = result.get("result", {}).get("isError", False)
for block in content:
if block.get("type") == "text":
try:
parsed = json.loads(block["text"])
if is_error:
parsed["_mcp_error"] = True
return parsed
except json.JSONDecodeError:
return {"_raw": block["text"], "_mcp_error": is_error}
return result
def call_tool_raw(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Call an MCP tool, returning the full JSON-RPC response."""
return self._rpc("tools/call", {"name": name, "arguments": arguments})
def list_tools(self) -> List[Dict[str, Any]]:
result = self._rpc("tools/list")
return result.get("result", {}).get("tools", [])
def close(self) -> None:
self._client.close()