Jeremiah Lowin commited on
Commit
0a9315c
·
unverified ·
2 Parent(s): 4a4ca9bd8d02f6

Merge pull request #261 from jlowin/server-tests

Browse files

Significant documentation and test expansion for tool input types

docs/servers/context.mdx CHANGED
@@ -19,7 +19,7 @@ The `Context` object provides a clean interface to access MCP features within yo
19
  - **Request Information**: Access metadata about the current request
20
  - **Server Access**: When needed, access the underlying FastMCP server instance
21
 
22
- ## Accessing Context
23
 
24
  To use the context object within your tool function, simply add a parameter to your function signature and type-hint it as `Context`. FastMCP will automatically inject the context instance when your tool is called.
25
 
 
19
  - **Request Information**: Access metadata about the current request
20
  - **Server Access**: When needed, access the underlying FastMCP server instance
21
 
22
+ ## Accessing the Context
23
 
24
  To use the context object within your tool function, simply add a parameter to your function signature and type-hint it as `Context`. FastMCP will automatically inject the context instance when your tool is called.
25
 
docs/servers/prompts.mdx CHANGED
@@ -20,7 +20,7 @@ Prompts provide parameterized message templates for LLMs. When a client requests
20
 
21
  This allows you to define consistent, reusable templates that LLMs can use across different clients and contexts.
22
 
23
- ## Defining Prompts
24
 
25
  ### The `@prompt` Decorator
26
 
 
20
 
21
  This allows you to define consistent, reusable templates that LLMs can use across different clients and contexts.
22
 
23
+ ## Prompts
24
 
25
  ### The `@prompt` Decorator
26
 
docs/servers/resources.mdx CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
  title: Resources & Templates
3
- sidebarTitle: Resources & Templates
4
  description: Expose data sources and dynamic content generators to your MCP client.
5
  icon: database
6
  ---
@@ -21,7 +21,7 @@ Resources provide read-only access to data for the LLM or client application. Wh
21
 
22
  This allows LLMs to access files, database content, configuration, or dynamically generated information relevant to the conversation.
23
 
24
- ## Defining Resources
25
 
26
  ### The `@resource` Decorator
27
 
@@ -201,7 +201,7 @@ mcp.add_resource(special_resource, key="internal://data-v2") # Will be stored a
201
 
202
  Note that this parameter is only available when using `add_resource()` directly and not through the `@resource` decorator, as URIs are provided explicitly when using the decorator.
203
 
204
- ## Defining Resource Templates
205
 
206
  Resource Templates allow clients to request resources whose content depends on parameters embedded in the URI. Define a template using the **same `@mcp.resource` decorator**, but include `{parameter_name}` placeholders in the URI string and add corresponding arguments to your function signature.
207
 
 
1
  ---
2
  title: Resources & Templates
3
+ sidebarTitle: Resources
4
  description: Expose data sources and dynamic content generators to your MCP client.
5
  icon: database
6
  ---
 
21
 
22
  This allows LLMs to access files, database content, configuration, or dynamically generated information relevant to the conversation.
23
 
24
+ ## Resources
25
 
26
  ### The `@resource` Decorator
27
 
 
201
 
202
  Note that this parameter is only available when using `add_resource()` directly and not through the `@resource` decorator, as URIs are provided explicitly when using the decorator.
203
 
204
+ ## Resource Templates
205
 
206
  Resource Templates allow clients to request resources whose content depends on parameters embedded in the URI. Define a template using the **same `@mcp.resource` decorator**, but include `{parameter_name}` placeholders in the URI string and add corresponding arguments to your function signature.
207
 
docs/servers/tools.mdx CHANGED
@@ -18,7 +18,7 @@ Tools in FastMCP transform regular Python functions into capabilities that LLMs
18
 
19
  This allows LLMs to perform tasks like querying databases, calling APIs, making calculations, or accessing files—extending their capabilities beyond what's in their training data.
20
 
21
- ## Defining Tools
22
 
23
  ### The `@tool` Decorator
24
 
@@ -44,114 +44,105 @@ When this tool is registered, FastMCP automatically:
44
 
45
  The way you define your Python function dictates how the tool appears and behaves for the LLM client.
46
 
47
- ### Type Annotations
48
 
49
- Type annotations are crucial. They:
50
- 1. Inform the LLM about the expected type for each parameter.
51
- 2. Allow FastMCP to validate the data received from the client.
52
- 3. Are used to generate the tool's input schema for the MCP protocol.
53
 
54
- FastMCP supports standard Python type annotations, including those from the `typing` module and Pydantic.
 
 
 
55
 
56
- ```python
57
- from typing import Literal, Optional, Union
58
- from pydantic import BaseModel, Field
59
 
60
- # Example using various type hints
61
  @mcp.tool()
62
- def process_data(
63
- data: list[float], # List of floats
64
- operation: Literal["sum", "average", "max"], # Fixed choices
65
- precision: int = 2, # Optional int with default
66
- description: str | None = None # Optional string (can be None)
67
  ) -> dict:
68
- """Process numerical data with the specified operation."""
69
- result = 0.0
70
- if operation == "sum":
71
- result = sum(data)
72
- elif operation == "average":
73
- result = sum(data) / len(data) if data else 0.0
74
- elif operation == "max":
75
- result = float(max(data)) if data else 0.0
76
-
77
- return {
78
- "operation": operation,
79
- "result": round(result, precision),
80
- "description": description
81
- }
82
  ```
83
 
84
- **Supported Type Annotation Examples:**
85
 
86
- | Type Annotation | Example | Description |
87
- | :---------------------- | :---------------------------- | :---------------------------------- |
88
- | Basic types | `int`, `float`, `str`, `bool` | Simple scalar values |
89
- | Container types | `list[str]`, `dict[str, int]` | Collections of items |
90
- | Optional types | `Optional[float]`, `float\|None`| Parameters that may be null/omitted |
91
- | Union types | `str \| int`, `Union[str, int]`| Parameters accepting multiple types |
92
- | Literal types | `Literal["A", "B"]` | Parameters with specific allowed values |
93
- | Pydantic models | `UserData` | Complex structured data (see below) |
94
 
95
- <Tip>
96
- **Automatic JSON Parsing:** FastMCP intelligently handles arguments. If a client sends a string that looks like valid JSON (e.g., `"['a', 'b']"`) for a parameter hinted as a structured type (like `list[str]` or a Pydantic model), FastMCP will automatically attempt to parse the JSON string into the expected Python object before validation. This improves robustness when interacting with various clients.
97
- </Tip>
98
 
99
- ### Required vs. Optional Parameters
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
- Parameters in your function signature are considered **required** unless they have a default value.
102
 
103
  ```python
104
  @mcp.tool()
105
- def search_products(
106
- query: str, # Required - no default value
107
- max_results: int = 10, # Optional - has default value
108
- sort_by: str = "relevance" # Optional - has default value
109
- ) -> list[dict]:
110
- """Search the product catalog."""
111
  # Implementation...
112
- print(f"Searching for '{query}', max {max_results}, sorted by {sort_by}")
113
- return [{"id": 1, "name": "Sample Product"}]
114
  ```
115
 
116
- In this example, the LLM *must* provide a `query`. If `max_results` or `sort_by` are omitted, their default values will be used.
 
 
 
 
 
117
 
118
- ### Structured Inputs
119
 
120
- For tools requiring complex, nested, or well-validated inputs, use Pydantic models. Define a `BaseModel` and use it as a type hint for a parameter.
121
 
122
- ```python
123
- from pydantic import BaseModel, Field
124
- from typing import Optional
125
- from datetime import date
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
- class ReservationRequest(BaseModel):
128
- guest_name: str = Field(description="Full name of the guest making the reservation.")
129
- check_in: date
130
- check_out: date
131
- room_type: Literal["standard", "deluxe", "suite"] = Field(default="standard", description="Type of room requested.")
132
- guests: int = Field(gt=0, description="Number of guests (must be positive).")
133
- special_requests: Optional[str] = Field(default=None, description="Any special requests for the stay.")
134
 
 
135
  @mcp.tool()
136
- def make_reservation(request: ReservationRequest) -> dict:
137
- """Creates a new hotel reservation based on the provided details."""
138
- # Pydantic automatically validates the incoming 'request' data
139
- # against the ReservationRequest model before this function runs.
140
- print(f"Making reservation for {request.guest_name}...")
 
 
141
  # Implementation...
142
- return {
143
- "reservation_id": "R12345",
144
- "status": "confirmed",
145
- "guest": request.guest_name,
146
- "dates": f"{request.check_in} to {request.check_out}"
147
- }
148
  ```
149
 
150
- Using Pydantic models provides:
151
- - Clear, self-documenting structure for complex inputs.
152
- - Built-in data validation (e.g., `gt=0`, date parsing).
153
- - Automatic generation of detailed JSON schemas for the LLM.
154
- - Easy handling of optional fields and default values.
155
 
156
  ### Metadata
157
 
@@ -212,6 +203,8 @@ FastMCP automatically converts the value returned by your function into the appr
212
  - **`fastmcp.Image`**: A helper class for easily returning image data. Sent as `ImageContent`.
213
  - **`None`**: Results in an empty response (no content is sent back to the client).
214
 
 
 
215
  ```python
216
  from fastmcp import FastMCP, Image
217
  import io
@@ -266,7 +259,7 @@ FastMCP automatically catches exceptions raised within your tool function:
266
 
267
  Using informative exceptions helps the LLM understand failures and react appropriately.
268
 
269
- ### Using Context in Tools
270
 
271
  Tools can access MCP features like logging, reading resources, or reporting progress through the `Context` object. To use it, add a parameter to your tool function with the type hint `Context`.
272
 
@@ -338,4 +331,331 @@ The duplicate behavior options are:
338
  - `"warn"` (default): Logs a warning and the new tool replaces the old one.
339
  - `"error"`: Raises a `ValueError`, preventing the duplicate registration.
340
  - `"replace"`: Silently replaces the existing tool with the new one.
341
- - `"ignore"`: Keeps the original tool and ignores the new registration attempt.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  This allows LLMs to perform tasks like querying databases, calling APIs, making calculations, or accessing files—extending their capabilities beyond what's in their training data.
20
 
21
+ ## Tools
22
 
23
  ### The `@tool` Decorator
24
 
 
44
 
45
  The way you define your Python function dictates how the tool appears and behaves for the LLM client.
46
 
47
+ ### Parameters
48
 
49
+ #### Annotations
 
 
 
50
 
51
+ Type annotations for parameters are essential for proper tool functionality. They:
52
+ 1. Inform the LLM about the expected data types for each parameter
53
+ 2. Enable FastMCP to validate input data from clients
54
+ 3. Generate accurate JSON schemas for the MCP protocol
55
 
56
+ Use standard Python type annotations for parameters:
 
 
57
 
58
+ ```python
59
  @mcp.tool()
60
+ def analyze_text(
61
+ text: str,
62
+ max_tokens: int = 100,
63
+ language: str | None = None
 
64
  ) -> dict:
65
+ """Analyze the provided text."""
66
+ # Implementation...
 
 
 
 
 
 
 
 
 
 
 
 
67
  ```
68
 
69
+ #### Parameter Metadata
70
 
71
+ You can provide additional metadata about parameters using Pydantic's `Field` class with `Annotated`. This approach is preferred as it's more modern and keeps type hints separate from validation rules:
 
 
 
 
 
 
 
72
 
73
+ ```python
74
+ from typing import Annotated
75
+ from pydantic import Field
76
 
77
+ @mcp.tool()
78
+ def process_image(
79
+ image_url: Annotated[str, Field(description="URL of the image to process")],
80
+ resize: Annotated[bool, Field(description="Whether to resize the image")] = False,
81
+ width: Annotated[int, Field(description="Target width in pixels", ge=1, le=2000)] = 800,
82
+ format: Annotated[
83
+ Literal["jpeg", "png", "webp"],
84
+ Field(description="Output image format")
85
+ ] = "jpeg"
86
+ ) -> dict:
87
+ """Process an image with optional resizing."""
88
+ # Implementation...
89
+ ```
90
 
91
+ You can also use the Field as a default value, though the Annotated approach is preferred:
92
 
93
  ```python
94
  @mcp.tool()
95
+ def search_database(
96
+ query: str = Field(description="Search query string"),
97
+ limit: int = Field(10, description="Maximum number of results", ge=1, le=100)
98
+ ) -> list:
99
+ """Search the database with the provided query."""
 
100
  # Implementation...
 
 
101
  ```
102
 
103
+ Field provides several validation and documentation features:
104
+ - `description`: Human-readable explanation of the parameter (shown to LLMs)
105
+ - `ge`/`gt`/`le`/`lt`: Greater/less than (or equal) constraints
106
+ - `min_length`/`max_length`: String or collection length constraints
107
+ - `pattern`: Regex pattern for string validation
108
+ - `default`: Default value if parameter is omitted
109
 
110
+ #### Supported Types
111
 
112
+ FastMCP supports a wide range of type annotations, including all Pydantic types:
113
 
114
+ | Type Annotation | Example | Description |
115
+ | :---------------------- | :---------------------------- | :---------------------------------- |
116
+ | Basic types | `int`, `float`, `str`, `bool` | Simple scalar values - see [Built-in Types](#built-in-types) |
117
+ | Binary data | `bytes` | Binary content - see [Binary Data](#binary-data) |
118
+ | Date and Time | `datetime`, `date`, `timedelta` | Date and time objects - see [Date and Time Types](#date-and-time-types) |
119
+ | Collection types | `list[str]`, `dict[str, int]`, `set[int]` | Collections of items - see [Collection Types](#collection-types) |
120
+ | Optional types | `float \| None`, `Optional[float]`| Parameters that may be null/omitted - see [Union and Optional Types](#union-and-optional-types) |
121
+ | Union types | `str \| int`, `Union[str, int]`| Parameters accepting multiple types - see [Union and Optional Types](#union-and-optional-types) |
122
+ | Constrained types | `Literal["A", "B"]`, `Enum` | Parameters with specific allowed values - see [Constrained Types](#constrained-types) |
123
+ | Paths | `Path` | File system paths - see [Paths](#paths) |
124
+ | UUIDs | `UUID` | Universally unique identifiers - see [UUIDs](#uuids) |
125
+ | Pydantic models | `UserData` | Complex structured data - see [Pydantic Models](#pydantic-models) |
126
+
127
+ For additional type annotations not listed here, see the [Parameter Types](#parameter-types) section below for more detailed information and examples.
128
+
129
+ #### Optional Arguments
130
 
131
+ FastMCP follows Python's standard function parameter conventions. Parameters without default values are required, while those with default values are optional.
 
 
 
 
 
 
132
 
133
+ ```python
134
  @mcp.tool()
135
+ def search_products(
136
+ query: str, # Required - no default value
137
+ max_results: int = 10, # Optional - has default value
138
+ sort_by: str = "relevance", # Optional - has default value
139
+ category: str | None = None # Optional - can be None
140
+ ) -> list[dict]:
141
+ """Search the product catalog."""
142
  # Implementation...
 
 
 
 
 
 
143
  ```
144
 
145
+ In this example, the LLM must provide a `query` parameter, while `max_results`, `sort_by`, and `category` will use their default values if not explicitly provided.
 
 
 
 
146
 
147
  ### Metadata
148
 
 
203
  - **`fastmcp.Image`**: A helper class for easily returning image data. Sent as `ImageContent`.
204
  - **`None`**: Results in an empty response (no content is sent back to the client).
205
 
206
+ FastMCP will attempt to serialize other types to a string if possible.
207
+
208
  ```python
209
  from fastmcp import FastMCP, Image
210
  import io
 
259
 
260
  Using informative exceptions helps the LLM understand failures and react appropriately.
261
 
262
+ ### Accessing MCP Context
263
 
264
  Tools can access MCP features like logging, reading resources, or reporting progress through the `Context` object. To use it, add a parameter to your tool function with the type hint `Context`.
265
 
 
331
  - `"warn"` (default): Logs a warning and the new tool replaces the old one.
332
  - `"error"`: Raises a `ValueError`, preventing the duplicate registration.
333
  - `"replace"`: Silently replaces the existing tool with the new one.
334
+ - `"ignore"`: Keeps the original tool and ignores the new registration attempt.
335
+
336
+ ## Parameter Types
337
+
338
+ FastMCP supports a wide variety of parameter types to give you flexibility when designing your tools.
339
+
340
+ FastMCP generally supports all types that Pydantic supports as fields, including all Pydantic custom types. This means you can use any type that can be validated and parsed by Pydantic in your tool parameters.
341
+
342
+ FastMCP supports **type coercion** when possible. This means that if a client sends data that doesn't match the expected type, FastMCP will attempt to convert it to the appropriate type. For example, if a client sends a string for a parameter annotated as `int`, FastMCP will attempt to convert it to an integer. If the conversion is not possible, FastMCP will return a validation error.
343
+
344
+ ### Built-in Types
345
+
346
+ The most common parameter types are Python's built-in scalar types:
347
+
348
+ ```python
349
+ @mcp.tool()
350
+ def process_values(
351
+ name: str, # Text data
352
+ count: int, # Integer numbers
353
+ amount: float, # Floating point numbers
354
+ enabled: bool # Boolean values (True/False)
355
+ ):
356
+ """Process various value types."""
357
+ # Implementation...
358
+ ```
359
+
360
+ These types provide clear expectations to the LLM about what values are acceptable and allow FastMCP to validate inputs properly. Even if a client provides a string like "42", it will be coerced to an integer for parameters annotated as `int`.
361
+
362
+ ### Date and Time Types
363
+
364
+ FastMCP supports various date and time types from the `datetime` module:
365
+
366
+ ```python
367
+ from datetime import datetime, date, timedelta
368
+
369
+ @mcp.tool()
370
+ def process_date_time(
371
+ event_date: date, # ISO format date string or date object
372
+ event_time: datetime, # ISO format datetime string or datetime object
373
+ duration: timedelta = timedelta(hours=1) # Integer seconds or timedelta
374
+ ) -> str:
375
+ """Process date and time information."""
376
+ # Types are automatically converted from strings
377
+ assert isinstance(event_date, date)
378
+ assert isinstance(event_time, datetime)
379
+ assert isinstance(duration, timedelta)
380
+
381
+ return f"Event on {event_date} at {event_time} for {duration}"
382
+ ```
383
+
384
+ - `datetime` - Accepts ISO format strings (e.g., "2023-04-15T14:30:00")
385
+ - `date` - Accepts ISO format date strings (e.g., "2023-04-15")
386
+ - `timedelta` - Accepts integer seconds or timedelta objects
387
+
388
+ ### Collection Types
389
+
390
+ FastMCP supports all standard Python collection types:
391
+
392
+ ```python
393
+ @mcp.tool()
394
+ def analyze_data(
395
+ values: list[float], # List of numbers
396
+ properties: dict[str, str], # Dictionary with string keys and values
397
+ unique_ids: set[int], # Set of unique integers
398
+ coordinates: tuple[float, float], # Tuple with fixed structure
399
+ mixed_data: dict[str, list[int]] # Nested collections
400
+ ):
401
+ """Analyze collections of data."""
402
+ # Implementation...
403
+ ```
404
+
405
+ All collection types can be used as parameter annotations:
406
+ - `list[T]` - Ordered sequence of items
407
+ - `dict[K, V]` - Key-value mapping
408
+ - `set[T]` - Unordered collection of unique items
409
+ - `tuple[T1, T2, ...]` - Fixed-length sequence with potentially different types
410
+
411
+ Collection types can be nested and combined to represent complex data structures. JSON strings that match the expected structure will be automatically parsed and converted to the appropriate Python collection type.
412
+
413
+ ### Union and Optional Types
414
+
415
+ For parameters that can accept multiple types or may be omitted:
416
+
417
+ ```python
418
+ @mcp.tool()
419
+ def flexible_search(
420
+ query: str | int, # Can be either string or integer
421
+ filters: dict[str, str] | None = None, # Optional dictionary
422
+ sort_field: str | None = None # Optional string
423
+ ):
424
+ """Search with flexible parameter types."""
425
+ # Implementation...
426
+ ```
427
+
428
+ Modern Python syntax (`str | int`) is preferred over older `Union[str, int]` forms. Similarly, `str | None` is preferred over `Optional[str]`.
429
+
430
+ ### Constrained Types
431
+
432
+ When a parameter must be one of a predefined set of values, you can use either Literal types or Enums:
433
+
434
+ #### Literals
435
+
436
+ Literals constrain parameters to a specific set of values:
437
+
438
+ ```python
439
+ from typing import Literal
440
+
441
+ @mcp.tool()
442
+ def sort_data(
443
+ data: list[float],
444
+ order: Literal["ascending", "descending"] = "ascending",
445
+ algorithm: Literal["quicksort", "mergesort", "heapsort"] = "quicksort"
446
+ ):
447
+ """Sort data using specific options."""
448
+ # Implementation...
449
+ ```
450
+
451
+ Literal types:
452
+ - Specify exact allowable values directly in the type annotation
453
+ - Help LLMs understand exactly which values are acceptable
454
+ - Provide input validation (errors for invalid values)
455
+ - Create clear schemas for clients
456
+
457
+ #### Enums
458
+
459
+ For more structured sets of constrained values, use Python's Enum class:
460
+
461
+ ```python
462
+ from enum import Enum
463
+
464
+ class Color(Enum):
465
+ RED = "red"
466
+ GREEN = "green"
467
+ BLUE = "blue"
468
+
469
+ @mcp.tool()
470
+ def process_image(
471
+ image_path: str,
472
+ color_filter: Color = Color.RED
473
+ ):
474
+ """Process an image with a color filter."""
475
+ # Implementation...
476
+ # color_filter will be a Color enum member
477
+ ```
478
+
479
+ When using Enum types:
480
+ - Clients should provide the enum's value (e.g., "red"), not the enum member name (e.g., "RED")
481
+ - FastMCP automatically coerces the string value into the appropriate Enum object
482
+ - Your function receives the actual Enum member (e.g., `Color.RED`)
483
+ - Validation errors are raised for values not in the enum
484
+
485
+ ### Binary Data
486
+
487
+ There are two approaches to handling binary data in tool parameters:
488
+
489
+ #### Bytes
490
+
491
+ ```python
492
+ @mcp.tool()
493
+ def process_binary(data: bytes):
494
+ """Process binary data directly.
495
+
496
+ The client can send a binary string, which will be
497
+ converted directly to bytes.
498
+ """
499
+ # Implementation using binary data
500
+ data_length = len(data)
501
+ # ...
502
+ ```
503
+
504
+ When you annotate a parameter as `bytes`, FastMCP will:
505
+ - Convert raw strings directly to bytes
506
+ - Validate that the input can be properly represented as bytes
507
+
508
+ FastMCP does not automatically decode base64-encoded strings for bytes parameters. If you need to accept base64-encoded data, you should handle the decoding manually as shown below.
509
+
510
+ #### Base64-encoded strings
511
+
512
+ ```python
513
+ from typing import Annotated
514
+ from pydantic import Field
515
+
516
+ @mcp.tool()
517
+ def process_image_data(
518
+ image_data: Annotated[str, Field(description="Base64-encoded image data")]
519
+ ):
520
+ """Process an image from base64-encoded string.
521
+
522
+ The client is expected to provide base64-encoded data as a string.
523
+ You'll need to decode it manually.
524
+ """
525
+ # Manual base64 decoding
526
+ import base64
527
+ binary_data = base64.b64decode(image_data)
528
+ # Process binary_data...
529
+ ```
530
+
531
+ This approach is recommended when you expect to receive base64-encoded binary data from clients.
532
+
533
+ ### Paths
534
+
535
+ The `Path` type from the `pathlib` module can be used for file system paths:
536
+
537
+ ```python
538
+ from pathlib import Path
539
+
540
+ @mcp.tool()
541
+ def process_file(path: Path) -> str:
542
+ """Process a file at the given path."""
543
+ assert isinstance(path, Path) # Path is properly converted
544
+ return f"Processing file at {path}"
545
+ ```
546
+
547
+ When a client sends a string path, FastMCP automatically converts it to a `Path` object.
548
+
549
+ ### UUIDs
550
+
551
+ The `UUID` type from the `uuid` module can be used for unique identifiers:
552
+
553
+ ```python
554
+ import uuid
555
+
556
+ @mcp.tool()
557
+ def process_item(
558
+ item_id: uuid.UUID # String UUID or UUID object
559
+ ) -> str:
560
+ """Process an item with the given UUID."""
561
+ assert isinstance(item_id, uuid.UUID) # Properly converted to UUID
562
+ return f"Processing item {item_id}"
563
+ ```
564
+
565
+ When a client sends a string UUID (e.g., "123e4567-e89b-12d3-a456-426614174000"), FastMCP automatically converts it to a `UUID` object.
566
+
567
+ ### Pydantic Models
568
+
569
+ For complex, structured data with nested fields and validation, use Pydantic models:
570
+
571
+ ```python
572
+ from pydantic import BaseModel, Field
573
+ from typing import Optional
574
+
575
+ class User(BaseModel):
576
+ username: str
577
+ email: str = Field(description="User's email address")
578
+ age: int | None = None
579
+ is_active: bool = True
580
+
581
+ @mcp.tool()
582
+ def create_user(user: User):
583
+ """Create a new user in the system."""
584
+ # The input is automatically validated against the User model
585
+ # Even if provided as a JSON string or dict
586
+ # Implementation...
587
+ ```
588
+
589
+ Using Pydantic models provides:
590
+ - Clear, self-documenting structure for complex inputs
591
+ - Built-in data validation
592
+ - Automatic generation of detailed JSON schemas for the LLM
593
+ - Automatic conversion from dict/JSON input
594
+
595
+ Clients can provide data for Pydantic model parameters as either:
596
+ - A JSON object (string)
597
+ - A dictionary with the appropriate structure
598
+ - Nested parameters in the appropriate format
599
+
600
+ ### Pydantic Fields
601
+
602
+ FastMCP supports robust parameter validation through Pydantic's `Field` class. This is especially useful to ensure that input values meet specific requirements beyond just their type.
603
+
604
+ Note that fields can be used *outside* Pydantic models to provide metadata and validation constraints. The preferred approach is using `Annotated` with `Field`:
605
+
606
+ ```python
607
+ from typing import Annotated
608
+ from pydantic import Field
609
+
610
+ @mcp.tool()
611
+ def analyze_metrics(
612
+ # Numbers with range constraints
613
+ count: Annotated[int, Field(ge=0, le=100)], # 0 <= count <= 100
614
+ ratio: Annotated[float, Field(gt=0, lt=1.0)], # 0 < ratio < 1.0
615
+
616
+ # String with pattern and length constraints
617
+ user_id: Annotated[str, Field(
618
+ pattern=r"^[A-Z]{2}\d{4}$", # Must match regex pattern
619
+ description="User ID in format XX0000"
620
+ )],
621
+
622
+ # String with length constraints
623
+ comment: Annotated[str, Field(min_length=3, max_length=500)] = "",
624
+
625
+ # Numeric constraints
626
+ factor: Annotated[int, Field(multiple_of=5)] = 10, # Must be multiple of 5
627
+ ):
628
+ """Analyze metrics with validated parameters."""
629
+ # Implementation...
630
+ ```
631
+
632
+ You can also use `Field` as a default value, though the `Annotated` approach is preferred:
633
+
634
+ ```python
635
+ @mcp.tool()
636
+ def validate_data(
637
+ # Value constraints
638
+ age: int = Field(ge=0, lt=120), # 0 <= age < 120
639
+
640
+ # String constraints
641
+ email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$"), # Email pattern
642
+
643
+ # Collection constraints
644
+ tags: list[str] = Field(min_length=1, max_length=10) # 1-10 tags
645
+ ):
646
+ """Process data with field validations."""
647
+ # Implementation...
648
+ ```
649
+
650
+ Common validation options include:
651
+
652
+ | Validation | Type | Description |
653
+ | :--------- | :--- | :---------- |
654
+ | `ge`, `gt` | Number | Greater than (or equal) constraint |
655
+ | `le`, `lt` | Number | Less than (or equal) constraint |
656
+ | `multiple_of` | Number | Value must be a multiple of this number |
657
+ | `min_length`, `max_length` | String, List, etc. | Length constraints |
658
+ | `pattern` | String | Regular expression pattern constraint |
659
+ | `description` | Any | Human-readable description (appears in schema) |
660
+
661
+ When a client sends invalid data, FastMCP will return a validation error explaining why the parameter failed validation.
tests/server/test_server.py CHANGED
@@ -1,25 +1,14 @@
1
- import base64
2
- import json
3
- from pathlib import Path
4
- from typing import TYPE_CHECKING
5
 
6
  import pytest
7
  from mcp.types import (
8
- BlobResourceContents,
9
- ImageContent,
10
  TextContent,
11
  TextResourceContents,
12
  )
13
- from pydantic import AnyUrl, Field
14
 
15
- from fastmcp import Client, Context, FastMCP
16
- from fastmcp.exceptions import ClientError, NotFoundError, ToolError
17
- from fastmcp.prompts.prompt import EmbeddedResource, Message, UserMessage
18
- from fastmcp.resources import FileResource, FunctionResource
19
- from fastmcp.utilities.types import Image
20
-
21
- if TYPE_CHECKING:
22
- from fastmcp import Context
23
 
24
 
25
  class TestCreateServer:
@@ -253,6 +242,36 @@ class TestToolDecorator:
253
  # Original name should not be registered
254
  assert "multiply" not in tools
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
 
257
  class TestResourceDecorator:
258
  async def test_no_resources_before_decorator(self):
@@ -715,825 +734,3 @@ class TestPromptDecorator:
715
  assert len(prompts_dict) == 1
716
  prompt = prompts_dict["sample_prompt"]
717
  assert prompt.tags == {"example", "test-tag"}
718
-
719
-
720
- @pytest.fixture
721
- def tool_server():
722
- mcp = FastMCP()
723
-
724
- @mcp.tool()
725
- def add(x: int, y: int) -> int:
726
- return x + y
727
-
728
- @mcp.tool()
729
- def list_tool() -> list[str | int]:
730
- return ["x", 2]
731
-
732
- @mcp.tool()
733
- def error_tool() -> None:
734
- raise ValueError("Test error")
735
-
736
- @mcp.tool()
737
- def image_tool(path: str) -> Image:
738
- return Image(path)
739
-
740
- @mcp.tool()
741
- def mixed_content_tool() -> list[TextContent | ImageContent]:
742
- return [
743
- TextContent(type="text", text="Hello"),
744
- ImageContent(type="image", data="abc", mimeType="image/png"),
745
- ]
746
-
747
- @mcp.tool()
748
- def mixed_list_fn(image_path: str) -> list:
749
- return [
750
- "text message",
751
- Image(image_path),
752
- {"key": "value"},
753
- TextContent(type="text", text="direct content"),
754
- ]
755
-
756
- return mcp
757
-
758
-
759
- class TestServerTools:
760
- async def test_add_tool_exists(self, tool_server: FastMCP):
761
- assert "add" in [t.name for t in await tool_server._mcp_list_tools()]
762
-
763
- async def test_list_tools(self, tool_server: FastMCP):
764
- assert len(await tool_server._mcp_list_tools()) == 6
765
-
766
- async def test_call_tool(self, tool_server: FastMCP):
767
- result = await tool_server._mcp_call_tool("add", {"x": 1, "y": 2})
768
- assert isinstance(result[0], TextContent)
769
- assert result[0].text == "3"
770
-
771
- async def test_call_tool_as_client(self, tool_server: FastMCP):
772
- async with Client(tool_server) as client:
773
- result = await client.call_tool("add", {"x": 1, "y": 2})
774
- assert isinstance(result[0], TextContent)
775
- assert result[0].text == "3"
776
-
777
- async def test_call_tool_error(self, tool_server: FastMCP):
778
- with pytest.raises(ToolError):
779
- await tool_server._mcp_call_tool("error_tool", {})
780
-
781
- async def test_call_tool_error_as_client(self, tool_server: FastMCP):
782
- async with Client(tool_server) as client:
783
- with pytest.raises(Exception):
784
- await client.call_tool("error_tool", {})
785
-
786
- async def test_call_tool_error_as_client_raw(self, tool_server: FastMCP):
787
- async with Client(tool_server) as client:
788
- result = await client.call_tool("error_tool", {}, _return_raw_result=True)
789
- assert result.isError
790
- assert isinstance(result.content[0], TextContent)
791
- assert "Test error" in result.content[0].text
792
-
793
- async def test_tool_returns_list(self, tool_server: FastMCP):
794
- result = await tool_server._mcp_call_tool("list_tool", {})
795
- assert isinstance(result[0], TextContent)
796
- assert result[0].text == '["x", 2]'
797
-
798
- async def test_tool_image_helper(self, tool_server: FastMCP, tmp_path: Path):
799
- # Create a test image
800
- image_path = tmp_path / "test.png"
801
- image_path.write_bytes(b"fake png data")
802
-
803
- result = await tool_server._mcp_call_tool(
804
- "image_tool", {"path": str(image_path)}
805
- )
806
- content = result[0]
807
- assert isinstance(content, ImageContent)
808
- assert content.type == "image"
809
- assert content.mimeType == "image/png"
810
- # Verify base64 encoding
811
- decoded = base64.b64decode(content.data)
812
- assert decoded == b"fake png data"
813
-
814
- async def test_tool_mixed_content(self, tool_server: FastMCP):
815
- result = await tool_server._mcp_call_tool("mixed_content_tool", {})
816
- assert len(result) == 2
817
- content1 = result[0]
818
- content2 = result[1]
819
- assert isinstance(content1, TextContent)
820
- assert content1.text == "Hello"
821
- assert isinstance(content2, ImageContent)
822
- assert content2.mimeType == "image/png"
823
- assert content2.data == "abc"
824
-
825
- async def test_tool_mixed_list_with_image(
826
- self, tool_server: FastMCP, tmp_path: Path
827
- ):
828
- """Test that lists containing Image objects and other types are handled
829
- correctly. Note that the non-MCP content will be grouped together."""
830
- # Create a test image
831
- image_path = tmp_path / "test.png"
832
- image_path.write_bytes(b"test image data")
833
-
834
- result = await tool_server._mcp_call_tool(
835
- "mixed_list_fn", {"image_path": str(image_path)}
836
- )
837
- assert len(result) == 3
838
- # Check text conversion
839
- content1 = result[0]
840
- assert isinstance(content1, TextContent)
841
- assert json.loads(content1.text) == ["text message", {"key": "value"}]
842
- # Check image conversion
843
- content2 = result[1]
844
- assert isinstance(content2, ImageContent)
845
- assert content2.mimeType == "image/png"
846
- assert base64.b64decode(content2.data) == b"test image data"
847
- # Check direct TextContent
848
- content3 = result[2]
849
- assert isinstance(content3, TextContent)
850
- assert content3.text == "direct content"
851
-
852
- async def test_parameter_descriptions(self):
853
- mcp = FastMCP("Test Server")
854
-
855
- @mcp.tool()
856
- def greet(
857
- name: str = Field(description="The name to greet"),
858
- title: str = Field(description="Optional title", default=""),
859
- ) -> str:
860
- """A greeting tool"""
861
- return f"Hello {title} {name}"
862
-
863
- tools = await mcp._mcp_list_tools()
864
- assert len(tools) == 1
865
- tool = tools[0]
866
-
867
- # Check that parameter descriptions are present in the schema
868
- properties = tool.inputSchema["properties"]
869
- assert "name" in properties
870
- assert properties["name"]["description"] == "The name to greet"
871
- assert "title" in properties
872
- assert properties["title"]["description"] == "Optional title"
873
-
874
-
875
- class TestServerResources:
876
- async def test_text_resource(self):
877
- mcp = FastMCP()
878
-
879
- def get_text():
880
- return "Hello, world!"
881
-
882
- resource = FunctionResource(
883
- uri=AnyUrl("resource://test"), name="test", fn=get_text
884
- )
885
- mcp.add_resource(resource)
886
-
887
- async with Client(mcp) as client:
888
- result = await client.read_resource(AnyUrl("resource://test"))
889
- assert isinstance(result[0], TextResourceContents)
890
- assert result[0].text == "Hello, world!"
891
-
892
- async def test_binary_resource(self):
893
- mcp = FastMCP()
894
-
895
- def get_binary():
896
- return b"Binary data"
897
-
898
- resource = FunctionResource(
899
- uri=AnyUrl("resource://binary"),
900
- name="binary",
901
- fn=get_binary,
902
- mime_type="application/octet-stream",
903
- )
904
- mcp.add_resource(resource)
905
-
906
- async with Client(mcp) as client:
907
- result = await client.read_resource(AnyUrl("resource://binary"))
908
- assert isinstance(result[0], BlobResourceContents)
909
- assert result[0].blob == base64.b64encode(b"Binary data").decode()
910
-
911
- async def test_file_resource_text(self, tmp_path: Path):
912
- mcp = FastMCP()
913
-
914
- # Create a text file
915
- text_file = tmp_path / "test.txt"
916
- text_file.write_text("Hello from file!")
917
-
918
- resource = FileResource(
919
- uri=AnyUrl("file://test.txt"), name="test.txt", path=text_file
920
- )
921
- mcp.add_resource(resource)
922
-
923
- async with Client(mcp) as client:
924
- result = await client.read_resource(AnyUrl("file://test.txt"))
925
- assert isinstance(result[0], TextResourceContents)
926
- assert result[0].text == "Hello from file!"
927
-
928
- async def test_file_resource_binary(self, tmp_path: Path):
929
- mcp = FastMCP()
930
-
931
- # Create a binary file
932
- binary_file = tmp_path / "test.bin"
933
- binary_file.write_bytes(b"Binary file data")
934
-
935
- resource = FileResource(
936
- uri=AnyUrl("file://test.bin"),
937
- name="test.bin",
938
- path=binary_file,
939
- mime_type="application/octet-stream",
940
- )
941
- mcp.add_resource(resource)
942
-
943
- async with Client(mcp) as client:
944
- result = await client.read_resource(AnyUrl("file://test.bin"))
945
- assert isinstance(result[0], BlobResourceContents)
946
- assert result[0].blob == base64.b64encode(b"Binary file data").decode()
947
-
948
-
949
- class TestServerResourceTemplates:
950
- async def test_resource_with_params_not_in_uri(self):
951
- """Test that a resource with function parameters raises an error if the URI
952
- parameters don't match"""
953
- mcp = FastMCP()
954
-
955
- with pytest.raises(
956
- ValueError,
957
- match="URI template must contain at least one parameter",
958
- ):
959
-
960
- @mcp.resource("resource://data")
961
- def get_data_fn(param: str) -> str:
962
- return f"Data: {param}"
963
-
964
- async def test_resource_with_uri_params_without_args(self):
965
- """Test that a resource with URI parameters is automatically a template"""
966
- mcp = FastMCP()
967
-
968
- with pytest.raises(
969
- ValueError,
970
- match="URI parameters .* must be a subset of the function arguments",
971
- ):
972
-
973
- @mcp.resource("resource://{param}")
974
- def get_data() -> str:
975
- return "Data"
976
-
977
- async def test_resource_with_untyped_params(self):
978
- """Test that a resource with untyped parameters raises an error"""
979
- mcp = FastMCP()
980
-
981
- @mcp.resource("resource://{param}")
982
- def get_data(param) -> str:
983
- return "Data"
984
-
985
- async def test_resource_matching_params(self):
986
- """Test that a resource with matching URI and function parameters works"""
987
- mcp = FastMCP()
988
-
989
- @mcp.resource("resource://{name}/data")
990
- def get_data(name: str) -> str:
991
- return f"Data for {name}"
992
-
993
- async with Client(mcp) as client:
994
- result = await client.read_resource(AnyUrl("resource://test/data"))
995
- assert isinstance(result[0], TextResourceContents)
996
- assert result[0].text == "Data for test"
997
-
998
- async def test_resource_mismatched_params(self):
999
- """Test that mismatched parameters raise an error"""
1000
- mcp = FastMCP()
1001
-
1002
- with pytest.raises(
1003
- ValueError,
1004
- match="URI parameters .* must be a subset of the required function arguments",
1005
- ):
1006
-
1007
- @mcp.resource("resource://{name}/data")
1008
- def get_data(user: str) -> str:
1009
- return f"Data for {user}"
1010
-
1011
- async def test_resource_multiple_params(self):
1012
- """Test that multiple parameters work correctly"""
1013
- mcp = FastMCP()
1014
-
1015
- @mcp.resource("resource://{org}/{repo}/data")
1016
- def get_data(org: str, repo: str) -> str:
1017
- return f"Data for {org}/{repo}"
1018
-
1019
- async with Client(mcp) as client:
1020
- result = await client.read_resource(
1021
- AnyUrl("resource://cursor/fastmcp/data")
1022
- )
1023
- assert isinstance(result[0], TextResourceContents)
1024
- assert result[0].text == "Data for cursor/fastmcp"
1025
-
1026
- async def test_resource_multiple_mismatched_params(self):
1027
- """Test that mismatched parameters raise an error"""
1028
- mcp = FastMCP()
1029
-
1030
- with pytest.raises(
1031
- ValueError,
1032
- match="URI parameters .* must be a subset of the required function arguments",
1033
- ):
1034
-
1035
- @mcp.resource("resource://{org}/{repo}/data")
1036
- def get_data_mismatched(org: str, repo_2: str) -> str:
1037
- return f"Data for {org}"
1038
-
1039
- """Test that a resource with no parameters works as a regular resource"""
1040
- mcp = FastMCP()
1041
-
1042
- @mcp.resource("resource://static")
1043
- def get_static_data() -> str:
1044
- return "Static data"
1045
-
1046
- async with Client(mcp) as client:
1047
- result = await client.read_resource(AnyUrl("resource://static"))
1048
- assert isinstance(result[0], TextResourceContents)
1049
- assert result[0].text == "Static data"
1050
-
1051
- async def test_template_with_default_params(self):
1052
- """Test that a template can have default parameters."""
1053
- mcp = FastMCP()
1054
-
1055
- @mcp.resource("math://add/{x}")
1056
- def add(x: int, y: int = 10) -> int:
1057
- return x + y
1058
-
1059
- # Verify it's registered as a template
1060
- templates_dict = await mcp.get_resource_templates()
1061
- templates = list(templates_dict.values())
1062
- assert len(templates) == 1
1063
- assert templates[0].uri_template == "math://add/{x}"
1064
-
1065
- # Call the template and verify it uses the default value
1066
- async with Client(mcp) as client:
1067
- result = await client.read_resource(AnyUrl("math://add/5"))
1068
- assert isinstance(result[0], TextResourceContents)
1069
- assert result[0].text == "15" # 5 + default 10
1070
-
1071
- # Can also call with explicit params
1072
- resource = await mcp._resource_manager.get_resource("math://add/7")
1073
- assert isinstance(resource, FunctionResource)
1074
- result = await resource.read()
1075
- assert result == "17" # 7 + default 10
1076
-
1077
- async def test_template_to_resource_conversion(self):
1078
- """Test that a template can be converted to a resource."""
1079
- mcp = FastMCP()
1080
-
1081
- @mcp.resource("resource://{name}/data")
1082
- def get_data(name: str) -> str:
1083
- return f"Data for {name}"
1084
-
1085
- # Verify it's registered as a template
1086
- templates_dict = await mcp.get_resource_templates()
1087
- templates = list(templates_dict.values())
1088
- assert len(templates) == 1
1089
- assert templates[0].uri_template == "resource://{name}/data"
1090
-
1091
- # When accessed, should create a concrete resource
1092
- resource = await mcp._resource_manager.get_resource("resource://test/data")
1093
- assert isinstance(resource, FunctionResource)
1094
- result = await resource.read()
1095
- assert result == "Data for test"
1096
-
1097
- async def test_stacked_resource_template_decorators(self):
1098
- """Test that resource template decorators can be stacked."""
1099
- mcp = FastMCP()
1100
-
1101
- @mcp.resource("users://email/{email}")
1102
- @mcp.resource("users://name/{name}")
1103
- def lookup_user(name: str | None = None, email: str | None = None) -> dict:
1104
- if name:
1105
- return {
1106
- "id": "123",
1107
- "name": name,
1108
- "email": "dummy@example.com",
1109
- "lookup": "name",
1110
- }
1111
- elif email:
1112
- return {
1113
- "id": "123",
1114
- "name": "Test User",
1115
- "email": email,
1116
- "lookup": "email",
1117
- }
1118
- else:
1119
- raise ValueError("Either name or email must be provided")
1120
-
1121
- # Verify both templates are registered
1122
- templates_dict = await mcp.get_resource_templates()
1123
- templates = list(templates_dict.values())
1124
- assert len(templates) == 2
1125
- template_uris = {t.uri_template for t in templates}
1126
- assert "users://email/{email}" in template_uris
1127
- assert "users://name/{name}" in template_uris
1128
-
1129
- # Test lookup by email
1130
- async with Client(mcp) as client:
1131
- email_result = await client.read_resource(
1132
- AnyUrl("users://email/user@example.com")
1133
- )
1134
- assert isinstance(email_result[0], TextResourceContents)
1135
- email_data = json.loads(email_result[0].text)
1136
- assert email_data["lookup"] == "email"
1137
- assert email_data["email"] == "user@example.com"
1138
-
1139
- # Test lookup by name
1140
- name_result = await client.read_resource(AnyUrl("users://name/John"))
1141
- assert isinstance(name_result[0], TextResourceContents)
1142
- name_data = json.loads(name_result[0].text)
1143
- assert name_data["lookup"] == "name"
1144
- assert name_data["name"] == "John"
1145
- assert name_data["email"] == "dummy@example.com"
1146
-
1147
- async def test_template_decorator_with_tags(self):
1148
- mcp = FastMCP()
1149
-
1150
- @mcp.resource("resource://{param}", tags={"template", "test-tag"})
1151
- def template_resource(param: str) -> str:
1152
- return f"Template resource: {param}"
1153
-
1154
- templates_dict = await mcp.get_resource_templates()
1155
- template = templates_dict["resource://{param}"]
1156
- assert template.tags == {"template", "test-tag"}
1157
-
1158
- async def test_template_decorator_wildcard_param(self):
1159
- mcp = FastMCP()
1160
-
1161
- @mcp.resource("resource://{param*}")
1162
- def template_resource(param: str) -> str:
1163
- return f"Template resource: {param}"
1164
-
1165
- async with Client(mcp) as client:
1166
- result = await client.read_resource(AnyUrl("resource://test/data"))
1167
- assert isinstance(result[0], TextResourceContents)
1168
- assert result[0].text == "Template resource: test/data"
1169
-
1170
- async def test_templates_match_in_order_of_definition(self):
1171
- """
1172
- If a wildcard template is defined first, it will take priority over another
1173
- matching template.
1174
-
1175
- """
1176
- mcp = FastMCP()
1177
-
1178
- @mcp.resource("resource://{param*}")
1179
- def template_resource(param: str) -> str:
1180
- return f"Template resource 1: {param}"
1181
-
1182
- @mcp.resource("resource://{x}/{y}")
1183
- def template_resource_with_params(x: str, y: str) -> str:
1184
- return f"Template resource 2: {x}/{y}"
1185
-
1186
- async with Client(mcp) as client:
1187
- result = await client.read_resource(AnyUrl("resource://a/b/c"))
1188
- assert isinstance(result[0], TextResourceContents)
1189
- assert result[0].text == "Template resource 1: a/b/c"
1190
-
1191
- result = await client.read_resource(AnyUrl("resource://a/b"))
1192
- assert isinstance(result[0], TextResourceContents)
1193
- assert result[0].text == "Template resource 1: a/b"
1194
-
1195
- async def test_templates_shadow_each_other_reorder(self):
1196
- """
1197
- If a wildcard template is defined second, it will *not* take priority over
1198
- another matching template.
1199
- """
1200
- mcp = FastMCP()
1201
-
1202
- @mcp.resource("resource://{x}/{y}")
1203
- def template_resource_with_params(x: str, y: str) -> str:
1204
- return f"Template resource 1: {x}/{y}"
1205
-
1206
- @mcp.resource("resource://{param*}")
1207
- def template_resource(param: str) -> str:
1208
- return f"Template resource 2: {param}"
1209
-
1210
- async with Client(mcp) as client:
1211
- result = await client.read_resource(AnyUrl("resource://a/b/c"))
1212
- assert isinstance(result[0], TextResourceContents)
1213
- assert result[0].text == "Template resource 2: a/b/c"
1214
-
1215
- result = await client.read_resource(AnyUrl("resource://a/b"))
1216
- assert isinstance(result[0], TextResourceContents)
1217
- assert result[0].text == "Template resource 1: a/b"
1218
-
1219
-
1220
- class TestContextInjection:
1221
- """Test context injection in tools."""
1222
-
1223
- async def test_context_detection(self):
1224
- """Test that context parameters are properly detected."""
1225
- mcp = FastMCP()
1226
-
1227
- def tool_with_context(x: int, ctx: Context) -> str:
1228
- return f"Request {ctx.request_id}: {x}"
1229
-
1230
- tool = mcp._tool_manager.add_tool_from_fn(tool_with_context)
1231
- assert tool.context_kwarg == "ctx"
1232
-
1233
- async def test_context_injection(self):
1234
- """Test that context is properly injected into tool calls."""
1235
- mcp = FastMCP()
1236
-
1237
- def tool_with_context(x: int, ctx: Context) -> str:
1238
- assert ctx.request_id is not None
1239
- return f"Request {ctx.request_id}: {x}"
1240
-
1241
- mcp.add_tool(tool_with_context)
1242
- async with Client(mcp) as client:
1243
- result = await client.call_tool("tool_with_context", {"x": 42})
1244
- assert len(result) == 1
1245
- content = result[0]
1246
- assert isinstance(content, TextContent)
1247
- assert "Request" in content.text
1248
- assert "42" in content.text
1249
-
1250
- async def test_async_context(self):
1251
- """Test that context works in async functions."""
1252
- mcp = FastMCP()
1253
-
1254
- async def async_tool(x: int, ctx: Context) -> str:
1255
- assert ctx.request_id is not None
1256
- return f"Async request {ctx.request_id}: {x}"
1257
-
1258
- mcp.add_tool(async_tool)
1259
- async with Client(mcp) as client:
1260
- result = await client.call_tool("async_tool", {"x": 42})
1261
- assert len(result) == 1
1262
- content = result[0]
1263
- assert isinstance(content, TextContent)
1264
- assert "Async request" in content.text
1265
- assert "42" in content.text
1266
-
1267
- async def test_context_logging(self):
1268
- from unittest.mock import patch
1269
-
1270
- import mcp.server.session
1271
-
1272
- """Test that context logging methods work."""
1273
- mcp = FastMCP()
1274
-
1275
- async def logging_tool(msg: str, ctx: Context) -> str:
1276
- await ctx.debug("Debug message")
1277
- await ctx.info("Info message")
1278
- await ctx.warning("Warning message")
1279
- await ctx.error("Error message")
1280
- return f"Logged messages for {msg}"
1281
-
1282
- mcp.add_tool(logging_tool)
1283
-
1284
- with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
1285
- async with Client(mcp) as client:
1286
- result = await client.call_tool("logging_tool", {"msg": "test"})
1287
- assert len(result) == 1
1288
- content = result[0]
1289
- assert isinstance(content, TextContent)
1290
- assert "Logged messages for test" in content.text
1291
-
1292
- assert mock_log.call_count == 4
1293
- mock_log.assert_any_call(
1294
- level="debug", data="Debug message", logger=None
1295
- )
1296
- mock_log.assert_any_call(level="info", data="Info message", logger=None)
1297
- mock_log.assert_any_call(
1298
- level="warning", data="Warning message", logger=None
1299
- )
1300
- mock_log.assert_any_call(
1301
- level="error", data="Error message", logger=None
1302
- )
1303
-
1304
- async def test_optional_context(self):
1305
- """Test that context is optional."""
1306
- mcp = FastMCP()
1307
-
1308
- def no_context(x: int) -> int:
1309
- return x * 2
1310
-
1311
- mcp.add_tool(no_context)
1312
- async with Client(mcp) as client:
1313
- result = await client.call_tool("no_context", {"x": 21})
1314
- assert len(result) == 1
1315
- content = result[0]
1316
- assert isinstance(content, TextContent)
1317
- assert content.text == "42"
1318
-
1319
- async def test_context_resource_access(self):
1320
- """Test that context can access resources."""
1321
- mcp = FastMCP()
1322
-
1323
- @mcp.resource("test://data")
1324
- def test_resource() -> str:
1325
- return "resource data"
1326
-
1327
- @mcp.tool()
1328
- async def tool_with_resource(ctx: Context) -> str:
1329
- r_iter = await ctx.read_resource("test://data")
1330
- r_list = list(r_iter)
1331
- assert len(r_list) == 1
1332
- r = r_list[0]
1333
- return f"Read resource: {r.content} with mime type {r.mime_type}"
1334
-
1335
- async with Client(mcp) as client:
1336
- result = await client.call_tool("tool_with_resource", {})
1337
- assert len(result) == 1
1338
- content = result[0]
1339
- assert isinstance(content, TextContent)
1340
- assert "Read resource: resource data" in content.text
1341
-
1342
-
1343
- class TestServerPrompts:
1344
- """Test prompt functionality in FastMCP server."""
1345
-
1346
- async def test_prompt_decorator(self):
1347
- """Test that the prompt decorator registers prompts correctly."""
1348
- mcp = FastMCP()
1349
-
1350
- @mcp.prompt()
1351
- def fn() -> str:
1352
- return "Hello, world!"
1353
-
1354
- prompts_dict = await mcp.get_prompts()
1355
- assert len(prompts_dict) == 1
1356
- prompt = prompts_dict["fn"]
1357
- assert prompt.name == "fn"
1358
- # Don't compare functions directly since validate_call wraps them
1359
- content = await prompt.render()
1360
- assert isinstance(content[0].content, TextContent)
1361
- assert content[0].content.text == "Hello, world!"
1362
-
1363
- async def test_prompt_decorator_with_name(self):
1364
- """Test prompt decorator with custom name."""
1365
- mcp = FastMCP()
1366
-
1367
- @mcp.prompt(name="custom_name")
1368
- def fn() -> str:
1369
- return "Hello, world!"
1370
-
1371
- prompts_dict = await mcp.get_prompts()
1372
- assert len(prompts_dict) == 1
1373
- prompt = prompts_dict["custom_name"]
1374
- assert prompt.name == "custom_name"
1375
- content = await prompt.render()
1376
- assert isinstance(content[0].content, TextContent)
1377
- assert content[0].content.text == "Hello, world!"
1378
-
1379
- async def test_prompt_decorator_with_description(self):
1380
- """Test prompt decorator with custom description."""
1381
- mcp = FastMCP()
1382
-
1383
- @mcp.prompt(description="A custom description")
1384
- def fn() -> str:
1385
- return "Hello, world!"
1386
-
1387
- prompts_dict = await mcp.get_prompts()
1388
- assert len(prompts_dict) == 1
1389
- prompt = prompts_dict["fn"]
1390
- assert prompt.description == "A custom description"
1391
- content = await prompt.render()
1392
- assert isinstance(content[0].content, TextContent)
1393
- assert content[0].content.text == "Hello, world!"
1394
-
1395
- def test_prompt_decorator_error(self):
1396
- """Test error when decorator is used incorrectly."""
1397
- mcp = FastMCP()
1398
- with pytest.raises(TypeError, match="decorator was used incorrectly"):
1399
-
1400
- @mcp.prompt # type: ignore
1401
- def fn() -> str:
1402
- return "Hello, world!"
1403
-
1404
- async def test_list_prompts(self):
1405
- """Test listing prompts through MCP protocol."""
1406
- mcp = FastMCP()
1407
-
1408
- @mcp.prompt()
1409
- def fn(name: str, optional: str = "default") -> str:
1410
- return f"Hello, {name}! {optional}"
1411
-
1412
- prompts_dict = await mcp.get_prompts()
1413
- assert len(prompts_dict) == 1
1414
-
1415
- async with Client(mcp) as client:
1416
- prompts = await client.list_prompts()
1417
- assert len(prompts) == 1
1418
- assert prompts[0].name == "fn"
1419
- assert prompts[0].description is None
1420
- assert prompts[0].arguments is not None
1421
- assert len(prompts[0].arguments) == 2
1422
- assert prompts[0].arguments[0].name == "name"
1423
- assert prompts[0].arguments[0].required is True
1424
- assert prompts[0].arguments[1].name == "optional"
1425
- assert prompts[0].arguments[1].required is False
1426
-
1427
- async def test_get_prompt(self):
1428
- """Test getting a prompt through MCP protocol."""
1429
- mcp = FastMCP()
1430
-
1431
- @mcp.prompt()
1432
- def fn(name: str) -> str:
1433
- return f"Hello, {name}!"
1434
-
1435
- async with Client(mcp) as client:
1436
- result = await client.get_prompt("fn", {"name": "World"})
1437
- assert len(result) == 1
1438
- message = result[0]
1439
- assert message.role == "user"
1440
- content = message.content
1441
- assert isinstance(content, TextContent)
1442
- assert content.text == "Hello, World!"
1443
-
1444
- async def test_get_prompt_with_resource(self):
1445
- """Test getting a prompt that returns resource content."""
1446
- mcp = FastMCP()
1447
-
1448
- @mcp.prompt()
1449
- def fn() -> Message:
1450
- return UserMessage(
1451
- content=EmbeddedResource(
1452
- type="resource",
1453
- resource=TextResourceContents(
1454
- uri=AnyUrl("file://file.txt"),
1455
- text="File contents",
1456
- mimeType="text/plain",
1457
- ),
1458
- )
1459
- )
1460
-
1461
- async with Client(mcp) as client:
1462
- result = await client.get_prompt("fn")
1463
- assert result[0].role == "user"
1464
- content = result[0].content
1465
- assert isinstance(content, EmbeddedResource)
1466
- resource = content.resource
1467
- assert isinstance(resource, TextResourceContents)
1468
- assert resource.text == "File contents"
1469
- assert resource.mimeType == "text/plain"
1470
-
1471
- async def test_get_unknown_prompt(self):
1472
- """Test error when getting unknown prompt."""
1473
- mcp = FastMCP()
1474
- with pytest.raises(ClientError, match="Unknown prompt"):
1475
- async with Client(mcp) as client:
1476
- await client.get_prompt("unknown")
1477
-
1478
- async def test_get_prompt_missing_args(self):
1479
- """Test error when required arguments are missing."""
1480
- mcp = FastMCP()
1481
-
1482
- @mcp.prompt()
1483
- def prompt_fn(name: str) -> str:
1484
- return f"Hello, {name}!"
1485
-
1486
- with pytest.raises(ClientError, match="Missing required arguments"):
1487
- async with Client(mcp) as client:
1488
- await client.get_prompt("prompt_fn")
1489
-
1490
- async def test_tool_decorator_with_tags(self):
1491
- """Test that the tool decorator properly sets tags."""
1492
- mcp = FastMCP()
1493
-
1494
- @mcp.tool(tags={"example", "test-tag"})
1495
- def sample_tool(x: int) -> int:
1496
- return x * 2
1497
-
1498
- # Verify the tags were set correctly
1499
- tools = mcp._tool_manager.list_tools()
1500
- assert len(tools) == 1
1501
- assert tools[0].tags == {"example", "test-tag"}
1502
-
1503
- async def test_resource_decorator_with_tags(self):
1504
- """Test that the resource decorator supports tags."""
1505
- mcp = FastMCP()
1506
-
1507
- @mcp.resource("resource://data", tags={"example", "test-tag"})
1508
- def get_data() -> str:
1509
- return "Hello, world!"
1510
-
1511
- resources_dict = await mcp.get_resources()
1512
- resources = list(resources_dict.values())
1513
- assert len(resources) == 1
1514
- assert resources[0].tags == {"example", "test-tag"}
1515
-
1516
- async def test_template_decorator_with_tags(self):
1517
- """Test that the template decorator properly sets tags."""
1518
- mcp = FastMCP()
1519
-
1520
- @mcp.resource("resource://{param}", tags={"template", "test-tag"})
1521
- def template_resource(param: str) -> str:
1522
- return f"Template resource: {param}"
1523
-
1524
- templates_dict = await mcp.get_resource_templates()
1525
- template = templates_dict["resource://{param}"]
1526
- assert template.tags == {"template", "test-tag"}
1527
-
1528
- async def test_prompt_decorator_with_tags(self):
1529
- """Test that the prompt decorator properly sets tags."""
1530
- mcp = FastMCP()
1531
-
1532
- @mcp.prompt(tags={"example", "test-tag"})
1533
- def sample_prompt() -> str:
1534
- return "Hello, world!"
1535
-
1536
- prompts_dict = await mcp.get_prompts()
1537
- assert len(prompts_dict) == 1
1538
- prompt = prompts_dict["sample_prompt"]
1539
- assert prompt.tags == {"example", "test-tag"}
 
1
+ from typing import Annotated
 
 
 
2
 
3
  import pytest
4
  from mcp.types import (
 
 
5
  TextContent,
6
  TextResourceContents,
7
  )
8
+ from pydantic import Field
9
 
10
+ from fastmcp import Client, FastMCP
11
+ from fastmcp.exceptions import ClientError, NotFoundError
 
 
 
 
 
 
12
 
13
 
14
  class TestCreateServer:
 
242
  # Original name should not be registered
243
  assert "multiply" not in tools
244
 
245
+ async def test_tool_with_annotated_arguments(self):
246
+ """Test that tools with annotated arguments work correctly."""
247
+ mcp = FastMCP()
248
+
249
+ @mcp.tool()
250
+ def add(
251
+ x: Annotated[int, Field(description="x is an int")],
252
+ y: Annotated[str, Field(description="y is not an int")],
253
+ ) -> None:
254
+ pass
255
+
256
+ tool = (await mcp.get_tools())["add"]
257
+ assert tool.parameters["properties"]["x"]["description"] == "x is an int"
258
+ assert tool.parameters["properties"]["y"]["description"] == "y is not an int"
259
+
260
+ async def test_tool_with_field_defaults(self):
261
+ """Test that tools with annotated arguments work correctly."""
262
+ mcp = FastMCP()
263
+
264
+ @mcp.tool()
265
+ def add(
266
+ x: int = Field(description="x is an int"),
267
+ y: str = Field(description="y is not an int"),
268
+ ) -> None:
269
+ pass
270
+
271
+ tool = (await mcp.get_tools())["add"]
272
+ assert tool.parameters["properties"]["x"]["description"] == "x is an int"
273
+ assert tool.parameters["properties"]["y"]["description"] == "y is not an int"
274
+
275
 
276
  class TestResourceDecorator:
277
  async def test_no_resources_before_decorator(self):
 
734
  assert len(prompts_dict) == 1
735
  prompt = prompts_dict["sample_prompt"]
736
  assert prompt.tags == {"example", "test-tag"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/server/test_server_interactions.py ADDED
@@ -0,0 +1,1278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import datetime
3
+ import json
4
+ import uuid
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import Annotated, Literal
8
+
9
+ import pytest
10
+ from mcp.types import (
11
+ BlobResourceContents,
12
+ ImageContent,
13
+ TextContent,
14
+ TextResourceContents,
15
+ )
16
+ from pydantic import AnyUrl, Field
17
+
18
+ from fastmcp import Client, Context, FastMCP
19
+ from fastmcp.exceptions import ClientError
20
+ from fastmcp.prompts.prompt import EmbeddedResource, Message, UserMessage
21
+ from fastmcp.resources import FileResource, FunctionResource
22
+ from fastmcp.utilities.types import Image
23
+
24
+
25
+ @pytest.fixture
26
+ def tool_server():
27
+ mcp = FastMCP()
28
+
29
+ @mcp.tool()
30
+ def add(x: int, y: int) -> int:
31
+ return x + y
32
+
33
+ @mcp.tool()
34
+ def list_tool() -> list[str | int]:
35
+ return ["x", 2]
36
+
37
+ @mcp.tool()
38
+ def error_tool() -> None:
39
+ raise ValueError("Test error")
40
+
41
+ @mcp.tool()
42
+ def image_tool(path: str) -> Image:
43
+ return Image(path)
44
+
45
+ @mcp.tool()
46
+ def mixed_content_tool() -> list[TextContent | ImageContent]:
47
+ return [
48
+ TextContent(type="text", text="Hello"),
49
+ ImageContent(type="image", data="abc", mimeType="image/png"),
50
+ ]
51
+
52
+ @mcp.tool()
53
+ def mixed_list_fn(image_path: str) -> list:
54
+ return [
55
+ "text message",
56
+ Image(image_path),
57
+ {"key": "value"},
58
+ TextContent(type="text", text="direct content"),
59
+ ]
60
+
61
+ return mcp
62
+
63
+
64
+ class TestTools:
65
+ async def test_add_tool_exists(self, tool_server: FastMCP):
66
+ async with Client(tool_server) as client:
67
+ tools = await client.list_tools()
68
+ assert "add" in [t.name for t in tools]
69
+
70
+ async def test_list_tools(self, tool_server: FastMCP):
71
+ async with Client(tool_server) as client:
72
+ assert len(await client.list_tools()) == 6
73
+
74
+ async def test_call_tool(self, tool_server: FastMCP):
75
+ async with Client(tool_server) as client:
76
+ result = await client.call_tool("add", {"x": 1, "y": 2})
77
+ assert isinstance(result[0], TextContent)
78
+ assert result[0].text == "3"
79
+
80
+ async def test_call_tool_as_client(self, tool_server: FastMCP):
81
+ async with Client(tool_server) as client:
82
+ result = await client.call_tool("add", {"x": 1, "y": 2})
83
+ assert isinstance(result[0], TextContent)
84
+ assert result[0].text == "3"
85
+
86
+ async def test_call_tool_error(self, tool_server: FastMCP):
87
+ async with Client(tool_server) as client:
88
+ with pytest.raises(Exception):
89
+ await client.call_tool("error_tool", {})
90
+
91
+ async def test_call_tool_error_as_client(self, tool_server: FastMCP):
92
+ async with Client(tool_server) as client:
93
+ with pytest.raises(Exception):
94
+ await client.call_tool("error_tool", {})
95
+
96
+ async def test_call_tool_error_as_client_raw(self, tool_server: FastMCP):
97
+ async with Client(tool_server) as client:
98
+ result = await client.call_tool("error_tool", {}, _return_raw_result=True)
99
+ assert result.isError
100
+ assert isinstance(result.content[0], TextContent)
101
+ assert "Test error" in result.content[0].text
102
+
103
+ async def test_tool_returns_list(self, tool_server: FastMCP):
104
+ async with Client(tool_server) as client:
105
+ result = await client.call_tool("list_tool", {})
106
+ assert isinstance(result[0], TextContent)
107
+ assert result[0].text == '["x", 2]'
108
+
109
+ async def test_tool_image_helper(self, tool_server: FastMCP, tmp_path: Path):
110
+ # Create a test image
111
+ image_path = tmp_path / "test.png"
112
+ image_path.write_bytes(b"fake png data")
113
+
114
+ async with Client(tool_server) as client:
115
+ result = await client.call_tool("image_tool", {"path": str(image_path)})
116
+ content = result[0]
117
+ assert isinstance(content, ImageContent)
118
+ assert content.type == "image"
119
+ assert content.mimeType == "image/png"
120
+ # Verify base64 encoding
121
+ decoded = base64.b64decode(content.data)
122
+ assert decoded == b"fake png data"
123
+
124
+ async def test_tool_mixed_content(self, tool_server: FastMCP):
125
+ async with Client(tool_server) as client:
126
+ result = await client.call_tool("mixed_content_tool", {})
127
+ assert len(result) == 2
128
+ content1 = result[0]
129
+ content2 = result[1]
130
+ assert isinstance(content1, TextContent)
131
+ assert content1.text == "Hello"
132
+ assert isinstance(content2, ImageContent)
133
+ assert content2.mimeType == "image/png"
134
+ assert content2.data == "abc"
135
+
136
+ async def test_tool_mixed_list_with_image(
137
+ self, tool_server: FastMCP, tmp_path: Path
138
+ ):
139
+ """Test that lists containing Image objects and other types are handled
140
+ correctly. Note that the non-MCP content will be grouped together."""
141
+ # Create a test image
142
+ image_path = tmp_path / "test.png"
143
+ image_path.write_bytes(b"test image data")
144
+
145
+ async with Client(tool_server) as client:
146
+ result = await client.call_tool(
147
+ "mixed_list_fn", {"image_path": str(image_path)}
148
+ )
149
+ assert len(result) == 3
150
+ # Check text conversion
151
+ content1 = result[0]
152
+ assert isinstance(content1, TextContent)
153
+ assert json.loads(content1.text) == ["text message", {"key": "value"}]
154
+ # Check image conversion
155
+ content2 = result[1]
156
+ assert isinstance(content2, ImageContent)
157
+ assert content2.mimeType == "image/png"
158
+ assert base64.b64decode(content2.data) == b"test image data"
159
+ # Check direct TextContent
160
+ content3 = result[2]
161
+ assert isinstance(content3, TextContent)
162
+ assert content3.text == "direct content"
163
+
164
+
165
+ class TestToolParameters:
166
+ async def test_parameter_descriptions_with_field_annotations(self):
167
+ mcp = FastMCP("Test Server")
168
+
169
+ @mcp.tool()
170
+ def greet(
171
+ name: Annotated[str, Field(description="The name to greet")],
172
+ title: Annotated[str, Field(description="Optional title", default="")],
173
+ ) -> str:
174
+ """A greeting tool"""
175
+ return f"Hello {title} {name}"
176
+
177
+ async with Client(mcp) as client:
178
+ tools = await client.list_tools()
179
+ assert len(tools) == 1
180
+ tool = tools[0]
181
+
182
+ # Check that parameter descriptions are present in the schema
183
+ properties = tool.inputSchema["properties"]
184
+ assert "name" in properties
185
+ assert properties["name"]["description"] == "The name to greet"
186
+ assert "title" in properties
187
+ assert properties["title"]["description"] == "Optional title"
188
+ assert properties["title"]["default"] == ""
189
+ assert tool.inputSchema["required"] == ["name"]
190
+
191
+ async def test_parameter_descriptions_with_field_defaults(self):
192
+ mcp = FastMCP("Test Server")
193
+
194
+ @mcp.tool()
195
+ def greet(
196
+ name: str = Field(description="The name to greet"),
197
+ title: str = Field(description="Optional title", default=""),
198
+ ) -> str:
199
+ """A greeting tool"""
200
+ return f"Hello {title} {name}"
201
+
202
+ async with Client(mcp) as client:
203
+ tools = await client.list_tools()
204
+ assert len(tools) == 1
205
+ tool = tools[0]
206
+
207
+ # Check that parameter descriptions are present in the schema
208
+ properties = tool.inputSchema["properties"]
209
+ assert "name" in properties
210
+ assert properties["name"]["description"] == "The name to greet"
211
+ assert "title" in properties
212
+ assert properties["title"]["description"] == "Optional title"
213
+ assert properties["title"]["default"] == ""
214
+ assert tool.inputSchema["required"] == ["name"]
215
+
216
+ async def test_tool_with_bytes_input(self):
217
+ mcp = FastMCP()
218
+
219
+ @mcp.tool()
220
+ def process_image(image: bytes) -> Image:
221
+ return Image(data=image)
222
+
223
+ async with Client(mcp) as client:
224
+ result = await client.call_tool(
225
+ "process_image", {"image": b"fake png data"}
226
+ )
227
+ assert isinstance(result[0], ImageContent)
228
+ assert result[0].mimeType == "image/png"
229
+ assert result[0].data == base64.b64encode(b"fake png data").decode()
230
+
231
+ async def test_tool_with_invalid_input(self):
232
+ mcp = FastMCP()
233
+
234
+ @mcp.tool()
235
+ def my_tool(x: int) -> int:
236
+ return x + 1
237
+
238
+ async with Client(mcp) as client:
239
+ with pytest.raises(
240
+ ClientError,
241
+ match="Input should be a valid integer, unable to parse string as an integer",
242
+ ):
243
+ await client.call_tool("my_tool", {"x": "not an int"})
244
+
245
+ async def test_tool_int_coercion(self):
246
+ """Test string-to-int type coercion."""
247
+ mcp = FastMCP()
248
+
249
+ @mcp.tool()
250
+ def add_one(x: int) -> int:
251
+ return x + 1
252
+
253
+ async with Client(mcp) as client:
254
+ # String with integer value should be coerced to int
255
+ result = await client.call_tool("add_one", {"x": "42"})
256
+ assert isinstance(result[0], TextContent)
257
+ assert result[0].text == "43"
258
+
259
+ async def test_tool_bool_coercion(self):
260
+ """Test string-to-bool type coercion."""
261
+ mcp = FastMCP()
262
+
263
+ @mcp.tool()
264
+ def toggle(flag: bool) -> bool:
265
+ return not flag
266
+
267
+ async with Client(mcp) as client:
268
+ # String with boolean value should be coerced to bool
269
+ result = await client.call_tool("toggle", {"flag": "true"})
270
+ assert isinstance(result[0], TextContent)
271
+ assert result[0].text == "false"
272
+
273
+ result = await client.call_tool("toggle", {"flag": "false"})
274
+ assert isinstance(result[0], TextContent)
275
+ assert result[0].text == "true"
276
+
277
+ async def test_tool_list_coercion(self):
278
+ """Test JSON string to collection type coercion."""
279
+ mcp = FastMCP()
280
+
281
+ @mcp.tool()
282
+ def process_list(items: list[int]) -> int:
283
+ return sum(items)
284
+
285
+ async with Client(mcp) as client:
286
+ # JSON array string should be coerced to list
287
+ result = await client.call_tool(
288
+ "process_list", {"items": "[1, 2, 3, 4, 5]"}
289
+ )
290
+ assert isinstance(result[0], TextContent)
291
+ assert result[0].text == "15"
292
+
293
+ async def test_tool_list_coercion_error(self):
294
+ """Test that a list coercion error is raised if the input is not a valid list."""
295
+ mcp = FastMCP()
296
+
297
+ @mcp.tool()
298
+ def process_list(items: list[int]) -> int:
299
+ return sum(items)
300
+
301
+ async with Client(mcp) as client:
302
+ with pytest.raises(
303
+ ClientError,
304
+ match="Input should be a valid list",
305
+ ):
306
+ await client.call_tool("process_list", {"items": "['a', 'b', 3]"})
307
+
308
+ async def test_tool_dict_coercion(self):
309
+ """Test JSON string to dict type coercion."""
310
+ mcp = FastMCP()
311
+
312
+ @mcp.tool()
313
+ def process_dict(data: dict[str, int]) -> int:
314
+ return sum(data.values())
315
+
316
+ async with Client(mcp) as client:
317
+ # JSON object string should be coerced to dict
318
+ result = await client.call_tool(
319
+ "process_dict", {"data": '{"a": 1, "b": "2", "c": 3}'}
320
+ )
321
+ assert isinstance(result[0], TextContent)
322
+ assert result[0].text == "6"
323
+
324
+ async def test_tool_set_coercion(self):
325
+ """Test JSON string to set type coercion."""
326
+ mcp = FastMCP()
327
+
328
+ @mcp.tool()
329
+ def process_set(items: set[int]) -> int:
330
+ assert isinstance(items, set)
331
+ return sum(items)
332
+
333
+ async with Client(mcp) as client:
334
+ result = await client.call_tool("process_set", {"items": "[1, 2, 3, 4, 5]"})
335
+ assert isinstance(result[0], TextContent)
336
+ assert result[0].text == "15"
337
+
338
+ async def test_tool_tuple_coercion(self):
339
+ """Test JSON string to tuple type coercion."""
340
+ mcp = FastMCP()
341
+
342
+ @mcp.tool()
343
+ def process_tuple(items: tuple[int, str]) -> int:
344
+ assert isinstance(items, tuple)
345
+ return items[0] + len(items[1])
346
+
347
+ async with Client(mcp) as client:
348
+ result = await client.call_tool("process_tuple", {"items": '["1", "two"]'})
349
+ assert isinstance(result[0], TextContent)
350
+ assert result[0].text == "4"
351
+
352
+ async def test_annotated_field_validation(self):
353
+ mcp = FastMCP()
354
+
355
+ @mcp.tool()
356
+ def analyze(x: Annotated[int, Field(ge=1)]) -> None:
357
+ pass
358
+
359
+ async with Client(mcp) as client:
360
+ with pytest.raises(
361
+ ClientError,
362
+ match="Input should be greater than or equal to 1",
363
+ ):
364
+ await client.call_tool("analyze", {"x": 0})
365
+
366
+ async def test_default_field_validation(self):
367
+ mcp = FastMCP()
368
+
369
+ @mcp.tool()
370
+ def analyze(x: int = Field(ge=1)) -> None:
371
+ pass
372
+
373
+ async with Client(mcp) as client:
374
+ with pytest.raises(
375
+ ClientError,
376
+ match="Input should be greater than or equal to 1",
377
+ ):
378
+ await client.call_tool("analyze", {"x": 0})
379
+
380
+ async def test_default_field_is_still_required_if_no_default_specified(self):
381
+ mcp = FastMCP()
382
+
383
+ @mcp.tool()
384
+ def analyze(x: int = Field()) -> None:
385
+ pass
386
+
387
+ async with Client(mcp) as client:
388
+ with pytest.raises(ClientError, match="Field required"):
389
+ await client.call_tool("analyze", {})
390
+
391
+ async def test_literal_type_validation_error(self):
392
+ mcp = FastMCP()
393
+
394
+ @mcp.tool()
395
+ def analyze(x: Literal["a", "b"]) -> None:
396
+ pass
397
+
398
+ async with Client(mcp) as client:
399
+ with pytest.raises(ClientError, match="Input should be 'a' or 'b'"):
400
+ await client.call_tool("analyze", {"x": "c"})
401
+
402
+ async def test_literal_type_validation_success(self):
403
+ mcp = FastMCP()
404
+
405
+ @mcp.tool()
406
+ def analyze(x: Literal["a", "b"]) -> str:
407
+ return x
408
+
409
+ async with Client(mcp) as client:
410
+ result = await client.call_tool("analyze", {"x": "a"})
411
+ assert isinstance(result[0], TextContent)
412
+ assert result[0].text == "a"
413
+
414
+ async def test_enum_type_validation_error(self):
415
+ mcp = FastMCP()
416
+
417
+ class MyEnum(Enum):
418
+ RED = "red"
419
+ GREEN = "green"
420
+ BLUE = "blue"
421
+
422
+ @mcp.tool()
423
+ def analyze(x: MyEnum) -> str:
424
+ return x.value
425
+
426
+ async with Client(mcp) as client:
427
+ with pytest.raises(
428
+ ClientError, match="Input should be 'red', 'green' or 'blue'"
429
+ ):
430
+ await client.call_tool("analyze", {"x": "some-color"})
431
+
432
+ async def test_enum_type_validation_success(self):
433
+ mcp = FastMCP()
434
+
435
+ class MyEnum(Enum):
436
+ RED = "red"
437
+ GREEN = "green"
438
+ BLUE = "blue"
439
+
440
+ @mcp.tool()
441
+ def analyze(x: MyEnum) -> str:
442
+ return x.value
443
+
444
+ async with Client(mcp) as client:
445
+ result = await client.call_tool("analyze", {"x": "red"})
446
+ assert isinstance(result[0], TextContent)
447
+ assert result[0].text == "red"
448
+
449
+ async def test_union_type_validation(self):
450
+ mcp = FastMCP()
451
+
452
+ @mcp.tool()
453
+ def analyze(x: int | float) -> str:
454
+ return str(x)
455
+
456
+ async with Client(mcp) as client:
457
+ result = await client.call_tool("analyze", {"x": 1})
458
+ assert isinstance(result[0], TextContent)
459
+ assert result[0].text == "1"
460
+
461
+ result = await client.call_tool("analyze", {"x": 1.0})
462
+ assert isinstance(result[0], TextContent)
463
+ assert result[0].text == "1.0"
464
+
465
+ with pytest.raises(ClientError, match="2 validation errors for analyze"):
466
+ await client.call_tool("analyze", {"x": "not a number"})
467
+
468
+ async def test_path_type(self):
469
+ mcp = FastMCP()
470
+
471
+ @mcp.tool()
472
+ def send_path(path: Path) -> str:
473
+ assert isinstance(path, Path)
474
+ return str(path)
475
+
476
+ # Use a platform-independent path
477
+ test_path = Path("tmp") / "test.txt"
478
+
479
+ async with Client(mcp) as client:
480
+ result = await client.call_tool("send_path", {"path": str(test_path)})
481
+ assert isinstance(result[0], TextContent)
482
+ assert result[0].text == str(test_path)
483
+
484
+ async def test_path_type_error(self):
485
+ mcp = FastMCP()
486
+
487
+ @mcp.tool()
488
+ def send_path(path: Path) -> str:
489
+ return str(path)
490
+
491
+ async with Client(mcp) as client:
492
+ with pytest.raises(ClientError, match="Input is not a valid path"):
493
+ await client.call_tool("send_path", {"path": 1})
494
+
495
+ async def test_uuid_type(self):
496
+ mcp = FastMCP()
497
+
498
+ @mcp.tool()
499
+ def send_uuid(x: uuid.UUID) -> str:
500
+ assert isinstance(x, uuid.UUID)
501
+ return str(x)
502
+
503
+ test_uuid = uuid.uuid4()
504
+
505
+ async with Client(mcp) as client:
506
+ result = await client.call_tool("send_uuid", {"x": test_uuid})
507
+ assert isinstance(result[0], TextContent)
508
+ assert result[0].text == str(test_uuid)
509
+
510
+ async def test_uuid_type_error(self):
511
+ mcp = FastMCP()
512
+
513
+ @mcp.tool()
514
+ def send_uuid(x: uuid.UUID) -> str:
515
+ return str(x)
516
+
517
+ async with Client(mcp) as client:
518
+ with pytest.raises(ClientError, match="Input should be a valid UUID"):
519
+ await client.call_tool("send_uuid", {"x": "not a uuid"})
520
+
521
+ async def test_datetime_type(self):
522
+ mcp = FastMCP()
523
+
524
+ @mcp.tool()
525
+ def send_datetime(x: datetime.datetime) -> str:
526
+ return x.isoformat()
527
+
528
+ dt = datetime.datetime(2025, 4, 25, 1, 2, 3)
529
+
530
+ async with Client(mcp) as client:
531
+ result = await client.call_tool("send_datetime", {"x": dt})
532
+ assert isinstance(result[0], TextContent)
533
+ assert result[0].text == dt.isoformat()
534
+
535
+ async def test_datetime_type_parse_string(self):
536
+ mcp = FastMCP()
537
+
538
+ @mcp.tool()
539
+ def send_datetime(x: datetime.datetime) -> str:
540
+ return x.isoformat()
541
+
542
+ async with Client(mcp) as client:
543
+ result = await client.call_tool(
544
+ "send_datetime", {"x": "2021-01-01T00:00:00"}
545
+ )
546
+ assert isinstance(result[0], TextContent)
547
+ assert result[0].text == "2021-01-01T00:00:00"
548
+
549
+ async def test_datetime_type_error(self):
550
+ mcp = FastMCP()
551
+
552
+ @mcp.tool()
553
+ def send_datetime(x: datetime.datetime) -> str:
554
+ return x.isoformat()
555
+
556
+ async with Client(mcp) as client:
557
+ with pytest.raises(ClientError, match="Input should be a valid datetime"):
558
+ await client.call_tool("send_datetime", {"x": "not a datetime"})
559
+
560
+ async def test_date_type(self):
561
+ mcp = FastMCP()
562
+
563
+ @mcp.tool()
564
+ def send_date(x: datetime.date) -> str:
565
+ return x.isoformat()
566
+
567
+ async with Client(mcp) as client:
568
+ result = await client.call_tool("send_date", {"x": datetime.date.today()})
569
+ assert isinstance(result[0], TextContent)
570
+ assert result[0].text == datetime.date.today().isoformat()
571
+
572
+ async def test_date_type_parse_string(self):
573
+ mcp = FastMCP()
574
+
575
+ @mcp.tool()
576
+ def send_date(x: datetime.date) -> str:
577
+ return x.isoformat()
578
+
579
+ async with Client(mcp) as client:
580
+ result = await client.call_tool("send_date", {"x": "2021-01-01"})
581
+ assert isinstance(result[0], TextContent)
582
+ assert result[0].text == "2021-01-01"
583
+
584
+ async def test_timedelta_type(self):
585
+ mcp = FastMCP()
586
+
587
+ @mcp.tool()
588
+ def send_timedelta(x: datetime.timedelta) -> str:
589
+ return str(x)
590
+
591
+ async with Client(mcp) as client:
592
+ result = await client.call_tool(
593
+ "send_timedelta", {"x": datetime.timedelta(days=1)}
594
+ )
595
+ assert isinstance(result[0], TextContent)
596
+ assert result[0].text == "1 day, 0:00:00"
597
+
598
+ async def test_timedelta_type_parse_int(self):
599
+ mcp = FastMCP()
600
+
601
+ @mcp.tool()
602
+ def send_timedelta(x: datetime.timedelta) -> str:
603
+ return str(x)
604
+
605
+ async with Client(mcp) as client:
606
+ result = await client.call_tool("send_timedelta", {"x": 1000})
607
+ assert isinstance(result[0], TextContent)
608
+ assert result[0].text == "0:16:40"
609
+
610
+
611
+ class TestResources:
612
+ async def test_text_resource(self):
613
+ mcp = FastMCP()
614
+
615
+ def get_text():
616
+ return "Hello, world!"
617
+
618
+ resource = FunctionResource(
619
+ uri=AnyUrl("resource://test"), name="test", fn=get_text
620
+ )
621
+ mcp.add_resource(resource)
622
+
623
+ async with Client(mcp) as client:
624
+ result = await client.read_resource(AnyUrl("resource://test"))
625
+ assert isinstance(result[0], TextResourceContents)
626
+ assert result[0].text == "Hello, world!"
627
+
628
+ async def test_binary_resource(self):
629
+ mcp = FastMCP()
630
+
631
+ def get_binary():
632
+ return b"Binary data"
633
+
634
+ resource = FunctionResource(
635
+ uri=AnyUrl("resource://binary"),
636
+ name="binary",
637
+ fn=get_binary,
638
+ mime_type="application/octet-stream",
639
+ )
640
+ mcp.add_resource(resource)
641
+
642
+ async with Client(mcp) as client:
643
+ result = await client.read_resource(AnyUrl("resource://binary"))
644
+ assert isinstance(result[0], BlobResourceContents)
645
+ assert result[0].blob == base64.b64encode(b"Binary data").decode()
646
+
647
+ async def test_file_resource_text(self, tmp_path: Path):
648
+ mcp = FastMCP()
649
+
650
+ # Create a text file
651
+ text_file = tmp_path / "test.txt"
652
+ text_file.write_text("Hello from file!")
653
+
654
+ resource = FileResource(
655
+ uri=AnyUrl("file://test.txt"), name="test.txt", path=text_file
656
+ )
657
+ mcp.add_resource(resource)
658
+
659
+ async with Client(mcp) as client:
660
+ result = await client.read_resource(AnyUrl("file://test.txt"))
661
+ assert isinstance(result[0], TextResourceContents)
662
+ assert result[0].text == "Hello from file!"
663
+
664
+ async def test_file_resource_binary(self, tmp_path: Path):
665
+ mcp = FastMCP()
666
+
667
+ # Create a binary file
668
+ binary_file = tmp_path / "test.bin"
669
+ binary_file.write_bytes(b"Binary file data")
670
+
671
+ resource = FileResource(
672
+ uri=AnyUrl("file://test.bin"),
673
+ name="test.bin",
674
+ path=binary_file,
675
+ mime_type="application/octet-stream",
676
+ )
677
+ mcp.add_resource(resource)
678
+
679
+ async with Client(mcp) as client:
680
+ result = await client.read_resource(AnyUrl("file://test.bin"))
681
+ assert isinstance(result[0], BlobResourceContents)
682
+ assert result[0].blob == base64.b64encode(b"Binary file data").decode()
683
+
684
+
685
+ class TestResourceTemplates:
686
+ async def test_resource_with_params_not_in_uri(self):
687
+ """Test that a resource with function parameters raises an error if the URI
688
+ parameters don't match"""
689
+ mcp = FastMCP()
690
+
691
+ with pytest.raises(
692
+ ValueError,
693
+ match="URI template must contain at least one parameter",
694
+ ):
695
+
696
+ @mcp.resource("resource://data")
697
+ def get_data_fn(param: str) -> str:
698
+ return f"Data: {param}"
699
+
700
+ async def test_resource_with_uri_params_without_args(self):
701
+ """Test that a resource with URI parameters is automatically a template"""
702
+ mcp = FastMCP()
703
+
704
+ with pytest.raises(
705
+ ValueError,
706
+ match="URI parameters .* must be a subset of the function arguments",
707
+ ):
708
+
709
+ @mcp.resource("resource://{param}")
710
+ def get_data() -> str:
711
+ return "Data"
712
+
713
+ async def test_resource_with_untyped_params(self):
714
+ """Test that a resource with untyped parameters raises an error"""
715
+ mcp = FastMCP()
716
+
717
+ @mcp.resource("resource://{param}")
718
+ def get_data(param) -> str:
719
+ return "Data"
720
+
721
+ async def test_resource_matching_params(self):
722
+ """Test that a resource with matching URI and function parameters works"""
723
+ mcp = FastMCP()
724
+
725
+ @mcp.resource("resource://{name}/data")
726
+ def get_data(name: str) -> str:
727
+ return f"Data for {name}"
728
+
729
+ async with Client(mcp) as client:
730
+ result = await client.read_resource(AnyUrl("resource://test/data"))
731
+ assert isinstance(result[0], TextResourceContents)
732
+ assert result[0].text == "Data for test"
733
+
734
+ async def test_resource_mismatched_params(self):
735
+ """Test that mismatched parameters raise an error"""
736
+ mcp = FastMCP()
737
+
738
+ with pytest.raises(
739
+ ValueError,
740
+ match="URI parameters .* must be a subset of the required function arguments",
741
+ ):
742
+
743
+ @mcp.resource("resource://{name}/data")
744
+ def get_data(user: str) -> str:
745
+ return f"Data for {user}"
746
+
747
+ async def test_resource_multiple_params(self):
748
+ """Test that multiple parameters work correctly"""
749
+ mcp = FastMCP()
750
+
751
+ @mcp.resource("resource://{org}/{repo}/data")
752
+ def get_data(org: str, repo: str) -> str:
753
+ return f"Data for {org}/{repo}"
754
+
755
+ async with Client(mcp) as client:
756
+ result = await client.read_resource(
757
+ AnyUrl("resource://cursor/fastmcp/data")
758
+ )
759
+ assert isinstance(result[0], TextResourceContents)
760
+ assert result[0].text == "Data for cursor/fastmcp"
761
+
762
+ async def test_resource_multiple_mismatched_params(self):
763
+ """Test that mismatched parameters raise an error"""
764
+ mcp = FastMCP()
765
+
766
+ with pytest.raises(
767
+ ValueError,
768
+ match="URI parameters .* must be a subset of the required function arguments",
769
+ ):
770
+
771
+ @mcp.resource("resource://{org}/{repo}/data")
772
+ def get_data_mismatched(org: str, repo_2: str) -> str:
773
+ return f"Data for {org}"
774
+
775
+ """Test that a resource with no parameters works as a regular resource"""
776
+ mcp = FastMCP()
777
+
778
+ @mcp.resource("resource://static")
779
+ def get_static_data() -> str:
780
+ return "Static data"
781
+
782
+ async with Client(mcp) as client:
783
+ result = await client.read_resource(AnyUrl("resource://static"))
784
+ assert isinstance(result[0], TextResourceContents)
785
+ assert result[0].text == "Static data"
786
+
787
+ async def test_template_with_default_params(self):
788
+ """Test that a template can have default parameters."""
789
+ mcp = FastMCP()
790
+
791
+ @mcp.resource("math://add/{x}")
792
+ def add(x: int, y: int = 10) -> int:
793
+ return x + y
794
+
795
+ # Verify it's registered as a template
796
+ templates_dict = await mcp.get_resource_templates()
797
+ templates = list(templates_dict.values())
798
+ assert len(templates) == 1
799
+ assert templates[0].uri_template == "math://add/{x}"
800
+
801
+ # Call the template and verify it uses the default value
802
+ async with Client(mcp) as client:
803
+ result = await client.read_resource(AnyUrl("math://add/5"))
804
+ assert isinstance(result[0], TextResourceContents)
805
+ assert result[0].text == "15" # 5 + default 10
806
+
807
+ # Can also call with explicit params
808
+ result2 = await client.read_resource(AnyUrl("math://add/7"))
809
+ assert isinstance(result2[0], TextResourceContents)
810
+ assert result2[0].text == "17" # 7 + default 10
811
+
812
+ async def test_template_to_resource_conversion(self):
813
+ """Test that a template can be converted to a resource."""
814
+ mcp = FastMCP()
815
+
816
+ @mcp.resource("resource://{name}/data")
817
+ def get_data(name: str) -> str:
818
+ return f"Data for {name}"
819
+
820
+ # Verify it's registered as a template
821
+ templates_dict = await mcp.get_resource_templates()
822
+ templates = list(templates_dict.values())
823
+ assert len(templates) == 1
824
+ assert templates[0].uri_template == "resource://{name}/data"
825
+
826
+ # When accessed, should create a concrete resource
827
+ async with Client(mcp) as client:
828
+ result = await client.read_resource(AnyUrl("resource://test/data"))
829
+ assert isinstance(result[0], TextResourceContents)
830
+ assert result[0].text == "Data for test"
831
+
832
+ async def test_stacked_resource_template_decorators(self):
833
+ """Test that resource template decorators can be stacked."""
834
+ mcp = FastMCP()
835
+
836
+ @mcp.resource("users://email/{email}")
837
+ @mcp.resource("users://name/{name}")
838
+ def lookup_user(name: str | None = None, email: str | None = None) -> dict:
839
+ if name:
840
+ return {
841
+ "id": "123",
842
+ "name": name,
843
+ "email": "dummy@example.com",
844
+ "lookup": "name",
845
+ }
846
+ elif email:
847
+ return {
848
+ "id": "123",
849
+ "name": "Test User",
850
+ "email": email,
851
+ "lookup": "email",
852
+ }
853
+ else:
854
+ raise ValueError("Either name or email must be provided")
855
+
856
+ # Verify both templates are registered
857
+ templates_dict = await mcp.get_resource_templates()
858
+ templates = list(templates_dict.values())
859
+ assert len(templates) == 2
860
+ template_uris = {t.uri_template for t in templates}
861
+ assert "users://email/{email}" in template_uris
862
+ assert "users://name/{name}" in template_uris
863
+
864
+ # Test lookup by email
865
+ async with Client(mcp) as client:
866
+ email_result = await client.read_resource(
867
+ AnyUrl("users://email/user@example.com")
868
+ )
869
+ assert isinstance(email_result[0], TextResourceContents)
870
+ email_data = json.loads(email_result[0].text)
871
+ assert email_data["lookup"] == "email"
872
+ assert email_data["email"] == "user@example.com"
873
+
874
+ # Test lookup by name
875
+ name_result = await client.read_resource(AnyUrl("users://name/John"))
876
+ assert isinstance(name_result[0], TextResourceContents)
877
+ name_data = json.loads(name_result[0].text)
878
+ assert name_data["lookup"] == "name"
879
+ assert name_data["name"] == "John"
880
+ assert name_data["email"] == "dummy@example.com"
881
+
882
+ async def test_template_decorator_with_tags(self):
883
+ mcp = FastMCP()
884
+
885
+ @mcp.resource("resource://{param}", tags={"template", "test-tag"})
886
+ def template_resource(param: str) -> str:
887
+ return f"Template resource: {param}"
888
+
889
+ templates_dict = await mcp.get_resource_templates()
890
+ template = templates_dict["resource://{param}"]
891
+ assert template.tags == {"template", "test-tag"}
892
+
893
+ async def test_template_decorator_wildcard_param(self):
894
+ mcp = FastMCP()
895
+
896
+ @mcp.resource("resource://{param*}")
897
+ def template_resource(param: str) -> str:
898
+ return f"Template resource: {param}"
899
+
900
+ async with Client(mcp) as client:
901
+ result = await client.read_resource(AnyUrl("resource://test/data"))
902
+ assert isinstance(result[0], TextResourceContents)
903
+ assert result[0].text == "Template resource: test/data"
904
+
905
+ async def test_templates_match_in_order_of_definition(self):
906
+ """
907
+ If a wildcard template is defined first, it will take priority over another
908
+ matching template.
909
+
910
+ """
911
+ mcp = FastMCP()
912
+
913
+ @mcp.resource("resource://{param*}")
914
+ def template_resource(param: str) -> str:
915
+ return f"Template resource 1: {param}"
916
+
917
+ @mcp.resource("resource://{x}/{y}")
918
+ def template_resource_with_params(x: str, y: str) -> str:
919
+ return f"Template resource 2: {x}/{y}"
920
+
921
+ async with Client(mcp) as client:
922
+ result = await client.read_resource(AnyUrl("resource://a/b/c"))
923
+ assert isinstance(result[0], TextResourceContents)
924
+ assert result[0].text == "Template resource 1: a/b/c"
925
+
926
+ result = await client.read_resource(AnyUrl("resource://a/b"))
927
+ assert isinstance(result[0], TextResourceContents)
928
+ assert result[0].text == "Template resource 1: a/b"
929
+
930
+ async def test_templates_shadow_each_other_reorder(self):
931
+ """
932
+ If a wildcard template is defined second, it will *not* take priority over
933
+ another matching template.
934
+ """
935
+ mcp = FastMCP()
936
+
937
+ @mcp.resource("resource://{x}/{y}")
938
+ def template_resource_with_params(x: str, y: str) -> str:
939
+ return f"Template resource 1: {x}/{y}"
940
+
941
+ @mcp.resource("resource://{param*}")
942
+ def template_resource(param: str) -> str:
943
+ return f"Template resource 2: {param}"
944
+
945
+ async with Client(mcp) as client:
946
+ result = await client.read_resource(AnyUrl("resource://a/b/c"))
947
+ assert isinstance(result[0], TextResourceContents)
948
+ assert result[0].text == "Template resource 2: a/b/c"
949
+
950
+ result = await client.read_resource(AnyUrl("resource://a/b"))
951
+ assert isinstance(result[0], TextResourceContents)
952
+ assert result[0].text == "Template resource 1: a/b"
953
+
954
+
955
+ class TestContextInjection:
956
+ """Test context injection in tools."""
957
+
958
+ async def test_context_detection(self):
959
+ """Test that context parameters are properly detected."""
960
+ mcp = FastMCP()
961
+
962
+ def tool_with_context(x: int, ctx: Context) -> str:
963
+ return f"Request {ctx.request_id}: {x}"
964
+
965
+ mcp.add_tool(tool_with_context)
966
+ async with Client(mcp) as client:
967
+ tools = await client.list_tools()
968
+ assert len(tools) == 1
969
+ assert tools[0].name == "tool_with_context"
970
+
971
+ async def test_context_injection(self):
972
+ """Test that context is properly injected into tool calls."""
973
+ mcp = FastMCP()
974
+
975
+ def tool_with_context(x: int, ctx: Context) -> str:
976
+ assert ctx.request_id is not None
977
+ return f"Request {ctx.request_id}: {x}"
978
+
979
+ mcp.add_tool(tool_with_context)
980
+ async with Client(mcp) as client:
981
+ result = await client.call_tool("tool_with_context", {"x": 42})
982
+ assert len(result) == 1
983
+ content = result[0]
984
+ assert isinstance(content, TextContent)
985
+ assert "Request" in content.text
986
+ assert "42" in content.text
987
+
988
+ async def test_async_context(self):
989
+ """Test that context works in async functions."""
990
+ mcp = FastMCP()
991
+
992
+ async def async_tool(x: int, ctx: Context) -> str:
993
+ assert ctx.request_id is not None
994
+ return f"Async request {ctx.request_id}: {x}"
995
+
996
+ mcp.add_tool(async_tool)
997
+ async with Client(mcp) as client:
998
+ result = await client.call_tool("async_tool", {"x": 42})
999
+ assert len(result) == 1
1000
+ content = result[0]
1001
+ assert isinstance(content, TextContent)
1002
+ assert "Async request" in content.text
1003
+ assert "42" in content.text
1004
+
1005
+ async def test_context_logging(self):
1006
+ from unittest.mock import patch
1007
+
1008
+ import mcp.server.session
1009
+
1010
+ """Test that context logging methods work."""
1011
+ mcp = FastMCP()
1012
+
1013
+ async def logging_tool(msg: str, ctx: Context) -> str:
1014
+ await ctx.debug("Debug message")
1015
+ await ctx.info("Info message")
1016
+ await ctx.warning("Warning message")
1017
+ await ctx.error("Error message")
1018
+ return f"Logged messages for {msg}"
1019
+
1020
+ mcp.add_tool(logging_tool)
1021
+
1022
+ with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
1023
+ async with Client(mcp) as client:
1024
+ result = await client.call_tool("logging_tool", {"msg": "test"})
1025
+ assert len(result) == 1
1026
+ content = result[0]
1027
+ assert isinstance(content, TextContent)
1028
+ assert "Logged messages for test" in content.text
1029
+
1030
+ assert mock_log.call_count == 4
1031
+ mock_log.assert_any_call(
1032
+ level="debug", data="Debug message", logger=None
1033
+ )
1034
+ mock_log.assert_any_call(level="info", data="Info message", logger=None)
1035
+ mock_log.assert_any_call(
1036
+ level="warning", data="Warning message", logger=None
1037
+ )
1038
+ mock_log.assert_any_call(
1039
+ level="error", data="Error message", logger=None
1040
+ )
1041
+
1042
+ async def test_optional_context(self):
1043
+ """Test that context is optional."""
1044
+ mcp = FastMCP()
1045
+
1046
+ def no_context(x: int) -> int:
1047
+ return x * 2
1048
+
1049
+ mcp.add_tool(no_context)
1050
+ async with Client(mcp) as client:
1051
+ result = await client.call_tool("no_context", {"x": 21})
1052
+ assert len(result) == 1
1053
+ content = result[0]
1054
+ assert isinstance(content, TextContent)
1055
+ assert content.text == "42"
1056
+
1057
+ async def test_context_resource_access(self):
1058
+ """Test that context can access resources."""
1059
+ mcp = FastMCP()
1060
+
1061
+ @mcp.resource("test://data")
1062
+ def test_resource() -> str:
1063
+ return "resource data"
1064
+
1065
+ @mcp.tool()
1066
+ async def tool_with_resource(ctx: Context) -> str:
1067
+ r_iter = await ctx.read_resource("test://data")
1068
+ r_list = list(r_iter)
1069
+ assert len(r_list) == 1
1070
+ r = r_list[0]
1071
+ return f"Read resource: {r.content} with mime type {r.mime_type}"
1072
+
1073
+ async with Client(mcp) as client:
1074
+ result = await client.call_tool("tool_with_resource", {})
1075
+ assert len(result) == 1
1076
+ content = result[0]
1077
+ assert isinstance(content, TextContent)
1078
+ assert "Read resource: resource data" in content.text
1079
+
1080
+ async def test_tool_decorator_with_tags(self):
1081
+ """Test that the tool decorator properly sets tags."""
1082
+ mcp = FastMCP()
1083
+
1084
+ @mcp.tool(tags={"example", "test-tag"})
1085
+ def sample_tool(x: int) -> int:
1086
+ return x * 2
1087
+
1088
+ # Verify the tool exists
1089
+ async with Client(mcp) as client:
1090
+ tools = await client.list_tools()
1091
+ assert len(tools) == 1
1092
+ # Note: MCPTool from the client API doesn't expose tags
1093
+
1094
+
1095
+ class TestPrompts:
1096
+ """Test prompt functionality in FastMCP server."""
1097
+
1098
+ async def test_prompt_decorator(self):
1099
+ """Test that the prompt decorator registers prompts correctly."""
1100
+ mcp = FastMCP()
1101
+
1102
+ @mcp.prompt()
1103
+ def fn() -> str:
1104
+ return "Hello, world!"
1105
+
1106
+ prompts_dict = await mcp.get_prompts()
1107
+ assert len(prompts_dict) == 1
1108
+ prompt = prompts_dict["fn"]
1109
+ assert prompt.name == "fn"
1110
+ # Don't compare functions directly since validate_call wraps them
1111
+ content = await prompt.render()
1112
+ assert isinstance(content[0].content, TextContent)
1113
+ assert content[0].content.text == "Hello, world!"
1114
+
1115
+ async def test_prompt_decorator_with_name(self):
1116
+ """Test prompt decorator with custom name."""
1117
+ mcp = FastMCP()
1118
+
1119
+ @mcp.prompt(name="custom_name")
1120
+ def fn() -> str:
1121
+ return "Hello, world!"
1122
+
1123
+ prompts_dict = await mcp.get_prompts()
1124
+ assert len(prompts_dict) == 1
1125
+ prompt = prompts_dict["custom_name"]
1126
+ assert prompt.name == "custom_name"
1127
+ content = await prompt.render()
1128
+ assert isinstance(content[0].content, TextContent)
1129
+ assert content[0].content.text == "Hello, world!"
1130
+
1131
+ async def test_prompt_decorator_with_description(self):
1132
+ """Test prompt decorator with custom description."""
1133
+ mcp = FastMCP()
1134
+
1135
+ @mcp.prompt(description="A custom description")
1136
+ def fn() -> str:
1137
+ return "Hello, world!"
1138
+
1139
+ prompts_dict = await mcp.get_prompts()
1140
+ assert len(prompts_dict) == 1
1141
+ prompt = prompts_dict["fn"]
1142
+ assert prompt.description == "A custom description"
1143
+ content = await prompt.render()
1144
+ assert isinstance(content[0].content, TextContent)
1145
+ assert content[0].content.text == "Hello, world!"
1146
+
1147
+ def test_prompt_decorator_error(self):
1148
+ """Test error when decorator is used incorrectly."""
1149
+ mcp = FastMCP()
1150
+ with pytest.raises(TypeError, match="decorator was used incorrectly"):
1151
+
1152
+ @mcp.prompt # type: ignore
1153
+ def fn() -> str:
1154
+ return "Hello, world!"
1155
+
1156
+ async def test_list_prompts(self):
1157
+ """Test listing prompts through MCP protocol."""
1158
+ mcp = FastMCP()
1159
+
1160
+ @mcp.prompt()
1161
+ def fn(name: str, optional: str = "default") -> str:
1162
+ return f"Hello, {name}! {optional}"
1163
+
1164
+ prompts_dict = await mcp.get_prompts()
1165
+ assert len(prompts_dict) == 1
1166
+
1167
+ async with Client(mcp) as client:
1168
+ prompts = await client.list_prompts()
1169
+ assert len(prompts) == 1
1170
+ assert prompts[0].name == "fn"
1171
+ assert prompts[0].description is None
1172
+ assert prompts[0].arguments is not None
1173
+ assert len(prompts[0].arguments) == 2
1174
+ assert prompts[0].arguments[0].name == "name"
1175
+ assert prompts[0].arguments[0].required is True
1176
+ assert prompts[0].arguments[1].name == "optional"
1177
+ assert prompts[0].arguments[1].required is False
1178
+
1179
+ async def test_get_prompt(self):
1180
+ """Test getting a prompt through MCP protocol."""
1181
+ mcp = FastMCP()
1182
+
1183
+ @mcp.prompt()
1184
+ def fn(name: str) -> str:
1185
+ return f"Hello, {name}!"
1186
+
1187
+ async with Client(mcp) as client:
1188
+ result = await client.get_prompt("fn", {"name": "World"})
1189
+ assert len(result) == 1
1190
+ message = result[0]
1191
+ assert message.role == "user"
1192
+ content = message.content
1193
+ assert isinstance(content, TextContent)
1194
+ assert content.text == "Hello, World!"
1195
+
1196
+ async def test_get_prompt_with_resource(self):
1197
+ """Test getting a prompt that returns resource content."""
1198
+ mcp = FastMCP()
1199
+
1200
+ @mcp.prompt()
1201
+ def fn() -> Message:
1202
+ return UserMessage(
1203
+ content=EmbeddedResource(
1204
+ type="resource",
1205
+ resource=TextResourceContents(
1206
+ uri=AnyUrl("file://file.txt"),
1207
+ text="File contents",
1208
+ mimeType="text/plain",
1209
+ ),
1210
+ )
1211
+ )
1212
+
1213
+ async with Client(mcp) as client:
1214
+ result = await client.get_prompt("fn")
1215
+ assert result[0].role == "user"
1216
+ content = result[0].content
1217
+ assert isinstance(content, EmbeddedResource)
1218
+ resource = content.resource
1219
+ assert isinstance(resource, TextResourceContents)
1220
+ assert resource.text == "File contents"
1221
+ assert resource.mimeType == "text/plain"
1222
+
1223
+ async def test_get_unknown_prompt(self):
1224
+ """Test error when getting unknown prompt."""
1225
+ mcp = FastMCP()
1226
+ with pytest.raises(ClientError, match="Unknown prompt"):
1227
+ async with Client(mcp) as client:
1228
+ await client.get_prompt("unknown")
1229
+
1230
+ async def test_get_prompt_missing_args(self):
1231
+ """Test error when required arguments are missing."""
1232
+ mcp = FastMCP()
1233
+
1234
+ @mcp.prompt()
1235
+ def prompt_fn(name: str) -> str:
1236
+ return f"Hello, {name}!"
1237
+
1238
+ with pytest.raises(ClientError, match="Missing required arguments"):
1239
+ async with Client(mcp) as client:
1240
+ await client.get_prompt("prompt_fn")
1241
+
1242
+ async def test_resource_decorator_with_tags(self):
1243
+ """Test that the resource decorator supports tags."""
1244
+ mcp = FastMCP()
1245
+
1246
+ @mcp.resource("resource://data", tags={"example", "test-tag"})
1247
+ def get_data() -> str:
1248
+ return "Hello, world!"
1249
+
1250
+ resources_dict = await mcp.get_resources()
1251
+ resources = list(resources_dict.values())
1252
+ assert len(resources) == 1
1253
+ assert resources[0].tags == {"example", "test-tag"}
1254
+
1255
+ async def test_template_decorator_with_tags(self):
1256
+ """Test that the template decorator properly sets tags."""
1257
+ mcp = FastMCP()
1258
+
1259
+ @mcp.resource("resource://{param}", tags={"template", "test-tag"})
1260
+ def template_resource(param: str) -> str:
1261
+ return f"Template resource: {param}"
1262
+
1263
+ templates_dict = await mcp.get_resource_templates()
1264
+ template = templates_dict["resource://{param}"]
1265
+ assert template.tags == {"template", "test-tag"}
1266
+
1267
+ async def test_prompt_decorator_with_tags(self):
1268
+ """Test that the prompt decorator properly sets tags."""
1269
+ mcp = FastMCP()
1270
+
1271
+ @mcp.prompt(tags={"example", "test-tag"})
1272
+ def sample_prompt() -> str:
1273
+ return "Hello, world!"
1274
+
1275
+ prompts_dict = await mcp.get_prompts()
1276
+ assert len(prompts_dict) == 1
1277
+ prompt = prompts_dict["sample_prompt"]
1278
+ assert prompt.tags == {"example", "test-tag"}