Skip to content

fix(mcp): first call in 0.5 s: executor per node, tare over 1 s, UDP - #93

Draft
jlongvalRobotiq wants to merge 6 commits into
feat/mcp-result-positionfrom
feat/mcp-single-executor
Draft

jlongvalRobotiq wants to merge 6 commits into
feat/mcp-result-positionfrom
feat/mcp-single-executor

Conversation

@jlongvalRobotiq

@jlongvalRobotiq jlongvalRobotiq commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Why this PR

Before merging feat/mcp into main, we ran a full QA pass of the gripper MCP server by hand, on the bench: a real 2F-85 with TSF-85 pads, with Claude Code as the client, as an agent would use it. Every tool and every failure case got one test, and each test's expected result was written down before the run. The pass found the problems fixed here, plus one fixed separately in #91. With both PRs, every test passed.

What we tested

Area Tests
Finding the gripper list the grippers, check health, read the current opening, ask for a gripper name that doesn't exist
Moving open fully, close fully, move to 40 mm, 20 open/close cycles in a row
Out-of-range requests 200 mm and −5 mm openings, efforts of 1.5 and −0.3: each clamped, with a note saying so, no error
Grasping close on a rigid block (reports an object and its width); close on foam at gentle, default and full effort (squeezes more as the effort goes up)
Tactile pads zero them with nothing touching; read with nothing touching; press one pad; check a held block; pull the block out; close on nothing; refuse to zero or read while the fingers are closed
Driver stopped health says so; a move fails within 10 s and says why; the position is not reported as current once it is old
A whole task "Grab this block, make sure you have it, tell me how wide it is, let go when I say": Claude picks and chains the tools on its own

The same tests also ran on the fake-hardware demo, including the ones with the driver stopped.

What was wrong, and the fix

1. The first move after the server started took 13 to 18 s, longer than the 10 s move limit, so an agent's very first move could fail. The server could not keep up with the ROS messages coming in: finger positions 500 times a second, pad readings about 2,000 times a second. The ROS message loop it used (MultiThreadedExecutor) took a whole CPU core and still dropped about four messages in five, and it slowed every other part of the server down. Each ROS node now gets its own simple, single-thread loop (SingleThreadedExecutor).

on the same 500-per-second topic CPU messages received per second
multi-thread loop (before) 108 % 86–105
single-thread loop (after) 22 % 500

One shared loop for all nodes was not enough: on some starts, the pad messages crowded out the gripper's own and the gripper was never read.

2. The pads reported contact when nothing touched them, 19 times in 50. Zeroing the pads (the "tare") averaged 100 readings, a number set in #83 when only about 110 readings a second got through. Once every reading gets through, 100 readings is only 0.06 s, too short to average out the pads' slow drift at rest. #83 is reverted, and the tare now averages everything that arrives in 1 s (about 2,000 readings), the 1 s Eric suggested in #66.

3. About one server restart in nine, the server saw no messages at all, and every tool failed until the next restart. This came from Fast DDS's shared-memory transport. The MCP image now sets FASTDDS_BUILTIN_TRANSPORTS=UDPv4 so it uses plain network messages. CycloneDDS, which the cell runs, ignores this setting.

4. The README lists the known limitations seen on the bench (below).

Verified

  • uv run pytest: 198 passed; black, flake8 and the quality gate pass on every commit.
  • Fake hardware: first call after a restart 12.2–16.7 s → 0.52–0.57 s; idle CPU 108 → 30 %.
  • Bench:
check before after
first move after a server restart 13–18 s 1.25–1.75 s
restarts that saw no messages 4 in 34 0 in 65
false contacts after a tare, pads free 19 in 50 0 in 50
20 open/close cycles – 40 results right
close on a rigid block – object reported at 26.6 mm, held
tare with the pad driver stopped – clear error naming the missing pad data

Needs

Known limitations (in the README)

  • Health takes a few seconds to notice a driver that has stopped.
  • The idle server uses about one CPU core on a gripper with pads (about 30 % without pads), because Python follows every message. Bringing that down means lowering the publish rates on the driver side, not changing the server.
  • The opening reported after a move is read before the fingers fully settle, so it can differ from the resting position by up to about 2 mm.

Still to come on this PR

  • The pads' full-scale value was tuned on the Isaac Sim twin. On the real pads a firm grasp reads 3.37 where about 1 is expected. Contact yes/no is not affected; only the size of the number is. It needs one firm-grasp reading on the bench.

jlongvalRobotiq and others added 5 commits September 25, 2026 12:37
This reverts commit d8b5c57, reversing
changes made to c97dd87.
A count only means a duration at one frame rate, and the frame rate is the
driver's to choose. #83 set 100 frames because about 110 frames a second
reached Python whatever the pads published; that ceiling was the rclpy
executor, not the pads. The TSF-85 publishes at about 2 kHz, so once every
frame arrives, 100 frames are 0.06 s of rest signal: too short to see the slow
part of the rest noise, and the threshold set from it sits under that noise.
On a free TSF-85 on the bench that gave 19 false contacts in 50 reads.

The datasheet now gives `baseline_s: 1.0`, Eric's 1 s compromise from #66,
and the tare averages every frame received in that window (about 2000 at
the pads' rate, 0 false contacts in 50 reads, pad on pad still read as
contact at full close). The result's `samples` reports how many frames that
was. The ROS source keeps every frame arriving during the window while the
caller sleeps through it, and a window that received none is a stopped driver.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8KnAMvo3aZyPWCQ886WBT
The driver publishes joint_states at 500 Hz and the TSF-85 driver its frames
at about 2 kHz. rclpy's MultiThreadedExecutor took a whole core to follow
that and still dropped about four messages in five (measured: 108 % CPU, 86 to
105 of 500 joint_states a second received; a SingleThreadedExecutor on the same
topic: 22 %, all 500). The spin thread held the GIL so constantly that the
server's first tool call, which does its one-time imports and schema work
then, took 12 to 18 s: longer than the 10 s motion timeout, so the first move
after the MCP starts could come back incomplete. Every later call paid it too,
0.34 s where 0.11 s is enough.

One executor per node rather than one shared: an executor serves its nodes in
no fixed order, and sharing one with the pads' 2 kHz frames left the gripper's
own topics unread on some starts. Each node is added before its executor
spins, so nothing is added to a running executor.

None of the callbacks blocks: they store a message, set an event or send a
cancel, and the tools wait on their own threads.

Demo (fake hardware): first call after an MCP restart 12.2 to 16.7 s -> 0.52
to 0.57 s, later calls 0.34 -> 0.11 s, idle CPU 108 -> 30 %. Bench (real
2F-85 + TSF-85): first move after an MCP restart 13 to 18 s -> 1.25 to 1.75 s;
20 open/close cycles, 40 verdicts right.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8KnAMvo3aZyPWCQ886WBT
With Fast DDS's shared-memory transport on, about one MCP restart in nine
came up blind: the ROS graph listed the driver's topics and action server, but
no joint_states, robot_description or tactile frame ever arrived, so every
tool failed with "No robot_description within 2 s" until the next restart.
On the bench (all containers on host network and host IPC): 4 blind starts in
34 restarts with shared memory, 0 in 65 over UDP only, same idle CPU. The
variable is ignored by CycloneDDS, which the cell runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8KnAMvo3aZyPWCQ886WBT
Health lagging a dead driver, the idle CPU cost of following 500 Hz and
2 kHz topics in Python, and a position reported before the fingers settle
all surprise a first user and none shows on the fake-hardware demo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8KnAMvo3aZyPWCQ886WBT
@ebarnett3

Copy link
Copy Markdown
Collaborator

I think that many of these problems would have been avoided if the MCP had been written in c++, based only on the SDKs. Is there a use case that requires the ROS layer ?

@ebarnett3 ebarnett3 left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve as a stopgap. The executor-per-node design is sound and the threading is correct; the tare rewrite is simpler than what it replaces; the UDP-only transport is the right fix for a container that only talks to other containers (both compose files already share ipc: host, so the shared-memory failure is not a missing IPC namespace). Bench claims in the README cross-check against the repo: update_rate: 500 in robotiq_controllers.yaml, goal_tolerance: 0.02 rad ≈ 2.1 mm on an 85 mm / ~0.8 rad stroke, and the SDK's findBaseline() does collect 1000 samples. The PR title's "0.5 s" is the fake-hardware figure; the body says 1.25–1.75 s on the bench, which is the number that matters to a user.

Findings and nits are inline.

Shelved (pre-existing, not for this PR)

  • RosGraph.shutdown never calls destroy_node() on the nodes; harmless at process exit, worth a line if the server ever reloads its wiring.
  • TactileService._tare_lock is one lock for every gripper, so a 1 s tare on one gripper blocks reads that need a tare on another. Was true at 100 frames too, just shorter.
  • The ROS backends and RosGraph have no tests (rclpy not importable in the unit suite). The sample rewrite therefore ships covered only through the fakes. Consistent with the rest of mcp/; note it, no action here.

Commit 3cef4356 (revert of #83) followed by f467e5d7 that rewrites the same lines: fine as history, but the revert message could point at f467e5d7's reason in one line so git log on the yaml makes sense without the PR.

# noise is +-2 counts.
baseline_samples: 100
# Seconds of frames a tare averages, however fast the pads publish. The pads
# publish at about 1.7 kHz, so 1 s is about the 1000 samples the SDK's

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so 1 s is about the 1000 samples
->
so 1 s is more than the 1000 samples

Comment on lines +74 to +81
def sample(self, duration_s: float) -> list[TactileReading]:
self._collected = []
self._sample_done.clear()
self._wanted = count
seen = 0
while not self._sample_done.wait(STALE_FRAME_S):
if len(self._collected) == seen:
self._wanted = 0
raise RuntimeError(stalled_sample_message(seen, count, STALE_FRAME_S))
seen = len(self._collected)
self._wanted = 0
return [self._reading(message) for message in self._collected]
self._collecting = True
time.sleep(duration_s)
self._collecting = False
frames = list(self._collected)
if not frames:
raise RuntimeError(stalled_sample_message(duration_s))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. Design — one more reason to move the tare to the SDK, as already agreed

The team already agreed to replace the MCP's tare with the tactile SDK's RobotiqTactileSensor::findBaseline() (tactile_sensors/sdk_cpp/RobotiqTactileSensor.h:110, 1000 samples at the sensor's own rate). This PR is evidence for why: it is the second round of tuning a tare implemented in Python over ROS frames (#83 set a frame count, this one a duration), and each round chased a property of the transport (the executor's frame ceiling, then the real frame rate) rather than of the pads. The new code still has a hole of the same kind: a window that got only a few frames is accepted, so a stalled stream yields a near-zero noise floor and the false contacts return silently. Fixing that would be a third round on code that is going away.

Take this commit as the stopgap it is, no further changes, and let the false-contact history (19 in 50 before #83, this fix, the stalled-window case) go into the migration issue as its motivation. If the ROS backend outlives the migration, the interim route is a tare service on the robotiq_tsf driver that wraps findBaseline(), as the tactile_centering demo does through /tactile_preprocessing/reset_baseline.

Comment thread mcp/Dockerfile
# transport on, about one MCP restart in nine came up blind: the graph listed the
# driver's topics but no message ever arrived (4 in 34 restarts on the bench, 0 in
# 45 over UDP only, same idle CPU). CycloneDDS ignores the variable.
ENV FASTDDS_BUILTIN_TRANSPORTS=UDPv4

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2. Low — FASTDDS_BUILTIN_TRANSPORTS is silently ignored on Humble

The variable exists from Fast DDS 2.10 (Iron and later). Humble ships 2.6, which does not read it, so a Humble base image (the README says Humble is supported and CI tests its interpreter) keeps shared memory on and keeps the one-in-nine blind start. Either say so in the comment and tag it per the repo rule (Humble EOL: drop this note), or give Humble the XML route (FASTRTPS_DEFAULT_PROFILES_FILE pointing at a profile with <transport_descriptors> UDPv4 only, plus RMW_FASTRTPS_USE_QOS_FROM_XML not needed for transports). A one-line note is enough if Humble is not a target for the MCP image; then the README should say the image is Jazzy-only.

Comment thread mcp/Dockerfile
Comment on lines +21 to +24
# Fast DDS reaches the other containers over UDP only. With its shared-memory
# transport on, about one MCP restart in nine came up blind: the graph listed the
# driver's topics but no message ever arrived (4 in 34 restarts on the bench, 0 in
# 45 over UDP only, same idle CPU). CycloneDDS ignores the variable.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3. Low — bench measurements in code comments

The Dockerfile comment carries "4 in 34 restarts, 0 in 45", the PR body says "0 in 65" for the same claim; the yaml says the pads publish at 1.7 kHz, the three other comments say 2 kHz. Numbers that live in two places drift. Keep the why in the comment (shared memory came up blind across containers; a short window misses the slow drift) and leave the counts in the PR body, which is where the rest of the QA evidence already is. The ros_backend.py module docstring is fine as the design rationale but "takes a whole core ... still drops most of them" is the same kind of measurement; "cannot keep up with 500 Hz joint_states under the GIL" says the why without a figure that will go stale.

Comment on lines 108 to +115
@classmethod
def shutdown(cls) -> None:
with cls._lock:
if cls._executor is not None:
cls._executor.shutdown()
for executor in cls._executors:
executor.shutdown()
if cls._executors:
rclpy.shutdown()
cls._executor = None
cls._executors = []

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4. Low — RosGraph.node can leave rclpy initialised with nothing to shut down

node() calls rclpy.init(); shutdown() calls rclpy.shutdown() only if an executor was registered. If a backend constructor raises between node() and spin() (bad namespace, subscription type import failure) rclpy stays up with no owner. Trivial fix: track an _initialised flag set in node() and test that instead of if cls._executors, or create the executor and register it in node() and only start the thread in spin().

@@ -100,10 +101,10 @@ def test_a_sample_is_one_fresh_reading_per_frame_asked():
read_opening_mm=lambda: opening["mm"], object_width_mm=OBJECT_WIDTH_MM

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: mcp/tests/test_mock_tactile_backend.py:96 — test name test_a_sample_is_one_fresh_reading_per_frame_asked no longer describes a duration-based sample; ..._per_frame_in_the_window.

TOUCH_COUNTS = 20
TAXEL_MAX_COUNTS = 110
STIFFNESS_COUNTS_PER_MM = 6.9
FRAME_RATE_HZ = 100

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: mcp/tests/fakes/tactile.py:34 — FRAME_RATE_HZ = 100 is a fake's rate used by three test files to compute expected counts; a one-line comment that it is arbitrary and only has to make baseline_s * rate an integer would stop someone "correcting" it to 2000.

Comment on lines +20 to 25
arrives during the tare window while the caller sleeps through it, so each
frame is a distinct sensor update and the two threads never hand off per
frame. The window is a duration rather than a frame count because the frame
rate is the driver's to choose. A window that received no frame at all means
the driver has stopped publishing.
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: mcp/gripper_mcp/ros_tactile_backend.py:20-25 — module docstring: "the two threads never hand off per frame" restates what the code shows (_collecting flag, time.sleep); the non-obvious part is only "a duration rather than a count because the frame rate is the driver's to choose". Trim to that.

Comment thread mcp/README.md
Comment on lines +195 to +198
- **About one CPU core at idle on a gripper with pads.** The server reads every
`joint_states` message (500 Hz) and every pad frame (about 2 kHz) in Python,
which costs about 85 % of a core on the bench and about 30 % without pads.
Lowering those publish rates on the driver side is the way to bring it down.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: mcp/README.md:195-198 — "about 85 % of a core" here vs "about one CPU core" in the PR body; pick one wording and reuse it.

The demo section of the README and the demo wiring said a close on fake
hardware reports closed_without_object, an outcome the server no longer has
since the motion tools moved to neutral outcomes (27d52b0). It comes back
reached, with object_detected false.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8KnAMvo3aZyPWCQ886WBT
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants