Fix multiple worker log level bug (#758)

When running `arcade_mcp_server` with `workers > 1`, uvicorn spawns
worker subprocesses that directly call `create_arcade_mcp_factory()`
without going through `main()`. Since `setup_logging()` is only called
in `main()`, these subprocesses have no logging configuration, causing:

1. Standard Python logging not intercepted by Loguru
    
2. DEBUG-level logs from libraries like urllib3 appearing when OTEL is
enabled
    
3. Inconsistent log formats between main process and workers

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches process-wide logging initialization for uvicorn worker
subprocesses, which can affect log levels/handlers and output across the
server. Functional impact is limited to observability but could change
verbosity when OTEL or libraries emit logs.
> 
> **Overview**
> Fixes multi-worker/reload mode logging by configuring Loguru inside
`create_arcade_mcp_factory()` (using `ARCADE_MCP_DEBUG` to set `INFO` vs
`DEBUG`) so uvicorn-spawned worker subprocesses get the same
logging/interception as `main()`.
> 
> Adds regression tests that assert the factory filters DEBUG logs by
default and enables them when `ARCADE_MCP_DEBUG=true`, and bumps
`arcade-mcp-server` to `1.15.2`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
0c262eb9716ecbd589f1524842243a7aed80666e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Eric Gustin 2026-01-30 15:37:52 -08:00 committed by GitHub
parent a4160dd9fe
commit d7d765343e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 110 additions and 7 deletions

View file

@ -15,6 +15,8 @@ from typing import Any
import uvicorn import uvicorn
from arcade_core.catalog import ToolCatalog from arcade_core.catalog import ToolCatalog
from arcade_core.discovery import discover_tools
from arcade_core.toolkit import ToolkitLoadError
from arcade_serve.fastapi import FastAPIWorker, TaskTrackerMiddleware from arcade_serve.fastapi import FastAPIWorker, TaskTrackerMiddleware
from arcade_serve.fastapi.telemetry import OTELHandler from arcade_serve.fastapi.telemetry import OTELHandler
from fastapi import FastAPI from fastapi import FastAPI
@ -23,6 +25,7 @@ from starlette.requests import Request
from starlette.responses import Response from starlette.responses import Response
from starlette.types import Receive, Scope, Send from starlette.types import Receive, Scope, Send
from arcade_mcp_server.__main__ import setup_logging
from arcade_mcp_server.fastapi.auth_routes import create_auth_router from arcade_mcp_server.fastapi.auth_routes import create_auth_router
from arcade_mcp_server.fastapi.middleware import AddTrailingSlashToPathMiddleware from arcade_mcp_server.fastapi.middleware import AddTrailingSlashToPathMiddleware
from arcade_mcp_server.resource_server.base import ResourceServerValidator from arcade_mcp_server.resource_server.base import ResourceServerValidator
@ -300,13 +303,12 @@ def create_arcade_mcp_factory() -> FastAPI:
This function is called by uvicorn when using reload mode with an import string. This function is called by uvicorn when using reload mode with an import string.
It rediscovers the catalog and reads configuration from environment variables. It rediscovers the catalog and reads configuration from environment variables.
""" """
import os # Configure logging first, before any other imports that might trigger logging.
# This is critical for worker subprocesses (workers > 1) where main() is not called.
from arcade_core.discovery import discover_tools
from arcade_core.toolkit import ToolkitLoadError
# Read configuration from env vars that were set before running the server
debug = os.environ.get("ARCADE_MCP_DEBUG", "false").lower() == "true" debug = os.environ.get("ARCADE_MCP_DEBUG", "false").lower() == "true"
log_level = "DEBUG" if debug else "INFO"
setup_logging(level=log_level, stdio_mode=False)
# Read configuration from the remaining env vars that were set before running the server
otel_enable = os.environ.get("ARCADE_MCP_OTEL_ENABLE", "false").lower() == "true" otel_enable = os.environ.get("ARCADE_MCP_OTEL_ENABLE", "false").lower() == "true"
tool_package = os.environ.get("ARCADE_MCP_TOOL_PACKAGE") tool_package = os.environ.get("ARCADE_MCP_TOOL_PACKAGE")
discover_installed = os.environ.get("ARCADE_MCP_DISCOVER_INSTALLED", "false").lower() == "true" discover_installed = os.environ.get("ARCADE_MCP_DISCOVER_INSTALLED", "false").lower() == "true"

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "arcade-mcp-server" name = "arcade-mcp-server"
version = "1.15.1" version = "1.15.2"
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" }]

View file

@ -0,0 +1,101 @@
import logging
import os
from io import StringIO
import pytest
from loguru import logger
@pytest.fixture(autouse=True)
def isolate_environment():
"""Isolate environment variables for each test."""
original_env = os.environ.copy()
yield
os.environ.clear()
os.environ.update(original_env)
@pytest.fixture(autouse=True)
def reset_logging():
"""Reset logging state before and after each test."""
# Store original loguru handlers
original_handlers = logger._core.handlers.copy()
# Clear any existing handlers from root logger
root_logger = logging.getLogger()
original_root_handlers = root_logger.handlers.copy()
original_root_level = root_logger.level
yield
# Restore loguru handlers
logger._core.handlers.clear()
logger._core.handlers.update(original_handlers)
# Restore root logger state
root_logger.handlers = original_root_handlers
root_logger.level = original_root_level
class TestFactoryLoggingConfiguration:
"""Tests for logging configuration in create_arcade_mcp_factory.
These tests verify the factory properly configures logging by checking
the actual logging state after the factory runs.
"""
def test_factory_filters_debug_logs_by_default(self):
"""Verify factory filters DEBUG logs when ARCADE_MCP_DEBUG is not set."""
os.environ["ARCADE_MCP_DISCOVER_INSTALLED"] = "true"
os.environ.pop("ARCADE_MCP_DEBUG", None)
os.environ["ARCADE_MCP_OTEL_ENABLE"] = "false"
try:
from arcade_mcp_server.worker import create_arcade_mcp_factory
create_arcade_mcp_factory()
except RuntimeError:
pass
# Capture output after factory configures logging
output = StringIO()
logger.remove()
handler_id = logger.add(output, format="{level} | {message}", level="INFO")
try:
test_logger = logging.getLogger("test.factory")
test_logger.debug("This debug message should be filtered")
test_logger.info("This info message should appear")
log_output = output.getvalue()
assert "This debug message should be filtered" not in log_output
assert "This info message should appear" in log_output
finally:
logger.remove(handler_id)
def test_factory_allows_debug_logs_when_env_var_set(self):
"""Verify factory allows DEBUG logs when ARCADE_MCP_DEBUG=true."""
os.environ["ARCADE_MCP_DISCOVER_INSTALLED"] = "true"
os.environ["ARCADE_MCP_DEBUG"] = "true"
os.environ["ARCADE_MCP_OTEL_ENABLE"] = "false"
try:
from arcade_mcp_server.worker import create_arcade_mcp_factory
create_arcade_mcp_factory()
except RuntimeError:
pass
# Capture output after factory configures logging
output = StringIO()
logger.remove()
handler_id = logger.add(output, format="{level} | {message}", level="DEBUG")
try:
test_logger = logging.getLogger("test.factory.debug")
test_logger.debug("This debug message should appear")
log_output = output.getvalue()
assert "This debug message should appear" in log_output
finally:
logger.remove(handler_id)