diff --git a/libs/arcade-core/arcade_core/schema.py b/libs/arcade-core/arcade_core/schema.py index ea6e79f2..97c62658 100644 --- a/libs/arcade-core/arcade_core/schema.py +++ b/libs/arcade-core/arcade_core/schema.py @@ -19,7 +19,7 @@ from __future__ import annotations import os from dataclasses import dataclass from enum import Enum -from typing import Any, Literal +from typing import Any, Literal, Protocol from pydantic import BaseModel, Field @@ -29,6 +29,75 @@ from arcade_core.errors import ErrorKind TOOL_NAME_SEPARATOR = os.getenv("ARCADE_TOOL_NAME_SEPARATOR", ".") +# ===================== +# MCP Feature Protocols and No-Op Implementations +# ===================== +# These protocols and stubs enable graceful degradation of MCP features +# in deployed (non-local) environments where the full MCP context is not available. + + +class LogsProtocol(Protocol): + """Protocol for logging interface.""" + + async def log( + self, + level: str, + message: str, + logger_name: str | None = None, + extra: dict[str, Any] | None = None, + ) -> None: ... + + async def debug(self, message: str, **kwargs: Any) -> None: ... + + async def info(self, message: str, **kwargs: Any) -> None: ... + + async def warning(self, message: str, **kwargs: Any) -> None: ... + + async def error(self, message: str, **kwargs: Any) -> None: ... + + +class ProgressProtocol(Protocol): + """Protocol for progress reporting interface.""" + + async def report( + self, progress: float, total: float | None = None, message: str | None = None + ) -> None: ... + + +class _NoOpLogs: + """No-op implementation for logging in deployed environments.""" + + async def log( + self, + level: str, + message: str, + logger_name: str | None = None, + extra: dict[str, Any] | None = None, + ) -> None: + pass + + async def debug(self, message: str, **kwargs: Any) -> None: + pass + + async def info(self, message: str, **kwargs: Any) -> None: + pass + + async def warning(self, message: str, **kwargs: Any) -> None: + pass + + async def error(self, message: str, **kwargs: Any) -> None: + pass + + +class _NoOpProgress: + """No-op implementation for progress in deployed environments.""" + + async def report( + self, progress: float, total: float | None = None, message: str | None = None + ) -> None: + pass + + class ValueSchema(BaseModel): """Value schema for input parameters and outputs.""" @@ -390,6 +459,75 @@ class ToolContext(BaseModel): raise ValueError(f"{item_name.capitalize()} '{key}' not found in context.") + # ============ MCP Feature Properties ============ + # Non-critical features (no-op in deployed environments) + + @property + def log(self) -> LogsProtocol: + """No-op logging interface (not supported in deployed environments).""" + return _NoOpLogs() + + @property + def progress(self) -> ProgressProtocol: + """No-op progress reporting (not supported in deployed environments).""" + return _NoOpProgress() + + # Critical features (raise error in deployed environments) + + @property + def resources(self) -> Any: + """Resources are not available in deployed environments.""" + raise RuntimeError( + "The resources feature is not supported for Arcade managed servers (non-local)" + ) + + @property + def tools(self) -> Any: + """Tool calling is not available in deployed environments.""" + raise RuntimeError( + "The tools feature is not supported for Arcade managed servers (non-local)" + ) + + @property + def prompts(self) -> Any: + """Prompts are not available in deployed environments.""" + raise RuntimeError( + "The prompts feature is not supported for Arcade managed servers (non-local)" + ) + + @property + def sampling(self) -> Any: + """Sampling is not available in deployed environments.""" + raise RuntimeError( + "The sampling feature is not supported for Arcade managed servers (non-local)" + ) + + @property + def ui(self) -> Any: + """UI/elicitation is not available in deployed environments.""" + raise RuntimeError("The ui feature is not supported for Arcade managed servers (non-local)") + + @property + def notifications(self) -> Any: + """Notifications are not available in deployed environments.""" + raise RuntimeError( + "The notifications feature is not supported for Arcade managed servers (non-local)" + ) + + @property + def request_id(self) -> Any: + """Request ID is not available in deployed environments.""" + raise RuntimeError( + "The request_id feature is not supported for Arcade managed servers (non-local)" + ) + + @property + def session_id(self) -> Any: + """Session ID is not available in deployed environments.""" + raise RuntimeError( + "The session_id feature is not supported for Arcade managed servers (non-local)" + ) + class ToolCallRequest(BaseModel): """The request to call (invoke) a tool.""" diff --git a/libs/arcade-core/pyproject.toml b/libs/arcade-core/pyproject.toml index b8db000b..2ca26c89 100644 --- a/libs/arcade-core/pyproject.toml +++ b/libs/arcade-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-core" -version = "3.3.3" +version = "3.3.4" description = "Arcade Core - Core library for Arcade platform" readme = "README.md" license = {text = "MIT"} diff --git a/libs/tests/core/test_schema_mcp_degradation.py b/libs/tests/core/test_schema_mcp_degradation.py new file mode 100644 index 00000000..f4d621af --- /dev/null +++ b/libs/tests/core/test_schema_mcp_degradation.py @@ -0,0 +1,164 @@ +""" +Tests for MCP feature graceful degradation in ToolContext. + +This module tests that: +1. Non-critical MCP features (log, progress) silently no-op in deployed environments +2. Critical MCP features (resources, tools, etc.) raise informative errors +""" + +import pytest +from arcade_core.schema import ToolContext + + +# ===================== +# Non-Critical Features (No-Op Tests) +# ===================== + + +@pytest.mark.asyncio +async def test_log_debug_no_op(): + """Test that context.log.debug() executes without error.""" + context = ToolContext() + # Should not raise any exception + await context.log.debug("test message") + + +@pytest.mark.asyncio +async def test_log_info_no_op(): + """Test that context.log.info() executes without error.""" + context = ToolContext() + # Should not raise any exception + await context.log.info("test message") + + +@pytest.mark.asyncio +async def test_log_warning_no_op(): + """Test that context.log.warning() executes without error.""" + context = ToolContext() + # Should not raise any exception + await context.log.warning("test message") + + +@pytest.mark.asyncio +async def test_log_error_no_op(): + """Test that context.log.error() executes without error.""" + context = ToolContext() + # Should not raise any exception + await context.log.error("test message") + + +@pytest.mark.asyncio +async def test_log_log_no_op(): + """Test that context.log.log() executes without error.""" + context = ToolContext() + # Should not raise any exception + await context.log.log("info", "test message") + + +@pytest.mark.asyncio +async def test_log_with_extra_kwargs_no_op(): + """Test that context.log methods with extra kwargs execute without error.""" + context = ToolContext() + # Should not raise any exception + await context.log.info("test message", logger_name="test_logger", extra={"key": "value"}) + + +@pytest.mark.asyncio +async def test_progress_report_no_op(): + """Test that context.progress.report() executes without error.""" + context = ToolContext() + # Should not raise any exception + await context.progress.report(0.5, total=1.0, message="Halfway done") + + +@pytest.mark.asyncio +async def test_progress_report_minimal_no_op(): + """Test that context.progress.report() with minimal params executes without error.""" + context = ToolContext() + # Should not raise any exception + await context.progress.report(0.5) + + +# ===================== +# Critical Features (Error Tests) +# ===================== + + +def test_resources_raises_error(): + """Test that accessing context.resources raises RuntimeError.""" + context = ToolContext() + with pytest.raises( + RuntimeError, + match="The resources feature is not supported for Arcade managed servers \\(non-local\\)", + ): + _ = context.resources + + +def test_tools_raises_error(): + """Test that accessing context.tools raises RuntimeError.""" + context = ToolContext() + with pytest.raises( + RuntimeError, + match="The tools feature is not supported for Arcade managed servers \\(non-local\\)", + ): + _ = context.tools + + +def test_prompts_raises_error(): + """Test that accessing context.prompts raises RuntimeError.""" + context = ToolContext() + with pytest.raises( + RuntimeError, + match="The prompts feature is not supported for Arcade managed servers \\(non-local\\)", + ): + _ = context.prompts + + +def test_sampling_raises_error(): + """Test that accessing context.sampling raises RuntimeError.""" + context = ToolContext() + with pytest.raises( + RuntimeError, + match="The sampling feature is not supported for Arcade managed servers \\(non-local\\)", + ): + _ = context.sampling + + +def test_ui_raises_error(): + """Test that accessing context.ui raises RuntimeError.""" + context = ToolContext() + with pytest.raises( + RuntimeError, + match="The ui feature is not supported for Arcade managed servers \\(non-local\\)", + ): + _ = context.ui + + +def test_notifications_raises_error(): + """Test that accessing context.notifications raises RuntimeError.""" + context = ToolContext() + with pytest.raises( + RuntimeError, + match="The notifications feature is not supported for Arcade managed servers \\(non-local\\)", + ): + _ = context.notifications + + +def test_request_id_raises_error(): + """Test that accessing context.request_id raises RuntimeError.""" + context = ToolContext() + with pytest.raises( + RuntimeError, + match="The request_id feature is not supported for Arcade managed servers \\(non-local\\)", + ): + _ = context.request_id + + +def test_session_id_raises_error(): + """Test that accessing context.session_id raises RuntimeError.""" + context = ToolContext() + with pytest.raises( + RuntimeError, + match="The session_id feature is not supported for Arcade managed servers \\(non-local\\)", + ): + _ = context.session_id