Skip to content

Commit 578da49

Browse files
committed
feat: add MCP API integration tests and launcher backwards compatibility fixes
- Integration tests against the real Sanic/DB/SpiceDB stack via SanicMCPDependencies - Regression test: launcher_create without launcher_type must not fail (old API compat) - launcher_type=None treated as interactive for backwards compatibility - launcher_type optional in launcher_create, only sent when explicitly set
1 parent 023c281 commit 578da49

4 files changed

Lines changed: 261 additions & 14 deletions

File tree

bases/renku_data_services/mcp_api/server.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -531,10 +531,14 @@ async def launcher_create(
531531
name: Annotated[str, Field(description="Launcher name")],
532532
resource_class_id: Annotated[int, Field(description="Resource class ID (from resource_classes())")],
533533
environment: Annotated[dict[str, Any], Field(description="Environment definition dict")],
534-
launcher_type: Annotated[str, Field(description="'interactive' for sessions, 'non_interactive' for jobs")] = "interactive",
534+
launcher_type: Annotated[
535+
str | None, Field(description="Set to 'non_interactive' for job launchers. Leave unset for interactive sessions (default).")
536+
] = None,
535537
description: Annotated[str, Field(description="Optional description")] = "",
536538
) -> dict[str, Any]:
537539
"""Create a session launcher. Always call resource_classes(cpu=..., memory=...) first.
540+
Do NOT set launcher_type unless creating a non_interactive job launcher — leave it unset
541+
for interactive sessions. Sending launcher_type='interactive' will fail on older deployments.
538542
539543
Three ways to specify the environment:
540544
@@ -558,8 +562,9 @@ async def launcher_create(
558562
"name": name,
559563
"resource_class_id": resource_class_id,
560564
"environment": environment,
561-
"launcher_type": launcher_type,
562565
}
566+
if launcher_type is not None:
567+
body["launcher_type"] = launcher_type
563568
if description:
564569
body["description"] = description
565570
return _launcher_summary(await _api(ctx, "POST", "/session_launchers", body))
@@ -641,7 +646,8 @@ async def session_launch(
641646
After calling this, use session_wait(session_id) to wait for 'running' state —
642647
do not sleep or poll manually."""
643648
launcher = await _api(ctx, "GET", f"/session_launchers/{launcher_id}")
644-
if launcher.get("launcher_type") != "interactive":
649+
# None means the API predates launcher_type — treat as interactive (the historical default).
650+
if launcher.get("launcher_type") not in ("interactive", None):
645651
raise RuntimeError(
646652
f"Launcher {launcher_id!r} has launcher_type={launcher.get('launcher_type')!r}. "
647653
"Use job_run for non_interactive launchers."
@@ -770,9 +776,10 @@ async def job_run(
770776
a new one — always verify _created=true in the response. If _created=false, delete
771777
the returned session and retry."""
772778
launcher = await _api(ctx, "GET", f"/session_launchers/{launcher_id}")
779+
# None means the API predates launcher_type — treat as interactive, so block job_run.
773780
if launcher.get("launcher_type") != "non_interactive":
774781
raise RuntimeError(
775-
f"Launcher {launcher_id!r} has launcher_type={launcher.get('launcher_type')!r}. "
782+
f"Launcher {launcher_id!r} has launcher_type={launcher.get('launcher_type') or 'interactive (default)'}. "
776783
"Use session_launch for interactive launchers."
777784
)
778785
body: dict[str, Any] = {"launcher_id": launcher_id}
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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})

test/bases/renku_data_services/mcp_api/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ def mock_deps() -> MCPDependencies:
2626

2727

2828
@contextlib.asynccontextmanager
29-
async def mcp_session(deps: MCPDependencies):
29+
async def mcp_session(deps: MCPDependencies, token: str = "test-token"):
3030
"""Async context manager that runs the MCP server in-process.
3131
Must be used within a single asyncio task to keep anyio cancel scopes happy."""
32-
set_current_token("test-token")
32+
set_current_token(token)
3333
server = create_server(deps)
3434

3535
async with create_client_server_memory_streams() as (client_streams, server_streams):

test/bases/renku_data_services/mcp_api/test_mcp_server.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ async def test_api_returns_headers_when_requested(httpx_mock):
186186
@pytest.mark.asyncio
187187
async def test_admin_blocks_tool_calls(mock_deps):
188188
"""All tools except auth_status are blocked for admin users."""
189+
189190
async def fake_api(method: str, path: str, token: str, *args: Any, **kwargs: Any) -> Any:
190191
if path == "/user":
191192
return {"is_admin": True, "id": "admin"}
@@ -202,6 +203,7 @@ async def fake_api(method: str, path: str, token: str, *args: Any, **kwargs: Any
202203
@pytest.mark.asyncio
203204
async def test_non_admin_allowed(mock_deps):
204205
"""Non-admin users can call tools normally."""
206+
205207
async def fake_api(method: str, path: str, token: str, *args: Any, **kwargs: Any) -> Any:
206208
if path == "/user":
207209
return {"is_admin": False, "id": "user1"}
@@ -217,6 +219,7 @@ async def fake_api(method: str, path: str, token: str, *args: Any, **kwargs: Any
217219
@pytest.mark.asyncio
218220
async def test_admin_check_cached(mock_deps):
219221
"""/user is only called once per token, even across multiple tool calls."""
222+
220223
async def fake_api(method: str, path: str, token: str, *args: Any, **kwargs: Any) -> Any:
221224
if path == "/user":
222225
return {"is_admin": False, "id": "user1"}
@@ -261,10 +264,20 @@ async def test_list_tools_smoke(mock_deps):
261264
result = await session.list_tools()
262265
names = {t.name for t in result.tools}
263266
for expected in (
264-
"auth_status", "project_list", "project_create", "project_update",
265-
"connector_create", "launcher_create", "launcher_delete",
266-
"session_launch", "session_wait", "job_run", "job_wait", "build_wait",
267-
"global_environments", "renku_group_members",
267+
"auth_status",
268+
"project_list",
269+
"project_create",
270+
"project_update",
271+
"connector_create",
272+
"launcher_create",
273+
"launcher_delete",
274+
"session_launch",
275+
"session_wait",
276+
"job_run",
277+
"job_wait",
278+
"build_wait",
279+
"global_environments",
280+
"renku_group_members",
268281
):
269282
assert expected in names, f"Missing tool: {expected}"
270283

@@ -273,7 +286,7 @@ async def test_list_tools_smoke(mock_deps):
273286
async def test_session_list_filters_stale(mock_deps):
274287
mock_deps.api.return_value = [
275288
make_session("running"),
276-
make_session("hibernated", will_delete_at=iso_ago(60)), # stale
289+
make_session("hibernated", will_delete_at=iso_ago(60)), # stale
277290
make_session("running", will_delete_at=iso_future(3600)), # not stale
278291
]
279292

@@ -406,9 +419,7 @@ async def fake_api(method: str, path: str, token: str, body: Any = None, **kwarg
406419
mock_deps.api.side_effect = fake_api
407420

408421
async with mcp_session(mock_deps) as (session, deps):
409-
await session.call_tool(
410-
"project_repo_add", {"project": "proj-1", "repository_url": "https://github.com/x/y"}
411-
)
422+
await session.call_tool("project_repo_add", {"project": "proj-1", "repository_url": "https://github.com/x/y"})
412423

413424
patch_calls = [c for c in deps.api.call_args_list if c.args[0] == "PATCH"]
414425
assert len(patch_calls) == 1
@@ -418,6 +429,7 @@ async def fake_api(method: str, path: str, token: str, body: Any = None, **kwarg
418429
@pytest.mark.asyncio
419430
async def test_project_repo_add_raises_without_etag(mock_deps):
420431
"""project_repo_add raises when the project has no ETag."""
432+
421433
async def fake_api(method: str, path: str, token: str, *args: Any, **kwargs: Any) -> Any:
422434
if path == "/user":
423435
return {"is_admin": False}

0 commit comments

Comments
 (0)