agenticros 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/__tests__/robot-config.test.d.ts +2 -0
  2. package/dist/__tests__/robot-config.test.d.ts.map +1 -0
  3. package/dist/__tests__/robot-config.test.js +374 -0
  4. package/dist/__tests__/robot-config.test.js.map +1 -0
  5. package/dist/__tests__/transport-shorthand.test.d.ts +2 -0
  6. package/dist/__tests__/transport-shorthand.test.d.ts.map +1 -0
  7. package/dist/__tests__/transport-shorthand.test.js +88 -0
  8. package/dist/__tests__/transport-shorthand.test.js.map +1 -0
  9. package/dist/commands/down.d.ts.map +1 -1
  10. package/dist/commands/down.js +55 -3
  11. package/dist/commands/down.js.map +1 -1
  12. package/dist/commands/robots.d.ts +57 -0
  13. package/dist/commands/robots.d.ts.map +1 -0
  14. package/dist/commands/robots.js +678 -0
  15. package/dist/commands/robots.js.map +1 -0
  16. package/dist/index.js +28 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/util/mcp-discovery.d.ts +57 -0
  19. package/dist/util/mcp-discovery.d.ts.map +1 -0
  20. package/dist/util/mcp-discovery.js +153 -0
  21. package/dist/util/mcp-discovery.js.map +1 -0
  22. package/dist/util/robot-config.d.ts +167 -0
  23. package/dist/util/robot-config.d.ts.map +1 -0
  24. package/dist/util/robot-config.js +375 -0
  25. package/dist/util/robot-config.js.map +1 -0
  26. package/dist/util/transport-shorthand.d.ts +66 -0
  27. package/dist/util/transport-shorthand.d.ts.map +1 -0
  28. package/dist/util/transport-shorthand.js +102 -0
  29. package/dist/util/transport-shorthand.js.map +1 -0
  30. package/package.json +2 -1
  31. package/runtime/BUNDLE.json +1 -1
  32. package/runtime/README.md +4 -0
  33. package/runtime/package.json +6 -1
  34. package/runtime/packages/agenticros/openclaw.plugin.json +6 -0
  35. package/runtime/packages/agenticros/package.json +2 -1
  36. package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
  37. package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
  38. package/runtime/packages/agenticros/src/service.ts +120 -2
  39. package/runtime/packages/agenticros/src/skill-loader.ts +9 -1
  40. package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
  41. package/runtime/packages/agenticros/src/tools/index.ts +49 -9
  42. package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
  43. package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
  44. package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
  45. package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
  46. package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
  47. package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
  48. package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
  49. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
  50. package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
  51. package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
  52. package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
  53. package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
  54. package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
  55. package/runtime/packages/agenticros-claude-code/package.json +2 -1
  56. package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +964 -0
  57. package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
  58. package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
  59. package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
  60. package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
  61. package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
  62. package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
  63. package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
  64. package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
  65. package/runtime/packages/agenticros-gemini/package.json +1 -0
  66. package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
  67. package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
  68. package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
  69. package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
  70. package/runtime/packages/core/package.json +1 -1
  71. package/runtime/packages/core/src/__tests__/capabilities.test.ts +261 -0
  72. package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
  73. package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
  74. package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
  75. package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
  76. package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
  77. package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
  78. package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
  79. package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
  80. package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
  81. package/runtime/packages/core/src/capabilities.ts +340 -0
  82. package/runtime/packages/core/src/config.ts +220 -38
  83. package/runtime/packages/core/src/discovery.ts +193 -0
  84. package/runtime/packages/core/src/find-robots-for.ts +164 -0
  85. package/runtime/packages/core/src/index.ts +67 -1
  86. package/runtime/packages/core/src/mission-registry.ts +142 -0
  87. package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
  88. package/runtime/packages/core/src/mission.ts +574 -0
  89. package/runtime/packages/core/src/planner/index.ts +564 -0
  90. package/runtime/packages/core/src/robots.ts +282 -0
  91. package/runtime/packages/core/src/topic-utils.ts +47 -9
  92. package/runtime/packages/core/src/transport-pool.ts +171 -0
  93. package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
  94. package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
  95. package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
  96. package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
  97. package/runtime/scripts/refresh-skill-deps.mjs +381 -0
  98. package/runtime/scripts/setup_gateway_plugin.sh +32 -5
  99. package/runtime/scripts/start_demo.sh +26 -3
  100. package/runtime/scripts/sync-skill-tools.mjs +67 -10
@@ -2,8 +2,32 @@
2
2
  * MCP tool definitions and handler. Mirrors OpenClaw adapter tools.
3
3
  */
4
4
 
5
- import type { AgenticROSConfig } from "@agenticros/core";
6
- import { resolveCameraSubscribeTopic, toNamespacedTopic, toNamespacedTopicFull } from "@agenticros/core";
5
+ import type {
6
+ AgenticROSConfig,
7
+ Capability,
8
+ Mission,
9
+ MissionToolDispatcher,
10
+ CapabilityToolBindings,
11
+ ResolvedRobot,
12
+ } from "@agenticros/core";
13
+ import {
14
+ resolveCameraSubscribeTopic,
15
+ toNamespacedTopic,
16
+ toNamespacedTopicFull,
17
+ listAllCapabilities,
18
+ runMission,
19
+ listRobots,
20
+ getActiveRobotId,
21
+ resolveRobotFromArgs,
22
+ discoverRobots,
23
+ findRobotsFor,
24
+ type FindRobotsForResult,
25
+ generateMissionId,
26
+ createMemoryTranscriptSink,
27
+ missionTranscriptNamespace,
28
+ compileGoalToMission,
29
+ } from "@agenticros/core";
30
+ import { getMissionRegistry } from "./mission-registry.js";
7
31
  import {
8
32
  ROS_MSG_COMPRESSED_IMAGE,
9
33
  ROS_MSG_IMAGE,
@@ -12,7 +36,7 @@ import {
12
36
  rosNumericField,
13
37
  } from "@agenticros/ros-camera";
14
38
  import { resolveMemoryNamespace } from "@agenticros/core";
15
- import { getTransport } from "./transport.js";
39
+ import { getTransportForRobot } from "./transport.js";
16
40
  import { checkPublishSafety } from "./safety.js";
17
41
  import { getDepthDistance } from "./depth.js";
18
42
  import { getFollowMeLocal } from "./follow-me/loop.js";
@@ -33,6 +57,35 @@ export const MEMORY_TOOL_NAMES = new Set<string>([
33
57
  "memory_status",
34
58
  ]);
35
59
 
60
+ /**
61
+ * Tools that read purely from local config / filesystem and don't need the
62
+ * ROS transport. The MCP server entry point uses this to skip
63
+ * `ensureConnected()` so these tools work even when zenohd is down.
64
+ *
65
+ * Memory tools are included because they're cross-adapter local storage.
66
+ * `ros2_list_capabilities` is included because it reads skill manifests.
67
+ */
68
+ export const NO_TRANSPORT_TOOL_NAMES = new Set<string>([
69
+ ...MEMORY_TOOL_NAMES,
70
+ "ros2_list_capabilities",
71
+ "ros2_list_robots",
72
+ // ros2_find_robots_for can run config-only (no online filter); the
73
+ // tool falls back to a transport-driven discovery only when the caller
74
+ // passes online=true. See the handler for the precise branch.
75
+ "ros2_find_robots_for",
76
+ // mission_cancel only mutates the in-process MissionRegistry — it
77
+ // never publishes to ROS, so it must work even when zenohd is down.
78
+ "mission_cancel",
79
+ // run_mission's *outer* call doesn't need transport — its handler
80
+ // first compiles the goal (Phase 1.g, no transport) or validates
81
+ // the mission shape, then per-step calls go through the transport
82
+ // pool individually. Gating the outer call would block the planner
83
+ // from surfacing compile errors when the transport is down (e.g. a
84
+ // user asking "paint the wall" should get suggestions, not an
85
+ // ECONNREFUSED).
86
+ "run_mission",
87
+ ]);
88
+
36
89
  export interface McpTool {
37
90
  name: string;
38
91
  description: string;
@@ -50,16 +103,65 @@ export const TOOLS: McpTool[] = [
50
103
  "List all available ROS2 topics and their message types. Use this to discover what data the robot publishes and what commands it accepts.",
51
104
  inputSchema: { type: "object", properties: {} },
52
105
  },
106
+ {
107
+ name: "ros2_list_capabilities",
108
+ description:
109
+ "List the high-level capabilities (named verbs) this robot can perform — built-in verbs like drive_base / take_snapshot / measure_depth plus every capability declared by installed AgenticROS skills (e.g. follow_person, find_object). PREFER this over ros2_list_topics for high-level planning: capabilities are agent-meaningful verbs with typed inputs/outputs, not raw topic names. Returns one structured response listing every capability the robot supports right now.",
110
+ inputSchema: {
111
+ type: "object",
112
+ properties: {
113
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used. Today every robot exposes the same capabilities, but this parameter is accepted so per-robot capability declarations can extend the registry without a schema change." },
114
+ },
115
+ },
116
+ },
117
+ {
118
+ name: "ros2_list_robots",
119
+ description:
120
+ "List the robots this gateway knows about (id, name, ROS2 namespace, default camera topic) and which one is the active default. Use this FIRST when the user mentions multiple robots, asks 'which robots can you see?', or names a specific robot you haven't heard of. The returned `id` is what later tools (in upcoming iterations) will accept as a `robot_id` parameter — today there's a single active robot, but the field will scope per-tool calls in fleet deployments.",
121
+ inputSchema: { type: "object", properties: {} },
122
+ },
123
+ {
124
+ name: "ros2_discover_robots",
125
+ description:
126
+ "Scan the ROS2 topic graph and report which robots are actually on the wire right now, classified against the gateway's configured robot list. Returns: (1) every namespace inferred from `<ns>/cmd_vel` topics, with a topicCount that says how many corroborating topics live under that namespace, (2) configured_online — configured robots currently publishing, (3) configured_offline — configured robots that are silent, (4) unknown_detected — robots on the wire that aren't in config yet (candidates to add via the CLI / config UI). Use this when the user asks 'which robots are online right now', 'is my robot connected', or wants to find a robot that isn't in ros2_list_robots. Requires the ROS transport to be connected.",
127
+ inputSchema: { type: "object", properties: {} },
128
+ },
129
+ {
130
+ name: "ros2_find_robots_for",
131
+ description:
132
+ "Find the robots in the configured fleet that match a capability + kind + online filter, ranked best-first. PREFER this over ros2_list_robots whenever the user names a verb ('which robot can find a chair', 'do I have an arm robot that can grasp', 'is there an AMR online that can follow a person'). Capability matches the verbs from ros2_list_capabilities — by default robots inherit the gateway-wide registry; declaring per-robot capabilities in config narrows it. Kind matches robot.kind exactly ('amr' | 'arm' | 'drone' | 'rover'). When online=true, only currently-reachable robots are returned (uses the same `<ns>/cmd_vel` heuristic as ros2_discover_robots and requires the transport). The result lists matched robots with id/name/namespace/kind/sensors/online flag, ranked so explicit capability declarations + online robots come first.",
133
+ inputSchema: {
134
+ type: "object",
135
+ properties: {
136
+ capability: {
137
+ type: "string",
138
+ description:
139
+ "Capability id to match (e.g. 'follow_person', 'find_object', 'drive_base'). Case-sensitive — use ros2_list_capabilities to get the exact list.",
140
+ },
141
+ kind: {
142
+ type: "string",
143
+ description:
144
+ "Robot kind filter (case-insensitive exact match). Common values: 'amr', 'arm', 'drone', 'rover'.",
145
+ },
146
+ online: {
147
+ type: "boolean",
148
+ description:
149
+ "When true, only return robots currently reachable on the ROS2 graph (requires the transport to be connected). When false, only return robots NOT reachable. When omitted, online status is annotated on every match but doesn't filter the list.",
150
+ },
151
+ },
152
+ },
153
+ },
53
154
  {
54
155
  name: "ros2_publish",
55
156
  description:
56
- "Publish a message to a ROS2 topic. Use this to send commands to the robot (e.g., velocity commands to /cmd_vel, navigation goals).",
157
+ "Publish a message to a ROS2 topic. Use this to send commands to the robot (e.g., velocity commands to /cmd_vel, navigation goals). Pass robot_id (from ros2_list_robots) to target a specific robot in a multi-robot deployment; omitted = active robot.",
57
158
  inputSchema: {
58
159
  type: "object",
59
160
  properties: {
60
161
  topic: { type: "string", description: "The ROS2 topic name (e.g., '/cmd_vel')" },
61
162
  type: { type: "string", description: "The ROS2 message type (e.g., 'geometry_msgs/msg/Twist')" },
62
163
  message: { type: "object", description: "The message payload matching the ROS2 message type schema" },
164
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
63
165
  },
64
166
  required: ["topic", "type", "message"],
65
167
  },
@@ -67,13 +169,14 @@ export const TOOLS: McpTool[] = [
67
169
  {
68
170
  name: "ros2_subscribe_once",
69
171
  description:
70
- "Subscribe to a ROS2 topic and return the next message. Use this to read sensor data, check robot state, or get the current value of a topic.",
172
+ "Subscribe to a ROS2 topic and return the next message. Use this to read sensor data, check robot state, or get the current value of a topic. Pass robot_id to target a specific robot.",
71
173
  inputSchema: {
72
174
  type: "object",
73
175
  properties: {
74
176
  topic: { type: "string", description: "The ROS2 topic name (e.g., '/battery_state')" },
75
177
  type: { type: "string", description: "The ROS2 message type (optional)" },
76
178
  timeout: { type: "number", description: "Timeout in milliseconds (default: 5000)" },
179
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
77
180
  },
78
181
  required: ["topic"],
79
182
  },
@@ -81,13 +184,14 @@ export const TOOLS: McpTool[] = [
81
184
  {
82
185
  name: "ros2_service_call",
83
186
  description:
84
- "Call a ROS2 service and return the response. Use for request/response operations like setting parameters or querying node state.",
187
+ "Call a ROS2 service and return the response. Use for request/response operations like setting parameters or querying node state. Pass robot_id to target a specific robot.",
85
188
  inputSchema: {
86
189
  type: "object",
87
190
  properties: {
88
191
  service: { type: "string", description: "The ROS2 service name (e.g., '/spawn_entity')" },
89
192
  type: { type: "string", description: "The ROS2 service type (optional)" },
90
193
  args: { type: "object", description: "The service request arguments" },
194
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
91
195
  },
92
196
  required: ["service"],
93
197
  },
@@ -95,38 +199,41 @@ export const TOOLS: McpTool[] = [
95
199
  {
96
200
  name: "ros2_action_goal",
97
201
  description:
98
- "Send a goal to a ROS2 action server. Use for long-running operations like navigation or arm movements.",
202
+ "Send a goal to a ROS2 action server. Use for long-running operations like navigation or arm movements. Pass robot_id to target a specific robot.",
99
203
  inputSchema: {
100
204
  type: "object",
101
205
  properties: {
102
206
  action: { type: "string", description: "The ROS2 action server name (e.g., '/navigate_to_pose')" },
103
207
  actionType: { type: "string", description: "The ROS2 action type (e.g., 'nav2_msgs/action/NavigateToPose')" },
104
208
  goal: { type: "object", description: "The action goal parameters" },
209
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
105
210
  },
106
211
  required: ["action", "actionType", "goal"],
107
212
  },
108
213
  },
109
214
  {
110
215
  name: "ros2_param_get",
111
- description: "Get the value of a ROS2 parameter from a node. Use to check robot configuration values.",
216
+ description: "Get the value of a ROS2 parameter from a node. Use to check robot configuration values. Pass robot_id to target a specific robot.",
112
217
  inputSchema: {
113
218
  type: "object",
114
219
  properties: {
115
220
  node: { type: "string", description: "The fully qualified node name (e.g., '/turtlebot3/controller')" },
116
221
  parameter: { type: "string", description: "The parameter name (e.g., 'max_velocity')" },
222
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
117
223
  },
118
224
  required: ["node", "parameter"],
119
225
  },
120
226
  },
121
227
  {
122
228
  name: "ros2_param_set",
123
- description: "Set the value of a ROS2 parameter on a node. Use to change robot configuration at runtime.",
229
+ description: "Set the value of a ROS2 parameter on a node. Use to change robot configuration at runtime. Pass robot_id to target a specific robot.",
124
230
  inputSchema: {
125
231
  type: "object",
126
232
  properties: {
127
233
  node: { type: "string", description: "The fully qualified node name" },
128
234
  parameter: { type: "string", description: "The parameter name" },
129
235
  value: { type: "object", description: "The new parameter value" },
236
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
130
237
  },
131
238
  required: ["node", "parameter", "value"],
132
239
  },
@@ -134,32 +241,34 @@ export const TOOLS: McpTool[] = [
134
241
  {
135
242
  name: "ros2_camera_snapshot",
136
243
  description:
137
- "Capture a single image from a ROS2 camera topic. Use when the user asks what the robot sees or requests a photo. Supports CompressedImage and raw Image.",
244
+ "Capture a single image from a ROS2 camera topic. Use when the user asks what the robot sees or requests a photo. Supports CompressedImage and raw Image. Pass robot_id to capture from a specific robot's camera; the per-robot default cameraTopic is used when no topic is given.",
138
245
  inputSchema: {
139
246
  type: "object",
140
247
  properties: {
141
- topic: { type: "string", description: "Camera image topic (default from robot.cameraTopic in ~/.agenticros/config.json). Run ros2 topic list and match your driver." },
248
+ topic: { type: "string", description: "Camera image topic (default from the robot's cameraTopic in ~/.agenticros/config.json). Run ros2 topic list and match your driver." },
142
249
  message_type: { type: "string", description: "'CompressedImage' (JPEG topics, names often contain /compressed) or 'Image' for raw sensor_msgs/Image—required if there is no compressed topic." },
143
250
  timeout: { type: "number", description: "Timeout in milliseconds (default: 10000)" },
251
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
144
252
  },
145
253
  },
146
254
  },
147
255
  {
148
256
  name: "ros2_depth_distance",
149
257
  description:
150
- "Get distance in meters from the robot's depth camera. Samples the center of the depth image. Use when the user asks how far they are from the robot.",
258
+ "Get distance in meters from the robot's depth camera. Samples the center of the depth image. Use when the user asks how far they are from the robot. Pass robot_id to sample a specific robot's depth camera.",
151
259
  inputSchema: {
152
260
  type: "object",
153
261
  properties: {
154
262
  topic: { type: "string", description: `Depth image topic (default: ${DEFAULT_DEPTH_TOPIC})` },
155
263
  timeout: { type: "number", description: "Timeout in ms (default 5000)" },
264
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
156
265
  },
157
266
  },
158
267
  },
159
268
  {
160
269
  name: "ros2_follow_me_start",
161
270
  description:
162
- "Start the follow-me skill — the robot follows a person. Optional target description to lock onto a specific person; otherwise follows the closest. Modes: 'depth' (default) runs an in-process depth-only loop in the MCP server (no neural net, no model file, just RealSense depth — drives toward the closest blob in [0.5, 4.0] m); 'node' sends a command to the agenticros_follow_me ROS2 node running on the robot; 'local' runs an in-process YOLOv8n loop (requires yolov8n.onnx, ~8 Hz).",
271
+ "Start the follow-me skill — the robot follows a person. Optional target description to lock onto a specific person; otherwise follows the closest. Modes: 'depth' (default) runs an in-process depth-only loop in the MCP server (no neural net, no model file, just RealSense depth — drives toward the closest blob in [0.5, 4.0] m); 'node' sends a command to the agenticros_follow_me ROS2 node running on the robot; 'local' runs an in-process YOLOv8n loop (requires yolov8n.onnx, ~8 Hz). Pass robot_id (from ros2_list_robots) to start follow-me on a specific robot in a multi-robot deployment; each robot keeps its own independent follow loop.",
163
272
  inputSchema: {
164
273
  type: "object",
165
274
  properties: {
@@ -171,39 +280,43 @@ export const TOOLS: McpTool[] = [
171
280
  type: "string",
172
281
  description: "Optional description of the person to follow (e.g., 'person in red shirt'). Empty = follow closest. Note: depth mode ignores this — it always follows the closest object.",
173
282
  },
283
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
174
284
  },
175
285
  },
176
286
  },
177
287
  {
178
288
  name: "ros2_follow_me_stop",
179
- description: "Stop the follow-me skill. Robot will stop sending follow velocity commands. Pass mode='local' for the YOLO loop or mode='depth' for the depth-only loop.",
289
+ description: "Stop the follow-me skill. Robot will stop sending follow velocity commands. Pass mode='local' for the YOLO loop or mode='depth' for the depth-only loop. Pass robot_id to stop a specific robot; omitted = active robot.",
180
290
  inputSchema: {
181
291
  type: "object",
182
292
  properties: {
183
293
  mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
294
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
184
295
  },
185
296
  },
186
297
  },
187
298
  {
188
299
  name: "ros2_follow_me_status",
189
300
  description:
190
- "Read the current follow-me status (enabled, tracking, target distance, persons detected). For mode='node' returns the latest message from follow_me/status; for mode='depth' or 'local' returns the in-process loop status.",
301
+ "Read the current follow-me status (enabled, tracking, target distance, persons detected). For mode='node' returns the latest message from follow_me/status; for mode='depth' or 'local' returns the in-process loop status. Pass robot_id to read a specific robot's status; omitted = active robot.",
191
302
  inputSchema: {
192
303
  type: "object",
193
304
  properties: {
194
305
  mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
195
306
  timeout: { type: "number", description: "Timeout in milliseconds (default: 3000). Only used for mode='node'." },
307
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
196
308
  },
197
309
  },
198
310
  },
199
311
  {
200
312
  name: "ros2_follow_me_set_distance",
201
- description: "Set the follow-me target distance in meters. Clamped server-side to [0.2, 5.0].",
313
+ description: "Set the follow-me target distance in meters. Clamped server-side to [0.2, 5.0]. Pass robot_id to target a specific robot's loop; omitted = active robot.",
202
314
  inputSchema: {
203
315
  type: "object",
204
316
  properties: {
205
317
  mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
206
318
  distance: { type: "number", description: "Target distance in meters (0.2 to 5.0)" },
319
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
207
320
  },
208
321
  required: ["distance"],
209
322
  },
@@ -211,12 +324,13 @@ export const TOOLS: McpTool[] = [
211
324
  {
212
325
  name: "ros2_follow_me_set_target",
213
326
  description:
214
- "Lock the follow-me tracker onto a person described by text. Locks onto the closest visible person and stores the description for future re-identification.",
327
+ "Lock the follow-me tracker onto a person described by text. Locks onto the closest visible person and stores the description for future re-identification. Pass robot_id to target a specific robot's loop; omitted = active robot.",
215
328
  inputSchema: {
216
329
  type: "object",
217
330
  properties: {
218
331
  mode: { type: "string", description: "'depth' (default), 'node', or 'local'." },
219
332
  description: { type: "string", description: "Description of the person to follow" },
333
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
220
334
  },
221
335
  required: ["description"],
222
336
  },
@@ -277,7 +391,7 @@ export const TOOLS: McpTool[] = [
277
391
  {
278
392
  name: "ros2_find_object",
279
393
  description:
280
- "Rotate the robot in place (clockwise by default) until a target object is detected by YOLOv8n in the camera feed, then stop. Target must be a COCO class name (e.g., 'cell phone', 'chair', 'bottle', 'cup', 'laptop'). Returns whether the object was found, its confidence, bounding box, and horizontal offset from image center (-1=left edge, 0=center, +1=right edge).",
394
+ "Rotate the robot in place (clockwise by default) until a target object is detected by YOLOv8n in the camera feed, then stop. Target must be a COCO class name (e.g., 'cell phone', 'chair', 'bottle', 'cup', 'laptop'). Returns whether the object was found, its confidence, bounding box, and horizontal offset from image center (-1=left edge, 0=center, +1=right edge). Pass robot_id (from ros2_list_robots) to scan with a specific robot's camera; omitted = active robot.",
281
395
  inputSchema: {
282
396
  type: "object",
283
397
  properties: {
@@ -302,10 +416,54 @@ export const TOOLS: McpTool[] = [
302
416
  type: "number",
303
417
  description: "Minimum detection confidence to accept (default 0.5).",
304
418
  },
419
+ robot_id: { type: "string", description: "Optional robot id (from ros2_list_robots) to scope this call. When omitted, the active robot is used." },
305
420
  },
306
421
  required: ["target"],
307
422
  },
308
423
  },
424
+ {
425
+ name: "run_mission",
426
+ description:
427
+ "Execute a multi-step mission by chaining capabilities (the verbs returned by ros2_list_capabilities). PASS EITHER a natural-language `goal` (recommended for simple verbs like 'find a chair', 'take a picture', 'follow me', 'find a chair and drive toward it') AND/OR an explicit `mission.steps[]` plan when you need precise control. Steps run sequentially; each step's outputs are available to later steps via {{stepId.outputs.fieldName}} template references. Returns a per-step result list, a summary line, a mission_id you can pass to mission_cancel to abort mid-run, and (when a goal was provided) the compiled plan + candidate match list so you can see what the planner did. When memory is enabled, every step is also written to the shared memory under namespace mission:<mission_id> so a second agent can recall the timeline via memory_recall. Today the runner supports: drive_base, take_snapshot, measure_depth, list_topics, publish_topic, subscribe_once, follow_person, find_object. Pass mission.robot_id (or robot_id at the top level when using goal) to target every step at one robot.",
428
+ inputSchema: {
429
+ type: "object",
430
+ properties: {
431
+ goal: {
432
+ type: "string",
433
+ description:
434
+ "Natural-language goal — the local planner compiles it into a mission against the capability registry. Examples: 'find a chair', 'find a chair and drive toward it', 'take a picture', 'follow me', 'measure depth', 'drive forward at 0.3 m/s', 'turn left', 'stop'. Either goal OR mission must be provided; if both are given, mission takes precedence (goal is then ignored).",
435
+ },
436
+ mission: {
437
+ type: "object",
438
+ description:
439
+ 'Declarative mission plan. Shape: { name?: string, goal?: string, robot_id?: string, steps: [{ id: string, capability: string, inputs?: object, on_fail?: "stop"|"continue" }] }. mission.robot_id (from ros2_list_robots) is the default robot for every step; individual steps can override via inputs.robot_id. Example: { "name": "find then approach chair", "robot_id": "robotA", "steps": [{ "id": "find", "capability": "find_object", "inputs": { "target": "chair" } }, { "id": "go", "capability": "drive_base", "inputs": { "linear_x": 0.2, "angular_z": "{{find.outputs.horizontal_offset}}" } }] }',
440
+ },
441
+ robot_id: {
442
+ type: "string",
443
+ description: "Optional robot id (from ros2_list_robots) — used when 'goal' is provided to scope every compiled step to one robot.",
444
+ },
445
+ },
446
+ },
447
+ },
448
+ {
449
+ name: "mission_cancel",
450
+ description:
451
+ "Cancel a mission that's currently running in this MCP server. Pass the mission_id returned by run_mission. The mission runner stops at the next step boundary (the in-flight step finishes naturally), marks remaining steps as 'cancelled', and returns. If the mission has already finished (or the id is unknown), this is a no-op that returns found=false. Optional 'reason' is recorded in the cancelled step results for traceability.",
452
+ inputSchema: {
453
+ type: "object",
454
+ properties: {
455
+ mission_id: {
456
+ type: "string",
457
+ description: "The mission_id echoed by run_mission. Required.",
458
+ },
459
+ reason: {
460
+ type: "string",
461
+ description: "Optional free-text reason — surfaced in the cancelled mission result.",
462
+ },
463
+ },
464
+ required: ["mission_id"],
465
+ },
466
+ },
309
467
  ];
310
468
 
311
469
  export type ToolContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string };
@@ -319,15 +477,19 @@ function followMeMode(args: Record<string, unknown>): "node" | "local" | "depth"
319
477
 
320
478
  async function publishFollowMeCmd(
321
479
  config: AgenticROSConfig,
480
+ robot: ResolvedRobot,
322
481
  payload: Record<string, unknown>,
323
482
  ): Promise<{ topic: string; payload: Record<string, unknown> }> {
324
- const transport = getTransport();
483
+ // Route through the per-robot pool so a robot with a custom transport
484
+ // override (e.g. zenoh-on-router) gets its own connection while
485
+ // single-transport deployments still share `__global__`.
486
+ const transport = await getTransportForRobot(config, robot);
325
487
  if (transport.getStatus() !== "connected") {
326
488
  throw new Error(
327
489
  "Transport not connected. Check zenohd (ws://localhost:10000) and config in ~/.agenticros/config.json.",
328
490
  );
329
491
  }
330
- const topic = toNamespacedTopicFull(config, "/follow_me/cmd");
492
+ const topic = toNamespacedTopicFull(robot.namespace, "/follow_me/cmd");
331
493
  const data = JSON.stringify(payload);
332
494
  const PUBLISH_TIMEOUT_MS = 5_000;
333
495
  const timeoutPromise = new Promise<never>((_, reject) => {
@@ -343,6 +505,332 @@ async function publishFollowMeCmd(
343
505
  return { topic, payload };
344
506
  }
345
507
 
508
+ /**
509
+ * Format the capability list for tool responses — shared by claude-code,
510
+ * OpenClaw, and Gemini so every adapter returns the same JSON shape.
511
+ */
512
+ function formatCapabilitiesResponse(caps: Capability[]): string {
513
+ const byKind = {
514
+ intrinsic: caps.filter((c) => c.source?.kind === "builtin").length,
515
+ skill: caps.filter((c) => c.source?.kind === "skill").length,
516
+ };
517
+ return JSON.stringify({
518
+ success: true,
519
+ total: caps.length,
520
+ intrinsic_count: byKind.intrinsic,
521
+ skill_count: byKind.skill,
522
+ capabilities: caps,
523
+ });
524
+ }
525
+
526
+ /**
527
+ * Convert an unknown-robot_id error from resolveRobotFromArgs into a
528
+ * tool-level error response. The error message already lists known ids
529
+ * and recommends ros2_list_robots, so the agent has everything it
530
+ * needs to recover without us re-formatting.
531
+ */
532
+ function robotResolveError(err: unknown): { content: ToolContent[]; isError: true } {
533
+ const message = err instanceof Error ? err.message : String(err);
534
+ return { content: [{ type: "text", text: message }], isError: true };
535
+ }
536
+
537
+ /**
538
+ * Format the robot list for tool responses — shared by claude-code,
539
+ * OpenClaw, and Gemini so every adapter returns the same JSON shape.
540
+ */
541
+ function formatRobotsResponse(robots: ResolvedRobot[], activeId: string): string {
542
+ return JSON.stringify({
543
+ success: true,
544
+ total: robots.length,
545
+ active_robot_id: activeId,
546
+ robots,
547
+ });
548
+ }
549
+
550
+ /**
551
+ * Render the find_robots_for result as a single LLM-friendly JSON
552
+ * payload. We flatten each `FindRobotsForMatch` so the agent doesn't
553
+ * have to dig through `.robot.*` to read id/name/namespace — those are
554
+ * the fields it'll pass to the next tool call.
555
+ */
556
+ function formatFindRobotsForResponse(result: FindRobotsForResult): string {
557
+ return JSON.stringify({
558
+ success: true,
559
+ query: result.query,
560
+ total: result.total,
561
+ robots: result.robots.map((m) => ({
562
+ id: m.robot.id,
563
+ name: m.robot.name,
564
+ namespace: m.robot.namespace,
565
+ kind: m.robot.kind,
566
+ sensors: m.robot.sensors,
567
+ capabilities: m.robot.capabilities ?? null,
568
+ cameraTopic: m.robot.cameraTopic,
569
+ online: m.online,
570
+ matched_capability_explicitly: m.matched_capability_explicitly,
571
+ score: m.score,
572
+ })),
573
+ });
574
+ }
575
+
576
+ /**
577
+ * Capability → MCP tool dispatch table used by `run_mission`.
578
+ *
579
+ * Every capability returned by `ros2_list_capabilities` that the mission
580
+ * runner can actually execute must appear here. If you add a new
581
+ * intrinsic capability to BUILTIN_CAPABILITIES, mirror it here. For new
582
+ * skill-declared capabilities, this is where you map them to the
583
+ * corresponding MCP tool. Steps that name a capability without an entry
584
+ * fail with an actionable "no mission-runner tool binding" error.
585
+ */
586
+ const MISSION_BINDINGS: CapabilityToolBindings = {
587
+ drive_base: {
588
+ tool: "ros2_publish",
589
+ buildArgs: (inputs) => {
590
+ const lx = Number(inputs.linear_x ?? 0) || 0;
591
+ const az = Number(inputs.angular_z ?? 0) || 0;
592
+ return {
593
+ topic: "/cmd_vel",
594
+ type: "geometry_msgs/msg/Twist",
595
+ message: {
596
+ linear: { x: lx, y: 0, z: 0 },
597
+ angular: { x: 0, y: 0, z: az },
598
+ },
599
+ };
600
+ },
601
+ },
602
+ take_snapshot: {
603
+ tool: "ros2_camera_snapshot",
604
+ buildArgs: (inputs) => {
605
+ const out: Record<string, unknown> = {};
606
+ if (typeof inputs.topic === "string") out.topic = inputs.topic;
607
+ if (typeof inputs.message_type === "string") out.message_type = inputs.message_type;
608
+ if (typeof inputs.timeout === "number") out.timeout = inputs.timeout;
609
+ return out;
610
+ },
611
+ },
612
+ measure_depth: {
613
+ tool: "ros2_depth_distance",
614
+ buildArgs: (inputs) => {
615
+ const out: Record<string, unknown> = {};
616
+ if (typeof inputs.topic === "string") out.topic = inputs.topic;
617
+ if (typeof inputs.timeout === "number") out.timeout = inputs.timeout;
618
+ return out;
619
+ },
620
+ },
621
+ list_topics: {
622
+ tool: "ros2_list_topics",
623
+ buildArgs: () => ({}),
624
+ },
625
+ publish_topic: {
626
+ tool: "ros2_publish",
627
+ buildArgs: (inputs) => ({
628
+ topic: String(inputs.topic ?? ""),
629
+ type: String(inputs.type ?? inputs.msg_type ?? ""),
630
+ message: inputs.message ?? inputs.msg ?? {},
631
+ }),
632
+ },
633
+ subscribe_once: {
634
+ tool: "ros2_subscribe_once",
635
+ buildArgs: (inputs) => {
636
+ const out: Record<string, unknown> = { topic: String(inputs.topic ?? "") };
637
+ if (typeof inputs.type === "string") out.type = inputs.type;
638
+ if (typeof inputs.timeout === "number") out.timeout = inputs.timeout;
639
+ return out;
640
+ },
641
+ },
642
+ follow_person: {
643
+ tool: "ros2_follow_me_start",
644
+ buildArgs: (inputs) => {
645
+ const out: Record<string, unknown> = {};
646
+ if (typeof inputs.target_distance === "number") out.target_distance = inputs.target_distance;
647
+ if (typeof inputs.mode === "string") out.mode = inputs.mode;
648
+ return out;
649
+ },
650
+ },
651
+ find_object: {
652
+ tool: "ros2_find_object",
653
+ buildArgs: (inputs) => {
654
+ const target = String(inputs.target ?? "");
655
+ const out: Record<string, unknown> = { target };
656
+ if (typeof inputs.angular_speed === "number") out.angular_speed = inputs.angular_speed;
657
+ if (typeof inputs.clockwise === "boolean") out.clockwise = inputs.clockwise;
658
+ if (typeof inputs.timeout_seconds === "number") out.timeout_seconds = inputs.timeout_seconds;
659
+ if (typeof inputs.min_confidence === "number") out.min_confidence = inputs.min_confidence;
660
+ return out;
661
+ },
662
+ },
663
+ };
664
+
665
+ /**
666
+ * Phase 1.f — `mission_cancel` handler. Lives at module scope so the
667
+ * outer `handleToolCall` can invoke it WITHOUT first resolving a
668
+ * transport (the cancel never touches ROS — see the registry note in
669
+ * `mission-registry.ts`).
670
+ */
671
+ function handleMissionCancel(
672
+ args: Record<string, unknown>,
673
+ ): { content: ToolContent[]; isError?: boolean } {
674
+ const missionId = typeof args["mission_id"] === "string" ? args["mission_id"].trim() : "";
675
+ if (!missionId) {
676
+ return {
677
+ content: [
678
+ {
679
+ type: "text",
680
+ text: "mission_cancel requires 'mission_id' (a non-empty string returned by run_mission).",
681
+ },
682
+ ],
683
+ isError: true,
684
+ };
685
+ }
686
+ const reason = typeof args["reason"] === "string" ? args["reason"] : undefined;
687
+ const outcome = getMissionRegistry().cancel(missionId, reason);
688
+ return {
689
+ content: [
690
+ {
691
+ type: "text",
692
+ text: JSON.stringify({
693
+ success: true,
694
+ mission_id: missionId,
695
+ found: outcome.found,
696
+ already_cancelled: outcome.alreadyCancelled,
697
+ reason: reason ?? null,
698
+ }),
699
+ },
700
+ ],
701
+ };
702
+ }
703
+
704
+ /**
705
+ * Phase 1.c + 1.f + 1.g — `run_mission` handler. Lives at module scope
706
+ * so the outer `handleToolCall` can short-circuit BEFORE
707
+ * `getTransportForRobot()` (the per-step dispatcher resolves transport
708
+ * tool-by-tool; the outer call must not). Accepts either an explicit
709
+ * `mission.steps[]` plan OR a natural-language `goal` (compiled via
710
+ * the local rule-based planner).
711
+ */
712
+ async function handleRunMission(
713
+ args: Record<string, unknown>,
714
+ config: AgenticROSConfig,
715
+ ): Promise<{ content: ToolContent[]; isError?: boolean }> {
716
+ const caps = listAllCapabilities(config);
717
+ const missionArg = args["mission"];
718
+ const goalArg = args["goal"];
719
+ const topLevelRobotId = typeof args["robot_id"] === "string" ? (args["robot_id"] as string) : undefined;
720
+
721
+ // Phase 1.g — when no explicit mission is provided, compile from
722
+ // the natural-language goal. We surface the planner's candidates
723
+ // + suggestions in the response so the agent can self-correct on
724
+ // a failed compile without a second round-trip.
725
+ let mission: Mission;
726
+ let plannerInfo:
727
+ | { compiled_from_goal: string; candidates: unknown[]; unmatched_verbs?: string[] }
728
+ | undefined;
729
+ if (missionArg && typeof missionArg === "object" && !Array.isArray(missionArg)) {
730
+ mission = missionArg as Mission;
731
+ if (!Array.isArray(mission.steps)) {
732
+ return {
733
+ content: [{ type: "text", text: "mission.steps must be an array of step objects." }],
734
+ isError: true,
735
+ };
736
+ }
737
+ } else if (typeof goalArg === "string" && goalArg.trim().length > 0) {
738
+ const planned = compileGoalToMission(goalArg, caps, { robot_id: topLevelRobotId });
739
+ if (!planned.mission) {
740
+ return {
741
+ content: [
742
+ {
743
+ type: "text",
744
+ text: JSON.stringify({
745
+ success: false,
746
+ error: planned.error,
747
+ goal: goalArg,
748
+ suggestions: planned.suggestions,
749
+ ...(planned.unmatched_verbs ? { unmatched_verbs: planned.unmatched_verbs } : {}),
750
+ }),
751
+ },
752
+ ],
753
+ isError: true,
754
+ };
755
+ }
756
+ mission = planned.mission;
757
+ plannerInfo = {
758
+ compiled_from_goal: goalArg,
759
+ candidates: planned.candidates,
760
+ ...(planned.unmatched_verbs ? { unmatched_verbs: planned.unmatched_verbs } : {}),
761
+ };
762
+ } else {
763
+ return {
764
+ content: [
765
+ {
766
+ type: "text",
767
+ text: 'run_mission requires either "mission" (object with steps[]) or "goal" (natural-language string). Pass at least one.',
768
+ },
769
+ ],
770
+ isError: true,
771
+ };
772
+ }
773
+ if (typeof mission.robot_id === "string" && mission.robot_id.trim().length > 0) {
774
+ try {
775
+ resolveRobotFromArgs(config, { robot_id: mission.robot_id });
776
+ } catch (err) {
777
+ return robotResolveError(err);
778
+ }
779
+ }
780
+ const dispatcher: MissionToolDispatcher = async (toolName, toolArgs) => {
781
+ const res = await handleToolCall(toolName, toolArgs, config);
782
+ const text = res.content
783
+ .map((c) => (c.type === "text" ? c.text : `[image: ${c.mimeType}]`))
784
+ .join("\n");
785
+ return { text, isError: res.isError };
786
+ };
787
+
788
+ const missionId = generateMissionId();
789
+ const registry = getMissionRegistry();
790
+ const { entry: regEntry, dispose: disposeRegistry } = registry.register(missionId, {
791
+ name: mission.name,
792
+ });
793
+
794
+ const memory = await ensureMemory(config);
795
+ const transcript = memory ? createMemoryTranscriptSink(memory, missionId) : undefined;
796
+
797
+ let result;
798
+ try {
799
+ result = await runMission(mission, caps, MISSION_BINDINGS, dispatcher, {
800
+ mission_id: missionId,
801
+ cancellation: regEntry.cancellation,
802
+ transcript,
803
+ adapter: "claude-code",
804
+ });
805
+ } finally {
806
+ disposeRegistry();
807
+ }
808
+ const compact = {
809
+ status: result.status,
810
+ mission_id: result.mission_id,
811
+ ...(result.cancellation_reason ? { cancellation_reason: result.cancellation_reason } : {}),
812
+ ...(transcript ? { transcript_namespace: missionTranscriptNamespace(missionId) } : {}),
813
+ ...(plannerInfo ? { planner: plannerInfo } : {}),
814
+ steps_run: result.steps_run,
815
+ steps_total: result.steps_total,
816
+ duration_ms: result.duration_ms,
817
+ summary: result.summary,
818
+ steps: result.steps.map((s) => ({
819
+ id: s.id,
820
+ capability: s.capability,
821
+ status: s.status,
822
+ inputs: s.inputs,
823
+ outputs: s.outputs,
824
+ ...(s.error ? { error: s.error } : {}),
825
+ duration_ms: s.duration_ms,
826
+ })),
827
+ };
828
+ return {
829
+ content: [{ type: "text", text: `${result.summary}\n${JSON.stringify(compact)}` }],
830
+ isError: result.status === "error",
831
+ };
832
+ }
833
+
346
834
  export async function handleToolCall(
347
835
  name: string,
348
836
  args: Record<string, unknown>,
@@ -353,7 +841,106 @@ export async function handleToolCall(
353
841
  if (MEMORY_TOOL_NAMES.has(name)) {
354
842
  return handleMemoryToolCall(name, args, config);
355
843
  }
356
- const transport = getTransport();
844
+ // ros2_list_capabilities reads skill manifests + intrinsic verbs from
845
+ // local config — no transport required. robot_id is accepted but
846
+ // doesn't change today's response (every robot exposes the same
847
+ // capabilities); we still validate it so an unknown id surfaces as a
848
+ // tool error instead of being silently ignored.
849
+ if (name === "ros2_list_capabilities") {
850
+ try {
851
+ resolveRobotFromArgs(config, args);
852
+ } catch (err) {
853
+ return robotResolveError(err);
854
+ }
855
+ const caps = listAllCapabilities(config);
856
+ return { content: [{ type: "text", text: formatCapabilitiesResponse(caps) }] };
857
+ }
858
+ // ros2_list_robots reads the multi-robot section of the config (with
859
+ // legacy fallback) — no transport required either.
860
+ if (name === "ros2_list_robots") {
861
+ const robots = listRobots(config);
862
+ const active = getActiveRobotId(config);
863
+ return {
864
+ content: [{ type: "text", text: formatRobotsResponse(robots, active) }],
865
+ };
866
+ }
867
+ // ros2_find_robots_for is config-driven by default and only touches
868
+ // the transport when the caller filters by online status. Putting it
869
+ // here (above the unconditional transport resolution) lets agents
870
+ // call it offline — useful for static-fleet planning and for the
871
+ // CLI's `agenticros robots` family which reuses this MCP tool.
872
+ if (name === "ros2_find_robots_for") {
873
+ const cap = typeof args["capability"] === "string" ? args["capability"] : undefined;
874
+ const kind = typeof args["kind"] === "string" ? args["kind"] : undefined;
875
+ const online = typeof args["online"] === "boolean" ? args["online"] : undefined;
876
+ let onlineIds: Set<string> | undefined;
877
+ if (online !== undefined) {
878
+ // Online filtering needs ONE live transport to list topics with.
879
+ // The active robot's transport works fine here — discoverRobots()
880
+ // operates on the global topic graph and doesn't care which
881
+ // namespace we listed from.
882
+ let activeRobot;
883
+ try {
884
+ activeRobot = resolveRobotFromArgs(config, {});
885
+ } catch (err) {
886
+ return robotResolveError(err);
887
+ }
888
+ const transport = await getTransportForRobot(config, activeRobot);
889
+ if (transport.getStatus() !== "connected") {
890
+ return {
891
+ content: [
892
+ {
893
+ type: "text",
894
+ text: JSON.stringify({
895
+ success: false,
896
+ error:
897
+ "online filter requires the ROS transport to be connected. Drop the 'online' arg to run config-only, or check that zenohd / rosbridge is up.",
898
+ }),
899
+ },
900
+ ],
901
+ };
902
+ }
903
+ const topics = await transport.listTopics();
904
+ const disc = discoverRobots(topics, config);
905
+ onlineIds = new Set(disc.configured_online.map((r) => r.id));
906
+ }
907
+ try {
908
+ const result = findRobotsFor(config, { capability: cap, kind, online }, onlineIds);
909
+ return { content: [{ type: "text", text: formatFindRobotsForResponse(result) }] };
910
+ } catch (e) {
911
+ return {
912
+ content: [
913
+ { type: "text", text: JSON.stringify({ success: false, error: e instanceof Error ? e.message : String(e) }) },
914
+ ],
915
+ };
916
+ }
917
+ }
918
+ // mission_cancel + run_mission must NOT trigger a transport connect
919
+ // at this outer layer: mission_cancel only mutates the in-process
920
+ // MissionRegistry, and run_mission's handler does its OWN per-step
921
+ // dispatch (each step's inner `handleToolCall` connects as needed).
922
+ // Routing the outer call through `getTransportForRobot()` would
923
+ // hang or fail when an agent only wanted to compile / cancel.
924
+ if (name === "mission_cancel") {
925
+ return handleMissionCancel(args);
926
+ }
927
+ if (name === "run_mission") {
928
+ return handleRunMission(args, config);
929
+ }
930
+ // Resolve target robot once for every transport-bound tool. Unknown
931
+ // robot_id surfaces as a tool error (not a thrown exception) so the
932
+ // LLM sees a clean self-correctable response.
933
+ let robot;
934
+ try {
935
+ robot = resolveRobotFromArgs(config, args);
936
+ } catch (err) {
937
+ return robotResolveError(err);
938
+ }
939
+ // Per-robot transport pool: returns the shared `__global__` entry
940
+ // when this robot has no override (the common case), OR materialises
941
+ // a dedicated transport on first use when the robot declares its own
942
+ // (e.g. one robot via local DDS + another via a zenoh router).
943
+ const transport = await getTransportForRobot(config, robot);
357
944
 
358
945
  switch (name) {
359
946
  case "ros2_list_topics": {
@@ -398,6 +985,31 @@ export async function handleToolCall(
398
985
  return { content: [{ type: "text", text }] };
399
986
  }
400
987
 
988
+ case "ros2_discover_robots": {
989
+ // Scan the live topic graph, then classify against the configured
990
+ // robot list using the shared pure-function in @agenticros/core.
991
+ // Discovery is read-only — nothing is written to config here. The
992
+ // agent (or the CLI / config UI on top) decides whether to
993
+ // promote an unknown_detected entry into config.robots[].
994
+ const topics = await transport.listTopics();
995
+ const result = discoverRobots(topics, config);
996
+ return {
997
+ content: [
998
+ {
999
+ type: "text",
1000
+ text: JSON.stringify({
1001
+ success: true,
1002
+ total_topics: result.total_topics,
1003
+ detected: result.detected,
1004
+ configured_online: result.configured_online,
1005
+ configured_offline: result.configured_offline,
1006
+ unknown_detected: result.unknown_detected,
1007
+ }),
1008
+ },
1009
+ ],
1010
+ };
1011
+ }
1012
+
401
1013
  case "ros2_publish": {
402
1014
  const rawTopicIn = String(args["topic"] ?? "").trim();
403
1015
  if (process.stderr?.write) {
@@ -422,7 +1034,7 @@ export async function handleToolCall(
422
1034
  const topic =
423
1035
  cmdVelMatch && !segment.toLowerCase().startsWith("robot")
424
1036
  ? `/robot${segment.replace(/-/g, "")}/cmd_vel`
425
- : toNamespacedTopic(config, rawTopicIn);
1037
+ : toNamespacedTopic(robot.namespace, rawTopicIn);
426
1038
  if (process.stderr?.write) {
427
1039
  process.stderr.write(`[AgenticROS] ros2_publish: → topic=${topic}\n`);
428
1040
  }
@@ -444,7 +1056,7 @@ export async function handleToolCall(
444
1056
 
445
1057
  case "ros2_subscribe_once": {
446
1058
  const rawTopic = args["topic"] as string;
447
- const topic = toNamespacedTopic(config, rawTopic);
1059
+ const topic = toNamespacedTopic(robot.namespace, rawTopic);
448
1060
  let msgType = args["type"] as string | undefined;
449
1061
  const timeout = (args["timeout"] as number | undefined) ?? 5000;
450
1062
  if (!msgType && /\/?(camera|image|color|depth)/i.test(rawTopic)) {
@@ -479,7 +1091,7 @@ export async function handleToolCall(
479
1091
 
480
1092
  case "ros2_service_call": {
481
1093
  const rawService = args["service"] as string;
482
- const service = toNamespacedTopic(config, rawService);
1094
+ const service = toNamespacedTopic(robot.namespace, rawService);
483
1095
  const type = args["type"] as string | undefined;
484
1096
  const reqArgs = args["args"] as Record<string, unknown> | undefined;
485
1097
  const response = await transport.callService({ service, type, args: reqArgs });
@@ -493,7 +1105,7 @@ export async function handleToolCall(
493
1105
 
494
1106
  case "ros2_action_goal": {
495
1107
  const rawAction = args["action"] as string;
496
- const action = toNamespacedTopic(config, rawAction);
1108
+ const action = toNamespacedTopic(robot.namespace, rawAction);
497
1109
  const actionType = args["actionType"] as string;
498
1110
  const goal = args["goal"] as Record<string, unknown>;
499
1111
  const actionResult = await transport.sendActionGoal({ action, actionType, args: goal });
@@ -507,7 +1119,7 @@ export async function handleToolCall(
507
1119
 
508
1120
  case "ros2_param_get": {
509
1121
  const rawNode = args["node"] as string;
510
- const node = toNamespacedTopic(config, rawNode);
1122
+ const node = toNamespacedTopic(robot.namespace, rawNode);
511
1123
  const parameter = args["parameter"] as string;
512
1124
  const response = await transport.callService({
513
1125
  service: `${node}/get_parameters`,
@@ -525,7 +1137,7 @@ export async function handleToolCall(
525
1137
 
526
1138
  case "ros2_param_set": {
527
1139
  const rawNode = args["node"] as string;
528
- const node = toNamespacedTopic(config, rawNode);
1140
+ const node = toNamespacedTopic(robot.namespace, rawNode);
529
1141
  const parameter = args["parameter"] as string;
530
1142
  const value = args["value"];
531
1143
  const response = await transport.callService({
@@ -543,9 +1155,9 @@ export async function handleToolCall(
543
1155
 
544
1156
  case "ros2_camera_snapshot": {
545
1157
  const defaultTopic =
546
- (config.robot?.cameraTopic ?? "").trim() || "/camera/camera/color/image_raw/compressed";
1158
+ (robot.cameraTopic ?? "").trim() || "/camera/camera/color/image_raw/compressed";
547
1159
  const rawTopic = (args["topic"] as string | undefined) ?? defaultTopic;
548
- const topic = resolveCameraSubscribeTopic(config, rawTopic);
1160
+ const topic = resolveCameraSubscribeTopic(robot.namespace, rawTopic);
549
1161
  const rawMsgType = args["message_type"] as string | undefined;
550
1162
  const messageType: "CompressedImage" | "Image" = rawMsgType === "Image" ? "Image" : "CompressedImage";
551
1163
  const timeout = (args["timeout"] as number | undefined) ?? 10000;
@@ -617,7 +1229,7 @@ export async function handleToolCall(
617
1229
 
618
1230
  case "ros2_depth_distance": {
619
1231
  const rawTopic = (args["topic"] as string | undefined)?.trim() || DEFAULT_DEPTH_TOPIC;
620
- const topic = resolveCameraSubscribeTopic(config, rawTopic);
1232
+ const topic = resolveCameraSubscribeTopic(robot.namespace, rawTopic);
621
1233
  const timeout = (args["timeout"] as number | undefined) ?? 5000;
622
1234
  try {
623
1235
  const result = await getDepthDistance(transport, topic, timeout);
@@ -645,9 +1257,9 @@ export async function handleToolCall(
645
1257
  };
646
1258
  }
647
1259
  try {
648
- const loop = getFollowMeLocal(config, transport);
1260
+ const loop = getFollowMeLocal(robot, config, transport);
649
1261
  await loop.start({ targetDescription: desc || undefined });
650
- const text = `Follow-me (local) started${desc ? ` (target: ${desc})` : " (closest person)"}. Use ros2_follow_me_status with mode='local' to check tracking state.`;
1262
+ const text = `Follow-me (local) started on ${robot.id}${desc ? ` (target: ${desc})` : " (closest person)"}. Use ros2_follow_me_status with mode='local' to check tracking state.`;
651
1263
  return { content: [{ type: "text", text }] };
652
1264
  } catch (err) {
653
1265
  return { content: [{ type: "text", text: `Follow-me local start failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
@@ -661,16 +1273,16 @@ export async function handleToolCall(
661
1273
  };
662
1274
  }
663
1275
  try {
664
- const loop = getFollowMeDepth(config, transport);
1276
+ const loop = getFollowMeDepth(robot, config, transport);
665
1277
  await loop.start({ targetDescription: desc || undefined });
666
- const text = `Follow-me (depth-only) started — driving toward the closest blob in [0.5, 4.0] m. No person recognition; will follow whatever object is closest. Use ros2_follow_me_status with mode='depth' to check tracking state.`;
1278
+ const text = `Follow-me (depth-only) started on ${robot.id} — driving toward the closest blob in [0.5, 4.0] m. No person recognition; will follow whatever object is closest. Use ros2_follow_me_status with mode='depth' to check tracking state.`;
667
1279
  return { content: [{ type: "text", text }] };
668
1280
  } catch (err) {
669
1281
  return { content: [{ type: "text", text: `Follow-me depth start failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
670
1282
  }
671
1283
  }
672
1284
  try {
673
- const { topic } = await publishFollowMeCmd(config, {
1285
+ const { topic } = await publishFollowMeCmd(config, robot, {
674
1286
  action: "start",
675
1287
  ...(desc ? { target: desc } : {}),
676
1288
  });
@@ -685,24 +1297,24 @@ export async function handleToolCall(
685
1297
  const mode = followMeMode(args);
686
1298
  if (mode === "local") {
687
1299
  try {
688
- const loop = getFollowMeLocal(config, transport);
1300
+ const loop = getFollowMeLocal(robot, config, transport);
689
1301
  await loop.stop();
690
- return { content: [{ type: "text", text: "Follow-me (local) stopped. cmd_vel zeroed." }] };
1302
+ return { content: [{ type: "text", text: `Follow-me (local) stopped on ${robot.id}. cmd_vel zeroed.` }] };
691
1303
  } catch (err) {
692
1304
  return { content: [{ type: "text", text: `Follow-me local stop failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
693
1305
  }
694
1306
  }
695
1307
  if (mode === "depth") {
696
1308
  try {
697
- const loop = getFollowMeDepth(config, transport);
1309
+ const loop = getFollowMeDepth(robot, config, transport);
698
1310
  await loop.stop();
699
- return { content: [{ type: "text", text: "Follow-me (depth) stopped. cmd_vel zeroed." }] };
1311
+ return { content: [{ type: "text", text: `Follow-me (depth) stopped on ${robot.id}. cmd_vel zeroed.` }] };
700
1312
  } catch (err) {
701
1313
  return { content: [{ type: "text", text: `Follow-me depth stop failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
702
1314
  }
703
1315
  }
704
1316
  try {
705
- const { topic } = await publishFollowMeCmd(config, { action: "stop" });
1317
+ const { topic } = await publishFollowMeCmd(config, robot, { action: "stop" });
706
1318
  return { content: [{ type: "text", text: `Follow-me stop sent to ${topic}.` }] };
707
1319
  } catch (err) {
708
1320
  return { content: [{ type: "text", text: `Follow-me stop failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
@@ -719,15 +1331,15 @@ export async function handleToolCall(
719
1331
  }
720
1332
  const mode = followMeMode(args);
721
1333
  if (mode === "local") {
722
- getFollowMeLocal(config, transport).setTargetDistance(distance);
723
- return { content: [{ type: "text", text: `Follow-me (local) target distance set to ${distance} m.` }] };
1334
+ getFollowMeLocal(robot, config, transport).setTargetDistance(distance);
1335
+ return { content: [{ type: "text", text: `Follow-me (local) target distance on ${robot.id} set to ${distance} m.` }] };
724
1336
  }
725
1337
  if (mode === "depth") {
726
- getFollowMeDepth(config, transport).setTargetDistance(distance);
727
- return { content: [{ type: "text", text: `Follow-me (depth) target distance set to ${distance} m.` }] };
1338
+ getFollowMeDepth(robot, config, transport).setTargetDistance(distance);
1339
+ return { content: [{ type: "text", text: `Follow-me (depth) target distance on ${robot.id} set to ${distance} m.` }] };
728
1340
  }
729
1341
  try {
730
- const { topic } = await publishFollowMeCmd(config, { action: "set_distance", distance });
1342
+ const { topic } = await publishFollowMeCmd(config, robot, { action: "set_distance", distance });
731
1343
  return { content: [{ type: "text", text: `Follow-me set_distance=${distance} sent to ${topic}.` }] };
732
1344
  } catch (err) {
733
1345
  return { content: [{ type: "text", text: `Follow-me set_distance failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
@@ -741,15 +1353,15 @@ export async function handleToolCall(
741
1353
  }
742
1354
  const mode = followMeMode(args);
743
1355
  if (mode === "local") {
744
- getFollowMeLocal(config, transport).setTargetDescription(description);
745
- return { content: [{ type: "text", text: `Follow-me (local) target description set: ${description}. (Note: local mode currently follows the largest person; description is recorded but not yet used for re-id.)` }] };
1356
+ getFollowMeLocal(robot, config, transport).setTargetDescription(description);
1357
+ return { content: [{ type: "text", text: `Follow-me (local) target description on ${robot.id} set: ${description}. (Note: local mode currently follows the largest person; description is recorded but not yet used for re-id.)` }] };
746
1358
  }
747
1359
  if (mode === "depth") {
748
- getFollowMeDepth(config, transport).setTargetDescription(description);
749
- return { content: [{ type: "text", text: `Follow-me (depth) target description recorded: ${description}. (Depth mode has no semantic recognition; it always follows the closest blob.)` }] };
1360
+ getFollowMeDepth(robot, config, transport).setTargetDescription(description);
1361
+ return { content: [{ type: "text", text: `Follow-me (depth) target description on ${robot.id} recorded: ${description}. (Depth mode has no semantic recognition; it always follows the closest blob.)` }] };
750
1362
  }
751
1363
  try {
752
- const { topic } = await publishFollowMeCmd(config, { action: "set_target", description });
1364
+ const { topic } = await publishFollowMeCmd(config, robot, { action: "set_target", description });
753
1365
  return { content: [{ type: "text", text: `Follow-me set_target sent to ${topic} (description: ${description}).` }] };
754
1366
  } catch (err) {
755
1367
  return { content: [{ type: "text", text: `Follow-me set_target failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
@@ -759,14 +1371,14 @@ export async function handleToolCall(
759
1371
  case "ros2_follow_me_status": {
760
1372
  const mode = followMeMode(args);
761
1373
  if (mode === "local") {
762
- const status = getFollowMeLocal(config, transport).status();
763
- return { content: [{ type: "text", text: JSON.stringify({ success: true, mode: "local", status }) }] };
1374
+ const status = getFollowMeLocal(robot, config, transport).status();
1375
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, mode: "local", robot_id: robot.id, status }) }] };
764
1376
  }
765
1377
  if (mode === "depth") {
766
- const status = getFollowMeDepth(config, transport).status();
767
- return { content: [{ type: "text", text: JSON.stringify({ success: true, mode: "depth", status }) }] };
1378
+ const status = getFollowMeDepth(robot, config, transport).status();
1379
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, mode: "depth", robot_id: robot.id, status }) }] };
768
1380
  }
769
- const topic = toNamespacedTopicFull(config, "/follow_me/status");
1381
+ const topic = toNamespacedTopicFull(robot.namespace, "/follow_me/status");
770
1382
  const timeout = (args["timeout"] as number | undefined) ?? 3000;
771
1383
  try {
772
1384
  const message = await new Promise<Record<string, unknown>>((resolve, reject) => {
@@ -808,7 +1420,7 @@ export async function handleToolCall(
808
1420
  if (!target) {
809
1421
  return { content: [{ type: "text", text: "Missing required argument: target" }], isError: true };
810
1422
  }
811
- const result = await findObject(config, transport, {
1423
+ const result = await findObject(robot, config, transport, {
812
1424
  target,
813
1425
  angularSpeed: args["angular_speed"] as number | undefined,
814
1426
  clockwise: args["clockwise"] as boolean | undefined,