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>
116 lines
3.7 KiB
Python
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()
|