Reload for MCPApp (#622)

Previously, MCPApp did not truly have reload capabilities. Instead, if
`reload=True`, then under the hood we would just change over to the
module execution code path (e.g., `arcade mcp`, or `python -m
arcade_mcp_server`). This was bad because custom `MCPApp` startup code
was not being executed and tools that were not added to `MCPApp`'s
catalog were being discovered and added to the server.

`MCPApp` now contains its own custom reload logic. It doesn't use
uvicorn's reload because uvicorn's discovery & factory pattern wasn't
the best fit for `MCPApp`'s self-contained pattern.

Now when `MCPApp.run(reload=True)` is called, `MCPApp` becomes the
parent process that manages reload itself.
This commit is contained in:
Eric Gustin 2025-10-17 17:38:11 -07:00 committed by GitHub
parent 75fc298681
commit 19bbaddf75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 303 additions and 13 deletions

View file

@ -6,23 +6,27 @@ Provides a clean, minimal API for building MCP servers with lazy initialization.
from __future__ import annotations from __future__ import annotations
import asyncio
import os import os
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Literal, ParamSpec, TypeVar from typing import Any, Callable, Literal, ParamSpec, TypeVar
import uvicorn
from arcade_core.catalog import MaterializedTool, ToolCatalog, ToolDefinitionError from arcade_core.catalog import MaterializedTool, ToolCatalog, ToolDefinitionError
from arcade_tdk.auth import ToolAuthorization from arcade_tdk.auth import ToolAuthorization
from arcade_tdk.error_adapters import ErrorAdapter from arcade_tdk.error_adapters import ErrorAdapter
from arcade_tdk.tool import tool as tool_decorator from arcade_tdk.tool import tool as tool_decorator
from dotenv import load_dotenv from dotenv import load_dotenv
from loguru import logger from loguru import logger
from watchfiles import watch
from arcade_mcp_server.exceptions import ServerError from arcade_mcp_server.exceptions import ServerError
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
from arcade_mcp_server.worker import run_arcade_mcp from arcade_mcp_server.worker import create_arcade_mcp
P = ParamSpec("P") P = ParamSpec("P")
T = TypeVar("T") T = TypeVar("T")
@ -231,20 +235,18 @@ class MCPApp:
# Since the transport could have changed since __init__, we need to setup logging again # Since the transport could have changed since __init__, we need to setup logging again
self._setup_logging(transport == "stdio") self._setup_logging(transport == "stdio")
if os.getenv("ARCADE_MCP_CHILD_PROCESS") == "1":
# parent watcher has already been setup
reload = False
logger.info(f"Starting {self.name} v{self.version} with {len(self._catalog)} tools") logger.info(f"Starting {self.name} v{self.version} with {len(self._catalog)} tools")
if transport in ["http", "streamable-http", "streamable"]: if transport in ["http", "streamable-http", "streamable"]:
run_arcade_mcp( if reload:
catalog=self._catalog, self._run_with_reload(host, port)
host=host, else:
port=port, self._create_and_run_server(host, port)
reload=reload,
mcp_settings=self._mcp_settings,
**self.server_kwargs,
)
elif transport == "stdio": elif transport == "stdio":
import asyncio
from arcade_mcp_server.__main__ import run_stdio_server from arcade_mcp_server.__main__ import run_stdio_server
asyncio.run( asyncio.run(
@ -257,6 +259,84 @@ class MCPApp:
else: else:
raise ServerError(f"Invalid transport: {transport}") raise ServerError(f"Invalid transport: {transport}")
def _run_with_reload(self, host: str, port: int) -> None:
"""
Run with file watching for auto-reload.
This method runs as the parent process that watches for file changes
and spawns/restarts child processes to run the actual server.
"""
env_file_path = Path.cwd() / ".env"
def start_server_process() -> subprocess.Popen:
"""Start a child process running the server."""
env = os.environ.copy()
env["ARCADE_MCP_CHILD_PROCESS"] = "1"
return subprocess.Popen(
[sys.executable, *sys.argv],
env=env,
)
def shutdown_server_process(process: subprocess.Popen, reason: str = "reload") -> None:
"""Shutdown server process gracefully with fallback to force kill."""
logger.info(f"Shutting down server for {reason}...")
process.terminate()
try:
process.wait(timeout=5)
logger.info("Server shut down gracefully")
except subprocess.TimeoutExpired:
logger.warning(
"Server did not shut down within 5 seconds (likely due to active client connections). "
"Force killing server process..."
)
process.kill()
process.wait()
logger.info("Server force killed")
logger.info("Starting file watcher for auto-reload")
process = start_server_process()
try:
def watch_filter(change: Any, path: str) -> bool:
return path.endswith(".py") or (Path(path) == env_file_path)
for changes in watch(".", watch_filter=watch_filter):
logger.info(f"Detected changes in {len(changes)} file(s), restarting server...")
shutdown_server_process(process, reason="reload")
process = start_server_process()
except KeyboardInterrupt:
logger.info("Received shutdown signal")
shutdown_server_process(process, reason="shutdown")
logger.info("File watcher stopped")
def _create_and_run_server(self, host: str, port: int) -> None:
"""
Create and run the server directly without reload.
This is used when reload=False or when running as a child process.
"""
debug = self.log_level == "DEBUG"
log_level = "debug" if debug else "info"
app = create_arcade_mcp(
catalog=self._catalog,
mcp_settings=self._mcp_settings,
debug=debug,
**self.server_kwargs,
)
uvicorn.run(
app,
host=host,
port=port,
log_level=log_level,
reload=False, # MCPApp handles its own reload via parent/child process pattern
lifespan="on",
)
@staticmethod @staticmethod
def _get_configuration_overrides( def _get_configuration_overrides(
host: str, port: int, transport: TransportType, reload: bool host: str, port: int, transport: TransportType, reload: bool

View file

@ -282,6 +282,9 @@ def run_arcade_mcp(
) -> None: ) -> None:
""" """
Run the integrated Arcade MCP server with uvicorn. Run the integrated Arcade MCP server with uvicorn.
This is used for module execution (`arcade mcp` and `python -m arcade_mcp_server`) only.
MCPApp has its own reload mechanism.
""" """
import os import os

View file

@ -28,6 +28,7 @@ dependencies = [
"pydantic>=2.0.0", "pydantic>=2.0.0",
"fastapi>=0.100.0", "fastapi>=0.100.0",
"uvicorn>=0.30.0", "uvicorn>=0.30.0",
"watchfiles>=0.18.0", # included with uvicorn, but listed to be explicit
"sse-starlette>=2.0.0", "sse-starlette>=2.0.0",
"starlette>=0.37.0", "starlette>=0.37.0",
"anyio>=4.0.0", "anyio>=4.0.0",

View file

@ -1,6 +1,9 @@
"""Tests for MCPApp initialization and basic functionality.""" """Tests for MCPApp initialization and basic functionality."""
import subprocess
import sys
from typing import Annotated from typing import Annotated
from unittest.mock import MagicMock, Mock, patch
import pytest import pytest
from arcade_core.catalog import MaterializedTool from arcade_core.catalog import MaterializedTool
@ -15,7 +18,15 @@ class TestMCPApp:
@pytest.fixture @pytest.fixture
def mcp_app(self) -> MCPApp: def mcp_app(self) -> MCPApp:
"""Create an MCP app.""" """Create an MCP app."""
return MCPApp(name="TestMCPApp", version="1.0.0") app = MCPApp(name="TestMCPApp", version="1.0.0")
# Add a sample tool so the app doesn't exit when run() is called
@app.tool
def sample_tool(message: Annotated[str, "A message"]) -> str:
"""A sample tool for testing."""
return f"Response: {message}"
return app
def test_mcp_app_initialization(self): def test_mcp_app_initialization(self):
"""Test MCPApp initialization creates proper settings.""" """Test MCPApp initialization creates proper settings."""
@ -89,6 +100,8 @@ class TestMCPApp:
def test_tool(self, mcp_app: MCPApp): def test_tool(self, mcp_app: MCPApp):
"""Test the MCPApp tool decorator.""" """Test the MCPApp tool decorator."""
initial_tool_count = len(mcp_app._catalog)
# Test decorator without parameters # Test decorator without parameters
@mcp_app.tool @mcp_app.tool
def simple_tool(message: Annotated[str, "A message"]) -> str: def simple_tool(message: Annotated[str, "A message"]) -> str:
@ -102,7 +115,7 @@ class TestMCPApp:
return f"Response: {message}" return f"Response: {message}"
# Verify both tools were added # Verify both tools were added
assert len(mcp_app._catalog) == 2 assert len(mcp_app._catalog) == initial_tool_count + 2
# Verify decorator attributes # Verify decorator attributes
assert hasattr(simple_tool, "__tool_name__") assert hasattr(simple_tool, "__tool_name__")
@ -311,3 +324,196 @@ class TestMCPApp:
monkeypatch.delenv("ARCADE_SERVER_HOST") monkeypatch.delenv("ARCADE_SERVER_HOST")
monkeypatch.delenv("ARCADE_SERVER_PORT") monkeypatch.delenv("ARCADE_SERVER_PORT")
monkeypatch.delenv("ARCADE_SERVER_TRANSPORT") monkeypatch.delenv("ARCADE_SERVER_TRANSPORT")
def test_create_and_run_server(self, mcp_app: MCPApp):
"""Test _create_and_run_server method with mocked dependencies."""
with patch("arcade_mcp_server.mcp_app.create_arcade_mcp") as mock_create, patch(
"arcade_mcp_server.mcp_app.uvicorn"
) as mock_uvicorn:
mock_fastapi_app = Mock()
mock_create.return_value = mock_fastapi_app
# Test with INFO log level
mcp_app.log_level = "INFO"
mcp_app._create_and_run_server("127.0.0.1", 8000)
mock_create.assert_called_once_with(
catalog=mcp_app._catalog,
mcp_settings=mcp_app._mcp_settings,
debug=False,
)
mock_uvicorn.run.assert_called_once_with(
mock_fastapi_app,
host="127.0.0.1",
port=8000,
log_level="info",
reload=False,
lifespan="on",
)
# Test with DEBUG log level
with patch("arcade_mcp_server.mcp_app.create_arcade_mcp") as mock_create, patch(
"arcade_mcp_server.mcp_app.uvicorn"
) as mock_uvicorn:
mock_fastapi_app = Mock()
mock_create.return_value = mock_fastapi_app
mcp_app.log_level = "DEBUG"
mcp_app._create_and_run_server("192.168.1.1", 9000)
mock_create.assert_called_once_with(
catalog=mcp_app._catalog,
mcp_settings=mcp_app._mcp_settings,
debug=True,
)
mock_uvicorn.run.assert_called_once_with(
mock_fastapi_app,
host="192.168.1.1",
port=9000,
log_level="debug",
reload=False,
lifespan="on",
)
def test_run_with_reload_spawns_child_process(self, mcp_app: MCPApp):
"""Test _run_with_reload spawns child process with correct environment."""
mock_process = Mock()
mock_process.terminate = Mock()
mock_process.wait = Mock()
with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
"arcade_mcp_server.mcp_app.watch"
) as mock_watch:
mock_popen.return_value = mock_process
# Return empty iterator to exit immediately
mock_watch.return_value = iter([])
mcp_app._run_with_reload("127.0.0.1", 8000)
# Verify Popen was called with correct args
mock_popen.assert_called_once()
call_args = mock_popen.call_args
assert call_args[0][0] == [sys.executable, *sys.argv]
assert call_args[1]["env"]["ARCADE_MCP_CHILD_PROCESS"] == "1"
def test_run_with_reload_restarts_on_changes(self, mcp_app: MCPApp):
"""Test _run_with_reload restarts server when file changes detected."""
mock_process1 = Mock()
mock_process2 = Mock()
with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
"arcade_mcp_server.mcp_app.watch"
) as mock_watch:
mock_popen.side_effect = [mock_process1, mock_process2]
# Yield one set of changes then stop
mock_watch.return_value = iter([{("change", "test.py")}])
mcp_app._run_with_reload("127.0.0.1", 8000)
# Verify both processes were created
assert mock_popen.call_count == 2
# Verify first process was terminated
mock_process1.terminate.assert_called_once()
mock_process1.wait.assert_called()
def test_run_with_reload_graceful_shutdown(self, mcp_app: MCPApp):
"""Test _run_with_reload gracefully shuts down process."""
mock_process = Mock()
mock_process.wait = Mock() # Succeeds without timeout
with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
"arcade_mcp_server.mcp_app.watch"
) as mock_watch:
mock_popen.return_value = mock_process
mock_watch.return_value = iter([{("change", "test.py")}])
mcp_app._run_with_reload("127.0.0.1", 8000)
# Verify graceful shutdown
mock_process.terminate.assert_called()
mock_process.wait.assert_called()
mock_process.kill.assert_not_called()
def test_run_with_reload_force_kill_on_timeout(self, mcp_app: MCPApp):
"""Test _run_with_reload force kills process on timeout."""
mock_process = Mock()
# First wait times out, second succeeds
mock_process.wait = Mock(
side_effect=[subprocess.TimeoutExpired("cmd", 5), None]
)
with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
"arcade_mcp_server.mcp_app.watch"
) as mock_watch:
mock_popen.return_value = mock_process
mock_watch.return_value = iter([{("change", "test.py")}])
mcp_app._run_with_reload("127.0.0.1", 8000)
# Verify terminate -> wait -> kill -> wait sequence
mock_process.terminate.assert_called()
assert mock_process.wait.call_count == 2
mock_process.kill.assert_called_once()
def test_run_with_reload_keyboard_interrupt(self, mcp_app: MCPApp):
"""Test _run_with_reload handles KeyboardInterrupt gracefully."""
mock_process = Mock()
with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
"arcade_mcp_server.mcp_app.watch"
) as mock_watch:
mock_popen.return_value = mock_process
mock_watch.side_effect = KeyboardInterrupt()
# Should not raise exception
mcp_app._run_with_reload("127.0.0.1", 8000)
# Verify process was shut down
mock_process.terminate.assert_called_once()
def test_run_routes_to_reload_method(self, mcp_app: MCPApp):
"""Test run() routes to _run_with_reload when reload=True."""
with patch.object(mcp_app, "_run_with_reload") as mock_reload, patch.object(
mcp_app, "_create_and_run_server"
) as mock_direct:
mcp_app.run(reload=True, transport="http", host="127.0.0.1", port=8000)
mock_reload.assert_called_once_with("127.0.0.1", 8000)
mock_direct.assert_not_called()
def test_run_routes_to_direct_method(self, mcp_app: MCPApp):
"""Test run() routes to _create_and_run_server when reload=False."""
with patch.object(mcp_app, "_run_with_reload") as mock_reload, patch.object(
mcp_app, "_create_and_run_server"
) as mock_direct:
mcp_app.run(reload=False, transport="http", host="127.0.0.1", port=8000)
mock_direct.assert_called_once_with("127.0.0.1", 8000)
mock_reload.assert_not_called()
def test_run_child_process_disables_reload(self, mcp_app: MCPApp, monkeypatch):
"""Test run() disables reload when ARCADE_MCP_CHILD_PROCESS is set."""
monkeypatch.setenv("ARCADE_MCP_CHILD_PROCESS", "1")
with patch.object(mcp_app, "_run_with_reload") as mock_reload, patch.object(
mcp_app, "_create_and_run_server"
) as mock_direct:
mcp_app.run(reload=True, transport="http", host="127.0.0.1", port=8000)
# Should route to direct method even though reload=True
mock_direct.assert_called_once_with("127.0.0.1", 8000)
mock_reload.assert_not_called()
def test_run_stdio_unaffected_by_reload(self, mcp_app: MCPApp):
"""Test run() with stdio transport is unaffected by reload flag."""
with patch("arcade_mcp_server.__main__.run_stdio_server") as mock_stdio:
# Test with reload=True
mcp_app.run(reload=True, transport="stdio")
mock_stdio.assert_called_once()
mock_stdio.reset_mock()
# Test with reload=False
mcp_app.run(reload=False, transport="stdio")
mock_stdio.assert_called_once()