diff --git a/CONTEXT.md b/CONTEXT.md
index a708c15..0c236e0 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -4,8 +4,10 @@ Last updated 2026-10-04. `HANDOFF.md` is the brief; this file tracks where the w
## Hardware on hand
-Raspberry Pi 4 (8 GB), BrainCraft HAT, Arducam UC-346 camera. Not yet: servos, USB-C splitter,
-printed enclosure, speakers. Work until those arrive is limited to what the Pi, HAT and camera can do.
+Raspberry Pi 4 (8 GB), BrainCraft HAT, Arducam UC-346 camera, and nothing else: **no servos, no
+fan, no speakers**, no USB-C splitter, no printed enclosure (the camera hangs loose on its ribbon and is
+held by hand). The Pi runs at about 62 C open-air with everything on and no fan; an enclosure will be
+hotter, so fit the fan before enclosing it. Playback and the servos are untested. Work until those arrive is limited to what the Pi, HAT and camera can do.
## The Pi (`hermes-bot`)
@@ -205,6 +207,26 @@ printed enclosure, speakers. Work until those arrive is limited to what the Pi,
49 face tests, 10 audio tests. **Not yet verified:** the audio node as a service (it was run as a
transient unit).
+## The MCP bridge (`ros/hermes_mcp`)
+
+The only way in or out of the ROS graph. Serves MCP (SDK 2.3, streamable HTTP) on
+`http://10.55.0.2:8765/mcp`, the USB address only (retrying the bind if the link is not up yet), with
+a bearer token. **Tool names are the stable contract with Hermes**: `look()`, and `turn_head` /
+`head_position` only when a head is fitted. With no servos only `look` is registered, by design
+(`head: false`; the node refuses `head: true` until `hermes_head` exists). `look()` waits for the next
+camera frame taken after the call (never a stale one) and passes the camera's JPEG through (1296x972,
+~155 KB); it also publishes `looking` on `/hermes/state` so the face squints and flashes.
+While an authenticated client is connected (in flight, or heard within `connected_grace_s` = 30) it
+republishes `idle` on `/hermes/state` once a second: the agent heartbeat that wakes the face. Verified
+end to end from talos over the USB link with a real MCP client: connects, lists `look`, `look()`
+returns a JPEG in 0.08 s, a wrong token is refused, the Wi-Fi path is dropped by the firewall, and
+the face went `looking` -> `idle` on connect and `agent gone, sleeping` about 40 s after the client
+left. The token is in `/etc/robot-mcp.env` (root:hermes, mode 640), generated by
+`pi/install-robot-service.sh` and never printed; read it with `sudo cat`. The MCP SDK is in `~/venv`
+(`pip install "mcp>=2"`). The old `pi/robot_mcp_server.py`, `pi/robot-mcp.service` and `pi/install.sh`
+are superseded (package 5 rewrites the installer). **Not yet verified:** a real Hermes client, and
+whether Hermes passes MCP image content to the model (HANDOFF package 6).
+
## Face recognition (opt-in)
`hermes_vision.recognize` / `people`: SFace embeddings on YuNet's landmarks. Nothing is stored until
@@ -223,11 +245,9 @@ unrelated photo scored 0.16. **Nobody is enrolled yet**; the owner enrols themse
`host/nixos/`. A new Omarchy host with a GPU (runs the local
model "ornith") arrives this week: it needs only `host/omarchy/robot-usb-link.sh` and its SSH key
added to the Pi.
-2. `hermes_mcp`: the MCP server as an `rclpy` node (stable tool names; `look()` returns the latest
- camera image, `turn_head()` calls the head action), the only bridge to the outside; it must
- republish `/hermes/state` about once a second as the agent heartbeat. Then `hermes_head` and
- the servos when they arrive, echo cancel and the `robot_mic` naming once speakers arrive, the
- Wi-Fi-off boot test, and the rest of `HANDOFF.md`.
+2. `hermes_head` and the servos when they arrive (then `head: true`), echo cancel and the `robot_mic`
+ naming once speakers arrive, a fan before any enclosure, the Wi-Fi-off boot test, a real Hermes
+ client against `hermes_mcp`, and the rest of `HANDOFF.md`.
3. Confirm the retuned listening detector live (the flicker fix is unit-tested only); a second person
in frame for the recognition test (not possible on 2026-10-04).
diff --git a/omarchy/50-robot-tunnel.conf b/omarchy/50-robot-tunnel.conf
index 1613ccd..014fe99 100644
--- a/omarchy/50-robot-tunnel.conf
+++ b/omarchy/50-robot-tunnel.conf
@@ -1,8 +1,8 @@
# ~/.config/pipewire/pipewire.conf.d/50-robot-tunnel.conf (on Omarchy)
#
# Makes the robot's speaker and microphone appear here as ordinary audio devices,
-# "Robot speaker" and "Robot microphone". Replace ROBOT_IP with the Pi's address
-# (give it a DHCP reservation on your router so it never changes).
+# "Robot speaker" and "Robot microphone". The robot is 10.55.0.2 on the USB link, a fixed address;
+# the robot's firewall accepts port 4713 only over that link, from 10.55.0.1.
#
# Apply with: systemctl --user restart pipewire pipewire-pulse wireplumber
@@ -10,7 +10,7 @@ context.modules = [
{ name = libpipewire-module-pulse-tunnel
args = {
tunnel.mode = sink
- pulse.server.address = "tcp:ROBOT_IP:4713"
+ pulse.server.address = "tcp:10.55.0.2:4713"
#pulse.device = "robot_ec_sink" # only with the Pi's optional echo-cancel config
pulse.latency = 200 # ms; lower is snappier but may crackle on weak Wi-Fi
reconnect.interval.ms = 5000 # keep retrying while the robot is off
@@ -23,7 +23,7 @@ context.modules = [
{ name = libpipewire-module-pulse-tunnel
args = {
tunnel.mode = source
- pulse.server.address = "tcp:ROBOT_IP:4713"
+ pulse.server.address = "tcp:10.55.0.2:4713"
#pulse.device = "robot_ec_source" # only with the Pi's optional echo-cancel config
pulse.latency = 200
reconnect.interval.ms = 5000
diff --git a/omarchy/hermes-config-snippet.yaml b/omarchy/hermes-config-snippet.yaml
index 320b874..ae4e0ad 100644
--- a/omarchy/hermes-config-snippet.yaml
+++ b/omarchy/hermes-config-snippet.yaml
@@ -1,10 +1,14 @@
# Merge these into ~/.hermes/config.yaml on Omarchy.
-# Put the token from the Pi's install.sh output in ~/.hermes/.env as:
-# ROBOT_MCP_TOKEN=<64 hex characters>
+# The robot is at 10.55.0.2 on the USB link (the host is 10.55.0.1; see host/omarchy/robot-usb-link.sh).
+# Put the token in ~/.hermes/.env as: ROBOT_MCP_TOKEN=<64 hex characters>
+# It lives only on the Pi, in /etc/robot-mcp.env (read it with: ssh hermes@10.55.0.2 sudo cat /etc/robot-mcp.env).
+# The robot's firewall accepts this port only over the USB link, from 10.55.0.1.
+# While Hermes holds a connection the robot's face is awake; when it stops, the face falls asleep after
+# about 40 seconds. A robot with no head fitted offers only look(); turn_head appears when servos do.
mcp_servers:
robot:
- url: "http://ROBOT_IP:8765/mcp"
+ url: "http://10.55.0.2:8765/mcp"
headers:
Authorization: "Bearer ${ROBOT_MCP_TOKEN}"
connect_timeout: 15
diff --git a/pi/install-robot-service.sh b/pi/install-robot-service.sh
index 9e4c4d1..fadc7c5 100755
--- a/pi/install-robot-service.sh
+++ b/pi/install-robot-service.sh
@@ -1,10 +1,17 @@
#!/bin/bash
-# One unit runs the whole robot (face, camera, vision, audio) via the hermes_bringup launch file, so
+# One unit runs the whole robot (face, camera, vision, audio, MCP bridge) via the hermes_bringup launch file, so
# the face is up whenever the robot is powered, and everything is ROS. Each node is its own process
# and is respawned by the launch file, so one crash cannot take the others down.
# Replaces the earlier standalone robot-face.service. Run as root from this directory. Re-runnable.
set -euo pipefail
cd "$(dirname "$0")"
+# The MCP bridge's bearer token: created once, readable only by root and the service user, and never
+# printed (read it yourself with `sudo cat /etc/robot-mcp.env` when configuring the host).
+if [ ! -s /etc/robot-mcp.env ]; then
+ (umask 077; printf 'ROBOT_MCP_TOKEN=%s\n' "$(openssl rand -hex 32)" > /etc/robot-mcp.env)
+fi
+chown root:hermes /etc/robot-mcp.env
+chmod 640 /etc/robot-mcp.env
install -m 644 robot-cpu-performance.service robot.service /etc/systemd/system/
systemctl disable --now robot-face.service 2>/dev/null || true
rm -f /etc/systemd/system/robot-face.service
diff --git a/pi/robot.service b/pi/robot.service
index 02311a7..97ad872 100644
--- a/pi/robot.service
+++ b/pi/robot.service
@@ -1,5 +1,5 @@
[Unit]
-Description=Robot (hermes_bringup): face, camera, face detection, audio levels
+Description=Robot (hermes_bringup): face, camera, face detection, audio levels, MCP bridge
# Nothing here needs the user session: audio_state retries until the user's PipeWire is up.
After=local-fs.target robot-cpu-performance.service robot-firewall.service
@@ -9,6 +9,8 @@ User=hermes
# be writable: systemd's default, /, is not.
RuntimeDirectory=robot
WorkingDirectory=/run/robot
+# hermes_mcp reads its bearer token from here (root:hermes, mode 640); it is never in a parameter
+EnvironmentFile=-/etc/robot-mcp.env
# audio_state reaches the user's PipeWire through this
Environment=XDG_RUNTIME_DIR=/run/user/1000
ExecStart=/bin/bash -c 'source /home/hermes/robot-env.sh && exec ros2 launch hermes_bringup robot.launch.py'
diff --git a/ros/hermes_bringup/config/robot.yaml b/ros/hermes_bringup/config/robot.yaml
index 968255f..d80e119 100644
--- a/ros/hermes_bringup/config/robot.yaml
+++ b/ros/hermes_bringup/config/robot.yaml
@@ -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
diff --git a/ros/hermes_bringup/launch/robot.launch.py b/ros/hermes_bringup/launch/robot.launch.py
index 6be6701..20bd321 100644
--- a/ros/hermes_bringup/launch/robot.launch.py
+++ b/ros/hermes_bringup/launch/robot.launch.py
@@ -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,
diff --git a/ros/hermes_bringup/package.xml b/ros/hermes_bringup/package.xml
index a78c20c..135a870 100644
--- a/ros/hermes_bringup/package.xml
+++ b/ros/hermes_bringup/package.xml
@@ -14,6 +14,7 @@
hermes_face
hermes_vision
hermes_audio
+ hermes_mcp
python3-pytest
diff --git a/ros/hermes_bringup/test/test_launch.py b/ros/hermes_bringup/test/test_launch.py
index aae216f..bd2f5ca 100644
--- a/ros/hermes_bringup/test/test_launch.py
+++ b/ros/hermes_bringup/test/test_launch.py
@@ -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
diff --git a/ros/hermes_mcp/hermes_mcp/__init__.py b/ros/hermes_mcp/hermes_mcp/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ros/hermes_mcp/hermes_mcp/bridge.py b/ros/hermes_mcp/hermes_mcp/bridge.py
new file mode 100644
index 0000000..d87fc72
--- /dev/null
+++ b/ros/hermes_mcp/hermes_mcp/bridge.py
@@ -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
diff --git a/ros/hermes_mcp/hermes_mcp/mcp_node.py b/ros/hermes_mcp/hermes_mcp/mcp_node.py
new file mode 100644
index 0000000..7efd85d
--- /dev/null
+++ b/ros/hermes_mcp/hermes_mcp/mcp_node.py
@@ -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()
diff --git a/ros/hermes_mcp/package.xml b/ros/hermes_mcp/package.xml
new file mode 100644
index 0000000..4023f6c
--- /dev/null
+++ b/ros/hermes_mcp/package.xml
@@ -0,0 +1,19 @@
+
+
+
+ hermes_mcp
+ 0.1.0
+ The MCP bridge: Hermes's only way in or out of the robot's ROS graph.
+ K. Isom
+ UNLICENSED
+
+ rclpy
+ sensor_msgs
+ std_msgs
+
+ python3-pytest
+
+
+ ament_python
+
+
diff --git a/ros/hermes_mcp/resource/hermes_mcp b/ros/hermes_mcp/resource/hermes_mcp
new file mode 100644
index 0000000..e69de29
diff --git a/ros/hermes_mcp/setup.cfg b/ros/hermes_mcp/setup.cfg
new file mode 100644
index 0000000..3a236aa
--- /dev/null
+++ b/ros/hermes_mcp/setup.cfg
@@ -0,0 +1,4 @@
+[develop]
+script_dir=$base/lib/hermes_mcp
+[install]
+install_scripts=$base/lib/hermes_mcp
diff --git a/ros/hermes_mcp/setup.py b/ros/hermes_mcp/setup.py
new file mode 100644
index 0000000..c3b63a6
--- /dev/null
+++ b/ros/hermes_mcp/setup.py
@@ -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',
+ ],
+ },
+)
diff --git a/ros/hermes_mcp/test/test_bridge.py b/ros/hermes_mcp/test/test_bridge.py
new file mode 100644
index 0000000..d84da23
--- /dev/null
+++ b/ros/hermes_mcp/test/test_bridge.py
@@ -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']