diff --git a/dimos/agents/agent.py b/dimos/agents/agent.py index 17f1871210..bf5ded4f00 100644 --- a/dimos/agents/agent.py +++ b/dimos/agents/agent.py @@ -33,11 +33,7 @@ from dimos.agents.spec import AgentSpec, Model, Provider from dimos.agents.system_prompt import SYSTEM_PROMPT from dimos.core import DimosCluster, rpc -from dimos.protocol.skill.coordinator import ( - SkillCoordinator, - SkillState, - SkillStateDict, -) +from dimos.protocol.skill.coordinator import SkillCoordinator, SkillState, SkillStateDict from dimos.protocol.skill.skill import SkillContainer from dimos.protocol.skill.type import Output from dimos.utils.logging_config import setup_logger diff --git a/dimos/core/skill_module.py b/dimos/core/skill_module.py index 212d7bbb99..545f904e60 100644 --- a/dimos/core/skill_module.py +++ b/dimos/core/skill_module.py @@ -25,6 +25,11 @@ def set_LlmAgent_register_skills(self, callable: RpcCall) -> None: callable.set_rpc(self.rpc) # type: ignore[arg-type] callable(RPCClient(self, self.__class__)) + @rpc + def set_MCPModule_register_skills(self, callable: RpcCall) -> None: + callable.set_rpc(self.rpc) # type: ignore[arg-type] + callable(RPCClient(self, self.__class__)) + def __getstate__(self) -> None: pass diff --git a/dimos/protocol/mcp/README.md b/dimos/protocol/mcp/README.md new file mode 100644 index 0000000000..2a3c382484 --- /dev/null +++ b/dimos/protocol/mcp/README.md @@ -0,0 +1,30 @@ +# 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`) diff --git a/dimos/protocol/mcp/__init__.py b/dimos/protocol/mcp/__init__.py new file mode 100644 index 0000000000..51432ba0cf --- /dev/null +++ b/dimos/protocol/mcp/__init__.py @@ -0,0 +1,17 @@ +# 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. + +from dimos.protocol.mcp.mcp import MCPModule + +__all__ = ["MCPModule"] diff --git a/dimos/protocol/mcp/__main__.py b/dimos/protocol/mcp/__main__.py new file mode 100644 index 0000000000..a58e59d367 --- /dev/null +++ b/dimos/protocol/mcp/__main__.py @@ -0,0 +1,36 @@ +# 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. + +"""CLI entry point for Dimensional MCP Bridge. + +Connects Claude Code (or other MCP clients) to a running DimOS agent. + +Usage: + python -m dimos.protocol.mcp # Bridge to running DimOS on default port +""" + +from __future__ import annotations + +import asyncio + +from dimos.protocol.mcp.bridge import main as bridge_main + + +def main() -> None: + """Main entry point - connects to running DimOS via bridge.""" + asyncio.run(bridge_main()) + + +if __name__ == "__main__": + main() diff --git a/dimos/protocol/mcp/bridge.py b/dimos/protocol/mcp/bridge.py new file mode 100644 index 0000000000..0b09997798 --- /dev/null +++ b/dimos/protocol/mcp/bridge.py @@ -0,0 +1,53 @@ +# Copyright 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. + + +"""MCP Bridge - Connects stdio (Claude Code) to TCP (DimOS Agent).""" + +import asyncio +import os +import sys + +DEFAULT_PORT = 9990 + + +async def main() -> None: + port = int(os.environ.get("MCP_PORT", DEFAULT_PORT)) + host = os.environ.get("MCP_HOST", "localhost") + + reader, writer = await asyncio.open_connection(host, port) + sys.stderr.write(f"MCP Bridge connected to {host}:{port}\n") + + async def stdin_to_tcp() -> None: + loop = asyncio.get_event_loop() + while True: + line = await loop.run_in_executor(None, sys.stdin.readline) + if not line: + break + writer.write(line.encode()) + await writer.drain() + + async def tcp_to_stdout() -> None: + while True: + data = await reader.readline() + if not data: + break + sys.stdout.write(data.decode()) + sys.stdout.flush() + + await asyncio.gather(stdin_to_tcp(), tcp_to_stdout()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/dimos/protocol/mcp/mcp.py b/dimos/protocol/mcp/mcp.py new file mode 100644 index 0000000000..f7427cd613 --- /dev/null +++ b/dimos/protocol/mcp/mcp.py @@ -0,0 +1,133 @@ +# 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. +from __future__ import annotations + +import asyncio +import json +from typing import TYPE_CHECKING, Any +import uuid + +from dimos.core import Module, rpc +from dimos.protocol.skill.coordinator import SkillCoordinator, SkillStateEnum + +if TYPE_CHECKING: + from dimos.protocol.skill.coordinator import SkillState + + +class MCPModule(Module): + def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] + super().__init__(*args, **kwargs) + self.coordinator = SkillCoordinator() + self._server: asyncio.AbstractServer | None = None + self._server_future: object | None = None + + @rpc + def start(self) -> None: + super().start() + self.coordinator.start() + self._start_server() + + @rpc + def stop(self) -> None: + if self._server: + self._server.close() + loop = self._loop + assert loop is not None + asyncio.run_coroutine_threadsafe(self._server.wait_closed(), loop).result() + self._server = None + if self._server_future and hasattr(self._server_future, "cancel"): + self._server_future.cancel() + self.coordinator.stop() + super().stop() + + @rpc + def register_skills(self, container) -> None: # type: ignore[no-untyped-def] + self.coordinator.register_skills(container) + + def _start_server(self, port: int = 9990) -> None: + async def handle_client(reader, writer) -> None: # type: ignore[no-untyped-def] + while True: + if not (data := await reader.readline()): + break + response = await self._handle_request(json.loads(data.decode())) + writer.write(json.dumps(response).encode() + b"\n") + await writer.drain() + writer.close() + + async def start_server() -> None: + self._server = await asyncio.start_server(handle_client, "0.0.0.0", port) + await self._server.serve_forever() + + loop = self._loop + assert loop is not None + self._server_future = asyncio.run_coroutine_threadsafe(start_server(), loop) + + async def _handle_request(self, request: dict[str, Any]) -> dict[str, Any]: + method = request.get("method", "") + params = request.get("params", {}) or {} + req_id = request.get("id") + if method == "initialize": + init_result = { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "dimensional", "version": "1.0.0"}, + } + return {"jsonrpc": "2.0", "id": req_id, "result": init_result} + if method == "tools/list": + tools = [ + { + "name": c.name, + "description": c.schema.get("function", {}).get("description", ""), + "inputSchema": c.schema.get("function", {}).get("parameters", {}), + } + for c in self.coordinator.skills().values() + if not c.hide_skill + ] + return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": tools}} + if method == "tools/call": + name = params.get("name") + args = params.get("arguments") or {} + if not isinstance(name, str): + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32602, "message": "Missing or invalid tool name"}, + } + if not isinstance(args, dict): + args = {} + call_id = str(uuid.uuid4()) + self.coordinator.call_skill(call_id, name, args) + result: SkillState | None = self.coordinator._skill_state.get(call_id) + try: + await asyncio.wait_for(self.coordinator.wait_for_updates(), timeout=5.0) + except asyncio.TimeoutError: + pass + if result is None: + text = "Skill not found" + elif result.state == SkillStateEnum.completed: + text = str(result.content()) if result.content() else "Completed" + elif result.state == SkillStateEnum.error: + text = f"Error: {result.content()}" + else: + text = f"Started ({result.state.name})" + return { + "jsonrpc": "2.0", + "id": req_id, + "result": {"content": [{"type": "text", "text": text}]}, + } + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32601, "message": f"Unknown: {method}"}, + } diff --git a/dimos/protocol/mcp/test_mcp_module.py b/dimos/protocol/mcp/test_mcp_module.py new file mode 100644 index 0000000000..1deb5b9057 --- /dev/null +++ b/dimos/protocol/mcp/test_mcp_module.py @@ -0,0 +1,208 @@ +# 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. + +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import threading + +import pytest + +from dimos.protocol.mcp.mcp import MCPModule +from dimos.protocol.skill.coordinator import SkillStateEnum +from dimos.protocol.skill.skill import SkillContainer, skill + + +def test_unitree_blueprint_has_mcp() -> None: + contents = Path("dimos/robot/unitree_webrtc/unitree_go2_blueprints.py").read_text() + assert "agentic_mcp" in contents + assert "MCPModule.blueprint()" in contents + + +def test_mcp_module_request_flow() -> None: + class DummySkill: + def __init__(self) -> None: + self.name = "add" + self.hide_skill = False + self.schema = {"function": {"description": "", "parameters": {"type": "object"}}} + + class DummyState: + def __init__(self, content: int) -> None: + self.state = SkillStateEnum.completed + self._content = content + + def content(self) -> int: + return self._content + + class DummyCoordinator: + def __init__(self) -> None: + self._skill_state: dict[str, DummyState] = {} + + def skills(self) -> dict[str, DummySkill]: + return {"add": DummySkill()} + + def call_skill(self, call_id: str, _name: str, args: dict[str, int]) -> None: + self._skill_state[call_id] = DummyState(args["x"] + args["y"]) + + async def wait_for_updates(self) -> bool: + return True + + mcp = MCPModule.__new__(MCPModule) + mcp.coordinator = DummyCoordinator() + + response = asyncio.run(mcp._handle_request({"method": "tools/list", "id": 1})) + assert response["result"]["tools"][0]["name"] == "add" + + response = asyncio.run( + mcp._handle_request( + { + "method": "tools/call", + "id": 2, + "params": {"name": "add", "arguments": {"x": 2, "y": 3}}, + } + ) + ) + assert response["result"]["content"][0]["text"] == "5" + + +def test_mcp_module_handles_hidden_and_errors() -> None: + class DummySkill: + def __init__(self, name: str, hide_skill: bool) -> None: + self.name = name + self.hide_skill = hide_skill + self.schema = {"function": {"description": "", "parameters": {"type": "object"}}} + + class DummyState: + def __init__(self, state: SkillStateEnum, content: str | None) -> None: + self.state = state + self._content = content + + def content(self) -> str | None: + return self._content + + class DummyCoordinator: + def __init__(self) -> None: + self._skill_state: dict[str, DummyState] = {} + self._skills = { + "visible": DummySkill("visible", False), + "hidden": DummySkill("hidden", True), + "fail": DummySkill("fail", False), + } + + def skills(self) -> dict[str, DummySkill]: + return self._skills + + def call_skill(self, call_id: str, name: str, _args: dict[str, int]) -> None: + if name == "fail": + self._skill_state[call_id] = DummyState(SkillStateEnum.error, "boom") + elif name in self._skills: + self._skill_state[call_id] = DummyState(SkillStateEnum.running, None) + + async def wait_for_updates(self) -> bool: + return True + + mcp = MCPModule.__new__(MCPModule) + mcp.coordinator = DummyCoordinator() + + response = asyncio.run(mcp._handle_request({"method": "tools/list", "id": 1})) + tool_names = {tool["name"] for tool in response["result"]["tools"]} + assert "visible" in tool_names + assert "hidden" not in tool_names + + response = asyncio.run( + mcp._handle_request( + {"method": "tools/call", "id": 2, "params": {"name": "fail", "arguments": {}}} + ) + ) + assert "Error:" in response["result"]["content"][0]["text"] + + +def test_mcp_end_to_end_lcm_bridge() -> None: + try: + import lcm # type: ignore[import-untyped] + + lcm.LCM() + except Exception as exc: + if os.environ.get("CI"): + pytest.fail(f"LCM unavailable for MCP end-to-end test: {exc}") + pytest.skip("LCM unavailable for MCP end-to-end test.") + + try: + socket.socket(socket.AF_INET, socket.SOCK_STREAM).close() + except PermissionError: + if os.environ.get("CI"): + pytest.fail("Socket creation not permitted in CI environment.") + pytest.skip("Socket creation not permitted in this environment.") + + class TestSkills(SkillContainer): + @skill() + def add(self, x: int, y: int) -> int: + return x + y + + mcp = MCPModule() + mcp.start() + + try: + mcp.register_skills(TestSkills()) + + env = {"MCP_HOST": "127.0.0.1", "MCP_PORT": "9990"} + proc = subprocess.Popen( + [sys.executable, "-m", "dimos.protocol.mcp"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={**os.environ, **env}, + text=True, + ) + try: + request = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} + proc.stdin.write(json.dumps(request) + "\n") + proc.stdin.flush() + stdout = proc.stdout.readline() + assert '"tools"' in stdout + assert '"add"' in stdout + finally: + proc.terminate() + proc.wait(timeout=5) + + proc = subprocess.Popen( + [sys.executable, "-m", "dimos.protocol.mcp"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={**os.environ, **env}, + text=True, + ) + try: + request = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "add", "arguments": {"x": 2, "y": 3}}, + } + proc.stdin.write(json.dumps(request) + "\n") + proc.stdin.flush() + stdout = proc.stdout.readline() + assert "5" in stdout + finally: + proc.terminate() + proc.wait(timeout=5) + finally: + mcp.stop() diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 7dbbc9c67a..9b118cbd60 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -22,6 +22,7 @@ "unitree-go2-detection": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:detection", "unitree-go2-spatial": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:spatial", "unitree-go2-agentic": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:agentic", + "unitree-go2-agentic-mcp": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:agentic_mcp", "unitree-go2-agentic-ollama": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:agentic_ollama", "unitree-go2-agentic-huggingface": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:agentic_huggingface", "unitree-g1": "dimos.robot.unitree_webrtc.unitree_g1_blueprints:standard", diff --git a/dimos/robot/unitree_webrtc/unitree_go2_blueprints.py b/dimos/robot/unitree_webrtc/unitree_go2_blueprints.py index 2d962af981..46d951650c 100644 --- a/dimos/robot/unitree_webrtc/unitree_go2_blueprints.py +++ b/dimos/robot/unitree_webrtc/unitree_go2_blueprints.py @@ -43,6 +43,7 @@ ) from dimos.perception.detection.moduleDB import ObjectDBModule, detectionDB_module from dimos.perception.spatial_perception import spatial_memory +from dimos.protocol.mcp.mcp import MCPModule from dimos.robot.foxglove_bridge import foxglove_bridge from dimos.robot.unitree.connection.go2 import GO2Connection, go2_connection from dimos.robot.unitree_webrtc.unitree_skill_container import unitree_skills @@ -165,6 +166,11 @@ _common_agentic, ) +agentic_mcp = autoconnect( + agentic, + MCPModule.blueprint(), +) + agentic_ollama = autoconnect( spatial, llm_agent( diff --git a/dimos/robot/unitree_webrtc/unitree_skill_container.py b/dimos/robot/unitree_webrtc/unitree_skill_container.py index 1d2557da4d..c3dea43424 100644 --- a/dimos/robot/unitree_webrtc/unitree_skill_container.py +++ b/dimos/robot/unitree_webrtc/unitree_skill_container.py @@ -81,6 +81,7 @@ def relative_move(self, forward: float = 0.0, left: float = 0.0, degrees: float # Move 3 meters left, and face that direction relative_move(forward=0, left=3, degrees=90) """ + forward, left, degrees = float(forward), float(left), float(degrees) tf = self.tf.get("world", "base_link") if tf is None: diff --git a/pyproject.toml b/pyproject.toml index 206263d209..bd7bc13da9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,9 @@ dependencies = [ "sse-starlette>=2.2.1", "uvicorn>=0.34.0", + # MCP Server + "mcp>=1.0.0", + # Agents "langchain>=1,<2", "langchain-chroma>=1,<2", diff --git a/uv.lock b/uv.lock index 6a89d211df..d497efcad4 100644 --- a/uv.lock +++ b/uv.lock @@ -1480,6 +1480,7 @@ dependencies = [ { name = "lap" }, { name = "lark" }, { name = "llvmlite" }, + { name = "mcp" }, { name = "moondream" }, { name = "numba" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1656,6 +1657,7 @@ requires-dist = [ { name = "lvis", marker = "extra == 'cuda'" }, { name = "lxml-stubs", marker = "extra == 'dev'", specifier = ">=0.5.1,<1" }, { name = "matplotlib", marker = "extra == 'manipulation'", specifier = ">=3.7.1" }, + { name = "mcp", specifier = ">=1.0.0" }, { name = "mmcv", marker = "extra == 'cuda'", specifier = ">=2.1.0" }, { name = "mmengine", marker = "extra == 'cuda'", specifier = ">=0.10.3" }, { name = "moondream" }, @@ -2666,6 +2668,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "huggingface-hub" version = "0.36.0" @@ -4248,6 +4259,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mcp" +version = "1.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, +] + [[package]] name = "mdit-py-plugins" version = "0.5.0" @@ -6805,6 +6841,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pylibsrtp" version = "1.0.0"