get_robot_state
Current joint positions [rad], TCP pose [m, quaternion xyzw] in the robot base frame, estimated external wrench [N, N*m], gripper state and safety status. Read-only.
Takes no parameters.

io.github.Lynn-hhPublished by GitHub account Lynn-hhv0.1.1Updated Oct 27, 2026
Safety-first MCP server for ROS 2 robot arms: server-side safety envelope, human approval, audit
Safety-first MCP server for ROS 2 robot arms: inspect state, plan and execute motions, and control a gripper under a server-enforced safety envelope.
Desktop only via STDIO. STDIO servers start a local process, so they need the SourceWeft desktop host.
Follow the launch instructions in the repository.
[CI] [PyPI] [License: Apache-2.0] [Python 3.10 | 3.12] [ROS 2 Jazzy] [MCP 2026-07-28] [status: alpha] [M8ven Verified]
A safety-first Model Context Protocol server for ROS 2 manipulators. It lets an LLM agent inspect a robot arm, plan motions, preview them and execute them. Every action passes through a safety envelope that the server enforces outside the model: joint limits, a workspace box and keep-out zones, speed and step-size caps, force/torque thresholds, allowlists, rate limits, a software e-stop, human approval through MCP elicitation, and an audit log. A prompt, or a prompt injection hidden in a camera image or topic string, can change what the model asks for. It cannot change what the server allows.
Status: alpha. The policy engine, safety envelope, approval flow, audit log and all 22 MCP tools run against a simulated Franka FR3 backend. The ROS 2 backend (MoveIt 2,
ros2_control,franka_ros2) is tested on ROS 2 Jazzy against real rclpy endpoints and a real MoveIt 2move_group. It has not been run on a physical robot yet.This is defense in depth, NOT certified functional safety. See Safety scope and non-goals.
Real output of scripts/demo_fake.py against the simulated FR3, abridged (full transcript).
No ROS, no clone. The package ships the FR3 demo policy, so the simulated arm starts with one command:
Point your MCP client at it and ask it to move the arm. Claude Desktop (claude_desktop_config.json)
and Cursor (.cursor/mcp.json):
Claude Code:
Then try, in order: "Where is the arm?", "Move the gripper 10 cm up" (runs), "Rotate joint 1 by
50 degrees" (asks you to approve), "Move to x=0.62, y=0.42, z=0.30" (refused: that is inside the
camera_mount keep-out zone). Every decision lands in the audit log; --audit-log audit.jsonl keeps it.
The bundled policy is for the simulated robot only: --backend ros2 always requires your own --policy.
More detail: docs/architecture.md, docs/threat-model.md, docs/demo.md (a real transcript), CONTRIBUTING.md.
Generic bridges such as robotmcp/ros-mcp-server do an
excellent job of what they aim for: they connect any MCP client to any ROS 1 or ROS 2 robot through
rosbridge, with no changes to the robot's code. The LLM can publish to topics, call services and
actions, and set parameters. That generality is the point, and it also means the model can reach anything
the ROS graph exposes. Their open issue #283
("Security: Unrestricted ROS service calls via MCP tool — prompt injection → robot control") describes the
resulting risk, and the project's README lists permissions among the contributions it welcomes.
armguard-mcp makes the opposite trade-off. It is narrow, specific to manipulators, and restrictive by design:
If you want to explore a robot freely from a chat window, use a generic bridge. If you want an agent near a real arm with limits it cannot talk its way around, that is what this project is for.
The LLM is never in the control loop. MCP works at the level of skills and tasks: plan, preview, execute,
grasp. Real-time control, such as 1 kHz impedance and force control, stays in ros2_control and the Franka
controllers. The server checks a whole trajectory before it runs and watches the external wrench while it
runs.
From PyPI, no clone needed (the bundled FR3 policy is used when --policy is omitted):
From source, for the tests, the demo and the evaluation scripts:
armguard-mcp --help lists every flag: --policy (required), --backend fake|ros2, --ros2-config,
--transport stdio|http, --host (default 127.0.0.1), --port (default 8765), --audit-log,
--dry-run (forces dry-run on top of the policy), --log-level, --version. Logs go to stderr only, so
stdout carries nothing but MCP messages. A policy that fails validation, a backend that is not
available, or an audit log that cannot be opened exits with status 2 before the server starts. Binding
HTTP to a non-loopback address logs a warning. SIGTERM (docker stop, systemd, MCP hosts) is handled like
Ctrl-C and end of input: the server commands a stop of the arm and gripper, then shuts the backend down.
The fake backend is a kinematic FR3 with real FR3 forward and inverse kinematics, time-scaled execution, a
Franka Hand model, generated camera images and a fake ros2_control graph. It starts at the policy's
home_joint_positions.
.mcp.json, stdio)Copy examples/mcp.json to .mcp.json at your project root, or run
claude mcp add with the same command and arguments. Use absolute paths.
Start with --dry-run or examples/policies/readonly.yaml, which
registers only 12 read-only and safety tools, until you have read the policy. If your client does not
support elicitation, every action that needs approval is denied. That is deliberate.
skills/armguard-mcp/SKILL.md is an agent skill (the SKILL.md format used by
Claude Code and other agents) that teaches an agent the look, plan, review, execute loop, how human
approval and the e-stop work, and what it must never attempt. Copy the skills/armguard-mcp/ folder into
your agent's skills directory (for Claude Code: .claude/skills/ in your project or ~/.claude/skills/).
examples/python_client.py is runnable; this is the core of it:
For HTTP, pass the URL instead: Client("http://127.0.0.1:8765/mcp").
The script plays the LLM side of an MCP session against the real server (in-memory transport, fake FR3, examples/policies/fr3.yaml) and measures three things. CI runs it on every push and fails if any unsafe request gets through.
1. Unsafe requests. 23 requests an LLM, or a prompt injection, could make. A scenario passes only if the server refuses it (or clamps or aborts it, where that is the designed behavior) and no actuation command reaches the robot beyond what the scenario allows. The script checks this by wrapping the backend's actuation methods and comparing the robot's joint state before and after, not by trusting the server's own answer.
2. Safe requests. Random targets within 12 cm of the home pose, inside the workspace and at least 3 cm from every keep-out zone, planned and executed one after another from wherever the arm ended up. After each move the TCP is checked independently against the workspace box and keep-out zones. Rate limits are raised for this run only; scenario 23 above covers them.
A run with --moves 1000 --seed 1 gives 1000/1000 executed, 0 rejected, 0 outside the envelope.
3. Link geometry (physics-model check). The envelope checks the TCP path only. To measure what that
misses, scripts/eval_link_collisions.py replays the joint waypoints of every accepted plan through the
Franka FR3 URDF in NVIDIA Newton: forward kinematics per
waypoint, every link's collision mesh transformed into the world, vertices tested against the keep-out
boxes, and a signed-distance self-collision check between non-adjacent links. (Newton's FK agrees with
armguard's own FR3 kinematics to 0.00 mm.)
So the TCP-only envelope is sufficient for small moves near home, and insufficient when a target lies just
outside a zone: the TCP path stays clear, as checked, while the hand body or the wrist links swing through
the box. This is the "elbow can still enter a keep-out box" limitation below, now with a number. A
link-level check is on the roadmap. Caveats: the check is kinematic (no dynamics); it tests collision-mesh
vertices, so a box that only touches a face between vertices is missed; the self-collision check samples
300 vertices per link; link0 to link2 sit inside robot_base_column by design and are excluded from the
headline count.
How to read these numbers.
plan_to_pose, including IK and the dense envelope check.--backend ros2)The backend targets ROS 2 Jazzy (Python 3.12) with MoveIt 2 and franka_ros2 v3.x. Humble may work but
has not been tested. rclpy is imported only when the ros2 backend is created, so the package installs
and runs without ROS. If rclpy is missing, --backend ros2 exits with status 2 and says what to source.
The defaults are the names in the franka_ros2 v3.5.3 sources for a launch without a namespace. Check
them on your setup (ros2 topic list, ros2 action list). Every name is configurable, either in a ros2:
section of the policy or in a file passed with --ros2-config. franka_msgs is not in the ROS apt
repositories, so build it from franka_ros2. Without it, the gripper falls back to GripperCommand, and
collision thresholds and error recovery report "not supported".
How the backend behaves:
ros2.require_wrench), and the
server refuses to move without one (force.require_wrench); running without force monitoring needs
both set to false. The backend cancels the trajectory goal on stop_motion, on a force-limit abort
and on a controller timeout, and cancels gripper goals on stop_motion / estop. It also cancels the
goal when the MCP call itself is cancelled, for example because the client disconnected, and the server
additionally commands a stop in that case.MultiThreadedExecutor on a
daemon thread. It lives in a private rclpy.Context and installs no signal handlers. rclpy futures are
bridged to asyncio with loop.call_soon_threadsafe, so the server must run on asyncio, which is the MCP
SDK default. At start-up the backend waits, with a bound, for DDS discovery of its endpoints and logs
any that are missing.Integration tests (tests_ros/, needs a sourced ROS 2 environment; without one every test is
reported as skipped):
The tests use a random ROS_DOMAIN_ID and ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST. They drive the
backend both directly and through the full MCP server with an in-memory client. They run against an
in-process fake FR3 cell made of real rclpy endpoints:
FollowJointTrajectory server that interpolates in real time and honours cancel requeststest_moveit_live.py also starts a real MoveIt 2 move_group, using the Panda from
moveit_resources_panda_moveit_config (same kinematic structure as the FR3). It checks OMPL planning, KDL
IK, Cartesian paths and /compute_fk end to end. It is skipped when MoveIt is not installed.
This table is generated from the tools the server actually registers:
python scripts/gen_tool_table.py. Parameters with ? are optional. Annotations are MCP tool hints
(read_only_hint, destructive_hint, idempotent_hint, open_world_hint) meant for clients; the server
enforces the policy whatever the client does with them.
Points worth knowing:
tools/list
at all. The safety group is always registered and cannot be disabled.execute_plan, switch_controllers, set_collision_thresholds,
error_recovery and reset_estop. Their approval parameter is injected by the server and hidden from
the input schema. An approval value that the client sends as an argument is ignored.stop_motion, estop, get_safety_status.last_violation in the safety status.execute_plan, gripper, controller and
threshold tools, and error_recovery. Introspection keeps working.stop_motion and estop stop the gripper too. A gripper action they interrupt returns an error,
never success. Only one arm execution and one gripper action run at a time; a second execute_plan
while one is running (or still being validated) is refused.estop cannot fail because of its input. Any reason (missing, null, a number, very long text)
is accepted; it is flattened to one line and truncated to 500 characters.execute_plan, the gripper tools, switch_controllers,
set_collision_thresholds and error_recovery validate (and ask for approval where the policy says so)
and report dry_run without actuating.camera_snapshot only reads topics listed in perception.camera_topics. Images wider than
max_image_width are downscaled with Pillow (pip install armguard-mcp[image]), or refused if Pillow is
missing. Image bytes are redacted from the audit log.execute_plan streams MCP progress notifications (0..1) while the arm moves.Plan. A plan_* tool asks the backend for a trajectory, then samples it in joint space every
check_resolution_rad and computes the TCP position of every sample with forward kinematics. Because a
fixed joint step does not bound the TCP step (a few centimetres at full reach), it bisects further until
consecutive TCP samples are at most tcp_check_resolution_m apart, and tests every segment between
samples against the keep-out boxes, so a thin zone cannot slip between two samples. It checks the whole
path against the envelope and returns a PlanSummary (including force_monitoring: whether the backend
currently provides a wrench estimate). The full trajectory stays on the server.
Hard or soft. Each finding is a Violation with a severity.
rejected, and no approval can override them. They are: a
joint outside its limits anywhere along the path, the TCP leaving the workspace box, the TCP entering
a keep-out zone (at a sample or between two samples), a joint travelling more than
max_joint_step_rad, a Cartesian path longer than max_cartesian_step_m, a peak joint velocity above
max_velocity_scaling of the joint limit, a peak joint acceleration above max_acceleration_scaling
of the joint limit (estimated from the waypoint timing, with 10 % tolerance, whatever scaling the
backend claims to have used), a scaling factor above its cap, and malformed plans.NEAR_JOINT_LIMIT: the goal is within joint_limit_margin_rad of a limit;
LARGE_MOTION: travel above soft_joint_step_rad) mark the plan as outside the envelope. That makes
its status needs_approval when approval.mode is outside_envelope.Execute. Before it asks anyone, the approval resolver runs its pre-checks: the rate limit, the
e-stop, whether another plan is executing, whether the plan is unknown, expired, used or rejected, and
whether it is stale (the robot has moved more than start_tolerance_rad since planning). A request that
fails a pre-check is denied without prompting, so a human is never asked to approve something the
server would refuse anyway.
Prompt. If approval is required, the server sends an MCP elicitation form with an approve
checkbox (unticked by default) and an operator name. The message states the duration, peak joint
speed against the cap, largest joint travel, TCP path length, final TCP position, whether force
monitoring is on, any soft warnings, and whether this is a dry run. docs/demo.md shows
a real one.
Decide in the tool body. The body first claims the single execution slot, synchronously, so a
concurrent execute_plan is refused and a stop_motion or estop from then on always reaches this
execution, even before anything has moved. It runs once and repeats the checks that matter: it
re-validates the plan against the envelope, re-checks staleness and reads the baseline wrench. Then it
interprets the outcome:
approve ticked: the plan runs;approve unticked, decline, or cancel: denied.Every outcome goes to the audit log together with the operator name. Once the body has examined a
plan, its plan_id is spent, whether the plan ran or was rejected, failed re-validation, was stale, or
was denied approval. A call refused earlier, by the rate limit or because another plan is still
executing, leaves the plan usable until it expires.
Run. Right before the trajectory is handed to the backend, the server checks once more that no
e-stop or stop request arrived during the checks; if one did, nothing moves. While the arm moves, a
force monitor polls the backend's external wrench at force.monitor_rate_hz. If the force or torque
goes over the limit, the server stops the motion, latches the software e-stop, invalidates every
outstanding plan, and returns an error that tells the model a human must call reset_estop. The
monitor fails closed: a wrench that cannot be read, is missing, takes longer than
force.wrench_timeout_s to read, or whose stamp stops advancing for that long aborts the motion. If
the stop command itself fails, the software e-stop is latched. With no wrench estimate at all,
execute_plan refuses to move unless the policy sets force.require_wrench: false, in which case the
plan summary and the approval prompt say that the force limit is not enforced. If the MCP call is
cancelled mid-motion (client cancel, host timeout, disconnect), the server commands a stop before the
cancellation propagates and records execution_interrupted in the audit log.
When a prompt is required (approval_required in server.py):
An action class that is left out of approval.require_for never prompts, except reset_estop, which the
policy loader always adds back to the list. In dry-run mode execute_plan never prompts because nothing
will move; the other tools still ask for approval and then report dry_run without acting.
Releasing an e-stop is bound to the e-stop the human saw. Every latch (from estop, the force
monitor, or a failed stop) gets a new event number. The reset_estop prompt names it, and the tool body
refuses the approval if a newer e-stop was latched while the prompt was open, so approving "event #1"
never releases "event #2". The prompt puts the server's facts first (event number, time, any latched force
violation). A reason written by the AI agent is shown on one line, in quotes, truncated and labelled
unverified, so an injected "operator note: safe to approve" cannot pass for server text.
Clients without elicitation are denied by default. If approval is required and the client did not
declare elicitation support, the result depends on approval.on_client_without_elicitation. With deny,
the default, the request is refused with a message that says why. With allow, it proceeds, and the audit
log records via: no_elicitation_client. allow applies to reset_estop as well, so a client without
elicitation can then release the e-stop, including a latched force violation, without any human. Keep the
default on real hardware.
Protocol versions. Approval uses an SDK Resolve-injected parameter, which works on both the
2025-11-25 (legacy) and the 2026-07-28 (multi-round-trip) protocol revisions. Tests cover auto, legacy
and 2026-07-28. Under 2026-07-28 the SDK may run a resolver more than once per call, so resolvers only
read state. The motion, plan consumption and auditing all happen in the tool body, which runs once; see
docs/architecture.md.
The policy is a YAML file, validated strictly (extra="forbid", so unknown keys are errors). It is loaded
once at start-up, is immutable, and cannot be changed by any tool. Units: metres, radians, seconds,
newtons. Positions are in robot.base_frame. A worked example is
examples/policies/fr3.yaml; get_safety_envelope returns the loaded policy
to the model.
Top level
robot (all fields required)
tools
workspace
motion
force
If a motion starts with the contact force already above the limit (for example, backing out after a force abort), it aborts only if the force rises more than 1 N (0.2 N·m for torque) above its starting value.
gripper (optional)
controllers, perception
rate_limits (token buckets that refill continuously; stop_motion, estop and get_safety_status
are exempt)
approval
estop tool is a
convenience. It is not an emergency stop.--dry-run, then
the read-only policy, then low speed caps and a small workspace.The threat model, including residual risks such as a human rubber-stamping approvals or a client that fakes them, is in docs/threat-model.md.
tcp_check_resolution_m apart, every segment tested against the keep-out boxes. The real TCP arc
deviates from those chords by roughly step²/(8·R), well under a millimetre at the default 1 cm for any
realistic radius of curvature, so give keep-out boxes a few millimetres of margin. It does not check link
geometry against the workspace or keep-out zones, so an elbow can still enter a keep-out box: in the
link-geometry check, 84 of 201 accepted plans with targets just outside a zone put the hand
or a wrist link inside it. Keep targets away from zones, or wait for the link-level check on the roadmap.monitor_rate_hz from
Python. It is a software backstop behind the robot's reflexes, not a real-time loop. It fails closed
(see the approval flow, step 6), but it can only react as fast as the wrench is
published and the controller handles a stop.plan_to_pose interpolates in joint space, so the TCP does not move in a straight line; use
plan_cartesian_path when you need one.127.0.0.1 and reach
it through an SSH tunnel.rejected.docker/ is an experimental, untested sketch.move_group, but not against
franka_ros2 on a physical FR3. Check the topic, service and action names, the wrench frame and the
collision-threshold semantics on the real cell before you rely on them.ros2.require_wrench: true (the default), a
missing or stale wrench makes execute_plan refuse to move, and get_robot_state return an error.stop_motion cancels only goals that armguard sent. It does not stop motions started by other tools,
for example MoveIt in RViz.FollowJointTrajectory execution, controller_manager,
franka_ros2 gripper, collision thresholds and error recovery, tf2, camera topics (tested in
simulation and against a real move_group; see tests_ros/).scripts/eval_link_collisions.py).insert_peg with force
feedback. The policy would run in the real-time loop, with armguard checking its preconditions and
force envelope.server.json is prepared).If you use armguard-mcp in academic work, please cite:
Lin He, University of Tennessee, Knoxville. Research on contact-rich manipulation with a Franka FR3.
Apache License 2.0. Copyright 2026 Lin He.
Source: README.md at commit d15b0c2
Current joint positions [rad], TCP pose [m, quaternion xyzw] in the robot base frame, estimated external wrench [N, N*m], gripper state and safety status. Read-only.
Takes no parameters.
The limits the server enforces (joint limits, workspace box, keep-out zones, speed/step caps, force thresholds, gripper limits, approval mode). Read this before planning and stay inside it: violations are rejected server-side and cannot be overridden.
Takes no parameters.
Pose of source_frame expressed in target_frame (tf2 semantics), e.g. target_frame='fr3_link0', source_frame='fr3_hand_tcp'. Position in metres, orientation as a quaternion (x, y, z, w).
source_framerequiredtarget_framerequiredros2_control controllers with their type and state, plus which ones the policy lets you switch.
Takes no parameters.
ROS 2 nodes, topics, services and actions visible to the server. Read-only; you cannot publish or call arbitrary services through this server.
Takes no parameters.
The last n audit-log events (tool calls, approvals, denials, e-stops). Images are redacted.
nGrab the latest image from an allowlisted camera topic (see get_safety_envelope.camera_topics). Returns the image plus JSON metadata (width, height, stamp). Images wider than the policy's max_image_width are downscaled (or refused if Pillow is not installed).
topicrequiredPlan (do NOT move) a joint-space motion from the current state to joint_positions [rad]. Returns a plan summary validated against the safety envelope; if status is not 'rejected', pass plan_id to execute_plan. Scaling above the policy cap is clamped.
joint_positionsrequiredvelocity_scalingacceleration_scalingPlan (do NOT move) a motion that brings the TCP to a pose. Uses inverse kinematics; the path is a joint-space interpolation (the TCP does not move in a straight line - use plan_cartesian_path for that). Returns a plan summary validated against the safety envelope.
frame_idpositionrequiredorientationvelocity_scalingacceleration_scalingPlan (do NOT move) a straight-line TCP path through the waypoints [m]. Total TCP path length is capped by the policy (max_cartesian_step_m). Returns a plan summary validated against the envelope.
frame_idwaypointsrequiredvelocity_scalingacceleration_scalingEXECUTE a previously planned motion on the robot. The server re-validates the plan against the safety envelope, rejects stale/expired/used plans, may ask a human to approve (depending on policy), monitors contact force while moving (aborting and latching an e-stop above the limit), and streams progress. Plan handles are single-use and one plan executes at a time. Refused if the robot provides no force estimate (unless the policy allows it). Cancelling the call stops the robot. In dry-run mode nothing moves.
plan_idrequiredWhether a plan is executing, its progress (0..1) and the result of the last execution.
Takes no parameters.
Move the gripper fingers to an opening width [m] without applying grasp force.
width_mrequiredspeed_mpsClose the gripper on an object of about width_m [m] with force_n [N]. Succeeds (ok=true) only if the fingers stop within [width - epsilon_inner, width + epsilon_outer]. Forces above the policy maximum are rejected, not clamped.
force_nrequiredwidth_mrequiredspeed_mpsepsilon_inner_mepsilon_outer_mHome (fully open and calibrate) the gripper.
Takes no parameters.
Activate/deactivate ros2_control controllers. Only controllers in the policy allowlist may be switched; a human may need to approve.
activatedeactivateSet the robot's collision (reflex) thresholds. Values above the policy maxima are rejected.
force_nrequiredtorque_nmrequiredStop the current motion immediately: the arm trajectory and any gripper action. Always available, never rate limited, never needs approval. Does not latch; use estop to also block further motion.
Takes no parameters.
SOFTWARE E-STOP: stop all motion (arm and gripper), invalidate every plan and refuse motion/gripper/control tools until a human approves reset_estop. Always available, never rate limited, never needs approval, accepts any reason. This is not a substitute for the hardware e-stop.
reasonRelease the software e-stop (and any latched force violation). ALWAYS requires human approval via the MCP client, regardless of the approval mode. An approval only releases the e-stop event it was shown for; if another e-stop is latched while the prompt is open, call reset_estop again.
Takes no parameters.
Clear the robot's error/reflex state (e.g. after a collision reflex). May require human approval. Refused while the software e-stop is active (reset it first).
Takes no parameters.
E-stop state, latched force violation, dry-run flag and the last envelope violation. Never rate limited.
Takes no parameters.
90% of the applicable checks met
How complete this listing is, computed from the registry entry, the endpoint and public sources. Not a rating of the server and not a statement that it is safe or working today: reachability is a dated check, and no installs or reviews go into the score.
Declare the credential headers in the registry entry, or keep the endpoint answering the probe so its auth can be classified.
uvx armguard-mcp # or: pip install armguard-mcp && armguard-mcp{ "mcpServers": { "armguard": { "command": "uvx", "args": ["armguard-mcp"] } }}claude mcp add armguard -- uvx armguard-mcpflowchart LR subgraph Client["MCP client (untrusted)"] LLM["LLM agent<br/>(Claude, GPT, …)"] Human(["Human operator"]) end
subgraph Server["armguard-mcp (trusted, enforces policy)"] direction TB Tools["22 MCP tools<br/>only enabled groups are registered"] RL["Rate limiter<br/>per-tool + global token buckets"] ES["E-stop / force latch"] Env["Safety envelope<br/>joint limits · workspace box · keep-out zones<br/>speed · step size · force"] Ap["Approval resolver<br/>(MCP elicitation)"] Plans["Plan store<br/>TTL · single-use · staleness"] Audit[("Audit log<br/>JSONL")] Tools --> RL --> ES --> Env --> Ap Env <--> Plans Tools -.-> Audit end
subgraph Backend["Robot backend"] Fake["fake FR3<br/>(kinematic sim, CI)"] ROS["ROS 2 (rclpy)<br/>MoveIt 2 · ros2_control · franka_ros2"] end
Robot["FR3 + Franka safety system<br/>1 kHz control stays here"]
LLM <-->|"MCP: stdio or streamable HTTP"| Tools Ap <-.->|"elicitation/create"| Human Ap --> Backend ROS --> Robotpip install armguard-mcp # or run it without installing: uvx armguard-mcp ...armguard-mcp --audit-log audit.jsonl # simulated FR3, bundled policyarmguard-mcp --policy my_policy.yaml # your own policy (required for --backend ros2)git clone https://github.com/Lynn-hh/armguard-mcp && cd armguard-mcppython -m venv .venv && . .venv/bin/activatepip install -e ".[dev]"pytest -q # unit tests, a few seconds (no ROS needed)
# stdio (what desktop and CLI MCP clients launch)armguard-mcp --policy examples/policies/fr3.yaml --backend fake --audit-log audit.jsonl
# streamable HTTP on http://127.0.0.1:8765/mcp (no authentication: keep it on localhost)armguard-mcp --policy examples/policies/fr3.yaml --backend fake --transport http --port 8765
# a scripted session: plan, execute, approval prompt, keep-out rejection, e-stoppython scripts/demo_fake.py
# the safety evaluation behind the numbers in "At a glance"python scripts/eval_safety.py{ "mcpServers": { "armguard": { "type": "stdio", "command": "/abs/path/to/armguard-mcp/.venv/bin/armguard-mcp", "args": [ "--policy", "/abs/path/to/armguard-mcp/examples/policies/fr3.yaml", "--backend", "fake", "--audit-log", "/abs/path/to/armguard-mcp/audit.jsonl" ] } }}from mcp.client import Clientfrom mcp.client.stdio import StdioServerParametersimport mcp.types as mt
async def ask_human(context, params): # shown when the server needs approval print(params.message) ok = input("approve? [y/N] ") == "y" return ( mt.ElicitResult(action="accept", content={"approve": True, "operator": "me"}) if ok else mt.ElicitResult(action="decline") )
server = StdioServerParameters( command="armguard-mcp", args=["--policy", "examples/policies/fr3.yaml", "--dry-run"])async with Client(server, elicitation_callback=ask_human) as client: plan = await client.call_tool("plan_to_pose", {"position": {"x": 0.4, "y": 0.0, "z": 0.4}}) result = await client.call_tool("execute_plan", {"plan_id": plan.structured_content["plan_id"]})python scripts/eval_safety.py # about 5 s on a laptop; --moves N --seed S --json out.jsonpip install newton GitPython # Newton physics engine; the FR3 asset is downloaded on first runpython scripts/eval_link_collisions.py --workload home --moves 1000python scripts/eval_link_collisions.py --workload near-zone --moves 300source /opt/ros/jazzy/setup.bash # plus your franka_ros2 workspace (for franka_msgs)python3 -m venv --system-site-packages .venv-ros && . .venv-ros/bin/activate # sees rclpypip install -e .armguard-mcp --policy examples/policies/fr3.yaml --backend ros2 \ --ros2-config examples/ros2/fr3_franka_ros2_jazzy.yamlsource /opt/ros/jazzy/setup.bash && . .venv-ros/bin/activate && pytest -q tests_rossequenceDiagram autonumber participant L as LLM (via MCP client) participant S as armguard-mcp participant H as Human (client UI) participant R as Robot backend L->>S: plan_to_pose(position, …) S->>R: plan (IK + trajectory) S->>S: densify path, refine to TCP resolution, FK every sample, check envelope S-->>L: PlanSummary {plan_id, status, violations, requires_approval, expires_at} L->>S: execute_plan(plan_id) S->>S: resolver: pre-checks (rate limit, e-stop, plan usable, not stale) alt approval required and client supports elicitation S->>H: elicitation: "APPROVE ROBOT MOTION …?" [approve ☐, operator] H-->>S: accept + approve / decline / cancel end S->>S: tool body: re-validate the plan, re-check staleness, interpret the approval, consume plan_id S->>R: execute, with the force monitor running R-->>S: progress … done S-->>L: ExecutionReport {status, approval, max_observed_force_n}@software{he_armguard_mcp_2026, author = {He, Lin}, title = {armguard-mcp: A Safety-First Model Context Protocol Server for ROS 2 Manipulators}, year = {2026}, version = {0.1.0}, url = {https://github.com/Lynn-hh/armguard-mcp}, note = {University of Tennessee, Knoxville}}