Skip to content

Commit b1acab8

Browse files
authored
Working dimensional MCP server - tested with Claude Code MCP client (#945)
* Working dimensional MCP server - tested with Claude Code MCP client * fix relative move float cast bug * Return skill state to MCP server for observability - still some race conditions/bugs * Fix race condition bug with SkillState * Cleanup MCP client bridge * Fix mypy * RE implemented MCP as a Module that plugs into Agent / Coordinator via RPC * Remove Agent RPC calls - MCP integrated with SkillCoordinator directly * Fully working new MCP implementation * Added end to end MPC test and async close to server for clean shutdown * Mypy fixes to mcp * fix mypy again # DimOS MCP Server Expose DimOS robot skills to Claude Code via Model Context Protocol. ## Setup Add to Claude Code (one command): ```bash claude mcp add --transport stdio dimos --scope project -- python -m dimos.protocol.mcp ``` ## Usage **Terminal 1** - Start DimOS: ```bash dimos --replay run unitree-go2-agentic ``` **Claude Code** - Use robot skills: ``` > move forward 1 meter > go to the kitchen > tag this location as "desk" ``` ## How It Works 1. `llm_agent(mcp_port=9990)` in the blueprint starts a TCP server 2. Claude Code spawns the bridge (`--bridge`) which connects to `localhost:9990` 3. Skills are exposed as MCP tools (e.g., `relative_move`, `navigate_with_text`) Former-commit-id: 44f654c
1 parent ec3e563 commit b1acab8

File tree

13 files changed

+495
-6
lines changed

13 files changed

+495
-6
lines changed

dimos/agents/agent.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,7 @@
3333
from dimos.agents.spec import AgentSpec, Model, Provider
3434
from dimos.agents.system_prompt import SYSTEM_PROMPT
3535
from dimos.core import DimosCluster, rpc
36-
from dimos.protocol.skill.coordinator import (
37-
SkillCoordinator,
38-
SkillState,
39-
SkillStateDict,
40-
)
36+
from dimos.protocol.skill.coordinator import SkillCoordinator, SkillState, SkillStateDict
4137
from dimos.protocol.skill.skill import SkillContainer
4238
from dimos.protocol.skill.type import Output
4339
from dimos.utils.logging_config import setup_logger

dimos/core/skill_module.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ def set_LlmAgent_register_skills(self, callable: RpcCall) -> None:
2525
callable.set_rpc(self.rpc) # type: ignore[arg-type]
2626
callable(RPCClient(self, self.__class__))
2727

28+
@rpc
29+
def set_MCPModule_register_skills(self, callable: RpcCall) -> None:
30+
callable.set_rpc(self.rpc) # type: ignore[arg-type]
31+
callable(RPCClient(self, self.__class__))
32+
2833
def __getstate__(self) -> None:
2934
pass
3035

dimos/protocol/mcp/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# DimOS MCP Server
2+
3+
Expose DimOS robot skills to Claude Code via Model Context Protocol.
4+
5+
## Setup
6+
7+
Add to Claude Code (one command):
8+
```bash
9+
claude mcp add --transport stdio dimos --scope project -- python -m dimos.protocol.mcp
10+
```
11+
12+
## Usage
13+
14+
**Terminal 1** - Start DimOS:
15+
```bash
16+
dimos --replay run unitree-go2-agentic
17+
```
18+
19+
**Claude Code** - Use robot skills:
20+
```
21+
> move forward 1 meter
22+
> go to the kitchen
23+
> tag this location as "desk"
24+
```
25+
26+
## How It Works
27+
28+
1. `llm_agent(mcp_port=9990)` in the blueprint starts a TCP server
29+
2. Claude Code spawns the bridge (`--bridge`) which connects to `localhost:9990`
30+
3. Skills are exposed as MCP tools (e.g., `relative_move`, `navigate_with_text`)

dimos/protocol/mcp/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Copyright 2025-2026 Dimensional Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from dimos.protocol.mcp.mcp import MCPModule
16+
17+
__all__ = ["MCPModule"]

dimos/protocol/mcp/__main__.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Copyright 2025-2026 Dimensional Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""CLI entry point for Dimensional MCP Bridge.
16+
17+
Connects Claude Code (or other MCP clients) to a running DimOS agent.
18+
19+
Usage:
20+
python -m dimos.protocol.mcp # Bridge to running DimOS on default port
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import asyncio
26+
27+
from dimos.protocol.mcp.bridge import main as bridge_main
28+
29+
30+
def main() -> None:
31+
"""Main entry point - connects to running DimOS via bridge."""
32+
asyncio.run(bridge_main())
33+
34+
35+
if __name__ == "__main__":
36+
main()

dimos/protocol/mcp/bridge.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Copyright 2026 Dimensional Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
"""MCP Bridge - Connects stdio (Claude Code) to TCP (DimOS Agent)."""
17+
18+
import asyncio
19+
import os
20+
import sys
21+
22+
DEFAULT_PORT = 9990
23+
24+
25+
async def main() -> None:
26+
port = int(os.environ.get("MCP_PORT", DEFAULT_PORT))
27+
host = os.environ.get("MCP_HOST", "localhost")
28+
29+
reader, writer = await asyncio.open_connection(host, port)
30+
sys.stderr.write(f"MCP Bridge connected to {host}:{port}\n")
31+
32+
async def stdin_to_tcp() -> None:
33+
loop = asyncio.get_event_loop()
34+
while True:
35+
line = await loop.run_in_executor(None, sys.stdin.readline)
36+
if not line:
37+
break
38+
writer.write(line.encode())
39+
await writer.drain()
40+
41+
async def tcp_to_stdout() -> None:
42+
while True:
43+
data = await reader.readline()
44+
if not data:
45+
break
46+
sys.stdout.write(data.decode())
47+
sys.stdout.flush()
48+
49+
await asyncio.gather(stdin_to_tcp(), tcp_to_stdout())
50+
51+
52+
if __name__ == "__main__":
53+
asyncio.run(main())

dimos/protocol/mcp/mcp.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Copyright 2025-2026 Dimensional Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from __future__ import annotations
15+
16+
import asyncio
17+
import json
18+
from typing import TYPE_CHECKING, Any
19+
import uuid
20+
21+
from dimos.core import Module, rpc
22+
from dimos.protocol.skill.coordinator import SkillCoordinator, SkillStateEnum
23+
24+
if TYPE_CHECKING:
25+
from dimos.protocol.skill.coordinator import SkillState
26+
27+
28+
class MCPModule(Module):
29+
def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
30+
super().__init__(*args, **kwargs)
31+
self.coordinator = SkillCoordinator()
32+
self._server: asyncio.AbstractServer | None = None
33+
self._server_future: object | None = None
34+
35+
@rpc
36+
def start(self) -> None:
37+
super().start()
38+
self.coordinator.start()
39+
self._start_server()
40+
41+
@rpc
42+
def stop(self) -> None:
43+
if self._server:
44+
self._server.close()
45+
loop = self._loop
46+
assert loop is not None
47+
asyncio.run_coroutine_threadsafe(self._server.wait_closed(), loop).result()
48+
self._server = None
49+
if self._server_future and hasattr(self._server_future, "cancel"):
50+
self._server_future.cancel()
51+
self.coordinator.stop()
52+
super().stop()
53+
54+
@rpc
55+
def register_skills(self, container) -> None: # type: ignore[no-untyped-def]
56+
self.coordinator.register_skills(container)
57+
58+
def _start_server(self, port: int = 9990) -> None:
59+
async def handle_client(reader, writer) -> None: # type: ignore[no-untyped-def]
60+
while True:
61+
if not (data := await reader.readline()):
62+
break
63+
response = await self._handle_request(json.loads(data.decode()))
64+
writer.write(json.dumps(response).encode() + b"\n")
65+
await writer.drain()
66+
writer.close()
67+
68+
async def start_server() -> None:
69+
self._server = await asyncio.start_server(handle_client, "0.0.0.0", port)
70+
await self._server.serve_forever()
71+
72+
loop = self._loop
73+
assert loop is not None
74+
self._server_future = asyncio.run_coroutine_threadsafe(start_server(), loop)
75+
76+
async def _handle_request(self, request: dict[str, Any]) -> dict[str, Any]:
77+
method = request.get("method", "")
78+
params = request.get("params", {}) or {}
79+
req_id = request.get("id")
80+
if method == "initialize":
81+
init_result = {
82+
"protocolVersion": "2024-11-05",
83+
"capabilities": {"tools": {}},
84+
"serverInfo": {"name": "dimensional", "version": "1.0.0"},
85+
}
86+
return {"jsonrpc": "2.0", "id": req_id, "result": init_result}
87+
if method == "tools/list":
88+
tools = [
89+
{
90+
"name": c.name,
91+
"description": c.schema.get("function", {}).get("description", ""),
92+
"inputSchema": c.schema.get("function", {}).get("parameters", {}),
93+
}
94+
for c in self.coordinator.skills().values()
95+
if not c.hide_skill
96+
]
97+
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": tools}}
98+
if method == "tools/call":
99+
name = params.get("name")
100+
args = params.get("arguments") or {}
101+
if not isinstance(name, str):
102+
return {
103+
"jsonrpc": "2.0",
104+
"id": req_id,
105+
"error": {"code": -32602, "message": "Missing or invalid tool name"},
106+
}
107+
if not isinstance(args, dict):
108+
args = {}
109+
call_id = str(uuid.uuid4())
110+
self.coordinator.call_skill(call_id, name, args)
111+
result: SkillState | None = self.coordinator._skill_state.get(call_id)
112+
try:
113+
await asyncio.wait_for(self.coordinator.wait_for_updates(), timeout=5.0)
114+
except asyncio.TimeoutError:
115+
pass
116+
if result is None:
117+
text = "Skill not found"
118+
elif result.state == SkillStateEnum.completed:
119+
text = str(result.content()) if result.content() else "Completed"
120+
elif result.state == SkillStateEnum.error:
121+
text = f"Error: {result.content()}"
122+
else:
123+
text = f"Started ({result.state.name})"
124+
return {
125+
"jsonrpc": "2.0",
126+
"id": req_id,
127+
"result": {"content": [{"type": "text", "text": text}]},
128+
}
129+
return {
130+
"jsonrpc": "2.0",
131+
"id": req_id,
132+
"error": {"code": -32601, "message": f"Unknown: {method}"},
133+
}

0 commit comments

Comments
 (0)