Skip to content

fix(mcp): take the achieved position from the controller's result - #91

Open
jlongvalRobotiq wants to merge 1 commit into
feat/mcpfrom
feat/mcp-result-position
Open

jlongvalRobotiq wants to merge 1 commit into
feat/mcpfrom
feat/mcp-result-position

Conversation

@jlongvalRobotiq

@jlongvalRobotiq jlongvalRobotiq commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Where this comes from

Before merging feat/mcp into main, we ran a full QA pass of the gripper MCP server by hand, on a real 2F-85 on the bench, with Claude Code as the client. #93 lists every test. The runs on fake hardware found the three bugs fixed in #84, #85 and #86, now merged into feat/mcp. This PR fixes the first problem found on the real gripper.

The test that found it: 20 open/close cycles with nothing between the fingers. Expected: every result reached. Observed: about one open in five came back stopped_on_object at 82.4 mm, while the controller had reported success at 83.9 mm and the fingers were resting at 85.0 mm a second later.

Why

The MCP took the achieved position from the controller's result, and fell back to its own latest joint_states sample when the result named no joint. parallel_gripper_action_controller fills result.state.position but never result.state.name, so the lookup always missed and the fallback always ran. At 500 Hz the Python subscription runs behind the controller, so the fallback read a position from partway through the move, and a move that had finished was called an object.

Once the position was right, a second disagreement showed up. The controller's goal tolerance is 0.02 rad, about 2.1 mm, wider than the datasheet's 1.5 mm closed tolerance. So the controller could report reached_goal and the MCP could still say "stopped on an object" on the same move. When the controller reached its goal, that verdict wins. When it didn't, the position comparison decides, which is the case where the stall flag used to be wrong (#29).

What

  • ros_messages.py: position_of takes the single position of a result that names no joint as that joint's.
  • service.py: classify returns reached when the controller reports reached_goal, before it compares positions.
  • Tests: a result naming no joint with one position, and one with none; a reached goal classified reached even when the position is outside the MCP's tolerance.

Verified

…d its verdict

parallel_gripper_action_controller fills `result.state.position` but never
`result.state.name`, so the lookup by joint name always missed and the
achieved position came from the MCP's own latest `joint_states` sample instead.
At 500 Hz that subscription runs behind the controller: on the bench (real
2F-85, driver with a working velocity estimate) one open in five reported
82.4 mm and "stopped on an object" while the controller had succeeded on
83.9 mm, the next Modbus reading, and the fingers rested at 85.0 mm a second
later. A result that names no joint and carries exactly one position is that
joint's.

classify also trusts `reached_goal` before comparing positions: the
controller's goal tolerance (0.02 rad, about 2.1 mm) is wider than the
datasheet's closed tolerance (1.5 mm), so a move the controller finished could
still be called an object by the MCP. The position comparison remains the
verdict when the controller did not reach, which is where the stall flag used
to lie (ros#29).

Found on row 4 of the acceptance matrix, pathway B (bench), 20 open/close
cycles with nothing between the fingers: 5 wrong results before, 0 after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8KnAMvo3aZyPWCQ886WBT
An error occurred while trying to automatically change base from feat/mcp-stale-state to feat/mcp-timeout-verdict September 25, 2026 16:42
@jlongvalRobotiq
jlongvalRobotiq changed the base branch from feat/mcp-stale-state to feat/mcp September 25, 2026 16:52
ebarnett3
ebarnett3 approved these changes Sep 25, 2026 •

@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 with one doc fix before merge. Both code changes are correct and match the upstream controller: parallel_gripper_action_controller_impl.hpp (jazzy) does state.position.resize(1) and writes state.position[0] on reach and on stall, and never touches state.name. A canceled goal gets an empty Result(), so the fallback still runs there and the new rule does not read a stale position. The reached_goal short-circuit is sound: the controller only sets it inside goal_tolerance, and both allow_stalling configs leave reached_goal=false on a stall, so the position comparison still decides the object case. Tests cover both new branches.

Findings and nits are inline.

Shelved (pre-existing, not for this PR)

  • mcp/gripper_mcp/ros_backend.py:326-336 — the GripperCommand branches in _goal / _result_position are Humble-only (Humble keeps PickNik's action) and carry no Humble EOL: tag.
  • mcp/gripper_mcp/ros_backend.py:208 — read_state falls back to 0.0 rad (fully open) when the joint is missing from a fresh joint_states; a raise would be safer than a silent "open".

return "refused"
if motion.timed_out:
return "incomplete"
if motion.reached_goal:

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. Low — service.py module docstring now contradicts classify (service.py:15-21, outside the diff)

mcp/gripper_mcp/service.py:15-21. The paragraph still says the verdict is read "from where the fingers ended up against where they were sent" and that the controller's flags do "not steer the outcome". After this PR reached_goal steers first. Rewrite the paragraph to the new rule, e.g.: "The controller's reached_goal is the verdict when it is set: its goal tolerance is wider than the datasheet's closed tolerance, so the position comparison would otherwise call a finished move an object. When it is not set, the verdict is read from where the fingers ended up against where they were sent, never from the stalled flag (#29: on the real driver that flag trips on every goal), which is passed through to the caller as-is." Keep the gOBJ stopgap sentence. Since #92 fixes the "no velocity is ever computed" cause, prefer wording that does not assert it as current fact.

Comment on lines +108 to +109
if not names and len(state.position) == 1:
return state.position[0]

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 — the unnamed-position rule is the action result's contract, but lives in the joint_states lookup

mcp/gripper_mcp/ros_messages.py:108-109. position_of is also called on joint_states samples (ros_backend.py:208, :283). A JointState on that topic always names its joints, so the branch is dead there and reads as a generic heuristic. Nothing says why it exists. Either add a one-line why (# parallel_gripper_action_controller fills result.state.position but never result.state.name) or split it out so the rule sits on the result path only:

def result_position_of(state, joint: str, fallback: float) -> float:
    if not state.name:
        return state.position[0] if len(state.position) == 1 else fallback
    return position_of(state, joint, fallback)

and call it from _result_position (ros_backend.py:334); move the two new tests with it.

return "incomplete"
if motion.reached_goal:
return "reached"
if stopped_on_something(commanded_mm, achieved_mm, stroke):

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 — the object-detection floor is now the controller's goal_tolerance, not closed_tolerance_mm

mcp/gripper_mcp/service.py:222 with grippers/robotiq_description/config/robotiq_controllers*.yaml (goal_tolerance: 0.02 rad ≈ 2.1 mm). A stop within 2.1 mm of the target is reached; closed_tolerance_mm (1.5 mm) only matters when the controller did not reach. Not a regression (a 1 mm stop was already reached), and the PR body says so, but the datasheet yaml now documents a tolerance that is not the effective one. A sentence in the docstring or the datasheet comment is enough for this PR.

Don't align the two numbers: the position comparison itself is on its way out. Once the controller takes stall and reached from the gripper's object status (#96, follow-up to #92), reached_goal means "at requested position" and stalled means "object detected" straight from the firmware, and classify should trust both flags and drop closed_tolerance_mm and the #29 rule. That is the consumer-side half of that issue, not a change here.

Comment thread mcp/tests/test_service.py
"the controller reached within its own tolerance, wider than ours",
],
)
def test_classify_decides_from_position_not_the_stall_flag(

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 (test_service.py:242): mcp/tests/test_service.py:242 — test_classify_decides_from_position_not_the_stall_flag now holds a row decided by reached_goal, not position. Rename to test_classify_trusts_reached_goal_then_position or move the new row to its own test.

Comment thread mcp/tests/test_service.py
(motion(reached_goal=False, stalled=True), 85.0, 49.0, "stopped_on_object"),
(motion(reached_goal=True), 30.0, 30.0, "reached"),
(motion(reached_goal=False, stalled=True), 30.0, 30.0, "reached"),
(motion(reached_goal=True), 85.0, 83.1, "reached"),

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_service.py:226 — the new row's id, "the controller reached within its own tolerance, wider than ours", is the clearest statement of the rule in the PR; the docstring fix in finding 1 could reuse it.

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