Colin Jermain commited on
Commit
4a12d22
·
unverified ·
1 Parent(s): 0b67acf

Structured client-side logging (#1326)

Browse files
docs/clients/logging.mdx CHANGED
@@ -13,17 +13,39 @@ MCP servers can emit log messages to clients. The client can handle these logs t
13
 
14
  ## Log Handler
15
 
16
- Provide a `log_handler` function when creating the client:
17
 
18
  ```python
 
19
  from fastmcp import Client
20
  from fastmcp.client.logging import LogMessage
21
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  async def log_handler(message: LogMessage):
23
- level = message.level.upper()
24
- logger = message.logger or 'server'
25
- data = message.data
26
- print(f"[{level}] {logger}: {data}")
 
 
 
 
 
 
 
 
 
27
 
28
  client = Client(
29
  "my_mcp_server.py",
@@ -31,6 +53,16 @@ client = Client(
31
  )
32
  ```
33
 
 
 
 
 
 
 
 
 
 
 
34
  ### Handler Parameters
35
 
36
  The `log_handler` is called every time a log message is received. It receives a `LogMessage` object:
@@ -46,8 +78,8 @@ The `log_handler` is called every time a log message is received. It receives a
46
  The logger name (optional, may be None)
47
  </ResponseField>
48
 
49
- <ResponseField name="data" type="Any">
50
- The actual log message content
51
  </ResponseField>
52
  </Expandable>
53
  </ResponseField>
@@ -55,12 +87,15 @@ The `log_handler` is called every time a log message is received. It receives a
55
 
56
  ```python
57
  async def detailed_log_handler(message: LogMessage):
 
 
 
58
  if message.level == "error":
59
- print(f"ERROR: {message.data}")
60
  elif message.level == "warning":
61
- print(f"WARNING: {message.data}")
62
  else:
63
- print(f"{message.level.upper()}: {message.data}")
64
  ```
65
 
66
  ## Default Log Handling
@@ -73,4 +108,4 @@ client = Client("my_mcp_server.py")
73
  async with client:
74
  # Server logs will be emitted at DEBUG level automatically
75
  await client.call_tool("some_tool")
76
- ```
 
13
 
14
  ## Log Handler
15
 
16
+ Provide a `log_handler` function when creating the client. For robust logging, the log messages can be integrated with Python's standard `logging` module.
17
 
18
  ```python
19
+ import logging
20
  from fastmcp import Client
21
  from fastmcp.client.logging import LogMessage
22
 
23
+ # In a real app, you might configure this in your main entry point
24
+ logging.basicConfig(
25
+ level=logging.INFO,
26
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
27
+ )
28
+
29
+ # Get a logger for the module where the client is used
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # This mapping is useful for converting MCP level strings to Python's levels
33
+ LOGGING_LEVEL_MAP = logging.getLevelNamesMapping()
34
+
35
  async def log_handler(message: LogMessage):
36
+ """
37
+ Handles incoming logs from the MCP server and forwards them
38
+ to the standard Python logging system.
39
+ """
40
+ msg = message.data.get('msg')
41
+ extra = message.data.get('extra')
42
+
43
+ # Convert the MCP log level to a Python log level
44
+ level = LOGGING_LEVEL_MAP.get(message.level.upper(), logging.INFO)
45
+
46
+ # Log the message using the standard logging library
47
+ logger.log(level, msg, extra=extra)
48
+
49
 
50
  client = Client(
51
  "my_mcp_server.py",
 
53
  )
54
  ```
55
 
56
+ ## Handling Structured Logs
57
+
58
+ The `message.data` attribute is a dictionary that contains the log payload from the server. This enables structured logging, allowing you to receive rich, contextual information.
59
+
60
+ The dictionary contains two keys:
61
+ - `msg`: The string log message.
62
+ - `extra`: A dictionary containing any extra data sent from the server.
63
+
64
+ This structure is preserved even when logs are forwarded through a FastMCP proxy, making it a powerful tool for debugging complex, multi-server applications.
65
+
66
  ### Handler Parameters
67
 
68
  The `log_handler` is called every time a log message is received. It receives a `LogMessage` object:
 
78
  The logger name (optional, may be None)
79
  </ResponseField>
80
 
81
+ <ResponseField name="data" type="dict">
82
+ The log payload, containing `msg` and `extra` keys.
83
  </ResponseField>
84
  </Expandable>
85
  </ResponseField>
 
87
 
88
  ```python
89
  async def detailed_log_handler(message: LogMessage):
90
+ msg = message.data.get('msg')
91
+ extra = message.data.get('extra')
92
+
93
  if message.level == "error":
94
+ print(f"ERROR: {msg} | Details: {extra}")
95
  elif message.level == "warning":
96
+ print(f"WARNING: {msg} | Details: {extra}")
97
  else:
98
+ print(f"{message.level.upper()}: {msg}")
99
  ```
100
 
101
  ## Default Log Handling
 
108
  async with client:
109
  # Server logs will be emitted at DEBUG level automatically
110
  await client.call_tool("some_tool")
111
+ ```
docs/servers/logging.mdx CHANGED
@@ -53,6 +53,24 @@ async def analyze_data(data: list[float], ctx: Context) -> dict:
53
  raise
54
  ```
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  ## Logging Methods
57
 
58
  <Card icon="code" title="Context Logging Methods">
@@ -63,6 +81,9 @@ async def analyze_data(data: list[float], ctx: Context) -> dict:
63
  <ResponseField name="message" type="str">
64
  The debug message to send to the client
65
  </ResponseField>
 
 
 
66
  </Expandable>
67
  </ResponseField>
68
 
@@ -73,6 +94,9 @@ async def analyze_data(data: list[float], ctx: Context) -> dict:
73
  <ResponseField name="message" type="str">
74
  The information message to send to the client
75
  </ResponseField>
 
 
 
76
  </Expandable>
77
  </ResponseField>
78
 
@@ -83,6 +107,9 @@ async def analyze_data(data: list[float], ctx: Context) -> dict:
83
  <ResponseField name="message" type="str">
84
  The warning message to send to the client
85
  </ResponseField>
 
 
 
86
  </Expandable>
87
  </ResponseField>
88
 
@@ -93,6 +120,9 @@ async def analyze_data(data: list[float], ctx: Context) -> dict:
93
  <ResponseField name="message" type="str">
94
  The error message to send to the client
95
  </ResponseField>
 
 
 
96
  </Expandable>
97
  </ResponseField>
98
 
@@ -111,6 +141,9 @@ async def analyze_data(data: list[float], ctx: Context) -> dict:
111
  <ResponseField name="logger_name" type="str | None" default="None">
112
  Optional custom logger name for categorizing messages
113
  </ResponseField>
 
 
 
114
  </Expandable>
115
  </ResponseField>
116
  </Card>
@@ -153,10 +186,16 @@ Use for potentially harmful situations that don't prevent execution:
153
  async def validate_config(config: dict, ctx: Context) -> dict:
154
  """Validate configuration with warnings for deprecated options."""
155
  if "old_api_key" in config:
156
- await ctx.warning("Using deprecated 'old_api_key' field. Please use 'api_key' instead")
 
 
 
157
 
158
  if config.get("timeout", 30) > 300:
159
- await ctx.warning("Timeout value is very high (>5 minutes), this may cause issues")
 
 
 
160
 
161
  return {"status": "valid", "warnings": "see logs"}
162
  ```
@@ -176,7 +215,10 @@ async def batch_process(items: list[str], ctx: Context) -> dict:
176
  # Process item
177
  successful += 1
178
  except Exception as e:
179
- await ctx.error(f"Failed to process item '{item}': {str(e)}")
 
 
 
180
  failed += 1
181
 
182
  return {"successful": successful, "failed": failed}
 
53
  raise
54
  ```
55
 
56
+ ## Structured Logging with `extra`
57
+
58
+ All logging methods (`debug`, `info`, `warning`, `error`, `log`) now accept an `extra` parameter, which is a dictionary of arbitrary data. This allows you to send structured data to the client, which is useful for creating rich, queryable logs.
59
+
60
+ ```python
61
+ @mcp.tool
62
+ async def process_transaction(transaction_id: str, amount: float, ctx: Context):
63
+ await ctx.info(
64
+ f"Processing transaction {transaction_id}",
65
+ extra={
66
+ "transaction_id": transaction_id,
67
+ "amount": amount,
68
+ "currency": "USD"
69
+ }
70
+ )
71
+ # ... processing logic ...
72
+ ```
73
+
74
  ## Logging Methods
75
 
76
  <Card icon="code" title="Context Logging Methods">
 
81
  <ResponseField name="message" type="str">
82
  The debug message to send to the client
83
  </ResponseField>
84
+ <ResponseField name="extra" type="dict | None" default="None">
85
+ Optional dictionary for structured logging data
86
+ </ResponseField>
87
  </Expandable>
88
  </ResponseField>
89
 
 
94
  <ResponseField name="message" type="str">
95
  The information message to send to the client
96
  </ResponseField>
97
+ <ResponseField name="extra" type="dict | None" default="None">
98
+ Optional dictionary for structured logging data
99
+ </ResponseField>
100
  </Expandable>
101
  </ResponseField>
102
 
 
107
  <ResponseField name="message" type="str">
108
  The warning message to send to the client
109
  </ResponseField>
110
+ <ResponseField name="extra" type="dict | None" default="None">
111
+ Optional dictionary for structured logging data
112
+ </ResponseField>
113
  </Expandable>
114
  </ResponseField>
115
 
 
120
  <ResponseField name="message" type="str">
121
  The error message to send to the client
122
  </ResponseField>
123
+ <ResponseField name="extra" type="dict | None" default="None">
124
+ Optional dictionary for structured logging data
125
+ </ResponseField>
126
  </Expandable>
127
  </ResponseField>
128
 
 
141
  <ResponseField name="logger_name" type="str | None" default="None">
142
  Optional custom logger name for categorizing messages
143
  </ResponseField>
144
+ <ResponseField name="extra" type="dict | None" default="None">
145
+ Optional dictionary for structured logging data
146
+ </ResponseField>
147
  </Expandable>
148
  </ResponseField>
149
  </Card>
 
186
  async def validate_config(config: dict, ctx: Context) -> dict:
187
  """Validate configuration with warnings for deprecated options."""
188
  if "old_api_key" in config:
189
+ await ctx.warning(
190
+ "Using deprecated 'old_api_key' field. Please use 'api_key' instead",
191
+ extra={"deprecated_field": "old_api_key"}
192
+ )
193
 
194
  if config.get("timeout", 30) > 300:
195
+ await ctx.warning(
196
+ "Timeout value is very high (>5 minutes), this may cause issues",
197
+ extra={"timeout_value": config.get("timeout")}
198
+ )
199
 
200
  return {"status": "valid", "warnings": "see logs"}
201
  ```
 
215
  # Process item
216
  successful += 1
217
  except Exception as e:
218
+ await ctx.error(
219
+ f"Failed to process item '{item}': {str(e)}",
220
+ extra={"failed_item": item}
221
+ )
222
  failed += 1
223
 
224
  return {"successful": successful, "failed": failed}
src/fastmcp/server/context.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
  import asyncio
4
  import copy
5
  import warnings
6
- from collections.abc import Generator
7
  from contextlib import contextmanager
8
  from contextvars import ContextVar, Token
9
  from dataclasses import dataclass
@@ -47,6 +47,18 @@ _current_context: ContextVar[Context | None] = ContextVar("context", default=Non
47
  _flush_lock = asyncio.Lock()
48
 
49
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  @contextmanager
51
  def set_context(context: Context) -> Generator[Context, None, None]:
52
  token = _current_context.set(context)
@@ -184,6 +196,7 @@ class Context:
184
  message: str,
185
  level: LoggingLevel | None = None,
186
  logger_name: str | None = None,
 
187
  ) -> None:
188
  """Send a log message to the client.
189
 
@@ -192,12 +205,14 @@ class Context:
192
  level: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical",
193
  "alert", or "emergency". Default is "info".
194
  logger_name: Optional logger name
 
195
  """
196
  if level is None:
197
  level = "info"
 
198
  await self.session.send_log_message(
199
  level=level,
200
- data=message,
201
  logger=logger_name,
202
  related_request_id=self.request_id,
203
  )
@@ -266,21 +281,49 @@ class Context:
266
  return self.request_context.session
267
 
268
  # Convenience methods for common log levels
269
- async def debug(self, message: str, logger_name: str | None = None) -> None:
 
 
 
 
 
270
  """Send a debug log message."""
271
- await self.log(level="debug", message=message, logger_name=logger_name)
 
 
272
 
273
- async def info(self, message: str, logger_name: str | None = None) -> None:
 
 
 
 
 
274
  """Send an info log message."""
275
- await self.log(level="info", message=message, logger_name=logger_name)
 
 
276
 
277
- async def warning(self, message: str, logger_name: str | None = None) -> None:
 
 
 
 
 
278
  """Send a warning log message."""
279
- await self.log(level="warning", message=message, logger_name=logger_name)
 
 
280
 
281
- async def error(self, message: str, logger_name: str | None = None) -> None:
 
 
 
 
 
282
  """Send an error log message."""
283
- await self.log(level="error", message=message, logger_name=logger_name)
 
 
284
 
285
  async def list_roots(self) -> list[Root]:
286
  """List the roots available to the server, as indicated by the client."""
 
3
  import asyncio
4
  import copy
5
  import warnings
6
+ from collections.abc import Generator, Mapping
7
  from contextlib import contextmanager
8
  from contextvars import ContextVar, Token
9
  from dataclasses import dataclass
 
47
  _flush_lock = asyncio.Lock()
48
 
49
 
50
+ @dataclass
51
+ class LogData:
52
+ """Data object for passing log arguments to client-side handlers.
53
+
54
+ This provides an interface to match the Python standard library logging,
55
+ for compatibility with structured logging.
56
+ """
57
+
58
+ msg: str
59
+ extra: Mapping[str, Any] | None = None
60
+
61
+
62
  @contextmanager
63
  def set_context(context: Context) -> Generator[Context, None, None]:
64
  token = _current_context.set(context)
 
196
  message: str,
197
  level: LoggingLevel | None = None,
198
  logger_name: str | None = None,
199
+ extra: Mapping[str, Any] | None = None,
200
  ) -> None:
201
  """Send a log message to the client.
202
 
 
205
  level: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical",
206
  "alert", or "emergency". Default is "info".
207
  logger_name: Optional logger name
208
+ extra: Optional mapping for additional arguments
209
  """
210
  if level is None:
211
  level = "info"
212
+ data = LogData(msg=message, extra=extra)
213
  await self.session.send_log_message(
214
  level=level,
215
+ data=data,
216
  logger=logger_name,
217
  related_request_id=self.request_id,
218
  )
 
281
  return self.request_context.session
282
 
283
  # Convenience methods for common log levels
284
+ async def debug(
285
+ self,
286
+ message: str,
287
+ logger_name: str | None = None,
288
+ extra: Mapping[str, Any] | None = None,
289
+ ) -> None:
290
  """Send a debug log message."""
291
+ await self.log(
292
+ level="debug", message=message, logger_name=logger_name, extra=extra
293
+ )
294
 
295
+ async def info(
296
+ self,
297
+ message: str,
298
+ logger_name: str | None = None,
299
+ extra: Mapping[str, Any] | None = None,
300
+ ) -> None:
301
  """Send an info log message."""
302
+ await self.log(
303
+ level="info", message=message, logger_name=logger_name, extra=extra
304
+ )
305
 
306
+ async def warning(
307
+ self,
308
+ message: str,
309
+ logger_name: str | None = None,
310
+ extra: Mapping[str, Any] | None = None,
311
+ ) -> None:
312
  """Send a warning log message."""
313
+ await self.log(
314
+ level="warning", message=message, logger_name=logger_name, extra=extra
315
+ )
316
 
317
+ async def error(
318
+ self,
319
+ message: str,
320
+ logger_name: str | None = None,
321
+ extra: Mapping[str, Any] | None = None,
322
+ ) -> None:
323
  """Send an error log message."""
324
+ await self.log(
325
+ level="error", message=message, logger_name=logger_name, extra=extra
326
+ )
327
 
328
  async def list_roots(self) -> list[Root]:
329
  """List the roots available to the server, as indicated by the client."""
src/fastmcp/server/proxy.py CHANGED
@@ -591,7 +591,9 @@ class ProxyClient(Client[ClientTransportT]):
591
  A handler that forwards the log notification from the remote server to the proxy's connected clients.
592
  """
593
  ctx = get_context()
594
- await ctx.log(message.data, level=message.level, logger_name=message.logger)
 
 
595
 
596
  @classmethod
597
  async def default_progress_handler(
 
591
  A handler that forwards the log notification from the remote server to the proxy's connected clients.
592
  """
593
  ctx = get_context()
594
+ msg = message.data.get("msg")
595
+ extra = message.data.get("extra")
596
+ await ctx.log(msg, level=message.level, logger_name=message.logger, extra=extra)
597
 
598
  @classmethod
599
  async def default_progress_handler(
tests/client/test_logs.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import pytest
2
  from mcp import LoggingLevel
3
 
@@ -8,10 +10,23 @@ from fastmcp.client.logging import LogMessage
8
  class LogHandler:
9
  def __init__(self):
10
  self.logs: list[LogMessage] = []
 
 
 
 
 
 
 
 
11
 
12
  async def handle_log(self, message: LogMessage) -> None:
13
  self.logs.append(message)
14
 
 
 
 
 
 
15
 
16
  @pytest.fixture
17
  def fastmcp_server():
@@ -34,27 +49,42 @@ def fastmcp_server():
34
 
35
 
36
  class TestClientLogs:
37
- async def test_log(self, fastmcp_server: FastMCP):
 
 
38
  log_handler = LogHandler()
39
  async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
40
  await client.call_tool("log", {})
41
 
42
  assert len(log_handler.logs) == 1
43
- assert log_handler.logs[0].data == "hello?"
44
  assert log_handler.logs[0].level == "info"
45
 
46
- async def test_echo_log(self, fastmcp_server: FastMCP):
 
 
 
 
 
 
47
  log_handler = LogHandler()
48
  async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
49
  await client.call_tool("echo_log", {"message": "this is a log"})
50
 
51
  assert len(log_handler.logs) == 1
 
52
  await client.call_tool(
53
  "echo_log", {"message": "this is a warning log", "level": "warning"}
54
  )
55
  assert len(log_handler.logs) == 2
 
56
 
57
- assert log_handler.logs[0].data == "this is a log"
58
  assert log_handler.logs[0].level == "info"
59
- assert log_handler.logs[1].data == "this is a warning log"
60
  assert log_handler.logs[1].level == "warning"
 
 
 
 
 
 
1
+ import logging
2
+
3
  import pytest
4
  from mcp import LoggingLevel
5
 
 
10
  class LogHandler:
11
  def __init__(self):
12
  self.logs: list[LogMessage] = []
13
+ self.logger = logging.getLogger(__name__)
14
+ # Backwards-compatible way to get the log level mapping
15
+ if hasattr(logging, "getLevelNamesMapping"):
16
+ # For Python 3.11+
17
+ self.LOGGING_LEVEL_MAP = logging.getLevelNamesMapping() # pyright: ignore [reportAttributeAccessIssue]
18
+ else:
19
+ # For older Python versions
20
+ self.LOGGING_LEVEL_MAP = logging._nameToLevel
21
 
22
  async def handle_log(self, message: LogMessage) -> None:
23
  self.logs.append(message)
24
 
25
+ level = self.LOGGING_LEVEL_MAP[message.level.upper()]
26
+ msg = message.data.get("msg")
27
+ extra = message.data.get("extra")
28
+ self.logger.log(level, msg, extra=extra)
29
+
30
 
31
  @pytest.fixture
32
  def fastmcp_server():
 
49
 
50
 
51
  class TestClientLogs:
52
+ async def test_log(self, fastmcp_server: FastMCP, caplog):
53
+ caplog.set_level(logging.INFO, logger=__name__)
54
+
55
  log_handler = LogHandler()
56
  async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
57
  await client.call_tool("log", {})
58
 
59
  assert len(log_handler.logs) == 1
60
+ assert log_handler.logs[0].data["msg"] == "hello?"
61
  assert log_handler.logs[0].level == "info"
62
 
63
+ assert len(caplog.records) == 1
64
+ assert caplog.records[0].msg == "hello?"
65
+ assert caplog.records[0].levelname == "INFO"
66
+
67
+ async def test_echo_log(self, fastmcp_server: FastMCP, caplog):
68
+ caplog.set_level(logging.INFO, logger=__name__)
69
+
70
  log_handler = LogHandler()
71
  async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
72
  await client.call_tool("echo_log", {"message": "this is a log"})
73
 
74
  assert len(log_handler.logs) == 1
75
+ assert len(caplog.records) == 1
76
  await client.call_tool(
77
  "echo_log", {"message": "this is a warning log", "level": "warning"}
78
  )
79
  assert len(log_handler.logs) == 2
80
+ assert len(caplog.records) == 2
81
 
82
+ assert log_handler.logs[0].data["msg"] == "this is a log"
83
  assert log_handler.logs[0].level == "info"
84
+ assert log_handler.logs[1].data["msg"] == "this is a warning log"
85
  assert log_handler.logs[1].level == "warning"
86
+
87
+ assert caplog.records[0].msg == "this is a log"
88
+ assert caplog.records[0].levelname == "INFO"
89
+ assert caplog.records[1].msg == "this is a warning log"
90
+ assert caplog.records[1].levelname == "WARNING"
tests/test_mcp_config.py CHANGED
@@ -1,4 +1,5 @@
1
  import inspect
 
2
  import tempfile
3
  from collections.abc import AsyncGenerator
4
  from pathlib import Path
@@ -281,10 +282,12 @@ async def test_remote_config_with_oauth_literal():
281
  assert isinstance(client.transport.transport.auth, OAuthClientProvider)
282
 
283
 
284
- async def test_multi_client_with_logging(tmp_path: Path):
285
  """
286
  Tests that logging is properly forwarded to the ultimate client.
287
  """
 
 
288
  server_script = inspect.cleandoc("""
289
  from fastmcp import FastMCP, Context
290
 
@@ -317,14 +320,31 @@ async def test_multi_client_with_logging(tmp_path: Path):
317
 
318
  MESSAGES = []
319
 
 
 
 
 
 
 
 
 
 
320
  async def log_handler(message: LogMessage):
321
  MESSAGES.append(message)
322
 
 
 
 
 
 
323
  async with Client(config, log_handler=log_handler) as client:
324
  result = await client.call_tool("test_server_log_test", {"message": "test 42"})
325
  assert result.data == 42
326
  assert len(MESSAGES) == 1
327
- assert MESSAGES[0].data == "test 42"
 
 
 
328
 
329
 
330
  async def test_multi_client_with_transforms(tmp_path: Path):
 
1
  import inspect
2
+ import logging
3
  import tempfile
4
  from collections.abc import AsyncGenerator
5
  from pathlib import Path
 
282
  assert isinstance(client.transport.transport.auth, OAuthClientProvider)
283
 
284
 
285
+ async def test_multi_client_with_logging(tmp_path: Path, caplog):
286
  """
287
  Tests that logging is properly forwarded to the ultimate client.
288
  """
289
+ caplog.set_level(logging.INFO, logger=__name__)
290
+
291
  server_script = inspect.cleandoc("""
292
  from fastmcp import FastMCP, Context
293
 
 
320
 
321
  MESSAGES = []
322
 
323
+ logger = logging.getLogger(__name__)
324
+ # Backwards-compatible way to get the log level mapping
325
+ if hasattr(logging, "getLevelNamesMapping"):
326
+ # For Python 3.11+
327
+ LOGGING_LEVEL_MAP = logging.getLevelNamesMapping() # pyright: ignore [reportAttributeAccessIssue]
328
+ else:
329
+ # For older Python versions
330
+ LOGGING_LEVEL_MAP = logging._nameToLevel
331
+
332
  async def log_handler(message: LogMessage):
333
  MESSAGES.append(message)
334
 
335
+ level = LOGGING_LEVEL_MAP[message.level.upper()]
336
+ msg = message.data.get("msg")
337
+ extra = message.data.get("extra")
338
+ logger.log(level, msg, extra=extra)
339
+
340
  async with Client(config, log_handler=log_handler) as client:
341
  result = await client.call_tool("test_server_log_test", {"message": "test 42"})
342
  assert result.data == 42
343
  assert len(MESSAGES) == 1
344
+ assert MESSAGES[0].data["msg"] == "test 42"
345
+
346
+ assert len(caplog.records) == 1
347
+ assert caplog.records[0].msg == "test 42"
348
 
349
 
350
  async def test_multi_client_with_transforms(tmp_path: Path):