diff --git a/ai-hub/app/api/routes/mcp.py b/ai-hub/app/api/routes/mcp.py index 43833e3..387a14b 100644 --- a/ai-hub/app/api/routes/mcp.py +++ b/ai-hub/app/api/routes/mcp.py @@ -320,6 +320,14 @@ _tool_def("list_skills", "List all skill folders (tool libraries) registered in the system.", {}), + _tool_def("dispatch", + "Dispatch a shell command to a specific agent node.", + { + "node_id": {"type": "string", "description": "Unique node ID"}, + "command": {"type": "string", "description": "Command to execute"}, + "session_id": {"type": "string", "description": "Optional session ID"}, + }, + required=["node_id", "command"]), ] } @@ -479,10 +487,30 @@ } return _ok(await loop.run_in_executor(None, _query)) + if name == "dispatch": + if not token: + raise ValueError("Authentication required to dispatch tasks.") + node_id = args.get("node_id") + command = args.get("command") + session_id = args.get("session_id", "") + + if not node_id or not command: + raise ValueError("node_id and command are required.") + + def _execute_dispatch(): + from app.db.session import get_db_session + with get_db_session() as db: + task_id = services.mesh_service.dispatch_task( + node_id, command, token, db, session_id=session_id + ) + return {"status": "accepted", "task_id": task_id} + + return _ok(await loop.run_in_executor(None, _execute_dispatch)) + # Writable tools (future-proofing check) # If OIDC is disabled, we block any tool that could manipulate the swarm mesh # as plain Identity Claims are not secure enough for headless write operations. - writable_tools = ["dispatch", "write_file", "delete_file"] # Planned tools + writable_tools = ["write_file", "delete_file"] # Planned tools if name in writable_tools and not settings.OIDC_ENABLED: raise HTTPException( status_code=403, diff --git a/ai-hub/integration_tests/test_mcp_dispatch.py b/ai-hub/integration_tests/test_mcp_dispatch.py new file mode 100644 index 0000000..3e484e2 --- /dev/null +++ b/ai-hub/integration_tests/test_mcp_dispatch.py @@ -0,0 +1,50 @@ +import pytest +import os +import httpx +import json +from conftest import BASE_URL + +def _headers(): + return { + "X-User-ID": os.environ.get("SYNC_TEST_USER_ID", "") + } + +@pytest.mark.slow +def test_mcp_dispatch(): + """ + Test calling the 'dispatch' tool via MCP. + """ + node_id = os.getenv("SYNC_TEST_NODE1", "test-node-1") + user_id = os.getenv("SYNC_TEST_USER_ID", "") + + with httpx.Client(timeout=10.0) as client: + # 1. Call tools/call for dispatch + payload = { + "jsonrpc": "2.0", + "id": "test-dispatch-1", + "method": "tools/call", + "params": { + "name": "dispatch", + "arguments": { + "node_id": node_id, + "command": "echo 'Hello from MCP'" + } + } + } + + r = client.post(f"{BASE_URL}/mcp/?token={user_id}", json=payload) + assert r.status_code == 200, f"MCP call failed: {r.text}" + + res_data = r.json() + assert "result" in res_data + result = res_data["result"] + + assert "content" in result + content_block = result["content"][0] + assert content_block["type"] == "text" + + text_val = content_block["text"] + data = json.loads(text_val) + assert data["status"] == "accepted" + assert "task_id" in data + print(f"[test] MCP Dispatch success, task_id: {data['task_id']}")