From b9eaebcbb318e8bb1627c60f22cbbc0124ab18c7 Mon Sep 17 00:00:00 2001 From: Ben Keith Date: Wed, 24 Sep 2025 14:30:13 -0400 Subject: [PATCH] Actually use FastMCP Previously we were installing the package but using the official SDK for of FastMCP 1.0. Using FastMCP directly gives us a lot more features should we need them. --- pyproject.toml | 2 +- server.py | 66 ++++++----- tests/test_server.py | 256 ++++++++++++++++++++++--------------------- uv.lock | 8 +- 4 files changed, 177 insertions(+), 155 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 312dd71..8068c2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "assisted-service-client>=2.41.0.post3", - "fastmcp>=2.8.0", + "fastmcp>=2.12.3", "netaddr>=1.3.0", "requests>=2.32.3", "retry>=0.9.2", diff --git a/server.py b/server.py index 41c7a87..5c6e438 100644 --- a/server.py +++ b/server.py @@ -15,7 +15,11 @@ import uvicorn from pydantic import Field from assisted_service_client import models -from mcp.server.fastmcp import FastMCP + +from fastmcp import FastMCP +from fastmcp.server.dependencies import get_http_headers +from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext +from fastmcp.tools.tool import ToolResult from metrics import metrics, track_tool_usage, initiate_metrics from service_client import InventoryClient @@ -29,10 +33,7 @@ ) -transport_type = os.environ.get("TRANSPORT", "sse").lower() -use_stateless_http = transport_type == "streamable-http" - -mcp = FastMCP("AssistedService", host="0.0.0.0", stateless_http=use_stateless_http) +mcp = FastMCP("AssistedService") def format_presigned_url(presigned_url: models.PresignedUrl) -> dict[str, Any]: @@ -87,12 +88,11 @@ def get_offline_token() -> str: log.debug("Found offline token in environment variables") return token - request = mcp.get_context().request_context.request - if request is not None: - token = request.headers.get("OCM-Offline-Token") - if token: - log.debug("Found offline token in request headers") - return token + headers = get_http_headers(include_all=True) + token = headers.get("ocm-offline-token") + if token: + log.debug("Found offline token in request headers") + return token log.error("No offline token found in environment or request headers") raise RuntimeError("No offline token found in environment or request headers") @@ -114,14 +114,13 @@ def get_access_token() -> str: """ log.debug("Attempting to retrieve access token") # First try to get the token from the authorization header: - request = mcp.get_context().request_context.request - if request is not None: - header = request.headers.get("Authorization") - if header is not None: - parts = header.split() - if len(parts) == 2 and parts[0].lower() == "bearer": - log.debug("Found access token in authorization header") - return parts[1] + headers = get_http_headers(include_all=True) + header = headers.get("authorization") + if header is not None: + parts = header.split() + if len(parts) == 2 and parts[0].lower() == "bearer": + log.debug("Found access token in authorization header") + return parts[1] # Now try to get the offline token, and generate a new access token from it: log.debug("Generating new access token from offline token") @@ -939,18 +938,33 @@ def list_tools() -> list[str]: """List all MCP tools.""" async def mcp_list_tools() -> list[str]: - return [t.name for t in await mcp.list_tools()] + return list((await mcp.get_tools()).keys()) return asyncio.run(mcp_list_tools()) +class StripSessionIDMiddleware(Middleware): + """Middleware to fix llama-stack behavior + + For some reason it injects session_id as an arg to every tool call + and it isn't configurable to disable. + """ + + async def on_call_tool( + self, + context: MiddlewareContext, + call_next: CallNext, + ) -> ToolResult: + """Strip session_id from tool calls""" + if "session_id" in context.message.arguments: + del context.message.arguments["session_id"] + + return await call_next(context) + + if __name__ == "__main__": - if transport_type == "streamable-http": - app = mcp.streamable_http_app() - log.info("Using StreamableHTTP transport (stateless)") - else: - app = mcp.sse_app() - log.info("Using SSE transport (stateful)") + mcp.add_middleware(StripSessionIDMiddleware()) + app = mcp.http_app(transport="streamable-http") initiate_metrics(list_tools()) app.add_route("/metrics", metrics) diff --git a/tests/test_server.py b/tests/test_server.py index cf37f53..e7387ff 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,13 +2,14 @@ Unit tests for the server module. """ -# pylint: disable=too-many-lines +# pylint: disable=too-many-lines,unused-argument import json import os -from typing import Generator, Tuple +from typing import Any, Generator, cast from unittest.mock import Mock, patch, call +from mcp.types import TextContent import pytest from requests.exceptions import RequestException @@ -27,14 +28,12 @@ class TestTokenFunctions: """Test cases for token handling functions.""" @pytest.fixture - def mock_mcp_get_context(self) -> Generator[Tuple[Mock, Mock], None, None]: + def mock_mcp_get_http_headers(self) -> Generator[dict[str, Any], None, None]: """Mock MCP context for testing.""" - mock_context = Mock() - mock_request = Mock() - mock_context.request_context.request = mock_request + headers: dict[str, Any] = {} - with patch.object(server.mcp, "get_context", return_value=mock_context): - yield mock_context, mock_request + with patch.object(server, "get_http_headers", return_value=headers): + yield headers def test_get_offline_token_from_environment(self) -> None: """Test retrieving offline token from environment variables.""" @@ -44,15 +43,14 @@ def test_get_offline_token_from_environment(self) -> None: assert result == test_token def test_get_offline_token_environment_takes_precedence( - self, mock_mcp_get_context: Tuple[Mock, Mock] + self, mock_mcp_get_http_headers: dict[str, Any] ) -> None: """Test that environment token takes precedence over request header token.""" - _mock_context, mock_request = mock_mcp_get_context env_token = "environment-token" header_token = "header-token" # Set up both environment and header tokens - mock_request.headers.get.return_value = header_token + mock_mcp_get_http_headers["ocm-offline-token"] = header_token with patch.dict(os.environ, {"OFFLINE_TOKEN": env_token}): result = server.get_offline_token() @@ -60,64 +58,42 @@ def test_get_offline_token_environment_takes_precedence( # Should return the environment token, not the header token assert result == env_token - # Should not even check the request headers since env token was found - mock_request.headers.get.assert_not_called() - def test_get_offline_token_from_headers( - self, mock_mcp_get_context: Tuple[Mock, Mock] + self, mock_mcp_get_http_headers: dict[str, Any] ) -> None: """Test retrieving offline token from request headers.""" - _mock_context, mock_request = mock_mcp_get_context test_token = "test-offline-token-header" - mock_request.headers.get.return_value = test_token + mock_mcp_get_http_headers["ocm-offline-token"] = test_token # Ensure environment variable is not set with patch.dict(os.environ, {}, clear=True): result = server.get_offline_token() assert result == test_token - mock_request.headers.get.assert_called_once_with("OCM-Offline-Token") def test_get_offline_token_not_found( - self, mock_mcp_get_context: Tuple[Mock, Mock] + self, mock_mcp_get_http_headers: dict[str, Any] ) -> None: """Test error when offline token is not found.""" - _mock_context, mock_request = mock_mcp_get_context - mock_request.headers.get.return_value = None - with patch.dict(os.environ, {}, clear=True): with pytest.raises(RuntimeError) as exc_info: server.get_offline_token() assert "No offline token found" in str(exc_info.value) - def test_get_offline_token_no_request(self) -> None: - """Test offline token retrieval when no request is available.""" - mock_context = Mock() - mock_context.request_context.request = None - - with patch.object(server.mcp, "get_context", return_value=mock_context): - with patch.dict(os.environ, {}, clear=True): - with pytest.raises(RuntimeError) as exc_info: - server.get_offline_token() - assert "No offline token found" in str(exc_info.value) - def test_get_access_token_from_authorization_header( - self, mock_mcp_get_context: Tuple[Mock, Mock] + self, mock_mcp_get_http_headers: dict[str, Any] ) -> None: """Test retrieving access token from Authorization header.""" - _mock_context, mock_request = mock_mcp_get_context test_token = "test-access-token" - mock_request.headers.get.return_value = f"Bearer {test_token}" + mock_mcp_get_http_headers["authorization"] = f"Bearer {test_token}" result = server.get_access_token() assert result == test_token - mock_request.headers.get.assert_called_once_with("Authorization") def test_get_access_token_invalid_authorization_header( - self, mock_mcp_get_context: Tuple[Mock, Mock] + self, mock_mcp_get_http_headers: dict[str, Any] ) -> None: """Test access token retrieval with invalid Authorization header.""" - _mock_context, mock_request = mock_mcp_get_context - mock_request.headers.get.return_value = "Invalid header format" + mock_mcp_get_http_headers["authorization"] = "Invalid header format" with patch.object(server, "get_offline_token", return_value="offline-token"): with patch("requests.post") as mock_post: @@ -129,12 +105,9 @@ def test_get_access_token_invalid_authorization_header( assert result == "new-token" def test_get_access_token_no_authorization_header( - self, mock_mcp_get_context: Tuple[Mock, Mock] + self, mock_mcp_get_http_headers: dict[str, Any] ) -> None: """Test access token retrieval without Authorization header.""" - _mock_context, mock_request = mock_mcp_get_context - mock_request.headers.get.return_value = None - with patch.object(server, "get_offline_token", return_value="offline-token"): with patch("requests.post") as mock_post: mock_response = Mock() @@ -146,12 +119,9 @@ def test_get_access_token_no_authorization_header( @patch("requests.post") def test_get_access_token_generate_from_offline_token( - self, mock_post: Mock, mock_mcp_get_context: Tuple[Mock, Mock] + self, mock_post: Mock, mock_mcp_get_http_headers: dict[str, Any] ) -> None: """Test generating access token from offline token.""" - _mock_context, mock_request = mock_mcp_get_context - mock_request.headers.get.return_value = None - offline_token = "test-offline-token" access_token = "generated-access-token" @@ -175,12 +145,9 @@ def test_get_access_token_generate_from_offline_token( @patch("requests.post") def test_get_access_token_custom_sso_url( - self, mock_post: Mock, mock_mcp_get_context: Tuple[Mock, Mock] + self, mock_post: Mock, mock_mcp_get_http_headers: dict[str, Any] ) -> None: """Test access token generation with custom SSO URL.""" - _mock_context, mock_request = mock_mcp_get_context - mock_request.headers.get.return_value = None - custom_sso_url = "https://custom-sso.example.com/token" offline_token = "test-offline-token" access_token = "generated-access-token" @@ -206,34 +173,27 @@ def test_get_access_token_custom_sso_url( @patch("requests.post") def test_get_access_token_request_failure( - self, mock_post: Mock, mock_mcp_get_context: Tuple[Mock, Mock] + self, mock_post: Mock, mock_mcp_get_http_headers: dict[str, Any] ) -> None: """Test access token generation request failure.""" - _mock_context, mock_request = mock_mcp_get_context - mock_request.headers.get.return_value = None - mock_post.side_effect = RequestException("Network error") with patch.object(server, "get_offline_token", return_value="offline-token"): with pytest.raises(RequestException): server.get_access_token() - def test_get_access_token_no_request_context(self) -> None: + def test_get_access_token_no_request_context( + self, mock_mcp_get_http_headers: dict[str, Any] + ) -> None: """Test access token retrieval when no request context is available.""" - mock_context = Mock() - mock_context.request_context.request = None - - with patch.object(server.mcp, "get_context", return_value=mock_context): - with patch.object( - server, "get_offline_token", return_value="offline-token" - ): - with patch("requests.post") as mock_post: - mock_response = Mock() - mock_response.json.return_value = {"access_token": "new-token"} - mock_post.return_value = mock_response + with patch.object(server, "get_offline_token", return_value="offline-token"): + with patch("requests.post") as mock_post: + mock_response = Mock() + mock_response.json.return_value = {"access_token": "new-token"} + mock_post.return_value = mock_response - result = server.get_access_token() - assert result == "new-token" + result = server.get_access_token() + assert result == "new-token" class TestMCPToolFunctions: # pylint: disable=too-many-public-methods @@ -264,9 +224,9 @@ async def test_cluster_info_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.cluster_info(cluster_id) + result = await server.cluster_info.run({"cluster_id": cluster_id}) - assert result == cluster.to_str() + assert cast(TextContent, result.content[0]).text == cluster.to_str() mock_inventory_client.get_cluster.assert_called_once_with( cluster_id=cluster_id ) @@ -297,10 +257,10 @@ async def test_list_clusters_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.list_clusters() + result = await server.list_clusters.run({}) expected_result = json.dumps(mock_clusters) - assert result == expected_result + assert cast(TextContent, result.content[0]).text == expected_result mock_inventory_client.list_clusters.assert_called_once() @pytest.mark.asyncio @@ -317,9 +277,9 @@ async def test_cluster_events_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.cluster_events(cluster_id) + result = await server.cluster_events.run({"cluster_id": cluster_id}) - assert result == mock_events + assert cast(TextContent, result.content[0]).text == mock_events mock_inventory_client.get_events.assert_called_once_with( cluster_id=cluster_id ) @@ -339,9 +299,11 @@ async def test_host_events_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.host_events(cluster_id, host_id) + result = await server.host_events.run( + {"cluster_id": cluster_id, "host_id": host_id} + ) - assert result == mock_events + assert cast(TextContent, result.content[0]).text == mock_events mock_inventory_client.get_events.assert_called_once_with( cluster_id=cluster_id, host_id=host_id ) @@ -369,7 +331,9 @@ async def test_cluster_iso_download_url_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.cluster_iso_download_url(cluster_id) + result = await server.cluster_iso_download_url.run( + {"cluster_id": cluster_id} + ) expected_result = json.dumps( [ @@ -379,7 +343,7 @@ async def test_cluster_iso_download_url_success( } ] ) - assert result == expected_result + assert cast(TextContent, result.content[0]).text == expected_result mock_inventory_client.list_infra_envs.assert_called_once_with(cluster_id) mock_inventory_client.get_infra_env_download_url.assert_called_once_with( "test-infraenv-id" @@ -430,7 +394,9 @@ async def test_cluster_iso_download_url_multiple_infraenvs( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.cluster_iso_download_url(cluster_id) + result = await server.cluster_iso_download_url.run( + {"cluster_id": cluster_id} + ) expected_result = json.dumps( [ @@ -444,7 +410,7 @@ async def test_cluster_iso_download_url_multiple_infraenvs( }, ] ) - assert result == expected_result + assert cast(TextContent, result.content[0]).text == expected_result mock_inventory_client.list_infra_envs.assert_called_once_with(cluster_id) mock_inventory_client.get_infra_env_download_url.assert_has_calls( [ @@ -476,7 +442,9 @@ async def test_cluster_iso_download_url_no_expiration( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.cluster_iso_download_url(cluster_id) + result = await server.cluster_iso_download_url.run( + {"cluster_id": cluster_id} + ) expected_result = json.dumps( [ @@ -485,7 +453,7 @@ async def test_cluster_iso_download_url_no_expiration( } ] ) - assert result == expected_result + assert cast(TextContent, result.content[0]).text == expected_result mock_inventory_client.list_infra_envs.assert_called_once_with(cluster_id) mock_inventory_client.get_infra_env_download_url.assert_called_once_with( "test-infraenv-id" @@ -514,7 +482,9 @@ async def test_cluster_iso_download_url_zero_expiration( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.cluster_iso_download_url(cluster_id) + result = await server.cluster_iso_download_url.run( + {"cluster_id": cluster_id} + ) # Should not include expiration time since it's a zero/default value expected_result = json.dumps( @@ -524,7 +494,7 @@ async def test_cluster_iso_download_url_zero_expiration( } ] ) - assert result == expected_result + assert cast(TextContent, result.content[0]).text == expected_result mock_inventory_client.list_infra_envs.assert_called_once_with(cluster_id) mock_inventory_client.get_infra_env_download_url.assert_called_once_with( "test-infraenv-id" @@ -543,9 +513,14 @@ async def test_cluster_iso_download_url_no_infraenvs( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.cluster_iso_download_url(cluster_id) + result = await server.cluster_iso_download_url.run( + {"cluster_id": cluster_id} + ) - assert result == "No ISO download URLs found for this cluster." + assert ( + cast(TextContent, result.content[0]).text + == "No ISO download URLs found for this cluster." + ) mock_inventory_client.list_infra_envs.assert_called_once_with(cluster_id) @pytest.mark.asyncio @@ -576,10 +551,15 @@ async def test_create_cluster_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.create_cluster( - name, version, base_domain, single_node + result = await server.create_cluster.run( + { + "name": name, + "version": version, + "base_domain": base_domain, + "single_node": single_node, + } ) - assert result == cluster.id + assert cast(TextContent, result.content[0]).text == cluster.id mock_inventory_client.create_cluster.assert_called_once_with( name, @@ -625,10 +605,16 @@ async def test_create_cluster_with_ssh_key_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.create_cluster( - name, version, base_domain, single_node, ssh_public_key + result = await server.create_cluster.run( + { + "name": name, + "version": version, + "base_domain": base_domain, + "single_node": single_node, + "ssh_public_key": ssh_public_key, + } ) - assert result == cluster.id + assert cast(TextContent, result.content[0]).text == cluster.id mock_inventory_client.create_cluster.assert_called_once_with( name, @@ -676,10 +662,17 @@ async def test_create_cluster_with_cpu_architecture_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.create_cluster( - name, version, base_domain, single_node, None, cpu_architecture + result = await server.create_cluster.run( + { + "name": name, + "version": version, + "base_domain": base_domain, + "single_node": single_node, + "ssh_public_key": None, + "cpu_architecture": cpu_architecture, + } ) - assert result == cluster.id + assert cast(TextContent, result.content[0]).text == cluster.id mock_inventory_client.create_cluster.assert_called_once_with( name, @@ -713,9 +706,15 @@ async def test_set_cluster_vips_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.set_cluster_vips(cluster_id, api_vip, ingress_vip) + result = await server.set_cluster_vips.run( + { + "cluster_id": cluster_id, + "api_vip": api_vip, + "ingress_vip": ingress_vip, + } + ) - assert result == cluster.to_str() + assert cast(TextContent, result.content[0]).text == cluster.to_str() mock_inventory_client.update_cluster.assert_called_once_with( cluster_id, api_vip=api_vip, ingress_vip=ingress_vip ) @@ -734,9 +733,9 @@ async def test_install_cluster_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.install_cluster(cluster_id) + result = await server.install_cluster.run({"cluster_id": cluster_id}) - assert result == cluster.to_str() + assert cast(TextContent, result.content[0]).text == cluster.to_str() mock_inventory_client.install_cluster.assert_called_once_with(cluster_id) @pytest.mark.asyncio @@ -752,10 +751,10 @@ async def test_list_versions_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.list_versions() + result = await server.list_versions.run({}) expected_result = json.dumps(mock_versions) - assert result == expected_result + assert cast(TextContent, result.content[0]).text == expected_result mock_inventory_client.get_openshift_versions.assert_called_once_with(True) @pytest.mark.asyncio @@ -774,10 +773,10 @@ async def test_list_operator_bundles_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.list_operator_bundles() + result = await server.list_operator_bundles.run({}) expected_result = json.dumps(mock_bundles) - assert result == expected_result + assert cast(TextContent, result.content[0]).text == expected_result mock_inventory_client.get_operator_bundles.assert_called_once() @pytest.mark.asyncio @@ -796,11 +795,11 @@ async def test_add_operator_bundle_to_cluster_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.add_operator_bundle_to_cluster( - cluster_id, bundle_name + result = await server.add_operator_bundle_to_cluster.run( + {"cluster_id": cluster_id, "bundle_name": bundle_name} ) - assert result == cluster.to_str() + assert cast(TextContent, result.content[0]).text == cluster.to_str() mock_inventory_client.add_operator_bundle_to_cluster.assert_called_once_with( cluster_id, bundle_name ) @@ -828,9 +827,11 @@ async def test_set_host_role_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.set_host_role(host_id, infraenv_id, role) + result = await server.set_host_role.run( + {"host_id": host_id, "cluster_id": infraenv_id, "role": role} + ) - assert result == host.to_str() + assert cast(TextContent, result.content[0]).text == host.to_str() mock_inventory_client.update_host.assert_called_once_with( host_id, infraenv_id, host_role=role ) @@ -853,8 +854,8 @@ async def test_cluster_credentials_download_url_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.cluster_credentials_download_url( - cluster_id, file_name + result = await server.cluster_credentials_download_url.run( + {"cluster_id": cluster_id, "file_name": file_name} ) expected_result = json.dumps( @@ -863,7 +864,7 @@ async def test_cluster_credentials_download_url_success( "expires_at": "2023-12-31T23:59:59Z", } ) - assert result == expected_result + assert cast(TextContent, result.content[0]).text == expected_result mock_inventory_client.get_presigned_for_cluster_credentials.assert_called_once_with( cluster_id, file_name ) @@ -886,12 +887,12 @@ async def test_cluster_credentials_download_url_no_expiration( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.cluster_credentials_download_url( - cluster_id, file_name + result = await server.cluster_credentials_download_url.run( + {"cluster_id": cluster_id, "file_name": file_name} ) expected_result = json.dumps({"url": "https://example.com/presigned-url"}) - assert result == expected_result + assert cast(TextContent, result.content[0]).text == expected_result mock_inventory_client.get_presigned_for_cluster_credentials.assert_called_once_with( cluster_id, file_name ) @@ -916,13 +917,13 @@ async def test_cluster_credentials_download_url_zero_expiration( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.cluster_credentials_download_url( - cluster_id, file_name + result = await server.cluster_credentials_download_url.run( + {"cluster_id": cluster_id, "file_name": file_name} ) # Should not include expiration time since it's a zero/default value expected_result = json.dumps({"url": "https://example.com/presigned-url"}) - assert result == expected_result + assert cast(TextContent, result.content[0]).text == expected_result mock_inventory_client.get_presigned_for_cluster_credentials.assert_called_once_with( cluster_id, file_name ) @@ -953,8 +954,10 @@ async def test_set_cluster_ssh_key_success( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.set_cluster_ssh_key(cluster_id, ssh_public_key) - assert result == cluster.to_str() + result = await server.set_cluster_ssh_key.run( + {"cluster_id": cluster_id, "ssh_public_key": ssh_public_key} + ) + assert cast(TextContent, result.content[0]).text == cluster.to_str() # Verify all expected calls were made mock_inventory_client.update_cluster.assert_called_once_with( @@ -991,10 +994,15 @@ async def test_set_cluster_ssh_key_infraenv_failure( with patch.object( server, "InventoryClient", return_value=mock_inventory_client ): - result = await server.set_cluster_ssh_key(cluster_id, ssh_public_key) + result = await server.set_cluster_ssh_key.run( + {"cluster_id": cluster_id, "ssh_public_key": ssh_public_key} + ) # Should return partial failure message - assert "Cluster key updated, but boot image key update failed" in result - assert cluster.to_str() in result + assert ( + "Cluster key updated, but boot image key update failed" + in cast(TextContent, result.content[0]).text + ) + assert cluster.to_str() in cast(TextContent, result.content[0]).text # Verify all expected calls were made mock_inventory_client.update_cluster.assert_called_once_with( diff --git a/uv.lock b/uv.lock index ce0c621..fa1aa95 100644 --- a/uv.lock +++ b/uv.lock @@ -134,7 +134,7 @@ test = [ [package.metadata] requires-dist = [ { name = "assisted-service-client", specifier = ">=2.41.0.post3" }, - { name = "fastmcp", specifier = ">=2.8.0" }, + { name = "fastmcp", specifier = ">=2.12.3" }, { name = "jinja2", specifier = ">=3.1" }, { name = "netaddr", specifier = ">=1.3.0" }, { name = "prometheus-client", specifier = ">=0.22.1" }, @@ -482,11 +482,11 @@ wheels = [ [[package]] name = "docutils" -version = "0.22.1" +version = "0.22.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/d869000fb74438584858acc628a364b277fc012695f0dfd513cb10f99768/docutils-0.22.1.tar.gz", hash = "sha256:d2fb50923a313532b6d41a77776d24cb459a594be9b7e4afa1fbcb5bda1893e6", size = 2291655, upload-time = "2025-09-17T17:58:45.409Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/c0/89fe6215b443b919cb98a5002e107cb5026854ed1ccb6b5833e0768419d1/docutils-0.22.2.tar.gz", hash = "sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", size = 2289092, upload-time = "2025-09-20T17:55:47.994Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/dc/1948b90c5d9dbfa4d1fd3991013a042ba3ac62ebd3afdcb3fac08366e755/docutils-0.22.1-py3-none-any.whl", hash = "sha256:806e896f256a17466426544038f30cb860a99f5d4af640e36c284bfcb1824512", size = 638455, upload-time = "2025-09-17T17:58:42.498Z" }, + { url = "https://files.pythonhosted.org/packages/66/dd/f95350e853a4468ec37478414fc04ae2d61dad7a947b3015c3dcc51a09b9/docutils-0.22.2-py3-none-any.whl", hash = "sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8", size = 632667, upload-time = "2025-09-20T17:55:43.052Z" }, ] [[package]]