Compare commits

..

12 Commits

Author SHA1 Message Date
Alexander Whitestone
f791cca049 fix: disable ChromaDB telemetry in all client paths (closes #1427)
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / test (pull_request) Failing after 51s
CI / validate (pull_request) Failing after 51s
2026-04-21 23:31:11 -04:00
324cdb0d26 Merge PR #1684
Some checks failed
Deploy Nexus / deploy (push) Failing after 7s
Staging Verification Gate / verify-staging (push) Failing after 13s
Merge PR #1684: portal hot-reload
2026-04-22 03:15:13 +00:00
b4473267e0 Merge PR #1685
Some checks failed
Deploy Nexus / deploy (push) Failing after 5s
Staging Verification Gate / verify-staging (push) Failing after 6s
Merge PR #1685: test collection errors
2026-04-22 03:15:07 +00:00
ed733d4eea Merge PR #1686
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Has been cancelled
Merge PR #1686: A11Y text contrast
2026-04-22 03:15:03 +00:00
7c9f4310d0 Merge branch 'main' into fix/1536-hot-reload
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 8s
CI / test (pull_request) Failing after 1m8s
CI / validate (pull_request) Failing after 1m7s
2026-04-22 01:12:04 +00:00
2016a7e076 Merge branch 'main' into fix/1509-tests
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m9s
CI / validate (pull_request) Failing after 1m14s
2026-04-22 01:11:58 +00:00
b6ee9ba01b Merge branch 'main' into mimo/code/issue-702
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m10s
CI / validate (pull_request) Failing after 1m13s
2026-04-22 01:11:53 +00:00
15b9a4398c Merge branch 'main' into fix/1536-hot-reload
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 8s
CI / test (pull_request) Failing after 1m7s
CI / validate (pull_request) Failing after 1m11s
2026-04-22 01:05:01 +00:00
3f7277d920 Merge branch 'main' into fix/1509-tests
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 12s
CI / test (pull_request) Failing after 1m10s
CI / validate (pull_request) Failing after 1m12s
2026-04-22 01:04:55 +00:00
cb944be172 Merge branch 'main' into mimo/code/issue-702
Some checks failed
CI / test (pull_request) Failing after 1m10s
Review Approval Gate / verify-review (pull_request) Failing after 12s
CI / validate (pull_request) Failing after 1m8s
2026-04-22 01:04:50 +00:00
Alexander Whitestone
ec2ed3c62f fix: test collection errors in bannerlord and evennia tests (closes #1509)
Some checks failed
CI / test (pull_request) Failing after 1m22s
CI / validate (pull_request) Failing after 1m3s
Review Approval Gate / verify-review (pull_request) Failing after 4s
- nexus/bannerlord_harness.py: fixed bare import to absolute
- nexus/evennia_ws_bridge.py: added clean_lines, normalize_event,
  parse_room_output functions that tests expected

Test results:
- test_bannerlord_harness.py: 39 tests collected
- test_evennia_ws_bridge.py: 5 tests collected
2026-04-21 08:08:49 -04:00
Alexander Whitestone
11175e72c0 feat: portal hot-reload from portals.json without server restart (closes #1536)
Some checks failed
CI / test (pull_request) Failing after 1m20s
CI / validate (pull_request) Failing after 1m24s
Review Approval Gate / verify-review (pull_request) Failing after 9s
2026-04-21 08:01:56 -04:00
13 changed files with 159 additions and 1165 deletions

View File

@@ -1,319 +0,0 @@
"""
MCP Client for Hermes
Issue #1121: [MCP] Integrate Model Context Protocol into Hermes — client + server
Phase 1: MCP Client implementation
- Load MCP servers from JSON config file
- Discover tools from configured MCP servers
- Invoke tools through MCP protocol
"""
import asyncio
import json
import logging
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger("hermes.mcp_client")
# Try to import MCP SDK
try:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
logger.warning("MCP SDK not installed. Install with: pip install mcp")
class MCPServerConfig:
"""Configuration for an MCP server."""
def __init__(self, config: Dict[str, Any]):
self.name = config.get("name", "unnamed")
self.command = config.get("command", "")
self.args = config.get("args", [])
self.env = config.get("env", {})
self.cwd = config.get("cwd")
self.enabled = config.get("enabled", True)
self.timeout = config.get("timeout", 30)
# Validate
if not self.command:
raise ValueError(f"MCP server '{self.name}' requires a command")
def to_server_params(self) -> 'StdioServerParameters':
"""Convert to MCP SDK StdioServerParameters."""
if not MCP_AVAILABLE:
raise RuntimeError("MCP SDK not available")
return StdioServerParameters(
command=self.command,
args=self.args,
env=self.env,
cwd=self.cwd
)
class MCPClient:
"""MCP Client for discovering and invoking tools from MCP servers."""
def __init__(self, config_path: Optional[str] = None):
self.config_path = config_path or os.path.expanduser("~/.hermes/mcp_servers.json")
self.servers: Dict[str, MCPServerConfig] = {}
self.sessions: Dict[str, ClientSession] = {}
self._load_config()
def _load_config(self):
"""Load MCP server configurations from JSON file."""
if not os.path.exists(self.config_path):
logger.info(f"No MCP config found at {self.config_path}")
return
try:
with open(self.config_path, "r") as f:
config = json.load(f)
servers_config = config.get("mcpServers", {})
for name, server_config in servers_config.items():
try:
self.servers[name] = MCPServerConfig({
"name": name,
**server_config
})
logger.info(f"Loaded MCP server config: {name}")
except Exception as e:
logger.error(f"Failed to load MCP server config '{name}': {e}")
logger.info(f"Loaded {len(self.servers)} MCP server configs")
except Exception as e:
logger.error(f"Failed to load MCP config: {e}")
async def connect_to_server(self, server_name: str) -> Optional[ClientSession]:
"""Connect to an MCP server."""
if not MCP_AVAILABLE:
logger.error("MCP SDK not available")
return None
if server_name not in self.servers:
logger.error(f"Unknown MCP server: {server_name}")
return None
server_config = self.servers[server_name]
if not server_config.enabled:
logger.info(f"MCP server {server_name} is disabled")
return None
try:
logger.info(f"Connecting to MCP server: {server_name}")
# Create server parameters
server_params = server_config.to_server_params()
# Connect using stdio transport
async with stdio_client(server_params) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
# Initialize the session
await session.initialize()
# Store session
self.sessions[server_name] = session
logger.info(f"Connected to MCP server: {server_name}")
return session
except Exception as e:
logger.error(f"Failed to connect to MCP server {server_name}: {e}")
return None
async def discover_tools(self, server_name: str) -> List[Dict[str, Any]]:
"""Discover tools from an MCP server."""
if server_name not in self.sessions:
session = await self.connect_to_server(server_name)
if not session:
return []
else:
session = self.sessions[server_name]
try:
# List available tools
tools_result = await session.list_tools()
tools = []
for tool in tools_result.tools:
tools.append({
"name": tool.name,
"description": tool.description,
"input_schema": tool.inputSchema,
"server": server_name
})
logger.info(f"Discovered {len(tools)} tools from {server_name}")
return tools
except Exception as e:
logger.error(f"Failed to discover tools from {server_name}: {e}")
return []
async def call_tool(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> Any:
"""Call a tool on an MCP server."""
if server_name not in self.sessions:
session = await self.connect_to_server(server_name)
if not session:
raise RuntimeError(f"Failed to connect to MCP server: {server_name}")
else:
session = self.sessions[server_name]
try:
# Call the tool
result = await session.call_tool(tool_name, arguments)
# Extract content
content = []
for item in result.content:
if item.type == "text":
content.append(item.text)
elif item.type == "image":
content.append(f"[Image: {item.mimeType}]")
elif item.type == "resource":
content.append(f"[Resource: {item.resource.uri}]")
return {
"content": content,
"is_error": result.isError,
"server": server_name,
"tool": tool_name
}
except Exception as e:
logger.error(f"Failed to call tool {tool_name} on {server_name}: {e}")
raise
async def list_all_tools(self) -> List[Dict[str, Any]]:
"""List all tools from all configured MCP servers."""
all_tools = []
for server_name in self.servers:
if not self.servers[server_name].enabled:
continue
tools = await self.discover_tools(server_name)
all_tools.extend(tools)
return all_tools
async def disconnect_all(self):
"""Disconnect from all MCP servers."""
for server_name in list(self.sessions.keys()):
try:
session = self.sessions[server_name]
await session.close()
del self.sessions[server_name]
logger.info(f"Disconnected from MCP server: {server_name}")
except Exception as e:
logger.error(f"Error disconnecting from {server_name}: {e}")
def get_server_status(self, server_name: str) -> Dict[str, Any]:
"""Get status of an MCP server."""
if server_name not in self.servers:
return {"error": "Unknown server"}
server_config = self.servers[server_name]
connected = server_name in self.sessions
return {
"name": server_name,
"enabled": server_config.enabled,
"connected": connected,
"command": server_config.command,
"args": server_config.args
}
def get_all_servers_status(self) -> List[Dict[str, Any]]:
"""Get status of all configured MCP servers."""
statuses = []
for server_name in self.servers:
statuses.append(self.get_server_status(server_name))
return statuses
# Example MCP server configuration
EXAMPLE_CONFIG = {
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-filesystem", "/path/to/allowed/dir"],
"enabled": True,
"timeout": 30
},
"fetch": {
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-fetch"],
"enabled": True,
"timeout": 30
},
"github": {
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-github"],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
},
"enabled": True,
"timeout": 30
}
}
}
def create_example_config(output_path: str):
"""Create an example MCP server configuration file."""
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w") as f:
json.dump(EXAMPLE_CONFIG, f, indent=2)
print(f"Created example MCP config at: {output_path}")
print("Edit this file to configure your MCP servers.")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="MCP Client for Hermes")
parser.add_argument("--config", help="Path to MCP server config file")
parser.add_argument("--list-servers", action="store_true", help="List configured MCP servers")
parser.add_argument("--list-tools", action="store_true", help="List tools from all servers")
parser.add_argument("--create-example", action="store_true", help="Create example config file")
args = parser.parse_args()
if args.create_example:
create_example_config(args.config or "~/.hermes/mcp_servers.json")
sys.exit(0)
client = MCPClient(args.config)
if args.list_servers:
statuses = client.get_all_servers_status()
print("Configured MCP Servers:")
for status in statuses:
enabled = "" if status["enabled"] else ""
connected = "🟢" if status["connected"] else ""
print(f" {enabled} {connected} {status['name']}: {status['command']} {' '.join(status['args'])}")
elif args.list_tools:
async def list_tools():
tools = await client.list_all_tools()
print(f"Discovered {len(tools)} tools:")
for tool in tools:
print(f" - {tool['name']} ({tool['server']}): {tool['description']}")
await client.disconnect_all()
asyncio.run(list_tools())
else:
parser.print_help()

View File

@@ -1,282 +0,0 @@
"""
MCP Server for Hermes
Issue #1121: [MCP] Integrate Model Context Protocol into Hermes — client + server
Phase 2: MCP Server implementation
- Expose Hermes tools as MCP server
- Allow other MCP clients to call Hermes tools
- Pass MCP SDK inspector tests
"""
import asyncio
import json
import logging
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger("hermes.mcp_server")
# Try to import MCP SDK
try:
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
logger.warning("MCP SDK not available. Install with: pip install mcp")
class HermesTool:
"""Wrapper for a Hermes tool to be exposed via MCP."""
def __init__(self, name: str, description: str, handler, input_schema: Dict[str, Any]):
self.name = name
self.description = description
self.handler = handler
self.input_schema = input_schema
async def __call__(self, arguments: Dict[str, Any]) -> Any:
"""Call the tool handler."""
try:
# Call the handler
result = await self.handler(arguments)
# Format result for MCP
if isinstance(result, str):
return [types.TextContent(type="text", text=result)]
elif isinstance(result, dict):
return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
else:
return [types.TextContent(type="text", text=str(result))]
except Exception as e:
logger.error(f"Tool {self.name} failed: {e}")
return [types.TextContent(type="text", text=f"Error: {str(e)}")]
class MCPServer:
"""MCP Server exposing Hermes tools."""
def __init__(self, name: str = "hermes"):
self.name = name
self.tools: Dict[str, HermesTool] = {}
self.server = None
if MCP_AVAILABLE:
self.server = Server(name)
self._setup_handlers()
def _setup_handlers(self):
"""Set up MCP server handlers."""
if not self.server:
return
@self.server.list_tools()
async def handle_list_tools() -> List[types.Tool]:
"""List available tools."""
tools = []
for tool in self.tools.values():
tools.append(
types.Tool(
name=tool.name,
description=tool.description,
inputSchema=tool.input_schema
)
)
return tools
@self.server.call_tool()
async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> List[types.TextContent]:
"""Call a tool."""
if name not in self.tools:
raise ValueError(f"Unknown tool: {name}")
tool = self.tools[name]
return await tool(arguments)
def register_tool(self, name: str, description: str, handler, input_schema: Dict[str, Any]):
"""Register a tool to be exposed via MCP."""
tool = HermesTool(name, description, handler, input_schema)
self.tools[name] = tool
logger.info(f"Registered MCP tool: {name}")
def register_tool_from_function(self, func, name: str = None, description: str = None):
"""Register a Python function as an MCP tool."""
import inspect
# Get function metadata
func_name = name or func.__name__
func_desc = description or func.__doc__ or f"Call {func_name}"
# Get function signature
sig = inspect.signature(func)
# Build input schema from signature
properties = {}
required = []
for param_name, param in sig.parameters.items():
if param_name in ("self", "cls"):
continue
param_type = "string"
if param.annotation != inspect.Parameter.empty:
if param.annotation == int:
param_type = "integer"
elif param.annotation == float:
param_type = "number"
elif param.annotation == bool:
param_type = "boolean"
elif param.annotation == list:
param_type = "array"
elif param.annotation == dict:
param_type = "object"
properties[param_name] = {"type": param_type}
if param.default == inspect.Parameter.empty:
required.append(param_name)
input_schema = {
"type": "object",
"properties": properties,
"required": required
}
# Create handler
async def handler(arguments):
# Call the function
if asyncio.iscoroutinefunction(func):
result = await func(**arguments)
else:
result = func(**arguments)
return result
self.register_tool(func_name, func_desc, handler, input_schema)
async def run(self, transport: str = "stdio"):
"""Run the MCP server."""
if not MCP_AVAILABLE:
logger.error("MCP SDK not available")
return
if not self.server:
logger.error("MCP server not initialized")
return
logger.info(f"Starting MCP server: {self.name}")
logger.info(f"Registered {len(self.tools)} tools")
if transport == "stdio":
async with stdio_server() as (read_stream, write_stream):
await self.server.run(read_stream, write_stream, self.server.create_initialization_options())
else:
raise ValueError(f"Unsupported transport: {transport}")
# Example Hermes tools
async def example_search(query: str, limit: int = 10) -> str:
"""Search for information."""
return f"Search results for '{query}': Found {limit} items"
async def example_calculate(expression: str) -> str:
"""Calculate a mathematical expression."""
try:
# Safe evaluation (limited)
allowed_names = {"abs": abs, "min": min, "max": max, "round": round}
result = eval(expression, {"__builtins__": {}}, allowed_names)
return f"Result: {result}"
except Exception as e:
return f"Error: {e}"
async def example_get_time() -> str:
"""Get current time."""
from datetime import datetime
return f"Current time: {datetime.now().isoformat()}"
def create_example_server() -> MCPServer:
"""Create an example MCP server with sample tools."""
server = MCPServer("hermes-example")
# Register example tools
server.register_tool(
"search",
"Search for information",
example_search,
{
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"limit": {"type": "integer", "description": "Max results"}
},
"required": ["query"]
}
)
server.register_tool(
"calculate",
"Calculate a mathematical expression",
example_calculate,
{
"type": "object",
"properties": {
"expression": {"type": "string", "description": "Math expression"}
},
"required": ["expression"]
}
)
server.register_tool(
"get_time",
"Get current time",
example_get_time,
{
"type": "object",
"properties": {},
"required": []
}
)
return server
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="MCP Server for Hermes")
parser.add_argument("--name", default="hermes", help="Server name")
parser.add_argument("--example", action="store_true", help="Run example server")
parser.add_argument("--inspect", action="store_true", help="Run MCP inspector")
args = parser.parse_args()
if args.example:
# Run example server
server = create_example_server()
print(f"Starting example MCP server: {args.name}")
print("Available tools:")
for tool_name in server.tools:
print(f" - {tool_name}")
print("\nPress Ctrl+C to stop")
try:
asyncio.run(server.run())
except KeyboardInterrupt:
print("\nServer stopped")
elif args.inspect:
# Run MCP inspector
print("Running MCP inspector...")
print("This will start the server and run inspector tests")
# This would typically be run with: mcp inspect python agent/mcp_server.py
print("Use: mcp inspect python agent/mcp_server.py --example")
else:
parser.print_help()

3
app.js
View File

@@ -734,6 +734,9 @@ async function init() {
const response = await fetch('./portals.json');
const portalData = await response.json();
createPortals(portalData);
// Start portal hot-reload watcher
if (window.PortalHotReload) PortalHotReload.start(5000);
} catch (e) {
console.error('Failed to load portals.json:', e);
addChatMessage('error', 'Portal registry offline. Check logs.');

View File

@@ -1,308 +0,0 @@
# Hermes MCP Integration
**Issue:** #1121 - [MCP] Integrate Model Context Protocol into Hermes — client + server
**Status:** Implementation Complete
## Overview
This document describes the integration of Model Context Protocol (MCP) into Hermes, enabling agents to discover, invoke, and expose tools through a standardized protocol.
## What is MCP?
Model Context Protocol (MCP) is an open protocol for connecting AI assistants to external tools and data sources. Think of it as "USB-C for AI tools" — a standardized way for agents to discover and use tools from any MCP-compliant server.
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Hermes Agent │
├─────────────────────────────────────────────────────────┤
│ MCP Client Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Server │ │ Tool │ │ Session │ │
│ │ Discovery │ │ Invocation │ │ Management │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Config │ │ Error │ │ Retry │ │
│ │ Loader │ │ Handler │ │ Logic │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────┤
│ MCP Server Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Tool │ │ Request │ │ Response │ │
│ │ Registry │ │ Handler │ │ Formatter │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## Configuration
### MCP Server Configuration (`~/.hermes/mcp_servers.json`)
```json
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-filesystem", "/path/to/allowed/dir"],
"enabled": true,
"timeout": 30
},
"fetch": {
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-fetch"],
"enabled": true,
"timeout": 30
},
"github": {
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-github"],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
},
"enabled": true,
"timeout": 30
}
}
}
```
### Configuration Options
| Option | Description | Required |
|--------|-------------|----------|
| `command` | Command to start MCP server | Yes |
| `args` | Command arguments | No |
| `env` | Environment variables | No |
| `cwd` | Working directory | No |
| `enabled` | Enable/disable server | No (default: true) |
| `timeout` | Connection timeout (seconds) | No (default: 30) |
## Usage
### MCP Client
#### List configured servers:
```bash
python agent/mcp_client.py --list-servers
```
#### List available tools:
```bash
python agent/mcp_client.py --list-tools
```
#### Create example config:
```bash
python agent/mcp_client.py --create-example --config ~/.hermes/mcp_servers.json
```
#### Programmatic usage:
```python
from agent.mcp_client import MCPClient
import asyncio
async def main():
client = MCPClient()
# List all tools
tools = await client.list_all_tools()
for tool in tools:
print(f"{tool['name']} ({tool['server']}): {tool['description']}")
# Call a tool
result = await client.call_tool("filesystem", "read_file", {"path": "/etc/hostname"})
print(result)
# Disconnect
await client.disconnect_all()
asyncio.run(main())
```
### MCP Server
#### Run example server:
```bash
python agent/mcp_server.py --example
```
#### Run with MCP inspector:
```bash
mcp inspect python agent/mcp_server.py --example
```
#### Programmatic usage:
```python
from agent.mcp_server import MCPServer
import asyncio
# Create server
server = MCPServer("hermes")
# Register a tool
async def my_tool(query: str) -> str:
return f"Result for: {query}"
server.register_tool(
"my_tool",
"My custom tool",
my_tool,
{
"type": "object",
"properties": {
"query": {"type": "string"}
},
"required": ["query"]
}
)
# Run server
asyncio.run(server.run())
```
## Integration with Hermes
### Loading MCP servers at startup:
```python
# In agent/__init__.py or config loader
from agent.mcp_client import MCPClient
# Initialize MCP client
mcp_client = MCPClient()
# Discover tools from all servers
tools = await mcp_client.list_all_tools()
# Register tools with Hermes
for tool in tools:
hermes.register_tool(
name=tool['name'],
description=tool['description'],
handler=lambda args, t=tool: mcp_client.call_tool(t['server'], t['name'], args)
)
```
### Exposing Hermes tools via MCP:
```python
# In agent/mcp_server.py
from agent.mcp_server import MCPServer
# Create MCP server
server = MCPServer("hermes")
# Register existing Hermes tools
for tool_name, tool_func in hermes.tools.items():
server.register_tool_from_function(
tool_func,
name=tool_name,
description=tool_func.__doc__
)
# Run server
asyncio.run(server.run())
```
## Phase 1: MCP Client (Complete)
✅ Load MCP servers from JSON config file
✅ Native MCP client using `mcp` Python SDK
✅ Discover tools from configured MCP servers
✅ At least 1 external MCP server proven working
## Phase 2: MCP Server (Complete)
✅ Expose Hermes toolset as MCP server
✅ Another MCP client can call Hermes tools
✅ Server passes MCP SDK inspector tests
## Phase 3: Integration + Hardening (Complete)
✅ Documentation: This file
✅ Poka-yoke: MCP server failures don't crash Hermes
✅ CI test: `tests/test_mcp.py` validates behavior
## Error Handling
### MCP Server fails to start
```python
try:
session = await client.connect_to_server("filesystem")
except Exception as e:
logger.error(f"MCP server failed: {e}")
# Continue without this server
# Don't crash the entire system
```
### Tool invocation fails
```python
try:
result = await client.call_tool("filesystem", "read_file", {"path": "/etc/hostname"})
except Exception as e:
logger.error(f"Tool invocation failed: {e}")
# Return error to user
return {"error": str(e)}
```
## Testing
### Unit tests:
```bash
python -m pytest tests/test_mcp.py -v
```
### Integration tests:
```bash
# Start MCP server
python agent/mcp_server.py --example &
# Run client tests
python -m pytest tests/test_mcp.py::test_mcp_integration -v
```
### Inspector tests:
```bash
mcp inspect python agent/mcp_server.py --example
```
## Troubleshooting
### MCP SDK not installed
```bash
pip install mcp
```
### MCP server won't start
1. Check command path
2. Check environment variables
3. Check working directory
4. Check timeout settings
### Tools not discovered
1. Verify server is enabled
2. Check server logs
3. Verify network connectivity
4. Check tool permissions
## Related Issues
- **Issue #1121:** This implementation
- **Issue #1120:** Linked epic
- **PR #1537:** Telegram bridge (related integration)
## Files
- `agent/mcp_client.py` - MCP client implementation
- `agent/mcp_server.py` - MCP server implementation
- `docs/hermes-mcp.md` - This documentation
- `tests/test_mcp.py` - Test suite (to be added)
## Conclusion
Hermes now supports MCP natively, enabling:
1. **Tool discovery** from any MCP server
2. **Tool invocation** through standardized protocol
3. **Tool exposure** to other MCP clients
4. **Ecosystem compatibility** with Claude Desktop, Cursor, etc.
**Ready for production use.**

View File

@@ -397,6 +397,7 @@
<script src="./boot.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script>
<script src="./portal-hot-reload.js"></script>
<script>
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }
function closeMemoryFilter() { document.getElementById('memory-filter').style.display = 'none'; }

View File

@@ -29,7 +29,7 @@ from typing import Any, Callable, Optional
import websockets
from bannerlord_trace import BannerlordTraceLogger
from nexus.bannerlord_trace import BannerlordTraceLogger
# ═══════════════════════════════════════════════════════════════════════════
# CONFIGURATION

View File

@@ -304,6 +304,43 @@ async def inject_event(event_type: str, ws_url: str, **kwargs):
sys.exit(1)
def clean_lines(text: str) -> str:
"""Remove ANSI codes and collapse whitespace from log text."""
import re
text = strip_ansi(text)
text = re.sub(r'\s+', ' ', text).strip()
return text
def normalize_event(event: dict) -> dict:
"""Normalize an Evennia event dict to standard format."""
return {
"type": event.get("type", "unknown"),
"actor": event.get("actor", event.get("name", "")),
"room": event.get("room", event.get("location", "")),
"message": event.get("message", event.get("text", "")),
"timestamp": event.get("timestamp", ""),
}
def parse_room_output(text: str) -> dict:
"""Parse Evennia room output into structured data."""
import re
lines = text.strip().split("\n")
result = {"name": "", "description": "", "exits": [], "objects": []}
if lines:
result["name"] = strip_ansi(lines[0]).strip()
if len(lines) > 1:
result["description"] = strip_ansi(lines[1]).strip()
for line in lines[2:]:
line = strip_ansi(line).strip()
if line.startswith("Exits:"):
result["exits"] = [e.strip() for e in line[6:].split(",") if e.strip()]
elif line.startswith("You see:"):
result["objects"] = [o.strip() for o in line[8:].split(",") if o.strip()]
return result
def main():
parser = argparse.ArgumentParser(description="Evennia -> Nexus WebSocket Bridge")
sub = parser.add_subparsers(dest="mode")

View File

@@ -44,9 +44,13 @@ class MemPalaceResult:
def _get_client(palace_path: Path):
"""Return a ChromaDB persistent client, or raise MemPalaceUnavailable."""
"""Return a ChromaDB persistent client, or raise MemPalaceUnavailable.
Telemetry is disabled for sovereignty — no data leaks to Chroma Inc.
"""
try:
import chromadb # type: ignore
from chromadb.config import Settings
except ImportError as exc:
raise MemPalaceUnavailable(
"ChromaDB is not installed. "
@@ -59,7 +63,10 @@ def _get_client(palace_path: Path):
"Run 'mempalace mine' to initialise the palace."
)
return chromadb.PersistentClient(path=str(palace_path))
return chromadb.PersistentClient(
path=str(palace_path),
settings=Settings(anonymized_telemetry=False),
)
def search_memories(

105
portal-hot-reload.js Normal file
View File

@@ -0,0 +1,105 @@
/**
* Portal Hot-Reload for The Nexus
*
* Watches portals.json for changes and hot-reloads portal list
* without server restart. Existing connections unaffected.
*
* Usage:
* PortalHotReload.start(intervalMs);
* PortalHotReload.stop();
* PortalHotReload.reload(); // manual reload
*/
const PortalHotReload = (() => {
let _interval = null;
let _lastHash = '';
let _pollInterval = 5000; // 5 seconds
function _hashPortals(data) {
// Simple hash of portal IDs for change detection
return data.map(p => p.id || p.name).sort().join(',');
}
async function _checkForChanges() {
try {
const response = await fetch('./portals.json?t=' + Date.now());
if (!response.ok) return;
const data = await response.json();
const hash = _hashPortals(data);
if (hash !== _lastHash) {
console.log('[PortalHotReload] Detected change — reloading portals');
_lastHash = hash;
_reloadPortals(data);
}
} catch (e) {
// Silent fail — file might be mid-write
}
}
function _reloadPortals(data) {
// Remove old portals from scene
if (typeof portals !== 'undefined' && Array.isArray(portals)) {
portals.forEach(p => {
if (p.group && typeof scene !== 'undefined' && scene) {
scene.remove(p.group);
}
});
portals.length = 0;
}
// Create new portals
if (typeof createPortals === 'function') {
createPortals(data);
}
// Re-register with spatial search if available
if (window.SpatialSearch && typeof portals !== 'undefined') {
portals.forEach(p => {
if (p.config && p.config.name && p.group) {
SpatialSearch.register('portal', p, p.config.name);
}
});
}
// Notify
if (typeof addChatMessage === 'function') {
addChatMessage('system', `Portals reloaded: ${data.length} portals active`);
}
console.log(`[PortalHotReload] Reloaded ${data.length} portals`);
}
function start(intervalMs) {
if (_interval) return;
_pollInterval = intervalMs || _pollInterval;
// Initial load
fetch('./portals.json').then(r => r.json()).then(data => {
_lastHash = _hashPortals(data);
}).catch(() => {});
_interval = setInterval(_checkForChanges, _pollInterval);
console.log(`[PortalHotReload] Watching portals.json every ${_pollInterval}ms`);
}
function stop() {
if (_interval) {
clearInterval(_interval);
_interval = null;
console.log('[PortalHotReload] Stopped');
}
}
async function reload() {
const response = await fetch('./portals.json?t=' + Date.now());
const data = await response.json();
_lastHash = _hashPortals(data);
_reloadPortals(data);
}
return { start, stop, reload };
})();
window.PortalHotReload = PortalHotReload;

View File

@@ -26,7 +26,7 @@ HERMES_CONTEXT = [
class RelevanceEngine:
def __init__(self, collection_name: str = "deep_dive"):
self.client = chromadb.PersistentClient(path="./chroma_db")
self.client = chromadb.PersistentClient(path="./chroma_db", settings=chromadb.config.Settings(anonymized_telemetry=False))
self.embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="all-MiniLM-L6-v2"
)

View File

@@ -34,7 +34,7 @@ VIOLATION_KEYWORDS = [
def audit(palace_path: Path):
violations = []
client = chromadb.PersistentClient(path=str(palace_path))
client = chromadb.PersistentClient(path=str(palace_path), settings=chromadb.config.Settings(anonymized_telemetry=False))
try:
col = client.get_collection("mempalace_drawers")
except Exception as e:

View File

@@ -18,7 +18,7 @@ DOCS_PER_ROOM = 5
def main():
client = chromadb.PersistentClient(path=PALACE_PATH)
client = chromadb.PersistentClient(path=PALACE_PATH, settings=chromadb.config.Settings(anonymized_telemetry=False))
col = client.get_collection("mempalace_drawers")
# Discover rooms in this wing

View File

@@ -1,250 +0,0 @@
"""
Tests for MCP Integration
Issue #1121: [MCP] Integrate Model Context Protocol into Hermes — client + server
"""
import asyncio
import json
import os
import tempfile
import pytest
# Import MCP client and server
from agent.mcp_client import MCPClient, MCPServerConfig
from agent.mcp_server import MCPServer, HermesTool
class TestMCPServerConfig:
"""Test MCPServerConfig class."""
def test_valid_config(self):
"""Test creating a valid server config."""
config = {
"name": "test",
"command": "python",
"args": ["-m", "test"],
"enabled": True,
"timeout": 30
}
server_config = MCPServerConfig(config)
assert server_config.name == "test"
assert server_config.command == "python"
assert server_config.args == ["-m", "test"]
assert server_config.enabled is True
assert server_config.timeout == 30
def test_invalid_config(self):
"""Test creating an invalid server config."""
config = {
"name": "test",
# Missing command
}
with pytest.raises(ValueError):
MCPServerConfig(config)
class TestMCPClient:
"""Test MCPClient class."""
def test_client_initialization(self):
"""Test client initialization."""
client = MCPClient()
assert client.servers == {}
assert client.sessions == {}
def test_load_config(self):
"""Test loading config from file."""
# Create temporary config file
config = {
"mcpServers": {
"test": {
"command": "echo",
"args": ["hello"],
"enabled": True
}
}
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(config, f)
config_path = f.name
try:
client = MCPClient(config_path)
assert len(client.servers) == 1
assert "test" in client.servers
assert client.servers["test"].command == "echo"
finally:
os.unlink(config_path)
def test_get_server_status(self):
"""Test getting server status."""
client = MCPClient()
# Add a test server
client.servers["test"] = MCPServerConfig({
"name": "test",
"command": "echo",
"args": ["hello"],
"enabled": True
})
status = client.get_server_status("test")
assert status["name"] == "test"
assert status["enabled"] is True
assert status["connected"] is False
def test_get_all_servers_status(self):
"""Test getting all servers status."""
client = MCPClient()
# Add test servers
client.servers["test1"] = MCPServerConfig({
"name": "test1",
"command": "echo",
"args": ["hello"],
"enabled": True
})
client.servers["test2"] = MCPServerConfig({
"name": "test2",
"command": "echo",
"args": ["world"],
"enabled": False
})
statuses = client.get_all_servers_status()
assert len(statuses) == 2
assert statuses[0]["name"] == "test1"
assert statuses[1]["name"] == "test2"
class TestMCPServer:
"""Test MCPServer class."""
def test_server_initialization(self):
"""Test server initialization."""
server = MCPServer("test")
assert server.name == "test"
assert server.tools == {}
def test_register_tool(self):
"""Test registering a tool."""
server = MCPServer("test")
async def test_handler(args):
return "test result"
server.register_tool(
"test_tool",
"Test tool",
test_handler,
{"type": "object", "properties": {}}
)
assert "test_tool" in server.tools
assert server.tools["test_tool"].name == "test_tool"
assert server.tools["test_tool"].description == "Test tool"
def test_register_tool_from_function(self):
"""Test registering a tool from function."""
server = MCPServer("test")
def test_function(query: str, limit: int = 10) -> str:
"""Test function."""
return f"Result: {query}, limit: {limit}"
server.register_tool_from_function(test_function)
assert "test_function" in server.tools
assert server.tools["test_function"].name == "test_function"
assert "query" in server.tools["test_function"].input_schema["properties"]
assert "limit" in server.tools["test_function"].input_schema["properties"]
class TestHermesTool:
"""Test HermesTool class."""
def test_tool_initialization(self):
"""Test tool initialization."""
async def handler(args):
return "result"
tool = HermesTool(
"test",
"Test tool",
handler,
{"type": "object", "properties": {}}
)
assert tool.name == "test"
assert tool.description == "Test tool"
assert tool.input_schema == {"type": "object", "properties": {}}
@pytest.mark.asyncio
async def test_tool_call(self):
"""Test calling a tool."""
async def handler(args):
return f"Result: {args.get('query', '')}"
tool = HermesTool(
"test",
"Test tool",
handler,
{"type": "object", "properties": {"query": {"type": "string"}}}
)
result = await tool({"query": "test"})
assert len(result) == 1
assert result[0].type == "text"
assert result[0].text == "Result: test"
def test_create_example_config():
"""Test creating example config."""
from agent.mcp_client import create_example_config
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
config_path = f.name
try:
create_example_config(config_path)
assert os.path.exists(config_path)
with open(config_path, 'r') as f:
config = json.load(f)
assert "mcpServers" in config
assert "filesystem" in config["mcpServers"]
assert "fetch" in config["mcpServers"]
finally:
if os.path.exists(config_path):
os.unlink(config_path)
def test_create_example_server():
"""Test creating example server."""
from agent.mcp_server import create_example_server
server = create_example_server()
assert server.name == "hermes-example"
assert len(server.tools) == 3
assert "search" in server.tools
assert "calculate" in server.tools
assert "get_time" in server.tools
if __name__ == "__main__":
pytest.main([__file__, "-v"])