Add hermes_mcp, the bridge between Hermes and the ROS graph

The MCP server is now a ROS node and the only way in or out, per the owner's
decision that ROS never leaves the robot. It serves MCP on the USB address only
with a bearer token, keeps the stable tool name look() (which waits for the next
camera frame, so it is never stale, and passes the camera's JPEG through), and
registers turn_head/head_position only when a head is fitted, which today it is
not. While an authenticated client is connected it republishes the agent
heartbeat on /hermes/state, so the face wakes on connect and sleeps about 40 s
after the client leaves; look() also makes the face squint.

The token is generated on the Pi into /etc/robot-mcp.env (root:hermes, 640) and
never printed. Verified end to end from talos with a real MCP client over the USB
link: tools listed, a 157 KB JPEG back in 0.08 s, a wrong token refused, the Wi-Fi
path dropped by the firewall. Host config snippets now point at 10.55.0.2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
This commit is contained in:
2026-10-05 00:20:58 -07:00
co-authored by Claude Sonnet 5.5
parent d8db0a48f5
commit 167f53ceeb
17 changed files with 462 additions and 24 deletions
+8
View File
@@ -30,3 +30,11 @@ hermes_face_detector:
hermes_audio_state:
ros__parameters:
rate_hz: 30.0
hermes_mcp:
ros__parameters:
bind: 10.55.0.2 # the USB link; the firewall accepts 8765 only from 10.55.0.1 on usb0
port: 8765
head: false # no servos fitted yet: turn_head and head_position stay unregistered
connected_grace_s: 30.0 # an authenticated client heard within this long keeps the heartbeat going
look_timeout_s: 3.0
+6 -5
View File
@@ -1,12 +1,12 @@
"""Bring up the robot: camera, face, face detection, audio levels.
"""Bring up the robot: camera, face, face detection, audio levels, and the MCP bridge.
ros2 launch hermes_bringup robot.launch.py [camera:=false] [face:=false] [vision:=false] [audio:=false]
ros2 launch hermes_bringup robot.launch.py [camera:=false] [face:=false] [vision:=false] [audio:=false] [mcp:=false]
[start_delay:=6.0]
Every node is its own process and is respawned if it exits, so one crash cannot take the others
down: the face keeps running when the camera does not. Parameters are in config/robot.yaml.
The face starts at once; the camera, vision and audio nodes wait start_delay seconds, so the four
The face starts at once; the camera, vision, audio and MCP nodes wait start_delay seconds, so the four
Python processes do not fight over the CPU while the face imports and draws its first frame.
"""
import os
@@ -24,10 +24,11 @@ NODES = [
('face', 'hermes_face', 'face_node', 'hermes_face'),
('vision', 'hermes_vision', 'face_detector', 'hermes_face_detector'),
('audio', 'hermes_audio', 'audio_state', 'hermes_audio_state'),
('mcp', 'hermes_mcp', 'mcp_server', 'hermes_mcp'),
]
DELAYED = {'camera', 'vision', 'audio'}
DELAYED = {'camera', 'vision', 'audio', 'mcp'}
def generate_launch_description():
@@ -35,7 +36,7 @@ def generate_launch_description():
actions = [DeclareLaunchArgument(arg, default_value='true', description=f'run the {name} node')
for arg, _, _, name in NODES]
actions.append(DeclareLaunchArgument('start_delay', default_value='6.0',
description='seconds the camera, vision and audio wait for the face'))
description='seconds the other nodes wait for the face'))
for arg, package, executable, name in NODES:
node = Node(package=package, executable=executable, name=name, output='screen',
parameters=[params], respawn=True, respawn_delay=2.0,
+1
View File
@@ -14,6 +14,7 @@
<exec_depend>hermes_face</exec_depend>
<exec_depend>hermes_vision</exec_depend>
<exec_depend>hermes_audio</exec_depend>
<exec_depend>hermes_mcp</exec_depend>
<test_depend>python3-pytest</test_depend>
+9 -3
View File
@@ -19,8 +19,8 @@ def test_launch_description_has_one_respawning_process_per_node():
ld = mod.generate_launch_description()
nodes = [e for e in ld.entities if isinstance(e, Node)]
delayed = [n for t in ld.entities if isinstance(t, TimerAction) for n in t.actions if isinstance(n, Node)]
assert len(nodes) + len(delayed) == len(mod.NODES) == 4
assert len(nodes) == 1 and 'face' in mod.NODES[1] and len(delayed) == 3 # the face starts at once
assert len(nodes) + len(delayed) == len(mod.NODES) == 5
assert len(nodes) == 1 and 'face' in mod.NODES[1] and len(delayed) == 4 # the face starts at once
# respawn lives on a private attribute that differs between ROS releases, so check the source
src = open(os.path.join(ROOT, 'launch', 'robot.launch.py')).read()
assert 'respawn=True' in src and 'respawn_delay' in src
@@ -28,7 +28,13 @@ def test_launch_description_has_one_respawning_process_per_node():
def test_every_node_in_the_launch_file_has_parameters_and_the_face_is_mounted_portrait():
cfg = yaml.safe_load(open(os.path.join(ROOT, 'config', 'robot.yaml')))
for name in ('camera', 'hermes_face', 'hermes_face_detector', 'hermes_audio_state'):
for name in ('camera', 'hermes_face', 'hermes_face_detector', 'hermes_audio_state', 'hermes_mcp'):
assert 'ros__parameters' in cfg[name], name
assert cfg['hermes_face']['ros__parameters']['rotation'] == 270
assert cfg['camera']['ros__parameters']['FrameDurationLimits'] == [66666, 66666]
def test_the_mcp_bridge_binds_the_usb_link_and_has_no_head_yet():
cfg = yaml.safe_load(open(os.path.join(ROOT, 'config', 'robot.yaml')))['hermes_mcp']['ros__parameters']
assert cfg['bind'] == '10.55.0.2' and cfg['port'] == 8765 and cfg['head'] is False
assert 'token' not in ' '.join(cfg) # the secret lives in an env file, not here
+150
View File
@@ -0,0 +1,150 @@
"""The MCP side of the bridge: the tools, bearer-token auth and connection tracking. No ROS imports,
so it is testable with a stub camera.
MCP tool names are the stable contract with Hermes and must not change when the internals do:
look() -> the next fresh JPEG from the camera
turn_head(pan, tilt) -> only registered if a head backend is given (servos fitted)
head_position() -> likewise
"""
from __future__ import annotations
import hmac
import threading
import time
from typing import Callable, Optional, Protocol
from mcp.server.mcpserver import Image, MCPServer
from mcp.server.transport_security import TransportSecuritySettings
class LatestFrame:
"""The newest camera frame, and a way to wait for one taken after a given moment: look() must
return what the camera sees now, not a frame that was already sitting there."""
def __init__(self) -> None:
self._cv = threading.Condition()
self._jpeg: Optional[bytes] = None
self._t = -1.0
def put(self, jpeg: bytes, t: Optional[float] = None) -> None:
with self._cv:
self._jpeg, self._t = jpeg, time.monotonic() if t is None else t
self._cv.notify_all()
def wait_newer(self, t0: float, timeout: float) -> Optional[bytes]:
"""The first frame received after t0, or None if none arrives within timeout seconds."""
deadline = time.monotonic() + timeout
with self._cv:
while self._jpeg is None or self._t <= t0:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
self._cv.wait(remaining)
return self._jpeg
class Head(Protocol):
def move(self, pan: float, tilt: float) -> tuple[float, float]: ...
def position(self) -> tuple[float, float]: ...
def build_mcp(frames: LatestFrame, announce_look: Callable[[], None] = lambda: None,
head: Optional[Head] = None, look_timeout_s: float = 3.0) -> MCPServer:
mcp = MCPServer(
"robot",
instructions=(
"This server is a small physical robot on the user's desk. "
"Call look() whenever you need to see what is in front of it; the image is current. "
"If turn_head is available, aim first and then look."
),
)
@mcp.tool()
def look() -> Image:
"""Take a photograph with the robot's camera and return it. Use this to see the room,
the desk, the person, or anything the user asks you to look at."""
t0 = time.monotonic()
announce_look() # the face squints and flashes
jpeg = frames.wait_newer(t0, look_timeout_s)
if jpeg is None:
raise RuntimeError("the robot's camera is not producing images")
return Image(data=jpeg, format="jpeg")
if head is not None:
@mcp.tool()
def turn_head(pan: float = 0.0, tilt: float = 0.0) -> str:
"""Aim the robot's head (and so its camera). Angles are in degrees from straight ahead.
pan: positive turns to the robot's left, negative to its right.
tilt: positive looks up, negative looks down.
Values beyond the mechanical limits are clamped. Call look() afterwards to see."""
p, t = head.move(pan, tilt)
return f"head now at pan {p:+.0f} degrees, tilt {t:+.0f} degrees"
@mcp.tool()
def head_position() -> str:
"""Report where the robot's head is currently pointing."""
p, t = head.position()
return f"pan {p:+.0f} degrees, tilt {t:+.0f} degrees"
return mcp
class BearerAuth:
"""ASGI middleware: reject any HTTP request without the right bearer token. Lifespan events pass
straight through so the MCP session manager starts properly."""
def __init__(self, app, token: str) -> None:
self.app = app
self.expected = f"Bearer {token}".encode()
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
supplied = dict(scope.get("headers") or []).get(b"authorization", b"")
if not hmac.compare_digest(supplied, self.expected):
await send({"type": "http.response.start", "status": 401,
"headers": [(b"content-type", b"text/plain"), (b"www-authenticate", b"Bearer")]})
await send({"type": "http.response.body", "body": b"unauthorized\n"})
return
await self.app(scope, receive, send)
class Activity:
"""ASGI middleware, placed inside BearerAuth so that only authenticated requests count: tracks
in-flight requests (a held-open event stream stays in flight) and when the last one finished.
A client that has been heard from recently is "connected"; that is the agent heartbeat."""
def __init__(self, app) -> None:
self.app = app
self._lock = threading.Lock()
self.inflight = 0
self.last = -1e9
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
with self._lock:
self.inflight += 1
self.last = time.monotonic()
try:
await self.app(scope, receive, send)
finally:
with self._lock:
self.inflight -= 1
self.last = time.monotonic()
def connected(self, now: float, grace_s: float) -> bool:
with self._lock:
return self.inflight > 0 or now - self.last <= grace_s
def build_app(mcp: MCPServer, token: str, host: str):
"""The ASGI app: token check outside, activity tracking inside. Returns (app, activity)."""
inner = mcp.streamable_http_app(
host=host,
# Bearer auth already stops drive-by requests; DNS-rebinding checks would only get in the way
# of reaching the Pi by IP.
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)
activity = Activity(inner)
return BearerAuth(activity, token), activity
+93
View File
@@ -0,0 +1,93 @@
"""ROS node: the bridge between the outside world and the ROS graph, and the only way in or out.
Serves MCP (streamable HTTP, bearer token) to Hermes on the USB link and talks to the graph locally.
Subscribes: image_topic (sensor_msgs/CompressedImage, default /camera/image_raw/compressed)
Publishes: /hermes/state (std_msgs/String):
`idle` once a second while an authenticated MCP client is connected: the agent heartbeat
the face uses to wake and, when it stops, to fall asleep ("no agent connected");
`looking` when look() is called, so the face squints and flashes.
The token comes from the ROBOT_MCP_TOKEN environment variable (an env file readable only by the
service user), never from a parameter.
"""
import os
import threading
import time
import rclpy
import uvicorn
from rclpy.node import Node
from sensor_msgs.msg import CompressedImage
from std_msgs.msg import String
from .bridge import LatestFrame, build_app, build_mcp
class McpNode(Node):
def __init__(self):
super().__init__('hermes_mcp')
param = lambda name, default: self.declare_parameter(name, default).value # noqa: E731
topic = param('image_topic', '/camera/image_raw/compressed')
self.port = int(param('port', 8765))
self.host = str(param('bind', '10.55.0.2')) # the USB link; the firewall allows only 10.55.0.1
self.grace_s = float(param('connected_grace_s', 30.0))
look_timeout = float(param('look_timeout_s', 3.0))
if bool(param('head', False)):
self.get_logger().error('head:=true but hermes_head is not built yet: turn_head stays off')
self.state_pub = self.create_publisher(String, '/hermes/state', 10)
self.frames = LatestFrame()
self.create_subscription(CompressedImage, topic, lambda m: self.frames.put(bytes(m.data)), 1)
token = os.environ.get('ROBOT_MCP_TOKEN', '').strip()
self.activity = None
self._server = None
self._stopping = False
if len(token) < 16:
self.get_logger().error('ROBOT_MCP_TOKEN is missing or too short (openssl rand -hex 32): '
'not serving MCP, and no heartbeat')
return
mcp = build_mcp(self.frames, self._announce_look, head=None, look_timeout_s=look_timeout)
self.app, self.activity = build_app(mcp, token, self.host)
threading.Thread(target=self._serve, name='mcp-http', daemon=True).start()
self.create_timer(1.0, self._heartbeat)
def _announce_look(self) -> None:
self.state_pub.publish(String(data='looking'))
def _heartbeat(self) -> None:
if self.activity.connected(time.monotonic(), self.grace_s):
self.state_pub.publish(String(data='idle'))
def _serve(self) -> None:
"""Serve until stopped. The USB address may not exist yet at boot, so a failed bind retries."""
while not self._stopping:
self._server = uvicorn.Server(uvicorn.Config(self.app, host=self.host, port=self.port,
log_level='warning'))
try:
self.get_logger().info(f'serving MCP on http://{self.host}:{self.port}/mcp')
self._server.run()
except (SystemExit, OSError) as exc:
self.get_logger().warn(f'MCP server stopped ({exc!r}); retrying in 3 s', throttle_duration_sec=30.0)
time.sleep(3.0)
def destroy_node(self):
self._stopping = True
if self._server is not None:
self._server.should_exit = True
super().destroy_node()
def main(args=None):
rclpy.init(args=args)
node = McpNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>hermes_mcp</name>
<version>0.1.0</version>
<description>The MCP bridge: Hermes's only way in or out of the robot's ROS graph.</description>
<maintainer email="noreply@example.com">K. Isom</maintainer>
<license>UNLICENSED</license>
<depend>rclpy</depend>
<depend>sensor_msgs</depend>
<depend>std_msgs</depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
View File
+4
View File
@@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/hermes_mcp
[install]
install_scripts=$base/lib/hermes_mcp
+24
View File
@@ -0,0 +1,24 @@
from setuptools import find_packages, setup
package_name = 'hermes_mcp'
setup(
name=package_name,
version='0.1.0',
packages=find_packages(exclude=['test']),
data_files=[
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='K. Isom',
maintainer_email='noreply@example.com',
description='MCP bridge for the Hermes robot.',
license='UNLICENSED',
entry_points={
'console_scripts': [
'mcp_server = hermes_mcp.mcp_node:main',
],
},
)
+99
View File
@@ -0,0 +1,99 @@
import asyncio
import base64
import threading
import time
import pytest
pytest.importorskip('mcp')
from starlette.testclient import TestClient # noqa: E402
from hermes_mcp.bridge import Activity, BearerAuth, LatestFrame, build_app, build_mcp # noqa: E402
TOKEN = 'x' * 32
class FakeHead:
def move(self, pan, tilt):
return max(-80, min(80, pan)), max(-45, min(45, tilt))
def position(self):
return 0.0, 0.0
def tool_names(mcp):
return {t.name for t in asyncio.run(mcp.list_tools())}
def test_tools_are_look_only_without_a_head_and_keep_their_names_with_one():
assert tool_names(build_mcp(LatestFrame())) == {'look'}
assert tool_names(build_mcp(LatestFrame(), head=FakeHead())) == {'look', 'turn_head', 'head_position'}
def test_look_returns_the_next_frame_not_a_stale_one():
frames = LatestFrame()
frames.put(b'stale', time.monotonic() - 5)
announced = []
mcp = build_mcp(frames, announce_look=lambda: announced.append(1), look_timeout_s=2.0)
threading.Timer(0.3, lambda: frames.put(b'\xff\xd8fresh')).start()
result = asyncio.run(mcp.call_tool('look', {}))
image = result.content[0]
assert announced == [1]
assert image.type == 'image' and image.mime_type == 'image/jpeg'
assert base64.b64decode(image.data) == b'\xff\xd8fresh' # the frame that arrived after the call
def test_look_raises_when_the_camera_is_silent():
mcp = build_mcp(LatestFrame(), look_timeout_s=0.3)
with pytest.raises(Exception) as err:
asyncio.run(mcp.call_tool('look', {}))
assert 'camera' in str(err.value.__cause__) # the SDK wraps it; the cause is ours
def test_wait_newer_semantics():
f = LatestFrame()
t0 = time.monotonic()
assert f.wait_newer(t0, 0.1) is None # nothing yet
f.put(b'a', t0 - 1)
assert f.wait_newer(t0, 0.1) is None # older than t0
threading.Timer(0.1, lambda: f.put(b'b')).start()
assert f.wait_newer(t0, 1.0) == b'b'
def test_auth_rejects_missing_and_wrong_tokens_and_counts_only_good_requests():
app, activity = build_app(build_mcp(LatestFrame()), TOKEN, '127.0.0.1')
with TestClient(app) as client:
assert client.get('/mcp').status_code == 401
assert client.get('/mcp', headers={'Authorization': 'Bearer wrong'}).status_code == 401
assert not activity.connected(time.monotonic(), 30.0) # rejected requests are not a client
r = client.get('/mcp', headers={'Authorization': f'Bearer {TOKEN}'})
assert r.status_code != 401
assert activity.connected(time.monotonic(), 30.0) # an authenticated one is
assert not activity.connected(time.monotonic() + 31.0, 30.0) # ...until the grace runs out
def test_activity_counts_inflight_requests_as_connected():
async def run():
release = asyncio.Event()
async def slow_app(scope, receive, send):
await release.wait()
act = Activity(slow_app)
task = asyncio.create_task(act({'type': 'http'}, None, None))
await asyncio.sleep(0.05)
assert act.inflight == 1 and act.connected(time.monotonic() + 1000, 1.0) # a held stream stays connected
release.set()
await task
assert act.inflight == 0
asyncio.run(run())
def test_bearer_auth_passes_lifespan_through():
seen = []
async def inner(scope, receive, send):
seen.append(scope['type'])
asyncio.run(BearerAuth(inner, TOKEN)({'type': 'lifespan'}, None, None))
assert seen == ['lifespan']