Add startup warnings for missing secrets (#712)
Add startup warnings for missing tool secrets to provide faster feedback on configuration issues. --- Linear Issue: [TOO-198](https://linear.app/arcadedev/issue/TOO-198/add-startup-warnings-for-missing-tool-secrets) <a href="https://cursor.com/background-agent?bcId=bc-203d1b6a-80a7-4933-b3ff-b3a9220b5809"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-cursor-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-cursor-light.svg"><img alt="Open in Cursor" src="https://cursor.com/open-in-cursor.svg"></picture></a> <a href="https://cursor.com/agents?id=bc-203d1b6a-80a7-4933-b3ff-b3a9220b5809"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-web-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-web-light.svg"><img alt="Open in Web" src="https://cursor.com/open-in-web.svg"></picture></a> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Eric Gustin <eric@arcade.dev>
This commit is contained in:
parent
f887877a8f
commit
65acf41b11
6 changed files with 438 additions and 23 deletions
|
|
@ -20,7 +20,6 @@ Usage:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -32,23 +31,11 @@ from arcade_core.toolkit import ToolkitLoadError
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from arcade_mcp_server.logging_utils import intercept_standard_logging
|
||||||
from arcade_mcp_server.server import MCPServer
|
from arcade_mcp_server.server import MCPServer
|
||||||
from arcade_mcp_server.settings import MCPSettings
|
from arcade_mcp_server.settings import MCPSettings
|
||||||
|
|
||||||
|
|
||||||
# Logging setup with Loguru
|
|
||||||
class LoguruInterceptHandler(logging.Handler):
|
|
||||||
"""Intercept standard logging and route to Loguru."""
|
|
||||||
|
|
||||||
def emit(self, record: logging.LogRecord) -> None:
|
|
||||||
try:
|
|
||||||
level = logger.level(record.levelname).name
|
|
||||||
except ValueError:
|
|
||||||
level = str(record.levelno)
|
|
||||||
|
|
||||||
logger.opt(exception=record.exc_info).log(level, record.getMessage())
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(level: str = "INFO", stdio_mode: bool = False) -> None:
|
def setup_logging(level: str = "INFO", stdio_mode: bool = False) -> None:
|
||||||
"""Configure logging with Loguru."""
|
"""Configure logging with Loguru."""
|
||||||
# Remove existing handlers
|
# Remove existing handlers
|
||||||
|
|
@ -74,7 +61,7 @@ def setup_logging(level: str = "INFO", stdio_mode: bool = False) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Intercept standard logging
|
# Intercept standard logging
|
||||||
logging.basicConfig(handlers=[LoguruInterceptHandler()], level=0, force=True)
|
intercept_standard_logging()
|
||||||
|
|
||||||
|
|
||||||
def initialize_tool_catalog(
|
def initialize_tool_catalog(
|
||||||
|
|
|
||||||
30
libs/arcade-mcp-server/arcade_mcp_server/logging_utils.py
Normal file
30
libs/arcade-mcp-server/arcade_mcp_server/logging_utils.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""Shared logging utilities for MCP server."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class LoguruInterceptHandler(logging.Handler):
|
||||||
|
"""Intercept standard logging and route to Loguru.
|
||||||
|
|
||||||
|
This handler bridges the standard Python logging module with Loguru,
|
||||||
|
ensuring all logs (from both systems) use the same formatting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
try:
|
||||||
|
level = logger.level(record.levelname).name
|
||||||
|
except ValueError:
|
||||||
|
level = str(record.levelno)
|
||||||
|
|
||||||
|
logger.opt(exception=record.exc_info).log(level, record.getMessage())
|
||||||
|
|
||||||
|
|
||||||
|
def intercept_standard_logging() -> None:
|
||||||
|
"""Configure standard logging to route through Loguru.
|
||||||
|
|
||||||
|
This should be called after Loguru is configured to ensure all
|
||||||
|
standard logging calls are intercepted and formatted consistently.
|
||||||
|
"""
|
||||||
|
logging.basicConfig(handlers=[LoguruInterceptHandler()], level=0, force=True)
|
||||||
|
|
@ -24,6 +24,7 @@ from loguru import logger
|
||||||
from watchfiles import watch
|
from watchfiles import watch
|
||||||
|
|
||||||
from arcade_mcp_server.exceptions import ServerError
|
from arcade_mcp_server.exceptions import ServerError
|
||||||
|
from arcade_mcp_server.logging_utils import intercept_standard_logging
|
||||||
from arcade_mcp_server.server import MCPServer
|
from arcade_mcp_server.server import MCPServer
|
||||||
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
||||||
from arcade_mcp_server.types import Prompt, PromptMessage, Resource
|
from arcade_mcp_server.types import Prompt, PromptMessage, Resource
|
||||||
|
|
@ -217,6 +218,9 @@ class MCPApp:
|
||||||
diagnose=(self.log_level == "DEBUG"),
|
diagnose=(self.log_level == "DEBUG"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Intercept standard logging and route through Loguru
|
||||||
|
intercept_standard_logging()
|
||||||
|
|
||||||
def add_tool(
|
def add_tool(
|
||||||
self,
|
self,
|
||||||
func: Callable[P, T],
|
func: Callable[P, T],
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,10 @@ class MCPServer:
|
||||||
await self._tool_manager.load_from_catalog(self._initial_catalog)
|
await self._tool_manager.load_from_catalog(self._initial_catalog)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to load tools from initial catalog")
|
logger.exception("Failed to load tools from initial catalog")
|
||||||
|
|
||||||
|
# Check for missing secrets and log warnings (only when worker routes are disabled)
|
||||||
|
await self._check_and_warn_missing_secrets()
|
||||||
|
|
||||||
await self._resource_manager.start()
|
await self._resource_manager.start()
|
||||||
await self._prompt_manager.start()
|
await self._prompt_manager.start()
|
||||||
await self.lifespan_manager.startup()
|
await self.lifespan_manager.startup()
|
||||||
|
|
@ -654,6 +658,40 @@ class MCPServer:
|
||||||
|
|
||||||
return tool_context
|
return tool_context
|
||||||
|
|
||||||
|
async def _check_and_warn_missing_secrets(self) -> None:
|
||||||
|
"""
|
||||||
|
Check for missing tool secrets and log warnings.
|
||||||
|
|
||||||
|
This method is called during server startup to provide early feedback
|
||||||
|
about missing configuration. It only runs when worker routes are disabled
|
||||||
|
(when ARCADE_WORKER_SECRET is not set), as worker routes receive secrets
|
||||||
|
with tool execution information.
|
||||||
|
"""
|
||||||
|
# Skip validation if worker routes are enabled
|
||||||
|
if self.settings.arcade.server_secret:
|
||||||
|
logger.debug("Skipping secret validation check - worker routes are enabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get all available secrets from settings and environment
|
||||||
|
available_secrets = set(self.settings.tool_secrets().keys()) | set(os.environ.keys())
|
||||||
|
|
||||||
|
# Check each tool for missing secrets
|
||||||
|
managed_tools = await self._tool_manager.registry.list()
|
||||||
|
for managed_tool in managed_tools:
|
||||||
|
tool = managed_tool["materialized"]
|
||||||
|
if tool.definition.requirements and tool.definition.requirements.secrets:
|
||||||
|
missing_secrets = []
|
||||||
|
for secret_requirement in tool.definition.requirements.secrets:
|
||||||
|
if secret_requirement.key not in available_secrets:
|
||||||
|
missing_secrets.append(secret_requirement.key)
|
||||||
|
|
||||||
|
if missing_secrets:
|
||||||
|
secret_list = "', '".join(missing_secrets)
|
||||||
|
tool_name = tool.definition.name
|
||||||
|
logger.warning(
|
||||||
|
f"Tool '{tool_name}' declares secret(s) '{secret_list}' which is/are not set. It will return an error if called."
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_call_tool(
|
async def _handle_call_tool(
|
||||||
self,
|
self,
|
||||||
message: CallToolRequest,
|
message: CallToolRequest,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "arcade-mcp-server"
|
name = "arcade-mcp-server"
|
||||||
version = "1.10.3"
|
version = "1.11.0"
|
||||||
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Arcade.dev" }]
|
authors = [{ name = "Arcade.dev" }]
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
from unittest.mock import AsyncMock, Mock
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from arcade_core.auth import OAuth2
|
||||||
from arcade_core.catalog import MaterializedTool, ToolMeta, create_func_models
|
from arcade_core.catalog import MaterializedTool, ToolMeta, create_func_models
|
||||||
from arcade_core.errors import ToolRuntimeError
|
from arcade_core.errors import ToolRuntimeError
|
||||||
from arcade_core.schema import (
|
from arcade_core.schema import (
|
||||||
|
|
@ -20,7 +22,7 @@ from arcade_core.schema import (
|
||||||
ToolSecretRequirement,
|
ToolSecretRequirement,
|
||||||
ValueSchema,
|
ValueSchema,
|
||||||
)
|
)
|
||||||
from arcade_core.auth import OAuth2
|
from arcade_mcp_server import tool
|
||||||
from arcade_mcp_server.middleware import Middleware
|
from arcade_mcp_server.middleware import Middleware
|
||||||
from arcade_mcp_server.server import MCPServer
|
from arcade_mcp_server.server import MCPServer
|
||||||
from arcade_mcp_server.session import InitializationState
|
from arcade_mcp_server.session import InitializationState
|
||||||
|
|
@ -35,7 +37,6 @@ from arcade_mcp_server.types import (
|
||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
PingRequest,
|
PingRequest,
|
||||||
)
|
)
|
||||||
from arcade_mcp_server import tool
|
|
||||||
|
|
||||||
|
|
||||||
class TestMCPServer:
|
class TestMCPServer:
|
||||||
|
|
@ -998,7 +999,7 @@ class TestMCPServer:
|
||||||
@tool(requires_secrets=["SECRET_KEY"])
|
@tool(requires_secrets=["SECRET_KEY"])
|
||||||
def secret_tool_func(text: Annotated[str, "Input text"]) -> Annotated[str, "Secret text"]:
|
def secret_tool_func(text: Annotated[str, "Input text"]) -> Annotated[str, "Secret text"]:
|
||||||
"""Secret tool function"""
|
"""Secret tool function"""
|
||||||
return f"Secret"
|
return "Secret"
|
||||||
|
|
||||||
input_model, output_model = create_func_models(secret_tool_func)
|
input_model, output_model = create_func_models(secret_tool_func)
|
||||||
meta = ToolMeta(module=secret_tool_func.__module__, toolkit="TestToolkit")
|
meta = ToolMeta(module=secret_tool_func.__module__, toolkit="TestToolkit")
|
||||||
|
|
@ -1068,8 +1069,13 @@ class TestMCPServer:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@tool(requires_auth=OAuth2(id="test-provider", scopes=["test.scope"]), requires_secrets=["API_KEY"])
|
@tool(
|
||||||
def combined_tool_func(text: Annotated[str, "Input text"]) -> Annotated[str, "Combined text"]:
|
requires_auth=OAuth2(id="test-provider", scopes=["test.scope"]),
|
||||||
|
requires_secrets=["API_KEY"],
|
||||||
|
)
|
||||||
|
def combined_tool_func(
|
||||||
|
text: Annotated[str, "Input text"],
|
||||||
|
) -> Annotated[str, "Combined text"]:
|
||||||
"""Combined tool function"""
|
"""Combined tool function"""
|
||||||
return f"Combined: {text}"
|
return f"Combined: {text}"
|
||||||
|
|
||||||
|
|
@ -1103,7 +1109,8 @@ class TestMCPServer:
|
||||||
assert response.result.isError is True
|
assert response.result.isError is True
|
||||||
assert "HTTP transport" in response.result.structuredContent["message"]
|
assert "HTTP transport" in response.result.structuredContent["message"]
|
||||||
assert (
|
assert (
|
||||||
"authorization or access to sensitive secrets" in response.result.structuredContent["message"]
|
"authorization or access to sensitive secrets"
|
||||||
|
in response.result.structuredContent["message"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -1199,3 +1206,352 @@ class TestMCPServer:
|
||||||
assert isinstance(response, JSONRPCResponse)
|
assert isinstance(response, JSONRPCResponse)
|
||||||
assert isinstance(response.result, CallToolResult)
|
assert isinstance(response.result, CallToolResult)
|
||||||
assert response.result.isError is False
|
assert response.result.isError is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestMissingSecretsWarnings:
|
||||||
|
"""Test startup warnings for missing tool secrets."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_warns_missing_secrets_on_startup(self, tool_catalog, mcp_settings, caplog):
|
||||||
|
"""Test that missing secrets trigger warnings during server startup."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Create tool definition with secret requirements
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="fetch_data",
|
||||||
|
fully_qualified_name="TestToolkit.fetch_data",
|
||||||
|
description="Fetch data from API.",
|
||||||
|
toolkit=ToolkitDefinition(
|
||||||
|
name="TestToolkit", description="Test toolkit", version="1.0.0"
|
||||||
|
),
|
||||||
|
input=ToolInput(
|
||||||
|
parameters=[
|
||||||
|
InputParameter(
|
||||||
|
name="query",
|
||||||
|
required=True,
|
||||||
|
description="Search query",
|
||||||
|
value_schema=ValueSchema(val_type="string"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output=ToolOutput(description="Result", value_schema=ValueSchema(val_type="string")),
|
||||||
|
requirements=ToolRequirements(
|
||||||
|
secrets=[
|
||||||
|
ToolSecretRequirement(key="API_KEY", description="API Key"),
|
||||||
|
ToolSecretRequirement(key="SECRET_TOKEN", description="Secret Token"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def fetch_data(query: Annotated[str, "Search query"]) -> Annotated[str, "Result"]:
|
||||||
|
"""Fetch data from API."""
|
||||||
|
return f"Data for {query}"
|
||||||
|
|
||||||
|
# Add tool to catalog
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(fetch_data)
|
||||||
|
meta = ToolMeta(module=fetch_data.__module__, toolkit="TestToolkit")
|
||||||
|
materialized = MaterializedTool(
|
||||||
|
tool=fetch_data,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
tool_catalog._tools[tool_def.get_fully_qualified_name()] = materialized
|
||||||
|
|
||||||
|
# Clear any existing secrets from environment
|
||||||
|
import os
|
||||||
|
|
||||||
|
old_api_key = os.environ.pop("API_KEY", None)
|
||||||
|
old_secret_token = os.environ.pop("SECRET_TOKEN", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure worker routes are disabled (no ARCADE_WORKER_SECRET)
|
||||||
|
mcp_settings.arcade.server_secret = None
|
||||||
|
|
||||||
|
# Create and start server
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
server = MCPServer(
|
||||||
|
catalog=tool_catalog,
|
||||||
|
name="Test Server",
|
||||||
|
version="1.0.0",
|
||||||
|
settings=mcp_settings,
|
||||||
|
)
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
# Check for warning message
|
||||||
|
warning_messages = [
|
||||||
|
rec.message for rec in caplog.records if rec.levelno == logging.WARNING
|
||||||
|
]
|
||||||
|
|
||||||
|
# Should have a warning about missing secrets
|
||||||
|
assert any("fetch_data" in msg and "API_KEY" in msg for msg in warning_messages), (
|
||||||
|
f"Expected warning about missing API_KEY for fetch_data. Got: {warning_messages}"
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
"fetch_data" in msg and "SECRET_TOKEN" in msg for msg in warning_messages
|
||||||
|
), (
|
||||||
|
f"Expected warning about missing SECRET_TOKEN for fetch_data. Got: {warning_messages}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await server.stop()
|
||||||
|
finally:
|
||||||
|
# Restore environment
|
||||||
|
if old_api_key is not None:
|
||||||
|
os.environ["API_KEY"] = old_api_key
|
||||||
|
if old_secret_token is not None:
|
||||||
|
os.environ["SECRET_TOKEN"] = old_secret_token
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_warning_when_secrets_present(self, tool_catalog, mcp_settings, caplog):
|
||||||
|
"""Test that no warnings are shown when secrets are available."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Create tool definition with secret requirements
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="secure_tool",
|
||||||
|
fully_qualified_name="TestToolkit.secure_tool",
|
||||||
|
description="Secure tool.",
|
||||||
|
toolkit=ToolkitDefinition(
|
||||||
|
name="TestToolkit", description="Test toolkit", version="1.0.0"
|
||||||
|
),
|
||||||
|
input=ToolInput(
|
||||||
|
parameters=[
|
||||||
|
InputParameter(
|
||||||
|
name="data",
|
||||||
|
required=True,
|
||||||
|
description="Data",
|
||||||
|
value_schema=ValueSchema(val_type="string"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output=ToolOutput(description="Result", value_schema=ValueSchema(val_type="string")),
|
||||||
|
requirements=ToolRequirements(
|
||||||
|
secrets=[ToolSecretRequirement(key="PRESENT_KEY", description="Present Key")]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def secure_tool(data: Annotated[str, "Data"]) -> Annotated[str, "Result"]:
|
||||||
|
"""Secure tool."""
|
||||||
|
return f"Processed {data}"
|
||||||
|
|
||||||
|
# Add tool to catalog
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(secure_tool)
|
||||||
|
meta = ToolMeta(module=secure_tool.__module__, toolkit="TestToolkit")
|
||||||
|
materialized = MaterializedTool(
|
||||||
|
tool=secure_tool,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
tool_catalog._tools[tool_def.get_fully_qualified_name()] = materialized
|
||||||
|
|
||||||
|
# Set the secret in environment
|
||||||
|
import os
|
||||||
|
|
||||||
|
old_value = os.environ.get("PRESENT_KEY")
|
||||||
|
os.environ["PRESENT_KEY"] = "test-value"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure worker routes are disabled
|
||||||
|
mcp_settings.arcade.server_secret = None
|
||||||
|
|
||||||
|
# Create and start server
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
server = MCPServer(
|
||||||
|
catalog=tool_catalog,
|
||||||
|
name="Test Server",
|
||||||
|
version="1.0.0",
|
||||||
|
settings=mcp_settings,
|
||||||
|
)
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
# Check that no warning is logged for this tool
|
||||||
|
warning_messages = [
|
||||||
|
rec.message for rec in caplog.records if rec.levelno == logging.WARNING
|
||||||
|
]
|
||||||
|
assert not any(
|
||||||
|
"secure_tool" in msg and "PRESENT_KEY" in msg for msg in warning_messages
|
||||||
|
), f"Should not warn about PRESENT_KEY when it's set. Got: {warning_messages}"
|
||||||
|
|
||||||
|
await server.stop()
|
||||||
|
finally:
|
||||||
|
# Restore environment
|
||||||
|
if old_value is not None:
|
||||||
|
os.environ["PRESENT_KEY"] = old_value
|
||||||
|
else:
|
||||||
|
os.environ.pop("PRESENT_KEY", None)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_warning_when_worker_routes_enabled(self, tool_catalog, mcp_settings, caplog):
|
||||||
|
"""Test that warnings are skipped when worker routes are enabled."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Create tool definition with secret requirements
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="worker_tool",
|
||||||
|
fully_qualified_name="TestToolkit.worker_tool",
|
||||||
|
description="Worker tool.",
|
||||||
|
toolkit=ToolkitDefinition(
|
||||||
|
name="TestToolkit", description="Test toolkit", version="1.0.0"
|
||||||
|
),
|
||||||
|
input=ToolInput(
|
||||||
|
parameters=[
|
||||||
|
InputParameter(
|
||||||
|
name="param",
|
||||||
|
required=True,
|
||||||
|
description="Param",
|
||||||
|
value_schema=ValueSchema(val_type="string"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output=ToolOutput(description="Result", value_schema=ValueSchema(val_type="string")),
|
||||||
|
requirements=ToolRequirements(
|
||||||
|
secrets=[ToolSecretRequirement(key="WORKER_API_KEY", description="Worker API Key")]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def worker_tool(param: Annotated[str, "Param"]) -> Annotated[str, "Result"]:
|
||||||
|
"""Worker tool."""
|
||||||
|
return f"Result: {param}"
|
||||||
|
|
||||||
|
# Add tool to catalog
|
||||||
|
|
||||||
|
input_model, output_model = create_func_models(worker_tool)
|
||||||
|
meta = ToolMeta(module=worker_tool.__module__, toolkit="TestToolkit")
|
||||||
|
materialized = MaterializedTool(
|
||||||
|
tool=worker_tool,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
tool_catalog._tools[tool_def.get_fully_qualified_name()] = materialized
|
||||||
|
|
||||||
|
# Clear the secret from environment
|
||||||
|
import os
|
||||||
|
|
||||||
|
old_value = os.environ.pop("WORKER_API_KEY", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Enable worker routes by setting ARCADE_WORKER_SECRET
|
||||||
|
mcp_settings.arcade.server_secret = "test-worker-secret"
|
||||||
|
|
||||||
|
# Create and start server
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
server = MCPServer(
|
||||||
|
catalog=tool_catalog,
|
||||||
|
name="Test Server",
|
||||||
|
version="1.0.0",
|
||||||
|
settings=mcp_settings,
|
||||||
|
)
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
# Check that no warning is logged (worker routes are enabled)
|
||||||
|
warning_messages = [
|
||||||
|
rec.message for rec in caplog.records if rec.levelno == logging.WARNING
|
||||||
|
]
|
||||||
|
assert not any(
|
||||||
|
"worker_tool" in msg and "WORKER_API_KEY" in msg for msg in warning_messages
|
||||||
|
), f"Should not warn when worker routes are enabled. Got: {warning_messages}"
|
||||||
|
|
||||||
|
await server.stop()
|
||||||
|
finally:
|
||||||
|
# Restore environment
|
||||||
|
if old_value is not None:
|
||||||
|
os.environ["WORKER_API_KEY"] = old_value
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_warning_format(self, tool_catalog, mcp_settings, caplog):
|
||||||
|
"""Test that warnings use the expected format."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Create tool definition with secret requirement
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="format_test_tool",
|
||||||
|
fully_qualified_name="TestToolkit.format_test_tool",
|
||||||
|
description="Format test tool.",
|
||||||
|
toolkit=ToolkitDefinition(
|
||||||
|
name="TestToolkit", description="Test toolkit", version="1.0.0"
|
||||||
|
),
|
||||||
|
input=ToolInput(
|
||||||
|
parameters=[
|
||||||
|
InputParameter(
|
||||||
|
name="x",
|
||||||
|
required=True,
|
||||||
|
description="Input",
|
||||||
|
value_schema=ValueSchema(val_type="integer"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output=ToolOutput(description="Output", value_schema=ValueSchema(val_type="integer")),
|
||||||
|
requirements=ToolRequirements(
|
||||||
|
secrets=[
|
||||||
|
ToolSecretRequirement(key="FORMAT_TEST_KEY", description="Format Test Key")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def format_test_tool(x: Annotated[int, "Input"]) -> Annotated[int, "Output"]:
|
||||||
|
"""Format test tool."""
|
||||||
|
return x * 2
|
||||||
|
|
||||||
|
# Add tool to catalog
|
||||||
|
input_model, output_model = create_func_models(format_test_tool)
|
||||||
|
meta = ToolMeta(module=format_test_tool.__module__, toolkit="TestToolkit")
|
||||||
|
materialized = MaterializedTool(
|
||||||
|
tool=format_test_tool,
|
||||||
|
definition=tool_def,
|
||||||
|
meta=meta,
|
||||||
|
input_model=input_model,
|
||||||
|
output_model=output_model,
|
||||||
|
)
|
||||||
|
tool_catalog._tools[tool_def.get_fully_qualified_name()] = materialized
|
||||||
|
|
||||||
|
# Clear the secret from environment
|
||||||
|
import os
|
||||||
|
|
||||||
|
old_value = os.environ.pop("FORMAT_TEST_KEY", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure worker routes are disabled
|
||||||
|
mcp_settings.arcade.server_secret = None
|
||||||
|
|
||||||
|
# Create and start server
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
server = MCPServer(
|
||||||
|
catalog=tool_catalog,
|
||||||
|
name="Test Server",
|
||||||
|
version="1.0.0",
|
||||||
|
settings=mcp_settings,
|
||||||
|
)
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
# Check warning format matches specification
|
||||||
|
warning_messages = [
|
||||||
|
rec.message for rec in caplog.records if rec.levelno == logging.WARNING
|
||||||
|
]
|
||||||
|
|
||||||
|
# Find the warning for our tool
|
||||||
|
matching_warnings = [msg for msg in warning_messages if "format_test_tool" in msg]
|
||||||
|
assert len(matching_warnings) > 0, (
|
||||||
|
f"Expected warning for format_test_tool. Got: {warning_messages}"
|
||||||
|
)
|
||||||
|
|
||||||
|
warning = matching_warnings[0]
|
||||||
|
# Check format: "⚠ Tool 'name' declares secret(s) 'KEY' which are not set"
|
||||||
|
assert "Tool 'format_test_tool'" in warning
|
||||||
|
assert "not set" in warning
|
||||||
|
|
||||||
|
await server.stop()
|
||||||
|
finally:
|
||||||
|
# Restore environment
|
||||||
|
if old_value is not None:
|
||||||
|
os.environ["FORMAT_TEST_KEY"] = old_value
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue