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
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal, Protocol
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
@ -29,6 +29,75 @@ from arcade_core.errors import ErrorKind
|
||||||
TOOL_NAME_SEPARATOR = os.getenv("ARCADE_TOOL_NAME_SEPARATOR", ".")
|
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):
|
class ValueSchema(BaseModel):
|
||||||
"""Value schema for input parameters and outputs."""
|
"""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.")
|
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):
|
class ToolCallRequest(BaseModel):
|
||||||
"""The request to call (invoke) a tool."""
|
"""The request to call (invoke) a tool."""
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "arcade-core"
|
name = "arcade-core"
|
||||||
version = "3.3.3"
|
version = "3.3.4"
|
||||||
description = "Arcade Core - Core library for Arcade platform"
|
description = "Arcade Core - Core library for Arcade platform"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {text = "MIT"}
|
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