Better Handling of MCP-specific Context usage for managed servers (#679)
Since servers managed by Arcade use the `/worker` routes under the hood, tools that use MCP-specific properties of `Context` will fail. This PR helps reduce the 'blast radius' of the above fact. For properties that were deemed 'non-critical' to the execution of a deployed tool, we simply no-op. For properties that were deemed 'critical' to the execution of a deployed tool, we raise an error that informs the caller that the feature is not supported for Arcade managed servers. - Non-critical property: A context property that returns None - Critical property: A context property that may return something that could be necessary for a tool execution to succeed.
This commit is contained in:
parent
57f2608dbe
commit
c15c07e12f
3 changed files with 304 additions and 2 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
164
libs/tests/core/test_schema_mcp_degradation.py
Normal file
164
libs/tests/core/test_schema_mcp_degradation.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue