Skip to content

Commit 1589329

Browse files
committed
Stream server logs to clients via notifications/message
Add an MCPLogBridge logging.Handler that, while serve_stdio is running, forwards every project-logger record to the MCP client as a notifications/message. The handler is attached / detached automatically per stdio session. logging/setLevel requests retune the bridge level on the fly, and the initialize handshake now advertises the logging capability so clients know to expect them.
1 parent 1d473a4 commit 1589329

4 files changed

Lines changed: 190 additions & 7 deletions

File tree

je_auto_control/utils/mcp_server/__init__.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
FakeState, fake_state, install_fake_backend, reset_fake_state,
1818
uninstall_fake_backend,
1919
)
20+
from je_auto_control.utils.mcp_server.log_bridge import MCPLogBridge
2021
from je_auto_control.utils.mcp_server.rate_limit import RateLimiter
2122
from je_auto_control.utils.mcp_server.http_transport import (
2223
HttpMCPServer, start_mcp_http_server,
@@ -33,10 +34,11 @@
3334
)
3435

3536
__all__ = [
36-
"AuditLogger", "FakeState", "HttpMCPServer", "MCPContent", "MCPPrompt",
37-
"MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool",
38-
"MCPToolAnnotations", "OperationCancelledError", "PromptProvider",
39-
"RateLimiter", "ResourceProvider", "ToolCallContext",
37+
"AuditLogger", "FakeState", "HttpMCPServer", "MCPContent",
38+
"MCPLogBridge", "MCPPrompt", "MCPPromptArgument", "MCPResource",
39+
"MCPServer", "MCPTool", "MCPToolAnnotations",
40+
"OperationCancelledError", "PromptProvider", "RateLimiter",
41+
"ResourceProvider", "ToolCallContext",
4042
"build_default_tool_registry",
4143
"default_prompt_provider", "default_resource_provider",
4244
"fake_state", "install_fake_backend", "make_plugin_tool",
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Bridge Python logging records onto MCP ``notifications/message``.
2+
3+
Once attached to the project logger, every record at or above the
4+
configured level is forwarded to the MCP client as a notification so
5+
the client can mirror server-side activity in its UI. The handler is
6+
no-op when the server's notifier is not yet connected — useful for
7+
unit tests that don't actually start a transport.
8+
"""
9+
import logging
10+
from typing import Any, Callable, Dict, Optional
11+
12+
# MCP log levels (RFC 5424 syslog names) mapped from stdlib logging levels.
13+
_LEVEL_NAME_FROM_LEVEL = {
14+
logging.DEBUG: "debug",
15+
logging.INFO: "info",
16+
logging.WARNING: "warning",
17+
logging.ERROR: "error",
18+
logging.CRITICAL: "critical",
19+
}
20+
21+
_LEVEL_FROM_MCP_NAME = {
22+
"debug": logging.DEBUG,
23+
"info": logging.INFO,
24+
"notice": logging.INFO,
25+
"warning": logging.WARNING,
26+
"error": logging.ERROR,
27+
"critical": logging.CRITICAL,
28+
"alert": logging.CRITICAL,
29+
"emergency": logging.CRITICAL,
30+
}
31+
32+
33+
def mcp_level_to_logging(name: str) -> Optional[int]:
34+
"""Return the :mod:`logging` level for an MCP log name, or ``None``."""
35+
return _LEVEL_FROM_MCP_NAME.get(str(name).strip().lower())
36+
37+
38+
def logging_level_to_mcp(level: int) -> str:
39+
"""Return the closest MCP level name for a stdlib logging level."""
40+
closest = max(
41+
(lvl for lvl in _LEVEL_NAME_FROM_LEVEL if lvl <= int(level)),
42+
default=logging.DEBUG,
43+
)
44+
return _LEVEL_NAME_FROM_LEVEL[closest]
45+
46+
47+
class MCPLogBridge(logging.Handler):
48+
"""Logging handler that forwards records as ``notifications/message``."""
49+
50+
def __init__(self, notifier: Optional[
51+
Callable[[str, Dict[str, Any]], None]] = None,
52+
logger_name: str = "je_auto_control",
53+
level: int = logging.INFO) -> None:
54+
super().__init__(level=level)
55+
self._notifier = notifier
56+
self._logger_name = str(logger_name)
57+
58+
def set_notifier(self, notifier: Optional[
59+
Callable[[str, Dict[str, Any]], None]]) -> None:
60+
self._notifier = notifier
61+
62+
def emit(self, record: logging.LogRecord) -> None:
63+
notifier = self._notifier
64+
if notifier is None:
65+
return
66+
try:
67+
text = record.getMessage()
68+
except (TypeError, ValueError):
69+
text = str(record.msg)
70+
params: Dict[str, Any] = {
71+
"level": logging_level_to_mcp(record.levelno),
72+
"logger": self._logger_name,
73+
"data": {
74+
"logger": record.name,
75+
"message": text,
76+
"module": record.module,
77+
"func": record.funcName,
78+
"line": record.lineno,
79+
},
80+
}
81+
try:
82+
notifier("notifications/message", params)
83+
except (OSError, RuntimeError, ValueError):
84+
# The bridge must never crash the producer.
85+
pass
86+
87+
88+
__all__ = [
89+
"MCPLogBridge", "logging_level_to_mcp", "mcp_level_to_logging",
90+
]

je_auto_control/utils/mcp_server/server.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@
1414
from typing import Any, Callable, Dict, List, Optional, TextIO
1515

1616
from je_auto_control.utils.logging.logging_instance import autocontrol_logger
17+
import logging
18+
1719
from je_auto_control.utils.mcp_server.audit import AuditLogger
1820
from je_auto_control.utils.mcp_server.context import (
1921
OperationCancelledError, ToolCallContext,
2022
)
23+
from je_auto_control.utils.mcp_server.log_bridge import (
24+
MCPLogBridge, mcp_level_to_logging,
25+
)
2126
from je_auto_control.utils.mcp_server.rate_limit import RateLimiter
2227
from je_auto_control.utils.mcp_server.prompts import (
2328
PromptProvider, default_prompt_provider,
@@ -55,6 +60,7 @@ def __init__(self, tools: Optional[List[MCPTool]] = None,
5560
concurrent_tools: bool = False,
5661
audit_logger: Optional[AuditLogger] = None,
5762
rate_limiter: Optional[RateLimiter] = None,
63+
log_bridge: Optional[MCPLogBridge] = None,
5864
) -> None:
5965
registry = tools if tools is not None else build_default_tool_registry()
6066
self._tools: Dict[str, MCPTool] = {tool.name: tool for tool in registry}
@@ -66,6 +72,7 @@ def __init__(self, tools: Optional[List[MCPTool]] = None,
6672
self._audit = (audit_logger if audit_logger is not None
6773
else AuditLogger())
6874
self._rate_limiter = rate_limiter
75+
self._log_bridge = log_bridge
6976
self._stop = threading.Event()
7077
self._initialized = False
7178
self._notifier: Optional[Callable[[str, Dict[str, Any]], None]] = None
@@ -129,6 +136,7 @@ def serve_stdio(self, stdin: Optional[TextIO] = None,
129136
# Stdio always opts into concurrent tool execution so sampling
130137
# requests issued by tool handlers don't block the reader.
131138
self._concurrent_tools = True
139+
self._attach_log_bridge_if_configured()
132140
try:
133141
while not self._stop.is_set():
134142
line = in_stream.readline()
@@ -141,6 +149,7 @@ def serve_stdio(self, stdin: Optional[TextIO] = None,
141149
if response is not None:
142150
self._write_message(out_stream, response)
143151
finally:
152+
self._detach_log_bridge_if_configured()
144153
self._notifier = prior_notifier
145154
self._writer = prior_writer
146155
self._concurrent_tools = prior_concurrent
@@ -163,6 +172,23 @@ def set_notifier(self,
163172
"""
164173
self._notifier = notifier
165174

175+
def _attach_log_bridge_if_configured(self) -> None:
176+
"""Wire the log bridge into the project logger and notifier."""
177+
if self._log_bridge is None:
178+
self._log_bridge = MCPLogBridge()
179+
self._log_bridge.set_notifier(self._notifier)
180+
if self._log_bridge not in autocontrol_logger.handlers:
181+
autocontrol_logger.addHandler(self._log_bridge)
182+
183+
def _detach_log_bridge_if_configured(self) -> None:
184+
if self._log_bridge is None:
185+
return
186+
self._log_bridge.set_notifier(None)
187+
try:
188+
autocontrol_logger.removeHandler(self._log_bridge)
189+
except ValueError:
190+
pass
191+
166192
def set_writer(self, writer: Optional[Callable[[str], None]]) -> None:
167193
"""Install a callback used to write any outbound JSON-RPC line.
168194
@@ -357,8 +383,26 @@ def _dispatch(self, msg_id: Any, method: Optional[str],
357383
for prompt in self._prompts.list()]}
358384
if method == "prompts/get":
359385
return self._handle_prompts_get(params)
386+
if method == "logging/setLevel":
387+
return self._handle_logging_set_level(params)
360388
raise _MCPError(-32601, f"Method not found: {method}")
361389

390+
def _handle_logging_set_level(self,
391+
params: Dict[str, Any]) -> Dict[str, Any]:
392+
name = params.get("level")
393+
if not isinstance(name, str):
394+
raise _MCPError(-32602, "logging/setLevel requires string 'level'")
395+
level = mcp_level_to_logging(name)
396+
if level is None:
397+
raise _MCPError(-32602, f"unknown log level: {name!r}")
398+
if self._log_bridge is None:
399+
self._log_bridge = MCPLogBridge()
400+
self._log_bridge.setLevel(level)
401+
autocontrol_logger.setLevel(min(autocontrol_logger.level or level,
402+
level) if autocontrol_logger.level
403+
else level)
404+
return {}
405+
362406
def _handle_initialize(self, params: Dict[str, Any]) -> Dict[str, Any]:
363407
client_version = params.get("protocolVersion", PROTOCOL_VERSION)
364408
client_caps = params.get("capabilities") or {}
@@ -369,6 +413,7 @@ def _handle_initialize(self, params: Dict[str, Any]) -> Dict[str, Any]:
369413
"resources": {"listChanged": False, "subscribe": False},
370414
"prompts": {"listChanged": False},
371415
"sampling": {},
416+
"logging": {},
372417
}
373418
if "roots" in self._client_capabilities:
374419
capabilities["roots"] = {"listChanged": True}

test/unit_test/headless/test_mcp_server.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,10 @@ def test_serve_stdio_processes_messages_until_eof():
158158
stdout = io.StringIO()
159159
server.serve_stdio(stdin=stdin, stdout=stdout)
160160
out_lines = [line for line in stdout.getvalue().splitlines() if line]
161-
assert len(out_lines) == 2 # initialize + tools/call (notification has no reply)
162-
last = _decode(out_lines[-1])
163-
assert last["result"]["content"][0]["text"] == "pong"
161+
responses = [_decode(line) for line in out_lines
162+
if '"id":' in line and '"method"' not in line]
163+
assert len(responses) == 2 # initialize + tools/call
164+
assert responses[-1]["result"]["content"][0]["text"] == "pong"
164165

165166

166167
def test_tool_descriptor_includes_annotations():
@@ -1193,6 +1194,51 @@ def run_refresh():
11931194
assert os.path.realpath(fs_provider.root) == os.path.realpath(str(target))
11941195

11951196

1197+
def test_initialize_advertises_logging_capability():
1198+
server = MCPServer(tools=[])
1199+
response = _decode(server.handle_line(_request("initialize", params={})))
1200+
assert "logging" in response["result"]["capabilities"]
1201+
1202+
1203+
def test_log_bridge_emits_notification_for_log_record():
1204+
from je_auto_control.utils.mcp_server.log_bridge import MCPLogBridge
1205+
captured = []
1206+
bridge = MCPLogBridge(
1207+
notifier=lambda method, params: captured.append((method, params)),
1208+
)
1209+
import logging
1210+
record = logging.LogRecord(
1211+
name="je_auto_control.tests", level=logging.WARNING,
1212+
pathname=__file__, lineno=10, msg="something %s", args=("happened",),
1213+
exc_info=None,
1214+
)
1215+
bridge.emit(record)
1216+
assert captured
1217+
method, params = captured[0]
1218+
assert method == "notifications/message"
1219+
assert params["level"] == "warning"
1220+
assert params["data"]["message"] == "something happened"
1221+
1222+
1223+
def test_logging_set_level_request_updates_bridge_level():
1224+
from je_auto_control.utils.mcp_server.log_bridge import MCPLogBridge
1225+
server = MCPServer(tools=[], log_bridge=MCPLogBridge())
1226+
response = _decode(server.handle_line(_request("logging/setLevel", params={
1227+
"level": "error",
1228+
})))
1229+
assert response["result"] == {}
1230+
import logging
1231+
assert server._log_bridge.level == logging.ERROR
1232+
1233+
1234+
def test_logging_set_level_rejects_unknown_name():
1235+
server = MCPServer(tools=[])
1236+
response = _decode(server.handle_line(_request("logging/setLevel", params={
1237+
"level": "telepathy",
1238+
})))
1239+
assert response["error"]["code"] == -32602
1240+
1241+
11961242
def test_default_registry_lists_core_automation_tools():
11971243
names = {tool.name for tool in build_default_tool_registry()}
11981244
expected = {

0 commit comments

Comments
 (0)