fastmcp 0.1.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
@@ -1,23 +1,47 @@
1
1
  """FastMCP - A more ergonomic interface for MCP servers."""
2
2
 
3
3
  import asyncio
4
- import base64
5
4
  import functools
5
+ import inspect
6
6
  import json
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
 
11
+ import pydantic_core
12
+ import uvicorn
9
13
  from mcp.server import Server as MCPServer
10
- from mcp.server.stdio import stdio_server
11
14
  from mcp.server.sse import SseServerTransport
12
- from mcp.types import Resource as MCPResource
13
- from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
15
+ from mcp.server.stdio import stdio_server
16
+ from mcp.shared.context import RequestContext
17
+ from mcp.types import (
18
+ EmbeddedResource,
19
+ GetPromptResult,
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,
34
+ )
14
35
  from pydantic import BaseModel
15
- from pydantic_settings import BaseSettings
16
- from pydantic.networks import _BaseUrl
17
- from .exceptions import ResourceError
18
- from .resources import Resource, FunctionResource, ResourceManager
19
- from .tools import ToolManager
20
- from .utilities.logging import get_logger, configure_logging
36
+ from pydantic.networks import AnyUrl
37
+ from pydantic_settings import BaseSettings, SettingsConfigDict
38
+
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
21
45
 
22
46
  logger = get_logger(__name__)
23
47
 
@@ -29,7 +53,7 @@ class Settings(BaseSettings):
29
53
  For example, FASTMCP_DEBUG=true will set debug=True.
30
54
  """
31
55
 
32
- model_config: dict = dict(env_prefix="FASTMCP_")
56
+ model_config: SettingsConfigDict = SettingsConfigDict(env_prefix="FASTMCP_")
33
57
 
34
58
  # Server settings
35
59
  debug: bool = False
@@ -45,9 +69,12 @@ class Settings(BaseSettings):
45
69
  # tool settings
46
70
  warn_on_duplicate_tools: bool = True
47
71
 
72
+ # prompt settings
73
+ warn_on_duplicate_prompts: bool = True
74
+
48
75
 
49
76
  class FastMCP:
50
- def __init__(self, name=None, **settings: Optional[Settings]):
77
+ def __init__(self, name: str | None = None, **settings: Any):
51
78
  self.settings = Settings(**settings)
52
79
  self._mcp_server = MCPServer(name=name or "FastMCP")
53
80
  self._tool_manager = ToolManager(
@@ -56,6 +83,9 @@ class FastMCP:
56
83
  self._resource_manager = ResourceManager(
57
84
  warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
58
85
  )
86
+ self._prompt_manager = PromptManager(
87
+ warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts
88
+ )
59
89
 
60
90
  # Set up MCP protocol handlers
61
91
  self._setup_handlers()
@@ -73,12 +103,14 @@ class FastMCP:
73
103
  Args:
74
104
  transport: Transport protocol to use ("stdio" or "sse")
75
105
  """
106
+ TRANSPORTS = Literal["stdio", "sse"]
107
+ if transport not in TRANSPORTS.__args__: # type: ignore
108
+ raise ValueError(f"Unknown transport: {transport}")
109
+
76
110
  if transport == "stdio":
77
111
  asyncio.run(self.run_stdio_async())
78
- elif transport == "sse":
112
+ else: # transport == "sse"
79
113
  asyncio.run(self.run_sse_async())
80
- else:
81
- raise ValueError(f"Unknown transport: {transport}")
82
114
 
83
115
  def _setup_handlers(self) -> None:
84
116
  """Set up core MCP protocol handlers."""
@@ -86,12 +118,16 @@ class FastMCP:
86
118
  self._mcp_server.call_tool()(self.call_tool)
87
119
  self._mcp_server.list_resources()(self.list_resources)
88
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)
89
125
 
90
- async def list_tools(self) -> list[Tool]:
126
+ async def list_tools(self) -> list[MCPTool]:
91
127
  """List all available tools."""
92
128
  tools = self._tool_manager.list_tools()
93
129
  return [
94
- Tool(
130
+ MCPTool(
95
131
  name=info.name,
96
132
  description=info.description,
97
133
  inputSchema=info.parameters,
@@ -99,29 +135,54 @@ class FastMCP:
99
135
  for info in tools
100
136
  ]
101
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
+
102
149
  async def call_tool(
103
150
  self, name: str, arguments: dict
104
- ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]:
151
+ ) -> Sequence[TextContent | ImageContent]:
105
152
  """Call a tool by name with arguments."""
106
- result = await self._tool_manager.call_tool(name, arguments)
107
- return [self._convert_to_content(result)]
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
108
157
 
109
158
  async def list_resources(self) -> list[MCPResource]:
110
159
  """List all available resources."""
160
+
111
161
  resources = self._resource_manager.list_resources()
112
162
  return [
113
163
  MCPResource(
114
164
  uri=resource.uri,
115
- name=resource.name,
165
+ name=resource.name or "",
116
166
  description=resource.description,
117
167
  mimeType=resource.mime_type,
118
168
  )
119
169
  for resource in resources
120
170
  ]
121
171
 
122
- 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:
123
184
  """Read a resource by URI."""
124
- resource = self._resource_manager.get_resource(uri)
185
+ resource = await self._resource_manager.get_resource(uri)
125
186
  if not resource:
126
187
  raise ResourceError(f"Unknown resource: {uri}")
127
188
 
@@ -131,37 +192,49 @@ class FastMCP:
131
192
  logger.error(f"Error reading resource {uri}: {e}")
132
193
  raise ResourceError(str(e))
133
194
 
134
- def _convert_to_content(
135
- self, value: Any
136
- ) -> Union[TextContent, ImageContent, EmbeddedResource]:
137
- """Convert Python values to MCP content types."""
138
- if isinstance(value, (dict, list)):
139
- return TextContent(type="text", text=json.dumps(value, indent=2))
140
- if isinstance(value, str):
141
- return TextContent(type="text", text=value)
142
- if isinstance(value, bytes):
143
- return ImageContent(
144
- type="image",
145
- data=base64.b64encode(value).decode(),
146
- mimeType="application/octet-stream",
147
- )
148
- if isinstance(value, BaseModel):
149
- return TextContent(type="text", text=value.model_dump_json(indent=2))
150
- return TextContent(type="text", text=str(value))
151
-
152
195
  def add_tool(
153
196
  self,
154
- func: Callable,
155
- name: Optional[str] = None,
156
- description: Optional[str] = None,
197
+ fn: Callable,
198
+ name: str | None = None,
199
+ description: str | None = None,
157
200
  ) -> None:
158
- """Add a tool to the server."""
159
- self._tool_manager.add_tool(func, name=name, description=description)
201
+ """Add a tool to the server.
160
202
 
161
- def tool(
162
- self, name: Optional[str] = None, description: Optional[str] = None
163
- ) -> Callable:
164
- """Decorator to register a tool."""
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.
205
+
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)
212
+
213
+ def tool(self, name: str | None = None, description: str | None = None) -> Callable:
214
+ """Decorator to register a tool.
215
+
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.
218
+
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
+ """
165
238
  # Check if user passed function directly instead of calling decorator
166
239
  if callable(name):
167
240
  raise TypeError(
@@ -169,9 +242,9 @@ class FastMCP:
169
242
  "Did you forget to call it? Use @tool() instead of @tool"
170
243
  )
171
244
 
172
- def decorator(func: Callable) -> Callable:
173
- self.add_tool(func, name=name, description=description)
174
- return func
245
+ def decorator(fn: Callable) -> Callable:
246
+ self.add_tool(fn, name=name, description=description)
247
+ return fn
175
248
 
176
249
  return decorator
177
250
 
@@ -187,16 +260,24 @@ class FastMCP:
187
260
  self,
188
261
  uri: str,
189
262
  *,
190
- name: Optional[str] = None,
191
- description: Optional[str] = None,
192
- mime_type: Optional[str] = None,
263
+ name: str | None = None,
264
+ description: str | None = None,
265
+ mime_type: str | None = None,
193
266
  ) -> Callable:
194
267
  """Decorator to register a function as a resource.
195
268
 
196
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.
197
277
 
198
278
  Args:
199
- 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
200
281
  description: Optional description of the resource
201
282
  mime_type: Optional MIME type for the resource
202
283
 
@@ -204,6 +285,10 @@ class FastMCP:
204
285
  @server.resource("resource://my-resource")
205
286
  def get_data() -> str:
206
287
  return "Hello, world!"
288
+
289
+ @server.resource("resource://{city}/weather")
290
+ def get_weather(city: str) -> str:
291
+ return f"Weather for {city}"
207
292
  """
208
293
  # Check if user passed function directly instead of calling decorator
209
294
  if callable(uri):
@@ -212,26 +297,110 @@ class FastMCP:
212
297
  "Did you forget to call it? Use @resource('uri') instead of @resource"
213
298
  )
214
299
 
215
- def decorator(func: Callable) -> Callable:
216
- @functools.wraps(func)
217
- def wrapper() -> Any:
218
- return func()
219
-
220
- resource = FunctionResource(
221
- uri=uri,
222
- name=name,
223
- description=description,
224
- mime_type=mime_type or "text/plain",
225
- func=wrapper,
226
- )
227
- 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)
228
338
  return wrapper
229
339
 
230
340
  return decorator
231
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
+
232
400
  async def run_stdio_async(self) -> None:
233
401
  """Run the server using stdio transport."""
234
402
  async with stdio_server() as (read_stream, write_stream):
403
+ logger.info(f'Starting "{self.name}"...')
235
404
  await self._mcp_server.run(
236
405
  read_stream,
237
406
  write_stream,
@@ -242,7 +411,6 @@ class FastMCP:
242
411
  """Run the server using SSE transport."""
243
412
  from starlette.applications import Starlette
244
413
  from starlette.routing import Route
245
- import uvicorn
246
414
 
247
415
  sse = SseServerTransport("/messages")
248
416
 
@@ -267,9 +435,222 @@ class FastMCP:
267
435
  ],
268
436
  )
269
437
 
270
- uvicorn.run(
438
+ config = uvicorn.Config(
271
439
  starlette_app,
272
440
  host=self.settings.host,
273
441
  port=self.settings.port,
274
- 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
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
275
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)
@@ -0,0 +1,4 @@
1
+ from .base import Tool
2
+ from .tool_manager import ToolManager
3
+
4
+ __all__ = ["Tool", "ToolManager"]