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:
Eric Gustin 2025-11-07 10:26:56 -08:00 committed by GitHub
parent 57f2608dbe
commit c15c07e12f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 304 additions and 2 deletions

View file

@ -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."""

View file

@ -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"}

View 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