|
| 1 | +"""Integration tests for the MCP server against the real data service API. |
| 2 | +
|
| 3 | +These tests verify that the API calls the MCP tools make are compatible with the |
| 4 | +current API definition in this repo. They use the same Sanic test infrastructure as |
| 5 | +the other data_api tests — a real DB, real SpiceDB, and a dummy authenticator. |
| 6 | +
|
| 7 | +The MCP tools are exercised via the full MCP protocol (in-process), routing API calls |
| 8 | +through the Sanic test client instead of httpx. |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +from typing import Any |
| 14 | + |
| 15 | +import pytest |
| 16 | +from sanic_testing.testing import SanicASGITestClient |
| 17 | + |
| 18 | +from renku_data_services.mcp_api.dependencies import MCPDependencies |
| 19 | +from renku_data_services.mcp_api.server import _admin_cache |
| 20 | +from test.bases.renku_data_services.mcp_api.conftest import ( |
| 21 | + mcp_session, |
| 22 | + tool_result_dict, |
| 23 | + tool_result_list, |
| 24 | +) |
| 25 | + |
| 26 | + |
| 27 | +class SanicMCPDependencies(MCPDependencies): |
| 28 | + """MCPDependencies that routes api() calls through the Sanic test client.""" |
| 29 | + |
| 30 | + def __init__(self, sanic_client: SanicASGITestClient) -> None: |
| 31 | + super().__init__(base_url="http://localhost") |
| 32 | + self._client = sanic_client |
| 33 | + |
| 34 | + async def api( |
| 35 | + self, |
| 36 | + method: str, |
| 37 | + path: str, |
| 38 | + token: str, |
| 39 | + body: Any = None, |
| 40 | + *, |
| 41 | + query: dict[str, Any] | None = None, |
| 42 | + extra_headers: dict[str, str] | None = None, |
| 43 | + return_headers: bool = False, |
| 44 | + ) -> Any: |
| 45 | + headers: dict[str, str] = {"Authorization": f"Bearer {token}"} |
| 46 | + if extra_headers: |
| 47 | + headers.update(extra_headers) |
| 48 | + |
| 49 | + _, response = await self._client.request( |
| 50 | + method, |
| 51 | + f"/api/data{path}", |
| 52 | + headers=headers, |
| 53 | + json=body, |
| 54 | + params=query, |
| 55 | + ) |
| 56 | + |
| 57 | + if response.status >= 400: |
| 58 | + raise RuntimeError(f"HTTP {response.status}: {response.text}") |
| 59 | + |
| 60 | + result = response.json if response.content_type and "json" in response.content_type else None |
| 61 | + if return_headers: |
| 62 | + return result, dict(response.headers) |
| 63 | + return result |
| 64 | + |
| 65 | + |
| 66 | +@pytest.fixture(autouse=True) |
| 67 | +def clear_admin_cache_integration(): |
| 68 | + _admin_cache.clear() |
| 69 | + yield |
| 70 | + _admin_cache.clear() |
| 71 | + |
| 72 | + |
| 73 | +@pytest.fixture |
| 74 | +def mcp_deps(sanic_client: SanicASGITestClient) -> SanicMCPDependencies: |
| 75 | + return SanicMCPDependencies(sanic_client) |
| 76 | + |
| 77 | + |
| 78 | +# --------------------------------------------------------------------------- |
| 79 | +# Platform / auth |
| 80 | +# --------------------------------------------------------------------------- |
| 81 | + |
| 82 | + |
| 83 | +@pytest.mark.asyncio |
| 84 | +async def test_auth_status(mcp_deps: SanicMCPDependencies, regular_user_access_token: str) -> None: |
| 85 | + async with mcp_session(mcp_deps, token=regular_user_access_token) as (session, _): |
| 86 | + result = await session.call_tool("auth_status", {}) |
| 87 | + data = tool_result_dict(result) |
| 88 | + assert data["authenticated"] is True |
| 89 | + assert data["is_admin"] is False |
| 90 | + |
| 91 | + |
| 92 | +@pytest.mark.asyncio |
| 93 | +async def test_resource_classes(mcp_deps: SanicMCPDependencies, regular_user_access_token: str) -> None: |
| 94 | + async with mcp_session(mcp_deps, token=regular_user_access_token) as (session, _): |
| 95 | + result = await session.call_tool("resource_classes", {}) |
| 96 | + classes = tool_result_list(result) |
| 97 | + # Resource pools may not be seeded in all test environments |
| 98 | + assert isinstance(classes, list) |
| 99 | + if classes: |
| 100 | + assert all("id" in c and "cpu" in c for c in classes) |
| 101 | + |
| 102 | + |
| 103 | +@pytest.mark.asyncio |
| 104 | +async def test_namespaces(mcp_deps: SanicMCPDependencies, regular_user_access_token: str) -> None: |
| 105 | + async with mcp_session(mcp_deps, token=regular_user_access_token) as (session, _): |
| 106 | + result = await session.call_tool("namespaces", {}) |
| 107 | + ns_list = tool_result_list(result) |
| 108 | + assert len(ns_list) > 0 |
| 109 | + |
| 110 | + |
| 111 | +# --------------------------------------------------------------------------- |
| 112 | +# Projects |
| 113 | +# --------------------------------------------------------------------------- |
| 114 | + |
| 115 | + |
| 116 | +@pytest.mark.asyncio |
| 117 | +async def test_project_list(mcp_deps: SanicMCPDependencies, regular_user_access_token: str) -> None: |
| 118 | + async with mcp_session(mcp_deps, token=regular_user_access_token) as (session, _): |
| 119 | + result = await session.call_tool("project_list", {}) |
| 120 | + assert result.isError is not True |
| 121 | + |
| 122 | + |
| 123 | +@pytest.mark.asyncio |
| 124 | +async def test_project_create_and_delete( |
| 125 | + mcp_deps: SanicMCPDependencies, regular_user_access_token: str, regular_user: Any |
| 126 | +) -> None: |
| 127 | + namespace = regular_user.namespace.path.serialize() |
| 128 | + async with mcp_session(mcp_deps, token=regular_user_access_token) as (session, _): |
| 129 | + created = await session.call_tool( |
| 130 | + "project_create", |
| 131 | + {"name": "mcp-integration-test", "namespace": namespace, "visibility": "private"}, |
| 132 | + ) |
| 133 | + assert created.isError is not True |
| 134 | + project = tool_result_dict(created) |
| 135 | + project_id = project["id"] |
| 136 | + |
| 137 | + deleted = await session.call_tool("project_delete", {"project": project_id}) |
| 138 | + assert deleted.isError is not True |
| 139 | + |
| 140 | + |
| 141 | +# --------------------------------------------------------------------------- |
| 142 | +# Launcher create — API compatibility regression tests |
| 143 | +# --------------------------------------------------------------------------- |
| 144 | + |
| 145 | + |
| 146 | +@pytest.mark.asyncio |
| 147 | +async def test_launcher_create_without_launcher_type( |
| 148 | + mcp_deps: SanicMCPDependencies, |
| 149 | + regular_user_access_token: str, |
| 150 | + regular_user: Any, |
| 151 | + create_session_environment: Any, |
| 152 | + create_resource_pool: Any, |
| 153 | +) -> None: |
| 154 | + """Launcher creation without launcher_type must not return 422. |
| 155 | +
|
| 156 | + Regression test: launcher_type was added to POST /session_launchers in a recent |
| 157 | + API change. Sending launcher_type='interactive' fails on older deployments because |
| 158 | + their schema has extra='forbid'. The MCP server must omit launcher_type when not |
| 159 | + explicitly set. |
| 160 | + """ |
| 161 | + env = await create_session_environment("mcp-test-env") |
| 162 | + pool = await create_resource_pool(admin=True) |
| 163 | + resource_class_id = pool["classes"][0]["id"] |
| 164 | + |
| 165 | + namespace = regular_user.namespace.path.serialize() |
| 166 | + async with mcp_session(mcp_deps, token=regular_user_access_token) as (session, _): |
| 167 | + project_result = await session.call_tool( |
| 168 | + "project_create", |
| 169 | + {"name": "mcp-launcher-test", "namespace": namespace, "visibility": "private"}, |
| 170 | + ) |
| 171 | + project_id = tool_result_dict(project_result)["id"] |
| 172 | + try: |
| 173 | + # Do NOT pass launcher_type — this is the key assertion |
| 174 | + launcher_result = await session.call_tool( |
| 175 | + "launcher_create", |
| 176 | + { |
| 177 | + "project_id": project_id, |
| 178 | + "name": "test-launcher", |
| 179 | + "resource_class_id": resource_class_id, |
| 180 | + "environment": {"id": env["id"]}, |
| 181 | + }, |
| 182 | + ) |
| 183 | + assert launcher_result.isError is not True, ( |
| 184 | + f"launcher_create failed without launcher_type: {launcher_result.content[0].text}" |
| 185 | + ) |
| 186 | + launcher_id = tool_result_dict(launcher_result)["id"] |
| 187 | + await session.call_tool("launcher_delete", {"launcher_id": launcher_id}) |
| 188 | + finally: |
| 189 | + await session.call_tool("project_delete", {"project": project_id}) |
| 190 | + |
| 191 | + |
| 192 | +@pytest.mark.asyncio |
| 193 | +async def test_launcher_create_non_interactive( |
| 194 | + mcp_deps: SanicMCPDependencies, |
| 195 | + regular_user_access_token: str, |
| 196 | + regular_user: Any, |
| 197 | + create_session_environment: Any, |
| 198 | + create_resource_pool: Any, |
| 199 | +) -> None: |
| 200 | + """Launcher creation with launcher_type='non_interactive' must succeed on current API.""" |
| 201 | + env = await create_session_environment("mcp-job-env") |
| 202 | + pool = await create_resource_pool(admin=True) |
| 203 | + resource_class_id = pool["classes"][0]["id"] |
| 204 | + |
| 205 | + namespace = regular_user.namespace.path.serialize() |
| 206 | + async with mcp_session(mcp_deps, token=regular_user_access_token) as (session, _): |
| 207 | + project_result = await session.call_tool( |
| 208 | + "project_create", |
| 209 | + {"name": "mcp-job-test", "namespace": namespace, "visibility": "private"}, |
| 210 | + ) |
| 211 | + project_id = tool_result_dict(project_result)["id"] |
| 212 | + try: |
| 213 | + launcher_result = await session.call_tool( |
| 214 | + "launcher_create", |
| 215 | + { |
| 216 | + "project_id": project_id, |
| 217 | + "name": "test-job-launcher", |
| 218 | + "resource_class_id": resource_class_id, |
| 219 | + "environment": {"id": env["id"]}, |
| 220 | + "launcher_type": "non_interactive", |
| 221 | + }, |
| 222 | + ) |
| 223 | + assert launcher_result.isError is not True |
| 224 | + launcher = tool_result_dict(launcher_result) |
| 225 | + assert launcher.get("launcher_type") == "non_interactive" |
| 226 | + await session.call_tool("launcher_delete", {"launcher_id": launcher["id"]}) |
| 227 | + finally: |
| 228 | + await session.call_tool("project_delete", {"project": project_id}) |
0 commit comments