Skip to content

Commit 96df4c4

Browse files
committed
Add logging infrastructure with MCPD_LOG_LEVEL support
Implements SDK logging supporting MCPD_LOG_LEVEL environment variable to help users diagnose issues with server health and tool availability. - Add LogLevel enum and Logger protocol in _logger.py - Integrate logger into McpdClient with lazy evaluation pattern - Log warnings for non-existent servers, unhealthy servers, and tool fetch failures - Export Logger and LogLevel in public API - Add comprehensive test coverage for logging functionality - Document logging configuration and custom logger usage in README Logging is disabled by default to avoid contaminating stdout/stderr, particularly important for MCP server contexts where stdout is used for JSON-RPC communication.
1 parent b319c3c commit 96df4c4

File tree

5 files changed

+564
-6
lines changed

5 files changed

+564
-6
lines changed

README.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ from mcpd import McpdClient
157157
# Initialize the client with your mcpd API endpoint.
158158
# api_key is optional and sends an 'MCPD-API-KEY' header.
159159
# server_health_cache_ttl is optional and sets the time in seconds to cache a server health response.
160+
# logger is optional and allows you to provide a custom logger implementation (see Logging section).
160161
client = McpdClient(api_endpoint="http://localhost:8090", api_key="optional-key", server_health_cache_ttl=10)
161162
```
162163

@@ -182,6 +183,94 @@ client = McpdClient(api_endpoint="http://localhost:8090", api_key="optional-key"
182183

183184
* `client.is_server_healthy(server_name: str) -> bool` - Checks if the specified server is healthy and can handle requests.
184185

186+
## Logging
187+
188+
The SDK includes built-in logging infrastructure that can be enabled via the `MCPD_LOG_LEVEL` environment variable. Logging is disabled by default to avoid contaminating stdout/stderr.
189+
190+
> [!IMPORTANT]
191+
> Only enable `MCPD_LOG_LEVEL` in non-MCP-server contexts. MCP servers can use stdout for JSON-RPC communication,
192+
> and any logging output will break the protocol.
193+
194+
### Available Log Levels
195+
196+
Set the `MCPD_LOG_LEVEL` environment variable to one of the following values (from most to least verbose):
197+
198+
* `trace` - Most verbose logging (includes all levels below)
199+
* `debug` - Debug-level logging
200+
* `info` - Informational logging
201+
* `warn` - Warning-level logging (recommended for most use cases)
202+
* `error` - Error-level logging only
203+
* `off` - Disable all logging (default)
204+
205+
### Example Usage
206+
207+
```bash
208+
# Enable warning-level logging
209+
export MCPD_LOG_LEVEL=warn
210+
python your_script.py
211+
```
212+
213+
```python
214+
from mcpd import McpdClient
215+
216+
# Warnings will be logged to stderr when MCPD_LOG_LEVEL=warn
217+
client = McpdClient(api_endpoint="http://localhost:8090")
218+
219+
# For example, the SDK will log warnings for:
220+
# - Non-existent servers when calling agent_tools()
221+
# - Unhealthy servers when calling agent_tools()
222+
# - Servers that become unavailable during tool fetching
223+
```
224+
225+
### Custom Logger
226+
227+
You can provide your own logger implementation that implements the `Logger` protocol:
228+
229+
```python
230+
from mcpd import McpdClient, Logger
231+
232+
class CustomLogger:
233+
def trace(self, msg: str, *args: object) -> None:
234+
print(f"TRACE: {msg % args}")
235+
236+
def debug(self, msg: str, *args: object) -> None:
237+
print(f"DEBUG: {msg % args}")
238+
239+
def info(self, msg: str, *args: object) -> None:
240+
print(f"INFO: {msg % args}")
241+
242+
def warn(self, msg: str, *args: object) -> None:
243+
print(f"WARN: {msg % args}")
244+
245+
def error(self, msg: str, *args: object) -> None:
246+
print(f"ERROR: {msg % args}")
247+
248+
# Use custom logger
249+
client = McpdClient(
250+
api_endpoint="http://localhost:8090",
251+
logger=CustomLogger()
252+
)
253+
```
254+
255+
You can also provide a partial logger implementation. Any omitted methods will fall back to the default logger (which respects `MCPD_LOG_LEVEL`):
256+
257+
```python
258+
class PartialLogger:
259+
def warn(self, msg: str, *args: object) -> None:
260+
# Custom warning handler
261+
print(f"CUSTOM WARN: {msg % args}")
262+
263+
def error(self, msg: str, *args: object) -> None:
264+
# Custom error handler
265+
print(f"CUSTOM ERROR: {msg % args}")
266+
# trace, debug, info use default logger (respects MCPD_LOG_LEVEL)
267+
268+
client = McpdClient(
269+
api_endpoint="http://localhost:8090",
270+
logger=PartialLogger()
271+
)
272+
```
273+
185274
## Error Handling
186275

187276
All SDK-level errors, including HTTP and connection errors, will raise a `McpdError` exception.

src/mcpd/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- Comprehensive error handling: Detailed exceptions for different failure modes
1313
"""
1414

15+
from ._logger import Logger, LogLevel
1516
from .exceptions import (
1617
AuthenticationError,
1718
ConnectionError,
@@ -28,6 +29,8 @@
2829
__all__ = [
2930
"McpdClient",
3031
"HealthStatus",
32+
"Logger",
33+
"LogLevel",
3134
"McpdError",
3235
"AuthenticationError",
3336
"ConnectionError",

src/mcpd/_logger.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
"""Internal logging infrastructure for the mcpd SDK.
2+
3+
This module provides a logging shim controlled by the MCPD_LOG_LEVEL environment
4+
variable. Logging is disabled by default to avoid contaminating stdout/stderr in
5+
MCP server contexts.
6+
7+
CRITICAL: Only enable MCPD_LOG_LEVEL in non-MCP-server contexts. MCP servers use
8+
stdout for JSON-RPC communication, and any logging output will break the protocol.
9+
"""
10+
11+
import logging
12+
import os
13+
from enum import Enum
14+
from typing import Protocol
15+
16+
17+
class LogLevel(str, Enum):
18+
"""Valid log level values for MCPD_LOG_LEVEL environment variable.
19+
20+
Aligns with mcpd server binary log levels for consistency across the mcpd ecosystem.
21+
"""
22+
23+
TRACE = "trace"
24+
DEBUG = "debug"
25+
INFO = "info"
26+
WARN = "warn"
27+
ERROR = "error"
28+
OFF = "off"
29+
30+
31+
class Logger(Protocol):
32+
"""Logger protocol defining the SDK's logging interface.
33+
34+
This protocol matches standard logging levels and allows custom logger injection.
35+
All methods accept a message and optional formatting arguments.
36+
"""
37+
38+
def trace(self, msg: str, *args: object) -> None:
39+
"""Log a trace-level message (most verbose)."""
40+
...
41+
42+
def debug(self, msg: str, *args: object) -> None:
43+
"""Log a debug-level message."""
44+
...
45+
46+
def info(self, msg: str, *args: object) -> None:
47+
"""Log an info-level message."""
48+
...
49+
50+
def warn(self, msg: str, *args: object) -> None:
51+
"""Log a warning-level message."""
52+
...
53+
54+
def error(self, msg: str, *args: object) -> None:
55+
"""Log an error-level message."""
56+
...
57+
58+
59+
# Custom TRACE level (below DEBUG=10).
60+
_TRACE = 5
61+
logging.addLevelName(_TRACE, "TRACE")
62+
63+
_RANKS: dict[str, int] = {
64+
LogLevel.TRACE.value: _TRACE,
65+
LogLevel.DEBUG.value: logging.DEBUG,
66+
LogLevel.INFO.value: logging.INFO,
67+
LogLevel.WARN.value: logging.WARNING,
68+
"warning": logging.WARNING, # Alias for backwards compatibility.
69+
LogLevel.ERROR.value: logging.ERROR,
70+
LogLevel.OFF.value: 1000, # Higher than any standard level.
71+
}
72+
73+
74+
def _resolve_log_level(raw: str | None) -> str:
75+
"""Resolve the log level from environment variable value.
76+
77+
Args:
78+
raw: Raw value from MCPD_LOG_LEVEL environment variable.
79+
80+
Returns:
81+
Valid log level string matching LogLevel enum values.
82+
Returns LogLevel.OFF.value if raw is None, empty, or not a valid level.
83+
"""
84+
candidate = raw.lower() if raw else None
85+
return candidate if candidate and candidate in _RANKS else LogLevel.OFF.value
86+
87+
88+
def _get_level() -> str:
89+
"""Get the current log level from environment variable (lazy evaluation).
90+
91+
This function is called on each log statement to support dynamic level changes.
92+
93+
Note:
94+
Dynamic level changes can facilitate testing.
95+
96+
Returns:
97+
The resolved log level string.
98+
"""
99+
return _resolve_log_level(os.getenv("MCPD_LOG_LEVEL"))
100+
101+
102+
def _create_default_logger() -> Logger:
103+
"""Create the default logger with lazy level evaluation.
104+
105+
Returns:
106+
A Logger instance that checks MCPD_LOG_LEVEL on each log call,
107+
enabling dynamic level changes without module reloading.
108+
"""
109+
# Create logger and handler once (not per-call).
110+
_logger = logging.getLogger(__name__)
111+
112+
if not _logger.handlers:
113+
# Add stderr handler (default for StreamHandler).
114+
handler = logging.StreamHandler()
115+
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
116+
_logger.addHandler(handler)
117+
_logger.propagate = False
118+
119+
class _DefaultLogger:
120+
"""Default logger that checks level on each call (lazy evaluation)."""
121+
122+
def trace(self, msg: str, *args: object) -> None:
123+
"""Log trace-level message."""
124+
lvl = _get_level()
125+
if lvl != LogLevel.OFF.value and _RANKS[lvl] <= _RANKS[LogLevel.TRACE.value]:
126+
_logger.setLevel(_TRACE)
127+
_logger.log(_TRACE, msg, *args)
128+
129+
def debug(self, msg: str, *args: object) -> None:
130+
"""Log debug-level message."""
131+
lvl = _get_level()
132+
if lvl != LogLevel.OFF.value and _RANKS[lvl] <= _RANKS[LogLevel.DEBUG.value]:
133+
_logger.setLevel(logging.DEBUG)
134+
_logger.debug(msg, *args)
135+
136+
def info(self, msg: str, *args: object) -> None:
137+
"""Log info-level message."""
138+
lvl = _get_level()
139+
if lvl != LogLevel.OFF.value and _RANKS[lvl] <= _RANKS[LogLevel.INFO.value]:
140+
_logger.setLevel(logging.INFO)
141+
_logger.info(msg, *args)
142+
143+
def warn(self, msg: str, *args: object) -> None:
144+
"""Log warning-level message."""
145+
lvl = _get_level()
146+
if lvl != LogLevel.OFF.value and _RANKS[lvl] <= _RANKS[LogLevel.WARN.value]:
147+
_logger.setLevel(logging.WARNING)
148+
_logger.warning(msg, *args)
149+
150+
def error(self, msg: str, *args: object) -> None:
151+
"""Log error-level message."""
152+
lvl = _get_level()
153+
if lvl != LogLevel.OFF.value and _RANKS[lvl] <= _RANKS[LogLevel.ERROR.value]:
154+
_logger.setLevel(logging.ERROR)
155+
_logger.error(msg, *args)
156+
157+
return _DefaultLogger()
158+
159+
160+
class _PartialLoggerWrapper:
161+
"""Wrapper that combines partial custom logger with default logger fallback.
162+
163+
This enables partial logger implementations where users can override specific
164+
methods while keeping defaults for others.
165+
"""
166+
167+
def __init__(self, custom: object, default: Logger):
168+
"""Initialize the wrapper.
169+
170+
Args:
171+
custom: Partial logger implementation (may not have all methods).
172+
default: Default logger to use for missing methods.
173+
"""
174+
self._custom = custom
175+
self._default = default
176+
177+
def trace(self, msg: str, *args: object) -> None:
178+
"""Log trace-level message."""
179+
if hasattr(self._custom, LogLevel.TRACE.value):
180+
self._custom.trace(msg, *args)
181+
else:
182+
self._default.trace(msg, *args)
183+
184+
def debug(self, msg: str, *args: object) -> None:
185+
"""Log debug-level message."""
186+
if hasattr(self._custom, LogLevel.DEBUG.value):
187+
self._custom.debug(msg, *args)
188+
else:
189+
self._default.debug(msg, *args)
190+
191+
def info(self, msg: str, *args: object) -> None:
192+
"""Log info-level message."""
193+
if hasattr(self._custom, LogLevel.INFO.value):
194+
self._custom.info(msg, *args)
195+
else:
196+
self._default.info(msg, *args)
197+
198+
def warn(self, msg: str, *args: object) -> None:
199+
"""Log warning-level message."""
200+
if hasattr(self._custom, LogLevel.WARN.value):
201+
self._custom.warn(msg, *args)
202+
else:
203+
self._default.warn(msg, *args)
204+
205+
def error(self, msg: str, *args: object) -> None:
206+
"""Log error-level message."""
207+
if hasattr(self._custom, LogLevel.ERROR.value):
208+
self._custom.error(msg, *args)
209+
else:
210+
self._default.error(msg, *args)
211+
212+
213+
def create_logger(impl: Logger | object | None = None) -> Logger:
214+
"""Create a logger, optionally using a custom implementation.
215+
216+
This function allows SDK users to inject their own logger implementation.
217+
Supports partial implementations - any omitted methods will fall back to the
218+
default logger, which respects the MCPD_LOG_LEVEL environment variable.
219+
220+
Args:
221+
impl: Optional custom Logger implementation or partial implementation.
222+
If None, uses the default logger controlled by MCPD_LOG_LEVEL.
223+
If partially provided, custom methods are used and omitted methods
224+
fall back to default logger (which respects MCPD_LOG_LEVEL).
225+
226+
Returns:
227+
A Logger instance with all methods implemented.
228+
229+
Example:
230+
>>> # Use default logger (controlled by MCPD_LOG_LEVEL).
231+
>>> logger = create_logger()
232+
>>>
233+
>>> # Full custom logger.
234+
>>> class MyLogger:
235+
... def trace(self, msg, *args): pass
236+
... def debug(self, msg, *args): pass
237+
... def info(self, msg, *args): pass
238+
... def warn(self, msg, *args): print(f"WARN: {msg % args}")
239+
... def error(self, msg, *args): print(f"ERROR: {msg % args}")
240+
>>> logger = create_logger(MyLogger())
241+
>>>
242+
>>> # Partial logger: custom warn/error, default (MCPD_LOG_LEVEL-aware) for others.
243+
>>> class PartialLogger:
244+
... def warn(self, msg, *args): print(f"WARN: {msg % args}")
245+
... def error(self, msg, *args): print(f"ERROR: {msg % args}")
246+
... # trace, debug, info use default logger (respects MCPD_LOG_LEVEL)
247+
>>> logger = create_logger(PartialLogger())
248+
"""
249+
if impl is None:
250+
return _default_logger
251+
252+
# Check if it's a full Logger implementation (has all required methods).
253+
required_methods = [
254+
LogLevel.TRACE.value,
255+
LogLevel.DEBUG.value,
256+
LogLevel.INFO.value,
257+
LogLevel.WARN.value,
258+
LogLevel.ERROR.value,
259+
]
260+
if all(hasattr(impl, method) for method in required_methods):
261+
return impl
262+
263+
# Partial implementation - wrap with fallback to default logger.
264+
return _PartialLoggerWrapper(impl, _default_logger)
265+
266+
267+
# Module-level default logger (created at import time).
268+
_default_logger: Logger = _create_default_logger()

0 commit comments

Comments
 (0)