Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,7 @@ yolo11n*.pt
*.ignore
*.ignore.*
site/

# Fetched third-party meshes for the PX4 HIL simulator (tools/fetch_x500_meshes.py)
dimos/simulation/px4_hil/assets/x500/*.stl
dimos/simulation/px4_hil/assets/x500/*.obj
44 changes: 30 additions & 14 deletions dimos/agents/mcp/mcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,15 @@

from collections.abc import Callable
from queue import Empty, Queue
import re
from threading import Event, RLock, Thread
import time
from typing import Any
import uuid
import warnings

from langchain_core._api.deprecation import LangChainPendingDeprecationWarning

# Importing langchain_core un-mutes its pending-deprecation warnings, so this ignore
# must be registered after that import to take precedence. It silences the noisy
# `allowed_objects` warning emitted when langchain.agents pulls in langgraph.checkpoint.
warnings.filterwarnings("ignore", category=LangChainPendingDeprecationWarning)

from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.messages.base import BaseMessage
from langchain_core.tools import StructuredTool
from langchain_openai import ChatOpenAI
Expand Down Expand Up @@ -189,7 +182,16 @@ def _try_fetch_tools(self, timeout: float, interval: float) -> dict[str, Any] |

def _mcp_tool_to_langchain(self, mcp_tool: dict[str, Any]) -> StructuredTool:
name = mcp_tool["name"]
# OpenAI restricts function names to [a-zA-Z0-9_-]. Namespaced MCP
# tools ("dog1/leggedsimmodule/state") contain '/', and ONE such name
# 400-fails the entire request -- the model never sees any tool and,
# before the guard in _thread_loop, the agent thread died on it. The
# model is shown a sanitized alias; the MCP server is always called
# with the real name via the closure below.
model_name = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
description = mcp_tool.get("description", "")
if model_name != name:
description = f"[{name}] {description}"
input_schema = mcp_tool.get("inputSchema", {"type": "object", "properties": {}})

def call_tool(**kwargs: Any) -> str:
Expand All @@ -210,7 +212,7 @@ def call_tool(**kwargs: Any) -> str:
return text

return StructuredTool(
name=name,
name=model_name,
description=description,
func=call_tool,
args_schema=input_schema,
Expand Down Expand Up @@ -335,10 +337,24 @@ def _thread_loop(self) -> None:
except Empty:
continue

with self._lock:
if not self._state_graph:
raise ValueError("No state graph initialized")
self._process_message(self._state_graph, message)
try:
with self._lock:
if not self._state_graph:
raise ValueError("No state graph initialized")
self._process_message(self._state_graph, message)
except Exception as e:
# A single failed turn (network blip, provider 4xx/5xx) must
# not kill this thread: before this guard, one exception left
# every later message queued forever while the UI showed
# "thinking...". Tell the operator and keep serving.
logger.error(f"Agent turn failed: {type(e).__name__}: {e}")
try:
self.agent.publish(
AIMessage(content=f"[agent error] {type(e).__name__}: {e}")
)
self.agent_idle.publish(True)
except Exception:
pass

def _process_message(
self, state_graph: CompiledStateGraph[Any, Any, Any, Any], message: BaseMessage
Expand Down
52 changes: 45 additions & 7 deletions dimos/agents/mcp/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,49 @@ def _handle_initialize(req_id: Any) -> dict[str, Any]:
)


def tool_names(skills: list[SkillInfo]) -> dict[str, SkillInfo]:
"""Map MCP tool name -> skill, disambiguating duplicates by instance.

A skill name is left bare when only one module offers it, which is the
common case and keeps existing blueprints and docs working. When several
module *instances* offer the same skill -- a namespaced fleet of three
drones all exposing ``takeoff`` -- the bare name is ambiguous and the last
registration would silently win, so those become
``drone1/px4dronemodule/takeoff``. That matches the RPC topic exactly.
"""
counts: dict[str, int] = {}
for s in skills:
counts[s.func_name] = counts.get(s.func_name, 0) + 1
out: dict[str, SkillInfo] = {}
for s in skills:
name = s.func_name if counts[s.func_name] == 1 else f"{s.class_name}/{s.func_name}"
out[name] = s
return out


def with_qualified_aliases(named: dict[str, SkillInfo]) -> dict[str, SkillInfo]:
"""Extend a tool-name map with each skill's instance-qualified name.

``tools/list`` shows only the canonical names from `tool_names`, but a call
to ``dog1/go2firstcontact/safe_move`` must work even while that dog is the
only module offering ``safe_move``: the qualified form is the RPC topic, so
it is never ambiguous, and an operator's fleet scripts keep working when
the fleet is temporarily one robot.
"""
out = dict(named)
for s in named.values():
out.setdefault(f"{s.class_name}/{s.func_name}", s)
return out


def _handle_tools_list(req_id: Any, skills: list[SkillInfo]) -> dict[str, Any]:
tools = []

for s in skills:
for name, s in tool_names(skills).items():
schema = json.loads(s.args_schema)
description = schema.pop("description", None)
schema.pop("title", None)
tool: dict[str, Any] = {"name": s.func_name, "inputSchema": schema}
tool: dict[str, Any] = {"name": name, "inputSchema": schema}
if description:
tool["description"] = description
if s.uses or s.lifecycle != "instant":
Expand Down Expand Up @@ -384,12 +419,15 @@ def on_system_modules(self, modules: list[RPCClient]) -> None:
app.state.skills = [
skill_info for module in modules for skill_info in (module.get_skills() or [])
]
app.state.skills_by_name = {s.func_name: s for s in app.state.skills}
# Keyed by MCP tool name, which is instance-qualified when a skill
# exists on more than one module instance. The RpcCall still targets the
# bare func_name on that instance's RPC topic. Qualified aliases are
# callable but not listed (see with_qualified_aliases).
named = with_qualified_aliases(tool_names(app.state.skills))
app.state.skills_by_name = named
app.state.rpc_calls = {
skill_info.func_name: RpcCall(
None, self.rpc, skill_info.func_name, skill_info.class_name, []
)
for skill_info in app.state.skills
name: RpcCall(None, self.rpc, skill_info.func_name, skill_info.class_name, [])
for name, skill_info in named.items()
}

@skill
Expand Down
31 changes: 31 additions & 0 deletions dimos/agents/mcp/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,34 @@ def test_instant_holder_conflict_waits_then_runs() -> None:
app.state.skills_by_name = saved_skills
app.state.cap_registry = saved_registry
app.state.cap_acquire_timeout = saved_timeout


def test_tool_names_qualified_aliases() -> None:
"""A unique skill lists bare but stays callable by its qualified RPC-topic
name, so fleet scripts (dog1/go2firstcontact/safe_move) keep working when
only one robot offers the skill. Duplicates stay qualified-only."""
from dimos.agents.mcp.mcp_server import tool_names, with_qualified_aliases

schema = json.dumps({"type": "object", "properties": {}})
skills = [
SkillInfo(class_name="dog1/go2firstcontact", func_name="safe_move", args_schema=schema),
SkillInfo(class_name="dog1/go2connection", func_name="stand_up", args_schema=schema),
]

named = tool_names(skills)
assert set(named) == {"safe_move", "stand_up"} # unique skills list bare

callable_names = with_qualified_aliases(named)
assert callable_names["safe_move"] is callable_names["dog1/go2firstcontact/safe_move"]
assert callable_names["stand_up"] is callable_names["dog1/go2connection/stand_up"]

# Two instances offering the same skill: bare name is ambiguous and gone,
# aliasing adds nothing beyond the already-qualified canonical names.
duplicated = skills + [
SkillInfo(class_name="dog2/go2firstcontact", func_name="safe_move", args_schema=schema)
]
named_dup = tool_names(duplicated)
assert "safe_move" not in named_dup
assert set(with_qualified_aliases(named_dup)) == set(named_dup) | {
"dog1/go2connection/stand_up"
}
8 changes: 7 additions & 1 deletion dimos/core/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,13 @@ def get_skills(self) -> list[SkillInfo]:
lifecycle = getattr(attr, "__skill_lifecycle__", "instant")
skills.append(
SkillInfo(
class_name=self.__class__.__name__,
# The instance name when this module is namespaced, so a
# fleet of identical modules stays individually
# addressable. RpcCall uses this as the RPC topic
# prefix, and _rpc_name() picks the same string, so the
# two agree. Falling back to the class name keeps
# single-instance blueprints unchanged.
class_name=self.config.instance_name or self.__class__.__name__,
func_name=name,
args_schema=schema,
uses=uses,
Expand Down
3 changes: 3 additions & 0 deletions dimos/robot/all_blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@
"unitree-g1-record": "dimos.robot.unitree.g1.blueprints.basic.unitree_g1_record:unitree_g1_record",
"unitree-g1-shm": "dimos.robot.unitree.g1.blueprints.perceptive.unitree_g1_shm:unitree_g1_shm",
"unitree-g1-sim": "dimos.robot.unitree.g1.blueprints.perceptive.unitree_g1_sim:unitree_g1_sim",
"drone-px4-sitl-fleet-mcp": "dimos.robot.drone.blueprints.basic.drone_px4_sitl_fleet_mcp:drone_px4_sitl_fleet_mcp",
"mixed-fleet-agentic": "dimos.robot.legged.blueprints.agentic.mixed_fleet_agentic:mixed_fleet_agentic",
"mixed-fleet-mcp": "dimos.robot.legged.blueprints.basic.mixed_fleet_mcp:mixed_fleet_mcp",
"unitree-go2": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2:unitree_go2",
"unitree-go2-agentic": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_agentic:unitree_go2_agentic",
"unitree-go2-agentic-huggingface": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_agentic_huggingface:unitree_go2_agentic_huggingface",
Expand Down
91 changes: 91 additions & 0 deletions dimos/robot/drone/blueprints/basic/drone_px4_sitl_fleet_mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
# Copyright 2025-2026 Dimensional Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Namespaced 3-drone PX4 SITL swarm with MCP (no LLM).

Each vehicle runs its own ``Px4DroneModule`` inside its own namespace, so the
per-drone skills, topics, TF frames and config keys are all separated::

drone1/px4dronemodule/takeoff RPC
/drone1/odom topic
-o drone1/px4dronemodule.connection_string=...

One shared ``SwarmCoordinator`` sits outside the namespaces and owns the
fleet-level surface (state, guardrails, formations, sweeps). Telemetry reaches
it on the exposed ``drone_state`` stream; its commands go back out on the
exposed ``swarm_cmd`` stream.

Launch the sim first, then drive the whole demo from the CLI — no OpenAI key
needed:

./dimos/simulation/px4_hil/sim.sh start 3 0 # in-repo MuJoCo+PX4 simulator
CI=1 dimos run drone-px4-sitl-fleet-mcp --daemon

dimos mcp modules # three drones + the coordinator
dimos mcp call fleet_state
dimos mcp call takeoff_all --arg altitude=5
dimos mcp call grid_sweep --arg corner_b_north=40 --arg corner_b_east=30
dimos mcp call count_within --arg drone=drone1 --arg radius_m=100
dimos mcp call line_formation
dimos mcp call rtl_all

Per-drone commands address the instance directly:

dimos mcp call drone2/px4dronemodule/takeoff --arg altitude=4
"""

from dimos.agents.mcp.mcp_server import McpServer
from dimos.core.coordination.blueprints import autoconnect
from dimos.robot.drone.px4_drone_module import Px4DroneModule
from dimos.robot.drone.px4_sitl_fleet_config import get_px4_sitl_fleet_configs
from dimos.robot.drone.px4_swarm_coordinator import SwarmCoordinator

# Streams that must stay global so they cross the namespace boundary: telemetry
# out of each drone, and fleet commands back in.
FLEET_BUS = {"drone_state", "swarm_cmd"}

_CONFIGS = get_px4_sitl_fleet_configs()


def px4_sitl_swarm(
configs=_CONFIGS,
min_separation_m: float = 2.0,
max_altitude_m: float | None = None,
):
"""Compose N namespaced PX4 drones plus one shared coordinator."""
return autoconnect(
SwarmCoordinator.blueprint(
min_separation_m=min_separation_m,
max_altitude_m=max_altitude_m,
expected_drones=",".join(f"drone{i + 1}" for i in range(len(configs))),
),
*[
Px4DroneModule.blueprint(
connection_string=c.connection_string,
instance=c.instance,
sys_id=c.sys_id,
max_altitude_m=max_altitude_m,
).namespace(f"drone{i + 1}", expose=FLEET_BUS)
for i, c in enumerate(configs)
],
)


drone_px4_sitl_fleet_mcp = autoconnect(
px4_sitl_swarm(),
McpServer.blueprint(),
).global_config(n_workers=max(2, 2 * len(_CONFIGS)))

__all__ = ["FLEET_BUS", "drone_px4_sitl_fleet_mcp", "px4_sitl_swarm"]
Loading
Loading