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