feat: create tools using MCP

This commit is contained in:
Abhishek Kumar 2026-05-31 16:50:44 +05:30
parent 5c29b6ed94
commit fcb7004c7a
17 changed files with 1989 additions and 572 deletions

View 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"]
)

View file

@ -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())