Spaces:
Running
Running
Merge pull request #263 from jlowin/context
Browse filesSupport context injection in resources, templates, and prompts (like tools)
- docs/servers/context.mdx +68 -14
- docs/servers/prompts.mdx +11 -20
- docs/servers/resources.mdx +32 -22
- docs/servers/tools.mdx +35 -34
- src/fastmcp/prompts/prompt.py +43 -4
- src/fastmcp/prompts/prompt_manager.py +14 -3
- src/fastmcp/resources/resource.py +12 -2
- src/fastmcp/resources/resource_manager.py +20 -5
- src/fastmcp/resources/template.py +43 -4
- src/fastmcp/resources/types.py +55 -11
- src/fastmcp/server/openapi.py +19 -3
- src/fastmcp/server/proxy.py +37 -20
- src/fastmcp/server/server.py +31 -3
- src/fastmcp/tools/tool.py +8 -5
- tests/server/test_server_interactions.py +181 -135
docs/servers/context.mdx
CHANGED
|
@@ -1,16 +1,16 @@
|
|
| 1 |
---
|
| 2 |
title: MCP Context
|
| 3 |
sidebarTitle: Context
|
| 4 |
-
description: Access MCP capabilities like logging, progress, and resources within your
|
| 5 |
icon: rectangle-code
|
| 6 |
---
|
| 7 |
import { VersionBadge } from '/snippets/version-badge.mdx'
|
| 8 |
|
| 9 |
-
When defining FastMCP [tools](/servers/tools), your functions might need to interact with the underlying MCP session or access server capabilities. FastMCP provides the `Context` object for this purpose.
|
| 10 |
|
| 11 |
## What Is Context?
|
| 12 |
|
| 13 |
-
The `Context` object provides a clean interface to access MCP features within your
|
| 14 |
|
| 15 |
- **Logging**: Send debug, info, warning, and error messages back to the client
|
| 16 |
- **Progress Reporting**: Update the client on the progress of long-running operations
|
|
@@ -21,7 +21,7 @@ The `Context` object provides a clean interface to access MCP features within yo
|
|
| 21 |
|
| 22 |
## Accessing the Context
|
| 23 |
|
| 24 |
-
To use the context object within
|
| 25 |
|
| 26 |
```python
|
| 27 |
from fastmcp import FastMCP, Context
|
|
@@ -65,15 +65,15 @@ async def process_file(file_uri: str, ctx: Context) -> str:
|
|
| 65 |
|
| 66 |
- The parameter name (e.g., `ctx`, `context`) doesn't matter, only the type hint `Context` is important.
|
| 67 |
- The context parameter can be placed anywhere in your function's signature.
|
| 68 |
-
- The context is optional -
|
| 69 |
-
- Context is only available
|
| 70 |
-
- Context methods are async, so your
|
| 71 |
|
| 72 |
## Context Capabilities
|
| 73 |
|
| 74 |
### Logging
|
| 75 |
|
| 76 |
-
Send log messages back to the MCP client. This is useful for debugging and providing visibility into
|
| 77 |
|
| 78 |
```python
|
| 79 |
@mcp.tool()
|
|
@@ -97,14 +97,14 @@ async def analyze_data(data: list[float], ctx: Context) -> dict:
|
|
| 97 |
**Available Logging Methods:**
|
| 98 |
|
| 99 |
- **`ctx.debug(message: str)`**: Low-level details useful for debugging
|
| 100 |
-
- **`ctx.info(message: str)`**: General information about
|
| 101 |
- **`ctx.warning(message: str)`**: Potential issues that didn't prevent execution
|
| 102 |
- **`ctx.error(message: str)`**: Errors that occurred during execution
|
| 103 |
- **`ctx.log(level: Literal["debug", "info", "warning", "error"], message: str, logger_name: str | None = None)`**: Generic log method supporting custom logger names
|
| 104 |
|
| 105 |
### Progress Reporting
|
| 106 |
|
| 107 |
-
For long-running
|
| 108 |
|
| 109 |
```python
|
| 110 |
@mcp.tool()
|
|
@@ -137,7 +137,7 @@ Progress reporting requires the client to have sent a `progressToken` in the ini
|
|
| 137 |
|
| 138 |
### Resource Access
|
| 139 |
|
| 140 |
-
Read data from resources registered with your FastMCP server. This allows
|
| 141 |
|
| 142 |
```python
|
| 143 |
@mcp.tool()
|
|
@@ -177,7 +177,7 @@ The returned content is typically accessed via `content_list[0].content` and can
|
|
| 177 |
|
| 178 |
<VersionBadge version="2.0.0" />
|
| 179 |
|
| 180 |
-
Request the client's LLM to generate text based on provided messages. This is useful when your
|
| 181 |
|
| 182 |
```python
|
| 183 |
@mcp.tool()
|
|
@@ -279,6 +279,60 @@ async def advanced_tool(ctx: Context) -> str:
|
|
| 279 |
Direct use of `session` or `request_context` requires understanding the low-level MCP Python SDK and may be less stable than using the methods provided directly on the `Context` object.
|
| 280 |
</Warning>
|
| 281 |
|
| 282 |
-
## Using Context in
|
| 283 |
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: MCP Context
|
| 3 |
sidebarTitle: Context
|
| 4 |
+
description: Access MCP capabilities like logging, progress, and resources within your MCP objects.
|
| 5 |
icon: rectangle-code
|
| 6 |
---
|
| 7 |
import { VersionBadge } from '/snippets/version-badge.mdx'
|
| 8 |
|
| 9 |
+
When defining FastMCP [tools](/servers/tools), [resources](/servers/resources), resource templates, or [prompts](/servers/prompts), your functions might need to interact with the underlying MCP session or access server capabilities. FastMCP provides the `Context` object for this purpose.
|
| 10 |
|
| 11 |
## What Is Context?
|
| 12 |
|
| 13 |
+
The `Context` object provides a clean interface to access MCP features within your functions, including:
|
| 14 |
|
| 15 |
- **Logging**: Send debug, info, warning, and error messages back to the client
|
| 16 |
- **Progress Reporting**: Update the client on the progress of long-running operations
|
|
|
|
| 21 |
|
| 22 |
## Accessing the Context
|
| 23 |
|
| 24 |
+
To use the context object within any of your functions, simply add a parameter to your function signature and type-hint it as `Context`. FastMCP will automatically inject the context instance when your function is called.
|
| 25 |
|
| 26 |
```python
|
| 27 |
from fastmcp import FastMCP, Context
|
|
|
|
| 65 |
|
| 66 |
- The parameter name (e.g., `ctx`, `context`) doesn't matter, only the type hint `Context` is important.
|
| 67 |
- The context parameter can be placed anywhere in your function's signature.
|
| 68 |
+
- The context is optional - functions that don't need it can omit the parameter.
|
| 69 |
+
- Context is only available during a request; attempting to use context methods outside a request will raise errors.
|
| 70 |
+
- Context methods are async, so your function usually needs to be async as well.
|
| 71 |
|
| 72 |
## Context Capabilities
|
| 73 |
|
| 74 |
### Logging
|
| 75 |
|
| 76 |
+
Send log messages back to the MCP client. This is useful for debugging and providing visibility into function execution during a request.
|
| 77 |
|
| 78 |
```python
|
| 79 |
@mcp.tool()
|
|
|
|
| 97 |
**Available Logging Methods:**
|
| 98 |
|
| 99 |
- **`ctx.debug(message: str)`**: Low-level details useful for debugging
|
| 100 |
+
- **`ctx.info(message: str)`**: General information about execution
|
| 101 |
- **`ctx.warning(message: str)`**: Potential issues that didn't prevent execution
|
| 102 |
- **`ctx.error(message: str)`**: Errors that occurred during execution
|
| 103 |
- **`ctx.log(level: Literal["debug", "info", "warning", "error"], message: str, logger_name: str | None = None)`**: Generic log method supporting custom logger names
|
| 104 |
|
| 105 |
### Progress Reporting
|
| 106 |
|
| 107 |
+
For long-running operations, notify the client about the progress. This allows clients to display progress indicators and provide a better user experience.
|
| 108 |
|
| 109 |
```python
|
| 110 |
@mcp.tool()
|
|
|
|
| 137 |
|
| 138 |
### Resource Access
|
| 139 |
|
| 140 |
+
Read data from resources registered with your FastMCP server. This allows functions to access files, configuration, or dynamically generated content.
|
| 141 |
|
| 142 |
```python
|
| 143 |
@mcp.tool()
|
|
|
|
| 177 |
|
| 178 |
<VersionBadge version="2.0.0" />
|
| 179 |
|
| 180 |
+
Request the client's LLM to generate text based on provided messages. This is useful when your function needs to leverage the LLM's capabilities to process data or generate responses.
|
| 181 |
|
| 182 |
```python
|
| 183 |
@mcp.tool()
|
|
|
|
| 279 |
Direct use of `session` or `request_context` requires understanding the low-level MCP Python SDK and may be less stable than using the methods provided directly on the `Context` object.
|
| 280 |
</Warning>
|
| 281 |
|
| 282 |
+
## Using Context in Different Components
|
| 283 |
|
| 284 |
+
All FastMCP components (tools, resources, templates, and prompts) can use the Context object following the same pattern - simply add a parameter with the `Context` type annotation.
|
| 285 |
+
|
| 286 |
+
### Context in Resources and Templates
|
| 287 |
+
|
| 288 |
+
Resources and resource templates can access context to customize their behavior:
|
| 289 |
+
|
| 290 |
+
```python
|
| 291 |
+
@mcp.resource("resource://user-data")
|
| 292 |
+
async def get_user_data(ctx: Context) -> dict:
|
| 293 |
+
"""Fetch personalized user data based on the request context."""
|
| 294 |
+
user_id = ctx.client_id or "anonymous"
|
| 295 |
+
await ctx.info(f"Fetching data for user {user_id}")
|
| 296 |
+
|
| 297 |
+
# Example of using context for dynamic resource generation
|
| 298 |
+
return {
|
| 299 |
+
"user_id": user_id,
|
| 300 |
+
"last_access": datetime.now().isoformat(),
|
| 301 |
+
"request_id": ctx.request_id
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
@mcp.resource("resource://users/{user_id}/profile")
|
| 305 |
+
async def get_user_profile(user_id: str, ctx: Context) -> dict:
|
| 306 |
+
"""Fetch user profile from database with context-aware logging."""
|
| 307 |
+
await ctx.info(f"Fetching profile for user {user_id}")
|
| 308 |
+
|
| 309 |
+
# Example of using context in a template resource
|
| 310 |
+
# In a real implementation, you might query a database
|
| 311 |
+
return {
|
| 312 |
+
"id": user_id,
|
| 313 |
+
"name": f"User {user_id}",
|
| 314 |
+
"request_id": ctx.request_id
|
| 315 |
+
}
|
| 316 |
+
```
|
| 317 |
+
|
| 318 |
+
### Context in Prompts
|
| 319 |
+
|
| 320 |
+
Prompts can use context to generate more dynamic templates:
|
| 321 |
+
|
| 322 |
+
```python
|
| 323 |
+
@mcp.prompt()
|
| 324 |
+
async def data_analysis_request(dataset: str, ctx: Context) -> str:
|
| 325 |
+
"""Generate a request to analyze data with contextual information."""
|
| 326 |
+
await ctx.info(f"Generating data analysis prompt for {dataset}")
|
| 327 |
+
|
| 328 |
+
# Could use context to read configuration or personalize the prompt
|
| 329 |
+
return f"""Please analyze the following dataset: {dataset}
|
| 330 |
+
|
| 331 |
+
Request initiated at: {datetime.now().isoformat()}
|
| 332 |
+
Request ID: {ctx.request_id}
|
| 333 |
+
"""
|
| 334 |
+
```
|
| 335 |
+
|
| 336 |
+
<VersionBadge version="2.3.0" />
|
| 337 |
+
|
| 338 |
+
All FastMCP objects now support context injection using the same consistent pattern, making it easy to add session-aware capabilities to all aspects of your MCP server.
|
docs/servers/prompts.mdx
CHANGED
|
@@ -171,33 +171,24 @@ async def data_based_prompt(data_id: str) -> str:
|
|
| 171 |
|
| 172 |
Use `async def` when your prompt function performs I/O operations like network requests, database queries, file I/O, or external service calls.
|
| 173 |
|
| 174 |
-
###
|
| 175 |
|
| 176 |
-
|
| 177 |
|
| 178 |
-
```
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
@mcp.prompt()
|
| 182 |
async def generate_report_request(report_type: str, ctx: Context) -> str:
|
| 183 |
-
"""Generates a request for a report
|
| 184 |
-
|
| 185 |
-
await ctx.info(f"Generating prompt for report type: {report_type}")
|
| 186 |
-
|
| 187 |
-
# Could potentially use ctx.read_resource to fetch data
|
| 188 |
-
# Or ctx.sample to get additional input from the LLM
|
| 189 |
-
|
| 190 |
-
return f"Please create a {report_type} report based on the available data."
|
| 191 |
```
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
- **Logging:** `ctx.debug()`, `ctx.info()`, etc.
|
| 196 |
-
- **Resource Access:** `ctx.read_resource(uri)`
|
| 197 |
-
- **LLM Sampling:** `ctx.sample(...)`
|
| 198 |
-
- **Request Info:** `ctx.request_id`, `ctx.client_id`
|
| 199 |
-
|
| 200 |
-
Refer to the [Context documentation](/servers/context) for more details on these capabilities.
|
| 201 |
|
| 202 |
## Server Behavior
|
| 203 |
|
|
|
|
| 171 |
|
| 172 |
Use `async def` when your prompt function performs I/O operations like network requests, database queries, file I/O, or external service calls.
|
| 173 |
|
| 174 |
+
### Accessing MCP Context
|
| 175 |
|
| 176 |
+
<VersionBadge version="2.2.5" />
|
| 177 |
|
| 178 |
+
Prompts can access additional MCP information and features through the `Context` object. To access it, add a parameter to your prompt function with a type annotation of `Context`:
|
| 179 |
+
|
| 180 |
+
```python {6}
|
| 181 |
+
from fastmcp import FastMCP, Context
|
| 182 |
+
|
| 183 |
+
mcp = FastMCP(name="PromptServer")
|
| 184 |
|
| 185 |
@mcp.prompt()
|
| 186 |
async def generate_report_request(report_type: str, ctx: Context) -> str:
|
| 187 |
+
"""Generates a request for a report."""
|
| 188 |
+
return f"Please create a {report_type} report. Request ID: {ctx.request_id}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
```
|
| 190 |
|
| 191 |
+
For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
## Server Behavior
|
| 194 |
|
docs/servers/resources.mdx
CHANGED
|
@@ -95,6 +95,36 @@ def get_application_status() -> dict:
|
|
| 95 |
- **`mime_type`**: Specifies the content type (FastMCP often infers a default like `text/plain` or `application/json`, but explicit is better for non-text types).
|
| 96 |
- **`tags`**: A set of strings for categorization, potentially used by clients for filtering.
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
### Asynchronous Resources
|
| 100 |
|
|
@@ -205,6 +235,8 @@ Note that this parameter is only available when using `add_resource()` directly
|
|
| 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 |
|
|
|
|
|
|
|
| 208 |
Resource templates generate a new resource for each unique set of parameters, which means that resources can be dynamically created on-demand. For example, if the resource template `"user://profile/{name}"` is registered, MCP clients could request `"user://profile/ford"` or `"user://profile/marvin"` to retrieve either of those two user profiles as resources, without having to register each resource individually.
|
| 209 |
|
| 210 |
Here is a complete example that shows how to define two resource templates:
|
|
@@ -379,28 +411,6 @@ In this stacked decorator pattern:
|
|
| 379 |
|
| 380 |
Templates provide a powerful way to expose parameterized data access points following REST-like principles.
|
| 381 |
|
| 382 |
-
### Custom Template Keys
|
| 383 |
-
|
| 384 |
-
<VersionBadge version="2.2.0" />
|
| 385 |
-
|
| 386 |
-
Similar to resources, you can provide custom keys when directly adding templates:
|
| 387 |
-
|
| 388 |
-
```python
|
| 389 |
-
from fastmcp.resources import ResourceTemplate
|
| 390 |
-
|
| 391 |
-
# Create a template with a function
|
| 392 |
-
template = ResourceTemplate.from_function(
|
| 393 |
-
my_function,
|
| 394 |
-
uri_template="data://{id}/details",
|
| 395 |
-
name="Data Details"
|
| 396 |
-
)
|
| 397 |
-
|
| 398 |
-
# Register with a custom key
|
| 399 |
-
mcp._resource_manager.add_template(template, key="custom://{id}/view")
|
| 400 |
-
```
|
| 401 |
-
|
| 402 |
-
This allows accessing the same template implementation through different URI patterns.
|
| 403 |
-
|
| 404 |
## Server Behavior
|
| 405 |
|
| 406 |
### Duplicate Resources
|
|
|
|
| 95 |
- **`mime_type`**: Specifies the content type (FastMCP often infers a default like `text/plain` or `application/json`, but explicit is better for non-text types).
|
| 96 |
- **`tags`**: A set of strings for categorization, potentially used by clients for filtering.
|
| 97 |
|
| 98 |
+
### Accessing MCP Context
|
| 99 |
+
|
| 100 |
+
<VersionBadge version="2.2.5" />
|
| 101 |
+
|
| 102 |
+
Resources and resource templates can access additional MCP information and features through the `Context` object. To access it, add a parameter to your resource function with a type annotation of `Context`:
|
| 103 |
+
|
| 104 |
+
```python {6, 14}
|
| 105 |
+
from fastmcp import FastMCP, Context
|
| 106 |
+
|
| 107 |
+
mcp = FastMCP(name="DataServer")
|
| 108 |
+
|
| 109 |
+
@mcp.resource("resource://system-status")
|
| 110 |
+
async def get_system_status(ctx: Context) -> dict:
|
| 111 |
+
"""Provides system status information."""
|
| 112 |
+
return {
|
| 113 |
+
"status": "operational",
|
| 114 |
+
"request_id": ctx.request_id
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
@mcp.resource("resource://{name}/details")
|
| 118 |
+
async def get_details(name: str, ctx: Context) -> dict:
|
| 119 |
+
"""Get details for a specific name."""
|
| 120 |
+
return {
|
| 121 |
+
"name": name,
|
| 122 |
+
"accessed_at": ctx.request_id
|
| 123 |
+
}
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
|
| 127 |
+
|
| 128 |
|
| 129 |
### Asynchronous Resources
|
| 130 |
|
|
|
|
| 235 |
|
| 236 |
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.
|
| 237 |
|
| 238 |
+
Resource templates share most configuration options with regular resources (name, description, mime_type, tags), but add the ability to define URI parameters that map to function parameters.
|
| 239 |
+
|
| 240 |
Resource templates generate a new resource for each unique set of parameters, which means that resources can be dynamically created on-demand. For example, if the resource template `"user://profile/{name}"` is registered, MCP clients could request `"user://profile/ford"` or `"user://profile/marvin"` to retrieve either of those two user profiles as resources, without having to register each resource individually.
|
| 241 |
|
| 242 |
Here is a complete example that shows how to define two resource templates:
|
|
|
|
| 411 |
|
| 412 |
Templates provide a powerful way to expose parameterized data access points following REST-like principles.
|
| 413 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
## Server Behavior
|
| 415 |
|
| 416 |
### Duplicate Resources
|
docs/servers/tools.mdx
CHANGED
|
@@ -263,7 +263,8 @@ FastMCP automatically catches exceptions raised within your tool function:
|
|
| 263 |
|
| 264 |
Using informative exceptions helps the LLM understand failures and react appropriately.
|
| 265 |
|
| 266 |
-
##
|
|
|
|
| 267 |
|
| 268 |
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`.
|
| 269 |
|
|
@@ -304,39 +305,6 @@ The Context object provides access to:
|
|
| 304 |
|
| 305 |
For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
|
| 306 |
|
| 307 |
-
## Server Behavior
|
| 308 |
-
|
| 309 |
-
### Duplicate Tools
|
| 310 |
-
|
| 311 |
-
<VersionBadge version="2.1.0" />
|
| 312 |
-
|
| 313 |
-
You can control how the FastMCP server behaves if you try to register multiple tools with the same name. This is configured using the `on_duplicate_tools` argument when creating the `FastMCP` instance.
|
| 314 |
-
|
| 315 |
-
```python
|
| 316 |
-
from fastmcp import FastMCP
|
| 317 |
-
|
| 318 |
-
mcp = FastMCP(
|
| 319 |
-
name="StrictServer",
|
| 320 |
-
# Configure behavior for duplicate tool names
|
| 321 |
-
on_duplicate_tools="error"
|
| 322 |
-
)
|
| 323 |
-
|
| 324 |
-
@mcp.tool()
|
| 325 |
-
def my_tool(): return "Version 1"
|
| 326 |
-
|
| 327 |
-
# This will now raise a ValueError because 'my_tool' already exists
|
| 328 |
-
# and on_duplicate_tools is set to "error".
|
| 329 |
-
# @mcp.tool()
|
| 330 |
-
# def my_tool(): return "Version 2"
|
| 331 |
-
```
|
| 332 |
-
|
| 333 |
-
The duplicate behavior options are:
|
| 334 |
-
|
| 335 |
-
- `"warn"` (default): Logs a warning and the new tool replaces the old one.
|
| 336 |
-
- `"error"`: Raises a `ValueError`, preventing the duplicate registration.
|
| 337 |
-
- `"replace"`: Silently replaces the existing tool with the new one.
|
| 338 |
-
- `"ignore"`: Keeps the original tool and ignores the new registration attempt.
|
| 339 |
-
|
| 340 |
## Parameter Types
|
| 341 |
|
| 342 |
FastMCP supports a wide variety of parameter types to give you flexibility when designing your tools.
|
|
@@ -663,3 +631,36 @@ Common validation options include:
|
|
| 663 |
| `description` | Any | Human-readable description (appears in schema) |
|
| 664 |
|
| 665 |
When a client sends invalid data, FastMCP will return a validation error explaining why the parameter failed validation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
|
| 264 |
Using informative exceptions helps the LLM understand failures and react appropriately.
|
| 265 |
|
| 266 |
+
## MCP Context
|
| 267 |
+
|
| 268 |
|
| 269 |
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`.
|
| 270 |
|
|
|
|
| 305 |
|
| 306 |
For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
|
| 307 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
## Parameter Types
|
| 309 |
|
| 310 |
FastMCP supports a wide variety of parameter types to give you flexibility when designing your tools.
|
|
|
|
| 631 |
| `description` | Any | Human-readable description (appears in schema) |
|
| 632 |
|
| 633 |
When a client sends invalid data, FastMCP will return a validation error explaining why the parameter failed validation.
|
| 634 |
+
|
| 635 |
+
## Server Behavior
|
| 636 |
+
|
| 637 |
+
### Duplicate Tools
|
| 638 |
+
|
| 639 |
+
<VersionBadge version="2.1.0" />
|
| 640 |
+
|
| 641 |
+
You can control how the FastMCP server behaves if you try to register multiple tools with the same name. This is configured using the `on_duplicate_tools` argument when creating the `FastMCP` instance.
|
| 642 |
+
|
| 643 |
+
```python
|
| 644 |
+
from fastmcp import FastMCP
|
| 645 |
+
|
| 646 |
+
mcp = FastMCP(
|
| 647 |
+
name="StrictServer",
|
| 648 |
+
# Configure behavior for duplicate tool names
|
| 649 |
+
on_duplicate_tools="error"
|
| 650 |
+
)
|
| 651 |
+
|
| 652 |
+
@mcp.tool()
|
| 653 |
+
def my_tool(): return "Version 1"
|
| 654 |
+
|
| 655 |
+
# This will now raise a ValueError because 'my_tool' already exists
|
| 656 |
+
# and on_duplicate_tools is set to "error".
|
| 657 |
+
# @mcp.tool()
|
| 658 |
+
# def my_tool(): return "Version 2"
|
| 659 |
+
```
|
| 660 |
+
|
| 661 |
+
The duplicate behavior options are:
|
| 662 |
+
|
| 663 |
+
- `"warn"` (default): Logs a warning and the new tool replaces the old one.
|
| 664 |
+
- `"error"`: Raises a `ValueError`, preventing the duplicate registration.
|
| 665 |
+
- `"replace"`: Silently replaces the existing tool with the new one.
|
| 666 |
+
- `"ignore"`: Keeps the original tool and ignores the new registration attempt.
|
src/fastmcp/prompts/prompt.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
"""Base classes for FastMCP prompts."""
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import inspect
|
| 4 |
import json
|
| 5 |
from collections.abc import Awaitable, Callable, Sequence
|
| 6 |
-
from typing import Annotated, Any, Literal
|
| 7 |
|
| 8 |
import pydantic_core
|
| 9 |
from mcp.types import EmbeddedResource, ImageContent, TextContent
|
|
@@ -13,6 +15,12 @@ from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_ca
|
|
| 13 |
|
| 14 |
from fastmcp.utilities.types import _convert_set_defaults
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
|
| 17 |
|
| 18 |
|
|
@@ -72,6 +80,9 @@ class Prompt(BaseModel):
|
|
| 72 |
None, description="Arguments that can be passed to the prompt"
|
| 73 |
)
|
| 74 |
fn: Callable[..., PromptResult | Awaitable[PromptResult]]
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
@classmethod
|
| 77 |
def from_function(
|
|
@@ -80,7 +91,8 @@ class Prompt(BaseModel):
|
|
| 80 |
name: str | None = None,
|
| 81 |
description: str | None = None,
|
| 82 |
tags: set[str] | None = None,
|
| 83 |
-
|
|
|
|
| 84 |
"""Create a Prompt from a function.
|
| 85 |
|
| 86 |
The function can return:
|
|
@@ -89,11 +101,24 @@ class Prompt(BaseModel):
|
|
| 89 |
- A dict (converted to a message)
|
| 90 |
- A sequence of any of the above
|
| 91 |
"""
|
|
|
|
|
|
|
| 92 |
func_name = name or fn.__name__
|
| 93 |
|
| 94 |
if func_name == "<lambda>":
|
| 95 |
raise ValueError("You must provide a name for lambda functions")
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
| 98 |
parameters = TypeAdapter(fn).json_schema()
|
| 99 |
|
|
@@ -101,6 +126,10 @@ class Prompt(BaseModel):
|
|
| 101 |
arguments: list[PromptArgument] = []
|
| 102 |
if "properties" in parameters:
|
| 103 |
for param_name, param in parameters["properties"].items():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
required = param_name in parameters.get("required", [])
|
| 105 |
arguments.append(
|
| 106 |
PromptArgument(
|
|
@@ -119,9 +148,14 @@ class Prompt(BaseModel):
|
|
| 119 |
arguments=arguments,
|
| 120 |
fn=fn,
|
| 121 |
tags=tags or set(),
|
|
|
|
| 122 |
)
|
| 123 |
|
| 124 |
-
async def render(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
"""Render the prompt with arguments."""
|
| 126 |
# Validate required arguments
|
| 127 |
if self.arguments:
|
|
@@ -132,8 +166,13 @@ class Prompt(BaseModel):
|
|
| 132 |
raise ValueError(f"Missing required arguments: {missing}")
|
| 133 |
|
| 134 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
# Call function and check if result is a coroutine
|
| 136 |
-
result = self.fn(**
|
| 137 |
if inspect.iscoroutine(result):
|
| 138 |
result = await result
|
| 139 |
|
|
|
|
| 1 |
"""Base classes for FastMCP prompts."""
|
| 2 |
|
| 3 |
+
from __future__ import annotations as _annotations
|
| 4 |
+
|
| 5 |
import inspect
|
| 6 |
import json
|
| 7 |
from collections.abc import Awaitable, Callable, Sequence
|
| 8 |
+
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
| 9 |
|
| 10 |
import pydantic_core
|
| 11 |
from mcp.types import EmbeddedResource, ImageContent, TextContent
|
|
|
|
| 15 |
|
| 16 |
from fastmcp.utilities.types import _convert_set_defaults
|
| 17 |
|
| 18 |
+
if TYPE_CHECKING:
|
| 19 |
+
from mcp.server.session import ServerSessionT
|
| 20 |
+
from mcp.shared.context import LifespanContextT
|
| 21 |
+
|
| 22 |
+
from fastmcp.server import Context
|
| 23 |
+
|
| 24 |
CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
|
| 25 |
|
| 26 |
|
|
|
|
| 80 |
None, description="Arguments that can be passed to the prompt"
|
| 81 |
)
|
| 82 |
fn: Callable[..., PromptResult | Awaitable[PromptResult]]
|
| 83 |
+
context_kwarg: str | None = Field(
|
| 84 |
+
None, description="Name of the kwarg that should receive context"
|
| 85 |
+
)
|
| 86 |
|
| 87 |
@classmethod
|
| 88 |
def from_function(
|
|
|
|
| 91 |
name: str | None = None,
|
| 92 |
description: str | None = None,
|
| 93 |
tags: set[str] | None = None,
|
| 94 |
+
context_kwarg: str | None = None,
|
| 95 |
+
) -> Prompt:
|
| 96 |
"""Create a Prompt from a function.
|
| 97 |
|
| 98 |
The function can return:
|
|
|
|
| 101 |
- A dict (converted to a message)
|
| 102 |
- A sequence of any of the above
|
| 103 |
"""
|
| 104 |
+
from fastmcp import Context
|
| 105 |
+
|
| 106 |
func_name = name or fn.__name__
|
| 107 |
|
| 108 |
if func_name == "<lambda>":
|
| 109 |
raise ValueError("You must provide a name for lambda functions")
|
| 110 |
|
| 111 |
+
# Auto-detect context parameter if not provided
|
| 112 |
+
if context_kwarg is None:
|
| 113 |
+
if inspect.ismethod(fn) and hasattr(fn, "__func__"):
|
| 114 |
+
sig = inspect.signature(fn.__func__)
|
| 115 |
+
else:
|
| 116 |
+
sig = inspect.signature(fn)
|
| 117 |
+
for param_name, param in sig.parameters.items():
|
| 118 |
+
if param.annotation is Context:
|
| 119 |
+
context_kwarg = param_name
|
| 120 |
+
break
|
| 121 |
+
|
| 122 |
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
| 123 |
parameters = TypeAdapter(fn).json_schema()
|
| 124 |
|
|
|
|
| 126 |
arguments: list[PromptArgument] = []
|
| 127 |
if "properties" in parameters:
|
| 128 |
for param_name, param in parameters["properties"].items():
|
| 129 |
+
# Skip context parameter
|
| 130 |
+
if param_name == context_kwarg:
|
| 131 |
+
continue
|
| 132 |
+
|
| 133 |
required = param_name in parameters.get("required", [])
|
| 134 |
arguments.append(
|
| 135 |
PromptArgument(
|
|
|
|
| 148 |
arguments=arguments,
|
| 149 |
fn=fn,
|
| 150 |
tags=tags or set(),
|
| 151 |
+
context_kwarg=context_kwarg,
|
| 152 |
)
|
| 153 |
|
| 154 |
+
async def render(
|
| 155 |
+
self,
|
| 156 |
+
arguments: dict[str, Any] | None = None,
|
| 157 |
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
| 158 |
+
) -> list[Message]:
|
| 159 |
"""Render the prompt with arguments."""
|
| 160 |
# Validate required arguments
|
| 161 |
if self.arguments:
|
|
|
|
| 166 |
raise ValueError(f"Missing required arguments: {missing}")
|
| 167 |
|
| 168 |
try:
|
| 169 |
+
# Prepare arguments with context
|
| 170 |
+
kwargs = arguments.copy() if arguments else {}
|
| 171 |
+
if self.context_kwarg is not None and context is not None:
|
| 172 |
+
kwargs[self.context_kwarg] = context
|
| 173 |
+
|
| 174 |
# Call function and check if result is a coroutine
|
| 175 |
+
result = self.fn(**kwargs)
|
| 176 |
if inspect.iscoroutine(result):
|
| 177 |
result = await result
|
| 178 |
|
src/fastmcp/prompts/prompt_manager.py
CHANGED
|
@@ -1,13 +1,21 @@
|
|
| 1 |
"""Prompt management functionality."""
|
| 2 |
|
|
|
|
|
|
|
| 3 |
from collections.abc import Awaitable, Callable
|
| 4 |
-
from typing import Any
|
| 5 |
|
| 6 |
from fastmcp.exceptions import NotFoundError
|
| 7 |
from fastmcp.prompts.prompt import Message, Prompt, PromptResult
|
| 8 |
from fastmcp.settings import DuplicateBehavior
|
| 9 |
from fastmcp.utilities.logging import get_logger
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
logger = get_logger(__name__)
|
| 12 |
|
| 13 |
|
|
@@ -69,14 +77,17 @@ class PromptManager:
|
|
| 69 |
return prompt
|
| 70 |
|
| 71 |
async def render_prompt(
|
| 72 |
-
self,
|
|
|
|
|
|
|
|
|
|
| 73 |
) -> list[Message]:
|
| 74 |
"""Render a prompt by name with arguments."""
|
| 75 |
prompt = self.get_prompt(name)
|
| 76 |
if not prompt:
|
| 77 |
raise NotFoundError(f"Unknown prompt: {name}")
|
| 78 |
|
| 79 |
-
return await prompt.render(arguments)
|
| 80 |
|
| 81 |
def has_prompt(self, key: str) -> bool:
|
| 82 |
"""Check if a prompt exists."""
|
|
|
|
| 1 |
"""Prompt management functionality."""
|
| 2 |
|
| 3 |
+
from __future__ import annotations as _annotations
|
| 4 |
+
|
| 5 |
from collections.abc import Awaitable, Callable
|
| 6 |
+
from typing import TYPE_CHECKING, Any
|
| 7 |
|
| 8 |
from fastmcp.exceptions import NotFoundError
|
| 9 |
from fastmcp.prompts.prompt import Message, Prompt, PromptResult
|
| 10 |
from fastmcp.settings import DuplicateBehavior
|
| 11 |
from fastmcp.utilities.logging import get_logger
|
| 12 |
|
| 13 |
+
if TYPE_CHECKING:
|
| 14 |
+
from mcp.server.session import ServerSessionT
|
| 15 |
+
from mcp.shared.context import LifespanContextT
|
| 16 |
+
|
| 17 |
+
from fastmcp.server import Context
|
| 18 |
+
|
| 19 |
logger = get_logger(__name__)
|
| 20 |
|
| 21 |
|
|
|
|
| 77 |
return prompt
|
| 78 |
|
| 79 |
async def render_prompt(
|
| 80 |
+
self,
|
| 81 |
+
name: str,
|
| 82 |
+
arguments: dict[str, Any] | None = None,
|
| 83 |
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
| 84 |
) -> list[Message]:
|
| 85 |
"""Render a prompt by name with arguments."""
|
| 86 |
prompt = self.get_prompt(name)
|
| 87 |
if not prompt:
|
| 88 |
raise NotFoundError(f"Unknown prompt: {name}")
|
| 89 |
|
| 90 |
+
return await prompt.render(arguments, context=context)
|
| 91 |
|
| 92 |
def has_prompt(self, key: str) -> bool:
|
| 93 |
"""Check if a prompt exists."""
|
src/fastmcp/resources/resource.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
"""Base classes and interfaces for FastMCP resources."""
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import abc
|
| 4 |
-
from typing import Annotated, Any
|
| 5 |
|
| 6 |
from mcp.types import Resource as MCPResource
|
| 7 |
from pydantic import (
|
|
@@ -17,6 +19,12 @@ from pydantic import (
|
|
| 17 |
|
| 18 |
from fastmcp.utilities.types import _convert_set_defaults
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
class Resource(BaseModel, abc.ABC):
|
| 22 |
"""Base class for all resources."""
|
|
@@ -58,7 +66,9 @@ class Resource(BaseModel, abc.ABC):
|
|
| 58 |
raise ValueError("Either name or uri must be provided")
|
| 59 |
|
| 60 |
@abc.abstractmethod
|
| 61 |
-
async def read(
|
|
|
|
|
|
|
| 62 |
"""Read the resource content."""
|
| 63 |
pass
|
| 64 |
|
|
|
|
| 1 |
"""Base classes and interfaces for FastMCP resources."""
|
| 2 |
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
import abc
|
| 6 |
+
from typing import TYPE_CHECKING, Annotated, Any
|
| 7 |
|
| 8 |
from mcp.types import Resource as MCPResource
|
| 9 |
from pydantic import (
|
|
|
|
| 19 |
|
| 20 |
from fastmcp.utilities.types import _convert_set_defaults
|
| 21 |
|
| 22 |
+
if TYPE_CHECKING:
|
| 23 |
+
from mcp.server.session import ServerSessionT
|
| 24 |
+
from mcp.shared.context import LifespanContextT
|
| 25 |
+
|
| 26 |
+
from fastmcp.server import Context
|
| 27 |
+
|
| 28 |
|
| 29 |
class Resource(BaseModel, abc.ABC):
|
| 30 |
"""Base class for all resources."""
|
|
|
|
| 66 |
raise ValueError("Either name or uri must be provided")
|
| 67 |
|
| 68 |
@abc.abstractmethod
|
| 69 |
+
async def read(
|
| 70 |
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
| 71 |
+
) -> str | bytes:
|
| 72 |
"""Read the resource content."""
|
| 73 |
pass
|
| 74 |
|
src/fastmcp/resources/resource_manager.py
CHANGED
|
@@ -61,9 +61,16 @@ class ResourceManager:
|
|
| 61 |
The added resource or template. If a resource or template with the same URI already exists,
|
| 62 |
returns the existing resource or template.
|
| 63 |
"""
|
|
|
|
|
|
|
| 64 |
# Check if this should be a template
|
| 65 |
has_uri_params = "{" in uri and "}" in uri
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
if has_uri_params or has_func_params:
|
| 69 |
return self.add_template_from_fn(
|
|
@@ -102,12 +109,12 @@ class ResourceManager:
|
|
| 102 |
The added resource. If a resource with the same URI already exists,
|
| 103 |
returns the existing resource.
|
| 104 |
"""
|
| 105 |
-
resource = FunctionResource(
|
|
|
|
| 106 |
uri=AnyUrl(uri),
|
| 107 |
name=name,
|
| 108 |
description=description,
|
| 109 |
mime_type=mime_type or "text/plain",
|
| 110 |
-
fn=fn,
|
| 111 |
tags=tags or set(),
|
| 112 |
)
|
| 113 |
return self.add_resource(resource)
|
|
@@ -212,9 +219,13 @@ class ResourceManager:
|
|
| 212 |
return True
|
| 213 |
return False
|
| 214 |
|
| 215 |
-
async def get_resource(self, uri: AnyUrl | str) -> Resource:
|
| 216 |
"""Get resource by URI, checking concrete resources first, then templates.
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
Raises:
|
| 219 |
NotFoundError: If no resource or template matching the URI is found.
|
| 220 |
"""
|
|
@@ -230,7 +241,11 @@ class ResourceManager:
|
|
| 230 |
# Try to match against the storage key (which might be a custom key)
|
| 231 |
if params := match_uri_template(uri_str, storage_key):
|
| 232 |
try:
|
| 233 |
-
return await template.create_resource(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
except Exception as e:
|
| 235 |
raise ValueError(f"Error creating resource from template: {e}")
|
| 236 |
|
|
|
|
| 61 |
The added resource or template. If a resource or template with the same URI already exists,
|
| 62 |
returns the existing resource or template.
|
| 63 |
"""
|
| 64 |
+
from fastmcp.server.context import Context
|
| 65 |
+
|
| 66 |
# Check if this should be a template
|
| 67 |
has_uri_params = "{" in uri and "}" in uri
|
| 68 |
+
# check if the function has any parameters (other than injected context)
|
| 69 |
+
has_func_params = any(
|
| 70 |
+
p
|
| 71 |
+
for p in inspect.signature(fn).parameters.values()
|
| 72 |
+
if p.annotation is not Context
|
| 73 |
+
)
|
| 74 |
|
| 75 |
if has_uri_params or has_func_params:
|
| 76 |
return self.add_template_from_fn(
|
|
|
|
| 109 |
The added resource. If a resource with the same URI already exists,
|
| 110 |
returns the existing resource.
|
| 111 |
"""
|
| 112 |
+
resource = FunctionResource.from_function(
|
| 113 |
+
fn=fn,
|
| 114 |
uri=AnyUrl(uri),
|
| 115 |
name=name,
|
| 116 |
description=description,
|
| 117 |
mime_type=mime_type or "text/plain",
|
|
|
|
| 118 |
tags=tags or set(),
|
| 119 |
)
|
| 120 |
return self.add_resource(resource)
|
|
|
|
| 219 |
return True
|
| 220 |
return False
|
| 221 |
|
| 222 |
+
async def get_resource(self, uri: AnyUrl | str, context=None) -> Resource:
|
| 223 |
"""Get resource by URI, checking concrete resources first, then templates.
|
| 224 |
|
| 225 |
+
Args:
|
| 226 |
+
uri: The URI of the resource to get
|
| 227 |
+
context: Optional context object to pass to template resources
|
| 228 |
+
|
| 229 |
Raises:
|
| 230 |
NotFoundError: If no resource or template matching the URI is found.
|
| 231 |
"""
|
|
|
|
| 241 |
# Try to match against the storage key (which might be a custom key)
|
| 242 |
if params := match_uri_template(uri_str, storage_key):
|
| 243 |
try:
|
| 244 |
+
return await template.create_resource(
|
| 245 |
+
uri_str,
|
| 246 |
+
params=params,
|
| 247 |
+
context=context,
|
| 248 |
+
)
|
| 249 |
except Exception as e:
|
| 250 |
raise ValueError(f"Error creating resource from template: {e}")
|
| 251 |
|
src/fastmcp/resources/template.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
| 5 |
import inspect
|
| 6 |
import re
|
| 7 |
from collections.abc import Callable
|
| 8 |
-
from typing import Annotated, Any
|
| 9 |
from urllib.parse import unquote
|
| 10 |
|
| 11 |
from mcp.types import ResourceTemplate as MCPResourceTemplate
|
|
@@ -22,6 +22,12 @@ from pydantic import (
|
|
| 22 |
from fastmcp.resources.types import FunctionResource, Resource
|
| 23 |
from fastmcp.utilities.types import _convert_set_defaults
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
def build_regex(template: str) -> re.Pattern:
|
| 27 |
parts = re.split(r"(\{[^}]+\})", template)
|
|
@@ -70,6 +76,9 @@ class ResourceTemplate(BaseModel):
|
|
| 70 |
parameters: dict[str, Any] = Field(
|
| 71 |
description="JSON schema for function parameters"
|
| 72 |
)
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
@field_validator("mime_type", mode="before")
|
| 75 |
@classmethod
|
|
@@ -88,18 +97,34 @@ class ResourceTemplate(BaseModel):
|
|
| 88 |
description: str | None = None,
|
| 89 |
mime_type: str | None = None,
|
| 90 |
tags: set[str] | None = None,
|
|
|
|
| 91 |
) -> ResourceTemplate:
|
| 92 |
"""Create a template from a function."""
|
|
|
|
|
|
|
| 93 |
func_name = name or fn.__name__
|
| 94 |
if func_name == "<lambda>":
|
| 95 |
raise ValueError("You must provide a name for lambda functions")
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
# Validate that URI params match function params
|
| 98 |
uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
|
| 99 |
if not uri_params:
|
| 100 |
raise ValueError("URI template must contain at least one parameter")
|
| 101 |
|
| 102 |
func_params = set(inspect.signature(fn).parameters.keys())
|
|
|
|
|
|
|
| 103 |
|
| 104 |
# get the parameters that are required
|
| 105 |
required_params = {
|
|
@@ -107,6 +132,8 @@ class ResourceTemplate(BaseModel):
|
|
| 107 |
for p in func_params
|
| 108 |
if inspect.signature(fn).parameters[p].default is inspect.Parameter.empty
|
| 109 |
}
|
|
|
|
|
|
|
| 110 |
|
| 111 |
if not required_params.issubset(uri_params):
|
| 112 |
raise ValueError(
|
|
@@ -132,17 +159,28 @@ class ResourceTemplate(BaseModel):
|
|
| 132 |
fn=fn,
|
| 133 |
parameters=parameters,
|
| 134 |
tags=tags or set(),
|
|
|
|
| 135 |
)
|
| 136 |
|
| 137 |
def matches(self, uri: str) -> dict[str, Any] | None:
|
| 138 |
"""Check if URI matches template and extract parameters."""
|
| 139 |
return match_uri_template(uri, self.uri_template)
|
| 140 |
|
| 141 |
-
async def create_resource(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
"""Create a resource from the template with the given parameters."""
|
| 143 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
# Call function and check if result is a coroutine
|
| 145 |
-
result = self.fn(**
|
| 146 |
if inspect.iscoroutine(result):
|
| 147 |
result = await result
|
| 148 |
|
|
@@ -151,8 +189,9 @@ class ResourceTemplate(BaseModel):
|
|
| 151 |
name=self.name,
|
| 152 |
description=self.description,
|
| 153 |
mime_type=self.mime_type,
|
| 154 |
-
fn=lambda: result, # Capture result in closure
|
| 155 |
tags=self.tags,
|
|
|
|
| 156 |
)
|
| 157 |
except Exception as e:
|
| 158 |
raise ValueError(f"Error creating resource from template: {e}")
|
|
|
|
| 5 |
import inspect
|
| 6 |
import re
|
| 7 |
from collections.abc import Callable
|
| 8 |
+
from typing import TYPE_CHECKING, Annotated, Any
|
| 9 |
from urllib.parse import unquote
|
| 10 |
|
| 11 |
from mcp.types import ResourceTemplate as MCPResourceTemplate
|
|
|
|
| 22 |
from fastmcp.resources.types import FunctionResource, Resource
|
| 23 |
from fastmcp.utilities.types import _convert_set_defaults
|
| 24 |
|
| 25 |
+
if TYPE_CHECKING:
|
| 26 |
+
from mcp.server.session import ServerSessionT
|
| 27 |
+
from mcp.shared.context import LifespanContextT
|
| 28 |
+
|
| 29 |
+
from fastmcp.server import Context
|
| 30 |
+
|
| 31 |
|
| 32 |
def build_regex(template: str) -> re.Pattern:
|
| 33 |
parts = re.split(r"(\{[^}]+\})", template)
|
|
|
|
| 76 |
parameters: dict[str, Any] = Field(
|
| 77 |
description="JSON schema for function parameters"
|
| 78 |
)
|
| 79 |
+
context_kwarg: str | None = Field(
|
| 80 |
+
None, description="Name of the kwarg that should receive context"
|
| 81 |
+
)
|
| 82 |
|
| 83 |
@field_validator("mime_type", mode="before")
|
| 84 |
@classmethod
|
|
|
|
| 97 |
description: str | None = None,
|
| 98 |
mime_type: str | None = None,
|
| 99 |
tags: set[str] | None = None,
|
| 100 |
+
context_kwarg: str | None = None,
|
| 101 |
) -> ResourceTemplate:
|
| 102 |
"""Create a template from a function."""
|
| 103 |
+
from fastmcp import Context
|
| 104 |
+
|
| 105 |
func_name = name or fn.__name__
|
| 106 |
if func_name == "<lambda>":
|
| 107 |
raise ValueError("You must provide a name for lambda functions")
|
| 108 |
|
| 109 |
+
# Auto-detect context parameter if not provided
|
| 110 |
+
if context_kwarg is None:
|
| 111 |
+
if inspect.ismethod(fn) and hasattr(fn, "__func__"):
|
| 112 |
+
sig = inspect.signature(fn.__func__)
|
| 113 |
+
else:
|
| 114 |
+
sig = inspect.signature(fn)
|
| 115 |
+
for param_name, param in sig.parameters.items():
|
| 116 |
+
if param.annotation is Context:
|
| 117 |
+
context_kwarg = param_name
|
| 118 |
+
break
|
| 119 |
+
|
| 120 |
# Validate that URI params match function params
|
| 121 |
uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
|
| 122 |
if not uri_params:
|
| 123 |
raise ValueError("URI template must contain at least one parameter")
|
| 124 |
|
| 125 |
func_params = set(inspect.signature(fn).parameters.keys())
|
| 126 |
+
if context_kwarg:
|
| 127 |
+
func_params.discard(context_kwarg)
|
| 128 |
|
| 129 |
# get the parameters that are required
|
| 130 |
required_params = {
|
|
|
|
| 132 |
for p in func_params
|
| 133 |
if inspect.signature(fn).parameters[p].default is inspect.Parameter.empty
|
| 134 |
}
|
| 135 |
+
if context_kwarg and context_kwarg in required_params:
|
| 136 |
+
required_params.discard(context_kwarg)
|
| 137 |
|
| 138 |
if not required_params.issubset(uri_params):
|
| 139 |
raise ValueError(
|
|
|
|
| 159 |
fn=fn,
|
| 160 |
parameters=parameters,
|
| 161 |
tags=tags or set(),
|
| 162 |
+
context_kwarg=context_kwarg,
|
| 163 |
)
|
| 164 |
|
| 165 |
def matches(self, uri: str) -> dict[str, Any] | None:
|
| 166 |
"""Check if URI matches template and extract parameters."""
|
| 167 |
return match_uri_template(uri, self.uri_template)
|
| 168 |
|
| 169 |
+
async def create_resource(
|
| 170 |
+
self,
|
| 171 |
+
uri: str,
|
| 172 |
+
params: dict[str, Any],
|
| 173 |
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
| 174 |
+
) -> Resource:
|
| 175 |
"""Create a resource from the template with the given parameters."""
|
| 176 |
try:
|
| 177 |
+
# Add context to parameters if needed
|
| 178 |
+
kwargs = params.copy()
|
| 179 |
+
if self.context_kwarg is not None and context is not None:
|
| 180 |
+
kwargs[self.context_kwarg] = context
|
| 181 |
+
|
| 182 |
# Call function and check if result is a coroutine
|
| 183 |
+
result = self.fn(**kwargs)
|
| 184 |
if inspect.iscoroutine(result):
|
| 185 |
result = await result
|
| 186 |
|
|
|
|
| 189 |
name=self.name,
|
| 190 |
description=self.description,
|
| 191 |
mime_type=self.mime_type,
|
| 192 |
+
fn=lambda **kwargs: result, # Capture result in closure
|
| 193 |
tags=self.tags,
|
| 194 |
+
context_kwarg=self.context_kwarg,
|
| 195 |
)
|
| 196 |
except Exception as e:
|
| 197 |
raise ValueError(f"Error creating resource from template: {e}")
|
src/fastmcp/resources/types.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
| 1 |
"""Concrete resource implementations."""
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import inspect
|
| 4 |
import json
|
| 5 |
from collections.abc import Callable
|
| 6 |
from pathlib import Path
|
| 7 |
-
from typing import Any
|
| 8 |
|
| 9 |
import anyio
|
| 10 |
import anyio.to_thread
|
|
@@ -13,15 +15,24 @@ import pydantic.json
|
|
| 13 |
import pydantic_core
|
| 14 |
from pydantic import Field, ValidationInfo
|
| 15 |
|
|
|
|
| 16 |
from fastmcp.resources.resource import Resource
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
class TextResource(Resource):
|
| 20 |
"""A resource that reads from a string."""
|
| 21 |
|
| 22 |
text: str = Field(description="Text content of the resource")
|
| 23 |
|
| 24 |
-
async def read(
|
|
|
|
|
|
|
| 25 |
"""Read the text content."""
|
| 26 |
return self.text
|
| 27 |
|
|
@@ -31,7 +42,9 @@ class BinaryResource(Resource):
|
|
| 31 |
|
| 32 |
data: bytes = Field(description="Binary content of the resource")
|
| 33 |
|
| 34 |
-
async def read(
|
|
|
|
|
|
|
| 35 |
"""Read the binary content."""
|
| 36 |
return self.data
|
| 37 |
|
|
@@ -50,15 +63,40 @@ class FunctionResource(Resource):
|
|
| 50 |
"""
|
| 51 |
|
| 52 |
fn: Callable[[], Any]
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
"""Read the resource by calling the wrapped function."""
|
| 56 |
try:
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
if isinstance(result, Resource):
|
| 61 |
-
return await result.read()
|
| 62 |
if isinstance(result, bytes):
|
| 63 |
return result
|
| 64 |
if isinstance(result, str):
|
|
@@ -105,7 +143,9 @@ class FileResource(Resource):
|
|
| 105 |
mime_type = info.data.get("mime_type", "text/plain")
|
| 106 |
return not mime_type.startswith("text/")
|
| 107 |
|
| 108 |
-
async def read(
|
|
|
|
|
|
|
| 109 |
"""Read the file content."""
|
| 110 |
try:
|
| 111 |
if self.is_binary:
|
|
@@ -123,7 +163,9 @@ class HttpResource(Resource):
|
|
| 123 |
default="application/json", description="MIME type of the resource content"
|
| 124 |
)
|
| 125 |
|
| 126 |
-
async def read(
|
|
|
|
|
|
|
| 127 |
"""Read the HTTP content."""
|
| 128 |
async with httpx.AsyncClient() as client:
|
| 129 |
response = await client.get(self.url)
|
|
@@ -175,7 +217,9 @@ class DirectoryResource(Resource):
|
|
| 175 |
except Exception as e:
|
| 176 |
raise ValueError(f"Error listing directory {self.path}: {e}")
|
| 177 |
|
| 178 |
-
async def read(
|
|
|
|
|
|
|
| 179 |
"""Read the directory listing."""
|
| 180 |
try:
|
| 181 |
files = await anyio.to_thread.run_sync(self.list_files)
|
|
|
|
| 1 |
"""Concrete resource implementations."""
|
| 2 |
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
import inspect
|
| 6 |
import json
|
| 7 |
from collections.abc import Callable
|
| 8 |
from pathlib import Path
|
| 9 |
+
from typing import TYPE_CHECKING, Any
|
| 10 |
|
| 11 |
import anyio
|
| 12 |
import anyio.to_thread
|
|
|
|
| 15 |
import pydantic_core
|
| 16 |
from pydantic import Field, ValidationInfo
|
| 17 |
|
| 18 |
+
import fastmcp
|
| 19 |
from fastmcp.resources.resource import Resource
|
| 20 |
|
| 21 |
+
if TYPE_CHECKING:
|
| 22 |
+
from mcp.server.session import ServerSessionT
|
| 23 |
+
from mcp.shared.context import LifespanContextT
|
| 24 |
+
|
| 25 |
+
from fastmcp.server import Context
|
| 26 |
+
|
| 27 |
|
| 28 |
class TextResource(Resource):
|
| 29 |
"""A resource that reads from a string."""
|
| 30 |
|
| 31 |
text: str = Field(description="Text content of the resource")
|
| 32 |
|
| 33 |
+
async def read(
|
| 34 |
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
| 35 |
+
) -> str:
|
| 36 |
"""Read the text content."""
|
| 37 |
return self.text
|
| 38 |
|
|
|
|
| 42 |
|
| 43 |
data: bytes = Field(description="Binary content of the resource")
|
| 44 |
|
| 45 |
+
async def read(
|
| 46 |
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
| 47 |
+
) -> bytes:
|
| 48 |
"""Read the binary content."""
|
| 49 |
return self.data
|
| 50 |
|
|
|
|
| 63 |
"""
|
| 64 |
|
| 65 |
fn: Callable[[], Any]
|
| 66 |
+
context_kwarg: str | None = Field(
|
| 67 |
+
default=None, description="Name of the kwarg that should receive context"
|
| 68 |
+
)
|
| 69 |
|
| 70 |
+
@classmethod
|
| 71 |
+
def from_function(
|
| 72 |
+
cls, fn: Callable[[], Any], context_kwarg: str | None = None, **kwargs
|
| 73 |
+
) -> FunctionResource:
|
| 74 |
+
if context_kwarg is None:
|
| 75 |
+
parameters = inspect.signature(fn).parameters
|
| 76 |
+
context_param = next(
|
| 77 |
+
(p for p in parameters.values() if p.annotation is fastmcp.Context),
|
| 78 |
+
None,
|
| 79 |
+
)
|
| 80 |
+
if context_param is not None:
|
| 81 |
+
context_kwarg = context_param.name
|
| 82 |
+
return cls(fn=fn, context_kwarg=context_kwarg, **kwargs)
|
| 83 |
+
|
| 84 |
+
async def read(
|
| 85 |
+
self,
|
| 86 |
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
| 87 |
+
) -> str | bytes:
|
| 88 |
"""Read the resource by calling the wrapped function."""
|
| 89 |
try:
|
| 90 |
+
kwargs = {}
|
| 91 |
+
if self.context_kwarg is not None:
|
| 92 |
+
kwargs[self.context_kwarg] = context
|
| 93 |
+
|
| 94 |
+
result = self.fn(**kwargs)
|
| 95 |
+
if inspect.iscoroutinefunction(self.fn):
|
| 96 |
+
result = await result
|
| 97 |
+
|
| 98 |
if isinstance(result, Resource):
|
| 99 |
+
return await result.read(context=context)
|
| 100 |
if isinstance(result, bytes):
|
| 101 |
return result
|
| 102 |
if isinstance(result, str):
|
|
|
|
| 143 |
mime_type = info.data.get("mime_type", "text/plain")
|
| 144 |
return not mime_type.startswith("text/")
|
| 145 |
|
| 146 |
+
async def read(
|
| 147 |
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
| 148 |
+
) -> str | bytes:
|
| 149 |
"""Read the file content."""
|
| 150 |
try:
|
| 151 |
if self.is_binary:
|
|
|
|
| 163 |
default="application/json", description="MIME type of the resource content"
|
| 164 |
)
|
| 165 |
|
| 166 |
+
async def read(
|
| 167 |
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
| 168 |
+
) -> str | bytes:
|
| 169 |
"""Read the HTTP content."""
|
| 170 |
async with httpx.AsyncClient() as client:
|
| 171 |
response = await client.get(self.url)
|
|
|
|
| 217 |
except Exception as e:
|
| 218 |
raise ValueError(f"Error listing directory {self.path}: {e}")
|
| 219 |
|
| 220 |
+
async def read(
|
| 221 |
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
| 222 |
+
) -> str: # Always returns JSON string
|
| 223 |
"""Read the directory listing."""
|
| 224 |
try:
|
| 225 |
files = await anyio.to_thread.run_sync(self.list_files)
|
src/fastmcp/server/openapi.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
"""FastMCP server implementation for OpenAPI integration."""
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import enum
|
| 4 |
import json
|
| 5 |
import re
|
| 6 |
from dataclasses import dataclass
|
| 7 |
from re import Pattern
|
| 8 |
-
from typing import Any, Literal
|
| 9 |
|
| 10 |
import httpx
|
| 11 |
from mcp.types import TextContent
|
|
@@ -22,6 +24,12 @@ from fastmcp.utilities.openapi import (
|
|
| 22 |
format_description_with_responses,
|
| 23 |
)
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
logger = get_logger(__name__)
|
| 26 |
|
| 27 |
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
|
@@ -257,7 +265,9 @@ class OpenAPIResource(Resource):
|
|
| 257 |
self._client = client
|
| 258 |
self._route = route
|
| 259 |
|
| 260 |
-
async def read(
|
|
|
|
|
|
|
| 261 |
"""Fetch the resource data by making an HTTP request."""
|
| 262 |
try:
|
| 263 |
# Extract path parameters from the URI if present
|
|
@@ -347,11 +357,17 @@ class OpenAPIResourceTemplate(ResourceTemplate):
|
|
| 347 |
fn=lambda **kwargs: None,
|
| 348 |
parameters=parameters,
|
| 349 |
tags=tags,
|
|
|
|
| 350 |
)
|
| 351 |
self._client = client
|
| 352 |
self._route = route
|
| 353 |
|
| 354 |
-
async def create_resource(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
"""Create a resource with the given parameters."""
|
| 356 |
# Generate a URI for this resource instance
|
| 357 |
uri_parts = []
|
|
|
|
| 1 |
"""FastMCP server implementation for OpenAPI integration."""
|
| 2 |
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
import enum
|
| 6 |
import json
|
| 7 |
import re
|
| 8 |
from dataclasses import dataclass
|
| 9 |
from re import Pattern
|
| 10 |
+
from typing import TYPE_CHECKING, Any, Literal
|
| 11 |
|
| 12 |
import httpx
|
| 13 |
from mcp.types import TextContent
|
|
|
|
| 24 |
format_description_with_responses,
|
| 25 |
)
|
| 26 |
|
| 27 |
+
if TYPE_CHECKING:
|
| 28 |
+
from mcp.server.session import ServerSessionT
|
| 29 |
+
from mcp.shared.context import LifespanContextT
|
| 30 |
+
|
| 31 |
+
from fastmcp.server import Context
|
| 32 |
+
|
| 33 |
logger = get_logger(__name__)
|
| 34 |
|
| 35 |
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
|
|
|
| 265 |
self._client = client
|
| 266 |
self._route = route
|
| 267 |
|
| 268 |
+
async def read(
|
| 269 |
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
| 270 |
+
) -> str | bytes:
|
| 271 |
"""Fetch the resource data by making an HTTP request."""
|
| 272 |
try:
|
| 273 |
# Extract path parameters from the URI if present
|
|
|
|
| 357 |
fn=lambda **kwargs: None,
|
| 358 |
parameters=parameters,
|
| 359 |
tags=tags,
|
| 360 |
+
context_kwarg=None,
|
| 361 |
)
|
| 362 |
self._client = client
|
| 363 |
self._route = route
|
| 364 |
|
| 365 |
+
async def create_resource(
|
| 366 |
+
self,
|
| 367 |
+
uri: str,
|
| 368 |
+
params: dict[str, Any],
|
| 369 |
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
| 370 |
+
) -> Resource:
|
| 371 |
"""Create a resource with the given parameters."""
|
| 372 |
# Generate a URI for this resource instance
|
| 373 |
uri_parts = []
|
src/fastmcp/server/proxy.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
from
|
|
|
|
|
|
|
| 2 |
from urllib.parse import quote
|
| 3 |
|
| 4 |
import mcp.types
|
|
@@ -25,6 +27,12 @@ from fastmcp.tools.tool import Tool
|
|
| 25 |
from fastmcp.utilities.func_metadata import func_metadata
|
| 26 |
from fastmcp.utilities.logging import get_logger
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
logger = get_logger(__name__)
|
| 29 |
|
| 30 |
|
|
@@ -33,12 +41,12 @@ def _proxy_passthrough():
|
|
| 33 |
|
| 34 |
|
| 35 |
class ProxyTool(Tool):
|
| 36 |
-
def __init__(self, client:
|
| 37 |
super().__init__(**kwargs)
|
| 38 |
self._client = client
|
| 39 |
|
| 40 |
@classmethod
|
| 41 |
-
async def from_client(cls, client:
|
| 42 |
return cls(
|
| 43 |
client=client,
|
| 44 |
name=tool.name,
|
|
@@ -50,7 +58,9 @@ class ProxyTool(Tool):
|
|
| 50 |
)
|
| 51 |
|
| 52 |
async def run(
|
| 53 |
-
self,
|
|
|
|
|
|
|
| 54 |
) -> Any:
|
| 55 |
# the client context manager will swallow any exceptions inside a TaskGroup
|
| 56 |
# so we return the raw result and raise an exception ourselves
|
|
@@ -64,17 +74,15 @@ class ProxyTool(Tool):
|
|
| 64 |
|
| 65 |
|
| 66 |
class ProxyResource(Resource):
|
| 67 |
-
def __init__(
|
| 68 |
-
self, client: "Client", *, _value: str | bytes | None = None, **kwargs
|
| 69 |
-
):
|
| 70 |
super().__init__(**kwargs)
|
| 71 |
self._client = client
|
| 72 |
self._value = _value
|
| 73 |
|
| 74 |
@classmethod
|
| 75 |
async def from_client(
|
| 76 |
-
cls, client:
|
| 77 |
-
) ->
|
| 78 |
return cls(
|
| 79 |
client=client,
|
| 80 |
uri=resource.uri,
|
|
@@ -83,7 +91,9 @@ class ProxyResource(Resource):
|
|
| 83 |
mime_type=resource.mimeType,
|
| 84 |
)
|
| 85 |
|
| 86 |
-
async def read(
|
|
|
|
|
|
|
| 87 |
if self._value is not None:
|
| 88 |
return self._value
|
| 89 |
|
|
@@ -98,14 +108,14 @@ class ProxyResource(Resource):
|
|
| 98 |
|
| 99 |
|
| 100 |
class ProxyTemplate(ResourceTemplate):
|
| 101 |
-
def __init__(self, client:
|
| 102 |
super().__init__(**kwargs)
|
| 103 |
self._client = client
|
| 104 |
|
| 105 |
@classmethod
|
| 106 |
async def from_client(
|
| 107 |
-
cls, client:
|
| 108 |
-
) ->
|
| 109 |
return cls(
|
| 110 |
client=client,
|
| 111 |
uri_template=template.uriTemplate,
|
|
@@ -115,7 +125,12 @@ class ProxyTemplate(ResourceTemplate):
|
|
| 115 |
parameters={},
|
| 116 |
)
|
| 117 |
|
| 118 |
-
async def create_resource(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
# dont use the provided uri, because it may not be the same as the
|
| 120 |
# uri_template on the remote server.
|
| 121 |
# quote params to ensure they are valid for the uri_template
|
|
@@ -144,14 +159,12 @@ class ProxyTemplate(ResourceTemplate):
|
|
| 144 |
|
| 145 |
|
| 146 |
class ProxyPrompt(Prompt):
|
| 147 |
-
def __init__(self, client:
|
| 148 |
super().__init__(**kwargs)
|
| 149 |
self._client = client
|
| 150 |
|
| 151 |
@classmethod
|
| 152 |
-
async def from_client(
|
| 153 |
-
cls, client: "Client", prompt: mcp.types.Prompt
|
| 154 |
-
) -> "ProxyPrompt":
|
| 155 |
return cls(
|
| 156 |
client=client,
|
| 157 |
name=prompt.name,
|
|
@@ -160,14 +173,18 @@ class ProxyPrompt(Prompt):
|
|
| 160 |
fn=_proxy_passthrough,
|
| 161 |
)
|
| 162 |
|
| 163 |
-
async def render(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
async with self._client:
|
| 165 |
result = await self._client.get_prompt(self.name, arguments)
|
| 166 |
return [Message(role=m.role, content=m.content) for m in result]
|
| 167 |
|
| 168 |
|
| 169 |
class FastMCPProxy(FastMCP):
|
| 170 |
-
def __init__(self, client:
|
| 171 |
super().__init__(**kwargs)
|
| 172 |
self.client = client
|
| 173 |
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import TYPE_CHECKING, Any, cast
|
| 4 |
from urllib.parse import quote
|
| 5 |
|
| 6 |
import mcp.types
|
|
|
|
| 27 |
from fastmcp.utilities.func_metadata import func_metadata
|
| 28 |
from fastmcp.utilities.logging import get_logger
|
| 29 |
|
| 30 |
+
if TYPE_CHECKING:
|
| 31 |
+
from mcp.server.session import ServerSessionT
|
| 32 |
+
from mcp.shared.context import LifespanContextT
|
| 33 |
+
|
| 34 |
+
from fastmcp.server import Context
|
| 35 |
+
|
| 36 |
logger = get_logger(__name__)
|
| 37 |
|
| 38 |
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
class ProxyTool(Tool):
|
| 44 |
+
def __init__(self, client: Client, **kwargs):
|
| 45 |
super().__init__(**kwargs)
|
| 46 |
self._client = client
|
| 47 |
|
| 48 |
@classmethod
|
| 49 |
+
async def from_client(cls, client: Client, tool: mcp.types.Tool) -> ProxyTool:
|
| 50 |
return cls(
|
| 51 |
client=client,
|
| 52 |
name=tool.name,
|
|
|
|
| 58 |
)
|
| 59 |
|
| 60 |
async def run(
|
| 61 |
+
self,
|
| 62 |
+
arguments: dict[str, Any],
|
| 63 |
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
| 64 |
) -> Any:
|
| 65 |
# the client context manager will swallow any exceptions inside a TaskGroup
|
| 66 |
# so we return the raw result and raise an exception ourselves
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
class ProxyResource(Resource):
|
| 77 |
+
def __init__(self, client: Client, *, _value: str | bytes | None = None, **kwargs):
|
|
|
|
|
|
|
| 78 |
super().__init__(**kwargs)
|
| 79 |
self._client = client
|
| 80 |
self._value = _value
|
| 81 |
|
| 82 |
@classmethod
|
| 83 |
async def from_client(
|
| 84 |
+
cls, client: Client, resource: mcp.types.Resource
|
| 85 |
+
) -> ProxyResource:
|
| 86 |
return cls(
|
| 87 |
client=client,
|
| 88 |
uri=resource.uri,
|
|
|
|
| 91 |
mime_type=resource.mimeType,
|
| 92 |
)
|
| 93 |
|
| 94 |
+
async def read(
|
| 95 |
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
| 96 |
+
) -> str | bytes:
|
| 97 |
if self._value is not None:
|
| 98 |
return self._value
|
| 99 |
|
|
|
|
| 108 |
|
| 109 |
|
| 110 |
class ProxyTemplate(ResourceTemplate):
|
| 111 |
+
def __init__(self, client: Client, **kwargs):
|
| 112 |
super().__init__(**kwargs)
|
| 113 |
self._client = client
|
| 114 |
|
| 115 |
@classmethod
|
| 116 |
async def from_client(
|
| 117 |
+
cls, client: Client, template: mcp.types.ResourceTemplate
|
| 118 |
+
) -> ProxyTemplate:
|
| 119 |
return cls(
|
| 120 |
client=client,
|
| 121 |
uri_template=template.uriTemplate,
|
|
|
|
| 125 |
parameters={},
|
| 126 |
)
|
| 127 |
|
| 128 |
+
async def create_resource(
|
| 129 |
+
self,
|
| 130 |
+
uri: str,
|
| 131 |
+
params: dict[str, Any],
|
| 132 |
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
| 133 |
+
) -> ProxyResource:
|
| 134 |
# dont use the provided uri, because it may not be the same as the
|
| 135 |
# uri_template on the remote server.
|
| 136 |
# quote params to ensure they are valid for the uri_template
|
|
|
|
| 159 |
|
| 160 |
|
| 161 |
class ProxyPrompt(Prompt):
|
| 162 |
+
def __init__(self, client: Client, **kwargs):
|
| 163 |
super().__init__(**kwargs)
|
| 164 |
self._client = client
|
| 165 |
|
| 166 |
@classmethod
|
| 167 |
+
async def from_client(cls, client: Client, prompt: mcp.types.Prompt) -> ProxyPrompt:
|
|
|
|
|
|
|
| 168 |
return cls(
|
| 169 |
client=client,
|
| 170 |
name=prompt.name,
|
|
|
|
| 173 |
fn=_proxy_passthrough,
|
| 174 |
)
|
| 175 |
|
| 176 |
+
async def render(
|
| 177 |
+
self,
|
| 178 |
+
arguments: dict[str, Any],
|
| 179 |
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
| 180 |
+
) -> list[Message]:
|
| 181 |
async with self._client:
|
| 182 |
result = await self._client.get_prompt(self.name, arguments)
|
| 183 |
return [Message(role=m.role, content=m.content) for m in result]
|
| 184 |
|
| 185 |
|
| 186 |
class FastMCPProxy(FastMCP):
|
| 187 |
+
def __init__(self, client: Client, **kwargs):
|
| 188 |
super().__init__(**kwargs)
|
| 189 |
self.client = client
|
| 190 |
|
src/fastmcp/server/server.py
CHANGED
|
@@ -398,9 +398,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 398 |
server.
|
| 399 |
"""
|
| 400 |
if self._resource_manager.has_resource(uri):
|
| 401 |
-
|
|
|
|
| 402 |
try:
|
| 403 |
-
content = await resource.read()
|
| 404 |
return [
|
| 405 |
ReadResourceContents(content=content, mime_type=resource.mime_type)
|
| 406 |
]
|
|
@@ -424,7 +425,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 424 |
|
| 425 |
"""
|
| 426 |
if self._prompt_manager.has_prompt(name):
|
| 427 |
-
|
|
|
|
|
|
|
|
|
|
| 428 |
return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
|
| 429 |
else:
|
| 430 |
for server in self._mounted_servers.values():
|
|
@@ -562,6 +566,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 562 |
- bytes for binary content
|
| 563 |
- other types will be converted to JSON
|
| 564 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
If the URI contains parameters (e.g. "resource://{param}") or the function
|
| 566 |
has parameters, it will be registered as a template resource.
|
| 567 |
|
|
@@ -586,6 +594,11 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 586 |
def get_weather(city: str) -> str:
|
| 587 |
return f"Weather for {city}"
|
| 588 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
@server.resource("resource://{city}/weather")
|
| 590 |
async def get_weather(city: str) -> str:
|
| 591 |
data = await fetch_weather(city)
|
|
@@ -639,6 +652,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 639 |
) -> Callable[[AnyFunction], AnyFunction]:
|
| 640 |
"""Decorator to register a prompt.
|
| 641 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
Args:
|
| 643 |
name: Optional name for the prompt (defaults to function name)
|
| 644 |
description: Optional description of what the prompt does
|
|
@@ -655,6 +672,17 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 655 |
}
|
| 656 |
]
|
| 657 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
@server.prompt()
|
| 659 |
async def analyze_file(path: str) -> list[Message]:
|
| 660 |
content = await read_file(path)
|
|
|
|
| 398 |
server.
|
| 399 |
"""
|
| 400 |
if self._resource_manager.has_resource(uri):
|
| 401 |
+
context = self.get_context()
|
| 402 |
+
resource = await self._resource_manager.get_resource(uri, context=context)
|
| 403 |
try:
|
| 404 |
+
content = await resource.read(context=context)
|
| 405 |
return [
|
| 406 |
ReadResourceContents(content=content, mime_type=resource.mime_type)
|
| 407 |
]
|
|
|
|
| 425 |
|
| 426 |
"""
|
| 427 |
if self._prompt_manager.has_prompt(name):
|
| 428 |
+
context = self.get_context()
|
| 429 |
+
messages = await self._prompt_manager.render_prompt(
|
| 430 |
+
name, arguments=arguments or {}, context=context
|
| 431 |
+
)
|
| 432 |
return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
|
| 433 |
else:
|
| 434 |
for server in self._mounted_servers.values():
|
|
|
|
| 566 |
- bytes for binary content
|
| 567 |
- other types will be converted to JSON
|
| 568 |
|
| 569 |
+
Resources can optionally request a Context object by adding a parameter with the
|
| 570 |
+
Context type annotation. The context provides access to MCP capabilities like
|
| 571 |
+
logging, progress reporting, and session information.
|
| 572 |
+
|
| 573 |
If the URI contains parameters (e.g. "resource://{param}") or the function
|
| 574 |
has parameters, it will be registered as a template resource.
|
| 575 |
|
|
|
|
| 594 |
def get_weather(city: str) -> str:
|
| 595 |
return f"Weather for {city}"
|
| 596 |
|
| 597 |
+
@server.resource("resource://{city}/weather")
|
| 598 |
+
def get_weather_with_context(city: str, ctx: Context) -> str:
|
| 599 |
+
ctx.info(f"Fetching weather for {city}")
|
| 600 |
+
return f"Weather for {city}"
|
| 601 |
+
|
| 602 |
@server.resource("resource://{city}/weather")
|
| 603 |
async def get_weather(city: str) -> str:
|
| 604 |
data = await fetch_weather(city)
|
|
|
|
| 652 |
) -> Callable[[AnyFunction], AnyFunction]:
|
| 653 |
"""Decorator to register a prompt.
|
| 654 |
|
| 655 |
+
Prompts can optionally request a Context object by adding a parameter with the
|
| 656 |
+
Context type annotation. The context provides access to MCP capabilities like
|
| 657 |
+
logging, progress reporting, and session information.
|
| 658 |
+
|
| 659 |
Args:
|
| 660 |
name: Optional name for the prompt (defaults to function name)
|
| 661 |
description: Optional description of what the prompt does
|
|
|
|
| 672 |
}
|
| 673 |
]
|
| 674 |
|
| 675 |
+
@server.prompt()
|
| 676 |
+
def analyze_with_context(table_name: str, ctx: Context) -> list[Message]:
|
| 677 |
+
ctx.info(f"Analyzing table {table_name}")
|
| 678 |
+
schema = read_table_schema(table_name)
|
| 679 |
+
return [
|
| 680 |
+
{
|
| 681 |
+
"role": "user",
|
| 682 |
+
"content": f"Analyze this schema:\n{schema}"
|
| 683 |
+
}
|
| 684 |
+
]
|
| 685 |
+
|
| 686 |
@server.prompt()
|
| 687 |
async def analyze_file(path: str) -> list[Message]:
|
| 688 |
content = await read_file(path)
|
src/fastmcp/tools/tool.py
CHANGED
|
@@ -101,13 +101,16 @@ class Tool(BaseModel):
|
|
| 101 |
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 102 |
"""Run the tool with arguments."""
|
| 103 |
try:
|
| 104 |
-
|
| 105 |
-
self.fn,
|
| 106 |
-
self.is_async,
|
| 107 |
-
arguments,
|
| 108 |
{self.context_kwarg: context}
|
| 109 |
if self.context_kwarg is not None
|
| 110 |
-
else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
)
|
| 112 |
return _convert_to_content(result)
|
| 113 |
except Exception as e:
|
|
|
|
| 101 |
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 102 |
"""Run the tool with arguments."""
|
| 103 |
try:
|
| 104 |
+
pass_args = (
|
|
|
|
|
|
|
|
|
|
| 105 |
{self.context_kwarg: context}
|
| 106 |
if self.context_kwarg is not None
|
| 107 |
+
else None
|
| 108 |
+
)
|
| 109 |
+
result = await self.fn_metadata.call_fn_with_arg_validation(
|
| 110 |
+
fn=self.fn,
|
| 111 |
+
fn_is_async=self.is_async,
|
| 112 |
+
arguments_to_validate=arguments,
|
| 113 |
+
arguments_to_pass_directly=pass_args,
|
| 114 |
)
|
| 115 |
return _convert_to_content(result)
|
| 116 |
except Exception as e:
|
tests/server/test_server_interactions.py
CHANGED
|
@@ -682,7 +682,147 @@ class TestToolParameters:
|
|
| 682 |
assert result[0].text == "0:16:40"
|
| 683 |
|
| 684 |
|
| 685 |
-
class
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 686 |
async def test_text_resource(self):
|
| 687 |
mcp = FastMCP()
|
| 688 |
|
|
@@ -756,6 +896,21 @@ class TestResources:
|
|
| 756 |
assert result[0].blob == base64.b64encode(b"Binary file data").decode()
|
| 757 |
|
| 758 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 759 |
class TestResourceTemplates:
|
| 760 |
async def test_resource_with_params_not_in_uri(self):
|
| 761 |
"""Test that a resource with function parameters raises an error if the URI
|
|
@@ -1026,144 +1181,19 @@ class TestResourceTemplates:
|
|
| 1026 |
assert result[0].text == "Template resource 1: a/b"
|
| 1027 |
|
| 1028 |
|
| 1029 |
-
class
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
async def test_context_detection(self):
|
| 1033 |
-
"""Test that context parameters are properly detected."""
|
| 1034 |
mcp = FastMCP()
|
| 1035 |
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
async with Client(mcp) as client:
|
| 1041 |
-
tools = await client.list_tools()
|
| 1042 |
-
assert len(tools) == 1
|
| 1043 |
-
assert tools[0].name == "tool_with_context"
|
| 1044 |
-
|
| 1045 |
-
async def test_context_injection(self):
|
| 1046 |
-
"""Test that context is properly injected into tool calls."""
|
| 1047 |
-
mcp = FastMCP()
|
| 1048 |
-
|
| 1049 |
-
def tool_with_context(x: int, ctx: Context) -> str:
|
| 1050 |
-
assert ctx.request_id is not None
|
| 1051 |
-
return f"Request {ctx.request_id}: {x}"
|
| 1052 |
-
|
| 1053 |
-
mcp.add_tool(tool_with_context)
|
| 1054 |
-
async with Client(mcp) as client:
|
| 1055 |
-
result = await client.call_tool("tool_with_context", {"x": 42})
|
| 1056 |
-
assert len(result) == 1
|
| 1057 |
-
content = result[0]
|
| 1058 |
-
assert isinstance(content, TextContent)
|
| 1059 |
-
assert "Request" in content.text
|
| 1060 |
-
assert "42" in content.text
|
| 1061 |
-
|
| 1062 |
-
async def test_async_context(self):
|
| 1063 |
-
"""Test that context works in async functions."""
|
| 1064 |
-
mcp = FastMCP()
|
| 1065 |
-
|
| 1066 |
-
async def async_tool(x: int, ctx: Context) -> str:
|
| 1067 |
-
assert ctx.request_id is not None
|
| 1068 |
-
return f"Async request {ctx.request_id}: {x}"
|
| 1069 |
-
|
| 1070 |
-
mcp.add_tool(async_tool)
|
| 1071 |
-
async with Client(mcp) as client:
|
| 1072 |
-
result = await client.call_tool("async_tool", {"x": 42})
|
| 1073 |
-
assert len(result) == 1
|
| 1074 |
-
content = result[0]
|
| 1075 |
-
assert isinstance(content, TextContent)
|
| 1076 |
-
assert "Async request" in content.text
|
| 1077 |
-
assert "42" in content.text
|
| 1078 |
-
|
| 1079 |
-
async def test_context_logging(self):
|
| 1080 |
-
from unittest.mock import patch
|
| 1081 |
-
|
| 1082 |
-
import mcp.server.session
|
| 1083 |
-
|
| 1084 |
-
"""Test that context logging methods work."""
|
| 1085 |
-
mcp = FastMCP()
|
| 1086 |
-
|
| 1087 |
-
async def logging_tool(msg: str, ctx: Context) -> str:
|
| 1088 |
-
await ctx.debug("Debug message")
|
| 1089 |
-
await ctx.info("Info message")
|
| 1090 |
-
await ctx.warning("Warning message")
|
| 1091 |
-
await ctx.error("Error message")
|
| 1092 |
-
return f"Logged messages for {msg}"
|
| 1093 |
-
|
| 1094 |
-
mcp.add_tool(logging_tool)
|
| 1095 |
-
|
| 1096 |
-
with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
|
| 1097 |
-
async with Client(mcp) as client:
|
| 1098 |
-
result = await client.call_tool("logging_tool", {"msg": "test"})
|
| 1099 |
-
assert len(result) == 1
|
| 1100 |
-
content = result[0]
|
| 1101 |
-
assert isinstance(content, TextContent)
|
| 1102 |
-
assert "Logged messages for test" in content.text
|
| 1103 |
-
|
| 1104 |
-
assert mock_log.call_count == 4
|
| 1105 |
-
mock_log.assert_any_call(
|
| 1106 |
-
level="debug", data="Debug message", logger=None
|
| 1107 |
-
)
|
| 1108 |
-
mock_log.assert_any_call(level="info", data="Info message", logger=None)
|
| 1109 |
-
mock_log.assert_any_call(
|
| 1110 |
-
level="warning", data="Warning message", logger=None
|
| 1111 |
-
)
|
| 1112 |
-
mock_log.assert_any_call(
|
| 1113 |
-
level="error", data="Error message", logger=None
|
| 1114 |
-
)
|
| 1115 |
-
|
| 1116 |
-
async def test_optional_context(self):
|
| 1117 |
-
"""Test that context is optional."""
|
| 1118 |
-
mcp = FastMCP()
|
| 1119 |
-
|
| 1120 |
-
def no_context(x: int) -> int:
|
| 1121 |
-
return x * 2
|
| 1122 |
-
|
| 1123 |
-
mcp.add_tool(no_context)
|
| 1124 |
-
async with Client(mcp) as client:
|
| 1125 |
-
result = await client.call_tool("no_context", {"x": 21})
|
| 1126 |
-
assert len(result) == 1
|
| 1127 |
-
content = result[0]
|
| 1128 |
-
assert isinstance(content, TextContent)
|
| 1129 |
-
assert content.text == "42"
|
| 1130 |
-
|
| 1131 |
-
async def test_context_resource_access(self):
|
| 1132 |
-
"""Test that context can access resources."""
|
| 1133 |
-
mcp = FastMCP()
|
| 1134 |
-
|
| 1135 |
-
@mcp.resource("test://data")
|
| 1136 |
-
def test_resource() -> str:
|
| 1137 |
-
return "resource data"
|
| 1138 |
-
|
| 1139 |
-
@mcp.tool()
|
| 1140 |
-
async def tool_with_resource(ctx: Context) -> str:
|
| 1141 |
-
r_iter = await ctx.read_resource("test://data")
|
| 1142 |
-
r_list = list(r_iter)
|
| 1143 |
-
assert len(r_list) == 1
|
| 1144 |
-
r = r_list[0]
|
| 1145 |
-
return f"Read resource: {r.content} with mime type {r.mime_type}"
|
| 1146 |
-
|
| 1147 |
-
async with Client(mcp) as client:
|
| 1148 |
-
result = await client.call_tool("tool_with_resource", {})
|
| 1149 |
-
assert len(result) == 1
|
| 1150 |
-
content = result[0]
|
| 1151 |
-
assert isinstance(content, TextContent)
|
| 1152 |
-
assert "Read resource: resource data" in content.text
|
| 1153 |
-
|
| 1154 |
-
async def test_tool_decorator_with_tags(self):
|
| 1155 |
-
"""Test that the tool decorator properly sets tags."""
|
| 1156 |
-
mcp = FastMCP()
|
| 1157 |
-
|
| 1158 |
-
@mcp.tool(tags={"example", "test-tag"})
|
| 1159 |
-
def sample_tool(x: int) -> int:
|
| 1160 |
-
return x * 2
|
| 1161 |
|
| 1162 |
-
# Verify the tool exists
|
| 1163 |
async with Client(mcp) as client:
|
| 1164 |
-
|
| 1165 |
-
assert
|
| 1166 |
-
|
| 1167 |
|
| 1168 |
|
| 1169 |
class TestPrompts:
|
|
@@ -1350,3 +1380,19 @@ class TestPrompts:
|
|
| 1350 |
assert len(prompts_dict) == 1
|
| 1351 |
prompt = prompts_dict["sample_prompt"]
|
| 1352 |
assert prompt.tags == {"example", "test-tag"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
assert result[0].text == "0:16:40"
|
| 683 |
|
| 684 |
|
| 685 |
+
class TestToolContextInjection:
|
| 686 |
+
"""Test context injection in tools."""
|
| 687 |
+
|
| 688 |
+
async def test_context_detection(self):
|
| 689 |
+
"""Test that context parameters are properly detected."""
|
| 690 |
+
mcp = FastMCP()
|
| 691 |
+
|
| 692 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 693 |
+
return f"Request {ctx.request_id}: {x}"
|
| 694 |
+
|
| 695 |
+
mcp.add_tool(tool_with_context)
|
| 696 |
+
async with Client(mcp) as client:
|
| 697 |
+
tools = await client.list_tools()
|
| 698 |
+
assert len(tools) == 1
|
| 699 |
+
assert tools[0].name == "tool_with_context"
|
| 700 |
+
|
| 701 |
+
async def test_context_injection(self):
|
| 702 |
+
"""Test that context is properly injected into tool calls."""
|
| 703 |
+
mcp = FastMCP()
|
| 704 |
+
|
| 705 |
+
@mcp.tool()
|
| 706 |
+
def tool_with_context(x: int, ctx: Context) -> str:
|
| 707 |
+
assert isinstance(ctx, Context)
|
| 708 |
+
assert ctx.request_id is not None
|
| 709 |
+
return ctx.request_id
|
| 710 |
+
|
| 711 |
+
async with Client(mcp) as client:
|
| 712 |
+
result = await client.call_tool("tool_with_context", {"x": 42})
|
| 713 |
+
assert len(result) == 1
|
| 714 |
+
content = result[0]
|
| 715 |
+
assert isinstance(content, TextContent)
|
| 716 |
+
assert content.text == "1"
|
| 717 |
+
|
| 718 |
+
async def test_async_context(self):
|
| 719 |
+
"""Test that context works in async functions."""
|
| 720 |
+
mcp = FastMCP()
|
| 721 |
+
|
| 722 |
+
async def async_tool(x: int, ctx: Context) -> str:
|
| 723 |
+
assert ctx.request_id is not None
|
| 724 |
+
return f"Async request {ctx.request_id}: {x}"
|
| 725 |
+
|
| 726 |
+
mcp.add_tool(async_tool)
|
| 727 |
+
async with Client(mcp) as client:
|
| 728 |
+
result = await client.call_tool("async_tool", {"x": 42})
|
| 729 |
+
assert len(result) == 1
|
| 730 |
+
content = result[0]
|
| 731 |
+
assert isinstance(content, TextContent)
|
| 732 |
+
assert "Async request" in content.text
|
| 733 |
+
assert "42" in content.text
|
| 734 |
+
|
| 735 |
+
async def test_context_logging(self):
|
| 736 |
+
from unittest.mock import patch
|
| 737 |
+
|
| 738 |
+
import mcp.server.session
|
| 739 |
+
|
| 740 |
+
"""Test that context logging methods work."""
|
| 741 |
+
mcp = FastMCP()
|
| 742 |
+
|
| 743 |
+
async def logging_tool(msg: str, ctx: Context) -> str:
|
| 744 |
+
await ctx.debug("Debug message")
|
| 745 |
+
await ctx.info("Info message")
|
| 746 |
+
await ctx.warning("Warning message")
|
| 747 |
+
await ctx.error("Error message")
|
| 748 |
+
return f"Logged messages for {msg}"
|
| 749 |
+
|
| 750 |
+
mcp.add_tool(logging_tool)
|
| 751 |
+
|
| 752 |
+
with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
|
| 753 |
+
async with Client(mcp) as client:
|
| 754 |
+
result = await client.call_tool("logging_tool", {"msg": "test"})
|
| 755 |
+
assert len(result) == 1
|
| 756 |
+
content = result[0]
|
| 757 |
+
assert isinstance(content, TextContent)
|
| 758 |
+
assert "Logged messages for test" in content.text
|
| 759 |
+
|
| 760 |
+
assert mock_log.call_count == 4
|
| 761 |
+
mock_log.assert_any_call(
|
| 762 |
+
level="debug", data="Debug message", logger=None
|
| 763 |
+
)
|
| 764 |
+
mock_log.assert_any_call(level="info", data="Info message", logger=None)
|
| 765 |
+
mock_log.assert_any_call(
|
| 766 |
+
level="warning", data="Warning message", logger=None
|
| 767 |
+
)
|
| 768 |
+
mock_log.assert_any_call(
|
| 769 |
+
level="error", data="Error message", logger=None
|
| 770 |
+
)
|
| 771 |
+
|
| 772 |
+
async def test_optional_context(self):
|
| 773 |
+
"""Test that context is optional."""
|
| 774 |
+
mcp = FastMCP()
|
| 775 |
+
|
| 776 |
+
def no_context(x: int) -> int:
|
| 777 |
+
return x * 2
|
| 778 |
+
|
| 779 |
+
mcp.add_tool(no_context)
|
| 780 |
+
async with Client(mcp) as client:
|
| 781 |
+
result = await client.call_tool("no_context", {"x": 21})
|
| 782 |
+
assert len(result) == 1
|
| 783 |
+
content = result[0]
|
| 784 |
+
assert isinstance(content, TextContent)
|
| 785 |
+
assert content.text == "42"
|
| 786 |
+
|
| 787 |
+
async def test_context_resource_access(self):
|
| 788 |
+
"""Test that context can access resources."""
|
| 789 |
+
mcp = FastMCP()
|
| 790 |
+
|
| 791 |
+
@mcp.resource("test://data")
|
| 792 |
+
def test_resource() -> str:
|
| 793 |
+
return "resource data"
|
| 794 |
+
|
| 795 |
+
@mcp.tool()
|
| 796 |
+
async def tool_with_resource(ctx: Context) -> str:
|
| 797 |
+
r_iter = await ctx.read_resource("test://data")
|
| 798 |
+
r_list = list(r_iter)
|
| 799 |
+
assert len(r_list) == 1
|
| 800 |
+
r = r_list[0]
|
| 801 |
+
return f"Read resource: {r.content} with mime type {r.mime_type}"
|
| 802 |
+
|
| 803 |
+
async with Client(mcp) as client:
|
| 804 |
+
result = await client.call_tool("tool_with_resource", {})
|
| 805 |
+
assert len(result) == 1
|
| 806 |
+
content = result[0]
|
| 807 |
+
assert isinstance(content, TextContent)
|
| 808 |
+
assert "Read resource: resource data" in content.text
|
| 809 |
+
|
| 810 |
+
async def test_tool_decorator_with_tags(self):
|
| 811 |
+
"""Test that the tool decorator properly sets tags."""
|
| 812 |
+
mcp = FastMCP()
|
| 813 |
+
|
| 814 |
+
@mcp.tool(tags={"example", "test-tag"})
|
| 815 |
+
def sample_tool(x: int) -> int:
|
| 816 |
+
return x * 2
|
| 817 |
+
|
| 818 |
+
# Verify the tool exists
|
| 819 |
+
async with Client(mcp) as client:
|
| 820 |
+
tools = await client.list_tools()
|
| 821 |
+
assert len(tools) == 1
|
| 822 |
+
# Note: MCPTool from the client API doesn't expose tags
|
| 823 |
+
|
| 824 |
+
|
| 825 |
+
class TestResource:
|
| 826 |
async def test_text_resource(self):
|
| 827 |
mcp = FastMCP()
|
| 828 |
|
|
|
|
| 896 |
assert result[0].blob == base64.b64encode(b"Binary file data").decode()
|
| 897 |
|
| 898 |
|
| 899 |
+
class TestResourceContext:
|
| 900 |
+
async def test_resource_with_context_annotation_gets_context(self):
|
| 901 |
+
mcp = FastMCP()
|
| 902 |
+
|
| 903 |
+
@mcp.resource("resource://test")
|
| 904 |
+
def resource_with_context(ctx: Context) -> str:
|
| 905 |
+
assert isinstance(ctx, Context)
|
| 906 |
+
return ctx.request_id
|
| 907 |
+
|
| 908 |
+
async with Client(mcp) as client:
|
| 909 |
+
result = await client.read_resource(AnyUrl("resource://test"))
|
| 910 |
+
assert isinstance(result[0], TextResourceContents)
|
| 911 |
+
assert result[0].text == "1"
|
| 912 |
+
|
| 913 |
+
|
| 914 |
class TestResourceTemplates:
|
| 915 |
async def test_resource_with_params_not_in_uri(self):
|
| 916 |
"""Test that a resource with function parameters raises an error if the URI
|
|
|
|
| 1181 |
assert result[0].text == "Template resource 1: a/b"
|
| 1182 |
|
| 1183 |
|
| 1184 |
+
class TestResourceTemplateContext:
|
| 1185 |
+
async def test_resource_template_context(self):
|
|
|
|
|
|
|
|
|
|
| 1186 |
mcp = FastMCP()
|
| 1187 |
|
| 1188 |
+
@mcp.resource("resource://{param}")
|
| 1189 |
+
def resource_template(param: str, ctx: Context) -> str:
|
| 1190 |
+
assert isinstance(ctx, Context)
|
| 1191 |
+
return f"Resource template: {param} {ctx.request_id}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1192 |
|
|
|
|
| 1193 |
async with Client(mcp) as client:
|
| 1194 |
+
result = await client.read_resource(AnyUrl("resource://test"))
|
| 1195 |
+
assert isinstance(result[0], TextResourceContents)
|
| 1196 |
+
assert result[0].text == "Resource template: test 1"
|
| 1197 |
|
| 1198 |
|
| 1199 |
class TestPrompts:
|
|
|
|
| 1380 |
assert len(prompts_dict) == 1
|
| 1381 |
prompt = prompts_dict["sample_prompt"]
|
| 1382 |
assert prompt.tags == {"example", "test-tag"}
|
| 1383 |
+
|
| 1384 |
+
|
| 1385 |
+
class TestPromptContext:
|
| 1386 |
+
async def test_prompt_context(self):
|
| 1387 |
+
mcp = FastMCP()
|
| 1388 |
+
|
| 1389 |
+
@mcp.prompt()
|
| 1390 |
+
def prompt_fn(name: str, ctx: Context) -> str:
|
| 1391 |
+
assert isinstance(ctx, Context)
|
| 1392 |
+
return f"Hello, {name}! {ctx.request_id}"
|
| 1393 |
+
|
| 1394 |
+
async with Client(mcp) as client:
|
| 1395 |
+
result = await client.get_prompt("prompt_fn", {"name": "World"})
|
| 1396 |
+
assert len(result) == 1
|
| 1397 |
+
message = result[0]
|
| 1398 |
+
assert message.role == "user"
|