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:
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user