mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
feat: create tools using MCP
This commit is contained in:
parent
5c29b6ed94
commit
fcb7004c7a
17 changed files with 1989 additions and 572 deletions
164
api/tests/test_mcp_tool_creation.py
Normal file
164
api/tests/test_mcp_tool_creation.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
|
||||
from api.app import app
|
||||
from api.mcp_server.server import mcp
|
||||
from api.mcp_server.tools.tool_creation import create_tool
|
||||
from api.schemas.tool import CreateToolRequest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authed_user() -> MagicMock:
|
||||
user = MagicMock()
|
||||
user.id = 11
|
||||
user.provider_id = "provider-11"
|
||||
user.selected_organization_id = 22
|
||||
return user
|
||||
|
||||
|
||||
def _tool_model(**overrides):
|
||||
now = datetime.now(UTC)
|
||||
values = {
|
||||
"id": 3,
|
||||
"tool_uuid": "tool-uuid-3",
|
||||
"name": "Lookup Account",
|
||||
"description": "Lookup an account by phone number",
|
||||
"category": "http_api",
|
||||
"icon": "globe",
|
||||
"icon_color": "#3B82F6",
|
||||
"status": "active",
|
||||
"definition": {
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
"config": {"method": "POST", "url": "https://api.example.com/lookup"},
|
||||
},
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def _http_tool_request(**config_overrides) -> CreateToolRequest:
|
||||
config = {"method": "post", "url": "https://api.example.com/lookup"}
|
||||
config.update(config_overrides)
|
||||
return CreateToolRequest(
|
||||
name="Lookup Account",
|
||||
description="Lookup an account by phone number",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "http_api",
|
||||
"config": config,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_create_tool_creates_reusable_tool(authed_user: MagicMock):
|
||||
create_tool_mock = AsyncMock(return_value=_tool_model())
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.mcp_server.tools.tool_creation.authenticate_mcp_request",
|
||||
AsyncMock(return_value=authed_user),
|
||||
),
|
||||
patch(
|
||||
"api.services.tool_management.db_client.create_tool",
|
||||
create_tool_mock,
|
||||
),
|
||||
patch("api.services.tool_management.capture_event") as capture_event_mock,
|
||||
):
|
||||
result = await create_tool(_http_tool_request())
|
||||
|
||||
assert result["created"] is True
|
||||
assert result["tool_uuid"] == "tool-uuid-3"
|
||||
assert result["category"] == "http_api"
|
||||
create_tool_mock.assert_awaited_once()
|
||||
assert create_tool_mock.call_args.kwargs["organization_id"] == 22
|
||||
assert create_tool_mock.call_args.kwargs["user_id"] == 11
|
||||
assert create_tool_mock.call_args.kwargs["definition"]["config"]["method"] == "POST"
|
||||
capture_event_mock.assert_called_once()
|
||||
assert capture_event_mock.call_args.kwargs["properties"]["source"] == "mcp"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_create_tool_rejects_unknown_credential(authed_user: MagicMock):
|
||||
create_tool_mock = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.mcp_server.tools.tool_creation.authenticate_mcp_request",
|
||||
AsyncMock(return_value=authed_user),
|
||||
),
|
||||
patch(
|
||||
"api.services.tool_management.db_client.get_credential_by_uuid",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"api.services.tool_management.db_client.create_tool",
|
||||
create_tool_mock,
|
||||
),
|
||||
):
|
||||
result = await create_tool(_http_tool_request(credential_uuid="cred-missing"))
|
||||
|
||||
assert result["created"] is False
|
||||
assert result["error_code"] == "credential_not_found"
|
||||
create_tool_mock.assert_not_awaited()
|
||||
|
||||
|
||||
def test_sdk_openapi_exposes_create_tool_schema_and_llm_hints():
|
||||
sdk_routes = [
|
||||
r
|
||||
for r in app.routes
|
||||
if getattr(r, "openapi_extra", None)
|
||||
and "x-sdk-method" in (r.openapi_extra or {})
|
||||
]
|
||||
spec = get_openapi(title=app.title, version=app.version, routes=sdk_routes)
|
||||
operations = [
|
||||
op
|
||||
for path_item in spec["paths"].values()
|
||||
for op in path_item.values()
|
||||
if isinstance(op, dict)
|
||||
]
|
||||
assert any(op.get("x-sdk-method") == "create_tool" for op in operations)
|
||||
|
||||
credential_schema = spec["components"]["schemas"]["HttpApiConfig"]["properties"][
|
||||
"credential_uuid"
|
||||
]
|
||||
assert "list_credentials" in credential_schema["llm_hint"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_create_tool_schema_includes_validation_and_llm_hints():
|
||||
tools = await mcp.list_tools()
|
||||
create_tool_spec = next(t for t in tools if t.name == "create_tool")
|
||||
|
||||
request_schema = create_tool_spec.parameters["properties"]["request"]
|
||||
definition_schema = request_schema["properties"]["definition"]
|
||||
http_config = definition_schema["oneOf"][0]["properties"]["config"]
|
||||
|
||||
assert request_schema["properties"]["category"]["enum"] == [
|
||||
"http_api",
|
||||
"end_call",
|
||||
"transfer_call",
|
||||
"calculator",
|
||||
"native",
|
||||
"integration",
|
||||
"mcp",
|
||||
]
|
||||
assert http_config["properties"]["method"]["enum"] == [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
]
|
||||
assert (
|
||||
"list_credentials" in http_config["properties"]["credential_uuid"]["llm_hint"]
|
||||
)
|
||||
|
|
@ -16,10 +16,20 @@ Test coverage:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from api.routes.tool import CreateToolRequest, McpToolDefinition, UpdateToolRequest
|
||||
from api.routes.tool import (
|
||||
CreateToolRequest,
|
||||
McpToolConfig,
|
||||
McpToolDefinition,
|
||||
UpdateToolRequest,
|
||||
_populate_discovered_tools,
|
||||
refresh_mcp_tools,
|
||||
)
|
||||
from api.services.workflow.tools.mcp_tool import (
|
||||
validate_mcp_definition,
|
||||
)
|
||||
|
|
@ -279,10 +289,6 @@ async def test_post_tool_mcp_invalid_url_returns_422(test_client_factory, db_ses
|
|||
|
||||
# ── Task 6: discovered_tools field and _populate_discovered_tools helper ──────
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from api.routes.tool import McpToolConfig, _populate_discovered_tools
|
||||
|
||||
|
||||
def test_mcp_config_accepts_discovered_tools():
|
||||
cfg = McpToolConfig(
|
||||
|
|
@ -296,10 +302,10 @@ def test_mcp_config_accepts_discovered_tools():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_populate_discovered_tools_overwrites_cache(monkeypatch):
|
||||
import api.routes.tool as tool_mod
|
||||
import api.services.tool_management as tool_svc
|
||||
|
||||
monkeypatch.setattr(
|
||||
tool_mod,
|
||||
tool_svc,
|
||||
"discover_mcp_tools",
|
||||
AsyncMock(return_value=[{"name": "echo", "description": "Echo"}]),
|
||||
)
|
||||
|
|
@ -327,10 +333,10 @@ async def test_populate_discovered_tools_non_mcp_is_noop():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_populate_discovered_tools_server_down_sets_empty(monkeypatch):
|
||||
import api.routes.tool as tool_mod
|
||||
import api.services.tool_management as tool_svc
|
||||
|
||||
monkeypatch.setattr(
|
||||
tool_mod,
|
||||
tool_svc,
|
||||
"discover_mcp_tools",
|
||||
AsyncMock(side_effect=RuntimeError("connection refused")),
|
||||
)
|
||||
|
|
@ -345,10 +351,6 @@ async def test_populate_discovered_tools_server_down_sets_empty(monkeypatch):
|
|||
|
||||
# ── Task 7: POST /{tool_uuid}/mcp/refresh ─────────────────────────────────────
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.routes.tool import refresh_mcp_tools
|
||||
|
||||
|
||||
def _fake_user(org_id=1):
|
||||
u = MagicMock()
|
||||
|
|
@ -373,19 +375,19 @@ def _mcp_tool_model(org_id=1):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_success(monkeypatch):
|
||||
import api.routes.tool as tool_mod
|
||||
import api.services.tool_management as tool_svc
|
||||
|
||||
tool = _mcp_tool_model()
|
||||
monkeypatch.setattr(
|
||||
tool_mod.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
|
||||
tool_svc.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tool_mod.db_client,
|
||||
tool_svc.db_client,
|
||||
"update_tool",
|
||||
AsyncMock(return_value=tool),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tool_mod,
|
||||
tool_svc,
|
||||
"discover_mcp_tools",
|
||||
AsyncMock(return_value=[{"name": "echo", "description": "Echo"}]),
|
||||
)
|
||||
|
|
@ -396,29 +398,29 @@ async def test_refresh_success(monkeypatch):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_server_down_returns_200_with_error(monkeypatch):
|
||||
import api.routes.tool as tool_mod
|
||||
import api.services.tool_management as tool_svc
|
||||
|
||||
tool = _mcp_tool_model()
|
||||
monkeypatch.setattr(
|
||||
tool_mod.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
|
||||
tool_svc.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
|
||||
)
|
||||
monkeypatch.setattr(tool_mod.db_client, "update_tool", AsyncMock(return_value=tool))
|
||||
monkeypatch.setattr(tool_mod, "discover_mcp_tools", AsyncMock(return_value=[]))
|
||||
monkeypatch.setattr(tool_svc.db_client, "update_tool", AsyncMock(return_value=tool))
|
||||
monkeypatch.setattr(tool_svc, "discover_mcp_tools", AsyncMock(return_value=[]))
|
||||
resp = await refresh_mcp_tools("tu-mcp", user=_fake_user())
|
||||
assert resp.discovered_tools == []
|
||||
assert resp.error # non-empty human-readable message
|
||||
# update_tool should NOT be called when discovery returns empty
|
||||
tool_mod.db_client.update_tool.assert_not_called()
|
||||
tool_svc.db_client.update_tool.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_non_mcp_is_400(monkeypatch):
|
||||
import api.routes.tool as tool_mod
|
||||
import api.services.tool_management as tool_svc
|
||||
|
||||
tool = _mcp_tool_model()
|
||||
tool.category = "http_api"
|
||||
monkeypatch.setattr(
|
||||
tool_mod.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
|
||||
tool_svc.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
|
||||
)
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
await refresh_mcp_tools("tu-mcp", user=_fake_user())
|
||||
|
|
@ -427,10 +429,10 @@ async def test_refresh_non_mcp_is_400(monkeypatch):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_not_found_is_404(monkeypatch):
|
||||
import api.routes.tool as tool_mod
|
||||
import api.services.tool_management as tool_svc
|
||||
|
||||
monkeypatch.setattr(
|
||||
tool_mod.db_client, "get_tool_by_uuid", AsyncMock(return_value=None)
|
||||
tool_svc.db_client, "get_tool_by_uuid", AsyncMock(return_value=None)
|
||||
)
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
await refresh_mcp_tools("nope", user=_fake_user())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue