Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
506c09a
1
Parent(s): a32223b
Update tests
Browse files- docs/servers/middleware.mdx +510 -0
- src/fastmcp/prompts/prompt_manager.py +13 -9
- src/fastmcp/resources/resource_manager.py +15 -16
- src/fastmcp/resources/template.py +3 -0
- src/fastmcp/tools/tool_manager.py +13 -9
- tests/prompts/test_prompt_manager.py +23 -24
- tests/resources/test_resource_manager.py +62 -50
- tests/resources/test_resource_template.py +1 -0
- tests/server/openapi/test_openapi.py +188 -162
- tests/server/test_mount.py +3 -2
- tests/server/test_resource_prefix_formats.py +2 -2
- tests/server/test_tool_annotations.py +6 -3
- tests/server/test_tool_exclude_args.py +5 -3
docs/servers/middleware.mdx
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: MCP Middleware
|
| 3 |
+
sidebarTitle: Middleware
|
| 4 |
+
description: Add cross-cutting functionality to your MCP server with middleware that can inspect, modify, and respond to all MCP requests and responses.
|
| 5 |
+
icon: layers
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
import { VersionBadge } from "/snippets/version-badge.mdx"
|
| 9 |
+
|
| 10 |
+
<VersionBadge version="2.8.0" />
|
| 11 |
+
|
| 12 |
+
MCP middleware is a powerful concept that allows you to add cross-cutting functionality to your FastMCP server. Unlike traditional web middleware, MCP middleware is designed specifically for the Model Context Protocol, providing hooks for different types of MCP operations like tool calls, resource reads, and prompt requests.
|
| 13 |
+
|
| 14 |
+
<Warning>
|
| 15 |
+
MCP middleware is a FastMCP-specific concept and is not part of the official MCP protocol specification. This middleware system is designed to work with FastMCP servers and may not be compatible with other MCP implementations.
|
| 16 |
+
</Warning>
|
| 17 |
+
|
| 18 |
+
## What is MCP Middleware?
|
| 19 |
+
|
| 20 |
+
MCP middleware lets you intercept and modify MCP requests and responses as they flow through your server. Unlike traditional HTTP middleware that operates on request/response pairs, MCP middleware is aware of the specific MCP protocol operations and can provide targeted hooks for different types of interactions.
|
| 21 |
+
|
| 22 |
+
Common use cases for MCP middleware include:
|
| 23 |
+
- **Authentication and Authorization**: Verify client permissions before executing operations
|
| 24 |
+
- **Logging and Monitoring**: Track usage patterns and performance metrics
|
| 25 |
+
- **Rate Limiting**: Control request frequency per client or operation type
|
| 26 |
+
- **Request/Response Transformation**: Modify data before it reaches tools or after it leaves
|
| 27 |
+
- **Caching**: Store frequently requested data to improve performance
|
| 28 |
+
- **Error Handling**: Provide consistent error responses across your server
|
| 29 |
+
|
| 30 |
+
## How MCP Middleware Works
|
| 31 |
+
|
| 32 |
+
MCP middleware operates on a pipeline model where each middleware can:
|
| 33 |
+
|
| 34 |
+
1. **Inspect the incoming request** and its context
|
| 35 |
+
2. **Modify the request** before passing it to the next middleware or handler
|
| 36 |
+
3. **Execute the next middleware/handler** in the chain
|
| 37 |
+
4. **Inspect and modify the response** before returning it
|
| 38 |
+
5. **Handle errors** that occur during processing
|
| 39 |
+
|
| 40 |
+
The middleware system provides specialized hooks for different MCP operations:
|
| 41 |
+
|
| 42 |
+
- `on_message`: Called for all MCP messages (requests and notifications)
|
| 43 |
+
- `on_request`: Called specifically for MCP requests (that expect responses)
|
| 44 |
+
- `on_notification`: Called specifically for MCP notifications (fire-and-forget)
|
| 45 |
+
- `on_call_tool`: Called when tools are being executed
|
| 46 |
+
- `on_read_resource`: Called when resources are being read
|
| 47 |
+
- `on_get_prompt`: Called when prompts are being retrieved
|
| 48 |
+
- `on_list_tools`: Called when listing available tools
|
| 49 |
+
- `on_list_resources`: Called when listing available resources
|
| 50 |
+
- `on_list_resource_templates`: Called when listing resource templates
|
| 51 |
+
- `on_list_prompts`: Called when listing available prompts
|
| 52 |
+
|
| 53 |
+
<Tip>
|
| 54 |
+
The middleware hook system is designed to be extensible. As FastMCP evolves and new MCP operations are added, additional hooks will be introduced to provide fine-grained control over new functionality.
|
| 55 |
+
</Tip>
|
| 56 |
+
|
| 57 |
+
## Creating Middleware
|
| 58 |
+
|
| 59 |
+
### Basic Middleware Structure
|
| 60 |
+
|
| 61 |
+
MCP middleware is implemented by subclassing the `MCPMiddleware` base class and overriding the hooks you need:
|
| 62 |
+
|
| 63 |
+
```python
|
| 64 |
+
from fastmcp import FastMCP
|
| 65 |
+
from fastmcp.server.middleware import MCPMiddleware, MiddlewareContext
|
| 66 |
+
|
| 67 |
+
class LoggingMiddleware(MCPMiddleware):
|
| 68 |
+
"""Middleware that logs all MCP operations."""
|
| 69 |
+
|
| 70 |
+
async def on_message(self, context: MiddlewareContext, call_next):
|
| 71 |
+
"""Called for all MCP messages."""
|
| 72 |
+
print(f"Processing {context.method} from {context.source}")
|
| 73 |
+
|
| 74 |
+
# Call the next middleware/handler in the chain
|
| 75 |
+
result = await call_next(context)
|
| 76 |
+
|
| 77 |
+
print(f"Completed {context.method}")
|
| 78 |
+
return result
|
| 79 |
+
|
| 80 |
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
| 81 |
+
"""Called specifically for tool calls."""
|
| 82 |
+
tool_name = context.message.name
|
| 83 |
+
print(f"Calling tool: {tool_name}")
|
| 84 |
+
|
| 85 |
+
result = await call_next(context)
|
| 86 |
+
|
| 87 |
+
print(f"Tool {tool_name} completed")
|
| 88 |
+
return result
|
| 89 |
+
|
| 90 |
+
# Add middleware to your server
|
| 91 |
+
mcp = FastMCP("MyServer")
|
| 92 |
+
mcp.add_middleware(LoggingMiddleware())
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
### Middleware Context
|
| 96 |
+
|
| 97 |
+
The `MiddlewareContext` object provides access to information about the current request:
|
| 98 |
+
|
| 99 |
+
```python
|
| 100 |
+
class InspectionMiddleware(MCPMiddleware):
|
| 101 |
+
async def on_request(self, context: MiddlewareContext, call_next):
|
| 102 |
+
# Access request information
|
| 103 |
+
method = context.method # e.g., "tools/call"
|
| 104 |
+
source = context.source # "client" or "server"
|
| 105 |
+
message_type = context.type # "request" or "notification"
|
| 106 |
+
timestamp = context.timestamp # When the request was received
|
| 107 |
+
message = context.message # The actual MCP message
|
| 108 |
+
fastmcp_context = context.fastmcp_context # FastMCP Context object (if available)
|
| 109 |
+
|
| 110 |
+
# Continue processing
|
| 111 |
+
return await call_next(context)
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### Middleware Hooks
|
| 115 |
+
|
| 116 |
+
Each middleware hook receives a `MiddlewareContext` and a `call_next` function. The hooks are organized in a hierarchy:
|
| 117 |
+
|
| 118 |
+
1. **`on_message`**: The broadest hook, called for all MCP messages
|
| 119 |
+
2. **`on_request`** / **`on_notification`**: Called based on message type
|
| 120 |
+
3. **Operation-specific hooks**: Called for specific MCP operations
|
| 121 |
+
|
| 122 |
+
```python
|
| 123 |
+
class ComprehensiveMiddleware(MCPMiddleware):
|
| 124 |
+
async def on_message(self, context: MiddlewareContext, call_next):
|
| 125 |
+
"""Called for ALL messages (requests and notifications)."""
|
| 126 |
+
print(f"Message: {context.method}")
|
| 127 |
+
return await call_next(context)
|
| 128 |
+
|
| 129 |
+
async def on_request(self, context: MiddlewareContext, call_next):
|
| 130 |
+
"""Called only for requests (messages that expect responses)."""
|
| 131 |
+
print(f"Request: {context.method}")
|
| 132 |
+
return await call_next(context)
|
| 133 |
+
|
| 134 |
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
| 135 |
+
"""Called only for tool execution requests."""
|
| 136 |
+
tool_name = context.message.name
|
| 137 |
+
print(f"Executing tool: {tool_name}")
|
| 138 |
+
return await call_next(context)
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
## Middleware Examples
|
| 142 |
+
|
| 143 |
+
### Authentication Middleware
|
| 144 |
+
|
| 145 |
+
```python
|
| 146 |
+
from fastmcp.server.middleware import MCPMiddleware, MiddlewareContext
|
| 147 |
+
from fastmcp.exceptions import ToolError
|
| 148 |
+
|
| 149 |
+
class AuthenticationMiddleware(MCPMiddleware):
|
| 150 |
+
def __init__(self, required_token: str):
|
| 151 |
+
self.required_token = required_token
|
| 152 |
+
|
| 153 |
+
async def on_request(self, context: MiddlewareContext, call_next):
|
| 154 |
+
"""Verify authentication for all requests."""
|
| 155 |
+
|
| 156 |
+
# Check if this is an HTTP request with headers
|
| 157 |
+
if hasattr(context, 'fastmcp_context') and context.fastmcp_context:
|
| 158 |
+
try:
|
| 159 |
+
# Access HTTP request if available
|
| 160 |
+
request = context.fastmcp_context.get_http_request()
|
| 161 |
+
auth_header = request.headers.get("Authorization")
|
| 162 |
+
|
| 163 |
+
if not auth_header or not auth_header.startswith("Bearer "):
|
| 164 |
+
raise ToolError("Missing or invalid authorization header")
|
| 165 |
+
|
| 166 |
+
token = auth_header.split(" ", 1)[1]
|
| 167 |
+
if token != self.required_token:
|
| 168 |
+
raise ToolError("Invalid authentication token")
|
| 169 |
+
|
| 170 |
+
except Exception:
|
| 171 |
+
# If HTTP request is not available, continue without auth
|
| 172 |
+
pass
|
| 173 |
+
|
| 174 |
+
return await call_next(context)
|
| 175 |
+
|
| 176 |
+
# Usage
|
| 177 |
+
mcp = FastMCP("SecureServer")
|
| 178 |
+
mcp.add_middleware(AuthenticationMiddleware("secret-token-123"))
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
### Performance Monitoring Middleware
|
| 182 |
+
|
| 183 |
+
```python
|
| 184 |
+
import time
|
| 185 |
+
import logging
|
| 186 |
+
|
| 187 |
+
class PerformanceMiddleware(MCPMiddleware):
|
| 188 |
+
def __init__(self):
|
| 189 |
+
self.logger = logging.getLogger("performance")
|
| 190 |
+
|
| 191 |
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
| 192 |
+
"""Monitor tool execution performance."""
|
| 193 |
+
tool_name = context.message.name
|
| 194 |
+
start_time = time.time()
|
| 195 |
+
|
| 196 |
+
try:
|
| 197 |
+
result = await call_next(context)
|
| 198 |
+
execution_time = time.time() - start_time
|
| 199 |
+
|
| 200 |
+
self.logger.info(
|
| 201 |
+
f"Tool {tool_name} completed in {execution_time:.3f}s"
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
return result
|
| 205 |
+
|
| 206 |
+
except Exception as e:
|
| 207 |
+
execution_time = time.time() - start_time
|
| 208 |
+
self.logger.error(
|
| 209 |
+
f"Tool {tool_name} failed after {execution_time:.3f}s: {e}"
|
| 210 |
+
)
|
| 211 |
+
raise
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
### Request/Response Transformation Middleware
|
| 215 |
+
|
| 216 |
+
```python
|
| 217 |
+
class TransformationMiddleware(MCPMiddleware):
|
| 218 |
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
| 219 |
+
"""Transform tool arguments and results."""
|
| 220 |
+
|
| 221 |
+
# Access and modify tool arguments
|
| 222 |
+
if hasattr(context.message, 'arguments'):
|
| 223 |
+
args = context.message.arguments or {}
|
| 224 |
+
|
| 225 |
+
# Example: Add a timestamp to all tool calls
|
| 226 |
+
args['_middleware_timestamp'] = context.timestamp.isoformat()
|
| 227 |
+
|
| 228 |
+
# Create a modified context
|
| 229 |
+
modified_context = context.copy(
|
| 230 |
+
message=context.message.model_copy(update={'arguments': args})
|
| 231 |
+
)
|
| 232 |
+
else:
|
| 233 |
+
modified_context = context
|
| 234 |
+
|
| 235 |
+
# Execute with modified context
|
| 236 |
+
result = await call_next(modified_context)
|
| 237 |
+
|
| 238 |
+
# Transform the result if needed
|
| 239 |
+
if hasattr(result, 'content'):
|
| 240 |
+
# Example: Add metadata to tool results
|
| 241 |
+
if result.content and len(result.content) > 0:
|
| 242 |
+
original_content = result.content[0].text
|
| 243 |
+
enhanced_content = f"[Processed at {context.timestamp}]\n{original_content}"
|
| 244 |
+
result.content[0].text = enhanced_content
|
| 245 |
+
|
| 246 |
+
return result
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
### Rate Limiting Middleware
|
| 250 |
+
|
| 251 |
+
```python
|
| 252 |
+
import asyncio
|
| 253 |
+
from collections import defaultdict
|
| 254 |
+
from datetime import datetime, timedelta
|
| 255 |
+
|
| 256 |
+
class RateLimitMiddleware(MCPMiddleware):
|
| 257 |
+
def __init__(self, max_requests: int = 100, window_minutes: int = 1):
|
| 258 |
+
self.max_requests = max_requests
|
| 259 |
+
self.window = timedelta(minutes=window_minutes)
|
| 260 |
+
self.requests = defaultdict(list) # client_id -> [timestamps]
|
| 261 |
+
|
| 262 |
+
async def on_request(self, context: MiddlewareContext, call_next):
|
| 263 |
+
"""Implement rate limiting per client."""
|
| 264 |
+
|
| 265 |
+
# Get client identifier (you may need to implement this based on your auth)
|
| 266 |
+
client_id = getattr(context.fastmcp_context, 'client_id', 'anonymous')
|
| 267 |
+
|
| 268 |
+
now = datetime.now()
|
| 269 |
+
|
| 270 |
+
# Clean old requests
|
| 271 |
+
self.requests[client_id] = [
|
| 272 |
+
timestamp for timestamp in self.requests[client_id]
|
| 273 |
+
if now - timestamp < self.window
|
| 274 |
+
]
|
| 275 |
+
|
| 276 |
+
# Check rate limit
|
| 277 |
+
if len(self.requests[client_id]) >= self.max_requests:
|
| 278 |
+
raise ToolError(
|
| 279 |
+
f"Rate limit exceeded: {self.max_requests} requests per "
|
| 280 |
+
f"{self.window.total_seconds()/60:.0f} minutes"
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
# Record this request
|
| 284 |
+
self.requests[client_id].append(now)
|
| 285 |
+
|
| 286 |
+
return await call_next(context)
|
| 287 |
+
```
|
| 288 |
+
|
| 289 |
+
## Adding Middleware to Your Server
|
| 290 |
+
|
| 291 |
+
### Single Middleware
|
| 292 |
+
|
| 293 |
+
```python
|
| 294 |
+
from fastmcp import FastMCP
|
| 295 |
+
|
| 296 |
+
mcp = FastMCP("MyServer")
|
| 297 |
+
|
| 298 |
+
# Add a single middleware instance
|
| 299 |
+
logging_middleware = LoggingMiddleware()
|
| 300 |
+
mcp.add_middleware(logging_middleware)
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
### Multiple Middleware
|
| 304 |
+
|
| 305 |
+
Middleware is executed in the order it's added to the server:
|
| 306 |
+
|
| 307 |
+
```python
|
| 308 |
+
mcp = FastMCP("MyServer")
|
| 309 |
+
|
| 310 |
+
# Add multiple middleware - they execute in order
|
| 311 |
+
mcp.add_middleware(AuthenticationMiddleware("secret-token"))
|
| 312 |
+
mcp.add_middleware(PerformanceMiddleware())
|
| 313 |
+
mcp.add_middleware(LoggingMiddleware())
|
| 314 |
+
|
| 315 |
+
# Request flow:
|
| 316 |
+
# 1. AuthenticationMiddleware.on_request()
|
| 317 |
+
# 2. PerformanceMiddleware.on_request()
|
| 318 |
+
# 3. LoggingMiddleware.on_request()
|
| 319 |
+
# 4. Actual tool/resource handler
|
| 320 |
+
# 5. LoggingMiddleware response processing
|
| 321 |
+
# 6. PerformanceMiddleware response processing
|
| 322 |
+
# 7. AuthenticationMiddleware response processing
|
| 323 |
+
```
|
| 324 |
+
|
| 325 |
+
## Advanced Patterns
|
| 326 |
+
|
| 327 |
+
### Conditional Middleware
|
| 328 |
+
|
| 329 |
+
```python
|
| 330 |
+
class ConditionalMiddleware(MCPMiddleware):
|
| 331 |
+
def __init__(self, condition_func):
|
| 332 |
+
self.should_process = condition_func
|
| 333 |
+
|
| 334 |
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
| 335 |
+
"""Only process certain tools."""
|
| 336 |
+
|
| 337 |
+
if not self.should_process(context.message.name):
|
| 338 |
+
# Skip processing for this tool
|
| 339 |
+
return await call_next(context)
|
| 340 |
+
|
| 341 |
+
# Apply middleware logic
|
| 342 |
+
print(f"Processing tool: {context.message.name}")
|
| 343 |
+
return await call_next(context)
|
| 344 |
+
|
| 345 |
+
# Usage
|
| 346 |
+
def only_expensive_tools(tool_name: str) -> bool:
|
| 347 |
+
return tool_name in ["complex_analysis", "heavy_computation"]
|
| 348 |
+
|
| 349 |
+
mcp.add_middleware(ConditionalMiddleware(only_expensive_tools))
|
| 350 |
+
```
|
| 351 |
+
|
| 352 |
+
### Middleware with State
|
| 353 |
+
|
| 354 |
+
```python
|
| 355 |
+
class StatefulMiddleware(MCPMiddleware):
|
| 356 |
+
def __init__(self):
|
| 357 |
+
self.call_count = 0
|
| 358 |
+
self.tools_used = set()
|
| 359 |
+
|
| 360 |
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
| 361 |
+
"""Track usage statistics."""
|
| 362 |
+
self.call_count += 1
|
| 363 |
+
self.tools_used.add(context.message.name)
|
| 364 |
+
|
| 365 |
+
print(f"Total calls: {self.call_count}, Unique tools: {len(self.tools_used)}")
|
| 366 |
+
|
| 367 |
+
return await call_next(context)
|
| 368 |
+
|
| 369 |
+
def get_stats(self):
|
| 370 |
+
return {
|
| 371 |
+
"total_calls": self.call_count,
|
| 372 |
+
"unique_tools": len(self.tools_used),
|
| 373 |
+
"tools_used": list(self.tools_used)
|
| 374 |
+
}
|
| 375 |
+
```
|
| 376 |
+
|
| 377 |
+
### Error Handling Middleware
|
| 378 |
+
|
| 379 |
+
```python
|
| 380 |
+
class ErrorHandlingMiddleware(MCPMiddleware):
|
| 381 |
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
| 382 |
+
"""Provide consistent error handling."""
|
| 383 |
+
try:
|
| 384 |
+
return await call_next(context)
|
| 385 |
+
except ToolError:
|
| 386 |
+
# Re-raise ToolErrors as-is
|
| 387 |
+
raise
|
| 388 |
+
except Exception as e:
|
| 389 |
+
# Log the error and convert to a user-friendly message
|
| 390 |
+
logging.error(f"Tool {context.message.name} failed: {e}")
|
| 391 |
+
raise ToolError(f"Tool execution failed: {str(e)}")
|
| 392 |
+
```
|
| 393 |
+
|
| 394 |
+
## Best Practices
|
| 395 |
+
|
| 396 |
+
### Performance Considerations
|
| 397 |
+
|
| 398 |
+
1. **Keep middleware lightweight**: Avoid heavy computations in middleware
|
| 399 |
+
2. **Use async operations**: Don't block the event loop with synchronous operations
|
| 400 |
+
3. **Cache when possible**: Store frequently accessed data to avoid repeated lookups
|
| 401 |
+
|
| 402 |
+
```python
|
| 403 |
+
class EfficientMiddleware(MCPMiddleware):
|
| 404 |
+
def __init__(self):
|
| 405 |
+
self._cache = {}
|
| 406 |
+
|
| 407 |
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
| 408 |
+
"""Example of efficient middleware with caching."""
|
| 409 |
+
|
| 410 |
+
# Check cache first
|
| 411 |
+
cache_key = f"{context.message.name}:{hash(str(context.message.arguments))}"
|
| 412 |
+
|
| 413 |
+
if cache_key in self._cache:
|
| 414 |
+
print("Returning cached result")
|
| 415 |
+
return self._cache[cache_key]
|
| 416 |
+
|
| 417 |
+
# Execute and cache result
|
| 418 |
+
result = await call_next(context)
|
| 419 |
+
self._cache[cache_key] = result
|
| 420 |
+
|
| 421 |
+
return result
|
| 422 |
+
```
|
| 423 |
+
|
| 424 |
+
### Error Handling
|
| 425 |
+
|
| 426 |
+
1. **Always call `call_next`**: Unless you're intentionally stopping the chain
|
| 427 |
+
2. **Handle exceptions appropriately**: Don't let middleware errors break the entire request
|
| 428 |
+
3. **Use `ToolError` for client-facing errors**: Keep internal errors internal
|
| 429 |
+
|
| 430 |
+
```python
|
| 431 |
+
class RobustMiddleware(MCPMiddleware):
|
| 432 |
+
async def on_request(self, context: MiddlewareContext, call_next):
|
| 433 |
+
"""Robust error handling example."""
|
| 434 |
+
try:
|
| 435 |
+
# Middleware logic here
|
| 436 |
+
return await call_next(context)
|
| 437 |
+
except ToolError:
|
| 438 |
+
# Client-facing errors should be re-raised
|
| 439 |
+
raise
|
| 440 |
+
except Exception as e:
|
| 441 |
+
# Log internal errors but don't expose details
|
| 442 |
+
logging.error(f"Middleware error: {e}")
|
| 443 |
+
# Optionally continue without middleware processing
|
| 444 |
+
return await call_next(context)
|
| 445 |
+
```
|
| 446 |
+
|
| 447 |
+
### Testing Middleware
|
| 448 |
+
|
| 449 |
+
```python
|
| 450 |
+
import pytest
|
| 451 |
+
from fastmcp import FastMCP, Client
|
| 452 |
+
|
| 453 |
+
@pytest.mark.asyncio
|
| 454 |
+
async def test_logging_middleware():
|
| 455 |
+
"""Test middleware functionality."""
|
| 456 |
+
|
| 457 |
+
# Create server with middleware
|
| 458 |
+
mcp = FastMCP("TestServer")
|
| 459 |
+
logging_middleware = LoggingMiddleware()
|
| 460 |
+
mcp.add_middleware(logging_middleware)
|
| 461 |
+
|
| 462 |
+
@mcp.tool
|
| 463 |
+
def test_tool(x: int) -> int:
|
| 464 |
+
return x * 2
|
| 465 |
+
|
| 466 |
+
# Test with client
|
| 467 |
+
async with Client(mcp) as client:
|
| 468 |
+
result = await client.call_tool("test_tool", {"x": 5})
|
| 469 |
+
assert result == 10
|
| 470 |
+
|
| 471 |
+
# Verify middleware was called
|
| 472 |
+
# (You'll need to add tracking to your middleware for testing)
|
| 473 |
+
```
|
| 474 |
+
|
| 475 |
+
## Server Composition and Middleware
|
| 476 |
+
|
| 477 |
+
When using [Server Composition](/servers/composition) with `mount` or `import_server`, middleware behavior follows these rules:
|
| 478 |
+
|
| 479 |
+
1. **Parent server middleware** runs for all requests, including those routed to mounted servers
|
| 480 |
+
2. **Mounted server middleware** only runs for requests handled by that specific server
|
| 481 |
+
3. **Middleware order** is preserved within each server
|
| 482 |
+
|
| 483 |
+
```python
|
| 484 |
+
# Parent server with middleware
|
| 485 |
+
parent = FastMCP("Parent")
|
| 486 |
+
parent.add_middleware(AuthenticationMiddleware("token"))
|
| 487 |
+
|
| 488 |
+
# Child server with its own middleware
|
| 489 |
+
child = FastMCP("Child")
|
| 490 |
+
child.add_middleware(LoggingMiddleware())
|
| 491 |
+
|
| 492 |
+
@child.tool
|
| 493 |
+
def child_tool() -> str:
|
| 494 |
+
return "from child"
|
| 495 |
+
|
| 496 |
+
# Mount the child server
|
| 497 |
+
parent.mount(child, prefix="child")
|
| 498 |
+
|
| 499 |
+
# Request to "child_tool" will:
|
| 500 |
+
# 1. Run parent's AuthenticationMiddleware
|
| 501 |
+
# 2. Route to child server
|
| 502 |
+
# 3. Run child's LoggingMiddleware
|
| 503 |
+
# 4. Execute child_tool
|
| 504 |
+
```
|
| 505 |
+
|
| 506 |
+
This allows you to create layered middleware architectures where parent servers handle cross-cutting concerns like authentication, while child servers focus on domain-specific middleware.
|
| 507 |
+
|
| 508 |
+
<Tip>
|
| 509 |
+
When debugging middleware issues in composed servers, remember that both parent and child middleware may be executing. Use detailed logging to trace the middleware execution path.
|
| 510 |
+
</Tip>
|
src/fastmcp/prompts/prompt_manager.py
CHANGED
|
@@ -27,7 +27,7 @@ class PromptManager:
|
|
| 27 |
mask_error_details: bool | None = None,
|
| 28 |
):
|
| 29 |
self._prompts: dict[str, Prompt] = {}
|
| 30 |
-
self.
|
| 31 |
self.mask_error_details = mask_error_details or settings.mask_error_details
|
| 32 |
|
| 33 |
# Default to "warn" if None is provided
|
|
@@ -44,7 +44,7 @@ class PromptManager:
|
|
| 44 |
|
| 45 |
def mount(self, server: MountedServer) -> None:
|
| 46 |
"""Adds a mounted server as a source for prompts."""
|
| 47 |
-
self.
|
| 48 |
|
| 49 |
async def _load_prompts(self, *, via_server: bool = False) -> dict[str, Prompt]:
|
| 50 |
"""
|
|
@@ -56,7 +56,7 @@ class PromptManager:
|
|
| 56 |
"""
|
| 57 |
all_prompts: dict[str, Prompt] = {}
|
| 58 |
|
| 59 |
-
for mounted in self.
|
| 60 |
try:
|
| 61 |
if via_server:
|
| 62 |
# Use the server-to-server filtered path
|
|
@@ -188,12 +188,16 @@ class PromptManager:
|
|
| 188 |
raise PromptError(f"Error rendering prompt {name!r}: {e}") from e
|
| 189 |
|
| 190 |
# 2. Check mounted servers using the filtered protocol path.
|
| 191 |
-
for mounted in reversed(self.
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
raise NotFoundError(f"Unknown prompt: {name}")
|
|
|
|
| 27 |
mask_error_details: bool | None = None,
|
| 28 |
):
|
| 29 |
self._prompts: dict[str, Prompt] = {}
|
| 30 |
+
self._mounted_servers: list[MountedServer] = []
|
| 31 |
self.mask_error_details = mask_error_details or settings.mask_error_details
|
| 32 |
|
| 33 |
# Default to "warn" if None is provided
|
|
|
|
| 44 |
|
| 45 |
def mount(self, server: MountedServer) -> None:
|
| 46 |
"""Adds a mounted server as a source for prompts."""
|
| 47 |
+
self._mounted_servers.append(server)
|
| 48 |
|
| 49 |
async def _load_prompts(self, *, via_server: bool = False) -> dict[str, Prompt]:
|
| 50 |
"""
|
|
|
|
| 56 |
"""
|
| 57 |
all_prompts: dict[str, Prompt] = {}
|
| 58 |
|
| 59 |
+
for mounted in self._mounted_servers:
|
| 60 |
try:
|
| 61 |
if via_server:
|
| 62 |
# Use the server-to-server filtered path
|
|
|
|
| 188 |
raise PromptError(f"Error rendering prompt {name!r}: {e}") from e
|
| 189 |
|
| 190 |
# 2. Check mounted servers using the filtered protocol path.
|
| 191 |
+
for mounted in reversed(self._mounted_servers):
|
| 192 |
+
prompt_key = name
|
| 193 |
+
if mounted.prefix:
|
| 194 |
+
if name.startswith(f"{mounted.prefix}_"):
|
| 195 |
+
prompt_key = name.removeprefix(f"{mounted.prefix}_")
|
| 196 |
+
else:
|
| 197 |
continue
|
| 198 |
+
try:
|
| 199 |
+
return await mounted.server._get_prompt(prompt_key, arguments)
|
| 200 |
+
except NotFoundError:
|
| 201 |
+
continue
|
| 202 |
|
| 203 |
raise NotFoundError(f"Unknown prompt: {name}")
|
src/fastmcp/resources/resource_manager.py
CHANGED
|
@@ -43,7 +43,7 @@ class ResourceManager:
|
|
| 43 |
"""
|
| 44 |
self._resources: dict[str, Resource] = {}
|
| 45 |
self._templates: dict[str, ResourceTemplate] = {}
|
| 46 |
-
self.
|
| 47 |
self.mask_error_details = mask_error_details or settings.mask_error_details
|
| 48 |
|
| 49 |
# Default to "warn" if None is provided
|
|
@@ -59,7 +59,7 @@ class ResourceManager:
|
|
| 59 |
|
| 60 |
def mount(self, server: MountedServer) -> None:
|
| 61 |
"""Adds a mounted server as a source for resources and templates."""
|
| 62 |
-
self.
|
| 63 |
|
| 64 |
async def get_resources(self) -> dict[str, Resource]:
|
| 65 |
"""Get all registered resources, keyed by URI."""
|
|
@@ -79,7 +79,7 @@ class ResourceManager:
|
|
| 79 |
"""
|
| 80 |
all_resources: dict[str, Resource] = {}
|
| 81 |
|
| 82 |
-
for mounted in self.
|
| 83 |
try:
|
| 84 |
if via_server:
|
| 85 |
# Use the server-to-server filtered path
|
|
@@ -129,7 +129,7 @@ class ResourceManager:
|
|
| 129 |
"""
|
| 130 |
all_templates: dict[str, ResourceTemplate] = {}
|
| 131 |
|
| 132 |
-
for mounted in self.
|
| 133 |
try:
|
| 134 |
if via_server:
|
| 135 |
# Use the server-to-server filtered path
|
|
@@ -457,8 +457,8 @@ class ResourceManager:
|
|
| 457 |
) from e
|
| 458 |
|
| 459 |
# 1b. Check local templates if not found in concrete resources
|
| 460 |
-
for template in self._templates.
|
| 461 |
-
if params := match_uri_template(uri_str,
|
| 462 |
try:
|
| 463 |
resource = await template.create_resource(uri_str, params=params)
|
| 464 |
return await resource.read()
|
|
@@ -483,29 +483,28 @@ class ResourceManager:
|
|
| 483 |
# 2. Check mounted servers using the filtered protocol path.
|
| 484 |
from fastmcp.server.server import has_resource_prefix, remove_resource_prefix
|
| 485 |
|
| 486 |
-
for mounted in reversed(self.
|
| 487 |
-
|
| 488 |
try:
|
| 489 |
if mounted.prefix:
|
| 490 |
-
# If server has a prefix, check if URI matches and strip prefix
|
| 491 |
if has_resource_prefix(
|
| 492 |
-
|
| 493 |
mounted.prefix,
|
| 494 |
mounted.resource_prefix_format,
|
| 495 |
):
|
| 496 |
-
|
| 497 |
-
|
| 498 |
mounted.prefix,
|
| 499 |
mounted.resource_prefix_format,
|
| 500 |
)
|
| 501 |
else:
|
| 502 |
continue
|
| 503 |
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
if result and len(result) > 0:
|
| 507 |
return result[0].content
|
| 508 |
-
|
|
|
|
| 509 |
except NotFoundError:
|
| 510 |
continue
|
| 511 |
|
|
|
|
| 43 |
"""
|
| 44 |
self._resources: dict[str, Resource] = {}
|
| 45 |
self._templates: dict[str, ResourceTemplate] = {}
|
| 46 |
+
self._mounted_servers: list[MountedServer] = []
|
| 47 |
self.mask_error_details = mask_error_details or settings.mask_error_details
|
| 48 |
|
| 49 |
# Default to "warn" if None is provided
|
|
|
|
| 59 |
|
| 60 |
def mount(self, server: MountedServer) -> None:
|
| 61 |
"""Adds a mounted server as a source for resources and templates."""
|
| 62 |
+
self._mounted_servers.append(server)
|
| 63 |
|
| 64 |
async def get_resources(self) -> dict[str, Resource]:
|
| 65 |
"""Get all registered resources, keyed by URI."""
|
|
|
|
| 79 |
"""
|
| 80 |
all_resources: dict[str, Resource] = {}
|
| 81 |
|
| 82 |
+
for mounted in self._mounted_servers:
|
| 83 |
try:
|
| 84 |
if via_server:
|
| 85 |
# Use the server-to-server filtered path
|
|
|
|
| 129 |
"""
|
| 130 |
all_templates: dict[str, ResourceTemplate] = {}
|
| 131 |
|
| 132 |
+
for mounted in self._mounted_servers:
|
| 133 |
try:
|
| 134 |
if via_server:
|
| 135 |
# Use the server-to-server filtered path
|
|
|
|
| 457 |
) from e
|
| 458 |
|
| 459 |
# 1b. Check local templates if not found in concrete resources
|
| 460 |
+
for key, template in self._templates.items():
|
| 461 |
+
if params := match_uri_template(uri_str, key):
|
| 462 |
try:
|
| 463 |
resource = await template.create_resource(uri_str, params=params)
|
| 464 |
return await resource.read()
|
|
|
|
| 483 |
# 2. Check mounted servers using the filtered protocol path.
|
| 484 |
from fastmcp.server.server import has_resource_prefix, remove_resource_prefix
|
| 485 |
|
| 486 |
+
for mounted in reversed(self._mounted_servers):
|
| 487 |
+
key = uri_str
|
| 488 |
try:
|
| 489 |
if mounted.prefix:
|
|
|
|
| 490 |
if has_resource_prefix(
|
| 491 |
+
key,
|
| 492 |
mounted.prefix,
|
| 493 |
mounted.resource_prefix_format,
|
| 494 |
):
|
| 495 |
+
key = remove_resource_prefix(
|
| 496 |
+
key,
|
| 497 |
mounted.prefix,
|
| 498 |
mounted.resource_prefix_format,
|
| 499 |
)
|
| 500 |
else:
|
| 501 |
continue
|
| 502 |
|
| 503 |
+
try:
|
| 504 |
+
result = await mounted.server._read_resource(key)
|
|
|
|
| 505 |
return result[0].content
|
| 506 |
+
except NotFoundError:
|
| 507 |
+
continue
|
| 508 |
except NotFoundError:
|
| 509 |
continue
|
| 510 |
|
src/fastmcp/resources/template.py
CHANGED
|
@@ -62,6 +62,9 @@ class ResourceTemplate(FastMCPComponent):
|
|
| 62 |
description="JSON schema for function parameters"
|
| 63 |
)
|
| 64 |
|
|
|
|
|
|
|
|
|
|
| 65 |
@staticmethod
|
| 66 |
def from_function(
|
| 67 |
fn: Callable[..., Any],
|
|
|
|
| 62 |
description="JSON schema for function parameters"
|
| 63 |
)
|
| 64 |
|
| 65 |
+
def __repr__(self) -> str:
|
| 66 |
+
return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
|
| 67 |
+
|
| 68 |
@staticmethod
|
| 69 |
def from_function(
|
| 70 |
fn: Callable[..., Any],
|
src/fastmcp/tools/tool_manager.py
CHANGED
|
@@ -28,7 +28,7 @@ class ToolManager:
|
|
| 28 |
mask_error_details: bool | None = None,
|
| 29 |
):
|
| 30 |
self._tools: dict[str, Tool] = {}
|
| 31 |
-
self.
|
| 32 |
self.mask_error_details = mask_error_details or settings.mask_error_details
|
| 33 |
|
| 34 |
# Default to "warn" if None is provided
|
|
@@ -45,7 +45,7 @@ class ToolManager:
|
|
| 45 |
|
| 46 |
def mount(self, server: MountedServer) -> None:
|
| 47 |
"""Adds a mounted server as a source for tools."""
|
| 48 |
-
self.
|
| 49 |
|
| 50 |
async def _load_tools(self, *, via_server: bool = False) -> dict[str, Tool]:
|
| 51 |
"""
|
|
@@ -57,7 +57,7 @@ class ToolManager:
|
|
| 57 |
"""
|
| 58 |
all_tools: dict[str, Tool] = {}
|
| 59 |
|
| 60 |
-
for mounted in self.
|
| 61 |
try:
|
| 62 |
if via_server:
|
| 63 |
# Use the server-to-server filtered path
|
|
@@ -201,12 +201,16 @@ class ToolManager:
|
|
| 201 |
raise ToolError(f"Error calling tool {key!r}: {e}") from e
|
| 202 |
|
| 203 |
# 2. Check mounted servers using the filtered protocol path.
|
| 204 |
-
for mounted in reversed(self.
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
raise NotFoundError(f"Tool {key!r} not found.")
|
|
|
|
| 28 |
mask_error_details: bool | None = None,
|
| 29 |
):
|
| 30 |
self._tools: dict[str, Tool] = {}
|
| 31 |
+
self._mounted_servers: list[MountedServer] = []
|
| 32 |
self.mask_error_details = mask_error_details or settings.mask_error_details
|
| 33 |
|
| 34 |
# Default to "warn" if None is provided
|
|
|
|
| 45 |
|
| 46 |
def mount(self, server: MountedServer) -> None:
|
| 47 |
"""Adds a mounted server as a source for tools."""
|
| 48 |
+
self._mounted_servers.append(server)
|
| 49 |
|
| 50 |
async def _load_tools(self, *, via_server: bool = False) -> dict[str, Tool]:
|
| 51 |
"""
|
|
|
|
| 57 |
"""
|
| 58 |
all_tools: dict[str, Tool] = {}
|
| 59 |
|
| 60 |
+
for mounted in self._mounted_servers:
|
| 61 |
try:
|
| 62 |
if via_server:
|
| 63 |
# Use the server-to-server filtered path
|
|
|
|
| 201 |
raise ToolError(f"Error calling tool {key!r}: {e}") from e
|
| 202 |
|
| 203 |
# 2. Check mounted servers using the filtered protocol path.
|
| 204 |
+
for mounted in reversed(self._mounted_servers):
|
| 205 |
+
tool_key = key
|
| 206 |
+
if mounted.prefix:
|
| 207 |
+
if key.startswith(f"{mounted.prefix}_"):
|
| 208 |
+
tool_key = key.removeprefix(f"{mounted.prefix}_")
|
| 209 |
+
else:
|
| 210 |
continue
|
| 211 |
+
try:
|
| 212 |
+
return await mounted.server._call_tool(tool_key, arguments)
|
| 213 |
+
except NotFoundError:
|
| 214 |
+
continue
|
| 215 |
|
| 216 |
raise NotFoundError(f"Tool {key!r} not found.")
|
tests/prompts/test_prompt_manager.py
CHANGED
|
@@ -10,7 +10,7 @@ from fastmcp.prompts.prompt_manager import PromptManager
|
|
| 10 |
|
| 11 |
|
| 12 |
class TestPromptManager:
|
| 13 |
-
def test_add_prompt(self):
|
| 14 |
"""Test adding a prompt to the manager."""
|
| 15 |
|
| 16 |
def fn() -> str:
|
|
@@ -20,9 +20,9 @@ class TestPromptManager:
|
|
| 20 |
prompt = Prompt.from_function(fn)
|
| 21 |
added = manager.add_prompt(prompt)
|
| 22 |
assert added == prompt
|
| 23 |
-
assert manager.get_prompt("fn") == prompt
|
| 24 |
|
| 25 |
-
def test_add_duplicate_prompt(self, caplog):
|
| 26 |
"""Test adding the same prompt twice."""
|
| 27 |
|
| 28 |
def fn() -> str:
|
|
@@ -35,7 +35,7 @@ class TestPromptManager:
|
|
| 35 |
assert first == second
|
| 36 |
assert "Prompt already exists" in caplog.text
|
| 37 |
|
| 38 |
-
def test_disable_warn_on_duplicate_prompts(self, caplog):
|
| 39 |
"""Test disabling warning on duplicate prompts."""
|
| 40 |
|
| 41 |
def fn() -> str:
|
|
@@ -48,7 +48,7 @@ class TestPromptManager:
|
|
| 48 |
assert first == second
|
| 49 |
assert "Prompt already exists" not in caplog.text
|
| 50 |
|
| 51 |
-
def test_warn_on_duplicate_prompts(self, caplog):
|
| 52 |
"""Test warning on duplicate prompts."""
|
| 53 |
manager = PromptManager(duplicate_behavior="warn")
|
| 54 |
|
|
@@ -62,9 +62,9 @@ class TestPromptManager:
|
|
| 62 |
|
| 63 |
assert "Prompt already exists: test_prompt" in caplog.text
|
| 64 |
# Should have the prompt
|
| 65 |
-
assert manager.get_prompt("test_prompt") is not None
|
| 66 |
|
| 67 |
-
def test_error_on_duplicate_prompts(self):
|
| 68 |
"""Test error on duplicate prompts."""
|
| 69 |
manager = PromptManager(duplicate_behavior="error")
|
| 70 |
|
|
@@ -78,7 +78,7 @@ class TestPromptManager:
|
|
| 78 |
with pytest.raises(ValueError, match="Prompt already exists: test_prompt"):
|
| 79 |
manager.add_prompt(prompt)
|
| 80 |
|
| 81 |
-
def test_replace_duplicate_prompts(self):
|
| 82 |
"""Test replacing duplicate prompts."""
|
| 83 |
manager = PromptManager(duplicate_behavior="replace")
|
| 84 |
|
|
@@ -95,12 +95,12 @@ class TestPromptManager:
|
|
| 95 |
manager.add_prompt(prompt2)
|
| 96 |
|
| 97 |
# Should have replaced with the new prompt
|
| 98 |
-
prompt = manager.get_prompt("test_prompt")
|
| 99 |
assert prompt is not None
|
| 100 |
assert isinstance(prompt, FunctionPrompt)
|
| 101 |
assert prompt.fn.__name__ == "replacement_fn"
|
| 102 |
|
| 103 |
-
def test_ignore_duplicate_prompts(self):
|
| 104 |
"""Test ignoring duplicate prompts."""
|
| 105 |
manager = PromptManager(duplicate_behavior="ignore")
|
| 106 |
|
|
@@ -117,7 +117,7 @@ class TestPromptManager:
|
|
| 117 |
result = manager.add_prompt(prompt2)
|
| 118 |
|
| 119 |
# Should keep the original
|
| 120 |
-
prompt = manager.get_prompt("test_prompt")
|
| 121 |
assert prompt is not None
|
| 122 |
assert isinstance(prompt, FunctionPrompt)
|
| 123 |
assert prompt.fn.__name__ == "original_fn"
|
|
@@ -125,7 +125,7 @@ class TestPromptManager:
|
|
| 125 |
assert isinstance(result, FunctionPrompt)
|
| 126 |
assert result.fn.__name__ == "original_fn"
|
| 127 |
|
| 128 |
-
def test_get_prompts(self):
|
| 129 |
"""Test retrieving all prompts."""
|
| 130 |
|
| 131 |
def fn1() -> str:
|
|
@@ -139,7 +139,7 @@ class TestPromptManager:
|
|
| 139 |
prompt2 = Prompt.from_function(fn2)
|
| 140 |
manager.add_prompt(prompt1)
|
| 141 |
manager.add_prompt(prompt2)
|
| 142 |
-
prompts = manager.get_prompts()
|
| 143 |
assert len(prompts) == 2
|
| 144 |
assert prompts["fn1"] == prompt1
|
| 145 |
assert prompts["fn2"] == prompt2
|
|
@@ -270,7 +270,7 @@ class TestRenderPrompt:
|
|
| 270 |
class TestPromptTags:
|
| 271 |
"""Test functionality related to prompt tags."""
|
| 272 |
|
| 273 |
-
def test_add_prompt_with_tags(self):
|
| 274 |
"""Test adding a prompt with tags."""
|
| 275 |
|
| 276 |
def greeting() -> str:
|
|
@@ -280,11 +280,11 @@ class TestPromptTags:
|
|
| 280 |
prompt = Prompt.from_function(greeting, tags={"greeting", "simple"})
|
| 281 |
manager.add_prompt(prompt)
|
| 282 |
|
| 283 |
-
prompt = manager.get_prompt("greeting")
|
| 284 |
assert prompt is not None
|
| 285 |
assert prompt.tags == {"greeting", "simple"}
|
| 286 |
|
| 287 |
-
def test_add_prompt_with_empty_tags(self):
|
| 288 |
"""Test adding a prompt with empty tags."""
|
| 289 |
|
| 290 |
def greeting() -> str:
|
|
@@ -294,11 +294,11 @@ class TestPromptTags:
|
|
| 294 |
prompt = Prompt.from_function(greeting, tags=set())
|
| 295 |
manager.add_prompt(prompt)
|
| 296 |
|
| 297 |
-
prompt = manager.get_prompt("greeting")
|
| 298 |
assert prompt is not None
|
| 299 |
assert prompt.tags == set()
|
| 300 |
|
| 301 |
-
def test_add_prompt_with_none_tags(self):
|
| 302 |
"""Test adding a prompt with None tags."""
|
| 303 |
|
| 304 |
def greeting() -> str:
|
|
@@ -308,11 +308,11 @@ class TestPromptTags:
|
|
| 308 |
prompt = Prompt.from_function(greeting, tags=None)
|
| 309 |
manager.add_prompt(prompt)
|
| 310 |
|
| 311 |
-
prompt = manager.get_prompt("greeting")
|
| 312 |
assert prompt is not None
|
| 313 |
assert prompt.tags == set()
|
| 314 |
|
| 315 |
-
def test_list_prompts_with_tags(self):
|
| 316 |
"""Test listing prompts with specific tags."""
|
| 317 |
|
| 318 |
def greeting() -> str:
|
|
@@ -332,13 +332,12 @@ class TestPromptTags:
|
|
| 332 |
)
|
| 333 |
|
| 334 |
# Filter prompts by tags
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
]
|
| 338 |
assert len(simple_prompts) == 2
|
| 339 |
assert {p.name for p in simple_prompts} == {"greeting", "summary"}
|
| 340 |
|
| 341 |
-
nlp_prompts = [p for p in
|
| 342 |
assert len(nlp_prompts) == 1
|
| 343 |
assert nlp_prompts[0].name == "summary"
|
| 344 |
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
class TestPromptManager:
|
| 13 |
+
async def test_add_prompt(self):
|
| 14 |
"""Test adding a prompt to the manager."""
|
| 15 |
|
| 16 |
def fn() -> str:
|
|
|
|
| 20 |
prompt = Prompt.from_function(fn)
|
| 21 |
added = manager.add_prompt(prompt)
|
| 22 |
assert added == prompt
|
| 23 |
+
assert await manager.get_prompt("fn") == prompt
|
| 24 |
|
| 25 |
+
async def test_add_duplicate_prompt(self, caplog):
|
| 26 |
"""Test adding the same prompt twice."""
|
| 27 |
|
| 28 |
def fn() -> str:
|
|
|
|
| 35 |
assert first == second
|
| 36 |
assert "Prompt already exists" in caplog.text
|
| 37 |
|
| 38 |
+
async def test_disable_warn_on_duplicate_prompts(self, caplog):
|
| 39 |
"""Test disabling warning on duplicate prompts."""
|
| 40 |
|
| 41 |
def fn() -> str:
|
|
|
|
| 48 |
assert first == second
|
| 49 |
assert "Prompt already exists" not in caplog.text
|
| 50 |
|
| 51 |
+
async def test_warn_on_duplicate_prompts(self, caplog):
|
| 52 |
"""Test warning on duplicate prompts."""
|
| 53 |
manager = PromptManager(duplicate_behavior="warn")
|
| 54 |
|
|
|
|
| 62 |
|
| 63 |
assert "Prompt already exists: test_prompt" in caplog.text
|
| 64 |
# Should have the prompt
|
| 65 |
+
assert await manager.get_prompt("test_prompt") is not None
|
| 66 |
|
| 67 |
+
async def test_error_on_duplicate_prompts(self):
|
| 68 |
"""Test error on duplicate prompts."""
|
| 69 |
manager = PromptManager(duplicate_behavior="error")
|
| 70 |
|
|
|
|
| 78 |
with pytest.raises(ValueError, match="Prompt already exists: test_prompt"):
|
| 79 |
manager.add_prompt(prompt)
|
| 80 |
|
| 81 |
+
async def test_replace_duplicate_prompts(self):
|
| 82 |
"""Test replacing duplicate prompts."""
|
| 83 |
manager = PromptManager(duplicate_behavior="replace")
|
| 84 |
|
|
|
|
| 95 |
manager.add_prompt(prompt2)
|
| 96 |
|
| 97 |
# Should have replaced with the new prompt
|
| 98 |
+
prompt = await manager.get_prompt("test_prompt")
|
| 99 |
assert prompt is not None
|
| 100 |
assert isinstance(prompt, FunctionPrompt)
|
| 101 |
assert prompt.fn.__name__ == "replacement_fn"
|
| 102 |
|
| 103 |
+
async def test_ignore_duplicate_prompts(self):
|
| 104 |
"""Test ignoring duplicate prompts."""
|
| 105 |
manager = PromptManager(duplicate_behavior="ignore")
|
| 106 |
|
|
|
|
| 117 |
result = manager.add_prompt(prompt2)
|
| 118 |
|
| 119 |
# Should keep the original
|
| 120 |
+
prompt = await manager.get_prompt("test_prompt")
|
| 121 |
assert prompt is not None
|
| 122 |
assert isinstance(prompt, FunctionPrompt)
|
| 123 |
assert prompt.fn.__name__ == "original_fn"
|
|
|
|
| 125 |
assert isinstance(result, FunctionPrompt)
|
| 126 |
assert result.fn.__name__ == "original_fn"
|
| 127 |
|
| 128 |
+
async def test_get_prompts(self):
|
| 129 |
"""Test retrieving all prompts."""
|
| 130 |
|
| 131 |
def fn1() -> str:
|
|
|
|
| 139 |
prompt2 = Prompt.from_function(fn2)
|
| 140 |
manager.add_prompt(prompt1)
|
| 141 |
manager.add_prompt(prompt2)
|
| 142 |
+
prompts = await manager.get_prompts()
|
| 143 |
assert len(prompts) == 2
|
| 144 |
assert prompts["fn1"] == prompt1
|
| 145 |
assert prompts["fn2"] == prompt2
|
|
|
|
| 270 |
class TestPromptTags:
|
| 271 |
"""Test functionality related to prompt tags."""
|
| 272 |
|
| 273 |
+
async def test_add_prompt_with_tags(self):
|
| 274 |
"""Test adding a prompt with tags."""
|
| 275 |
|
| 276 |
def greeting() -> str:
|
|
|
|
| 280 |
prompt = Prompt.from_function(greeting, tags={"greeting", "simple"})
|
| 281 |
manager.add_prompt(prompt)
|
| 282 |
|
| 283 |
+
prompt = await manager.get_prompt("greeting")
|
| 284 |
assert prompt is not None
|
| 285 |
assert prompt.tags == {"greeting", "simple"}
|
| 286 |
|
| 287 |
+
async def test_add_prompt_with_empty_tags(self):
|
| 288 |
"""Test adding a prompt with empty tags."""
|
| 289 |
|
| 290 |
def greeting() -> str:
|
|
|
|
| 294 |
prompt = Prompt.from_function(greeting, tags=set())
|
| 295 |
manager.add_prompt(prompt)
|
| 296 |
|
| 297 |
+
prompt = await manager.get_prompt("greeting")
|
| 298 |
assert prompt is not None
|
| 299 |
assert prompt.tags == set()
|
| 300 |
|
| 301 |
+
async def test_add_prompt_with_none_tags(self):
|
| 302 |
"""Test adding a prompt with None tags."""
|
| 303 |
|
| 304 |
def greeting() -> str:
|
|
|
|
| 308 |
prompt = Prompt.from_function(greeting, tags=None)
|
| 309 |
manager.add_prompt(prompt)
|
| 310 |
|
| 311 |
+
prompt = await manager.get_prompt("greeting")
|
| 312 |
assert prompt is not None
|
| 313 |
assert prompt.tags == set()
|
| 314 |
|
| 315 |
+
async def test_list_prompts_with_tags(self):
|
| 316 |
"""Test listing prompts with specific tags."""
|
| 317 |
|
| 318 |
def greeting() -> str:
|
|
|
|
| 332 |
)
|
| 333 |
|
| 334 |
# Filter prompts by tags
|
| 335 |
+
prompts = await manager.get_prompts()
|
| 336 |
+
simple_prompts = [p for p in prompts.values() if "simple" in p.tags]
|
|
|
|
| 337 |
assert len(simple_prompts) == 2
|
| 338 |
assert {p.name for p in simple_prompts} == {"greeting", "summary"}
|
| 339 |
|
| 340 |
+
nlp_prompts = [p for p in prompts.values() if "nlp" in p.tags]
|
| 341 |
assert len(nlp_prompts) == 1
|
| 342 |
assert nlp_prompts[0].name == "summary"
|
| 343 |
|
tests/resources/test_resource_manager.py
CHANGED
|
@@ -33,7 +33,7 @@ def temp_file():
|
|
| 33 |
class TestResourceManager:
|
| 34 |
"""Test ResourceManager functionality."""
|
| 35 |
|
| 36 |
-
def test_add_resource(self, temp_file: Path):
|
| 37 |
"""Test adding a resource."""
|
| 38 |
manager = ResourceManager()
|
| 39 |
file_url = "file://test-resource"
|
|
@@ -45,10 +45,11 @@ class TestResourceManager:
|
|
| 45 |
added = manager.add_resource(resource)
|
| 46 |
assert added == resource
|
| 47 |
# Get the actual key from the resource manager
|
| 48 |
-
|
| 49 |
-
assert
|
|
|
|
| 50 |
|
| 51 |
-
def test_add_duplicate_resource(self, temp_file: Path):
|
| 52 |
"""Test adding the same resource twice."""
|
| 53 |
manager = ResourceManager()
|
| 54 |
file_url = "file://test-resource"
|
|
@@ -61,10 +62,11 @@ class TestResourceManager:
|
|
| 61 |
second = manager.add_resource(resource)
|
| 62 |
assert first == second
|
| 63 |
# Check the resource is there
|
| 64 |
-
|
| 65 |
-
assert
|
|
|
|
| 66 |
|
| 67 |
-
def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
|
| 68 |
"""Test warning on duplicate resources."""
|
| 69 |
manager = ResourceManager(duplicate_behavior="warn")
|
| 70 |
|
|
@@ -80,10 +82,11 @@ class TestResourceManager:
|
|
| 80 |
|
| 81 |
assert "Resource already exists" in caplog.text
|
| 82 |
# Should have the resource
|
| 83 |
-
|
| 84 |
-
assert
|
|
|
|
| 85 |
|
| 86 |
-
def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
|
| 87 |
"""Test disabling warning on duplicate resources."""
|
| 88 |
manager = ResourceManager(duplicate_behavior="ignore")
|
| 89 |
resource = FileResource(
|
|
@@ -95,7 +98,7 @@ class TestResourceManager:
|
|
| 95 |
manager.add_resource(resource)
|
| 96 |
assert "Resource already exists" not in caplog.text
|
| 97 |
|
| 98 |
-
def test_error_on_duplicate_resources(self, temp_file: Path):
|
| 99 |
"""Test error on duplicate resources."""
|
| 100 |
manager = ResourceManager(duplicate_behavior="error")
|
| 101 |
|
|
@@ -110,7 +113,7 @@ class TestResourceManager:
|
|
| 110 |
with pytest.raises(ValueError, match="Resource already exists"):
|
| 111 |
manager.add_resource(resource)
|
| 112 |
|
| 113 |
-
def test_replace_duplicate_resources(self, temp_file: Path):
|
| 114 |
"""Test replacing duplicate resources."""
|
| 115 |
manager = ResourceManager(duplicate_behavior="replace")
|
| 116 |
|
|
@@ -131,11 +134,12 @@ class TestResourceManager:
|
|
| 131 |
manager.add_resource(resource2)
|
| 132 |
|
| 133 |
# Should have replaced with the new resource
|
| 134 |
-
resources =
|
| 135 |
-
|
| 136 |
-
assert
|
|
|
|
| 137 |
|
| 138 |
-
def test_ignore_duplicate_resources(self, temp_file: Path):
|
| 139 |
"""Test ignoring duplicate resources."""
|
| 140 |
manager = ResourceManager(duplicate_behavior="ignore")
|
| 141 |
|
|
@@ -156,13 +160,14 @@ class TestResourceManager:
|
|
| 156 |
result = manager.add_resource(resource2)
|
| 157 |
|
| 158 |
# Should keep the original
|
| 159 |
-
resources =
|
| 160 |
-
|
| 161 |
-
assert
|
|
|
|
| 162 |
# Result should be the original resource
|
| 163 |
assert result.name == "original"
|
| 164 |
|
| 165 |
-
def test_warn_on_duplicate_templates(self, caplog):
|
| 166 |
"""Test warning on duplicate templates."""
|
| 167 |
manager = ResourceManager(duplicate_behavior="warn")
|
| 168 |
|
|
@@ -180,9 +185,10 @@ class TestResourceManager:
|
|
| 180 |
|
| 181 |
assert "Template already exists" in caplog.text
|
| 182 |
# Should have the template
|
| 183 |
-
|
|
|
|
| 184 |
|
| 185 |
-
def test_error_on_duplicate_templates(self):
|
| 186 |
"""Test error on duplicate templates."""
|
| 187 |
manager = ResourceManager(duplicate_behavior="error")
|
| 188 |
|
|
@@ -200,7 +206,7 @@ class TestResourceManager:
|
|
| 200 |
with pytest.raises(ValueError, match="Template already exists"):
|
| 201 |
manager.add_template(template)
|
| 202 |
|
| 203 |
-
def test_replace_duplicate_templates(self):
|
| 204 |
"""Test replacing duplicate templates."""
|
| 205 |
manager = ResourceManager(duplicate_behavior="replace")
|
| 206 |
|
|
@@ -226,11 +232,12 @@ class TestResourceManager:
|
|
| 226 |
manager.add_template(template2)
|
| 227 |
|
| 228 |
# Should have replaced with the new template
|
| 229 |
-
|
|
|
|
| 230 |
assert len(templates) == 1
|
| 231 |
assert templates[0].name == "replacement"
|
| 232 |
|
| 233 |
-
def test_ignore_duplicate_templates(self):
|
| 234 |
"""Test ignoring duplicate templates."""
|
| 235 |
manager = ResourceManager(duplicate_behavior="ignore")
|
| 236 |
|
|
@@ -256,7 +263,8 @@ class TestResourceManager:
|
|
| 256 |
result = manager.add_template(template2)
|
| 257 |
|
| 258 |
# Should keep the original
|
| 259 |
-
|
|
|
|
| 260 |
assert len(templates) == 1
|
| 261 |
assert templates[0].name == "original"
|
| 262 |
# Result should be the original template
|
|
@@ -299,7 +307,7 @@ class TestResourceManager:
|
|
| 299 |
with pytest.raises(NotFoundError, match="Unknown resource"):
|
| 300 |
await manager.get_resource(AnyUrl("unknown://test"))
|
| 301 |
|
| 302 |
-
def test_get_resources(self, temp_file: Path):
|
| 303 |
"""Test retrieving all resources."""
|
| 304 |
manager = ResourceManager()
|
| 305 |
file_url1 = "file://test-resource1"
|
|
@@ -316,7 +324,7 @@ class TestResourceManager:
|
|
| 316 |
)
|
| 317 |
manager.add_resource(resource1)
|
| 318 |
manager.add_resource(resource2)
|
| 319 |
-
resources = manager.get_resources()
|
| 320 |
assert len(resources) == 2
|
| 321 |
values = list(resources.values())
|
| 322 |
assert resource1 in values
|
|
@@ -326,7 +334,7 @@ class TestResourceManager:
|
|
| 326 |
class TestResourceTags:
|
| 327 |
"""Test functionality related to resource tags."""
|
| 328 |
|
| 329 |
-
def test_add_resource_with_tags(self, temp_file: Path):
|
| 330 |
"""Test adding a resource with tags."""
|
| 331 |
manager = ResourceManager()
|
| 332 |
resource = FileResource(
|
|
@@ -338,11 +346,12 @@ class TestResourceTags:
|
|
| 338 |
manager.add_resource(resource)
|
| 339 |
|
| 340 |
# Check that tags are preserved
|
| 341 |
-
|
|
|
|
| 342 |
assert len(resources) == 1
|
| 343 |
assert resources[0].tags == {"weather", "data"}
|
| 344 |
|
| 345 |
-
def test_add_function_resource_with_tags(self):
|
| 346 |
"""Test adding a function resource with tags."""
|
| 347 |
manager = ResourceManager()
|
| 348 |
|
|
@@ -359,11 +368,12 @@ class TestResourceTags:
|
|
| 359 |
)
|
| 360 |
|
| 361 |
manager.add_resource(resource)
|
| 362 |
-
|
|
|
|
| 363 |
assert len(resources) == 1
|
| 364 |
assert resources[0].tags == {"sample", "test", "data"}
|
| 365 |
|
| 366 |
-
def test_add_template_with_tags(self):
|
| 367 |
"""Test adding a resource template with tags."""
|
| 368 |
manager = ResourceManager()
|
| 369 |
|
|
@@ -379,11 +389,12 @@ class TestResourceTags:
|
|
| 379 |
)
|
| 380 |
|
| 381 |
manager.add_template(template)
|
| 382 |
-
|
|
|
|
| 383 |
assert len(templates) == 1
|
| 384 |
assert templates[0].tags == {"users", "template", "data"}
|
| 385 |
|
| 386 |
-
def test_filter_resources_by_tags(self, temp_file: Path):
|
| 387 |
"""Test filtering resources by tags."""
|
| 388 |
manager = ResourceManager()
|
| 389 |
|
|
@@ -392,7 +403,7 @@ class TestResourceTags:
|
|
| 392 |
uri=FileUrl("file://weather-data"),
|
| 393 |
name="weather_data",
|
| 394 |
path=temp_file,
|
| 395 |
-
tags={"weather", "
|
| 396 |
)
|
| 397 |
|
| 398 |
async def get_user_data():
|
|
@@ -401,8 +412,10 @@ class TestResourceTags:
|
|
| 401 |
resource2 = FunctionResource(
|
| 402 |
uri=AnyUrl("data://users"),
|
| 403 |
name="user_data",
|
|
|
|
|
|
|
| 404 |
fn=get_user_data,
|
| 405 |
-
tags={"users", "
|
| 406 |
)
|
| 407 |
|
| 408 |
async def get_system_data():
|
|
@@ -411,26 +424,25 @@ class TestResourceTags:
|
|
| 411 |
resource3 = FunctionResource(
|
| 412 |
uri=AnyUrl("data://system"),
|
| 413 |
name="system_data",
|
|
|
|
|
|
|
| 414 |
fn=get_system_data,
|
| 415 |
-
tags={"system", "
|
| 416 |
)
|
| 417 |
|
| 418 |
manager.add_resource(resource1)
|
| 419 |
manager.add_resource(resource2)
|
| 420 |
manager.add_resource(resource3)
|
| 421 |
|
| 422 |
-
# Filter
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
assert
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
]
|
| 432 |
-
assert len(external_resources) == 1
|
| 433 |
-
assert external_resources[0].name == "weather_data"
|
| 434 |
|
| 435 |
|
| 436 |
class TestCustomResourceKeys:
|
|
|
|
| 33 |
class TestResourceManager:
|
| 34 |
"""Test ResourceManager functionality."""
|
| 35 |
|
| 36 |
+
async def test_add_resource(self, temp_file: Path):
|
| 37 |
"""Test adding a resource."""
|
| 38 |
manager = ResourceManager()
|
| 39 |
file_url = "file://test-resource"
|
|
|
|
| 45 |
added = manager.add_resource(resource)
|
| 46 |
assert added == resource
|
| 47 |
# Get the actual key from the resource manager
|
| 48 |
+
resources = await manager.get_resources()
|
| 49 |
+
assert len(resources) == 1
|
| 50 |
+
assert resource in resources.values()
|
| 51 |
|
| 52 |
+
async def test_add_duplicate_resource(self, temp_file: Path):
|
| 53 |
"""Test adding the same resource twice."""
|
| 54 |
manager = ResourceManager()
|
| 55 |
file_url = "file://test-resource"
|
|
|
|
| 62 |
second = manager.add_resource(resource)
|
| 63 |
assert first == second
|
| 64 |
# Check the resource is there
|
| 65 |
+
resources = await manager.get_resources()
|
| 66 |
+
assert len(resources) == 1
|
| 67 |
+
assert resource in resources.values()
|
| 68 |
|
| 69 |
+
async def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
|
| 70 |
"""Test warning on duplicate resources."""
|
| 71 |
manager = ResourceManager(duplicate_behavior="warn")
|
| 72 |
|
|
|
|
| 82 |
|
| 83 |
assert "Resource already exists" in caplog.text
|
| 84 |
# Should have the resource
|
| 85 |
+
resources = await manager.get_resources()
|
| 86 |
+
assert len(resources) == 1
|
| 87 |
+
assert resource in resources.values()
|
| 88 |
|
| 89 |
+
async def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
|
| 90 |
"""Test disabling warning on duplicate resources."""
|
| 91 |
manager = ResourceManager(duplicate_behavior="ignore")
|
| 92 |
resource = FileResource(
|
|
|
|
| 98 |
manager.add_resource(resource)
|
| 99 |
assert "Resource already exists" not in caplog.text
|
| 100 |
|
| 101 |
+
async def test_error_on_duplicate_resources(self, temp_file: Path):
|
| 102 |
"""Test error on duplicate resources."""
|
| 103 |
manager = ResourceManager(duplicate_behavior="error")
|
| 104 |
|
|
|
|
| 113 |
with pytest.raises(ValueError, match="Resource already exists"):
|
| 114 |
manager.add_resource(resource)
|
| 115 |
|
| 116 |
+
async def test_replace_duplicate_resources(self, temp_file: Path):
|
| 117 |
"""Test replacing duplicate resources."""
|
| 118 |
manager = ResourceManager(duplicate_behavior="replace")
|
| 119 |
|
|
|
|
| 134 |
manager.add_resource(resource2)
|
| 135 |
|
| 136 |
# Should have replaced with the new resource
|
| 137 |
+
resources = await manager.get_resources()
|
| 138 |
+
resource_list = list(resources.values())
|
| 139 |
+
assert len(resource_list) == 1
|
| 140 |
+
assert resource_list[0].name == "replacement"
|
| 141 |
|
| 142 |
+
async def test_ignore_duplicate_resources(self, temp_file: Path):
|
| 143 |
"""Test ignoring duplicate resources."""
|
| 144 |
manager = ResourceManager(duplicate_behavior="ignore")
|
| 145 |
|
|
|
|
| 160 |
result = manager.add_resource(resource2)
|
| 161 |
|
| 162 |
# Should keep the original
|
| 163 |
+
resources = await manager.get_resources()
|
| 164 |
+
resource_list = list(resources.values())
|
| 165 |
+
assert len(resource_list) == 1
|
| 166 |
+
assert resource_list[0].name == "original"
|
| 167 |
# Result should be the original resource
|
| 168 |
assert result.name == "original"
|
| 169 |
|
| 170 |
+
async def test_warn_on_duplicate_templates(self, caplog):
|
| 171 |
"""Test warning on duplicate templates."""
|
| 172 |
manager = ResourceManager(duplicate_behavior="warn")
|
| 173 |
|
|
|
|
| 185 |
|
| 186 |
assert "Template already exists" in caplog.text
|
| 187 |
# Should have the template
|
| 188 |
+
templates = await manager.get_resource_templates()
|
| 189 |
+
assert templates == {"test://{id}": template}
|
| 190 |
|
| 191 |
+
async def test_error_on_duplicate_templates(self):
|
| 192 |
"""Test error on duplicate templates."""
|
| 193 |
manager = ResourceManager(duplicate_behavior="error")
|
| 194 |
|
|
|
|
| 206 |
with pytest.raises(ValueError, match="Template already exists"):
|
| 207 |
manager.add_template(template)
|
| 208 |
|
| 209 |
+
async def test_replace_duplicate_templates(self):
|
| 210 |
"""Test replacing duplicate templates."""
|
| 211 |
manager = ResourceManager(duplicate_behavior="replace")
|
| 212 |
|
|
|
|
| 232 |
manager.add_template(template2)
|
| 233 |
|
| 234 |
# Should have replaced with the new template
|
| 235 |
+
templates_dict = await manager.get_resource_templates()
|
| 236 |
+
templates = list(templates_dict.values())
|
| 237 |
assert len(templates) == 1
|
| 238 |
assert templates[0].name == "replacement"
|
| 239 |
|
| 240 |
+
async def test_ignore_duplicate_templates(self):
|
| 241 |
"""Test ignoring duplicate templates."""
|
| 242 |
manager = ResourceManager(duplicate_behavior="ignore")
|
| 243 |
|
|
|
|
| 263 |
result = manager.add_template(template2)
|
| 264 |
|
| 265 |
# Should keep the original
|
| 266 |
+
templates_dict = await manager.get_resource_templates()
|
| 267 |
+
templates = list(templates_dict.values())
|
| 268 |
assert len(templates) == 1
|
| 269 |
assert templates[0].name == "original"
|
| 270 |
# Result should be the original template
|
|
|
|
| 307 |
with pytest.raises(NotFoundError, match="Unknown resource"):
|
| 308 |
await manager.get_resource(AnyUrl("unknown://test"))
|
| 309 |
|
| 310 |
+
async def test_get_resources(self, temp_file: Path):
|
| 311 |
"""Test retrieving all resources."""
|
| 312 |
manager = ResourceManager()
|
| 313 |
file_url1 = "file://test-resource1"
|
|
|
|
| 324 |
)
|
| 325 |
manager.add_resource(resource1)
|
| 326 |
manager.add_resource(resource2)
|
| 327 |
+
resources = await manager.get_resources()
|
| 328 |
assert len(resources) == 2
|
| 329 |
values = list(resources.values())
|
| 330 |
assert resource1 in values
|
|
|
|
| 334 |
class TestResourceTags:
|
| 335 |
"""Test functionality related to resource tags."""
|
| 336 |
|
| 337 |
+
async def test_add_resource_with_tags(self, temp_file: Path):
|
| 338 |
"""Test adding a resource with tags."""
|
| 339 |
manager = ResourceManager()
|
| 340 |
resource = FileResource(
|
|
|
|
| 346 |
manager.add_resource(resource)
|
| 347 |
|
| 348 |
# Check that tags are preserved
|
| 349 |
+
resources_dict = await manager.get_resources()
|
| 350 |
+
resources = list(resources_dict.values())
|
| 351 |
assert len(resources) == 1
|
| 352 |
assert resources[0].tags == {"weather", "data"}
|
| 353 |
|
| 354 |
+
async def test_add_function_resource_with_tags(self):
|
| 355 |
"""Test adding a function resource with tags."""
|
| 356 |
manager = ResourceManager()
|
| 357 |
|
|
|
|
| 368 |
)
|
| 369 |
|
| 370 |
manager.add_resource(resource)
|
| 371 |
+
resources_dict = await manager.get_resources()
|
| 372 |
+
resources = list(resources_dict.values())
|
| 373 |
assert len(resources) == 1
|
| 374 |
assert resources[0].tags == {"sample", "test", "data"}
|
| 375 |
|
| 376 |
+
async def test_add_template_with_tags(self):
|
| 377 |
"""Test adding a resource template with tags."""
|
| 378 |
manager = ResourceManager()
|
| 379 |
|
|
|
|
| 389 |
)
|
| 390 |
|
| 391 |
manager.add_template(template)
|
| 392 |
+
templates_dict = await manager.get_resource_templates()
|
| 393 |
+
templates = list(templates_dict.values())
|
| 394 |
assert len(templates) == 1
|
| 395 |
assert templates[0].tags == {"users", "template", "data"}
|
| 396 |
|
| 397 |
+
async def test_filter_resources_by_tags(self, temp_file: Path):
|
| 398 |
"""Test filtering resources by tags."""
|
| 399 |
manager = ResourceManager()
|
| 400 |
|
|
|
|
| 403 |
uri=FileUrl("file://weather-data"),
|
| 404 |
name="weather_data",
|
| 405 |
path=temp_file,
|
| 406 |
+
tags={"weather", "data"},
|
| 407 |
)
|
| 408 |
|
| 409 |
async def get_user_data():
|
|
|
|
| 412 |
resource2 = FunctionResource(
|
| 413 |
uri=AnyUrl("data://users"),
|
| 414 |
name="user_data",
|
| 415 |
+
description="User data resource",
|
| 416 |
+
mime_type="text/plain",
|
| 417 |
fn=get_user_data,
|
| 418 |
+
tags={"users", "data"},
|
| 419 |
)
|
| 420 |
|
| 421 |
async def get_system_data():
|
|
|
|
| 424 |
resource3 = FunctionResource(
|
| 425 |
uri=AnyUrl("data://system"),
|
| 426 |
name="system_data",
|
| 427 |
+
description="System data resource",
|
| 428 |
+
mime_type="text/plain",
|
| 429 |
fn=get_system_data,
|
| 430 |
+
tags={"system", "admin"},
|
| 431 |
)
|
| 432 |
|
| 433 |
manager.add_resource(resource1)
|
| 434 |
manager.add_resource(resource2)
|
| 435 |
manager.add_resource(resource3)
|
| 436 |
|
| 437 |
+
# Filter by tags
|
| 438 |
+
resources_dict = await manager.get_resources()
|
| 439 |
+
data_resources = [r for r in resources_dict.values() if "data" in r.tags]
|
| 440 |
+
assert len(data_resources) == 2
|
| 441 |
+
assert {r.name for r in data_resources} == {"weather_data", "user_data"}
|
| 442 |
+
|
| 443 |
+
admin_resources = [r for r in resources_dict.values() if "admin" in r.tags]
|
| 444 |
+
assert len(admin_resources) == 1
|
| 445 |
+
assert admin_resources[0].name == "system_data"
|
|
|
|
|
|
|
|
|
|
| 446 |
|
| 447 |
|
| 448 |
class TestCustomResourceKeys:
|
tests/resources/test_resource_template.py
CHANGED
|
@@ -405,6 +405,7 @@ class TestMatchUriTemplate:
|
|
| 405 |
("test://a/b/c", None),
|
| 406 |
("test://a/x/b", {"x": "x"}),
|
| 407 |
("test://a/x/y/b", None),
|
|
|
|
| 408 |
],
|
| 409 |
)
|
| 410 |
def test_match_uri_template_single_param(
|
|
|
|
| 405 |
("test://a/b/c", None),
|
| 406 |
("test://a/x/b", {"x": "x"}),
|
| 407 |
("test://a/x/y/b", None),
|
| 408 |
+
("test://a/1-2/b", {"x": "1-2"}),
|
| 409 |
],
|
| 410 |
)
|
| 411 |
def test_match_uri_template_single_param(
|
tests/server/openapi/test_openapi.py
CHANGED
|
@@ -136,7 +136,7 @@ def api_client(fastapi_app: FastAPI) -> AsyncClient:
|
|
| 136 |
|
| 137 |
|
| 138 |
@pytest.fixture
|
| 139 |
-
async def
|
| 140 |
fastapi_app: FastAPI, api_client: httpx.AsyncClient
|
| 141 |
) -> FastMCPOpenAPI:
|
| 142 |
openapi_spec = fastapi_app.openapi()
|
|
@@ -213,13 +213,11 @@ class TestTools:
|
|
| 213 |
assert len(await server.get_resources()) == 0
|
| 214 |
assert len(await server.get_resource_templates()) == 0
|
| 215 |
|
| 216 |
-
async def test_list_tools(
|
| 217 |
-
self, fastmcp_openapi_server_with_all_types: FastMCPOpenAPI
|
| 218 |
-
):
|
| 219 |
"""
|
| 220 |
By default, tools exclude GET methods
|
| 221 |
"""
|
| 222 |
-
async with Client(
|
| 223 |
tools = await client.list_tools()
|
| 224 |
assert len(tools) == 2
|
| 225 |
|
|
@@ -254,13 +252,13 @@ class TestTools:
|
|
| 254 |
|
| 255 |
async def test_call_create_user_tool(
|
| 256 |
self,
|
| 257 |
-
|
| 258 |
api_client,
|
| 259 |
):
|
| 260 |
"""
|
| 261 |
The tool created by the OpenAPI server should be the same as the original
|
| 262 |
"""
|
| 263 |
-
async with Client(
|
| 264 |
tool_response = await client.call_tool(
|
| 265 |
"create_user_users_post", {"name": "David", "active": False}
|
| 266 |
)
|
|
@@ -274,7 +272,7 @@ class TestTools:
|
|
| 274 |
assert len(response.json()) == 4
|
| 275 |
|
| 276 |
# Check that the user was created via MCP
|
| 277 |
-
async with Client(
|
| 278 |
user_response = await client.read_resource("resource://get_user_users/4")
|
| 279 |
response_text = user_response[0].text # type: ignore[attr-defined]
|
| 280 |
user = json.loads(response_text)
|
|
@@ -282,13 +280,13 @@ class TestTools:
|
|
| 282 |
|
| 283 |
async def test_call_update_user_name_tool(
|
| 284 |
self,
|
| 285 |
-
|
| 286 |
api_client,
|
| 287 |
):
|
| 288 |
"""
|
| 289 |
The tool created by the OpenAPI server should be the same as the original
|
| 290 |
"""
|
| 291 |
-
async with Client(
|
| 292 |
tool_response = await client.call_tool(
|
| 293 |
"update_user_name_users",
|
| 294 |
{"user_id": 1, "name": "XYZ"},
|
|
@@ -303,7 +301,7 @@ class TestTools:
|
|
| 303 |
assert expected_data in response.json()
|
| 304 |
|
| 305 |
# Check that the user was updated via MCP
|
| 306 |
-
async with Client(
|
| 307 |
user_response = await client.read_resource("resource://get_user_users/1")
|
| 308 |
response_text = user_response[0].text # type: ignore[attr-defined]
|
| 309 |
user = json.loads(response_text)
|
|
@@ -335,13 +333,11 @@ class TestTools:
|
|
| 335 |
|
| 336 |
|
| 337 |
class TestResources:
|
| 338 |
-
async def test_list_resources(
|
| 339 |
-
self, fastmcp_openapi_server_with_all_types: FastMCPOpenAPI
|
| 340 |
-
):
|
| 341 |
"""
|
| 342 |
By default, resources exclude GET methods without parameters
|
| 343 |
"""
|
| 344 |
-
async with Client(
|
| 345 |
resources = await client.list_resources()
|
| 346 |
assert len(resources) == 4
|
| 347 |
assert resources[0].uri == AnyUrl("resource://get_users_users_get")
|
|
@@ -349,7 +345,7 @@ class TestResources:
|
|
| 349 |
|
| 350 |
async def test_get_resource(
|
| 351 |
self,
|
| 352 |
-
|
| 353 |
api_client,
|
| 354 |
users_db: dict[int, User],
|
| 355 |
):
|
|
@@ -360,7 +356,7 @@ class TestResources:
|
|
| 360 |
json_users = TypeAdapter(list[User]).dump_python(
|
| 361 |
sorted(users_db.values(), key=lambda x: x.id)
|
| 362 |
)
|
| 363 |
-
async with Client(
|
| 364 |
resource_response = await client.read_resource(
|
| 365 |
"resource://get_users_users_get"
|
| 366 |
)
|
|
@@ -372,11 +368,11 @@ class TestResources:
|
|
| 372 |
|
| 373 |
async def test_get_bytes_resource(
|
| 374 |
self,
|
| 375 |
-
|
| 376 |
api_client,
|
| 377 |
):
|
| 378 |
"""Test reading a resource that returns bytes."""
|
| 379 |
-
async with Client(
|
| 380 |
resource_response = await client.read_resource(
|
| 381 |
"resource://ping_bytes_ping_bytes_get"
|
| 382 |
)
|
|
@@ -385,23 +381,23 @@ class TestResources:
|
|
| 385 |
|
| 386 |
async def test_get_str_resource(
|
| 387 |
self,
|
| 388 |
-
|
| 389 |
api_client,
|
| 390 |
):
|
| 391 |
"""Test reading a resource that returns a string."""
|
| 392 |
-
async with Client(
|
| 393 |
resource_response = await client.read_resource("resource://ping_ping_get")
|
| 394 |
assert resource_response[0].text == "pong" # type: ignore[attr-defined]
|
| 395 |
|
| 396 |
|
| 397 |
class TestResourceTemplates:
|
| 398 |
async def test_list_resource_templates(
|
| 399 |
-
self,
|
| 400 |
):
|
| 401 |
"""
|
| 402 |
By default, resource templates exclude GET methods without parameters
|
| 403 |
"""
|
| 404 |
-
async with Client(
|
| 405 |
resource_templates = await client.list_resource_templates()
|
| 406 |
assert len(resource_templates) == 2
|
| 407 |
assert resource_templates[0].name == "get_user_users"
|
|
@@ -416,7 +412,7 @@ class TestResourceTemplates:
|
|
| 416 |
|
| 417 |
async def test_get_resource_template(
|
| 418 |
self,
|
| 419 |
-
|
| 420 |
api_client,
|
| 421 |
users_db: dict[int, User],
|
| 422 |
):
|
|
@@ -424,7 +420,7 @@ class TestResourceTemplates:
|
|
| 424 |
The resource template created by the OpenAPI server should be the same as the original
|
| 425 |
"""
|
| 426 |
user_id = 2
|
| 427 |
-
async with Client(
|
| 428 |
resource_response = await client.read_resource(
|
| 429 |
f"resource://get_user_users/{user_id}"
|
| 430 |
)
|
|
@@ -437,7 +433,7 @@ class TestResourceTemplates:
|
|
| 437 |
|
| 438 |
async def test_get_resource_template_multi_param(
|
| 439 |
self,
|
| 440 |
-
|
| 441 |
api_client,
|
| 442 |
users_db: dict[int, User],
|
| 443 |
):
|
|
@@ -446,7 +442,7 @@ class TestResourceTemplates:
|
|
| 446 |
"""
|
| 447 |
user_id = 2
|
| 448 |
is_active = True
|
| 449 |
-
async with Client(
|
| 450 |
resource_response = await client.read_resource(
|
| 451 |
f"resource://get_user_active_state_users/{is_active}/{user_id}"
|
| 452 |
)
|
|
@@ -459,13 +455,11 @@ class TestResourceTemplates:
|
|
| 459 |
|
| 460 |
|
| 461 |
class TestPrompts:
|
| 462 |
-
async def test_list_prompts(
|
| 463 |
-
self, fastmcp_openapi_server_with_all_types: FastMCPOpenAPI
|
| 464 |
-
):
|
| 465 |
"""
|
| 466 |
By default, there are no prompts.
|
| 467 |
"""
|
| 468 |
-
async with Client(
|
| 469 |
prompts = await client.list_prompts()
|
| 470 |
assert len(prompts) == 0
|
| 471 |
|
|
@@ -474,11 +468,11 @@ class TestTagTransfer:
|
|
| 474 |
"""Tests for transferring tags from OpenAPI routes to MCP objects."""
|
| 475 |
|
| 476 |
async def test_tags_transferred_to_tools(
|
| 477 |
-
self,
|
| 478 |
):
|
| 479 |
"""Test that tags from OpenAPI routes are correctly transferred to Tools."""
|
| 480 |
# Get internal tools directly (not the public API which returns MCP.Content)
|
| 481 |
-
tools =
|
| 482 |
|
| 483 |
# Find the create_user and update_user_name tools
|
| 484 |
create_user_tool = next(
|
|
@@ -502,13 +496,12 @@ class TestTagTransfer:
|
|
| 502 |
assert len(update_user_tool.tags) == 2
|
| 503 |
|
| 504 |
async def test_tags_transferred_to_resources(
|
| 505 |
-
self,
|
| 506 |
):
|
| 507 |
"""Test that tags from OpenAPI routes are correctly transferred to Resources."""
|
| 508 |
# Get internal resources directly
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
)
|
| 512 |
|
| 513 |
# Find the get_users resource
|
| 514 |
get_users_resource = next(
|
|
@@ -523,13 +516,14 @@ class TestTagTransfer:
|
|
| 523 |
assert len(get_users_resource.tags) == 2
|
| 524 |
|
| 525 |
async def test_tags_transferred_to_resource_templates(
|
| 526 |
-
self,
|
| 527 |
):
|
| 528 |
"""Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
|
| 529 |
# Get internal resource templates directly
|
| 530 |
-
|
| 531 |
-
|
| 532 |
)
|
|
|
|
| 533 |
|
| 534 |
# Find the get_user template
|
| 535 |
get_user_template = next(
|
|
@@ -544,13 +538,14 @@ class TestTagTransfer:
|
|
| 544 |
assert len(get_user_template.tags) == 2
|
| 545 |
|
| 546 |
async def test_tags_preserved_in_resources_created_from_templates(
|
| 547 |
-
self,
|
| 548 |
):
|
| 549 |
"""Test that tags are preserved when creating resources from templates."""
|
| 550 |
# Get internal resource templates directly
|
| 551 |
-
|
| 552 |
-
|
| 553 |
)
|
|
|
|
| 554 |
|
| 555 |
# Find the get_user template
|
| 556 |
get_user_template = next(
|
|
@@ -1167,7 +1162,7 @@ class TestDescriptionPropagation:
|
|
| 1167 |
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
| 1168 |
|
| 1169 |
@pytest.fixture
|
| 1170 |
-
async def
|
| 1171 |
"""Create a FastMCPOpenAPI server with the simple test spec."""
|
| 1172 |
return FastMCPOpenAPI(
|
| 1173 |
openapi_spec=simple_openapi_spec,
|
|
@@ -1179,11 +1174,11 @@ class TestDescriptionPropagation:
|
|
| 1179 |
# --- RESOURCE TESTS ---
|
| 1180 |
|
| 1181 |
async def test_resource_includes_route_description(
|
| 1182 |
-
self,
|
| 1183 |
):
|
| 1184 |
"""Test that a Resource includes the route description."""
|
| 1185 |
resources = list(
|
| 1186 |
-
|
| 1187 |
)
|
| 1188 |
list_resource = next((r for r in resources if r.name == "listItems"), None)
|
| 1189 |
|
|
@@ -1193,11 +1188,11 @@ class TestDescriptionPropagation:
|
|
| 1193 |
)
|
| 1194 |
|
| 1195 |
async def test_resource_includes_response_description(
|
| 1196 |
-
self,
|
| 1197 |
):
|
| 1198 |
"""Test that a Resource includes the response description."""
|
| 1199 |
resources = list(
|
| 1200 |
-
|
| 1201 |
)
|
| 1202 |
list_resource = next((r for r in resources if r.name == "listItems"), None)
|
| 1203 |
|
|
@@ -1207,11 +1202,11 @@ class TestDescriptionPropagation:
|
|
| 1207 |
)
|
| 1208 |
|
| 1209 |
async def test_resource_includes_response_model_fields(
|
| 1210 |
-
self,
|
| 1211 |
):
|
| 1212 |
"""Test that a Resource description includes response model field descriptions."""
|
| 1213 |
resources = list(
|
| 1214 |
-
|
| 1215 |
)
|
| 1216 |
list_resource = next((r for r in resources if r.name == "listItems"), None)
|
| 1217 |
|
|
@@ -1230,12 +1225,13 @@ class TestDescriptionPropagation:
|
|
| 1230 |
# --- RESOURCE TEMPLATE TESTS ---
|
| 1231 |
|
| 1232 |
async def test_template_includes_route_description(
|
| 1233 |
-
self,
|
| 1234 |
):
|
| 1235 |
"""Test that a ResourceTemplate includes the route description."""
|
| 1236 |
-
|
| 1237 |
-
|
| 1238 |
)
|
|
|
|
| 1239 |
get_template = next((t for t in templates if t.name == "getItem"), None)
|
| 1240 |
|
| 1241 |
assert get_template is not None, "getItem template wasn't created"
|
|
@@ -1244,12 +1240,13 @@ class TestDescriptionPropagation:
|
|
| 1244 |
)
|
| 1245 |
|
| 1246 |
async def test_template_includes_function_docstring(
|
| 1247 |
-
self,
|
| 1248 |
):
|
| 1249 |
"""Test that a ResourceTemplate includes the function docstring."""
|
| 1250 |
-
|
| 1251 |
-
|
| 1252 |
)
|
|
|
|
| 1253 |
get_template = next((t for t in templates if t.name == "getItem"), None)
|
| 1254 |
|
| 1255 |
assert get_template is not None, "getItem template wasn't created"
|
|
@@ -1258,12 +1255,13 @@ class TestDescriptionPropagation:
|
|
| 1258 |
)
|
| 1259 |
|
| 1260 |
async def test_template_includes_path_parameter_description(
|
| 1261 |
-
self,
|
| 1262 |
):
|
| 1263 |
"""Test that a ResourceTemplate includes path parameter descriptions."""
|
| 1264 |
-
|
| 1265 |
-
|
| 1266 |
)
|
|
|
|
| 1267 |
get_template = next((t for t in templates if t.name == "getItem"), None)
|
| 1268 |
|
| 1269 |
assert get_template is not None, "getItem template wasn't created"
|
|
@@ -1272,12 +1270,13 @@ class TestDescriptionPropagation:
|
|
| 1272 |
)
|
| 1273 |
|
| 1274 |
async def test_template_includes_query_parameter_description(
|
| 1275 |
-
self,
|
| 1276 |
):
|
| 1277 |
"""Test that a ResourceTemplate includes query parameter descriptions."""
|
| 1278 |
-
|
| 1279 |
-
|
| 1280 |
)
|
|
|
|
| 1281 |
get_template = next((t for t in templates if t.name == "getItem"), None)
|
| 1282 |
|
| 1283 |
assert get_template is not None, "getItem template wasn't created"
|
|
@@ -1286,12 +1285,13 @@ class TestDescriptionPropagation:
|
|
| 1286 |
)
|
| 1287 |
|
| 1288 |
async def test_template_parameter_schema_includes_description(
|
| 1289 |
-
self,
|
| 1290 |
):
|
| 1291 |
"""Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
|
| 1292 |
-
|
| 1293 |
-
|
| 1294 |
)
|
|
|
|
| 1295 |
get_template = next((t for t in templates if t.name == "getItem"), None)
|
| 1296 |
|
| 1297 |
assert get_template is not None, "getItem template wasn't created"
|
|
@@ -1311,9 +1311,10 @@ class TestDescriptionPropagation:
|
|
| 1311 |
|
| 1312 |
# --- TOOL TESTS ---
|
| 1313 |
|
| 1314 |
-
async def test_tool_includes_route_description(self,
|
| 1315 |
"""Test that a Tool includes the route description."""
|
| 1316 |
-
|
|
|
|
| 1317 |
create_tool = next((t for t in tools if t.name == "createItem"), None)
|
| 1318 |
|
| 1319 |
assert create_tool is not None, "createItem tool wasn't created"
|
|
@@ -1321,9 +1322,10 @@ class TestDescriptionPropagation:
|
|
| 1321 |
"Route description missing from Tool"
|
| 1322 |
)
|
| 1323 |
|
| 1324 |
-
async def test_tool_includes_function_docstring(self,
|
| 1325 |
"""Test that a Tool includes the function docstring."""
|
| 1326 |
-
|
|
|
|
| 1327 |
create_tool = next((t for t in tools if t.name == "createItem"), None)
|
| 1328 |
|
| 1329 |
assert create_tool is not None, "createItem tool wasn't created"
|
|
@@ -1333,10 +1335,11 @@ class TestDescriptionPropagation:
|
|
| 1333 |
)
|
| 1334 |
|
| 1335 |
async def test_tool_parameter_schema_includes_property_description(
|
| 1336 |
-
self,
|
| 1337 |
):
|
| 1338 |
"""Test that a Tool's parameter schema includes property descriptions from request model."""
|
| 1339 |
-
|
|
|
|
| 1340 |
create_tool = next((t for t in tools if t.name == "createItem"), None)
|
| 1341 |
|
| 1342 |
assert create_tool is not None, "createItem tool wasn't created"
|
|
@@ -1356,9 +1359,9 @@ class TestDescriptionPropagation:
|
|
| 1356 |
|
| 1357 |
# --- CLIENT API TESTS ---
|
| 1358 |
|
| 1359 |
-
async def test_client_api_resource_description(self,
|
| 1360 |
"""Test that Resource descriptions are accessible via the client API."""
|
| 1361 |
-
async with Client(
|
| 1362 |
resources = await client.list_resources()
|
| 1363 |
list_resource = next((r for r in resources if r.name == "listItems"), None)
|
| 1364 |
|
|
@@ -1370,9 +1373,9 @@ class TestDescriptionPropagation:
|
|
| 1370 |
"Route description missing in Resource from client API"
|
| 1371 |
)
|
| 1372 |
|
| 1373 |
-
async def test_client_api_template_description(self,
|
| 1374 |
"""Test that ResourceTemplate descriptions are accessible via the client API."""
|
| 1375 |
-
async with Client(
|
| 1376 |
templates = await client.list_resource_templates()
|
| 1377 |
get_template = next((t for t in templates if t.name == "getItem"), None)
|
| 1378 |
|
|
@@ -1384,9 +1387,9 @@ class TestDescriptionPropagation:
|
|
| 1384 |
"Route description missing in ResourceTemplate from client API"
|
| 1385 |
)
|
| 1386 |
|
| 1387 |
-
async def test_client_api_tool_description(self,
|
| 1388 |
"""Test that Tool descriptions are accessible via the client API."""
|
| 1389 |
-
async with Client(
|
| 1390 |
tools = await client.list_tools()
|
| 1391 |
create_tool = next((t for t in tools if t.name == "createItem"), None)
|
| 1392 |
|
|
@@ -1398,9 +1401,9 @@ class TestDescriptionPropagation:
|
|
| 1398 |
"Function docstring missing in Tool from client API"
|
| 1399 |
)
|
| 1400 |
|
| 1401 |
-
async def test_client_api_tool_parameter_schema(self,
|
| 1402 |
"""Test that Tool parameter schemas are accessible via the client API."""
|
| 1403 |
-
async with Client(
|
| 1404 |
tools = await client.list_tools()
|
| 1405 |
create_tool = next((t for t in tools if t.name == "createItem"), None)
|
| 1406 |
|
|
@@ -1533,22 +1536,26 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1533 |
|
| 1534 |
# Debug: print all components created
|
| 1535 |
print("\nDEBUG - Resources created:")
|
| 1536 |
-
|
|
|
|
| 1537 |
print(f" Resource: {name}, Name attribute: {resource.name}")
|
| 1538 |
|
| 1539 |
print("\nDEBUG - Templates created:")
|
| 1540 |
-
|
|
|
|
| 1541 |
print(f" Template: {name}, Name attribute: {template.name}")
|
| 1542 |
|
| 1543 |
print("\nDEBUG - Tools created:")
|
| 1544 |
-
|
|
|
|
| 1545 |
print(f" Tool: {tool.name}")
|
| 1546 |
|
| 1547 |
return server
|
| 1548 |
|
| 1549 |
-
async def test_resource_includes_function_docstring(self, fastapi_server):
|
| 1550 |
"""Test that a Resource includes the function docstring."""
|
| 1551 |
-
|
|
|
|
| 1552 |
|
| 1553 |
# Now checking for the get_items operation ID rather than list_items
|
| 1554 |
list_resource = next((r for r in resources if "items_get" in r.name), None)
|
|
@@ -1559,13 +1566,16 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1559 |
"Function docstring missing from Resource"
|
| 1560 |
)
|
| 1561 |
|
| 1562 |
-
async def test_resource_includes_response_model_fields(
|
|
|
|
|
|
|
| 1563 |
"""Test that a Resource description includes basic response information.
|
| 1564 |
|
| 1565 |
Note: FastAPI doesn't reliably include Pydantic field descriptions in the OpenAPI schema,
|
| 1566 |
so we can only check for basic response information being present.
|
| 1567 |
"""
|
| 1568 |
-
|
|
|
|
| 1569 |
list_resource = next((r for r in resources if "items_get" in r.name), None)
|
| 1570 |
|
| 1571 |
assert list_resource is not None, "GET /items resource wasn't created"
|
|
@@ -1579,9 +1589,10 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1579 |
# We've already verified in TestDescriptionPropagation that when descriptions
|
| 1580 |
# are present in the OpenAPI schema, they are properly included in the component description
|
| 1581 |
|
| 1582 |
-
async def test_template_includes_function_docstring(self, fastapi_server):
|
| 1583 |
"""Test that a ResourceTemplate includes the function docstring."""
|
| 1584 |
-
|
|
|
|
| 1585 |
get_template = next((t for t in templates if "get_item_items" in t.name), None)
|
| 1586 |
|
| 1587 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
|
@@ -1590,13 +1601,16 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1590 |
"Function docstring missing from ResourceTemplate"
|
| 1591 |
)
|
| 1592 |
|
| 1593 |
-
async def test_template_includes_path_parameter_description(
|
|
|
|
|
|
|
| 1594 |
"""Test that a ResourceTemplate includes path parameter descriptions.
|
| 1595 |
|
| 1596 |
Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)]
|
| 1597 |
are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
|
| 1598 |
"""
|
| 1599 |
-
|
|
|
|
| 1600 |
get_template = next((t for t in templates if "get_item_items" in t.name), None)
|
| 1601 |
|
| 1602 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
|
@@ -1610,13 +1624,16 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1610 |
"item_id parameter missing from ResourceTemplate description"
|
| 1611 |
)
|
| 1612 |
|
| 1613 |
-
async def test_template_includes_query_parameter_description(
|
|
|
|
|
|
|
| 1614 |
"""Test that a ResourceTemplate includes query parameter descriptions.
|
| 1615 |
|
| 1616 |
Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)]
|
| 1617 |
are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
|
| 1618 |
"""
|
| 1619 |
-
|
|
|
|
| 1620 |
get_template = next((t for t in templates if "get_item_items" in t.name), None)
|
| 1621 |
|
| 1622 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
|
@@ -1630,9 +1647,12 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1630 |
"fields parameter missing from ResourceTemplate description"
|
| 1631 |
)
|
| 1632 |
|
| 1633 |
-
async def test_template_parameter_schema_includes_description(
|
|
|
|
|
|
|
| 1634 |
"""Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
|
| 1635 |
-
|
|
|
|
| 1636 |
get_template = next((t for t in templates if "get_item_items" in t.name), None)
|
| 1637 |
|
| 1638 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
|
@@ -1650,9 +1670,10 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1650 |
in get_template.parameters["properties"]["item_id"]["description"]
|
| 1651 |
), "Path parameter description incorrect in schema"
|
| 1652 |
|
| 1653 |
-
async def test_tool_includes_function_docstring(self, fastapi_server):
|
| 1654 |
"""Test that a Tool includes the function docstring."""
|
| 1655 |
-
|
|
|
|
| 1656 |
create_tool = next(
|
| 1657 |
(t for t in tools if "create_item_items_post" == t.name), None
|
| 1658 |
)
|
|
@@ -1664,7 +1685,7 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1664 |
)
|
| 1665 |
|
| 1666 |
async def test_tool_parameter_schema_includes_property_description(
|
| 1667 |
-
self, fastapi_server
|
| 1668 |
):
|
| 1669 |
"""Test that a Tool's parameter schema includes property descriptions from request model.
|
| 1670 |
|
|
@@ -1672,7 +1693,8 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1672 |
may not be consistently propagated into the FastAPI OpenAPI schema and thus not into the tool's
|
| 1673 |
parameter schema.
|
| 1674 |
"""
|
| 1675 |
-
|
|
|
|
| 1676 |
create_tool = next(
|
| 1677 |
(t for t in tools if "create_item_items_post" == t.name), None
|
| 1678 |
)
|
|
@@ -1686,7 +1708,7 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1686 |
)
|
| 1687 |
# We don't test for the description field content as it may not be consistently propagated
|
| 1688 |
|
| 1689 |
-
async def test_client_api_resource_description(self, fastapi_server):
|
| 1690 |
"""Test that Resource descriptions are accessible via the client API."""
|
| 1691 |
async with Client(fastapi_server) as client:
|
| 1692 |
resources = await client.list_resources()
|
|
@@ -1700,7 +1722,7 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1700 |
"Function docstring missing in Resource from client API"
|
| 1701 |
)
|
| 1702 |
|
| 1703 |
-
async def test_client_api_template_description(self, fastapi_server):
|
| 1704 |
"""Test that ResourceTemplate descriptions are accessible via the client API."""
|
| 1705 |
async with Client(fastapi_server) as client:
|
| 1706 |
templates = await client.list_resource_templates()
|
|
@@ -1716,7 +1738,7 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1716 |
"Function docstring missing in ResourceTemplate from client API"
|
| 1717 |
)
|
| 1718 |
|
| 1719 |
-
async def test_client_api_tool_description(self, fastapi_server):
|
| 1720 |
"""Test that Tool descriptions are accessible via the client API."""
|
| 1721 |
async with Client(fastapi_server) as client:
|
| 1722 |
tools = await client.list_tools()
|
|
@@ -1732,7 +1754,7 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1732 |
"Function docstring missing in Tool from client API"
|
| 1733 |
)
|
| 1734 |
|
| 1735 |
-
async def test_client_api_tool_parameter_schema(self, fastapi_server):
|
| 1736 |
"""Test that Tool parameter schemas are accessible via the client API."""
|
| 1737 |
async with Client(fastapi_server) as client:
|
| 1738 |
tools = await client.list_tools()
|
|
@@ -1755,11 +1777,9 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1755 |
class TestReprMethods:
|
| 1756 |
"""Tests for the custom __repr__ methods of OpenAPI objects."""
|
| 1757 |
|
| 1758 |
-
async def test_openapi_tool_repr(
|
| 1759 |
-
self, fastmcp_openapi_server_with_all_types: FastMCPOpenAPI
|
| 1760 |
-
):
|
| 1761 |
"""Test that OpenAPITool's __repr__ method works without recursion errors."""
|
| 1762 |
-
tools =
|
| 1763 |
tool = next(iter(tools))
|
| 1764 |
|
| 1765 |
# Verify repr doesn't cause recursion and contains expected elements
|
|
@@ -1769,13 +1789,10 @@ class TestReprMethods:
|
|
| 1769 |
assert "method=" in tool_repr
|
| 1770 |
assert "path=" in tool_repr
|
| 1771 |
|
| 1772 |
-
async def test_openapi_resource_repr(
|
| 1773 |
-
self, fastmcp_openapi_server_with_all_types: FastMCPOpenAPI
|
| 1774 |
-
):
|
| 1775 |
"""Test that OpenAPIResource's __repr__ method works without recursion errors."""
|
| 1776 |
-
|
| 1777 |
-
|
| 1778 |
-
)
|
| 1779 |
resource = next(iter(resources))
|
| 1780 |
|
| 1781 |
# Verify repr doesn't cause recursion and contains expected elements
|
|
@@ -1786,12 +1803,13 @@ class TestReprMethods:
|
|
| 1786 |
assert "path=" in resource_repr
|
| 1787 |
|
| 1788 |
async def test_openapi_resource_template_repr(
|
| 1789 |
-
self,
|
| 1790 |
):
|
| 1791 |
"""Test that OpenAPIResourceTemplate's __repr__ method works without recursion errors."""
|
| 1792 |
-
|
| 1793 |
-
|
| 1794 |
)
|
|
|
|
| 1795 |
template = next(iter(templates))
|
| 1796 |
|
| 1797 |
# Verify repr doesn't cause recursion and contains expected elements
|
|
@@ -1836,7 +1854,7 @@ class TestEnumHandling:
|
|
| 1836 |
)
|
| 1837 |
|
| 1838 |
# Get the tools from the server
|
| 1839 |
-
tools = server._tool_manager._list_tools()
|
| 1840 |
|
| 1841 |
# Find the read_item tool
|
| 1842 |
read_item_tool = next((t for t in tools if t.name == "read_item_items"), None)
|
|
@@ -1929,7 +1947,7 @@ class TestRouteMapWildcard:
|
|
| 1929 |
)
|
| 1930 |
|
| 1931 |
# All operations should be mapped to tools
|
| 1932 |
-
tools = mcp._tool_manager._list_tools()
|
| 1933 |
tool_names = {tool.name for tool in tools}
|
| 1934 |
|
| 1935 |
# Check that all 4 operations became tools
|
|
@@ -2007,11 +2025,11 @@ class TestRouteMapTags:
|
|
| 2007 |
)
|
| 2008 |
|
| 2009 |
# Check that admin-tagged routes are tools
|
| 2010 |
-
|
| 2011 |
-
tool_names = {t.name for t in
|
| 2012 |
|
| 2013 |
-
|
| 2014 |
-
resource_names = {r.name for r in
|
| 2015 |
|
| 2016 |
# Routes with "admin" tag should be tools
|
| 2017 |
assert "createUser" in tool_names
|
|
@@ -2040,11 +2058,11 @@ class TestRouteMapTags:
|
|
| 2040 |
)
|
| 2041 |
|
| 2042 |
# Check that internal-tagged routes are excluded
|
| 2043 |
-
|
| 2044 |
-
resource_names = {r.name for r in
|
| 2045 |
|
| 2046 |
-
|
| 2047 |
-
tool_names = {t.name for t in
|
| 2048 |
|
| 2049 |
# Internal-tagged route should be excluded
|
| 2050 |
assert "getAdminStats" not in resource_names
|
|
@@ -2075,11 +2093,11 @@ class TestRouteMapTags:
|
|
| 2075 |
route_maps=route_maps,
|
| 2076 |
)
|
| 2077 |
|
| 2078 |
-
|
| 2079 |
-
tool_names = {t.name for t in
|
| 2080 |
|
| 2081 |
-
|
| 2082 |
-
resource_names = {r.name for r in
|
| 2083 |
|
| 2084 |
# Only createUser has both "users" AND "admin" tags
|
| 2085 |
assert "createUser" in tool_names
|
|
@@ -2110,11 +2128,11 @@ class TestRouteMapTags:
|
|
| 2110 |
route_maps=route_maps,
|
| 2111 |
)
|
| 2112 |
|
| 2113 |
-
|
| 2114 |
-
tool_names = {t.name for t in
|
| 2115 |
|
| 2116 |
-
|
| 2117 |
-
resource_names = {r.name for r in
|
| 2118 |
|
| 2119 |
# Only getAdminStats matches both /admin/ pattern AND "admin" tag
|
| 2120 |
assert "getAdminStats" in tool_names
|
|
@@ -2140,8 +2158,8 @@ class TestRouteMapTags:
|
|
| 2140 |
route_maps=route_maps,
|
| 2141 |
)
|
| 2142 |
|
| 2143 |
-
|
| 2144 |
-
tool_names = {t.name for t in
|
| 2145 |
|
| 2146 |
# All routes should be tools since empty tags matches everything
|
| 2147 |
expected_tools = {
|
|
@@ -2246,18 +2264,18 @@ class TestMCPNames:
|
|
| 2246 |
)
|
| 2247 |
|
| 2248 |
# Check tools use custom names
|
| 2249 |
-
tools = server._tool_manager._list_tools()
|
| 2250 |
tool_names = {tool.name for tool in tools}
|
| 2251 |
assert "admin_create_user" in tool_names
|
| 2252 |
|
| 2253 |
# Check resource templates use custom names
|
| 2254 |
-
|
| 2255 |
-
template_names = {template.name for template in
|
| 2256 |
assert "user_detail" in template_names
|
| 2257 |
|
| 2258 |
# Check resources use custom names
|
| 2259 |
-
|
| 2260 |
-
resource_names = {resource.name for resource in
|
| 2261 |
assert "user_list" in resource_names
|
| 2262 |
|
| 2263 |
async def test_mcp_names_fallback_to_operation_id_short(
|
|
@@ -2276,14 +2294,14 @@ class TestMCPNames:
|
|
| 2276 |
route_maps=GET_ROUTE_MAPS,
|
| 2277 |
)
|
| 2278 |
|
| 2279 |
-
tools = server._tool_manager._list_tools()
|
| 2280 |
tool_names = {tool.name for tool in tools}
|
| 2281 |
|
| 2282 |
-
|
| 2283 |
-
template_names = {template.name for template in
|
| 2284 |
|
| 2285 |
-
|
| 2286 |
-
resource_names = {resource.name for resource in
|
| 2287 |
|
| 2288 |
# Custom mapped name should be used
|
| 2289 |
assert "custom_user_list" in resource_names
|
|
@@ -2300,9 +2318,11 @@ class TestMCPNames:
|
|
| 2300 |
route_maps=GET_ROUTE_MAPS,
|
| 2301 |
)
|
| 2302 |
|
| 2303 |
-
|
| 2304 |
resource_names = {
|
| 2305 |
-
resource.name
|
|
|
|
|
|
|
| 2306 |
}
|
| 2307 |
|
| 2308 |
# Special chars and spaces should be slugified
|
|
@@ -2330,14 +2350,14 @@ class TestMCPNames:
|
|
| 2330 |
# Check all component types
|
| 2331 |
all_names = []
|
| 2332 |
|
| 2333 |
-
tools = server._tool_manager._list_tools()
|
| 2334 |
all_names.extend(tool.name for tool in tools)
|
| 2335 |
|
| 2336 |
-
|
| 2337 |
-
all_names.extend(resource.name for resource in
|
| 2338 |
|
| 2339 |
-
|
| 2340 |
-
all_names.extend(template.name for template in
|
| 2341 |
|
| 2342 |
# All names should be 56 characters or less
|
| 2343 |
for name in all_names:
|
|
@@ -2363,7 +2383,7 @@ class TestMCPNames:
|
|
| 2363 |
mcp_names=mcp_names,
|
| 2364 |
)
|
| 2365 |
|
| 2366 |
-
tools = server._tool_manager._list_tools()
|
| 2367 |
tool_names = {tool.name for tool in tools}
|
| 2368 |
assert "openapi_user_list" in tool_names
|
| 2369 |
|
|
@@ -2395,7 +2415,7 @@ class TestMCPNames:
|
|
| 2395 |
mcp_names=mcp_names,
|
| 2396 |
)
|
| 2397 |
|
| 2398 |
-
tools = server._tool_manager._list_tools()
|
| 2399 |
tool_names = {tool.name for tool in tools}
|
| 2400 |
|
| 2401 |
assert "fastapi_create_user" in tool_names
|
|
@@ -2419,9 +2439,11 @@ class TestMCPNames:
|
|
| 2419 |
route_maps=GET_ROUTE_MAPS,
|
| 2420 |
)
|
| 2421 |
|
| 2422 |
-
|
| 2423 |
resource_names = {
|
| 2424 |
-
resource.name
|
|
|
|
|
|
|
| 2425 |
}
|
| 2426 |
|
| 2427 |
# Find the resource that should have the custom name
|
|
@@ -2496,7 +2518,7 @@ class TestRouteMapMCPTags:
|
|
| 2496 |
)
|
| 2497 |
|
| 2498 |
# Get the POST tool
|
| 2499 |
-
tools = server._tool_manager._list_tools()
|
| 2500 |
create_user_tool = next((t for t in tools if "create_user" in t.name), None)
|
| 2501 |
|
| 2502 |
assert create_user_tool is not None, "create_user tool not found"
|
|
@@ -2530,7 +2552,8 @@ class TestRouteMapMCPTags:
|
|
| 2530 |
)
|
| 2531 |
|
| 2532 |
# Get the resource
|
| 2533 |
-
|
|
|
|
| 2534 |
get_users_resource = next((r for r in resources if "get_users" in r.name), None)
|
| 2535 |
|
| 2536 |
assert get_users_resource is not None, "get_users resource not found"
|
|
@@ -2564,7 +2587,8 @@ class TestRouteMapMCPTags:
|
|
| 2564 |
)
|
| 2565 |
|
| 2566 |
# Get the resource template
|
| 2567 |
-
|
|
|
|
| 2568 |
get_user_template = next((t for t in templates if "get_user" in t.name), None)
|
| 2569 |
|
| 2570 |
assert get_user_template is not None, "get_user template not found"
|
|
@@ -2610,21 +2634,23 @@ class TestRouteMapMCPTags:
|
|
| 2610 |
)
|
| 2611 |
|
| 2612 |
# Check tool tags
|
| 2613 |
-
tools = server._tool_manager._list_tools()
|
| 2614 |
create_tool = next((t for t in tools if "create_user" in t.name), None)
|
| 2615 |
assert create_tool is not None
|
| 2616 |
assert "write-operation" in create_tool.tags
|
| 2617 |
assert "mutation" in create_tool.tags
|
| 2618 |
|
| 2619 |
# Check resource template tags
|
| 2620 |
-
|
|
|
|
| 2621 |
detail_template = next((t for t in templates if "get_user" in t.name), None)
|
| 2622 |
assert detail_template is not None
|
| 2623 |
assert "detail" in detail_template.tags
|
| 2624 |
assert "single-item" in detail_template.tags
|
| 2625 |
|
| 2626 |
# Check resource tags
|
| 2627 |
-
|
|
|
|
| 2628 |
list_resource = next((r for r in resources if "get_users" in r.name), None)
|
| 2629 |
assert list_resource is not None
|
| 2630 |
assert "list" in list_resource.tags
|
|
|
|
| 136 |
|
| 137 |
|
| 138 |
@pytest.fixture
|
| 139 |
+
async def fastmcp_openapi_server(
|
| 140 |
fastapi_app: FastAPI, api_client: httpx.AsyncClient
|
| 141 |
) -> FastMCPOpenAPI:
|
| 142 |
openapi_spec = fastapi_app.openapi()
|
|
|
|
| 213 |
assert len(await server.get_resources()) == 0
|
| 214 |
assert len(await server.get_resource_templates()) == 0
|
| 215 |
|
| 216 |
+
async def test_list_tools(self, fastmcp_openapi_server: FastMCPOpenAPI):
|
|
|
|
|
|
|
| 217 |
"""
|
| 218 |
By default, tools exclude GET methods
|
| 219 |
"""
|
| 220 |
+
async with Client(fastmcp_openapi_server) as client:
|
| 221 |
tools = await client.list_tools()
|
| 222 |
assert len(tools) == 2
|
| 223 |
|
|
|
|
| 252 |
|
| 253 |
async def test_call_create_user_tool(
|
| 254 |
self,
|
| 255 |
+
fastmcp_openapi_server: FastMCPOpenAPI,
|
| 256 |
api_client,
|
| 257 |
):
|
| 258 |
"""
|
| 259 |
The tool created by the OpenAPI server should be the same as the original
|
| 260 |
"""
|
| 261 |
+
async with Client(fastmcp_openapi_server) as client:
|
| 262 |
tool_response = await client.call_tool(
|
| 263 |
"create_user_users_post", {"name": "David", "active": False}
|
| 264 |
)
|
|
|
|
| 272 |
assert len(response.json()) == 4
|
| 273 |
|
| 274 |
# Check that the user was created via MCP
|
| 275 |
+
async with Client(fastmcp_openapi_server) as client:
|
| 276 |
user_response = await client.read_resource("resource://get_user_users/4")
|
| 277 |
response_text = user_response[0].text # type: ignore[attr-defined]
|
| 278 |
user = json.loads(response_text)
|
|
|
|
| 280 |
|
| 281 |
async def test_call_update_user_name_tool(
|
| 282 |
self,
|
| 283 |
+
fastmcp_openapi_server: FastMCPOpenAPI,
|
| 284 |
api_client,
|
| 285 |
):
|
| 286 |
"""
|
| 287 |
The tool created by the OpenAPI server should be the same as the original
|
| 288 |
"""
|
| 289 |
+
async with Client(fastmcp_openapi_server) as client:
|
| 290 |
tool_response = await client.call_tool(
|
| 291 |
"update_user_name_users",
|
| 292 |
{"user_id": 1, "name": "XYZ"},
|
|
|
|
| 301 |
assert expected_data in response.json()
|
| 302 |
|
| 303 |
# Check that the user was updated via MCP
|
| 304 |
+
async with Client(fastmcp_openapi_server) as client:
|
| 305 |
user_response = await client.read_resource("resource://get_user_users/1")
|
| 306 |
response_text = user_response[0].text # type: ignore[attr-defined]
|
| 307 |
user = json.loads(response_text)
|
|
|
|
| 333 |
|
| 334 |
|
| 335 |
class TestResources:
|
| 336 |
+
async def test_list_resources(self, fastmcp_openapi_server: FastMCPOpenAPI):
|
|
|
|
|
|
|
| 337 |
"""
|
| 338 |
By default, resources exclude GET methods without parameters
|
| 339 |
"""
|
| 340 |
+
async with Client(fastmcp_openapi_server) as client:
|
| 341 |
resources = await client.list_resources()
|
| 342 |
assert len(resources) == 4
|
| 343 |
assert resources[0].uri == AnyUrl("resource://get_users_users_get")
|
|
|
|
| 345 |
|
| 346 |
async def test_get_resource(
|
| 347 |
self,
|
| 348 |
+
fastmcp_openapi_server: FastMCPOpenAPI,
|
| 349 |
api_client,
|
| 350 |
users_db: dict[int, User],
|
| 351 |
):
|
|
|
|
| 356 |
json_users = TypeAdapter(list[User]).dump_python(
|
| 357 |
sorted(users_db.values(), key=lambda x: x.id)
|
| 358 |
)
|
| 359 |
+
async with Client(fastmcp_openapi_server) as client:
|
| 360 |
resource_response = await client.read_resource(
|
| 361 |
"resource://get_users_users_get"
|
| 362 |
)
|
|
|
|
| 368 |
|
| 369 |
async def test_get_bytes_resource(
|
| 370 |
self,
|
| 371 |
+
fastmcp_openapi_server: FastMCPOpenAPI,
|
| 372 |
api_client,
|
| 373 |
):
|
| 374 |
"""Test reading a resource that returns bytes."""
|
| 375 |
+
async with Client(fastmcp_openapi_server) as client:
|
| 376 |
resource_response = await client.read_resource(
|
| 377 |
"resource://ping_bytes_ping_bytes_get"
|
| 378 |
)
|
|
|
|
| 381 |
|
| 382 |
async def test_get_str_resource(
|
| 383 |
self,
|
| 384 |
+
fastmcp_openapi_server: FastMCPOpenAPI,
|
| 385 |
api_client,
|
| 386 |
):
|
| 387 |
"""Test reading a resource that returns a string."""
|
| 388 |
+
async with Client(fastmcp_openapi_server) as client:
|
| 389 |
resource_response = await client.read_resource("resource://ping_ping_get")
|
| 390 |
assert resource_response[0].text == "pong" # type: ignore[attr-defined]
|
| 391 |
|
| 392 |
|
| 393 |
class TestResourceTemplates:
|
| 394 |
async def test_list_resource_templates(
|
| 395 |
+
self, fastmcp_openapi_server: FastMCPOpenAPI
|
| 396 |
):
|
| 397 |
"""
|
| 398 |
By default, resource templates exclude GET methods without parameters
|
| 399 |
"""
|
| 400 |
+
async with Client(fastmcp_openapi_server) as client:
|
| 401 |
resource_templates = await client.list_resource_templates()
|
| 402 |
assert len(resource_templates) == 2
|
| 403 |
assert resource_templates[0].name == "get_user_users"
|
|
|
|
| 412 |
|
| 413 |
async def test_get_resource_template(
|
| 414 |
self,
|
| 415 |
+
fastmcp_openapi_server: FastMCPOpenAPI,
|
| 416 |
api_client,
|
| 417 |
users_db: dict[int, User],
|
| 418 |
):
|
|
|
|
| 420 |
The resource template created by the OpenAPI server should be the same as the original
|
| 421 |
"""
|
| 422 |
user_id = 2
|
| 423 |
+
async with Client(fastmcp_openapi_server) as client:
|
| 424 |
resource_response = await client.read_resource(
|
| 425 |
f"resource://get_user_users/{user_id}"
|
| 426 |
)
|
|
|
|
| 433 |
|
| 434 |
async def test_get_resource_template_multi_param(
|
| 435 |
self,
|
| 436 |
+
fastmcp_openapi_server: FastMCPOpenAPI,
|
| 437 |
api_client,
|
| 438 |
users_db: dict[int, User],
|
| 439 |
):
|
|
|
|
| 442 |
"""
|
| 443 |
user_id = 2
|
| 444 |
is_active = True
|
| 445 |
+
async with Client(fastmcp_openapi_server) as client:
|
| 446 |
resource_response = await client.read_resource(
|
| 447 |
f"resource://get_user_active_state_users/{is_active}/{user_id}"
|
| 448 |
)
|
|
|
|
| 455 |
|
| 456 |
|
| 457 |
class TestPrompts:
|
| 458 |
+
async def test_list_prompts(self, fastmcp_openapi_server: FastMCPOpenAPI):
|
|
|
|
|
|
|
| 459 |
"""
|
| 460 |
By default, there are no prompts.
|
| 461 |
"""
|
| 462 |
+
async with Client(fastmcp_openapi_server) as client:
|
| 463 |
prompts = await client.list_prompts()
|
| 464 |
assert len(prompts) == 0
|
| 465 |
|
|
|
|
| 468 |
"""Tests for transferring tags from OpenAPI routes to MCP objects."""
|
| 469 |
|
| 470 |
async def test_tags_transferred_to_tools(
|
| 471 |
+
self, fastmcp_openapi_server: FastMCPOpenAPI
|
| 472 |
):
|
| 473 |
"""Test that tags from OpenAPI routes are correctly transferred to Tools."""
|
| 474 |
# Get internal tools directly (not the public API which returns MCP.Content)
|
| 475 |
+
tools = await fastmcp_openapi_server._tool_manager._list_tools()
|
| 476 |
|
| 477 |
# Find the create_user and update_user_name tools
|
| 478 |
create_user_tool = next(
|
|
|
|
| 496 |
assert len(update_user_tool.tags) == 2
|
| 497 |
|
| 498 |
async def test_tags_transferred_to_resources(
|
| 499 |
+
self, fastmcp_openapi_server: FastMCPOpenAPI
|
| 500 |
):
|
| 501 |
"""Test that tags from OpenAPI routes are correctly transferred to Resources."""
|
| 502 |
# Get internal resources directly
|
| 503 |
+
resources_dict = await fastmcp_openapi_server._resource_manager.get_resources()
|
| 504 |
+
resources = list(resources_dict.values())
|
|
|
|
| 505 |
|
| 506 |
# Find the get_users resource
|
| 507 |
get_users_resource = next(
|
|
|
|
| 516 |
assert len(get_users_resource.tags) == 2
|
| 517 |
|
| 518 |
async def test_tags_transferred_to_resource_templates(
|
| 519 |
+
self, fastmcp_openapi_server: FastMCPOpenAPI
|
| 520 |
):
|
| 521 |
"""Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
|
| 522 |
# Get internal resource templates directly
|
| 523 |
+
templates_dict = (
|
| 524 |
+
await fastmcp_openapi_server._resource_manager.get_resource_templates()
|
| 525 |
)
|
| 526 |
+
templates = list(templates_dict.values())
|
| 527 |
|
| 528 |
# Find the get_user template
|
| 529 |
get_user_template = next(
|
|
|
|
| 538 |
assert len(get_user_template.tags) == 2
|
| 539 |
|
| 540 |
async def test_tags_preserved_in_resources_created_from_templates(
|
| 541 |
+
self, fastmcp_openapi_server: FastMCPOpenAPI
|
| 542 |
):
|
| 543 |
"""Test that tags are preserved when creating resources from templates."""
|
| 544 |
# Get internal resource templates directly
|
| 545 |
+
templates_dict = (
|
| 546 |
+
await fastmcp_openapi_server._resource_manager.get_resource_templates()
|
| 547 |
)
|
| 548 |
+
templates = list(templates_dict.values())
|
| 549 |
|
| 550 |
# Find the get_user template
|
| 551 |
get_user_template = next(
|
|
|
|
| 1162 |
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
| 1163 |
|
| 1164 |
@pytest.fixture
|
| 1165 |
+
async def simple_mcp_server(self, simple_openapi_spec, mock_client):
|
| 1166 |
"""Create a FastMCPOpenAPI server with the simple test spec."""
|
| 1167 |
return FastMCPOpenAPI(
|
| 1168 |
openapi_spec=simple_openapi_spec,
|
|
|
|
| 1174 |
# --- RESOURCE TESTS ---
|
| 1175 |
|
| 1176 |
async def test_resource_includes_route_description(
|
| 1177 |
+
self, simple_mcp_server: FastMCP
|
| 1178 |
):
|
| 1179 |
"""Test that a Resource includes the route description."""
|
| 1180 |
resources = list(
|
| 1181 |
+
(await simple_mcp_server._resource_manager.get_resources()).values()
|
| 1182 |
)
|
| 1183 |
list_resource = next((r for r in resources if r.name == "listItems"), None)
|
| 1184 |
|
|
|
|
| 1188 |
)
|
| 1189 |
|
| 1190 |
async def test_resource_includes_response_description(
|
| 1191 |
+
self, simple_mcp_server: FastMCP
|
| 1192 |
):
|
| 1193 |
"""Test that a Resource includes the response description."""
|
| 1194 |
resources = list(
|
| 1195 |
+
(await simple_mcp_server._resource_manager.get_resources()).values()
|
| 1196 |
)
|
| 1197 |
list_resource = next((r for r in resources if r.name == "listItems"), None)
|
| 1198 |
|
|
|
|
| 1202 |
)
|
| 1203 |
|
| 1204 |
async def test_resource_includes_response_model_fields(
|
| 1205 |
+
self, simple_mcp_server: FastMCP
|
| 1206 |
):
|
| 1207 |
"""Test that a Resource description includes response model field descriptions."""
|
| 1208 |
resources = list(
|
| 1209 |
+
(await simple_mcp_server._resource_manager.get_resources()).values()
|
| 1210 |
)
|
| 1211 |
list_resource = next((r for r in resources if r.name == "listItems"), None)
|
| 1212 |
|
|
|
|
| 1225 |
# --- RESOURCE TEMPLATE TESTS ---
|
| 1226 |
|
| 1227 |
async def test_template_includes_route_description(
|
| 1228 |
+
self, simple_mcp_server: FastMCP
|
| 1229 |
):
|
| 1230 |
"""Test that a ResourceTemplate includes the route description."""
|
| 1231 |
+
templates_dict = (
|
| 1232 |
+
await simple_mcp_server._resource_manager.get_resource_templates()
|
| 1233 |
)
|
| 1234 |
+
templates = list(templates_dict.values())
|
| 1235 |
get_template = next((t for t in templates if t.name == "getItem"), None)
|
| 1236 |
|
| 1237 |
assert get_template is not None, "getItem template wasn't created"
|
|
|
|
| 1240 |
)
|
| 1241 |
|
| 1242 |
async def test_template_includes_function_docstring(
|
| 1243 |
+
self, simple_mcp_server: FastMCP
|
| 1244 |
):
|
| 1245 |
"""Test that a ResourceTemplate includes the function docstring."""
|
| 1246 |
+
templates_dict = (
|
| 1247 |
+
await simple_mcp_server._resource_manager.get_resource_templates()
|
| 1248 |
)
|
| 1249 |
+
templates = list(templates_dict.values())
|
| 1250 |
get_template = next((t for t in templates if t.name == "getItem"), None)
|
| 1251 |
|
| 1252 |
assert get_template is not None, "getItem template wasn't created"
|
|
|
|
| 1255 |
)
|
| 1256 |
|
| 1257 |
async def test_template_includes_path_parameter_description(
|
| 1258 |
+
self, simple_mcp_server: FastMCP
|
| 1259 |
):
|
| 1260 |
"""Test that a ResourceTemplate includes path parameter descriptions."""
|
| 1261 |
+
templates_dict = (
|
| 1262 |
+
await simple_mcp_server._resource_manager.get_resource_templates()
|
| 1263 |
)
|
| 1264 |
+
templates = list(templates_dict.values())
|
| 1265 |
get_template = next((t for t in templates if t.name == "getItem"), None)
|
| 1266 |
|
| 1267 |
assert get_template is not None, "getItem template wasn't created"
|
|
|
|
| 1270 |
)
|
| 1271 |
|
| 1272 |
async def test_template_includes_query_parameter_description(
|
| 1273 |
+
self, simple_mcp_server: FastMCP
|
| 1274 |
):
|
| 1275 |
"""Test that a ResourceTemplate includes query parameter descriptions."""
|
| 1276 |
+
templates_dict = (
|
| 1277 |
+
await simple_mcp_server._resource_manager.get_resource_templates()
|
| 1278 |
)
|
| 1279 |
+
templates = list(templates_dict.values())
|
| 1280 |
get_template = next((t for t in templates if t.name == "getItem"), None)
|
| 1281 |
|
| 1282 |
assert get_template is not None, "getItem template wasn't created"
|
|
|
|
| 1285 |
)
|
| 1286 |
|
| 1287 |
async def test_template_parameter_schema_includes_description(
|
| 1288 |
+
self, simple_mcp_server: FastMCP
|
| 1289 |
):
|
| 1290 |
"""Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
|
| 1291 |
+
templates_dict = (
|
| 1292 |
+
await simple_mcp_server._resource_manager.get_resource_templates()
|
| 1293 |
)
|
| 1294 |
+
templates = list(templates_dict.values())
|
| 1295 |
get_template = next((t for t in templates if t.name == "getItem"), None)
|
| 1296 |
|
| 1297 |
assert get_template is not None, "getItem template wasn't created"
|
|
|
|
| 1311 |
|
| 1312 |
# --- TOOL TESTS ---
|
| 1313 |
|
| 1314 |
+
async def test_tool_includes_route_description(self, simple_mcp_server: FastMCP):
|
| 1315 |
"""Test that a Tool includes the route description."""
|
| 1316 |
+
tools_dict = await simple_mcp_server._tool_manager.get_tools()
|
| 1317 |
+
tools = list(tools_dict.values())
|
| 1318 |
create_tool = next((t for t in tools if t.name == "createItem"), None)
|
| 1319 |
|
| 1320 |
assert create_tool is not None, "createItem tool wasn't created"
|
|
|
|
| 1322 |
"Route description missing from Tool"
|
| 1323 |
)
|
| 1324 |
|
| 1325 |
+
async def test_tool_includes_function_docstring(self, simple_mcp_server: FastMCP):
|
| 1326 |
"""Test that a Tool includes the function docstring."""
|
| 1327 |
+
tools_dict = await simple_mcp_server._tool_manager.get_tools()
|
| 1328 |
+
tools = list(tools_dict.values())
|
| 1329 |
create_tool = next((t for t in tools if t.name == "createItem"), None)
|
| 1330 |
|
| 1331 |
assert create_tool is not None, "createItem tool wasn't created"
|
|
|
|
| 1335 |
)
|
| 1336 |
|
| 1337 |
async def test_tool_parameter_schema_includes_property_description(
|
| 1338 |
+
self, simple_mcp_server: FastMCP
|
| 1339 |
):
|
| 1340 |
"""Test that a Tool's parameter schema includes property descriptions from request model."""
|
| 1341 |
+
tools_dict = await simple_mcp_server._tool_manager.get_tools()
|
| 1342 |
+
tools = list(tools_dict.values())
|
| 1343 |
create_tool = next((t for t in tools if t.name == "createItem"), None)
|
| 1344 |
|
| 1345 |
assert create_tool is not None, "createItem tool wasn't created"
|
|
|
|
| 1359 |
|
| 1360 |
# --- CLIENT API TESTS ---
|
| 1361 |
|
| 1362 |
+
async def test_client_api_resource_description(self, simple_mcp_server: FastMCP):
|
| 1363 |
"""Test that Resource descriptions are accessible via the client API."""
|
| 1364 |
+
async with Client(simple_mcp_server) as client:
|
| 1365 |
resources = await client.list_resources()
|
| 1366 |
list_resource = next((r for r in resources if r.name == "listItems"), None)
|
| 1367 |
|
|
|
|
| 1373 |
"Route description missing in Resource from client API"
|
| 1374 |
)
|
| 1375 |
|
| 1376 |
+
async def test_client_api_template_description(self, simple_mcp_server: FastMCP):
|
| 1377 |
"""Test that ResourceTemplate descriptions are accessible via the client API."""
|
| 1378 |
+
async with Client(simple_mcp_server) as client:
|
| 1379 |
templates = await client.list_resource_templates()
|
| 1380 |
get_template = next((t for t in templates if t.name == "getItem"), None)
|
| 1381 |
|
|
|
|
| 1387 |
"Route description missing in ResourceTemplate from client API"
|
| 1388 |
)
|
| 1389 |
|
| 1390 |
+
async def test_client_api_tool_description(self, simple_mcp_server: FastMCP):
|
| 1391 |
"""Test that Tool descriptions are accessible via the client API."""
|
| 1392 |
+
async with Client(simple_mcp_server) as client:
|
| 1393 |
tools = await client.list_tools()
|
| 1394 |
create_tool = next((t for t in tools if t.name == "createItem"), None)
|
| 1395 |
|
|
|
|
| 1401 |
"Function docstring missing in Tool from client API"
|
| 1402 |
)
|
| 1403 |
|
| 1404 |
+
async def test_client_api_tool_parameter_schema(self, simple_mcp_server: FastMCP):
|
| 1405 |
"""Test that Tool parameter schemas are accessible via the client API."""
|
| 1406 |
+
async with Client(simple_mcp_server) as client:
|
| 1407 |
tools = await client.list_tools()
|
| 1408 |
create_tool = next((t for t in tools if t.name == "createItem"), None)
|
| 1409 |
|
|
|
|
| 1536 |
|
| 1537 |
# Debug: print all components created
|
| 1538 |
print("\nDEBUG - Resources created:")
|
| 1539 |
+
resources_dict = await server._resource_manager.get_resources()
|
| 1540 |
+
for name, resource in resources_dict.items():
|
| 1541 |
print(f" Resource: {name}, Name attribute: {resource.name}")
|
| 1542 |
|
| 1543 |
print("\nDEBUG - Templates created:")
|
| 1544 |
+
templates_dict = await server._resource_manager.get_resource_templates()
|
| 1545 |
+
for name, template in templates_dict.items():
|
| 1546 |
print(f" Template: {name}, Name attribute: {template.name}")
|
| 1547 |
|
| 1548 |
print("\nDEBUG - Tools created:")
|
| 1549 |
+
tools = await server._tool_manager._list_tools()
|
| 1550 |
+
for tool in tools:
|
| 1551 |
print(f" Tool: {tool.name}")
|
| 1552 |
|
| 1553 |
return server
|
| 1554 |
|
| 1555 |
+
async def test_resource_includes_function_docstring(self, fastapi_server: FastMCP):
|
| 1556 |
"""Test that a Resource includes the function docstring."""
|
| 1557 |
+
resources_dict = await fastapi_server._resource_manager.get_resources()
|
| 1558 |
+
resources = list(resources_dict.values())
|
| 1559 |
|
| 1560 |
# Now checking for the get_items operation ID rather than list_items
|
| 1561 |
list_resource = next((r for r in resources if "items_get" in r.name), None)
|
|
|
|
| 1566 |
"Function docstring missing from Resource"
|
| 1567 |
)
|
| 1568 |
|
| 1569 |
+
async def test_resource_includes_response_model_fields(
|
| 1570 |
+
self, fastapi_server: FastMCP
|
| 1571 |
+
):
|
| 1572 |
"""Test that a Resource description includes basic response information.
|
| 1573 |
|
| 1574 |
Note: FastAPI doesn't reliably include Pydantic field descriptions in the OpenAPI schema,
|
| 1575 |
so we can only check for basic response information being present.
|
| 1576 |
"""
|
| 1577 |
+
resources_dict = await fastapi_server._resource_manager.get_resources()
|
| 1578 |
+
resources = list(resources_dict.values())
|
| 1579 |
list_resource = next((r for r in resources if "items_get" in r.name), None)
|
| 1580 |
|
| 1581 |
assert list_resource is not None, "GET /items resource wasn't created"
|
|
|
|
| 1589 |
# We've already verified in TestDescriptionPropagation that when descriptions
|
| 1590 |
# are present in the OpenAPI schema, they are properly included in the component description
|
| 1591 |
|
| 1592 |
+
async def test_template_includes_function_docstring(self, fastapi_server: FastMCP):
|
| 1593 |
"""Test that a ResourceTemplate includes the function docstring."""
|
| 1594 |
+
templates_dict = await fastapi_server._resource_manager.get_resource_templates()
|
| 1595 |
+
templates = list(templates_dict.values())
|
| 1596 |
get_template = next((t for t in templates if "get_item_items" in t.name), None)
|
| 1597 |
|
| 1598 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
|
|
|
| 1601 |
"Function docstring missing from ResourceTemplate"
|
| 1602 |
)
|
| 1603 |
|
| 1604 |
+
async def test_template_includes_path_parameter_description(
|
| 1605 |
+
self, fastapi_server: FastMCP
|
| 1606 |
+
):
|
| 1607 |
"""Test that a ResourceTemplate includes path parameter descriptions.
|
| 1608 |
|
| 1609 |
Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)]
|
| 1610 |
are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
|
| 1611 |
"""
|
| 1612 |
+
templates_dict = await fastapi_server._resource_manager.get_resource_templates()
|
| 1613 |
+
templates = list(templates_dict.values())
|
| 1614 |
get_template = next((t for t in templates if "get_item_items" in t.name), None)
|
| 1615 |
|
| 1616 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
|
|
|
| 1624 |
"item_id parameter missing from ResourceTemplate description"
|
| 1625 |
)
|
| 1626 |
|
| 1627 |
+
async def test_template_includes_query_parameter_description(
|
| 1628 |
+
self, fastapi_server: FastMCP
|
| 1629 |
+
):
|
| 1630 |
"""Test that a ResourceTemplate includes query parameter descriptions.
|
| 1631 |
|
| 1632 |
Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)]
|
| 1633 |
are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
|
| 1634 |
"""
|
| 1635 |
+
templates_dict = await fastapi_server._resource_manager.get_resource_templates()
|
| 1636 |
+
templates = list(templates_dict.values())
|
| 1637 |
get_template = next((t for t in templates if "get_item_items" in t.name), None)
|
| 1638 |
|
| 1639 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
|
|
|
| 1647 |
"fields parameter missing from ResourceTemplate description"
|
| 1648 |
)
|
| 1649 |
|
| 1650 |
+
async def test_template_parameter_schema_includes_description(
|
| 1651 |
+
self, fastapi_server: FastMCP
|
| 1652 |
+
):
|
| 1653 |
"""Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
|
| 1654 |
+
templates_dict = await fastapi_server._resource_manager.get_resource_templates()
|
| 1655 |
+
templates = list(templates_dict.values())
|
| 1656 |
get_template = next((t for t in templates if "get_item_items" in t.name), None)
|
| 1657 |
|
| 1658 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
|
|
|
| 1670 |
in get_template.parameters["properties"]["item_id"]["description"]
|
| 1671 |
), "Path parameter description incorrect in schema"
|
| 1672 |
|
| 1673 |
+
async def test_tool_includes_function_docstring(self, fastapi_server: FastMCP):
|
| 1674 |
"""Test that a Tool includes the function docstring."""
|
| 1675 |
+
tools_dict = await fastapi_server._tool_manager.get_tools()
|
| 1676 |
+
tools = list(tools_dict.values())
|
| 1677 |
create_tool = next(
|
| 1678 |
(t for t in tools if "create_item_items_post" == t.name), None
|
| 1679 |
)
|
|
|
|
| 1685 |
)
|
| 1686 |
|
| 1687 |
async def test_tool_parameter_schema_includes_property_description(
|
| 1688 |
+
self, fastapi_server: FastMCP
|
| 1689 |
):
|
| 1690 |
"""Test that a Tool's parameter schema includes property descriptions from request model.
|
| 1691 |
|
|
|
|
| 1693 |
may not be consistently propagated into the FastAPI OpenAPI schema and thus not into the tool's
|
| 1694 |
parameter schema.
|
| 1695 |
"""
|
| 1696 |
+
tools_dict = await fastapi_server._tool_manager.get_tools()
|
| 1697 |
+
tools = list(tools_dict.values())
|
| 1698 |
create_tool = next(
|
| 1699 |
(t for t in tools if "create_item_items_post" == t.name), None
|
| 1700 |
)
|
|
|
|
| 1708 |
)
|
| 1709 |
# We don't test for the description field content as it may not be consistently propagated
|
| 1710 |
|
| 1711 |
+
async def test_client_api_resource_description(self, fastapi_server: FastMCP):
|
| 1712 |
"""Test that Resource descriptions are accessible via the client API."""
|
| 1713 |
async with Client(fastapi_server) as client:
|
| 1714 |
resources = await client.list_resources()
|
|
|
|
| 1722 |
"Function docstring missing in Resource from client API"
|
| 1723 |
)
|
| 1724 |
|
| 1725 |
+
async def test_client_api_template_description(self, fastapi_server: FastMCP):
|
| 1726 |
"""Test that ResourceTemplate descriptions are accessible via the client API."""
|
| 1727 |
async with Client(fastapi_server) as client:
|
| 1728 |
templates = await client.list_resource_templates()
|
|
|
|
| 1738 |
"Function docstring missing in ResourceTemplate from client API"
|
| 1739 |
)
|
| 1740 |
|
| 1741 |
+
async def test_client_api_tool_description(self, fastapi_server: FastMCP):
|
| 1742 |
"""Test that Tool descriptions are accessible via the client API."""
|
| 1743 |
async with Client(fastapi_server) as client:
|
| 1744 |
tools = await client.list_tools()
|
|
|
|
| 1754 |
"Function docstring missing in Tool from client API"
|
| 1755 |
)
|
| 1756 |
|
| 1757 |
+
async def test_client_api_tool_parameter_schema(self, fastapi_server: FastMCP):
|
| 1758 |
"""Test that Tool parameter schemas are accessible via the client API."""
|
| 1759 |
async with Client(fastapi_server) as client:
|
| 1760 |
tools = await client.list_tools()
|
|
|
|
| 1777 |
class TestReprMethods:
|
| 1778 |
"""Tests for the custom __repr__ methods of OpenAPI objects."""
|
| 1779 |
|
| 1780 |
+
async def test_openapi_tool_repr(self, fastmcp_openapi_server: FastMCPOpenAPI):
|
|
|
|
|
|
|
| 1781 |
"""Test that OpenAPITool's __repr__ method works without recursion errors."""
|
| 1782 |
+
tools = await fastmcp_openapi_server._tool_manager._list_tools()
|
| 1783 |
tool = next(iter(tools))
|
| 1784 |
|
| 1785 |
# Verify repr doesn't cause recursion and contains expected elements
|
|
|
|
| 1789 |
assert "method=" in tool_repr
|
| 1790 |
assert "path=" in tool_repr
|
| 1791 |
|
| 1792 |
+
async def test_openapi_resource_repr(self, fastmcp_openapi_server: FastMCPOpenAPI):
|
|
|
|
|
|
|
| 1793 |
"""Test that OpenAPIResource's __repr__ method works without recursion errors."""
|
| 1794 |
+
resources_dict = await fastmcp_openapi_server._resource_manager.get_resources()
|
| 1795 |
+
resources = list(resources_dict.values())
|
|
|
|
| 1796 |
resource = next(iter(resources))
|
| 1797 |
|
| 1798 |
# Verify repr doesn't cause recursion and contains expected elements
|
|
|
|
| 1803 |
assert "path=" in resource_repr
|
| 1804 |
|
| 1805 |
async def test_openapi_resource_template_repr(
|
| 1806 |
+
self, fastmcp_openapi_server: FastMCPOpenAPI
|
| 1807 |
):
|
| 1808 |
"""Test that OpenAPIResourceTemplate's __repr__ method works without recursion errors."""
|
| 1809 |
+
templates_dict = (
|
| 1810 |
+
await fastmcp_openapi_server._resource_manager.get_resource_templates()
|
| 1811 |
)
|
| 1812 |
+
templates = list(templates_dict.values())
|
| 1813 |
template = next(iter(templates))
|
| 1814 |
|
| 1815 |
# Verify repr doesn't cause recursion and contains expected elements
|
|
|
|
| 1854 |
)
|
| 1855 |
|
| 1856 |
# Get the tools from the server
|
| 1857 |
+
tools = await server._tool_manager._list_tools()
|
| 1858 |
|
| 1859 |
# Find the read_item tool
|
| 1860 |
read_item_tool = next((t for t in tools if t.name == "read_item_items"), None)
|
|
|
|
| 1947 |
)
|
| 1948 |
|
| 1949 |
# All operations should be mapped to tools
|
| 1950 |
+
tools = await mcp._tool_manager._list_tools()
|
| 1951 |
tool_names = {tool.name for tool in tools}
|
| 1952 |
|
| 1953 |
# Check that all 4 operations became tools
|
|
|
|
| 2025 |
)
|
| 2026 |
|
| 2027 |
# Check that admin-tagged routes are tools
|
| 2028 |
+
tools_dict = await server._tool_manager.get_tools()
|
| 2029 |
+
tool_names = {t.name for t in tools_dict.values()}
|
| 2030 |
|
| 2031 |
+
resources_dict = await server._resource_manager.get_resources()
|
| 2032 |
+
resource_names = {r.name for r in resources_dict.values()}
|
| 2033 |
|
| 2034 |
# Routes with "admin" tag should be tools
|
| 2035 |
assert "createUser" in tool_names
|
|
|
|
| 2058 |
)
|
| 2059 |
|
| 2060 |
# Check that internal-tagged routes are excluded
|
| 2061 |
+
resources_dict = await server._resource_manager.get_resources()
|
| 2062 |
+
resource_names = {r.name for r in resources_dict.values()}
|
| 2063 |
|
| 2064 |
+
tools_dict = await server._tool_manager.get_tools()
|
| 2065 |
+
tool_names = {t.name for t in tools_dict.values()}
|
| 2066 |
|
| 2067 |
# Internal-tagged route should be excluded
|
| 2068 |
assert "getAdminStats" not in resource_names
|
|
|
|
| 2093 |
route_maps=route_maps,
|
| 2094 |
)
|
| 2095 |
|
| 2096 |
+
tools_dict = await server._tool_manager.get_tools()
|
| 2097 |
+
tool_names = {t.name for t in tools_dict.values()}
|
| 2098 |
|
| 2099 |
+
resources_dict = await server._resource_manager.get_resources()
|
| 2100 |
+
resource_names = {r.name for r in resources_dict.values()}
|
| 2101 |
|
| 2102 |
# Only createUser has both "users" AND "admin" tags
|
| 2103 |
assert "createUser" in tool_names
|
|
|
|
| 2128 |
route_maps=route_maps,
|
| 2129 |
)
|
| 2130 |
|
| 2131 |
+
tools_dict = await server._tool_manager.get_tools()
|
| 2132 |
+
tool_names = {t.name for t in tools_dict.values()}
|
| 2133 |
|
| 2134 |
+
resources_dict = await server._resource_manager.get_resources()
|
| 2135 |
+
resource_names = {r.name for r in resources_dict.values()}
|
| 2136 |
|
| 2137 |
# Only getAdminStats matches both /admin/ pattern AND "admin" tag
|
| 2138 |
assert "getAdminStats" in tool_names
|
|
|
|
| 2158 |
route_maps=route_maps,
|
| 2159 |
)
|
| 2160 |
|
| 2161 |
+
tools_dict = await server._tool_manager.get_tools()
|
| 2162 |
+
tool_names = {t.name for t in tools_dict.values()}
|
| 2163 |
|
| 2164 |
# All routes should be tools since empty tags matches everything
|
| 2165 |
expected_tools = {
|
|
|
|
| 2264 |
)
|
| 2265 |
|
| 2266 |
# Check tools use custom names
|
| 2267 |
+
tools = await server._tool_manager._list_tools()
|
| 2268 |
tool_names = {tool.name for tool in tools}
|
| 2269 |
assert "admin_create_user" in tool_names
|
| 2270 |
|
| 2271 |
# Check resource templates use custom names
|
| 2272 |
+
templates_dict = await server._resource_manager.get_resource_templates()
|
| 2273 |
+
template_names = {template.name for template in templates_dict.values()}
|
| 2274 |
assert "user_detail" in template_names
|
| 2275 |
|
| 2276 |
# Check resources use custom names
|
| 2277 |
+
resources_dict = await server._resource_manager.get_resources()
|
| 2278 |
+
resource_names = {resource.name for resource in resources_dict.values()}
|
| 2279 |
assert "user_list" in resource_names
|
| 2280 |
|
| 2281 |
async def test_mcp_names_fallback_to_operation_id_short(
|
|
|
|
| 2294 |
route_maps=GET_ROUTE_MAPS,
|
| 2295 |
)
|
| 2296 |
|
| 2297 |
+
tools = await server._tool_manager._list_tools()
|
| 2298 |
tool_names = {tool.name for tool in tools}
|
| 2299 |
|
| 2300 |
+
templates_dict = await server._resource_manager.get_resource_templates()
|
| 2301 |
+
template_names = {template.name for template in templates_dict.values()}
|
| 2302 |
|
| 2303 |
+
resources_dict = await server._resource_manager.get_resources()
|
| 2304 |
+
resource_names = {resource.name for resource in resources_dict.values()}
|
| 2305 |
|
| 2306 |
# Custom mapped name should be used
|
| 2307 |
assert "custom_user_list" in resource_names
|
|
|
|
| 2318 |
route_maps=GET_ROUTE_MAPS,
|
| 2319 |
)
|
| 2320 |
|
| 2321 |
+
resources_dict = await server._resource_manager.get_resources()
|
| 2322 |
resource_names = {
|
| 2323 |
+
resource.name
|
| 2324 |
+
for resource in resources_dict.values()
|
| 2325 |
+
if resource.name is not None
|
| 2326 |
}
|
| 2327 |
|
| 2328 |
# Special chars and spaces should be slugified
|
|
|
|
| 2350 |
# Check all component types
|
| 2351 |
all_names = []
|
| 2352 |
|
| 2353 |
+
tools = await server._tool_manager._list_tools()
|
| 2354 |
all_names.extend(tool.name for tool in tools)
|
| 2355 |
|
| 2356 |
+
resources_dict = await server._resource_manager.get_resources()
|
| 2357 |
+
all_names.extend(resource.name for resource in resources_dict.values())
|
| 2358 |
|
| 2359 |
+
templates_dict = await server._resource_manager.get_resource_templates()
|
| 2360 |
+
all_names.extend(template.name for template in templates_dict.values())
|
| 2361 |
|
| 2362 |
# All names should be 56 characters or less
|
| 2363 |
for name in all_names:
|
|
|
|
| 2383 |
mcp_names=mcp_names,
|
| 2384 |
)
|
| 2385 |
|
| 2386 |
+
tools = await server._tool_manager._list_tools()
|
| 2387 |
tool_names = {tool.name for tool in tools}
|
| 2388 |
assert "openapi_user_list" in tool_names
|
| 2389 |
|
|
|
|
| 2415 |
mcp_names=mcp_names,
|
| 2416 |
)
|
| 2417 |
|
| 2418 |
+
tools = await server._tool_manager._list_tools()
|
| 2419 |
tool_names = {tool.name for tool in tools}
|
| 2420 |
|
| 2421 |
assert "fastapi_create_user" in tool_names
|
|
|
|
| 2439 |
route_maps=GET_ROUTE_MAPS,
|
| 2440 |
)
|
| 2441 |
|
| 2442 |
+
resources_dict = await server._resource_manager.get_resources()
|
| 2443 |
resource_names = {
|
| 2444 |
+
resource.name
|
| 2445 |
+
for resource in resources_dict.values()
|
| 2446 |
+
if resource.name is not None
|
| 2447 |
}
|
| 2448 |
|
| 2449 |
# Find the resource that should have the custom name
|
|
|
|
| 2518 |
)
|
| 2519 |
|
| 2520 |
# Get the POST tool
|
| 2521 |
+
tools = await server._tool_manager._list_tools()
|
| 2522 |
create_user_tool = next((t for t in tools if "create_user" in t.name), None)
|
| 2523 |
|
| 2524 |
assert create_user_tool is not None, "create_user tool not found"
|
|
|
|
| 2552 |
)
|
| 2553 |
|
| 2554 |
# Get the resource
|
| 2555 |
+
resources_dict = await server._resource_manager.get_resources()
|
| 2556 |
+
resources = list(resources_dict.values())
|
| 2557 |
get_users_resource = next((r for r in resources if "get_users" in r.name), None)
|
| 2558 |
|
| 2559 |
assert get_users_resource is not None, "get_users resource not found"
|
|
|
|
| 2587 |
)
|
| 2588 |
|
| 2589 |
# Get the resource template
|
| 2590 |
+
templates_dict = await server._resource_manager.get_resource_templates()
|
| 2591 |
+
templates = list(templates_dict.values())
|
| 2592 |
get_user_template = next((t for t in templates if "get_user" in t.name), None)
|
| 2593 |
|
| 2594 |
assert get_user_template is not None, "get_user template not found"
|
|
|
|
| 2634 |
)
|
| 2635 |
|
| 2636 |
# Check tool tags
|
| 2637 |
+
tools = await server._tool_manager._list_tools()
|
| 2638 |
create_tool = next((t for t in tools if "create_user" in t.name), None)
|
| 2639 |
assert create_tool is not None
|
| 2640 |
assert "write-operation" in create_tool.tags
|
| 2641 |
assert "mutation" in create_tool.tags
|
| 2642 |
|
| 2643 |
# Check resource template tags
|
| 2644 |
+
templates_dict = await server._resource_manager.get_resource_templates()
|
| 2645 |
+
templates = list(templates_dict.values())
|
| 2646 |
detail_template = next((t for t in templates if "get_user" in t.name), None)
|
| 2647 |
assert detail_template is not None
|
| 2648 |
assert "detail" in detail_template.tags
|
| 2649 |
assert "single-item" in detail_template.tags
|
| 2650 |
|
| 2651 |
# Check resource tags
|
| 2652 |
+
resources_dict = await server._resource_manager.get_resources()
|
| 2653 |
+
resources = list(resources_dict.values())
|
| 2654 |
list_resource = next((r for r in resources if "get_users" in r.name), None)
|
| 2655 |
assert list_resource is not None
|
| 2656 |
assert "list" in list_resource.tags
|
tests/server/test_mount.py
CHANGED
|
@@ -71,7 +71,8 @@ class TestBasicMount:
|
|
| 71 |
# Mount without deprecated parameters
|
| 72 |
main_app.mount(api_app, "api")
|
| 73 |
|
| 74 |
-
|
|
|
|
| 75 |
main_app = FastMCP("MainApp")
|
| 76 |
sub_app = FastMCP("SubApp")
|
| 77 |
|
|
@@ -80,7 +81,7 @@ class TestBasicMount:
|
|
| 80 |
return "This is from the sub app"
|
| 81 |
|
| 82 |
# Mount with empty prefix but without deprecated separators
|
| 83 |
-
main_app.mount(sub_app, prefix=
|
| 84 |
|
| 85 |
tools = await main_app.get_tools()
|
| 86 |
# With empty prefix, the tool should keep its original name
|
|
|
|
| 71 |
# Mount without deprecated parameters
|
| 72 |
main_app.mount(api_app, "api")
|
| 73 |
|
| 74 |
+
@pytest.mark.parametrize("prefix", ["", None])
|
| 75 |
+
async def test_mount_with_no_prefix(self, prefix):
|
| 76 |
main_app = FastMCP("MainApp")
|
| 77 |
sub_app = FastMCP("SubApp")
|
| 78 |
|
|
|
|
| 81 |
return "This is from the sub app"
|
| 82 |
|
| 83 |
# Mount with empty prefix but without deprecated separators
|
| 84 |
+
main_app.mount(sub_app, prefix=prefix)
|
| 85 |
|
| 86 |
tools = await main_app.get_tools()
|
| 87 |
# With empty prefix, the tool should keep its original name
|
tests/server/test_resource_prefix_formats.py
CHANGED
|
@@ -56,8 +56,8 @@ async def test_resource_prefix_format_in_import_server():
|
|
| 56 |
await main_server_protocol.import_server(server, "sub")
|
| 57 |
|
| 58 |
# Check that the resources are prefixed correctly
|
| 59 |
-
path_resources = main_server_path._resource_manager.get_resources()
|
| 60 |
-
protocol_resources = main_server_protocol._resource_manager.get_resources()
|
| 61 |
|
| 62 |
# Path format should be resource://sub/test
|
| 63 |
assert "resource://sub/test" in path_resources
|
|
|
|
| 56 |
await main_server_protocol.import_server(server, "sub")
|
| 57 |
|
| 58 |
# Check that the resources are prefixed correctly
|
| 59 |
+
path_resources = await main_server_path._resource_manager.get_resources()
|
| 60 |
+
protocol_resources = await main_server_protocol._resource_manager.get_resources()
|
| 61 |
|
| 62 |
# Path format should be resource://sub/test
|
| 63 |
assert "resource://sub/test" in path_resources
|
tests/server/test_tool_annotations.py
CHANGED
|
@@ -22,7 +22,8 @@ async def test_tool_annotations_in_tool_manager():
|
|
| 22 |
return message
|
| 23 |
|
| 24 |
# Check internal tool objects directly
|
| 25 |
-
|
|
|
|
| 26 |
assert len(tools) == 1
|
| 27 |
assert tools[0].annotations is not None
|
| 28 |
assert tools[0].annotations.title == "Echo Tool"
|
|
@@ -124,7 +125,8 @@ async def test_direct_tool_annotations_in_tool_manager():
|
|
| 124 |
return {"modified": True, **data}
|
| 125 |
|
| 126 |
# Check internal tool objects directly
|
| 127 |
-
|
|
|
|
| 128 |
assert len(tools) == 1
|
| 129 |
assert tools[0].annotations is not None
|
| 130 |
assert tools[0].annotations.title == "Direct Tool"
|
|
@@ -183,7 +185,8 @@ async def test_add_tool_method_annotations():
|
|
| 183 |
mcp.add_tool(tool)
|
| 184 |
|
| 185 |
# Check internal tool objects directly
|
| 186 |
-
|
|
|
|
| 187 |
assert len(tools) == 1
|
| 188 |
assert tools[0].annotations is not None
|
| 189 |
assert tools[0].annotations.title == "Create Item"
|
|
|
|
| 22 |
return message
|
| 23 |
|
| 24 |
# Check internal tool objects directly
|
| 25 |
+
tools_dict = await mcp._tool_manager.get_tools()
|
| 26 |
+
tools = list(tools_dict.values())
|
| 27 |
assert len(tools) == 1
|
| 28 |
assert tools[0].annotations is not None
|
| 29 |
assert tools[0].annotations.title == "Echo Tool"
|
|
|
|
| 125 |
return {"modified": True, **data}
|
| 126 |
|
| 127 |
# Check internal tool objects directly
|
| 128 |
+
tools_dict = await mcp._tool_manager.get_tools()
|
| 129 |
+
tools = list(tools_dict.values())
|
| 130 |
assert len(tools) == 1
|
| 131 |
assert tools[0].annotations is not None
|
| 132 |
assert tools[0].annotations.title == "Direct Tool"
|
|
|
|
| 185 |
mcp.add_tool(tool)
|
| 186 |
|
| 187 |
# Check internal tool objects directly
|
| 188 |
+
tools_dict = await mcp._tool_manager.get_tools()
|
| 189 |
+
tools = list(tools_dict.values())
|
| 190 |
assert len(tools) == 1
|
| 191 |
assert tools[0].annotations is not None
|
| 192 |
assert tools[0].annotations.title == "Create Item"
|
tests/server/test_tool_exclude_args.py
CHANGED
|
@@ -19,9 +19,10 @@ async def test_tool_exclude_args_in_tool_manager():
|
|
| 19 |
pass
|
| 20 |
return message
|
| 21 |
|
| 22 |
-
|
|
|
|
| 23 |
assert len(tools) == 1
|
| 24 |
-
assert "state" not in
|
| 25 |
|
| 26 |
|
| 27 |
async def test_tool_exclude_args_without_default_value_raises_error():
|
|
@@ -60,7 +61,8 @@ async def test_add_tool_method_exclude_args():
|
|
| 60 |
mcp.add_tool(tool)
|
| 61 |
|
| 62 |
# Check internal tool objects directly
|
| 63 |
-
|
|
|
|
| 64 |
assert len(tools) == 1
|
| 65 |
assert "state" not in tools[0].parameters["properties"]
|
| 66 |
|
|
|
|
| 19 |
pass
|
| 20 |
return message
|
| 21 |
|
| 22 |
+
tools_dict = await mcp._tool_manager.get_tools()
|
| 23 |
+
tools = list(tools_dict.values())
|
| 24 |
assert len(tools) == 1
|
| 25 |
+
assert "state" not in tools[0].parameters["properties"]
|
| 26 |
|
| 27 |
|
| 28 |
async def test_tool_exclude_args_without_default_value_raises_error():
|
|
|
|
| 61 |
mcp.add_tool(tool)
|
| 62 |
|
| 63 |
# Check internal tool objects directly
|
| 64 |
+
tools_dict = await mcp._tool_manager.get_tools()
|
| 65 |
+
tools = list(tools_dict.values())
|
| 66 |
assert len(tools) == 1
|
| 67 |
assert "state" not in tools[0].parameters["properties"]
|
| 68 |
|