mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 14:51:39 +08:00
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: hjlarry <hjlarry@163.com>
1065 lines
38 KiB
Python
1065 lines
38 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import types
|
|
from collections.abc import Iterator
|
|
from inspect import unwrap
|
|
from unittest.mock import MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from flask import Flask, Response
|
|
from flask.testing import FlaskClient
|
|
from pydantic import ValidationError
|
|
from sqlalchemy import inspect, select
|
|
from sqlalchemy.engine import Engine
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
import controllers.mcp as mcp_controller
|
|
import controllers.mcp.mcp as module
|
|
from controllers.mcp.mcp import MCPAppApi
|
|
from models.engine import db
|
|
from models.enums import EndUserType
|
|
from models.model import App, AppAnnotationSetting, AppMCPServer, AppMode, AppModelConfig, EndUser, IconType
|
|
from models.workflow import Workflow, WorkflowType
|
|
from services.app.mcp_server_service import AppMCPServerStatus
|
|
from tests.helpers.unit.model_factories import make_end_user
|
|
|
|
"""Unit tests for controllers.mcp.mcp endpoints."""
|
|
|
|
|
|
def fake_payload(data):
|
|
module.mcp_ns.payload = data
|
|
|
|
|
|
_TENANT_ID = str(uuid4())
|
|
_APP_ID = str(uuid4())
|
|
_SERVER_ID = str(uuid4())
|
|
|
|
|
|
def _server(status: module.AppMCPServerStatus | str) -> AppMCPServer:
|
|
server = AppMCPServer(
|
|
tenant_id=_TENANT_ID,
|
|
app_id=_APP_ID,
|
|
name="Test server",
|
|
description="Test server",
|
|
server_code="server-1",
|
|
status=status,
|
|
parameters="{}",
|
|
)
|
|
server.id = _SERVER_ID
|
|
return server
|
|
|
|
|
|
def _app(
|
|
mode: module.AppMode,
|
|
*,
|
|
workflow_variables: list[dict[str, object]] | None = None,
|
|
with_model_config: bool = False,
|
|
) -> App:
|
|
app = App(
|
|
id=_APP_ID,
|
|
tenant_id=_TENANT_ID,
|
|
name="test_app",
|
|
description="",
|
|
mode=mode,
|
|
icon_type=IconType.EMOJI,
|
|
icon="robot",
|
|
icon_background="#ffffff",
|
|
enable_site=False,
|
|
enable_api=False,
|
|
api_rpm=0,
|
|
api_rph=0,
|
|
is_demo=False,
|
|
is_public=False,
|
|
is_universal=False,
|
|
max_active_requests=None,
|
|
use_icon_as_answer_icon=False,
|
|
)
|
|
if workflow_variables is not None:
|
|
workflow = Workflow.new(
|
|
tenant_id=_TENANT_ID,
|
|
app_id=_APP_ID,
|
|
type=WorkflowType.WORKFLOW,
|
|
version=Workflow.VERSION_DRAFT,
|
|
graph=json.dumps({"nodes": [{"id": "start", "data": {"type": "start", "variables": workflow_variables}}]}),
|
|
features="{}",
|
|
created_by="user-1",
|
|
environment_variables=[],
|
|
conversation_variables=[],
|
|
rag_pipeline_variables=[],
|
|
)
|
|
db.session.add(workflow)
|
|
db.session.flush()
|
|
app.workflow_id = workflow.id
|
|
if with_model_config:
|
|
config = AppModelConfig(app_id=_APP_ID)
|
|
config.user_input_form = "[]"
|
|
db.session.add(config)
|
|
db.session.flush()
|
|
app.app_model_config_id = config.id
|
|
return app
|
|
|
|
|
|
def _end_user() -> EndUser:
|
|
return make_end_user(
|
|
end_user_id=str(uuid4()),
|
|
tenant_id=_TENANT_ID,
|
|
app_id=_APP_ID,
|
|
end_user_type=EndUserType.MCP,
|
|
session_id=_SERVER_ID,
|
|
)
|
|
|
|
|
|
class DummyResult:
|
|
def model_dump(self, **kwargs):
|
|
return {"jsonrpc": "2.0", "result": "ok", "id": 1}
|
|
|
|
|
|
_UNSUPPORTED_VERSION = "1999-01-01"
|
|
|
|
|
|
def _initialize_payload(protocol_version: str = "2024-11-05") -> dict[str, object]:
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"method": "initialize",
|
|
"id": 1,
|
|
"params": {
|
|
"protocolVersion": protocol_version,
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test-client", "version": "1.0"},
|
|
},
|
|
}
|
|
|
|
|
|
def _tools_list_payload(request_id: int | None = 1) -> dict[str, object]:
|
|
payload: dict[str, object] = {"jsonrpc": "2.0", "method": "tools/list", "params": {}}
|
|
if request_id is not None:
|
|
payload["id"] = request_id
|
|
return payload
|
|
|
|
|
|
def _tools_call_payload() -> dict[str, object]:
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"method": "tools/call",
|
|
"id": 1,
|
|
"params": {"name": "test_app", "arguments": {"query": "test question"}},
|
|
}
|
|
|
|
|
|
class TestMcpCases:
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def app() -> Iterator[Flask]:
|
|
app = Flask(__name__)
|
|
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
|
db.init_app(app)
|
|
|
|
with app.app_context():
|
|
for table in (
|
|
App.__table__,
|
|
AppAnnotationSetting.__table__,
|
|
AppMCPServer.__table__,
|
|
AppModelConfig.__table__,
|
|
EndUser.__table__,
|
|
Workflow.__table__,
|
|
):
|
|
table.create(db.engine)
|
|
with patch.object(module.session_factory, "get_session_maker", return_value=sessionmaker(db.engine)):
|
|
yield app
|
|
|
|
@staticmethod
|
|
@pytest.fixture(autouse=True)
|
|
def mock_mcp_ns(monkeypatch: pytest.MonkeyPatch):
|
|
fake_ns = types.SimpleNamespace()
|
|
fake_ns.payload = None
|
|
fake_ns.models = {}
|
|
monkeypatch.setattr(module, "mcp_ns", fake_ns)
|
|
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def flask_req_ctx(app: Flask):
|
|
with app.test_request_context("/"):
|
|
yield
|
|
|
|
@pytest.mark.usefixtures("flask_req_ctx")
|
|
class TestMCPAppApi:
|
|
@patch.object(module, "handle_mcp_request", return_value=DummyResult(), autospec=True)
|
|
def test_success_request(self, mock_handle):
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "initialize",
|
|
"id": 1,
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test-client", "version": "1.0"},
|
|
},
|
|
}
|
|
)
|
|
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
app = _app(module.AppMode.ADVANCED_CHAT, workflow_variables=[])
|
|
|
|
api = module.MCPAppApi()
|
|
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
|
|
|
post_fn = unwrap(api.post)
|
|
response = post_fn("server-1")
|
|
|
|
assert isinstance(response, Response)
|
|
mock_handle.assert_called_once()
|
|
|
|
def test_notification_initialized(self):
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "notifications/initialized",
|
|
"params": {},
|
|
}
|
|
)
|
|
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
app = _app(module.AppMode.ADVANCED_CHAT, workflow_variables=[])
|
|
|
|
api = module.MCPAppApi()
|
|
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
|
|
|
post_fn = unwrap(api.post)
|
|
response = post_fn("server-1")
|
|
|
|
assert response.status_code == 202
|
|
|
|
def test_invalid_notification_method(self):
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "notifications/invalid",
|
|
"params": {},
|
|
}
|
|
)
|
|
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
app = _app(module.AppMode.ADVANCED_CHAT, workflow_variables=[])
|
|
|
|
api = module.MCPAppApi()
|
|
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
|
|
|
post_fn = unwrap(api.post)
|
|
|
|
response = post_fn("server-1")
|
|
assert response.status_code == 202
|
|
assert response.data == b""
|
|
|
|
def test_inactive_server(self):
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "ping",
|
|
"id": 1,
|
|
"params": {},
|
|
}
|
|
)
|
|
|
|
server = _server("inactive")
|
|
app = _app(module.AppMode.ADVANCED_CHAT, workflow_variables=[])
|
|
|
|
api = module.MCPAppApi()
|
|
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
|
|
|
post_fn = unwrap(api.post)
|
|
|
|
response = post_fn("server-1")
|
|
assert response.status_code == 404
|
|
assert response.get_json()["error"]["message"] == "Server Not Found"
|
|
|
|
def test_invalid_payload(self, mocker):
|
|
fake_payload({"invalid": "data"})
|
|
|
|
api = module.MCPAppApi()
|
|
mocker.patch.object(
|
|
api,
|
|
"_get_mcp_server_and_app",
|
|
return_value=(_server(module.AppMCPServerStatus.ACTIVE), _app(module.AppMode.WORKFLOW)),
|
|
)
|
|
post_fn = unwrap(api.post)
|
|
|
|
with pytest.raises(ValidationError):
|
|
post_fn("server-1")
|
|
|
|
def test_unknown_method_without_request_id_is_ignored(self):
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "test",
|
|
"params": {},
|
|
}
|
|
)
|
|
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
app = _app(module.AppMode.WORKFLOW, workflow_variables=[])
|
|
|
|
api = module.MCPAppApi()
|
|
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
|
|
|
post_fn = unwrap(api.post)
|
|
|
|
response = post_fn("server-1")
|
|
assert response.status_code == 202
|
|
assert response.data == b""
|
|
|
|
def test_server_not_found(self, mocker):
|
|
"""Test when MCP server doesn't exist"""
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "initialize",
|
|
"id": 1,
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test-client", "version": "1.0"},
|
|
},
|
|
}
|
|
)
|
|
|
|
api = module.MCPAppApi()
|
|
mocker.patch.object(api, "_get_mcp_server_and_app", side_effect=module.MCPServerNotFoundError())
|
|
|
|
post_fn = unwrap(api.post)
|
|
|
|
response = post_fn("server-1")
|
|
assert response.status_code == 404
|
|
assert response.get_json()["error"]["message"] == "Server Not Found"
|
|
|
|
def test_app_not_found(self, mocker):
|
|
"""Test when app associated with server doesn't exist"""
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "initialize",
|
|
"id": 1,
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test-client", "version": "1.0"},
|
|
},
|
|
}
|
|
)
|
|
|
|
api = module.MCPAppApi()
|
|
mocker.patch.object(api, "_get_mcp_server_and_app", side_effect=module.MCPServerNotFoundError())
|
|
|
|
post_fn = unwrap(api.post)
|
|
|
|
response = post_fn("server-1")
|
|
assert response.status_code == 404
|
|
assert response.get_json()["error"]["message"] == "Server Not Found"
|
|
|
|
def test_app_unavailable_no_workflow(self):
|
|
"""Test when app has no workflow (ADVANCED_CHAT mode)"""
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "initialize",
|
|
"id": 1,
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test-client", "version": "1.0"},
|
|
},
|
|
}
|
|
)
|
|
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
app = _app(module.AppMode.ADVANCED_CHAT)
|
|
|
|
api = module.MCPAppApi()
|
|
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
|
|
|
post_fn = unwrap(api.post)
|
|
|
|
with pytest.raises(module.MCPRequestError) as exc_info:
|
|
post_fn("server-1")
|
|
assert "App is unavailable" in str(exc_info.value)
|
|
|
|
def test_app_unavailable_no_model_config(self):
|
|
"""Test when app has no model config (chat mode)"""
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "initialize",
|
|
"id": 1,
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test-client", "version": "1.0"},
|
|
},
|
|
}
|
|
)
|
|
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
app = _app(module.AppMode.CHAT)
|
|
|
|
api = module.MCPAppApi()
|
|
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
|
|
|
post_fn = unwrap(api.post)
|
|
|
|
with pytest.raises(module.MCPRequestError) as exc_info:
|
|
post_fn("server-1")
|
|
assert "App is unavailable" in str(exc_info.value)
|
|
|
|
@patch.object(module, "handle_mcp_request", return_value=None, autospec=True)
|
|
def test_mcp_request_no_response(self, mock_handle):
|
|
"""Test when handle_mcp_request returns None"""
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "initialize",
|
|
"id": 1,
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test-client", "version": "1.0"},
|
|
},
|
|
}
|
|
)
|
|
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
app = _app(module.AppMode.ADVANCED_CHAT, workflow_variables=[])
|
|
|
|
api = module.MCPAppApi()
|
|
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
|
|
|
post_fn = unwrap(api.post)
|
|
|
|
with pytest.raises(module.MCPRequestError) as exc_info:
|
|
post_fn("server-1")
|
|
assert "No response generated" in str(exc_info.value)
|
|
|
|
def test_workflow_mode_with_user_input_form(self):
|
|
"""Test WORKFLOW mode app with user input form"""
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "initialize",
|
|
"id": 1,
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test-client", "version": "1.0"},
|
|
},
|
|
}
|
|
)
|
|
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
app = _app(
|
|
module.AppMode.WORKFLOW,
|
|
workflow_variables=[{"type": "text-input", "variable": "test_var", "label": "Test", "required": False}],
|
|
)
|
|
|
|
api = module.MCPAppApi()
|
|
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
|
|
|
with patch.object(module, "handle_mcp_request", return_value=DummyResult(), autospec=True):
|
|
post_fn = unwrap(api.post)
|
|
response = post_fn("server-1")
|
|
assert isinstance(response, Response)
|
|
|
|
def test_chat_mode_with_model_config(self):
|
|
"""Test CHAT mode app with model config"""
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "initialize",
|
|
"id": 1,
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test-client", "version": "1.0"},
|
|
},
|
|
}
|
|
)
|
|
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
app = _app(module.AppMode.CHAT, with_model_config=True)
|
|
|
|
api = module.MCPAppApi()
|
|
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
|
|
|
with patch.object(module, "handle_mcp_request", return_value=DummyResult(), autospec=True):
|
|
post_fn = unwrap(api.post)
|
|
response = post_fn("server-1")
|
|
assert isinstance(response, Response)
|
|
|
|
def test_invalid_mcp_request_format(self):
|
|
"""Test invalid MCP request that doesn't match any type"""
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "invalid_method_xyz",
|
|
"id": 1,
|
|
"params": {},
|
|
}
|
|
)
|
|
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
app = _app(module.AppMode.ADVANCED_CHAT, workflow_variables=[])
|
|
|
|
api = module.MCPAppApi()
|
|
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
|
|
|
post_fn = unwrap(api.post)
|
|
|
|
response = post_fn("server-1")
|
|
assert response.status_code == 400
|
|
assert response.get_json() == {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"error": {"code": module.mcp_types.METHOD_NOT_FOUND, "message": "Method not found"},
|
|
}
|
|
|
|
def test_validate_server_status_active(self):
|
|
"""Test successful server status validation"""
|
|
api = module.MCPAppApi()
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
|
|
# Should not raise an exception
|
|
api._validate_server_status(server)
|
|
|
|
def test_convert_user_input_form_empty(self):
|
|
"""Test converting empty user input form"""
|
|
api = module.MCPAppApi()
|
|
result = api._convert_user_input_form([])
|
|
assert result == []
|
|
|
|
def test_invalid_user_input_form_validation(self):
|
|
"""Test invalid user input form that fails validation"""
|
|
fake_payload(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "initialize",
|
|
"id": 1,
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test-client", "version": "1.0"},
|
|
},
|
|
}
|
|
)
|
|
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
app = _app(
|
|
module.AppMode.WORKFLOW,
|
|
workflow_variables=[{"type": "invalid-type", "variable": "test_var"}],
|
|
)
|
|
|
|
api = module.MCPAppApi()
|
|
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
|
|
|
post_fn = unwrap(api.post)
|
|
|
|
with pytest.raises(module.MCPRequestError) as exc_info:
|
|
post_fn("server-1")
|
|
assert "Invalid user_input_form" in str(exc_info.value)
|
|
|
|
@staticmethod
|
|
def test_execution_reloads_attached_orm_objects_after_identity_preflight(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
app_model = _app(module.AppMode.CHAT, with_model_config=True)
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
db.session.add_all([app_model, server])
|
|
db.session.commit()
|
|
api = module.MCPAppApi()
|
|
lookup = api._get_mcp_server_and_app
|
|
snapshots: list[tuple[module.AppMCPServer, module.App]] = []
|
|
|
|
def observe_lookup(code: str, session: Session) -> tuple[module.AppMCPServer, module.App]:
|
|
pair = lookup(code, session)
|
|
snapshots.append(pair)
|
|
return pair
|
|
|
|
def process(
|
|
_mcp_request: module.mcp_types.ClientRequest | module.mcp_types.ClientNotification,
|
|
_request_id: int | str | None,
|
|
fresh_app: module.App,
|
|
fresh_server: module.AppMCPServer,
|
|
_form: list[module.VariableEntity],
|
|
session: Session,
|
|
_protocol: str,
|
|
) -> Response:
|
|
old_server, old_app = snapshots[0]
|
|
assert inspect(old_server).detached
|
|
assert inspect(old_app).detached
|
|
assert fresh_server is not old_server
|
|
assert fresh_app is not old_app
|
|
assert inspect(fresh_server).session is session
|
|
assert inspect(fresh_app).session is session
|
|
return Response("ok")
|
|
|
|
monkeypatch.setattr(api, "_get_mcp_server_and_app", observe_lookup)
|
|
monkeypatch.setattr(api, "_process_mcp_message", process)
|
|
fake_payload({"jsonrpc": "2.0", "method": "ping", "id": 1})
|
|
with app.test_request_context("/"):
|
|
response = api.post("server-1")
|
|
assert response.status_code == 200
|
|
assert len(snapshots) == 2
|
|
|
|
@staticmethod
|
|
def test_execution_session_survives_a_commit_inside_message_processing(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# AdvancedChatAppGenerator commits the caller's session on a first conversation and
|
|
# keeps using it; a sessions.begin() context manager turns that into InvalidRequestError.
|
|
app_model = _app(module.AppMode.ADVANCED_CHAT, workflow_variables=[])
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
db.session.add_all([app_model, server])
|
|
db.session.commit()
|
|
api = module.MCPAppApi()
|
|
|
|
def process(
|
|
_mcp_request: module.mcp_types.ClientRequest | module.mcp_types.ClientNotification,
|
|
_request_id: int | str | None,
|
|
fresh_app: module.App,
|
|
_server: module.AppMCPServer,
|
|
_form: list[module.VariableEntity],
|
|
session: Session,
|
|
_protocol: str,
|
|
) -> Response:
|
|
fresh_app.description = "committed by generation"
|
|
session.commit()
|
|
session.refresh(fresh_app)
|
|
fresh_app.name = "changed after the inner commit"
|
|
return Response("ok")
|
|
|
|
monkeypatch.setattr(api, "_process_mcp_message", process)
|
|
fake_payload({"jsonrpc": "2.0", "method": "ping", "id": 1})
|
|
with app.test_request_context("/"):
|
|
response = api.post("server-1")
|
|
|
|
assert response.status_code == 200
|
|
with sessionmaker(db.engine)() as session:
|
|
persisted = session.get(App, app_model.id)
|
|
assert persisted is not None
|
|
assert persisted.description == "committed by generation"
|
|
assert persisted.name == "changed after the inner commit"
|
|
|
|
@staticmethod
|
|
@pytest.mark.usefixtures("app")
|
|
def test_identity_query_does_not_accept_a_server_bound_to_another_tenants_app() -> None:
|
|
app_model = _app(module.AppMode.CHAT)
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
server.tenant_id = str(uuid4())
|
|
db.session.add_all([app_model, server])
|
|
db.session.commit()
|
|
with sessionmaker(db.engine).begin() as session:
|
|
with pytest.raises(module.MCPServerNotFoundError):
|
|
module.MCPAppApi()._get_mcp_server_and_app("server-1", session)
|
|
|
|
class TestMCPProtocolVersionNegotiationApi:
|
|
"""MCP protocol version negotiation exercised through the HTTP controller layer.
|
|
|
|
Covers the MCP-Protocol-Version header contract (resolution, rejection, threading)
|
|
and the serialized JSON responses seen by modern (2025-06-18) vs legacy (2024-11-05)
|
|
clients, including the back-compat guarantee that legacy responses carry none of the
|
|
structured-output fields.
|
|
"""
|
|
|
|
def _make_api(self) -> module.MCPAppApi:
|
|
server = _server(module.AppMCPServerStatus.ACTIVE)
|
|
app = _app(module.AppMode.CHAT, with_model_config=True)
|
|
api = module.MCPAppApi()
|
|
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
|
api._retrieve_end_user = MagicMock(return_value=_end_user())
|
|
return api
|
|
|
|
def _post(
|
|
self,
|
|
flask_app: Flask,
|
|
api: module.MCPAppApi,
|
|
payload: dict[str, object],
|
|
headers: dict[str, str] | None = None,
|
|
) -> Response:
|
|
fake_payload(payload)
|
|
post_fn = unwrap(api.post)
|
|
with flask_app.test_request_context(headers=headers):
|
|
return post_fn("server-1")
|
|
|
|
@pytest.mark.parametrize("version", sorted(module.mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS))
|
|
def test_initialize_echoes_supported_body_version(self, app, version):
|
|
"""Initialize echoes every supported client-requested version back unchanged."""
|
|
api = self._make_api()
|
|
|
|
response = self._post(app, api, _initialize_payload(version))
|
|
|
|
body = response.get_json()
|
|
assert body["result"]["protocolVersion"] == version
|
|
|
|
def test_initialize_falls_back_for_unsupported_body_version(self, app):
|
|
"""An unsupported requested version falls back to the server latest."""
|
|
api = self._make_api()
|
|
|
|
response = self._post(app, api, _initialize_payload(_UNSUPPORTED_VERSION))
|
|
|
|
body = response.get_json()
|
|
assert body["result"]["protocolVersion"] == module.mcp_types.SERVER_LATEST_PROTOCOL_VERSION
|
|
|
|
def test_initialize_ignores_unsupported_header(self, app):
|
|
"""Initialize negotiates via the request body, so its header is never rejected."""
|
|
api = self._make_api()
|
|
|
|
response = self._post(
|
|
app,
|
|
api,
|
|
_initialize_payload("2024-11-05"),
|
|
headers={"MCP-Protocol-Version": _UNSUPPORTED_VERSION},
|
|
)
|
|
|
|
body = response.get_json()
|
|
assert "error" not in body
|
|
assert body["result"]["protocolVersion"] == "2024-11-05"
|
|
|
|
@pytest.mark.parametrize("request_id", [5, None])
|
|
def test_unsupported_header_returns_invalid_request_error(self, app, request_id):
|
|
"""An unsupported header gets a JSON-RPC error echoing the request id (missing id -> null)."""
|
|
api = self._make_api()
|
|
|
|
with patch.object(module, "handle_mcp_request", autospec=True) as mock_handle:
|
|
response = self._post(
|
|
app,
|
|
api,
|
|
_tools_list_payload(request_id=request_id),
|
|
headers={"MCP-Protocol-Version": _UNSUPPORTED_VERSION},
|
|
)
|
|
|
|
body = response.get_json()
|
|
assert response.status_code == 200
|
|
assert body["jsonrpc"] == "2.0"
|
|
assert body["id"] == request_id
|
|
assert body["error"]["code"] == module.mcp_types.INVALID_REQUEST
|
|
assert _UNSUPPORTED_VERSION in body["error"]["message"]
|
|
mock_handle.assert_not_called()
|
|
|
|
def test_notification_with_unsupported_header_is_accepted(self, app):
|
|
"""A notification is accepted (202, no body) even with an unsupported header."""
|
|
api = self._make_api()
|
|
|
|
response = self._post(
|
|
app,
|
|
api,
|
|
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
|
|
headers={"MCP-Protocol-Version": _UNSUPPORTED_VERSION},
|
|
)
|
|
|
|
assert response.status_code == 202
|
|
|
|
@pytest.mark.parametrize("version", sorted(module.mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS))
|
|
def test_supported_header_is_threaded_to_handler(self, app, version):
|
|
"""Every supported header value is passed through to handle_mcp_request."""
|
|
api = self._make_api()
|
|
|
|
with patch.object(module, "handle_mcp_request", return_value=DummyResult(), autospec=True) as mock_handle:
|
|
self._post(
|
|
app,
|
|
api,
|
|
_tools_list_payload(),
|
|
headers={"MCP-Protocol-Version": version},
|
|
)
|
|
|
|
assert mock_handle.call_args.args[-1] == version
|
|
|
|
def test_absent_header_defaults_to_back_compat_version(self, app):
|
|
"""An absent header resolves to the spec's default version (2025-03-26)."""
|
|
api = self._make_api()
|
|
|
|
with patch.object(module, "handle_mcp_request", return_value=DummyResult(), autospec=True) as mock_handle:
|
|
self._post(app, api, _tools_list_payload())
|
|
|
|
assert mock_handle.call_args.args[-1] == module.mcp_types.DEFAULT_NEGOTIATED_VERSION
|
|
|
|
def test_tools_list_json_advertises_structured_output_for_modern_client(self, app):
|
|
"""A 2025-06-18 client sees outputSchema and title in the serialized tool JSON."""
|
|
api = self._make_api()
|
|
|
|
response = self._post(
|
|
app,
|
|
api,
|
|
_tools_list_payload(),
|
|
headers={"MCP-Protocol-Version": "2025-06-18"},
|
|
)
|
|
|
|
tool = response.get_json()["result"]["tools"][0]
|
|
assert tool["outputSchema"] == {"type": "object"}
|
|
assert tool["title"] == "test_app"
|
|
|
|
def test_tools_list_json_unchanged_for_legacy_client(self, app):
|
|
"""A 2024-11-05 client sees exactly the pre-upgrade tool JSON keys."""
|
|
api = self._make_api()
|
|
|
|
response = self._post(
|
|
app,
|
|
api,
|
|
_tools_list_payload(),
|
|
headers={"MCP-Protocol-Version": "2024-11-05"},
|
|
)
|
|
|
|
tool = response.get_json()["result"]["tools"][0]
|
|
assert set(tool) == {"name", "description", "inputSchema"}
|
|
|
|
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
|
def test_tools_call_json_includes_structured_content_for_modern_client(self, mock_app_generate, app):
|
|
"""A 2025-06-18 client receives structuredContent alongside the text content."""
|
|
api = self._make_api()
|
|
mock_app_generate.generate.return_value = {"answer": "test answer"}
|
|
|
|
response = self._post(
|
|
app,
|
|
api,
|
|
_tools_call_payload(),
|
|
headers={"MCP-Protocol-Version": "2025-06-18"},
|
|
)
|
|
|
|
result = response.get_json()["result"]
|
|
assert result["structuredContent"] == {"answer": "test answer"}
|
|
assert result["content"][0]["text"] == "test answer"
|
|
|
|
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
|
def test_tools_call_json_omits_structured_content_for_legacy_client(self, mock_app_generate, app):
|
|
"""A 2024-11-05 client receives the pre-upgrade tools/call JSON without structuredContent."""
|
|
api = self._make_api()
|
|
mock_app_generate.generate.return_value = {"answer": "test answer"}
|
|
|
|
response = self._post(
|
|
app,
|
|
api,
|
|
_tools_call_payload(),
|
|
headers={"MCP-Protocol-Version": "2024-11-05"},
|
|
)
|
|
|
|
result = response.get_json()["result"]
|
|
assert "structuredContent" not in result
|
|
assert result["content"][0]["text"] == "test answer"
|
|
|
|
|
|
"""MCP request parsing errors exercised through the real HTTP blueprint."""
|
|
|
|
|
|
_URL = "/mcp/server/test-server/mcp"
|
|
_MODERN_HEADERS = {
|
|
"Accept": "application/json, text/event-stream",
|
|
"MCP-Protocol-Version": "2026-07-28",
|
|
"Mcp-Method": "server/discover",
|
|
}
|
|
|
|
|
|
class TestMcpRequestErrorsCases:
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def app(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Flask:
|
|
app = Flask(__name__)
|
|
app.config["SQLALCHEMY_DATABASE_URI"] = str(sqlite_engine.url)
|
|
db.init_app(app)
|
|
app.register_blueprint(mcp_controller.bp)
|
|
monkeypatch.setattr(module, "mcp_ns", mcp_controller.mcp_ns)
|
|
return app
|
|
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def client(app: Flask) -> FlaskClient:
|
|
return app.test_client()
|
|
|
|
@staticmethod
|
|
@pytest.fixture(autouse=True)
|
|
def published_server(sqlite_session: Session) -> AppMCPServer:
|
|
app_id = str(uuid4())
|
|
tenant_id = str(uuid4())
|
|
model_config = AppModelConfig(app_id=app_id, user_input_form="[]")
|
|
app = App(
|
|
id=app_id,
|
|
tenant_id=tenant_id,
|
|
name="test_app",
|
|
mode=AppMode.CHAT,
|
|
enable_site=False,
|
|
enable_api=True,
|
|
app_model_config_id=model_config.id,
|
|
)
|
|
server = AppMCPServer(
|
|
tenant_id=tenant_id,
|
|
app_id=app_id,
|
|
name="test_app",
|
|
description="Test MCP app",
|
|
server_code="test-server",
|
|
status=AppMCPServerStatus.ACTIVE,
|
|
parameters="{}",
|
|
)
|
|
sqlite_session.add_all([app, model_config, server])
|
|
sqlite_session.commit()
|
|
return server
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("request_id", [1, 0, "discover-1", None])
|
|
def test_server_discover_returns_method_not_found(client: FlaskClient, request_id: int | str | None) -> None:
|
|
response = client.post(
|
|
_URL,
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"method": "server/discover",
|
|
"params": {
|
|
"_meta": {
|
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
"io.modelcontextprotocol/clientInfo": {"name": "modern-client", "version": "1.0"},
|
|
"io.modelcontextprotocol/clientCapabilities": {
|
|
"extensions": {"io.modelcontextprotocol/ui": {"mimeTypes": ["text/html;profile=mcp-app"]}}
|
|
},
|
|
}
|
|
},
|
|
},
|
|
headers=_MODERN_HEADERS,
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
body = response.get_json()
|
|
assert body == {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"error": {"code": -32601, "message": "Method not found"},
|
|
}
|
|
assert type(body["id"]) is type(request_id)
|
|
|
|
@staticmethod
|
|
def test_invalid_tool_params_return_safe_jsonrpc_error(client: FlaskClient) -> None:
|
|
response = client.post(
|
|
_URL,
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": "tool-call-1",
|
|
"method": "tools/call",
|
|
"params": {"arguments": {"secret": "private-input-do-not-echo"}},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
body = response.get_json()
|
|
assert body["jsonrpc"] == "2.0"
|
|
assert body["id"] == "tool-call-1"
|
|
assert body["error"]["code"] == -32602
|
|
assert "private-input-do-not-echo" not in response.get_data(as_text=True)
|
|
assert "validation error" not in body["error"]["message"].lower()
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("method", ["server/discover", "notifications/unknown", "tools/call"])
|
|
def test_unparseable_notification_has_no_jsonrpc_response(client: FlaskClient, method: str) -> None:
|
|
response = client.post(
|
|
_URL,
|
|
json={"jsonrpc": "2.0", "method": method, "params": {}},
|
|
headers={**_MODERN_HEADERS, "Mcp-Method": method},
|
|
)
|
|
|
|
assert response.status_code == 202
|
|
assert response.data == b""
|
|
|
|
@staticmethod
|
|
def test_legacy_initialize_and_list_tools_remain_compatible(
|
|
client: FlaskClient, published_server: AppMCPServer, sqlite_session: Session
|
|
) -> None:
|
|
response = client.post(
|
|
_URL,
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": 0,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "legacy-client", "version": "1.0"},
|
|
},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
initialized = response.get_json()
|
|
assert initialized["id"] == 0
|
|
assert initialized["result"]["protocolVersion"] == "2024-11-05"
|
|
end_user = sqlite_session.scalar(select(EndUser).where(EndUser.session_id == published_server.id))
|
|
assert end_user is not None
|
|
assert end_user.tenant_id == published_server.tenant_id
|
|
assert end_user.app_id == published_server.app_id
|
|
assert end_user.type == EndUserType.MCP
|
|
assert end_user.name == "legacy-client@1.0"
|
|
|
|
headers = {"MCP-Protocol-Version": "2024-11-05"}
|
|
notification = client.post(
|
|
_URL,
|
|
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
headers=headers,
|
|
)
|
|
assert notification.status_code == 202
|
|
assert notification.data == b""
|
|
|
|
listed = client.post(
|
|
_URL,
|
|
json={"jsonrpc": "2.0", "id": "list-1", "method": "tools/list"},
|
|
headers=headers,
|
|
)
|
|
assert listed.status_code == 200
|
|
body = listed.get_json()
|
|
assert body["id"] == "list-1"
|
|
assert body["result"]["tools"] == [
|
|
{
|
|
"name": "test_app",
|
|
"description": "Test MCP app",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string", "description": "User Input/Question content"}},
|
|
"required": ["query"],
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
"""SQLite-backed unit tests for MCP app controller database helpers."""
|
|
|
|
|
|
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
|
APP_ID = "22222222-2222-2222-2222-222222222222"
|
|
|
|
|
|
class TestMcpAppApiCases:
|
|
@staticmethod
|
|
@pytest.mark.parametrize("sqlite_session", [(App, AppMCPServer)], indirect=True)
|
|
def test_get_mcp_server_and_app_returns_persisted_models(sqlite_session: Session) -> None:
|
|
app = App(
|
|
tenant_id=TENANT_ID,
|
|
name="MCP App",
|
|
description="",
|
|
mode=AppMode.ADVANCED_CHAT,
|
|
icon_type=IconType.EMOJI,
|
|
icon="🤖",
|
|
icon_background="#FFFFFF",
|
|
enable_site=False,
|
|
enable_api=False,
|
|
max_active_requests=0,
|
|
)
|
|
app.id = APP_ID
|
|
server = AppMCPServer(
|
|
tenant_id=TENANT_ID,
|
|
app_id=APP_ID,
|
|
name="Test MCP Server",
|
|
description="Test server",
|
|
server_code="server-1",
|
|
status=AppMCPServerStatus.ACTIVE,
|
|
parameters="{}",
|
|
)
|
|
sqlite_session.add_all([app, server])
|
|
sqlite_session.commit()
|
|
|
|
result_server, result_app = MCPAppApi()._get_mcp_server_and_app("server-1", sqlite_session)
|
|
|
|
assert result_server is server
|
|
assert result_app is app
|