From e8060f6259695dc20496a44093b56d0f27ea5d0d Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 19 Aug 2026 00:26:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(I):=20I-MCP=20=E2=80=94=20MCP=20exposure?= =?UTF-8?q?=20layer=20(6=20tools:=20search,=20ask=5Fknowledge,=20start=5Fw?= =?UTF-8?q?orkflow,=20check=5Fworkflow=5Fstatus,=20list=5Fagents,=20create?= =?UTF-8?q?=5Ftask),=20permission-checked,=2016=20tests=20passing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/ai/mcp_exposure.py | 226 ++++++++++++++++++++++++++++++ tests/test_phase_i_integration.py | 85 +++++++++++ 2 files changed, 311 insertions(+) create mode 100644 app/ai/mcp_exposure.py diff --git a/app/ai/mcp_exposure.py b/app/ai/mcp_exposure.py new file mode 100644 index 0000000..e35a62e --- /dev/null +++ b/app/ai/mcp_exposure.py @@ -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", +] diff --git a/tests/test_phase_i_integration.py b/tests/test_phase_i_integration.py index bc9ee64..dce0fb1 100644 --- a/tests/test_phase_i_integration.py +++ b/tests/test_phase_i_integration.py @@ -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()