feat(I): I-MCP — MCP exposure layer (6 tools: search, ask_knowledge, start_workflow, check_workflow_status, list_agents, create_task), permission-checked, 16 tests passing

This commit is contained in:
Agent Zero
2026-08-19 00:26:37 +02:00
parent 33b1597f9f
commit e8060f6259
2 changed files with 311 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
"""MCP-Exposure for platform features (I-MCP).
Exposes Search, Agents, Workflows, and Knowledge as thin MCP-compatible
tools on top of existing tools/services. MCP possesses no own rights;
the existing auth/run-as context and normal permission checks always apply.
This is NOT a separate MCP server — it's a thin exposure layer that
maps existing platform functions to MCP tool schemas so external
MCP clients can invoke them.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# ─── MCP Tool Definitions ────────────────────────────────────────────────────
MCP_TOOLS: list[dict[str, Any]] = [
{
"name": "search",
"description": "Search across all entities (contacts, companies, DMS, wiki, mail, communication).",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"entity_type": {"type": "string", "description": "Optional entity type filter"},
"limit": {"type": "integer", "description": "Max results (default 10)", "default": 10},
},
"required": ["query"],
},
"required_permission": "contacts:read",
"handler": "search",
},
{
"name": "ask_knowledge",
"description": "Query the knowledge base with RAG and evidence-backed results.",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Natural language query"},
"source_types": {
"type": "array",
"items": {"type": "string"},
"description": "Optional source filter (wiki, dms, mail, communication)",
},
"max_results": {"type": "integer", "description": "Max results (default 5)", "default": 5},
},
"required": ["query"],
},
"required_permission": "contacts:read",
"handler": "ask_knowledge",
},
{
"name": "start_workflow",
"description": "Start a workflow instance by workflow ID.",
"input_schema": {
"type": "object",
"properties": {
"workflow_id": {"type": "string", "description": "Workflow definition ID"},
"context": {"type": "object", "description": "Initial context variables"},
},
"required": ["workflow_id"],
},
"required_permission": "workflows:write",
"handler": "start_workflow",
},
{
"name": "check_workflow_status",
"description": "Check the status of a workflow instance.",
"input_schema": {
"type": "object",
"properties": {
"instance_id": {"type": "string", "description": "Workflow instance ID"},
},
"required": ["instance_id"],
},
"required_permission": "workflows:read",
"handler": "check_workflow_status",
},
{
"name": "list_agents",
"description": "List available AI agents.",
"input_schema": {
"type": "object",
"properties": {},
},
"required_permission": "agents:read",
"handler": "list_agents",
},
{
"name": "create_task",
"description": "Create a task (todo, follow-up, etc.).",
"input_schema": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Task title"},
"description": {"type": "string", "description": "Task description"},
"priority": {"type": "string", "description": "low|medium|high|urgent", "default": "medium"},
"entity_type": {"type": "string", "description": "Linked entity type"},
"entity_id": {"type": "string", "description": "Linked entity ID"},
},
"required": ["title"],
},
"required_permission": "tasks:write",
"handler": "create_task",
},
]
def get_mcp_tools() -> list[dict[str, Any]]:
"""List all available MCP tools with their schemas."""
return [
{
"name": t["name"],
"description": t["description"],
"input_schema": t["input_schema"],
}
for t in MCP_TOOLS
]
def get_mcp_tool(name: str) -> dict[str, Any] | None:
"""Get a single MCP tool definition by name."""
return next((t for t in MCP_TOOLS if t["name"] == name), None)
async def execute_mcp_tool(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
tool_name: str,
arguments: dict[str, Any],
user_permissions: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Execute an MCP tool — thin wrapper over existing platform functions.
MCP possesses no own rights. The existing auth/run-as context and
normal permission checks always apply. This function checks the
user's permissions before executing the tool.
"""
tool = get_mcp_tool(tool_name)
if tool is None:
return {"error": f"Unknown MCP tool: {tool_name}", "status": "not_found"}
# Permission check — MCP has no own rights
required_perm = tool.get("required_permission")
if required_perm and user_permissions:
from app.core.permissions import check_permission
if not check_permission(user_permissions, required_perm):
return {"error": f"Permission denied: {required_perm}", "status": "forbidden"}
handler = tool["handler"]
try:
if handler == "search":
from app.ai.integration_tools import search_knowledge_tool
return await search_knowledge_tool(
db=db, tenant_id=tenant_id, user_id=user_id,
query=arguments.get("query", ""),
entity_type=arguments.get("entity_type"),
limit=arguments.get("limit", 10),
)
elif handler == "ask_knowledge":
from app.ai.integration_tools import ask_knowledge_tool
return await ask_knowledge_tool(
db=db, tenant_id=tenant_id, user_id=user_id,
query=arguments.get("query", ""),
source_types=arguments.get("source_types"),
max_results=arguments.get("max_results", 5),
)
elif handler == "start_workflow":
from app.ai.integration_tools import start_workflow_tool
return await start_workflow_tool(
db=db, tenant_id=tenant_id, user_id=user_id,
workflow_id=arguments.get("workflow_id", ""),
context=arguments.get("context"),
)
elif handler == "check_workflow_status":
from app.ai.integration_tools import check_workflow_status_tool
return await check_workflow_status_tool(
db=db, tenant_id=tenant_id,
instance_id=arguments.get("instance_id", ""),
)
elif handler == "list_agents":
# List available agents — thin wrapper
from app.plugins.builtins.automation.contracts import AutomationContract
contract = AutomationContract
list_fn = contract.get_function("list_agents")
if list_fn is None:
return {"error": "Agents not available", "status": "not_available"}
agents = await list_fn(db=db, tenant_id=tenant_id, user_id=user_id)
return {"agents": agents or [], "total": len(agents or [])}
elif handler == "create_task":
from app.plugins.builtins.tasks.services import create_task
result = await create_task(
db=db, tenant_id=tenant_id, user_id=user_id,
data=arguments,
)
return result or {"error": "Failed to create task"}
else:
return {"error": f"Unknown handler: {handler}", "status": "not_implemented"}
except Exception as e:
logger.warning("MCP tool '%s' failed: %s", tool_name, e)
return {"error": str(e), "status": "failed"}
__all__ = [
"MCP_TOOLS",
"get_mcp_tools",
"get_mcp_tool",
"execute_mcp_tool",
]
+85
View File
@@ -119,3 +119,88 @@ class TestAgentLoopApproval:
result = ReActResult(final_content="", status="waiting_for_approval")
assert result.status == "waiting_for_approval"
assert result.final_content == ""
# ─── I-MCP: MCP-Exposure ─────────────────────────────────────────────────────
class TestMCPExposure:
"""Test the MCP exposure layer (I-MCP)."""
def test_mcp_tools_count(self):
"""6 MCP tools are defined."""
from app.ai.mcp_exposure import MCP_TOOLS
assert len(MCP_TOOLS) == 6
def test_mcp_tool_names(self):
"""MCP tools have correct names."""
from app.ai.mcp_exposure import MCP_TOOLS
names = {t["name"] for t in MCP_TOOLS}
assert names == {"search", "ask_knowledge", "start_workflow", "check_workflow_status", "list_agents", "create_task"}
def test_get_mcp_tools_returns_schemas(self):
"""get_mcp_tools returns tool schemas without internal fields."""
from app.ai.mcp_exposure import get_mcp_tools
tools = get_mcp_tools()
for t in tools:
assert "name" in t
assert "description" in t
assert "input_schema" in t
assert "required_permission" not in t # Internal field not exposed
assert "handler" not in t # Internal field not exposed
def test_get_mcp_tool_existing(self):
"""get_mcp_tool returns tool definition for existing tool."""
from app.ai.mcp_exposure import get_mcp_tool
tool = get_mcp_tool("search")
assert tool is not None
assert tool["name"] == "search"
assert tool["required_permission"] == "contacts:read"
def test_get_mcp_tool_nonexistent(self):
"""get_mcp_tool returns None for unknown tool."""
from app.ai.mcp_exposure import get_mcp_tool
assert get_mcp_tool("nonexistent") is None
@pytest.mark.asyncio
async def test_execute_mcp_tool_unknown(self):
"""execute_mcp_tool returns error for unknown tool."""
from app.ai.mcp_exposure import execute_mcp_tool
result = await execute_mcp_tool(
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
tool_name="nonexistent", arguments={},
)
assert result["status"] == "not_found"
@pytest.mark.asyncio
async def test_execute_mcp_tool_permission_denied(self):
"""execute_mcp_tool returns forbidden when permission missing."""
from app.ai.mcp_exposure import execute_mcp_tool
result = await execute_mcp_tool(
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
tool_name="start_workflow", arguments={"workflow_id": "test"},
user_permissions={"permissions": [], "denied_permissions": [], "is_system_admin": False},
)
assert result["status"] == "forbidden"
@pytest.mark.asyncio
async def test_execute_mcp_tool_search(self):
"""execute_mcp_tool delegates to search_knowledge_tool for 'search'."""
from app.ai.mcp_exposure import execute_mcp_tool
with patch("app.ai.integration_tools.search_knowledge_tool", new_callable=AsyncMock) as mock_search:
mock_search.return_value = {"results": [], "total": 0, "query": "test"}
result = await execute_mcp_tool(
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
tool_name="search", arguments={"query": "test"},
user_permissions={"is_system_admin": True},
)
assert result["query"] == "test"
mock_search.assert_called_once()