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

View file

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

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