arcade-mcp/libs/arcade-serve/arcade_serve/mcp/logging.py
Sam Partee b6b4cd0a4c
🏗️ Restructure: Multi-Package Architecture + uv Migration (#412)
### Overview
Major restructuring from monolithic `arcade-ai` package to modular
library architecture with standardized uv-based dependency management.

![arcade-ai Monorepo
(2)](https://github.com/user-attachments/assets/25f102b0-bb87-4a04-9701-d227d05664b1)

### New Package Structure
- **`arcade-tdk`** - Lightweight toolkit development kit (core
decorators, auth)
- **`arcade-core`** - Core execution engine and catalog functionality  
- **`arcade-serve`** - FastAPI/MCP server components
- **`arcade-ai`** - Meta package that includes CLI functionality.
Optionally include evals via the `evals` extra. Optionally include all
packages via the `all` extra.

### Key Benefits
- **Lighter Dependencies**: Toolkits now depend only on `arcade-tdk` (~2
deps) vs full `arcade-ai` (~30+ deps)
- **Faster Builds**: uv provides 10-100x faster dependency resolution
and installation
- **Better Modularity**: Clear separation of concerns, consumers import
only what they need
- **Standard Tooling**: Eliminates custom poetry scripts, uses standard
Python packaging

### Migration Impact
- All 20 toolkits converted from poetry → uv with `arcade-tdk`
dependencies plus `arcade-ai[evals]` and `arcade-serve` dev
dependencies. When developing locally, devs should install toolkits via
`make install-local`.
- Modern Python 3.10+ type hints throughout
- Standardized build system with hatchling backend
- Enhanced Makefile with robust toolkit management commands
- Removed `arcade dev` CLI command
- Reduce the number of files created by `arcade new` and add an option
to not generate a tests and evals folder.

This foundation enables faster development cycles and cleaner dependency
chains for the growing toolkit ecosystem.

### Todo After this PR is merged
- [ ] Post-merge workflow(s) (release & publish containers, etc)
- [ ] Release order plan. @EricGustin suggests releasing in the
following order:
    1. `arcade-core` version 0.1.0
    2. `arcade-serve` version 0.1.0 and `arcade-tdk` version 0.1.0
    3. `arcade-ai` version 2.0.0
4. Patch release for all toolkits (all changes in toolkits are internal
refactors)
- [ ] [Update docs](https://github.com/ArcadeAI/docs/pull/318)

---------

Co-authored-by: Eric Gustin <eric@arcade.dev>
Co-authored-by: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
2025-06-11 16:48:17 -07:00

215 lines
7.7 KiB
Python

import json
import logging
import sys
import time
from typing import Any
from arcade_serve.mcp.types import (
JSONRPCError,
JSONRPCRequest,
JSONRPCResponse,
MCPMessage,
)
logger = logging.getLogger("arcade.mcp")
class MCPLoggingMiddleware:
"""
Middleware for logging MCP requests and responses.
Logs request and response details, including timing and errors.
"""
def __init__(
self,
log_level: str = "INFO",
log_request_body: bool = False,
log_response_body: bool = False,
log_errors: bool = True,
min_duration_to_log_ms: int = 0,
stdio_mode: bool = False,
) -> None:
"""
Initialize the MCP logging middleware.
Args:
log_level: Logging level (default: "INFO").
log_request_body: Whether to log full request bodies (default: False).
log_response_body: Whether to log full response bodies (default: False).
log_errors: Whether to log errors at ERROR level (default: True).
min_duration_to_log_ms: Minimum duration in ms to log (0 logs all).
stdio_mode: Whether running in stdio mode (redirects logs to stderr).
"""
self.log_level = getattr(logging, log_level.upper())
self.log_request_body = log_request_body
self.log_response_body = log_response_body
self.log_errors = log_errors
self.min_duration_to_log_ms = min_duration_to_log_ms
self.request_log_format = "[MCP>] {method}{params_str} (id: {id})"
self.response_log_format = "[MCP<] {method} completed in {duration:.2f}ms (id: {id})"
self.error_log_format = "[MCP!] {method} error: {error} (id: {id})"
# If in stdio mode, ensure MCP logs go to stderr
if stdio_mode:
self._redirect_logs_to_stderr()
# Log that middleware is initialized
logger.debug(f"MCP logging middleware initialized (level: {log_level})")
def _redirect_logs_to_stderr(self) -> None:
"""Redirect MCP logs to stderr to avoid interfering with stdio communication."""
# Remove any existing handlers
for handler in logger.handlers[:]:
logger.removeHandler(handler)
# Add a stderr handler
stderr_handler = logging.StreamHandler(sys.stderr)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
stderr_handler.setFormatter(formatter)
logger.addHandler(stderr_handler)
# Ensure we're not propagating to root logger which might log to stdout
logger.propagate = False
logger.debug("MCP logs redirected to stderr for stdio mode")
def __call__(self, message: MCPMessage, direction: str) -> MCPMessage:
"""
Process and log an MCP message.
Args:
message: The MCP message to process.
direction: The message direction ("request" or "response").
Returns:
The original message (unmodified).
"""
if direction == "request":
self._log_request(message)
else:
self._log_response(message)
return message
def _log_request(self, message: MCPMessage) -> None:
"""
Log an MCP request message.
"""
if not isinstance(message, JSONRPCRequest):
logger.debug(f"Ignoring non-request message: {type(message).__name__}")
return
try:
# Store request start time for duration calculation
message._mcp_start_time = time.time() # type: ignore[attr-defined]
# Format parameters for logging
params_str = ""
if self.log_request_body and hasattr(message, "params") and message.params is not None:
params_str = f": {self._format_params(message.params)}"
log_msg = self.request_log_format.format(
method=message.method, params_str=params_str, id=getattr(message, "id", "none")
)
logger.log(self.log_level, log_msg)
except Exception:
logger.exception("Error logging request")
def _log_response(self, message: MCPMessage) -> None:
"""
Log an MCP response message.
"""
if not isinstance(message, (JSONRPCResponse, JSONRPCError)):
logger.debug(f"Ignoring non-response message: {type(message).__name__}")
return
try:
# Calculate request duration if we have the start time
duration_ms = 0
request = getattr(message, "_request", None)
if request:
start_time = getattr(request, "_mcp_start_time", None)
if start_time:
duration_ms = (time.time() - start_time) * 1000
else:
start_time = getattr(message, "_mcp_start_time", None)
if start_time:
duration_ms = (time.time() - start_time) * 1000
# Skip if below minimum duration threshold
if self.min_duration_to_log_ms > 0 and duration_ms < self.min_duration_to_log_ms:
return
# Handle error responses
if hasattr(message, "error") and message.error is not None:
if self.log_errors:
error_msg = self.error_log_format.format(
method=getattr(message, "method", "unknown"),
error=getattr(message.error, "message", str(message.error)),
id=getattr(message, "id", "none"),
)
logger.error(error_msg)
return
# Log successful response
result_str = ""
if self.log_response_body and hasattr(message, "result"):
result_str = f": {self._format_result(message.result)}"
log_msg = self.response_log_format.format(
method=getattr(message, "method", "unknown"),
duration=duration_ms,
id=getattr(message, "id", "none"),
result_str=result_str,
)
logger.log(self.log_level, log_msg)
except Exception:
logger.exception("Error logging response")
def _format_params(self, params: Any) -> str:
"""
Format parameters for logging.
"""
try:
if isinstance(params, dict):
# Handle common MCP params specially
if "name" in params and "arguments" in params:
return f"{params['name']}({json.dumps(params.get('arguments', {}))})"
return json.dumps(params)
return str(params)
except Exception:
logger.debug(f"Error formatting params {params!s}")
return str(params)
def _format_result(self, result: Any) -> str:
"""
Format result for logging.
"""
try:
if isinstance(result, dict):
return json.dumps(result)
return str(result)
except Exception as e:
logger.debug(f"Error formatting result {e!s}")
return str(result)
def create_mcp_logging_middleware(**config: Any) -> MCPLoggingMiddleware:
"""
Create an MCP logging middleware with the given configuration.
Args:
**config: Configuration options.
Returns:
An MCPLoggingMiddleware instance.
"""
return MCPLoggingMiddleware(
log_level=config.get("log_level", "INFO"),
log_request_body=config.get("log_request_body", False),
log_response_body=config.get("log_response_body", False),
log_errors=config.get("log_errors", True),
min_duration_to_log_ms=config.get("min_duration_to_log_ms", 0),
stdio_mode=config.get("stdio_mode", False),
)