fastmcp 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fastmcp/server.py CHANGED
@@ -2,27 +2,46 @@
2
2
 
3
3
  import asyncio
4
4
  import functools
5
+ import inspect
5
6
  import json
6
- import logging
7
- from typing import Any, Callable, Optional, Sequence, Union, Literal
7
+ import re
8
+ from itertools import chain
9
+ from typing import Any, Callable, Dict, Literal, Sequence
8
10
 
9
- import pydantic.json
11
+ import pydantic_core
12
+ import uvicorn
10
13
  from mcp.server import Server as MCPServer
11
- from mcp.server.stdio import stdio_server
12
14
  from mcp.server.sse import SseServerTransport
15
+ from mcp.server.stdio import stdio_server
16
+ from mcp.shared.context import RequestContext
13
17
  from mcp.types import (
14
- Resource as MCPResource,
15
- Tool,
16
- TextContent,
18
+ EmbeddedResource,
19
+ GetPromptResult,
17
20
  ImageContent,
21
+ TextContent,
22
+ )
23
+ from mcp.types import (
24
+ Prompt as MCPPrompt,
25
+ )
26
+ from mcp.types import (
27
+ Resource as MCPResource,
28
+ )
29
+ from mcp.types import (
30
+ ResourceTemplate as MCPResourceTemplate,
31
+ )
32
+ from mcp.types import (
33
+ Tool as MCPTool,
18
34
  )
19
- from pydantic_settings import BaseSettings
20
- from pydantic.networks import _BaseUrl
35
+ from pydantic import BaseModel
36
+ from pydantic.networks import AnyUrl
37
+ from pydantic_settings import BaseSettings, SettingsConfigDict
21
38
 
22
- from .exceptions import ResourceError
23
- from .resources import Resource, FunctionResource, ResourceManager
24
- from .tools import ToolManager, Image
25
- from .utilities.logging import get_logger, configure_logging
39
+ from fastmcp.exceptions import ResourceError
40
+ from fastmcp.prompts import Prompt, PromptManager
41
+ from fastmcp.resources import FunctionResource, Resource, ResourceManager
42
+ from fastmcp.tools import ToolManager
43
+ from fastmcp.utilities.logging import configure_logging, get_logger
44
+ from fastmcp.utilities.types import Image
26
45
 
27
46
  logger = get_logger(__name__)
28
47
 
@@ -34,13 +53,11 @@ class Settings(BaseSettings):
34
53
  For example, FASTMCP_DEBUG=true will set debug=True.
35
54
  """
36
55
 
37
- model_config: dict = dict(env_prefix="FASTMCP_")
56
+ model_config: SettingsConfigDict = SettingsConfigDict(env_prefix="FASTMCP_")
38
57
 
39
58
  # Server settings
40
59
  debug: bool = False
41
- log_level: Literal[
42
- logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL
43
- ] = logging.INFO
60
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
44
61
 
45
62
  # HTTP settings
46
63
  host: str = "0.0.0.0"
@@ -52,9 +69,12 @@ class Settings(BaseSettings):
52
69
  # tool settings
53
70
  warn_on_duplicate_tools: bool = True
54
71
 
72
+ # prompt settings
73
+ warn_on_duplicate_prompts: bool = True
74
+
55
75
 
56
76
  class FastMCP:
57
- def __init__(self, name=None, **settings: Optional[Settings]):
77
+ def __init__(self, name: str | None = None, **settings: Any):
58
78
  self.settings = Settings(**settings)
59
79
  self._mcp_server = MCPServer(name=name or "FastMCP")
60
80
  self._tool_manager = ToolManager(
@@ -63,6 +83,9 @@ class FastMCP:
63
83
  self._resource_manager = ResourceManager(
64
84
  warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
65
85
  )
86
+ self._prompt_manager = PromptManager(
87
+ warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts
88
+ )
66
89
 
67
90
  # Set up MCP protocol handlers
68
91
  self._setup_handlers()
@@ -95,12 +118,16 @@ class FastMCP:
95
118
  self._mcp_server.call_tool()(self.call_tool)
96
119
  self._mcp_server.list_resources()(self.list_resources)
97
120
  self._mcp_server.read_resource()(self.read_resource)
121
+ self._mcp_server.list_prompts()(self.list_prompts)
122
+ self._mcp_server.get_prompt()(self.get_prompt)
123
+ # TODO: This has not been added to MCP yet, see https://github.com/jlowin/fastmcp/issues/10
124
+ # self._mcp_server.list_resource_templates()(self.list_resource_templates)
98
125
 
99
- async def list_tools(self) -> list[Tool]:
126
+ async def list_tools(self) -> list[MCPTool]:
100
127
  """List all available tools."""
101
128
  tools = self._tool_manager.list_tools()
102
129
  return [
103
- Tool(
130
+ MCPTool(
104
131
  name=info.name,
105
132
  description=info.description,
106
133
  inputSchema=info.parameters,
@@ -108,22 +135,25 @@ class FastMCP:
108
135
  for info in tools
109
136
  ]
110
137
 
138
+ def get_context(self) -> "Context":
139
+ """
140
+ Returns a Context object. Note that the context will only be valid
141
+ during a request; outside a request, most methods will error.
142
+ """
143
+ try:
144
+ request_context = self._mcp_server.request_context
145
+ except LookupError:
146
+ request_context = None
147
+ return Context(request_context=request_context, fastmcp=self)
148
+
111
149
  async def call_tool(
112
150
  self, name: str, arguments: dict
113
- ) -> Sequence[Union[TextContent, ImageContent]]:
151
+ ) -> Sequence[TextContent | ImageContent]:
114
152
  """Call a tool by name with arguments."""
115
- try:
116
- result = await self._tool_manager.call_tool(name, arguments)
117
- return self._convert_to_content(result)
118
- except Exception as e:
119
- logger.error(f"Error calling tool {name}: {e}")
120
- return [
121
- TextContent(
122
- type="text",
123
- text=str(e),
124
- is_error=True,
125
- )
126
- ]
153
+ context = self.get_context()
154
+ result = await self._tool_manager.call_tool(name, arguments, context=context)
155
+ converted_result = _convert_to_content(result)
156
+ return converted_result
127
157
 
128
158
  async def list_resources(self) -> list[MCPResource]:
129
159
  """List all available resources."""
@@ -132,16 +162,27 @@ class FastMCP:
132
162
  return [
133
163
  MCPResource(
134
164
  uri=resource.uri,
135
- name=resource.name,
165
+ name=resource.name or "",
136
166
  description=resource.description,
137
167
  mimeType=resource.mime_type,
138
168
  )
139
169
  for resource in resources
140
170
  ]
141
171
 
142
- async def read_resource(self, uri: _BaseUrl) -> Union[str, bytes]:
172
+ async def list_resource_templates(self) -> list[MCPResourceTemplate]:
173
+ templates = self._resource_manager.list_templates()
174
+ return [
175
+ MCPResourceTemplate(
176
+ uriTemplate=template.uri_template,
177
+ name=template.name,
178
+ description=template.description,
179
+ )
180
+ for template in templates
181
+ ]
182
+
183
+ async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
143
184
  """Read a resource by URI."""
144
- resource = self._resource_manager.get_resource(uri)
185
+ resource = await self._resource_manager.get_resource(uri)
145
186
  if not resource:
146
187
  raise ResourceError(f"Unknown resource: {uri}")
147
188
 
@@ -151,64 +192,49 @@ class FastMCP:
151
192
  logger.error(f"Error reading resource {uri}: {e}")
152
193
  raise ResourceError(str(e))
153
194
 
154
- def _convert_to_content(
155
- self, value: Any
156
- ) -> Sequence[Union[TextContent, ImageContent]]:
157
- """Convert a tool result to MCP content types."""
158
-
159
- # Already a sequence of valid content types
160
- if isinstance(value, (list, tuple)):
161
- if all(isinstance(x, (TextContent, ImageContent)) for x in value):
162
- return value
163
- # Handle mixed content including Image objects
164
- result = []
165
- for item in value:
166
- if isinstance(item, (TextContent, ImageContent)):
167
- result.append(item)
168
- elif isinstance(item, Image):
169
- result.append(item.to_image_content())
170
- else:
171
- result.append(
172
- TextContent(
173
- type="text",
174
- text=json.dumps(
175
- item, indent=2, default=pydantic.json.pydantic_encoder
176
- ),
177
- )
178
- )
179
- return result
195
+ def add_tool(
196
+ self,
197
+ fn: Callable,
198
+ name: str | None = None,
199
+ description: str | None = None,
200
+ ) -> None:
201
+ """Add a tool to the server.
180
202
 
181
- # Single content type
182
- if isinstance(value, (TextContent, ImageContent)):
183
- return [value]
203
+ The tool function can optionally request a Context object by adding a parameter
204
+ with the Context type annotation. See the @tool decorator for examples.
184
205
 
185
- # Image helper
186
- if isinstance(value, Image):
187
- return [value.to_image_content()]
206
+ Args:
207
+ fn: The function to register as a tool
208
+ name: Optional name for the tool (defaults to function name)
209
+ description: Optional description of what the tool does
210
+ """
211
+ self._tool_manager.add_tool(fn, name=name, description=description)
188
212
 
189
- # All other types - convert to JSON string with pydantic encoder
190
- return [
191
- TextContent(
192
- type="text",
193
- text=json.dumps(
194
- value, indent=2, default=pydantic.json.pydantic_encoder
195
- ),
196
- )
197
- ]
213
+ def tool(self, name: str | None = None, description: str | None = None) -> Callable:
214
+ """Decorator to register a tool.
198
215
 
199
- def add_tool(
200
- self,
201
- func: Callable,
202
- name: Optional[str] = None,
203
- description: Optional[str] = None,
204
- ) -> None:
205
- """Add a tool to the server."""
206
- self._tool_manager.add_tool(func, name=name, description=description)
216
+ Tools can optionally request a Context object by adding a parameter with the Context type annotation.
217
+ The context provides access to MCP capabilities like logging, progress reporting, and resource access.
207
218
 
208
- def tool(
209
- self, name: Optional[str] = None, description: Optional[str] = None
210
- ) -> Callable:
211
- """Decorator to register a tool."""
219
+ Args:
220
+ name: Optional name for the tool (defaults to function name)
221
+ description: Optional description of what the tool does
222
+
223
+ Example:
224
+ @server.tool()
225
+ def my_tool(x: int) -> str:
226
+ return str(x)
227
+
228
+ @server.tool()
229
+ def tool_with_context(x: int, ctx: Context) -> str:
230
+ ctx.info(f"Processing {x}")
231
+ return str(x)
232
+
233
+ @server.tool()
234
+ async def async_tool(x: int, context: Context) -> str:
235
+ await context.report_progress(50, 100)
236
+ return str(x)
237
+ """
212
238
  # Check if user passed function directly instead of calling decorator
213
239
  if callable(name):
214
240
  raise TypeError(
@@ -216,9 +242,9 @@ class FastMCP:
216
242
  "Did you forget to call it? Use @tool() instead of @tool"
217
243
  )
218
244
 
219
- def decorator(func: Callable) -> Callable:
220
- self.add_tool(func, name=name, description=description)
221
- return func
245
+ def decorator(fn: Callable) -> Callable:
246
+ self.add_tool(fn, name=name, description=description)
247
+ return fn
222
248
 
223
249
  return decorator
224
250
 
@@ -234,16 +260,24 @@ class FastMCP:
234
260
  self,
235
261
  uri: str,
236
262
  *,
237
- name: Optional[str] = None,
238
- description: Optional[str] = None,
239
- mime_type: Optional[str] = None,
263
+ name: str | None = None,
264
+ description: str | None = None,
265
+ mime_type: str | None = None,
240
266
  ) -> Callable:
241
267
  """Decorator to register a function as a resource.
242
268
 
243
269
  The function will be called when the resource is read to generate its content.
270
+ The function can return:
271
+ - str for text content
272
+ - bytes for binary content
273
+ - other types will be converted to JSON
274
+
275
+ If the URI contains parameters (e.g. "resource://{param}") or the function
276
+ has parameters, it will be registered as a template resource.
244
277
 
245
278
  Args:
246
- uri: URI for the resource (e.g. "resource://my-resource")
279
+ uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
280
+ name: Optional name for the resource
247
281
  description: Optional description of the resource
248
282
  mime_type: Optional MIME type for the resource
249
283
 
@@ -251,6 +285,10 @@ class FastMCP:
251
285
  @server.resource("resource://my-resource")
252
286
  def get_data() -> str:
253
287
  return "Hello, world!"
288
+
289
+ @server.resource("resource://{city}/weather")
290
+ def get_weather(city: str) -> str:
291
+ return f"Weather for {city}"
254
292
  """
255
293
  # Check if user passed function directly instead of calling decorator
256
294
  if callable(uri):
@@ -259,26 +297,110 @@ class FastMCP:
259
297
  "Did you forget to call it? Use @resource('uri') instead of @resource"
260
298
  )
261
299
 
262
- def decorator(func: Callable) -> Callable:
263
- @functools.wraps(func)
264
- def wrapper() -> Any:
265
- return func()
266
-
267
- resource = FunctionResource(
268
- uri=uri,
269
- name=name,
270
- description=description,
271
- mime_type=mime_type or "text/plain",
272
- func=wrapper,
273
- )
274
- self.add_resource(resource)
300
+ def decorator(fn: Callable) -> Callable:
301
+ @functools.wraps(fn)
302
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
303
+ return fn(*args, **kwargs)
304
+
305
+ # Check if this should be a template
306
+ has_uri_params = "{" in uri and "}" in uri
307
+ has_func_params = bool(inspect.signature(fn).parameters)
308
+
309
+ if has_uri_params or has_func_params:
310
+ # Validate that URI params match function params
311
+ uri_params = set(re.findall(r"{(\w+)}", uri))
312
+ func_params = set(inspect.signature(fn).parameters.keys())
313
+
314
+ if uri_params != func_params:
315
+ raise ValueError(
316
+ f"Mismatch between URI parameters {uri_params} "
317
+ f"and function parameters {func_params}"
318
+ )
319
+
320
+ # Register as template
321
+ self._resource_manager.add_template(
322
+ wrapper,
323
+ uri_template=uri,
324
+ name=name,
325
+ description=description,
326
+ mime_type=mime_type or "text/plain",
327
+ )
328
+ else:
329
+ # Register as regular resource
330
+ resource = FunctionResource(
331
+ uri=AnyUrl(uri),
332
+ name=name,
333
+ description=description,
334
+ mime_type=mime_type or "text/plain",
335
+ fn=wrapper,
336
+ )
337
+ self.add_resource(resource)
275
338
  return wrapper
276
339
 
277
340
  return decorator
278
341
 
342
+ def add_prompt(self, prompt: Prompt) -> None:
343
+ """Add a prompt to the server.
344
+
345
+ Args:
346
+ prompt: A Prompt instance to add
347
+ """
348
+ self._prompt_manager.add_prompt(prompt)
349
+
350
+ def prompt(
351
+ self, name: str | None = None, description: str | None = None
352
+ ) -> Callable:
353
+ """Decorator to register a prompt.
354
+
355
+ Args:
356
+ name: Optional name for the prompt (defaults to function name)
357
+ description: Optional description of what the prompt does
358
+
359
+ Example:
360
+ @server.prompt()
361
+ def analyze_table(table_name: str) -> list[Message]:
362
+ schema = read_table_schema(table_name)
363
+ return [
364
+ {
365
+ "role": "user",
366
+ "content": f"Analyze this schema:\n{schema}"
367
+ }
368
+ ]
369
+
370
+ @server.prompt()
371
+ async def analyze_file(path: str) -> list[Message]:
372
+ content = await read_file(path)
373
+ return [
374
+ {
375
+ "role": "user",
376
+ "content": {
377
+ "type": "resource",
378
+ "resource": {
379
+ "uri": f"file://{path}",
380
+ "text": content
381
+ }
382
+ }
383
+ }
384
+ ]
385
+ """
386
+ # Check if user passed function directly instead of calling decorator
387
+ if callable(name):
388
+ raise TypeError(
389
+ "The @prompt decorator was used incorrectly. "
390
+ "Did you forget to call it? Use @prompt() instead of @prompt"
391
+ )
392
+
393
+ def decorator(func: Callable) -> Callable:
394
+ prompt = Prompt.from_function(func, name=name, description=description)
395
+ self.add_prompt(prompt)
396
+ return func
397
+
398
+ return decorator
399
+
279
400
  async def run_stdio_async(self) -> None:
280
401
  """Run the server using stdio transport."""
281
402
  async with stdio_server() as (read_stream, write_stream):
403
+ logger.info(f'Starting "{self.name}"...')
282
404
  await self._mcp_server.run(
283
405
  read_stream,
284
406
  write_stream,
@@ -289,7 +411,6 @@ class FastMCP:
289
411
  """Run the server using SSE transport."""
290
412
  from starlette.applications import Starlette
291
413
  from starlette.routing import Route
292
- import uvicorn
293
414
 
294
415
  sse = SseServerTransport("/messages")
295
416
 
@@ -314,9 +435,222 @@ class FastMCP:
314
435
  ],
315
436
  )
316
437
 
317
- uvicorn.run(
438
+ config = uvicorn.Config(
318
439
  starlette_app,
319
440
  host=self.settings.host,
320
441
  port=self.settings.port,
321
- log_level=self.settings.log_level,
442
+ log_level=self.settings.log_level.lower(),
443
+ )
444
+ server = uvicorn.Server(config)
445
+ await server.serve()
446
+
447
+ async def list_prompts(self) -> list[MCPPrompt]:
448
+ """List all available prompts."""
449
+ prompts = self._prompt_manager.list_prompts()
450
+ return [
451
+ MCPPrompt(
452
+ name=prompt.name,
453
+ description=prompt.description,
454
+ arguments=[
455
+ {
456
+ "name": arg.name,
457
+ "description": arg.description,
458
+ "required": arg.required,
459
+ }
460
+ for arg in (prompt.arguments or [])
461
+ ],
462
+ )
463
+ for prompt in prompts
464
+ ]
465
+
466
+ async def get_prompt(
467
+ self, name: str, arguments: Dict[str, Any] | None = None
468
+ ) -> GetPromptResult:
469
+ """Get a prompt by name with arguments."""
470
+ try:
471
+ messages = await self._prompt_manager.render_prompt(name, arguments)
472
+
473
+ return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
474
+ except Exception as e:
475
+ logger.error(f"Error getting prompt {name}: {e}")
476
+ raise ValueError(str(e))
477
+
478
+
479
+ def _convert_to_content(
480
+ result: Any,
481
+ ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
482
+ """Convert a result to a sequence of content objects."""
483
+ if result is None:
484
+ return []
485
+
486
+ if isinstance(result, (TextContent, ImageContent, EmbeddedResource)):
487
+ return [result]
488
+
489
+ if isinstance(result, Image):
490
+ return [result.to_image_content()]
491
+
492
+ if isinstance(result, (list, tuple)):
493
+ return list(chain.from_iterable(_convert_to_content(item) for item in result))
494
+
495
+ if not isinstance(result, str):
496
+ try:
497
+ result = json.dumps(pydantic_core.to_jsonable_python(result))
498
+ except Exception:
499
+ result = str(result)
500
+
501
+ return [TextContent(type="text", text=result)]
502
+
503
+
504
+ class Context(BaseModel):
505
+ """Context object providing access to MCP capabilities.
506
+
507
+ This provides a cleaner interface to MCP's RequestContext functionality.
508
+ It gets injected into tool and resource functions that request it via type hints.
509
+
510
+ To use context in a tool function, add a parameter with the Context type annotation:
511
+
512
+ ```python
513
+ @server.tool()
514
+ def my_tool(x: int, ctx: Context) -> str:
515
+ # Log messages to the client
516
+ ctx.info(f"Processing {x}")
517
+ ctx.debug("Debug info")
518
+ ctx.warning("Warning message")
519
+ ctx.error("Error message")
520
+
521
+ # Report progress
522
+ ctx.report_progress(50, 100)
523
+
524
+ # Access resources
525
+ data = ctx.read_resource("resource://data")
526
+
527
+ # Get request info
528
+ request_id = ctx.request_id
529
+ client_id = ctx.client_id
530
+
531
+ return str(x)
532
+ ```
533
+
534
+ The context parameter name can be anything as long as it's annotated with Context.
535
+ The context is optional - tools that don't need it can omit the parameter.
536
+ """
537
+
538
+ _request_context: RequestContext | None
539
+ _fastmcp: FastMCP | None
540
+
541
+ def __init__(
542
+ self,
543
+ *,
544
+ request_context: RequestContext | None = None,
545
+ fastmcp: FastMCP | None = None,
546
+ **kwargs: Any,
547
+ ):
548
+ super().__init__(**kwargs)
549
+ self._request_context = request_context
550
+ self._fastmcp = fastmcp
551
+
552
+ @property
553
+ def fastmcp(self) -> FastMCP:
554
+ """Access to the FastMCP server."""
555
+ if self._fastmcp is None:
556
+ raise ValueError("Context is not available outside of a request")
557
+ return self._fastmcp
558
+
559
+ @property
560
+ def request_context(self) -> RequestContext:
561
+ """Access to the underlying request context."""
562
+ if self._request_context is None:
563
+ raise ValueError("Context is not available outside of a request")
564
+ return self._request_context
565
+
566
+ async def report_progress(
567
+ self, progress: float, total: float | None = None
568
+ ) -> None:
569
+ """Report progress for the current operation.
570
+
571
+ Args:
572
+ progress: Current progress value e.g. 24
573
+ total: Optional total value e.g. 100
574
+ """
575
+
576
+ progress_token = (
577
+ self.request_context.meta.progressToken
578
+ if self.request_context.meta
579
+ else None
322
580
  )
581
+
582
+ if not progress_token:
583
+ return
584
+
585
+ await self.request_context.session.send_progress_notification(
586
+ progress_token=progress_token, progress=progress, total=total
587
+ )
588
+
589
+ async def read_resource(self, uri: str | AnyUrl) -> str | bytes:
590
+ """Read a resource by URI.
591
+
592
+ Args:
593
+ uri: Resource URI to read
594
+
595
+ Returns:
596
+ The resource content as either text or bytes
597
+ """
598
+ assert (
599
+ self._fastmcp is not None
600
+ ), "Context is not available outside of a request"
601
+ return await self._fastmcp.read_resource(uri)
602
+
603
+ def log(
604
+ self,
605
+ level: Literal["debug", "info", "warning", "error"],
606
+ message: str,
607
+ *,
608
+ logger_name: str | None = None,
609
+ ) -> None:
610
+ """Send a log message to the client.
611
+
612
+ Args:
613
+ level: Log level (debug, info, warning, error)
614
+ message: Log message
615
+ logger_name: Optional logger name
616
+ **extra: Additional structured data to include
617
+ """
618
+ self.request_context.session.send_log_message(
619
+ level=level, data=message, logger=logger_name
620
+ )
621
+
622
+ @property
623
+ def client_id(self) -> str | None:
624
+ """Get the client ID if available."""
625
+ return (
626
+ getattr(self.request_context.meta, "client_id", None)
627
+ if self.request_context.meta
628
+ else None
629
+ )
630
+
631
+ @property
632
+ def request_id(self) -> str:
633
+ """Get the unique ID for this request."""
634
+ return str(self.request_context.request_id)
635
+
636
+ @property
637
+ def session(self):
638
+ """Access to the underlying session for advanced usage."""
639
+ return self.request_context.session
640
+
641
+ # Convenience methods for common log levels
642
+ def debug(self, message: str, **extra: Any) -> None:
643
+ """Send a debug log message."""
644
+ self.log("debug", message, **extra)
645
+
646
+ def info(self, message: str, **extra: Any) -> None:
647
+ """Send an info log message."""
648
+ self.log("info", message, **extra)
649
+
650
+ def warning(self, message: str, **extra: Any) -> None:
651
+ """Send a warning log message."""
652
+ self.log("warning", message, **extra)
653
+
654
+ def error(self, message: str, **extra: Any) -> None:
655
+ """Send an error log message."""
656
+ self.log("error", message, **extra)