Spaces:
Running
Running
Merge branch 'main' into dev-20250619-173841
Browse files- CLAUDE.md +30 -0
- docs/docs.json +1 -0
- docs/patterns/tool-transformation.mdx +1 -0
- docs/servers/fastmcp.mdx +1 -1
- docs/servers/middleware.mdx +421 -0
- src/fastmcp/prompts/prompt_manager.py +119 -43
- src/fastmcp/resources/resource_manager.py +249 -76
- src/fastmcp/resources/template.py +16 -0
- src/fastmcp/server/context.py +9 -2
- src/fastmcp/server/middleware.py +236 -0
- src/fastmcp/server/proxy.py +250 -140
- src/fastmcp/server/server.py +243 -266
- src/fastmcp/tools/tool_manager.py +114 -45
- tests/client/test_client.py +6 -1
- tests/deprecated/test_resource_prefixes.py +1 -1
- tests/prompts/test_prompt_manager.py +23 -24
- tests/resources/test_resource_manager.py +73 -53
- tests/resources/test_resource_template.py +1 -0
- tests/server/middleware/__init__.py +0 -0
- tests/server/middleware/test_middleware.py +567 -0
- tests/server/openapi/test_openapi.py +188 -162
- tests/server/test_import_server.py +4 -4
- tests/server/test_mount.py +18 -14
- tests/server/test_resource_prefix_formats.py +2 -2
- tests/server/test_server.py +1 -1
- tests/server/test_tool_annotations.py +6 -3
- tests/server/test_tool_exclude_args.py +5 -3
- tests/tools/test_tool_manager.py +46 -41
CLAUDE.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FastMCP Development Guidelines
|
| 2 |
+
|
| 3 |
+
## Testing and Investigation
|
| 4 |
+
|
| 5 |
+
### In-Memory Transport - Always Preferred
|
| 6 |
+
|
| 7 |
+
When testing or investigating FastMCP servers, **always prefer the in-memory transport** unless you specifically need HTTP transport features. Pass a FastMCP server directly to a Client to eliminate separate processes and network complexity.
|
| 8 |
+
|
| 9 |
+
```python
|
| 10 |
+
# Create your FastMCP server
|
| 11 |
+
mcp = FastMCP("TestServer")
|
| 12 |
+
|
| 13 |
+
@mcp.tool
|
| 14 |
+
def greet(name: str) -> str:
|
| 15 |
+
return f"Hello, {name}!"
|
| 16 |
+
|
| 17 |
+
# Pass server directly to client - uses in-memory transport
|
| 18 |
+
async with Client(mcp) as client:
|
| 19 |
+
result = await client.call_tool("greet", {"name": "World"})
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
### When to Use HTTP Transport
|
| 23 |
+
|
| 24 |
+
Only use HTTP transport when testing network-specific features. Prefer StreamableHttp over SSE as it's the modern approach.
|
| 25 |
+
|
| 26 |
+
```python
|
| 27 |
+
# Only when network testing is required
|
| 28 |
+
async with Client(transport=StreamableHttpTransport(server_url)) as client:
|
| 29 |
+
result = await client.ping()
|
| 30 |
+
```
|
docs/docs.json
CHANGED
|
@@ -78,6 +78,7 @@
|
|
| 78 |
"servers/auth/bearer"
|
| 79 |
]
|
| 80 |
},
|
|
|
|
| 81 |
"servers/openapi",
|
| 82 |
"servers/proxy",
|
| 83 |
"servers/composition",
|
|
|
|
| 78 |
"servers/auth/bearer"
|
| 79 |
]
|
| 80 |
},
|
| 81 |
+
"servers/middleware",
|
| 82 |
"servers/openapi",
|
| 83 |
"servers/proxy",
|
| 84 |
"servers/composition",
|
docs/patterns/tool-transformation.mdx
CHANGED
|
@@ -3,6 +3,7 @@ title: Tool Transformation
|
|
| 3 |
sidebarTitle: Tool Transformation
|
| 4 |
description: Create enhanced tool variants with modified schemas, argument mappings, and custom behavior.
|
| 5 |
icon: wand-magic-sparkles
|
|
|
|
| 6 |
---
|
| 7 |
|
| 8 |
import { VersionBadge } from '/snippets/version-badge.mdx'
|
|
|
|
| 3 |
sidebarTitle: Tool Transformation
|
| 4 |
description: Create enhanced tool variants with modified schemas, argument mappings, and custom behavior.
|
| 5 |
icon: wand-magic-sparkles
|
| 6 |
+
tag: NEW
|
| 7 |
---
|
| 8 |
|
| 9 |
import { VersionBadge } from '/snippets/version-badge.mdx'
|
docs/servers/fastmcp.mdx
CHANGED
|
@@ -193,7 +193,7 @@ def hello():
|
|
| 193 |
return "hi"
|
| 194 |
|
| 195 |
# Mount directly
|
| 196 |
-
main.mount(
|
| 197 |
```
|
| 198 |
|
| 199 |
## Proxying Servers
|
|
|
|
| 193 |
return "hi"
|
| 194 |
|
| 195 |
# Mount directly
|
| 196 |
+
main.mount(sub, prefix="sub")
|
| 197 |
```
|
| 198 |
|
| 199 |
## Proxying Servers
|
docs/servers/middleware.mdx
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: layer-group
|
| 6 |
+
tag: NEW
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
import { VersionBadge } from "/snippets/version-badge.mdx"
|
| 10 |
+
|
| 11 |
+
<VersionBadge version="2.9.0" />
|
| 12 |
+
|
| 13 |
+
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.
|
| 14 |
+
|
| 15 |
+
<Tip>
|
| 16 |
+
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.
|
| 17 |
+
</Tip>
|
| 18 |
+
|
| 19 |
+
<Warning>
|
| 20 |
+
MCP middleware is a brand new concept and may be subject to breaking changes in future versions.
|
| 21 |
+
</Warning>
|
| 22 |
+
|
| 23 |
+
## What is MCP Middleware?
|
| 24 |
+
|
| 25 |
+
MCP middleware lets you intercept and modify MCP requests and responses as they flow through your server. Think of it as a pipeline where each piece of middleware can inspect what's happening, make changes, and then pass control to the next middleware in the chain.
|
| 26 |
+
|
| 27 |
+
Common use cases for MCP middleware include:
|
| 28 |
+
- **Authentication and Authorization**: Verify client permissions before executing operations
|
| 29 |
+
- **Logging and Monitoring**: Track usage patterns and performance metrics
|
| 30 |
+
- **Rate Limiting**: Control request frequency per client or operation type
|
| 31 |
+
- **Request/Response Transformation**: Modify data before it reaches tools or after it leaves
|
| 32 |
+
- **Caching**: Store frequently requested data to improve performance
|
| 33 |
+
- **Error Handling**: Provide consistent error responses across your server
|
| 34 |
+
|
| 35 |
+
## How Middleware Works
|
| 36 |
+
|
| 37 |
+
FastMCP middleware operates on a pipeline model. When a request comes in, it flows through your middleware in the order they were added to the server. Each middleware can:
|
| 38 |
+
|
| 39 |
+
1. **Inspect the incoming request** and its context
|
| 40 |
+
2. **Modify the request** before passing it to the next middleware or handler
|
| 41 |
+
3. **Execute the next middleware/handler** in the chain by calling `call_next()`
|
| 42 |
+
4. **Inspect and modify the response** before returning it
|
| 43 |
+
5. **Handle errors** that occur during processing
|
| 44 |
+
|
| 45 |
+
The key insight is that middleware forms a chain where each piece decides whether to continue processing or stop the chain entirely.
|
| 46 |
+
|
| 47 |
+
If you're familiar with ASGI middleware, the basic structure of FastMCP middleware will feel familiar. At its core, middleware is a callable class that receives a context object containing information about the current JSON-RPC message and a handler function to continue the middleware chain.
|
| 48 |
+
|
| 49 |
+
It's important to understand that MCP operates on the [JSON-RPC specification](https://spec.modelcontextprotocol.io/specification/basic/transports/). While FastMCP presents requests and responses in a familiar way, these are fundamentally JSON-RPC messages, not HTTP request/response pairs like you might be used to in web applications. FastMCP middleware works with all [transport types](/clients/transports), including local stdio transport and HTTP transports, though not all middleware implementations are compatible across all transports (e.g., middleware that inspects HTTP headers won't work with stdio transport).
|
| 50 |
+
|
| 51 |
+
The most fundamental way to implement middleware is by overriding the `__call__` method on the `Middleware` base class:
|
| 52 |
+
|
| 53 |
+
```python
|
| 54 |
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
| 55 |
+
|
| 56 |
+
class RawMiddleware(Middleware):
|
| 57 |
+
async def __call__(self, context: MiddlewareContext, call_next):
|
| 58 |
+
# This method receives ALL messages regardless of type
|
| 59 |
+
print(f"Raw middleware processing: {context.method}")
|
| 60 |
+
result = await call_next(context)
|
| 61 |
+
print(f"Raw middleware completed: {context.method}")
|
| 62 |
+
return result
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
This gives you complete control over every message that flows through your server, but requires you to handle all message types manually.
|
| 66 |
+
|
| 67 |
+
## Middleware Hooks
|
| 68 |
+
|
| 69 |
+
To make it easier for users to target specific types of messages, FastMCP middleware provides a variety of specialized hooks. Instead of implementing the raw `__call__` method, you can override specific hook methods that are called only for certain types of operations, allowing you to target exactly the level of specificity you need for your middleware logic.
|
| 70 |
+
|
| 71 |
+
### Hook Hierarchy and Execution Order
|
| 72 |
+
|
| 73 |
+
FastMCP provides multiple hooks that are called with varying levels of specificity. Understanding this hierarchy is crucial for effective middleware design.
|
| 74 |
+
|
| 75 |
+
When a request comes in, **multiple hooks may be called for the same request**, going from general to specific:
|
| 76 |
+
|
| 77 |
+
1. **`on_message`** - Called for ALL MCP messages (both requests and notifications)
|
| 78 |
+
2. **`on_request` or `on_notification`** - Called based on the message type
|
| 79 |
+
3. **Operation-specific hooks** - Called for specific MCP operations like `on_call_tool`
|
| 80 |
+
|
| 81 |
+
For example, when a client calls a tool, your middleware will receive **three separate hook calls**:
|
| 82 |
+
1. First: `on_message` (because it's any MCP message)
|
| 83 |
+
2. Second: `on_request` (because tool calls expect responses)
|
| 84 |
+
3. Third: `on_call_tool` (because it's specifically a tool execution)
|
| 85 |
+
|
| 86 |
+
This hierarchy allows you to target your middleware logic with the right level of specificity. Use `on_message` for broad concerns like logging, `on_request` for authentication, and `on_call_tool` for tool-specific logic like performance monitoring.
|
| 87 |
+
|
| 88 |
+
### Available Hooks
|
| 89 |
+
|
| 90 |
+
- `on_message`: Called for all MCP messages (requests and notifications)
|
| 91 |
+
- `on_request`: Called specifically for MCP requests (that expect responses)
|
| 92 |
+
- `on_notification`: Called specifically for MCP notifications (fire-and-forget)
|
| 93 |
+
- `on_call_tool`: Called when tools are being executed
|
| 94 |
+
- `on_read_resource`: Called when resources are being read
|
| 95 |
+
- `on_get_prompt`: Called when prompts are being retrieved
|
| 96 |
+
- `on_list_tools`: Called when listing available tools
|
| 97 |
+
- `on_list_resources`: Called when listing available resources
|
| 98 |
+
- `on_list_resource_templates`: Called when listing resource templates
|
| 99 |
+
- `on_list_prompts`: Called when listing available prompts
|
| 100 |
+
|
| 101 |
+
## Component Access in Middleware
|
| 102 |
+
|
| 103 |
+
Understanding how to access component information (tools, resources, prompts) in middleware is crucial for building powerful middleware functionality. The access patterns differ significantly between listing operations and execution operations.
|
| 104 |
+
|
| 105 |
+
### Listing Operations vs Execution Operations
|
| 106 |
+
|
| 107 |
+
FastMCP middleware handles two types of operations differently:
|
| 108 |
+
|
| 109 |
+
**Listing Operations** (`on_list_tools`, `on_list_resources`, `on_list_prompts`, etc.):
|
| 110 |
+
- Middleware receives **FastMCP component objects** with full metadata
|
| 111 |
+
- These objects include FastMCP-specific properties like `tags` that aren't part of the MCP specification
|
| 112 |
+
- The result contains complete component information before it's converted to MCP format
|
| 113 |
+
- Tags and other metadata are stripped when finally returned to the MCP client
|
| 114 |
+
|
| 115 |
+
**Execution Operations** (`on_call_tool`, `on_read_resource`, `on_get_prompt`):
|
| 116 |
+
- Middleware runs **before** the component is executed
|
| 117 |
+
- The middleware result is either the execution result or an error if the component wasn't found
|
| 118 |
+
- Component metadata isn't directly available in the hook parameters
|
| 119 |
+
|
| 120 |
+
### Accessing Component Metadata During Execution
|
| 121 |
+
|
| 122 |
+
If you need to check component properties (like tags) during execution operations, use the FastMCP server instance available through the context:
|
| 123 |
+
|
| 124 |
+
```python
|
| 125 |
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
| 126 |
+
from fastmcp.exceptions import ToolError
|
| 127 |
+
|
| 128 |
+
class TagBasedMiddleware(Middleware):
|
| 129 |
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
| 130 |
+
# Access the tool object to check its metadata
|
| 131 |
+
if context.fastmcp_context:
|
| 132 |
+
try:
|
| 133 |
+
tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name)
|
| 134 |
+
|
| 135 |
+
# Check if this tool has a "private" tag
|
| 136 |
+
if "private" in tool.tags:
|
| 137 |
+
raise ToolError("Access denied: private tool")
|
| 138 |
+
|
| 139 |
+
# Check if tool is enabled
|
| 140 |
+
if not tool.enabled:
|
| 141 |
+
raise ToolError("Tool is currently disabled")
|
| 142 |
+
|
| 143 |
+
except Exception:
|
| 144 |
+
# Tool not found or other error - let execution continue
|
| 145 |
+
# and handle the error naturally
|
| 146 |
+
pass
|
| 147 |
+
|
| 148 |
+
return await call_next(context)
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
The same pattern works for resources and prompts:
|
| 152 |
+
|
| 153 |
+
```python
|
| 154 |
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
| 155 |
+
from fastmcp.exceptions import ResourceError, PromptError
|
| 156 |
+
|
| 157 |
+
class ComponentAccessMiddleware(Middleware):
|
| 158 |
+
async def on_read_resource(self, context: MiddlewareContext, call_next):
|
| 159 |
+
if context.fastmcp_context:
|
| 160 |
+
try:
|
| 161 |
+
resource = await context.fastmcp_context.fastmcp.get_resource(context.message.uri)
|
| 162 |
+
if "restricted" in resource.tags:
|
| 163 |
+
raise ResourceError("Access denied: restricted resource")
|
| 164 |
+
except Exception:
|
| 165 |
+
pass
|
| 166 |
+
return await call_next(context)
|
| 167 |
+
|
| 168 |
+
async def on_get_prompt(self, context: MiddlewareContext, call_next):
|
| 169 |
+
if context.fastmcp_context:
|
| 170 |
+
try:
|
| 171 |
+
prompt = await context.fastmcp_context.fastmcp.get_prompt(context.message.name)
|
| 172 |
+
if not prompt.enabled:
|
| 173 |
+
raise PromptError("Prompt is currently disabled")
|
| 174 |
+
except Exception:
|
| 175 |
+
pass
|
| 176 |
+
return await call_next(context)
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
### Working with Listing Results
|
| 180 |
+
|
| 181 |
+
For listing operations, you can inspect and modify the FastMCP components directly:
|
| 182 |
+
|
| 183 |
+
```python
|
| 184 |
+
from fastmcp.server.middleware import Middleware, MiddlewareContext, ListToolsResult
|
| 185 |
+
|
| 186 |
+
class ListingFilterMiddleware(Middleware):
|
| 187 |
+
async def on_list_tools(self, context: MiddlewareContext, call_next):
|
| 188 |
+
result = await call_next(context)
|
| 189 |
+
|
| 190 |
+
# Filter out tools with "private" tag
|
| 191 |
+
filtered_tools = {
|
| 192 |
+
name: tool for name, tool in result.tools.items()
|
| 193 |
+
if "private" not in tool.tags
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
# Return modified result
|
| 197 |
+
return ListToolsResult(tools=filtered_tools)
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
This filtering happens before the components are converted to MCP format and returned to the client, so the tags (which are FastMCP-specific) are naturally stripped in the final response.
|
| 201 |
+
|
| 202 |
+
### Anatomy of a Hook
|
| 203 |
+
|
| 204 |
+
Every middleware hook follows the same pattern. Let's examine the `on_message` hook to understand the structure:
|
| 205 |
+
|
| 206 |
+
```python
|
| 207 |
+
async def on_message(self, context: MiddlewareContext, call_next):
|
| 208 |
+
# 1. Pre-processing: Inspect and optionally modify the request
|
| 209 |
+
print(f"Processing {context.method}")
|
| 210 |
+
|
| 211 |
+
# 2. Chain continuation: Call the next middleware/handler
|
| 212 |
+
result = await call_next(context)
|
| 213 |
+
|
| 214 |
+
# 3. Post-processing: Inspect and optionally modify the response
|
| 215 |
+
print(f"Completed {context.method}")
|
| 216 |
+
|
| 217 |
+
# 4. Return the result (potentially modified)
|
| 218 |
+
return result
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
### Hook Parameters
|
| 222 |
+
|
| 223 |
+
Every hook receives two parameters:
|
| 224 |
+
|
| 225 |
+
1. **`context: MiddlewareContext`** - Contains information about the current request:
|
| 226 |
+
- `context.method` - The MCP method name (e.g., "tools/call")
|
| 227 |
+
- `context.source` - Where the request came from ("client" or "server")
|
| 228 |
+
- `context.type` - Message type ("request" or "notification")
|
| 229 |
+
- `context.message` - The MCP message data
|
| 230 |
+
- `context.timestamp` - When the request was received
|
| 231 |
+
- `context.fastmcp_context` - FastMCP Context object (if available)
|
| 232 |
+
|
| 233 |
+
2. **`call_next`** - A function that continues the middleware chain. You **must** call this to proceed, unless you want to stop processing entirely.
|
| 234 |
+
|
| 235 |
+
### Control Flow
|
| 236 |
+
|
| 237 |
+
You have complete control over the request flow:
|
| 238 |
+
- **Continue processing**: Call `await call_next(context)` to proceed
|
| 239 |
+
- **Modify the request**: Change the context before calling `call_next`
|
| 240 |
+
- **Modify the response**: Change the result after calling `call_next`
|
| 241 |
+
- **Stop the chain**: Don't call `call_next` (rarely needed)
|
| 242 |
+
- **Handle errors**: Wrap `call_next` in try/catch blocks
|
| 243 |
+
|
| 244 |
+
## Creating Middleware
|
| 245 |
+
|
| 246 |
+
FastMCP middleware is implemented by subclassing the `Middleware` base class and overriding the hooks you need. You only need to implement the hooks that are relevant to your use case.
|
| 247 |
+
|
| 248 |
+
```python
|
| 249 |
+
from fastmcp import FastMCP
|
| 250 |
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
| 251 |
+
|
| 252 |
+
class LoggingMiddleware(Middleware):
|
| 253 |
+
"""Middleware that logs all MCP operations."""
|
| 254 |
+
|
| 255 |
+
async def on_message(self, context: MiddlewareContext, call_next):
|
| 256 |
+
"""Called for all MCP messages."""
|
| 257 |
+
print(f"Processing {context.method} from {context.source}")
|
| 258 |
+
|
| 259 |
+
result = await call_next(context)
|
| 260 |
+
|
| 261 |
+
print(f"Completed {context.method}")
|
| 262 |
+
return result
|
| 263 |
+
|
| 264 |
+
# Add middleware to your server
|
| 265 |
+
mcp = FastMCP("MyServer")
|
| 266 |
+
mcp.add_middleware(LoggingMiddleware())
|
| 267 |
+
```
|
| 268 |
+
|
| 269 |
+
This creates a basic logging middleware that will print information about every request that flows through your server.
|
| 270 |
+
|
| 271 |
+
## Adding Middleware to Your Server
|
| 272 |
+
|
| 273 |
+
### Single Middleware
|
| 274 |
+
|
| 275 |
+
Adding middleware to your server is straightforward:
|
| 276 |
+
|
| 277 |
+
```python
|
| 278 |
+
mcp = FastMCP("MyServer")
|
| 279 |
+
mcp.add_middleware(LoggingMiddleware())
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
### Multiple Middleware
|
| 283 |
+
|
| 284 |
+
Middleware executes in the order it's added to the server. The first middleware added runs first on the way in, and last on the way out:
|
| 285 |
+
|
| 286 |
+
```python
|
| 287 |
+
mcp = FastMCP("MyServer")
|
| 288 |
+
|
| 289 |
+
mcp.add_middleware(AuthenticationMiddleware("secret-token"))
|
| 290 |
+
mcp.add_middleware(PerformanceMiddleware())
|
| 291 |
+
mcp.add_middleware(LoggingMiddleware())
|
| 292 |
+
```
|
| 293 |
+
|
| 294 |
+
This creates the following execution flow:
|
| 295 |
+
1. AuthenticationMiddleware (pre-processing)
|
| 296 |
+
2. PerformanceMiddleware (pre-processing)
|
| 297 |
+
3. LoggingMiddleware (pre-processing)
|
| 298 |
+
4. Actual tool/resource handler
|
| 299 |
+
5. LoggingMiddleware (post-processing)
|
| 300 |
+
6. PerformanceMiddleware (post-processing)
|
| 301 |
+
7. AuthenticationMiddleware (post-processing)
|
| 302 |
+
|
| 303 |
+
## Server Composition and Middleware
|
| 304 |
+
|
| 305 |
+
When using [Server Composition](/servers/composition) with `mount` or `import_server`, middleware behavior follows these rules:
|
| 306 |
+
|
| 307 |
+
1. **Parent server middleware** runs for all requests, including those routed to mounted servers
|
| 308 |
+
2. **Mounted server middleware** only runs for requests handled by that specific server
|
| 309 |
+
3. **Middleware order** is preserved within each server
|
| 310 |
+
|
| 311 |
+
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.
|
| 312 |
+
|
| 313 |
+
```python
|
| 314 |
+
# Parent server with middleware
|
| 315 |
+
parent = FastMCP("Parent")
|
| 316 |
+
parent.add_middleware(AuthenticationMiddleware("token"))
|
| 317 |
+
|
| 318 |
+
# Child server with its own middleware
|
| 319 |
+
child = FastMCP("Child")
|
| 320 |
+
child.add_middleware(LoggingMiddleware())
|
| 321 |
+
|
| 322 |
+
@child.tool
|
| 323 |
+
def child_tool() -> str:
|
| 324 |
+
return "from child"
|
| 325 |
+
|
| 326 |
+
# Mount the child server
|
| 327 |
+
parent.mount(child, prefix="child")
|
| 328 |
+
```
|
| 329 |
+
|
| 330 |
+
When a client calls "child_tool", the request will flow through the parent's authentication middleware first, then route to the child server where it will go through the child's logging middleware.
|
| 331 |
+
|
| 332 |
+
## Examples
|
| 333 |
+
|
| 334 |
+
### Authentication Middleware
|
| 335 |
+
|
| 336 |
+
This middleware checks for a valid authorization token on all requests:
|
| 337 |
+
|
| 338 |
+
```python
|
| 339 |
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
| 340 |
+
from fastmcp.exceptions import ToolError
|
| 341 |
+
|
| 342 |
+
class AuthenticationMiddleware(Middleware):
|
| 343 |
+
def __init__(self, required_token: str):
|
| 344 |
+
self.required_token = required_token
|
| 345 |
+
|
| 346 |
+
async def on_request(self, context: MiddlewareContext, call_next):
|
| 347 |
+
if hasattr(context, 'fastmcp_context') and context.fastmcp_context:
|
| 348 |
+
try:
|
| 349 |
+
request = context.fastmcp_context.get_http_request()
|
| 350 |
+
auth_header = request.headers.get("Authorization")
|
| 351 |
+
|
| 352 |
+
if not auth_header or not auth_header.startswith("Bearer "):
|
| 353 |
+
raise ToolError("Missing or invalid authorization header")
|
| 354 |
+
|
| 355 |
+
token = auth_header.split(" ", 1)[1]
|
| 356 |
+
if token != self.required_token:
|
| 357 |
+
raise ToolError("Invalid authentication token")
|
| 358 |
+
|
| 359 |
+
except Exception:
|
| 360 |
+
pass
|
| 361 |
+
|
| 362 |
+
return await call_next(context)
|
| 363 |
+
|
| 364 |
+
# Usage
|
| 365 |
+
mcp = FastMCP("SecureServer")
|
| 366 |
+
mcp.add_middleware(AuthenticationMiddleware("secret-token-123"))
|
| 367 |
+
```
|
| 368 |
+
|
| 369 |
+
### Performance Monitoring Middleware
|
| 370 |
+
|
| 371 |
+
This middleware tracks how long tools take to execute:
|
| 372 |
+
|
| 373 |
+
```python
|
| 374 |
+
import time
|
| 375 |
+
import logging
|
| 376 |
+
|
| 377 |
+
class PerformanceMiddleware(Middleware):
|
| 378 |
+
def __init__(self):
|
| 379 |
+
self.logger = logging.getLogger("performance")
|
| 380 |
+
|
| 381 |
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
| 382 |
+
tool_name = context.message.name
|
| 383 |
+
start_time = time.time()
|
| 384 |
+
|
| 385 |
+
try:
|
| 386 |
+
result = await call_next(context)
|
| 387 |
+
execution_time = time.time() - start_time
|
| 388 |
+
|
| 389 |
+
self.logger.info(
|
| 390 |
+
f"Tool {tool_name} completed in {execution_time:.3f}s"
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
return result
|
| 394 |
+
|
| 395 |
+
except Exception as e:
|
| 396 |
+
execution_time = time.time() - start_time
|
| 397 |
+
self.logger.error(
|
| 398 |
+
f"Tool {tool_name} failed after {execution_time:.3f}s: {e}"
|
| 399 |
+
)
|
| 400 |
+
raise
|
| 401 |
+
```
|
| 402 |
+
|
| 403 |
+
### Request Transformation Middleware
|
| 404 |
+
|
| 405 |
+
This middleware adds metadata to tool calls:
|
| 406 |
+
|
| 407 |
+
```python
|
| 408 |
+
class TransformationMiddleware(Middleware):
|
| 409 |
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
| 410 |
+
if hasattr(context.message, 'arguments'):
|
| 411 |
+
args = context.message.arguments or {}
|
| 412 |
+
args['_middleware_timestamp'] = context.timestamp.isoformat()
|
| 413 |
+
|
| 414 |
+
modified_context = context.copy(
|
| 415 |
+
message=context.message.model_copy(update={'arguments': args})
|
| 416 |
+
)
|
| 417 |
+
else:
|
| 418 |
+
modified_context = context
|
| 419 |
+
|
| 420 |
+
return await call_next(modified_context)
|
| 421 |
+
```
|
src/fastmcp/prompts/prompt_manager.py
CHANGED
|
@@ -13,7 +13,7 @@ from fastmcp.settings import DuplicateBehavior
|
|
| 13 |
from fastmcp.utilities.logging import get_logger
|
| 14 |
|
| 15 |
if TYPE_CHECKING:
|
| 16 |
-
|
| 17 |
|
| 18 |
logger = get_logger(__name__)
|
| 19 |
|
|
@@ -27,6 +27,7 @@ class PromptManager:
|
|
| 27 |
mask_error_details: bool | None = None,
|
| 28 |
):
|
| 29 |
self._prompts: dict[str, Prompt] = {}
|
|
|
|
| 30 |
self.mask_error_details = mask_error_details or settings.mask_error_details
|
| 31 |
|
| 32 |
# Default to "warn" if None is provided
|
|
@@ -41,15 +42,74 @@ class PromptManager:
|
|
| 41 |
|
| 42 |
self.duplicate_behavior = duplicate_behavior
|
| 43 |
|
| 44 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
"""Get prompt by key."""
|
| 46 |
-
|
| 47 |
-
|
|
|
|
| 48 |
raise NotFoundError(f"Unknown prompt: {key}")
|
| 49 |
|
| 50 |
-
def get_prompts(self) -> dict[str, Prompt]:
|
| 51 |
-
"""
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
def add_prompt_from_fn(
|
| 55 |
self,
|
|
@@ -71,24 +131,22 @@ class PromptManager:
|
|
| 71 |
)
|
| 72 |
return self.add_prompt(prompt) # type: ignore
|
| 73 |
|
| 74 |
-
def add_prompt(self, prompt: Prompt
|
| 75 |
"""Add a prompt to the manager."""
|
| 76 |
-
key = key or prompt.name
|
| 77 |
-
|
| 78 |
# Check for duplicates
|
| 79 |
-
existing = self._prompts.get(key)
|
| 80 |
if existing:
|
| 81 |
if self.duplicate_behavior == "warn":
|
| 82 |
-
logger.warning(f"Prompt already exists: {key}")
|
| 83 |
-
self._prompts[key] = prompt
|
| 84 |
elif self.duplicate_behavior == "replace":
|
| 85 |
-
self._prompts[key] = prompt
|
| 86 |
elif self.duplicate_behavior == "error":
|
| 87 |
-
raise ValueError(f"Prompt already exists: {key}")
|
| 88 |
elif self.duplicate_behavior == "ignore":
|
| 89 |
return existing
|
| 90 |
else:
|
| 91 |
-
self._prompts[key] = prompt
|
| 92 |
return prompt
|
| 93 |
|
| 94 |
async def render_prompt(
|
|
@@ -96,30 +154,48 @@ class PromptManager:
|
|
| 96 |
name: str,
|
| 97 |
arguments: dict[str, Any] | None = None,
|
| 98 |
) -> GetPromptResult:
|
| 99 |
-
"""
|
| 100 |
-
prompt
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
raise
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from fastmcp.utilities.logging import get_logger
|
| 14 |
|
| 15 |
if TYPE_CHECKING:
|
| 16 |
+
from fastmcp.server.server import MountedServer
|
| 17 |
|
| 18 |
logger = get_logger(__name__)
|
| 19 |
|
|
|
|
| 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
|
|
|
|
| 42 |
|
| 43 |
self.duplicate_behavior = duplicate_behavior
|
| 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 |
+
"""
|
| 51 |
+
The single, consolidated recursive method for fetching prompts. The 'via_server'
|
| 52 |
+
parameter determines the communication path.
|
| 53 |
+
|
| 54 |
+
- via_server=False: Manager-to-manager path for complete, unfiltered inventory
|
| 55 |
+
- via_server=True: Server-to-server path for filtered MCP requests
|
| 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
|
| 63 |
+
child_results = await mounted.server._list_prompts()
|
| 64 |
+
else:
|
| 65 |
+
# Use the manager-to-manager unfiltered path
|
| 66 |
+
child_results = await mounted.server._prompt_manager.list_prompts()
|
| 67 |
+
|
| 68 |
+
# The combination logic is the same for both paths
|
| 69 |
+
child_dict = {p.key: p for p in child_results}
|
| 70 |
+
if mounted.prefix:
|
| 71 |
+
for prompt in child_dict.values():
|
| 72 |
+
prefixed_prompt = prompt.with_key(
|
| 73 |
+
f"{mounted.prefix}_{prompt.key}"
|
| 74 |
+
)
|
| 75 |
+
all_prompts[prefixed_prompt.key] = prefixed_prompt
|
| 76 |
+
else:
|
| 77 |
+
all_prompts.update(child_dict)
|
| 78 |
+
except Exception as e:
|
| 79 |
+
# Skip failed mounts silently, matches existing behavior
|
| 80 |
+
logger.warning(
|
| 81 |
+
f"Failed to get prompts from mounted server '{mounted.prefix}': {e}"
|
| 82 |
+
)
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
# Finally, add local prompts, which always take precedence
|
| 86 |
+
all_prompts.update(self._prompts)
|
| 87 |
+
return all_prompts
|
| 88 |
+
|
| 89 |
+
async def has_prompt(self, key: str) -> bool:
|
| 90 |
+
"""Check if a prompt exists."""
|
| 91 |
+
prompts = await self.get_prompts()
|
| 92 |
+
return key in prompts
|
| 93 |
+
|
| 94 |
+
async def get_prompt(self, key: str) -> Prompt:
|
| 95 |
"""Get prompt by key."""
|
| 96 |
+
prompts = await self.get_prompts()
|
| 97 |
+
if key in prompts:
|
| 98 |
+
return prompts[key]
|
| 99 |
raise NotFoundError(f"Unknown prompt: {key}")
|
| 100 |
|
| 101 |
+
async def get_prompts(self) -> dict[str, Prompt]:
|
| 102 |
+
"""
|
| 103 |
+
Gets the complete, unfiltered inventory of all prompts.
|
| 104 |
+
"""
|
| 105 |
+
return await self._load_prompts(via_server=False)
|
| 106 |
+
|
| 107 |
+
async def list_prompts(self) -> list[Prompt]:
|
| 108 |
+
"""
|
| 109 |
+
Lists all prompts, applying protocol filtering.
|
| 110 |
+
"""
|
| 111 |
+
prompts_dict = await self._load_prompts(via_server=True)
|
| 112 |
+
return list(prompts_dict.values())
|
| 113 |
|
| 114 |
def add_prompt_from_fn(
|
| 115 |
self,
|
|
|
|
| 131 |
)
|
| 132 |
return self.add_prompt(prompt) # type: ignore
|
| 133 |
|
| 134 |
+
def add_prompt(self, prompt: Prompt) -> Prompt:
|
| 135 |
"""Add a prompt to the manager."""
|
|
|
|
|
|
|
| 136 |
# Check for duplicates
|
| 137 |
+
existing = self._prompts.get(prompt.key)
|
| 138 |
if existing:
|
| 139 |
if self.duplicate_behavior == "warn":
|
| 140 |
+
logger.warning(f"Prompt already exists: {prompt.key}")
|
| 141 |
+
self._prompts[prompt.key] = prompt
|
| 142 |
elif self.duplicate_behavior == "replace":
|
| 143 |
+
self._prompts[prompt.key] = prompt
|
| 144 |
elif self.duplicate_behavior == "error":
|
| 145 |
+
raise ValueError(f"Prompt already exists: {prompt.key}")
|
| 146 |
elif self.duplicate_behavior == "ignore":
|
| 147 |
return existing
|
| 148 |
else:
|
| 149 |
+
self._prompts[prompt.key] = prompt
|
| 150 |
return prompt
|
| 151 |
|
| 152 |
async def render_prompt(
|
|
|
|
| 154 |
name: str,
|
| 155 |
arguments: dict[str, Any] | None = None,
|
| 156 |
) -> GetPromptResult:
|
| 157 |
+
"""
|
| 158 |
+
Internal API for servers: Finds and renders a prompt, respecting the
|
| 159 |
+
filtered protocol path.
|
| 160 |
+
"""
|
| 161 |
+
# 1. Check local prompts first. The server will have already applied its filter.
|
| 162 |
+
if name in self._prompts:
|
| 163 |
+
prompt = await self.get_prompt(name)
|
| 164 |
+
if not prompt:
|
| 165 |
+
raise NotFoundError(f"Unknown prompt: {name}")
|
| 166 |
+
|
| 167 |
+
try:
|
| 168 |
+
messages = await prompt.render(arguments)
|
| 169 |
+
return GetPromptResult(
|
| 170 |
+
description=prompt.description, messages=messages
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
# Pass through PromptErrors as-is
|
| 174 |
+
except PromptError as e:
|
| 175 |
+
logger.exception(f"Error rendering prompt {name!r}: {e}")
|
| 176 |
+
raise e
|
| 177 |
+
|
| 178 |
+
# Handle other exceptions
|
| 179 |
+
except Exception as e:
|
| 180 |
+
logger.exception(f"Error rendering prompt {name!r}: {e}")
|
| 181 |
+
if self.mask_error_details:
|
| 182 |
+
# Mask internal details
|
| 183 |
+
raise PromptError(f"Error rendering prompt {name!r}") from e
|
| 184 |
+
else:
|
| 185 |
+
# Include original error details
|
| 186 |
+
raise PromptError(f"Error rendering prompt {name!r}: {e}") from e
|
| 187 |
+
|
| 188 |
+
# 2. Check mounted servers using the filtered protocol path.
|
| 189 |
+
for mounted in reversed(self._mounted_servers):
|
| 190 |
+
prompt_key = name
|
| 191 |
+
if mounted.prefix:
|
| 192 |
+
if name.startswith(f"{mounted.prefix}_"):
|
| 193 |
+
prompt_key = name.removeprefix(f"{mounted.prefix}_")
|
| 194 |
+
else:
|
| 195 |
+
continue
|
| 196 |
+
try:
|
| 197 |
+
return await mounted.server._get_prompt(prompt_key, arguments)
|
| 198 |
+
except NotFoundError:
|
| 199 |
+
continue
|
| 200 |
+
|
| 201 |
+
raise NotFoundError(f"Unknown prompt: {name}")
|
src/fastmcp/resources/resource_manager.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
"""Resource manager functionality."""
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import inspect
|
| 4 |
import warnings
|
| 5 |
from collections.abc import Callable
|
| 6 |
-
from typing import Any
|
| 7 |
|
| 8 |
from pydantic import AnyUrl
|
| 9 |
|
|
@@ -17,6 +19,9 @@ from fastmcp.resources.template import (
|
|
| 17 |
from fastmcp.settings import DuplicateBehavior
|
| 18 |
from fastmcp.utilities.logging import get_logger
|
| 19 |
|
|
|
|
|
|
|
|
|
|
| 20 |
logger = get_logger(__name__)
|
| 21 |
|
| 22 |
|
|
@@ -38,6 +43,7 @@ class ResourceManager:
|
|
| 38 |
"""
|
| 39 |
self._resources: dict[str, Resource] = {}
|
| 40 |
self._templates: dict[str, ResourceTemplate] = {}
|
|
|
|
| 41 |
self.mask_error_details = mask_error_details or settings.mask_error_details
|
| 42 |
|
| 43 |
# Default to "warn" if None is provided
|
|
@@ -51,6 +57,128 @@ class ResourceManager:
|
|
| 51 |
)
|
| 52 |
self.duplicate_behavior = duplicate_behavior
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
def add_resource_or_template_from_fn(
|
| 55 |
self,
|
| 56 |
fn: Callable[..., Any],
|
|
@@ -139,35 +267,26 @@ class ResourceManager:
|
|
| 139 |
)
|
| 140 |
return self.add_resource(resource)
|
| 141 |
|
| 142 |
-
def add_resource(self, resource: Resource
|
| 143 |
"""Add a resource to the manager.
|
| 144 |
|
| 145 |
Args:
|
| 146 |
-
resource: A Resource instance to add
|
| 147 |
-
|
|
|
|
| 148 |
"""
|
| 149 |
-
|
| 150 |
-
logger.debug(
|
| 151 |
-
"Adding resource",
|
| 152 |
-
extra={
|
| 153 |
-
"uri": resource.uri,
|
| 154 |
-
"storage_key": storage_key,
|
| 155 |
-
"type": type(resource).__name__,
|
| 156 |
-
"resource_name": resource.name,
|
| 157 |
-
},
|
| 158 |
-
)
|
| 159 |
-
existing = self._resources.get(storage_key)
|
| 160 |
if existing:
|
| 161 |
if self.duplicate_behavior == "warn":
|
| 162 |
-
logger.warning(f"Resource already exists: {
|
| 163 |
-
self._resources[
|
| 164 |
elif self.duplicate_behavior == "replace":
|
| 165 |
-
self._resources[
|
| 166 |
elif self.duplicate_behavior == "error":
|
| 167 |
-
raise ValueError(f"Resource already exists: {
|
| 168 |
elif self.duplicate_behavior == "ignore":
|
| 169 |
return existing
|
| 170 |
-
self._resources[
|
| 171 |
return resource
|
| 172 |
|
| 173 |
def add_template_from_fn(
|
|
@@ -197,52 +316,47 @@ class ResourceManager:
|
|
| 197 |
)
|
| 198 |
return self.add_template(template)
|
| 199 |
|
| 200 |
-
def add_template(
|
| 201 |
-
self, template: ResourceTemplate, key: str | None = None
|
| 202 |
-
) -> ResourceTemplate:
|
| 203 |
"""Add a template to the manager.
|
| 204 |
|
| 205 |
Args:
|
| 206 |
-
template: A ResourceTemplate instance to add
|
| 207 |
-
|
|
|
|
| 208 |
|
| 209 |
Returns:
|
| 210 |
The added template. If a template with the same URI already exists,
|
| 211 |
returns the existing template.
|
| 212 |
"""
|
| 213 |
-
|
| 214 |
-
storage_key = key or uri_template_str
|
| 215 |
-
logger.debug(
|
| 216 |
-
"Adding template",
|
| 217 |
-
extra={
|
| 218 |
-
"uri_template": uri_template_str,
|
| 219 |
-
"storage_key": storage_key,
|
| 220 |
-
"type": type(template).__name__,
|
| 221 |
-
"template_name": template.name,
|
| 222 |
-
},
|
| 223 |
-
)
|
| 224 |
-
existing = self._templates.get(storage_key)
|
| 225 |
if existing:
|
| 226 |
if self.duplicate_behavior == "warn":
|
| 227 |
-
logger.warning(f"Template already exists: {
|
| 228 |
-
self._templates[
|
| 229 |
elif self.duplicate_behavior == "replace":
|
| 230 |
-
self._templates[
|
| 231 |
elif self.duplicate_behavior == "error":
|
| 232 |
-
raise ValueError(f"Template already exists: {
|
| 233 |
elif self.duplicate_behavior == "ignore":
|
| 234 |
return existing
|
| 235 |
-
self._templates[
|
| 236 |
return template
|
| 237 |
|
| 238 |
-
def has_resource(self, uri: AnyUrl | str) -> bool:
|
| 239 |
"""Check if a resource exists."""
|
| 240 |
uri_str = str(uri)
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
| 242 |
return True
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
| 244 |
if match_uri_template(uri_str, template_key):
|
| 245 |
return True
|
|
|
|
| 246 |
return False
|
| 247 |
|
| 248 |
async def get_resource(self, uri: AnyUrl | str) -> Resource:
|
|
@@ -257,12 +371,14 @@ class ResourceManager:
|
|
| 257 |
uri_str = str(uri)
|
| 258 |
logger.debug("Getting resource", extra={"uri": uri_str})
|
| 259 |
|
| 260 |
-
# First check concrete resources
|
| 261 |
-
|
|
|
|
| 262 |
return resource
|
| 263 |
|
| 264 |
-
# Then check templates - use the utility function to match against storage keys
|
| 265 |
-
|
|
|
|
| 266 |
# Try to match against the storage key (which might be a custom key)
|
| 267 |
if params := match_uri_template(uri_str, storage_key):
|
| 268 |
try:
|
|
@@ -289,31 +405,88 @@ class ResourceManager:
|
|
| 289 |
raise NotFoundError(f"Unknown resource: {uri_str}")
|
| 290 |
|
| 291 |
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
|
| 292 |
-
"""
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
# raise ResourceErrors as-is
|
| 299 |
-
except ResourceError as e:
|
| 300 |
-
logger.error(f"Error reading resource {uri!r}: {e}")
|
| 301 |
-
raise e
|
| 302 |
-
|
| 303 |
-
# Handle other exceptions
|
| 304 |
-
except Exception as e:
|
| 305 |
-
logger.error(f"Error reading resource {uri!r}: {e}")
|
| 306 |
-
if self.mask_error_details:
|
| 307 |
-
# Mask internal details
|
| 308 |
-
raise ResourceError(f"Error reading resource {uri!r}") from e
|
| 309 |
-
else:
|
| 310 |
-
# Include original error details
|
| 311 |
-
raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
|
| 312 |
-
|
| 313 |
-
def get_resources(self) -> dict[str, Resource]:
|
| 314 |
-
"""Get all registered resources, keyed by URI."""
|
| 315 |
-
return self._resources
|
| 316 |
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Resource manager functionality."""
|
| 2 |
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
import inspect
|
| 6 |
import warnings
|
| 7 |
from collections.abc import Callable
|
| 8 |
+
from typing import TYPE_CHECKING, Any
|
| 9 |
|
| 10 |
from pydantic import AnyUrl
|
| 11 |
|
|
|
|
| 19 |
from fastmcp.settings import DuplicateBehavior
|
| 20 |
from fastmcp.utilities.logging import get_logger
|
| 21 |
|
| 22 |
+
if TYPE_CHECKING:
|
| 23 |
+
from fastmcp.server.server import MountedServer
|
| 24 |
+
|
| 25 |
logger = get_logger(__name__)
|
| 26 |
|
| 27 |
|
|
|
|
| 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
|
|
|
|
| 57 |
)
|
| 58 |
self.duplicate_behavior = duplicate_behavior
|
| 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."""
|
| 66 |
+
return await self._load_resources(via_server=False)
|
| 67 |
+
|
| 68 |
+
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
| 69 |
+
"""Get all registered templates, keyed by URI template."""
|
| 70 |
+
return await self._load_resource_templates(via_server=False)
|
| 71 |
+
|
| 72 |
+
async def _load_resources(self, *, via_server: bool = False) -> dict[str, Resource]:
|
| 73 |
+
"""
|
| 74 |
+
The single, consolidated recursive method for fetching resources. The 'via_server'
|
| 75 |
+
parameter determines the communication path.
|
| 76 |
+
|
| 77 |
+
- via_server=False: Manager-to-manager path for complete, unfiltered inventory
|
| 78 |
+
- via_server=True: Server-to-server path for filtered MCP requests
|
| 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
|
| 86 |
+
child_resources_list = await mounted.server._list_resources()
|
| 87 |
+
child_resources = {
|
| 88 |
+
resource.key: resource for resource in child_resources_list
|
| 89 |
+
}
|
| 90 |
+
else:
|
| 91 |
+
# Use the manager-to-manager unfiltered path
|
| 92 |
+
child_resources = (
|
| 93 |
+
await mounted.server._resource_manager.get_resources()
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Apply prefix if needed
|
| 97 |
+
if mounted.prefix:
|
| 98 |
+
from fastmcp.server.server import add_resource_prefix
|
| 99 |
+
|
| 100 |
+
for uri, resource in child_resources.items():
|
| 101 |
+
prefixed_uri = add_resource_prefix(
|
| 102 |
+
uri, mounted.prefix, mounted.resource_prefix_format
|
| 103 |
+
)
|
| 104 |
+
# Create a copy of the resource with the prefixed key
|
| 105 |
+
prefixed_resource = resource.with_key(prefixed_uri)
|
| 106 |
+
all_resources[prefixed_uri] = prefixed_resource
|
| 107 |
+
else:
|
| 108 |
+
all_resources.update(child_resources)
|
| 109 |
+
except Exception as e:
|
| 110 |
+
# Skip failed mounts silently, matches existing behavior
|
| 111 |
+
logger.warning(
|
| 112 |
+
f"Failed to get resources from mounted server '{mounted.prefix}': {e}"
|
| 113 |
+
)
|
| 114 |
+
continue
|
| 115 |
+
|
| 116 |
+
# Finally, add local resources, which always take precedence
|
| 117 |
+
all_resources.update(self._resources)
|
| 118 |
+
return all_resources
|
| 119 |
+
|
| 120 |
+
async def _load_resource_templates(
|
| 121 |
+
self, *, via_server: bool = False
|
| 122 |
+
) -> dict[str, ResourceTemplate]:
|
| 123 |
+
"""
|
| 124 |
+
The single, consolidated recursive method for fetching templates. The 'via_server'
|
| 125 |
+
parameter determines the communication path.
|
| 126 |
+
|
| 127 |
+
- via_server=False: Manager-to-manager path for complete, unfiltered inventory
|
| 128 |
+
- via_server=True: Server-to-server path for filtered MCP requests
|
| 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
|
| 136 |
+
child_templates = await mounted.server._list_resource_templates()
|
| 137 |
+
else:
|
| 138 |
+
# Use the manager-to-manager unfiltered path
|
| 139 |
+
child_templates = (
|
| 140 |
+
await mounted.server._resource_manager.list_resource_templates()
|
| 141 |
+
)
|
| 142 |
+
child_dict = {template.key: template for template in child_templates}
|
| 143 |
+
|
| 144 |
+
# Apply prefix if needed
|
| 145 |
+
if mounted.prefix:
|
| 146 |
+
from fastmcp.server.server import add_resource_prefix
|
| 147 |
+
|
| 148 |
+
for uri_template, template in child_dict.items():
|
| 149 |
+
prefixed_uri_template = add_resource_prefix(
|
| 150 |
+
uri_template, mounted.prefix, mounted.resource_prefix_format
|
| 151 |
+
)
|
| 152 |
+
# Create a copy of the template with the prefixed key
|
| 153 |
+
prefixed_template = template.with_key(prefixed_uri_template)
|
| 154 |
+
all_templates[prefixed_uri_template] = prefixed_template
|
| 155 |
+
else:
|
| 156 |
+
all_templates.update(child_dict)
|
| 157 |
+
except Exception as e:
|
| 158 |
+
# Skip failed mounts silently, matches existing behavior
|
| 159 |
+
logger.warning(
|
| 160 |
+
f"Failed to get templates from mounted server '{mounted.prefix}': {e}"
|
| 161 |
+
)
|
| 162 |
+
continue
|
| 163 |
+
|
| 164 |
+
# Finally, add local templates, which always take precedence
|
| 165 |
+
all_templates.update(self._templates)
|
| 166 |
+
return all_templates
|
| 167 |
+
|
| 168 |
+
async def list_resources(self) -> list[Resource]:
|
| 169 |
+
"""
|
| 170 |
+
Lists all resources, applying protocol filtering.
|
| 171 |
+
"""
|
| 172 |
+
resources_dict = await self._load_resources(via_server=True)
|
| 173 |
+
return list(resources_dict.values())
|
| 174 |
+
|
| 175 |
+
async def list_resource_templates(self) -> list[ResourceTemplate]:
|
| 176 |
+
"""
|
| 177 |
+
Lists all templates, applying protocol filtering.
|
| 178 |
+
"""
|
| 179 |
+
templates_dict = await self._load_resource_templates(via_server=True)
|
| 180 |
+
return list(templates_dict.values())
|
| 181 |
+
|
| 182 |
def add_resource_or_template_from_fn(
|
| 183 |
self,
|
| 184 |
fn: Callable[..., Any],
|
|
|
|
| 267 |
)
|
| 268 |
return self.add_resource(resource)
|
| 269 |
|
| 270 |
+
def add_resource(self, resource: Resource) -> Resource:
|
| 271 |
"""Add a resource to the manager.
|
| 272 |
|
| 273 |
Args:
|
| 274 |
+
resource: A Resource instance to add. The resource's .key attribute
|
| 275 |
+
will be used as the storage key. To overwrite it, call
|
| 276 |
+
Resource.with_key() before calling this method.
|
| 277 |
"""
|
| 278 |
+
existing = self._resources.get(resource.key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
if existing:
|
| 280 |
if self.duplicate_behavior == "warn":
|
| 281 |
+
logger.warning(f"Resource already exists: {resource.key}")
|
| 282 |
+
self._resources[resource.key] = resource
|
| 283 |
elif self.duplicate_behavior == "replace":
|
| 284 |
+
self._resources[resource.key] = resource
|
| 285 |
elif self.duplicate_behavior == "error":
|
| 286 |
+
raise ValueError(f"Resource already exists: {resource.key}")
|
| 287 |
elif self.duplicate_behavior == "ignore":
|
| 288 |
return existing
|
| 289 |
+
self._resources[resource.key] = resource
|
| 290 |
return resource
|
| 291 |
|
| 292 |
def add_template_from_fn(
|
|
|
|
| 316 |
)
|
| 317 |
return self.add_template(template)
|
| 318 |
|
| 319 |
+
def add_template(self, template: ResourceTemplate) -> ResourceTemplate:
|
|
|
|
|
|
|
| 320 |
"""Add a template to the manager.
|
| 321 |
|
| 322 |
Args:
|
| 323 |
+
template: A ResourceTemplate instance to add. The template's .key attribute
|
| 324 |
+
will be used as the storage key. To overwrite it, call
|
| 325 |
+
ResourceTemplate.with_key() before calling this method.
|
| 326 |
|
| 327 |
Returns:
|
| 328 |
The added template. If a template with the same URI already exists,
|
| 329 |
returns the existing template.
|
| 330 |
"""
|
| 331 |
+
existing = self._templates.get(template.key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
if existing:
|
| 333 |
if self.duplicate_behavior == "warn":
|
| 334 |
+
logger.warning(f"Template already exists: {template.key}")
|
| 335 |
+
self._templates[template.key] = template
|
| 336 |
elif self.duplicate_behavior == "replace":
|
| 337 |
+
self._templates[template.key] = template
|
| 338 |
elif self.duplicate_behavior == "error":
|
| 339 |
+
raise ValueError(f"Template already exists: {template.key}")
|
| 340 |
elif self.duplicate_behavior == "ignore":
|
| 341 |
return existing
|
| 342 |
+
self._templates[template.key] = template
|
| 343 |
return template
|
| 344 |
|
| 345 |
+
async def has_resource(self, uri: AnyUrl | str) -> bool:
|
| 346 |
"""Check if a resource exists."""
|
| 347 |
uri_str = str(uri)
|
| 348 |
+
|
| 349 |
+
# First check concrete resources (local and mounted)
|
| 350 |
+
resources = await self.get_resources()
|
| 351 |
+
if uri_str in resources:
|
| 352 |
return True
|
| 353 |
+
|
| 354 |
+
# Then check templates (local and mounted) only if not found in concrete resources
|
| 355 |
+
templates = await self.get_resource_templates()
|
| 356 |
+
for template_key in templates.keys():
|
| 357 |
if match_uri_template(uri_str, template_key):
|
| 358 |
return True
|
| 359 |
+
|
| 360 |
return False
|
| 361 |
|
| 362 |
async def get_resource(self, uri: AnyUrl | str) -> Resource:
|
|
|
|
| 371 |
uri_str = str(uri)
|
| 372 |
logger.debug("Getting resource", extra={"uri": uri_str})
|
| 373 |
|
| 374 |
+
# First check concrete resources (local and mounted)
|
| 375 |
+
resources = await self.get_resources()
|
| 376 |
+
if resource := resources.get(uri_str):
|
| 377 |
return resource
|
| 378 |
|
| 379 |
+
# Then check templates (local and mounted) - use the utility function to match against storage keys
|
| 380 |
+
templates = await self.get_resource_templates()
|
| 381 |
+
for storage_key, template in templates.items():
|
| 382 |
# Try to match against the storage key (which might be a custom key)
|
| 383 |
if params := match_uri_template(uri_str, storage_key):
|
| 384 |
try:
|
|
|
|
| 405 |
raise NotFoundError(f"Unknown resource: {uri_str}")
|
| 406 |
|
| 407 |
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
|
| 408 |
+
"""
|
| 409 |
+
Internal API for servers: Finds and reads a resource, respecting the
|
| 410 |
+
filtered protocol path.
|
| 411 |
+
"""
|
| 412 |
+
uri_str = str(uri)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
|
| 414 |
+
# 1. Check local resources first. The server will have already applied its filter.
|
| 415 |
+
if uri_str in self._resources:
|
| 416 |
+
resource = await self.get_resource(uri_str)
|
| 417 |
+
if not resource:
|
| 418 |
+
raise NotFoundError(f"Resource {uri_str!r} not found")
|
| 419 |
+
|
| 420 |
+
try:
|
| 421 |
+
return await resource.read()
|
| 422 |
+
|
| 423 |
+
# raise ResourceErrors as-is
|
| 424 |
+
except ResourceError as e:
|
| 425 |
+
logger.exception(f"Error reading resource {uri_str!r}: {e}")
|
| 426 |
+
raise e
|
| 427 |
+
|
| 428 |
+
# Handle other exceptions
|
| 429 |
+
except Exception as e:
|
| 430 |
+
logger.exception(f"Error reading resource {uri_str!r}: {e}")
|
| 431 |
+
if self.mask_error_details:
|
| 432 |
+
# Mask internal details
|
| 433 |
+
raise ResourceError(f"Error reading resource {uri_str!r}") from e
|
| 434 |
+
else:
|
| 435 |
+
# Include original error details
|
| 436 |
+
raise ResourceError(
|
| 437 |
+
f"Error reading resource {uri_str!r}: {e}"
|
| 438 |
+
) from e
|
| 439 |
+
|
| 440 |
+
# 1b. Check local templates if not found in concrete resources
|
| 441 |
+
for key, template in self._templates.items():
|
| 442 |
+
if params := match_uri_template(uri_str, key):
|
| 443 |
+
try:
|
| 444 |
+
resource = await template.create_resource(uri_str, params=params)
|
| 445 |
+
return await resource.read()
|
| 446 |
+
except ResourceError as e:
|
| 447 |
+
logger.exception(
|
| 448 |
+
f"Error reading resource from template {uri_str!r}: {e}"
|
| 449 |
+
)
|
| 450 |
+
raise e
|
| 451 |
+
except Exception as e:
|
| 452 |
+
logger.exception(
|
| 453 |
+
f"Error reading resource from template {uri_str!r}: {e}"
|
| 454 |
+
)
|
| 455 |
+
if self.mask_error_details:
|
| 456 |
+
raise ResourceError(
|
| 457 |
+
f"Error reading resource from template {uri_str!r}"
|
| 458 |
+
) from e
|
| 459 |
+
else:
|
| 460 |
+
raise ResourceError(
|
| 461 |
+
f"Error reading resource from template {uri_str!r}: {e}"
|
| 462 |
+
) from e
|
| 463 |
+
|
| 464 |
+
# 2. Check mounted servers using the filtered protocol path.
|
| 465 |
+
from fastmcp.server.server import has_resource_prefix, remove_resource_prefix
|
| 466 |
+
|
| 467 |
+
for mounted in reversed(self._mounted_servers):
|
| 468 |
+
key = uri_str
|
| 469 |
+
try:
|
| 470 |
+
if mounted.prefix:
|
| 471 |
+
if has_resource_prefix(
|
| 472 |
+
key,
|
| 473 |
+
mounted.prefix,
|
| 474 |
+
mounted.resource_prefix_format,
|
| 475 |
+
):
|
| 476 |
+
key = remove_resource_prefix(
|
| 477 |
+
key,
|
| 478 |
+
mounted.prefix,
|
| 479 |
+
mounted.resource_prefix_format,
|
| 480 |
+
)
|
| 481 |
+
else:
|
| 482 |
+
continue
|
| 483 |
+
|
| 484 |
+
try:
|
| 485 |
+
result = await mounted.server._read_resource(key)
|
| 486 |
+
return result[0].content
|
| 487 |
+
except NotFoundError:
|
| 488 |
+
continue
|
| 489 |
+
except NotFoundError:
|
| 490 |
+
continue
|
| 491 |
+
|
| 492 |
+
raise NotFoundError(f"Resource {uri_str!r} not found.")
|
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],
|
|
@@ -128,6 +131,19 @@ class ResourceTemplate(FastMCPComponent):
|
|
| 128 |
}
|
| 129 |
return MCPResourceTemplate(**kwargs | overrides)
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
@property
|
| 132 |
def key(self) -> str:
|
| 133 |
"""
|
|
|
|
| 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],
|
|
|
|
| 131 |
}
|
| 132 |
return MCPResourceTemplate(**kwargs | overrides)
|
| 133 |
|
| 134 |
+
@classmethod
|
| 135 |
+
def from_mcp_template(cls, mcp_template: MCPResourceTemplate) -> ResourceTemplate:
|
| 136 |
+
"""Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object."""
|
| 137 |
+
# Note: This creates a simple ResourceTemplate instance. For function-based templates,
|
| 138 |
+
# the original function is lost, which is expected for remote templates.
|
| 139 |
+
return cls(
|
| 140 |
+
uri_template=mcp_template.uriTemplate,
|
| 141 |
+
name=mcp_template.name,
|
| 142 |
+
description=mcp_template.description,
|
| 143 |
+
mime_type=mcp_template.mimeType or "text/plain",
|
| 144 |
+
parameters={}, # Remote templates don't have local parameters
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
@property
|
| 148 |
def key(self) -> str:
|
| 149 |
"""
|
src/fastmcp/server/context.py
CHANGED
|
@@ -8,6 +8,7 @@ from dataclasses import dataclass
|
|
| 8 |
|
| 9 |
from mcp import LoggingLevel
|
| 10 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
|
|
| 11 |
from mcp.shared.context import RequestContext
|
| 12 |
from mcp.types import (
|
| 13 |
CreateMessageResult,
|
|
@@ -95,8 +96,14 @@ class Context:
|
|
| 95 |
|
| 96 |
@property
|
| 97 |
def request_context(self) -> RequestContext:
|
| 98 |
-
"""Access to the underlying request context.
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
async def report_progress(
|
| 102 |
self, progress: float, total: float | None = None, message: str | None = None
|
|
|
|
| 8 |
|
| 9 |
from mcp import LoggingLevel
|
| 10 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
| 11 |
+
from mcp.server.lowlevel.server import request_ctx
|
| 12 |
from mcp.shared.context import RequestContext
|
| 13 |
from mcp.types import (
|
| 14 |
CreateMessageResult,
|
|
|
|
| 96 |
|
| 97 |
@property
|
| 98 |
def request_context(self) -> RequestContext:
|
| 99 |
+
"""Access to the underlying request context.
|
| 100 |
+
|
| 101 |
+
If called outside of a request context, this will raise a ValueError.
|
| 102 |
+
"""
|
| 103 |
+
try:
|
| 104 |
+
return request_ctx.get()
|
| 105 |
+
except LookupError:
|
| 106 |
+
raise ValueError("Context is not available outside of a request")
|
| 107 |
|
| 108 |
async def report_progress(
|
| 109 |
self, progress: float, total: float | None = None, message: str | None = None
|
src/fastmcp/server/middleware.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from collections.abc import Awaitable
|
| 5 |
+
from dataclasses import dataclass, field, replace
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from functools import partial
|
| 8 |
+
from typing import (
|
| 9 |
+
TYPE_CHECKING,
|
| 10 |
+
Any,
|
| 11 |
+
Generic,
|
| 12 |
+
Literal,
|
| 13 |
+
Protocol,
|
| 14 |
+
TypeVar,
|
| 15 |
+
runtime_checkable,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
import mcp.types as mt
|
| 19 |
+
|
| 20 |
+
from fastmcp.prompts.prompt import Prompt
|
| 21 |
+
from fastmcp.resources.resource import Resource
|
| 22 |
+
from fastmcp.resources.template import ResourceTemplate
|
| 23 |
+
from fastmcp.tools.tool import Tool
|
| 24 |
+
|
| 25 |
+
if TYPE_CHECKING:
|
| 26 |
+
from fastmcp.server.context import Context
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
T = TypeVar("T")
|
| 32 |
+
R = TypeVar("R", covariant=True)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@runtime_checkable
|
| 36 |
+
class CallNext(Protocol[T, R]):
|
| 37 |
+
def __call__(self, context: MiddlewareContext[T]) -> Awaitable[R]: ...
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
ServerResultT = TypeVar(
|
| 41 |
+
"ServerResultT",
|
| 42 |
+
bound=mt.EmptyResult
|
| 43 |
+
| mt.InitializeResult
|
| 44 |
+
| mt.CompleteResult
|
| 45 |
+
| mt.GetPromptResult
|
| 46 |
+
| mt.ListPromptsResult
|
| 47 |
+
| mt.ListResourcesResult
|
| 48 |
+
| mt.ListResourceTemplatesResult
|
| 49 |
+
| mt.ReadResourceResult
|
| 50 |
+
| mt.CallToolResult
|
| 51 |
+
| mt.ListToolsResult,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass(kw_only=True)
|
| 56 |
+
class CallToolResult:
|
| 57 |
+
content: list[mt.Content]
|
| 58 |
+
isError: bool = False
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass(kw_only=True)
|
| 62 |
+
class ListToolsResult:
|
| 63 |
+
tools: dict[str, Tool]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass(kw_only=True)
|
| 67 |
+
class ListResourcesResult:
|
| 68 |
+
resources: list[Resource]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@dataclass(kw_only=True)
|
| 72 |
+
class ListResourceTemplatesResult:
|
| 73 |
+
resource_templates: list[ResourceTemplate]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@dataclass(kw_only=True)
|
| 77 |
+
class ListPromptsResult:
|
| 78 |
+
prompts: list[Prompt]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@runtime_checkable
|
| 82 |
+
class ServerResultProtocol(Protocol[ServerResultT]):
|
| 83 |
+
root: ServerResultT
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@dataclass(kw_only=True, frozen=True)
|
| 87 |
+
class MiddlewareContext(Generic[T]):
|
| 88 |
+
"""
|
| 89 |
+
Unified context for all middleware operations.
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
message: T
|
| 93 |
+
|
| 94 |
+
fastmcp_context: Context | None = None
|
| 95 |
+
|
| 96 |
+
# Common metadata
|
| 97 |
+
source: Literal["client", "server"] = "client"
|
| 98 |
+
type: Literal["request", "notification"] = "request"
|
| 99 |
+
method: str | None = None
|
| 100 |
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
| 101 |
+
|
| 102 |
+
def copy(self, **kwargs: Any) -> MiddlewareContext[T]:
|
| 103 |
+
return replace(self, **kwargs)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def make_middleware_wrapper(
|
| 107 |
+
middleware: Middleware, call_next: CallNext[T, R]
|
| 108 |
+
) -> CallNext[T, R]:
|
| 109 |
+
"""Create a wrapper that applies a single middleware to a context. The
|
| 110 |
+
closure bakes in the middleware and call_next function, so it can be
|
| 111 |
+
passed to other functions that expect a call_next function."""
|
| 112 |
+
|
| 113 |
+
async def wrapper(context: MiddlewareContext[T]) -> R:
|
| 114 |
+
return await middleware(context, call_next)
|
| 115 |
+
|
| 116 |
+
return wrapper
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class Middleware:
|
| 120 |
+
"""Base class for FastMCP middleware with dispatching hooks."""
|
| 121 |
+
|
| 122 |
+
async def __call__(
|
| 123 |
+
self,
|
| 124 |
+
context: MiddlewareContext[T],
|
| 125 |
+
call_next: CallNext[T, Any],
|
| 126 |
+
) -> Any:
|
| 127 |
+
"""Main entry point that orchestrates the pipeline."""
|
| 128 |
+
handler_chain = await self._dispatch_handler(
|
| 129 |
+
context,
|
| 130 |
+
call_next=call_next,
|
| 131 |
+
)
|
| 132 |
+
return await handler_chain(context)
|
| 133 |
+
|
| 134 |
+
async def _dispatch_handler(
|
| 135 |
+
self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]
|
| 136 |
+
) -> CallNext[Any, Any]:
|
| 137 |
+
"""Builds a chain of handlers for a given message."""
|
| 138 |
+
handler = call_next
|
| 139 |
+
|
| 140 |
+
match context.method:
|
| 141 |
+
case "tools/call":
|
| 142 |
+
handler = partial(self.on_call_tool, call_next=handler)
|
| 143 |
+
case "resources/read":
|
| 144 |
+
handler = partial(self.on_read_resource, call_next=handler)
|
| 145 |
+
case "prompts/get":
|
| 146 |
+
handler = partial(self.on_get_prompt, call_next=handler)
|
| 147 |
+
case "tools/list":
|
| 148 |
+
handler = partial(self.on_list_tools, call_next=handler)
|
| 149 |
+
case "resources/list":
|
| 150 |
+
handler = partial(self.on_list_resources, call_next=handler)
|
| 151 |
+
case "resources/templates/list":
|
| 152 |
+
handler = partial(self.on_list_resource_templates, call_next=handler)
|
| 153 |
+
case "prompts/list":
|
| 154 |
+
handler = partial(self.on_list_prompts, call_next=handler)
|
| 155 |
+
|
| 156 |
+
match context.type:
|
| 157 |
+
case "request":
|
| 158 |
+
handler = partial(self.on_request, call_next=handler)
|
| 159 |
+
case "notification":
|
| 160 |
+
handler = partial(self.on_notification, call_next=handler)
|
| 161 |
+
|
| 162 |
+
handler = partial(self.on_message, call_next=handler)
|
| 163 |
+
|
| 164 |
+
return handler
|
| 165 |
+
|
| 166 |
+
async def on_message(
|
| 167 |
+
self,
|
| 168 |
+
context: MiddlewareContext[Any],
|
| 169 |
+
call_next: CallNext[Any, Any],
|
| 170 |
+
) -> Any:
|
| 171 |
+
return await call_next(context)
|
| 172 |
+
|
| 173 |
+
async def on_request(
|
| 174 |
+
self,
|
| 175 |
+
context: MiddlewareContext[mt.Request],
|
| 176 |
+
call_next: CallNext[mt.Request, Any],
|
| 177 |
+
) -> Any:
|
| 178 |
+
return await call_next(context)
|
| 179 |
+
|
| 180 |
+
async def on_notification(
|
| 181 |
+
self,
|
| 182 |
+
context: MiddlewareContext[mt.Notification],
|
| 183 |
+
call_next: CallNext[mt.Notification, Any],
|
| 184 |
+
) -> Any:
|
| 185 |
+
return await call_next(context)
|
| 186 |
+
|
| 187 |
+
async def on_call_tool(
|
| 188 |
+
self,
|
| 189 |
+
context: MiddlewareContext[mt.CallToolRequestParams],
|
| 190 |
+
call_next: CallNext[mt.CallToolRequestParams, mt.CallToolResult],
|
| 191 |
+
) -> mt.CallToolResult:
|
| 192 |
+
return await call_next(context)
|
| 193 |
+
|
| 194 |
+
async def on_read_resource(
|
| 195 |
+
self,
|
| 196 |
+
context: MiddlewareContext[mt.ReadResourceRequestParams],
|
| 197 |
+
call_next: CallNext[mt.ReadResourceRequestParams, mt.ReadResourceResult],
|
| 198 |
+
) -> mt.ReadResourceResult:
|
| 199 |
+
return await call_next(context)
|
| 200 |
+
|
| 201 |
+
async def on_get_prompt(
|
| 202 |
+
self,
|
| 203 |
+
context: MiddlewareContext[mt.GetPromptRequestParams],
|
| 204 |
+
call_next: CallNext[mt.GetPromptRequestParams, mt.GetPromptResult],
|
| 205 |
+
) -> mt.GetPromptResult:
|
| 206 |
+
return await call_next(context)
|
| 207 |
+
|
| 208 |
+
async def on_list_tools(
|
| 209 |
+
self,
|
| 210 |
+
context: MiddlewareContext[mt.ListToolsRequest],
|
| 211 |
+
call_next: CallNext[mt.ListToolsRequest, ListToolsResult],
|
| 212 |
+
) -> ListToolsResult:
|
| 213 |
+
return await call_next(context)
|
| 214 |
+
|
| 215 |
+
async def on_list_resources(
|
| 216 |
+
self,
|
| 217 |
+
context: MiddlewareContext[mt.ListResourcesRequest],
|
| 218 |
+
call_next: CallNext[mt.ListResourcesRequest, ListResourcesResult],
|
| 219 |
+
) -> ListResourcesResult:
|
| 220 |
+
return await call_next(context)
|
| 221 |
+
|
| 222 |
+
async def on_list_resource_templates(
|
| 223 |
+
self,
|
| 224 |
+
context: MiddlewareContext[mt.ListResourceTemplatesRequest],
|
| 225 |
+
call_next: CallNext[
|
| 226 |
+
mt.ListResourceTemplatesRequest, ListResourceTemplatesResult
|
| 227 |
+
],
|
| 228 |
+
) -> ListResourceTemplatesResult:
|
| 229 |
+
return await call_next(context)
|
| 230 |
+
|
| 231 |
+
async def on_list_prompts(
|
| 232 |
+
self,
|
| 233 |
+
context: MiddlewareContext[mt.ListPromptsRequest],
|
| 234 |
+
call_next: CallNext[mt.ListPromptsRequest, ListPromptsResult],
|
| 235 |
+
) -> ListPromptsResult:
|
| 236 |
+
return await call_next(context)
|
src/fastmcp/server/proxy.py
CHANGED
|
@@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Any, cast
|
|
| 4 |
from urllib.parse import quote
|
| 5 |
|
| 6 |
import mcp.types
|
| 7 |
-
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
| 8 |
from mcp.shared.exceptions import McpError
|
| 9 |
from mcp.types import (
|
| 10 |
METHOD_NOT_FOUND,
|
|
@@ -17,10 +16,14 @@ from pydantic.networks import AnyUrl
|
|
| 17 |
from fastmcp.client import Client
|
| 18 |
from fastmcp.exceptions import NotFoundError, ResourceError, ToolError
|
| 19 |
from fastmcp.prompts import Prompt, PromptMessage
|
|
|
|
|
|
|
| 20 |
from fastmcp.resources import Resource, ResourceTemplate
|
|
|
|
| 21 |
from fastmcp.server.context import Context
|
| 22 |
from fastmcp.server.server import FastMCP
|
| 23 |
from fastmcp.tools.tool import Tool
|
|
|
|
| 24 |
from fastmcp.utilities.logging import get_logger
|
| 25 |
from fastmcp.utilities.types import MCPContent
|
| 26 |
|
|
@@ -30,18 +33,197 @@ if TYPE_CHECKING:
|
|
| 30 |
logger = get_logger(__name__)
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
class ProxyTool(Tool):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def __init__(self, client: Client, **kwargs):
|
| 35 |
super().__init__(**kwargs)
|
| 36 |
self._client = client
|
| 37 |
|
| 38 |
@classmethod
|
| 39 |
-
|
|
|
|
| 40 |
return cls(
|
| 41 |
client=client,
|
| 42 |
-
name=
|
| 43 |
-
description=
|
| 44 |
-
parameters=
|
|
|
|
| 45 |
)
|
| 46 |
|
| 47 |
async def run(
|
|
@@ -49,8 +231,8 @@ class ProxyTool(Tool):
|
|
| 49 |
arguments: dict[str, Any],
|
| 50 |
context: Context | None = None,
|
| 51 |
) -> list[MCPContent]:
|
| 52 |
-
|
| 53 |
-
#
|
| 54 |
async with self._client:
|
| 55 |
result = await self._client.call_tool_mcp(
|
| 56 |
name=self.name,
|
|
@@ -62,6 +244,10 @@ class ProxyTool(Tool):
|
|
| 62 |
|
| 63 |
|
| 64 |
class ProxyResource(Resource):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
_client: Client
|
| 66 |
_value: str | bytes | None = None
|
| 67 |
|
|
@@ -71,18 +257,20 @@ class ProxyResource(Resource):
|
|
| 71 |
self._value = _value
|
| 72 |
|
| 73 |
@classmethod
|
| 74 |
-
|
| 75 |
-
cls, client: Client,
|
| 76 |
) -> ProxyResource:
|
|
|
|
| 77 |
return cls(
|
| 78 |
client=client,
|
| 79 |
-
uri=
|
| 80 |
-
name=
|
| 81 |
-
description=
|
| 82 |
-
mime_type=
|
| 83 |
)
|
| 84 |
|
| 85 |
async def read(self) -> str | bytes:
|
|
|
|
| 86 |
if self._value is not None:
|
| 87 |
return self._value
|
| 88 |
|
|
@@ -97,20 +285,26 @@ class ProxyResource(Resource):
|
|
| 97 |
|
| 98 |
|
| 99 |
class ProxyTemplate(ResourceTemplate):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
def __init__(self, client: Client, **kwargs):
|
| 101 |
super().__init__(**kwargs)
|
| 102 |
self._client = client
|
| 103 |
|
| 104 |
@classmethod
|
| 105 |
-
|
| 106 |
-
cls, client: Client,
|
| 107 |
) -> ProxyTemplate:
|
|
|
|
| 108 |
return cls(
|
| 109 |
client=client,
|
| 110 |
-
uri_template=
|
| 111 |
-
name=
|
| 112 |
-
description=
|
| 113 |
-
|
|
|
|
| 114 |
)
|
| 115 |
|
| 116 |
async def create_resource(
|
|
@@ -119,6 +313,7 @@ class ProxyTemplate(ResourceTemplate):
|
|
| 119 |
params: dict[str, Any],
|
| 120 |
context: Context | None = None,
|
| 121 |
) -> ProxyResource:
|
|
|
|
| 122 |
# don't use the provided uri, because it may not be the same as the
|
| 123 |
# uri_template on the remote server.
|
| 124 |
# quote params to ensure they are valid for the uri_template
|
|
@@ -146,6 +341,10 @@ class ProxyTemplate(ResourceTemplate):
|
|
| 146 |
|
| 147 |
|
| 148 |
class ProxyPrompt(Prompt):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
_client: Client
|
| 150 |
|
| 151 |
def __init__(self, client: Client, **kwargs):
|
|
@@ -153,139 +352,50 @@ class ProxyPrompt(Prompt):
|
|
| 153 |
self._client = client
|
| 154 |
|
| 155 |
@classmethod
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
return cls(
|
| 158 |
client=client,
|
| 159 |
-
name=
|
| 160 |
-
description=
|
| 161 |
-
arguments=
|
| 162 |
)
|
| 163 |
|
| 164 |
async def render(self, arguments: dict[str, Any]) -> list[PromptMessage]:
|
|
|
|
| 165 |
async with self._client:
|
| 166 |
result = await self._client.get_prompt(self.name, arguments)
|
| 167 |
return result.messages
|
| 168 |
|
| 169 |
|
| 170 |
class FastMCPProxy(FastMCP):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
def __init__(self, client: Client, **kwargs):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
super().__init__(**kwargs)
|
| 173 |
self.client = client
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
try:
|
| 180 |
-
client_tools = await self.client.list_tools()
|
| 181 |
-
except McpError as e:
|
| 182 |
-
if e.error.code == METHOD_NOT_FOUND:
|
| 183 |
-
client_tools = []
|
| 184 |
-
else:
|
| 185 |
-
raise e
|
| 186 |
-
for tool in client_tools:
|
| 187 |
-
# don't overwrite tools defined in the server
|
| 188 |
-
if tool.name not in tools:
|
| 189 |
-
tool_proxy = await ProxyTool.from_client(self.client, tool)
|
| 190 |
-
tools[tool_proxy.name] = tool_proxy
|
| 191 |
-
|
| 192 |
-
return tools
|
| 193 |
-
|
| 194 |
-
async def get_resources(self) -> dict[str, Resource]:
|
| 195 |
-
resources = await super().get_resources()
|
| 196 |
-
|
| 197 |
-
async with self.client:
|
| 198 |
-
try:
|
| 199 |
-
client_resources = await self.client.list_resources()
|
| 200 |
-
except McpError as e:
|
| 201 |
-
if e.error.code == METHOD_NOT_FOUND:
|
| 202 |
-
client_resources = []
|
| 203 |
-
else:
|
| 204 |
-
raise e
|
| 205 |
-
for resource in client_resources:
|
| 206 |
-
# don't overwrite resources defined in the server
|
| 207 |
-
if str(resource.uri) not in resources:
|
| 208 |
-
resource_proxy = await ProxyResource.from_client(
|
| 209 |
-
self.client, resource
|
| 210 |
-
)
|
| 211 |
-
resources[str(resource_proxy.uri)] = resource_proxy
|
| 212 |
-
|
| 213 |
-
return resources
|
| 214 |
-
|
| 215 |
-
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
| 216 |
-
templates = await super().get_resource_templates()
|
| 217 |
-
|
| 218 |
-
async with self.client:
|
| 219 |
-
try:
|
| 220 |
-
client_templates = await self.client.list_resource_templates()
|
| 221 |
-
except McpError as e:
|
| 222 |
-
if e.error.code == METHOD_NOT_FOUND:
|
| 223 |
-
client_templates = []
|
| 224 |
-
else:
|
| 225 |
-
raise e
|
| 226 |
-
for template in client_templates:
|
| 227 |
-
# don't overwrite templates defined in the server
|
| 228 |
-
if template.uriTemplate not in templates:
|
| 229 |
-
template_proxy = await ProxyTemplate.from_client(
|
| 230 |
-
self.client, template
|
| 231 |
-
)
|
| 232 |
-
templates[template_proxy.uri_template] = template_proxy
|
| 233 |
-
|
| 234 |
-
return templates
|
| 235 |
-
|
| 236 |
-
async def get_prompts(self) -> dict[str, Prompt]:
|
| 237 |
-
prompts = await super().get_prompts()
|
| 238 |
-
|
| 239 |
-
async with self.client:
|
| 240 |
-
try:
|
| 241 |
-
client_prompts = await self.client.list_prompts()
|
| 242 |
-
except McpError as e:
|
| 243 |
-
if e.error.code == METHOD_NOT_FOUND:
|
| 244 |
-
client_prompts = []
|
| 245 |
-
else:
|
| 246 |
-
raise e
|
| 247 |
-
for prompt in client_prompts:
|
| 248 |
-
# don't overwrite prompts defined in the server
|
| 249 |
-
if prompt.name not in prompts:
|
| 250 |
-
prompt_proxy = await ProxyPrompt.from_client(self.client, prompt)
|
| 251 |
-
prompts[prompt_proxy.name] = prompt_proxy
|
| 252 |
-
|
| 253 |
-
return prompts
|
| 254 |
-
|
| 255 |
-
async def _call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
|
| 256 |
-
try:
|
| 257 |
-
result = await super()._call_tool(key, arguments)
|
| 258 |
-
return result
|
| 259 |
-
except NotFoundError:
|
| 260 |
-
async with self.client:
|
| 261 |
-
result = await self.client.call_tool(key, arguments)
|
| 262 |
-
return result
|
| 263 |
-
|
| 264 |
-
async def _read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
| 265 |
-
try:
|
| 266 |
-
result = await super()._read_resource(uri)
|
| 267 |
-
return result
|
| 268 |
-
except NotFoundError:
|
| 269 |
-
async with self.client:
|
| 270 |
-
resource = await self.client.read_resource(uri)
|
| 271 |
-
if isinstance(resource[0], TextResourceContents):
|
| 272 |
-
content = resource[0].text
|
| 273 |
-
elif isinstance(resource[0], BlobResourceContents):
|
| 274 |
-
content = resource[0].blob
|
| 275 |
-
else:
|
| 276 |
-
raise ValueError(f"Unsupported content type: {type(resource[0])}")
|
| 277 |
-
|
| 278 |
-
return [
|
| 279 |
-
ReadResourceContents(content=content, mime_type=resource[0].mimeType)
|
| 280 |
-
]
|
| 281 |
-
|
| 282 |
-
async def _get_prompt(
|
| 283 |
-
self, name: str, arguments: dict[str, Any] | None = None
|
| 284 |
-
) -> GetPromptResult:
|
| 285 |
-
try:
|
| 286 |
-
result = await super()._get_prompt(name, arguments)
|
| 287 |
-
return result
|
| 288 |
-
except NotFoundError:
|
| 289 |
-
async with self.client:
|
| 290 |
-
result = await self.client.get_prompt(name, arguments)
|
| 291 |
-
return result
|
|
|
|
| 4 |
from urllib.parse import quote
|
| 5 |
|
| 6 |
import mcp.types
|
|
|
|
| 7 |
from mcp.shared.exceptions import McpError
|
| 8 |
from mcp.types import (
|
| 9 |
METHOD_NOT_FOUND,
|
|
|
|
| 16 |
from fastmcp.client import Client
|
| 17 |
from fastmcp.exceptions import NotFoundError, ResourceError, ToolError
|
| 18 |
from fastmcp.prompts import Prompt, PromptMessage
|
| 19 |
+
from fastmcp.prompts.prompt import PromptArgument
|
| 20 |
+
from fastmcp.prompts.prompt_manager import PromptManager
|
| 21 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 22 |
+
from fastmcp.resources.resource_manager import ResourceManager
|
| 23 |
from fastmcp.server.context import Context
|
| 24 |
from fastmcp.server.server import FastMCP
|
| 25 |
from fastmcp.tools.tool import Tool
|
| 26 |
+
from fastmcp.tools.tool_manager import ToolManager
|
| 27 |
from fastmcp.utilities.logging import get_logger
|
| 28 |
from fastmcp.utilities.types import MCPContent
|
| 29 |
|
|
|
|
| 33 |
logger = get_logger(__name__)
|
| 34 |
|
| 35 |
|
| 36 |
+
class ProxyToolManager(ToolManager):
|
| 37 |
+
"""A ToolManager that sources its tools from a remote client in addition to local and mounted tools."""
|
| 38 |
+
|
| 39 |
+
def __init__(self, client: Client, **kwargs):
|
| 40 |
+
super().__init__(**kwargs)
|
| 41 |
+
self.client = client
|
| 42 |
+
|
| 43 |
+
async def get_tools(self) -> dict[str, Tool]:
|
| 44 |
+
"""Gets the unfiltered tool inventory including local, mounted, and proxy tools."""
|
| 45 |
+
# First get local and mounted tools from parent
|
| 46 |
+
all_tools = await super().get_tools()
|
| 47 |
+
|
| 48 |
+
# Then add proxy tools, but don't overwrite existing ones
|
| 49 |
+
try:
|
| 50 |
+
async with self.client:
|
| 51 |
+
client_tools = await self.client.list_tools()
|
| 52 |
+
for tool in client_tools:
|
| 53 |
+
if tool.name not in all_tools:
|
| 54 |
+
all_tools[tool.name] = ProxyTool.from_mcp_tool(
|
| 55 |
+
self.client, tool
|
| 56 |
+
)
|
| 57 |
+
except McpError as e:
|
| 58 |
+
if e.error.code == METHOD_NOT_FOUND:
|
| 59 |
+
pass # No tools available from proxy
|
| 60 |
+
else:
|
| 61 |
+
raise e
|
| 62 |
+
|
| 63 |
+
return all_tools
|
| 64 |
+
|
| 65 |
+
async def list_tools(self) -> list[Tool]:
|
| 66 |
+
"""Gets the filtered list of tools including local, mounted, and proxy tools."""
|
| 67 |
+
tools_dict = await self.get_tools()
|
| 68 |
+
return list(tools_dict.values())
|
| 69 |
+
|
| 70 |
+
async def call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
|
| 71 |
+
"""Calls a tool, trying local/mounted first, then proxy if not found."""
|
| 72 |
+
try:
|
| 73 |
+
# First try local and mounted tools
|
| 74 |
+
return await super().call_tool(key, arguments)
|
| 75 |
+
except NotFoundError:
|
| 76 |
+
# If not found locally, try proxy
|
| 77 |
+
async with self.client:
|
| 78 |
+
return await self.client.call_tool(key, arguments)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class ProxyResourceManager(ResourceManager):
|
| 82 |
+
"""A ResourceManager that sources its resources from a remote client in addition to local and mounted resources."""
|
| 83 |
+
|
| 84 |
+
def __init__(self, client: Client, **kwargs):
|
| 85 |
+
super().__init__(**kwargs)
|
| 86 |
+
self.client = client
|
| 87 |
+
|
| 88 |
+
async def get_resources(self) -> dict[str, Resource]:
|
| 89 |
+
"""Gets the unfiltered resource inventory including local, mounted, and proxy resources."""
|
| 90 |
+
# First get local and mounted resources from parent
|
| 91 |
+
all_resources = await super().get_resources()
|
| 92 |
+
|
| 93 |
+
# Then add proxy resources, but don't overwrite existing ones
|
| 94 |
+
try:
|
| 95 |
+
async with self.client:
|
| 96 |
+
client_resources = await self.client.list_resources()
|
| 97 |
+
for resource in client_resources:
|
| 98 |
+
if str(resource.uri) not in all_resources:
|
| 99 |
+
all_resources[str(resource.uri)] = (
|
| 100 |
+
ProxyResource.from_mcp_resource(self.client, resource)
|
| 101 |
+
)
|
| 102 |
+
except McpError as e:
|
| 103 |
+
if e.error.code == METHOD_NOT_FOUND:
|
| 104 |
+
pass # No resources available from proxy
|
| 105 |
+
else:
|
| 106 |
+
raise e
|
| 107 |
+
|
| 108 |
+
return all_resources
|
| 109 |
+
|
| 110 |
+
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
| 111 |
+
"""Gets the unfiltered template inventory including local, mounted, and proxy templates."""
|
| 112 |
+
# First get local and mounted templates from parent
|
| 113 |
+
all_templates = await super().get_resource_templates()
|
| 114 |
+
|
| 115 |
+
# Then add proxy templates, but don't overwrite existing ones
|
| 116 |
+
try:
|
| 117 |
+
async with self.client:
|
| 118 |
+
client_templates = await self.client.list_resource_templates()
|
| 119 |
+
for template in client_templates:
|
| 120 |
+
if template.uriTemplate not in all_templates:
|
| 121 |
+
all_templates[template.uriTemplate] = (
|
| 122 |
+
ProxyTemplate.from_mcp_template(self.client, template)
|
| 123 |
+
)
|
| 124 |
+
except McpError as e:
|
| 125 |
+
if e.error.code == METHOD_NOT_FOUND:
|
| 126 |
+
pass # No templates available from proxy
|
| 127 |
+
else:
|
| 128 |
+
raise e
|
| 129 |
+
|
| 130 |
+
return all_templates
|
| 131 |
+
|
| 132 |
+
async def list_resources(self) -> list[Resource]:
|
| 133 |
+
"""Gets the filtered list of resources including local, mounted, and proxy resources."""
|
| 134 |
+
resources_dict = await self.get_resources()
|
| 135 |
+
return list(resources_dict.values())
|
| 136 |
+
|
| 137 |
+
async def list_resource_templates(self) -> list[ResourceTemplate]:
|
| 138 |
+
"""Gets the filtered list of templates including local, mounted, and proxy templates."""
|
| 139 |
+
templates_dict = await self.get_resource_templates()
|
| 140 |
+
return list(templates_dict.values())
|
| 141 |
+
|
| 142 |
+
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
|
| 143 |
+
"""Reads a resource, trying local/mounted first, then proxy if not found."""
|
| 144 |
+
try:
|
| 145 |
+
# First try local and mounted resources
|
| 146 |
+
return await super().read_resource(uri)
|
| 147 |
+
except NotFoundError:
|
| 148 |
+
# If not found locally, try proxy
|
| 149 |
+
async with self.client:
|
| 150 |
+
result = await self.client.read_resource(uri)
|
| 151 |
+
if isinstance(result[0], TextResourceContents):
|
| 152 |
+
return result[0].text
|
| 153 |
+
elif isinstance(result[0], BlobResourceContents):
|
| 154 |
+
return result[0].blob
|
| 155 |
+
else:
|
| 156 |
+
raise ResourceError(f"Unsupported content type: {type(result[0])}")
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
class ProxyPromptManager(PromptManager):
|
| 160 |
+
"""A PromptManager that sources its prompts from a remote client in addition to local and mounted prompts."""
|
| 161 |
+
|
| 162 |
+
def __init__(self, client: Client, **kwargs):
|
| 163 |
+
super().__init__(**kwargs)
|
| 164 |
+
self.client = client
|
| 165 |
+
|
| 166 |
+
async def get_prompts(self) -> dict[str, Prompt]:
|
| 167 |
+
"""Gets the unfiltered prompt inventory including local, mounted, and proxy prompts."""
|
| 168 |
+
# First get local and mounted prompts from parent
|
| 169 |
+
all_prompts = await super().get_prompts()
|
| 170 |
+
|
| 171 |
+
# Then add proxy prompts, but don't overwrite existing ones
|
| 172 |
+
try:
|
| 173 |
+
async with self.client:
|
| 174 |
+
client_prompts = await self.client.list_prompts()
|
| 175 |
+
for prompt in client_prompts:
|
| 176 |
+
if prompt.name not in all_prompts:
|
| 177 |
+
all_prompts[prompt.name] = ProxyPrompt.from_mcp_prompt(
|
| 178 |
+
self.client, prompt
|
| 179 |
+
)
|
| 180 |
+
except McpError as e:
|
| 181 |
+
if e.error.code == METHOD_NOT_FOUND:
|
| 182 |
+
pass # No prompts available from proxy
|
| 183 |
+
else:
|
| 184 |
+
raise e
|
| 185 |
+
|
| 186 |
+
return all_prompts
|
| 187 |
+
|
| 188 |
+
async def list_prompts(self) -> list[Prompt]:
|
| 189 |
+
"""Gets the filtered list of prompts including local, mounted, and proxy prompts."""
|
| 190 |
+
prompts_dict = await self.get_prompts()
|
| 191 |
+
return list(prompts_dict.values())
|
| 192 |
+
|
| 193 |
+
async def render_prompt(
|
| 194 |
+
self,
|
| 195 |
+
name: str,
|
| 196 |
+
arguments: dict[str, Any] | None = None,
|
| 197 |
+
) -> GetPromptResult:
|
| 198 |
+
"""Renders a prompt, trying local/mounted first, then proxy if not found."""
|
| 199 |
+
try:
|
| 200 |
+
# First try local and mounted prompts
|
| 201 |
+
return await super().render_prompt(name, arguments)
|
| 202 |
+
except NotFoundError:
|
| 203 |
+
# If not found locally, try proxy
|
| 204 |
+
async with self.client:
|
| 205 |
+
result = await self.client.get_prompt(name, arguments)
|
| 206 |
+
return result
|
| 207 |
+
|
| 208 |
+
|
| 209 |
class ProxyTool(Tool):
|
| 210 |
+
"""
|
| 211 |
+
A Tool that represents and executes a tool on a remote server.
|
| 212 |
+
"""
|
| 213 |
+
|
| 214 |
def __init__(self, client: Client, **kwargs):
|
| 215 |
super().__init__(**kwargs)
|
| 216 |
self._client = client
|
| 217 |
|
| 218 |
@classmethod
|
| 219 |
+
def from_mcp_tool(cls, client: Client, mcp_tool: mcp.types.Tool) -> ProxyTool:
|
| 220 |
+
"""Factory method to create a ProxyTool from a raw MCP tool schema."""
|
| 221 |
return cls(
|
| 222 |
client=client,
|
| 223 |
+
name=mcp_tool.name,
|
| 224 |
+
description=mcp_tool.description,
|
| 225 |
+
parameters=mcp_tool.inputSchema,
|
| 226 |
+
annotations=mcp_tool.annotations,
|
| 227 |
)
|
| 228 |
|
| 229 |
async def run(
|
|
|
|
| 231 |
arguments: dict[str, Any],
|
| 232 |
context: Context | None = None,
|
| 233 |
) -> list[MCPContent]:
|
| 234 |
+
"""Executes the tool by making a call through the client."""
|
| 235 |
+
# This is where the remote execution logic lives.
|
| 236 |
async with self._client:
|
| 237 |
result = await self._client.call_tool_mcp(
|
| 238 |
name=self.name,
|
|
|
|
| 244 |
|
| 245 |
|
| 246 |
class ProxyResource(Resource):
|
| 247 |
+
"""
|
| 248 |
+
A Resource that represents and reads a resource from a remote server.
|
| 249 |
+
"""
|
| 250 |
+
|
| 251 |
_client: Client
|
| 252 |
_value: str | bytes | None = None
|
| 253 |
|
|
|
|
| 257 |
self._value = _value
|
| 258 |
|
| 259 |
@classmethod
|
| 260 |
+
def from_mcp_resource(
|
| 261 |
+
cls, client: Client, mcp_resource: mcp.types.Resource
|
| 262 |
) -> ProxyResource:
|
| 263 |
+
"""Factory method to create a ProxyResource from a raw MCP resource schema."""
|
| 264 |
return cls(
|
| 265 |
client=client,
|
| 266 |
+
uri=mcp_resource.uri,
|
| 267 |
+
name=mcp_resource.name,
|
| 268 |
+
description=mcp_resource.description,
|
| 269 |
+
mime_type=mcp_resource.mimeType or "text/plain",
|
| 270 |
)
|
| 271 |
|
| 272 |
async def read(self) -> str | bytes:
|
| 273 |
+
"""Read the resource content from the remote server."""
|
| 274 |
if self._value is not None:
|
| 275 |
return self._value
|
| 276 |
|
|
|
|
| 285 |
|
| 286 |
|
| 287 |
class ProxyTemplate(ResourceTemplate):
|
| 288 |
+
"""
|
| 289 |
+
A ResourceTemplate that represents and creates resources from a remote server template.
|
| 290 |
+
"""
|
| 291 |
+
|
| 292 |
def __init__(self, client: Client, **kwargs):
|
| 293 |
super().__init__(**kwargs)
|
| 294 |
self._client = client
|
| 295 |
|
| 296 |
@classmethod
|
| 297 |
+
def from_mcp_template(
|
| 298 |
+
cls, client: Client, mcp_template: mcp.types.ResourceTemplate
|
| 299 |
) -> ProxyTemplate:
|
| 300 |
+
"""Factory method to create a ProxyTemplate from a raw MCP template schema."""
|
| 301 |
return cls(
|
| 302 |
client=client,
|
| 303 |
+
uri_template=mcp_template.uriTemplate,
|
| 304 |
+
name=mcp_template.name,
|
| 305 |
+
description=mcp_template.description,
|
| 306 |
+
mime_type=mcp_template.mimeType or "text/plain",
|
| 307 |
+
parameters={}, # Remote templates don't have local parameters
|
| 308 |
)
|
| 309 |
|
| 310 |
async def create_resource(
|
|
|
|
| 313 |
params: dict[str, Any],
|
| 314 |
context: Context | None = None,
|
| 315 |
) -> ProxyResource:
|
| 316 |
+
"""Create a resource from the template by calling the remote server."""
|
| 317 |
# don't use the provided uri, because it may not be the same as the
|
| 318 |
# uri_template on the remote server.
|
| 319 |
# quote params to ensure they are valid for the uri_template
|
|
|
|
| 341 |
|
| 342 |
|
| 343 |
class ProxyPrompt(Prompt):
|
| 344 |
+
"""
|
| 345 |
+
A Prompt that represents and renders a prompt from a remote server.
|
| 346 |
+
"""
|
| 347 |
+
|
| 348 |
_client: Client
|
| 349 |
|
| 350 |
def __init__(self, client: Client, **kwargs):
|
|
|
|
| 352 |
self._client = client
|
| 353 |
|
| 354 |
@classmethod
|
| 355 |
+
def from_mcp_prompt(
|
| 356 |
+
cls, client: Client, mcp_prompt: mcp.types.Prompt
|
| 357 |
+
) -> ProxyPrompt:
|
| 358 |
+
"""Factory method to create a ProxyPrompt from a raw MCP prompt schema."""
|
| 359 |
+
arguments = [
|
| 360 |
+
PromptArgument(
|
| 361 |
+
name=arg.name,
|
| 362 |
+
description=arg.description,
|
| 363 |
+
required=arg.required or False,
|
| 364 |
+
)
|
| 365 |
+
for arg in mcp_prompt.arguments or []
|
| 366 |
+
]
|
| 367 |
return cls(
|
| 368 |
client=client,
|
| 369 |
+
name=mcp_prompt.name,
|
| 370 |
+
description=mcp_prompt.description,
|
| 371 |
+
arguments=arguments,
|
| 372 |
)
|
| 373 |
|
| 374 |
async def render(self, arguments: dict[str, Any]) -> list[PromptMessage]:
|
| 375 |
+
"""Render the prompt by making a call through the client."""
|
| 376 |
async with self._client:
|
| 377 |
result = await self._client.get_prompt(self.name, arguments)
|
| 378 |
return result.messages
|
| 379 |
|
| 380 |
|
| 381 |
class FastMCPProxy(FastMCP):
|
| 382 |
+
"""
|
| 383 |
+
A FastMCP server that acts as a proxy to a remote MCP-compliant server.
|
| 384 |
+
It uses specialized managers that fulfill requests via an HTTP client.
|
| 385 |
+
"""
|
| 386 |
+
|
| 387 |
def __init__(self, client: Client, **kwargs):
|
| 388 |
+
"""
|
| 389 |
+
Initializes the proxy server.
|
| 390 |
+
|
| 391 |
+
Args:
|
| 392 |
+
client: The FastMCP client connected to the backend server.
|
| 393 |
+
**kwargs: Additional settings for the FastMCP server.
|
| 394 |
+
"""
|
| 395 |
super().__init__(**kwargs)
|
| 396 |
self.client = client
|
| 397 |
|
| 398 |
+
# Replace the default managers with our specialized proxy managers.
|
| 399 |
+
self._tool_manager = ProxyToolManager(client=self.client)
|
| 400 |
+
self._resource_manager = ProxyResourceManager(client=self.client)
|
| 401 |
+
self._prompt_manager = ProxyPromptManager(client=self.client)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/fastmcp/server/server.py
CHANGED
|
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any, Generic, Literal, cast, overload
|
|
| 19 |
|
| 20 |
import anyio
|
| 21 |
import httpx
|
|
|
|
| 22 |
import uvicorn
|
| 23 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
| 24 |
from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
|
|
@@ -34,7 +35,7 @@ from mcp.types import Resource as MCPResource
|
|
| 34 |
from mcp.types import ResourceTemplate as MCPResourceTemplate
|
| 35 |
from mcp.types import Tool as MCPTool
|
| 36 |
from pydantic import AnyUrl
|
| 37 |
-
from starlette.middleware import Middleware
|
| 38 |
from starlette.requests import Request
|
| 39 |
from starlette.responses import Response
|
| 40 |
from starlette.routing import BaseRoute, Route
|
|
@@ -53,6 +54,7 @@ from fastmcp.server.http import (
|
|
| 53 |
create_sse_app,
|
| 54 |
create_streamable_http_app,
|
| 55 |
)
|
|
|
|
| 56 |
from fastmcp.settings import Settings
|
| 57 |
from fastmcp.tools import ToolManager
|
| 58 |
from fastmcp.tools.tool import FunctionTool, Tool
|
|
@@ -115,6 +117,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 115 |
*,
|
| 116 |
version: str | None = None,
|
| 117 |
auth: OAuthProvider | None = None,
|
|
|
|
| 118 |
lifespan: (
|
| 119 |
Callable[
|
| 120 |
[FastMCP[LifespanResultT]],
|
|
@@ -155,7 +158,6 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 155 |
self._cache = TimedCache(
|
| 156 |
expiration=datetime.timedelta(seconds=cache_expiration_seconds or 0)
|
| 157 |
)
|
| 158 |
-
self._mounted_servers: list[MountedServer] = []
|
| 159 |
self._additional_http_routes: list[BaseRoute] = []
|
| 160 |
self._tool_manager = ToolManager(
|
| 161 |
duplicate_behavior=on_duplicate_tools,
|
|
@@ -196,6 +198,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 196 |
self.include_tags = include_tags
|
| 197 |
self.exclude_tags = exclude_tags
|
| 198 |
|
|
|
|
|
|
|
| 199 |
# Set up MCP protocol handlers
|
| 200 |
self._setup_handlers()
|
| 201 |
self.dependencies = dependencies or fastmcp.settings.server_dependencies
|
|
@@ -319,30 +323,23 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 319 |
self._mcp_server.read_resource()(self._mcp_read_resource)
|
| 320 |
self._mcp_server.get_prompt()(self._mcp_get_prompt)
|
| 321 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
async def get_tools(self) -> dict[str, Tool]:
|
| 323 |
"""Get all registered tools, indexed by registered key."""
|
| 324 |
-
|
| 325 |
-
tools: dict[str, Tool] = {}
|
| 326 |
-
|
| 327 |
-
# iterate such that new mounts overwrite older ones
|
| 328 |
-
for mounted_server in self._mounted_servers:
|
| 329 |
-
try:
|
| 330 |
-
server_tools = await mounted_server.server.get_tools()
|
| 331 |
-
# Apply prefix to each tool key if prefix exists and is not empty
|
| 332 |
-
if mounted_server.prefix:
|
| 333 |
-
for tool in server_tools.values():
|
| 334 |
-
tool = tool.with_key(f"{mounted_server.prefix}_{tool.key}")
|
| 335 |
-
tools[tool.key] = tool
|
| 336 |
-
else:
|
| 337 |
-
tools.update(server_tools)
|
| 338 |
-
except Exception as e:
|
| 339 |
-
logger.warning(
|
| 340 |
-
f"Failed to get tools from mounted server '{mounted_server.prefix}': {e}"
|
| 341 |
-
)
|
| 342 |
-
continue
|
| 343 |
-
tools.update(self._tool_manager.get_tools())
|
| 344 |
-
self._cache.set("tools", tools)
|
| 345 |
-
return tools
|
| 346 |
|
| 347 |
async def get_tool(self, key: str) -> Tool:
|
| 348 |
tools = await self.get_tools()
|
|
@@ -352,34 +349,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 352 |
|
| 353 |
async def get_resources(self) -> dict[str, Resource]:
|
| 354 |
"""Get all registered resources, indexed by registered key."""
|
| 355 |
-
|
| 356 |
-
resources: dict[str, Resource] = {}
|
| 357 |
-
|
| 358 |
-
# iterate such that new mounts overwrite older ones
|
| 359 |
-
for mounted_server in self._mounted_servers:
|
| 360 |
-
try:
|
| 361 |
-
server_resources = await mounted_server.server.get_resources()
|
| 362 |
-
# Apply prefix to each resource key if prefix exists
|
| 363 |
-
if mounted_server.prefix:
|
| 364 |
-
for resource in server_resources.values():
|
| 365 |
-
resource = resource.with_key(
|
| 366 |
-
add_resource_prefix(
|
| 367 |
-
resource.key,
|
| 368 |
-
mounted_server.prefix,
|
| 369 |
-
self.resource_prefix_format,
|
| 370 |
-
)
|
| 371 |
-
)
|
| 372 |
-
resources[resource.key] = resource
|
| 373 |
-
else:
|
| 374 |
-
resources.update(server_resources)
|
| 375 |
-
except Exception as e:
|
| 376 |
-
logger.warning(
|
| 377 |
-
f"Failed to get resources from mounted server '{mounted_server.prefix}': {e}"
|
| 378 |
-
)
|
| 379 |
-
continue
|
| 380 |
-
resources.update(self._resource_manager.get_resources())
|
| 381 |
-
self._cache.set("resources", resources)
|
| 382 |
-
return resources
|
| 383 |
|
| 384 |
async def get_resource(self, key: str) -> Resource:
|
| 385 |
resources = await self.get_resources()
|
|
@@ -389,39 +359,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 389 |
|
| 390 |
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
| 391 |
"""Get all registered resource templates, indexed by registered key."""
|
| 392 |
-
|
| 393 |
-
templates := self._cache.get("resource_templates")
|
| 394 |
-
) is self._cache.NOT_FOUND:
|
| 395 |
-
templates: dict[str, ResourceTemplate] = {}
|
| 396 |
-
|
| 397 |
-
# iterate such that new mounts overwrite older ones
|
| 398 |
-
for mounted_server in self._mounted_servers:
|
| 399 |
-
try:
|
| 400 |
-
server_templates = (
|
| 401 |
-
await mounted_server.server.get_resource_templates()
|
| 402 |
-
)
|
| 403 |
-
# Apply prefix to each template key if prefix exists
|
| 404 |
-
if mounted_server.prefix:
|
| 405 |
-
for template in server_templates.values():
|
| 406 |
-
template = template.with_key(
|
| 407 |
-
add_resource_prefix(
|
| 408 |
-
template.key,
|
| 409 |
-
mounted_server.prefix,
|
| 410 |
-
self.resource_prefix_format,
|
| 411 |
-
)
|
| 412 |
-
)
|
| 413 |
-
templates[template.key] = template
|
| 414 |
-
else:
|
| 415 |
-
templates.update(server_templates)
|
| 416 |
-
except Exception as e:
|
| 417 |
-
logger.warning(
|
| 418 |
-
"Failed to get resource templates from mounted server "
|
| 419 |
-
f"'{mounted_server.prefix}': {e}"
|
| 420 |
-
)
|
| 421 |
-
continue
|
| 422 |
-
templates.update(self._resource_manager.get_templates())
|
| 423 |
-
self._cache.set("resource_templates", templates)
|
| 424 |
-
return templates
|
| 425 |
|
| 426 |
async def get_resource_template(self, key: str) -> ResourceTemplate:
|
| 427 |
templates = await self.get_resource_templates()
|
|
@@ -433,31 +371,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 433 |
"""
|
| 434 |
List all available prompts.
|
| 435 |
"""
|
| 436 |
-
|
| 437 |
-
if (prompts := self._cache.get("prompts")) is self._cache.NOT_FOUND:
|
| 438 |
-
prompts: dict[str, Prompt] = {}
|
| 439 |
-
|
| 440 |
-
# iterate such that new mounts overwrite older ones
|
| 441 |
-
for mounted_server in self._mounted_servers:
|
| 442 |
-
try:
|
| 443 |
-
server_prompts = await mounted_server.server.get_prompts()
|
| 444 |
-
# Apply prefix to each prompt key if prefix exists
|
| 445 |
-
if mounted_server.prefix:
|
| 446 |
-
for prompt in server_prompts.values():
|
| 447 |
-
prompt = prompt.with_key(
|
| 448 |
-
f"{mounted_server.prefix}_{prompt.key}"
|
| 449 |
-
)
|
| 450 |
-
prompts[prompt.key] = prompt
|
| 451 |
-
else:
|
| 452 |
-
prompts.update(server_prompts)
|
| 453 |
-
except Exception as e:
|
| 454 |
-
logger.warning(
|
| 455 |
-
f"Failed to get prompts from mounted server '{mounted_server.prefix}': {e}"
|
| 456 |
-
)
|
| 457 |
-
continue
|
| 458 |
-
prompts.update(self._prompt_manager.get_prompts())
|
| 459 |
-
self._cache.set("prompts", prompts)
|
| 460 |
-
return prompts
|
| 461 |
|
| 462 |
async def get_prompt(self, key: str) -> Prompt:
|
| 463 |
prompts = await self.get_prompts()
|
|
@@ -510,59 +424,165 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 510 |
return decorator
|
| 511 |
|
| 512 |
async def _mcp_list_tools(self) -> list[MCPTool]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
"""
|
| 514 |
List all available tools, in the format expected by the low-level MCP
|
| 515 |
server.
|
| 516 |
|
| 517 |
"""
|
| 518 |
-
tools = await self.get_tools()
|
| 519 |
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
|
| 525 |
-
|
|
|
|
| 526 |
|
| 527 |
async def _mcp_list_resources(self) -> list[MCPResource]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
"""
|
| 529 |
List all available resources, in the format expected by the low-level MCP
|
| 530 |
server.
|
| 531 |
|
| 532 |
"""
|
| 533 |
-
resources = await self.get_resources()
|
| 534 |
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
|
| 541 |
async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
"""
|
| 543 |
-
List all available resource templates, in the format expected by the low-level
|
| 544 |
-
|
| 545 |
|
| 546 |
"""
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
|
| 554 |
async def _mcp_list_prompts(self) -> list[MCPPrompt]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
"""
|
| 556 |
List all available prompts, in the format expected by the low-level MCP
|
| 557 |
server.
|
| 558 |
|
| 559 |
"""
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
|
| 567 |
async def _mcp_call_tool(
|
| 568 |
self, key: str, arguments: dict[str, Any]
|
|
@@ -579,56 +599,40 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 579 |
Returns:
|
| 580 |
List of MCP Content objects containing the tool results
|
| 581 |
"""
|
| 582 |
-
logger.debug("
|
| 583 |
|
| 584 |
-
# Create and use context for the entire call
|
| 585 |
with fastmcp.server.context.Context(fastmcp=self):
|
| 586 |
try:
|
| 587 |
return await self._call_tool(key, arguments)
|
| 588 |
except DisabledError:
|
| 589 |
-
# convert to NotFoundError to avoid leaking tool presence
|
| 590 |
raise NotFoundError(f"Unknown tool: {key}")
|
| 591 |
except NotFoundError:
|
| 592 |
-
# standardize NotFound message
|
| 593 |
raise NotFoundError(f"Unknown tool: {key}")
|
| 594 |
|
| 595 |
async def _call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
|
| 596 |
"""
|
| 597 |
-
|
| 598 |
-
this method, not _mcp_call_tool.
|
| 599 |
-
|
| 600 |
-
Args:
|
| 601 |
-
key: The name of the tool to call arguments: Arguments to pass to
|
| 602 |
-
the tool
|
| 603 |
-
|
| 604 |
-
Returns:
|
| 605 |
-
List of MCP Content objects containing the tool results
|
| 606 |
"""
|
| 607 |
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
|
|
|
| 611 |
if not self._should_enable_component(tool):
|
| 612 |
-
raise
|
| 613 |
-
return await self._tool_manager.call_tool(key, arguments)
|
| 614 |
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
tool_key = key
|
| 619 |
-
try:
|
| 620 |
-
# If server has a prefix, check if key matches and strip prefix
|
| 621 |
-
if mounted_server.prefix:
|
| 622 |
-
if tool_key.startswith(f"{mounted_server.prefix}_"):
|
| 623 |
-
tool_key = tool_key.removeprefix(f"{mounted_server.prefix}_")
|
| 624 |
-
else:
|
| 625 |
-
continue
|
| 626 |
-
return await mounted_server.server._call_tool(tool_key, arguments)
|
| 627 |
-
except NotFoundError:
|
| 628 |
-
# Tool not found on this server, try the next one
|
| 629 |
-
continue
|
| 630 |
|
| 631 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 632 |
|
| 633 |
async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
| 634 |
"""
|
|
@@ -636,7 +640,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 636 |
|
| 637 |
Delegates to _read_resource, which should be overridden by FastMCP subclasses.
|
| 638 |
"""
|
| 639 |
-
logger.debug("
|
| 640 |
|
| 641 |
with fastmcp.server.context.Context(fastmcp=self):
|
| 642 |
try:
|
|
@@ -650,45 +654,38 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 650 |
|
| 651 |
async def _read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
| 652 |
"""
|
| 653 |
-
|
| 654 |
-
server.
|
| 655 |
"""
|
| 656 |
-
|
| 657 |
-
|
|
|
|
|
|
|
|
|
|
| 658 |
if not self._should_enable_component(resource):
|
| 659 |
-
raise
|
| 660 |
-
|
|
|
|
| 661 |
return [
|
| 662 |
ReadResourceContents(
|
| 663 |
content=content,
|
| 664 |
mime_type=resource.mime_type,
|
| 665 |
)
|
| 666 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
else:
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
):
|
| 679 |
-
resource_uri = remove_resource_prefix(
|
| 680 |
-
str(resource_uri),
|
| 681 |
-
mounted_server.prefix,
|
| 682 |
-
self.resource_prefix_format,
|
| 683 |
-
)
|
| 684 |
-
else:
|
| 685 |
-
continue
|
| 686 |
-
return await mounted_server.server._mcp_read_resource(resource_uri)
|
| 687 |
-
except NotFoundError:
|
| 688 |
-
# Resource not found on this server, try the next one
|
| 689 |
-
continue
|
| 690 |
-
else:
|
| 691 |
-
raise NotFoundError(f"Unknown resource: {uri}")
|
| 692 |
|
| 693 |
async def _mcp_get_prompt(
|
| 694 |
self, name: str, arguments: dict[str, Any] | None = None
|
|
@@ -698,7 +695,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 698 |
|
| 699 |
Delegates to _get_prompt, which should be overridden by FastMCP subclasses.
|
| 700 |
"""
|
| 701 |
-
logger.debug("
|
| 702 |
|
| 703 |
with fastmcp.server.context.Context(fastmcp=self):
|
| 704 |
try:
|
|
@@ -713,45 +710,29 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 713 |
async def _get_prompt(
|
| 714 |
self, name: str, arguments: dict[str, Any] | None = None
|
| 715 |
) -> GetPromptResult:
|
| 716 |
-
"""Handle MCP 'getPrompt' requests.
|
| 717 |
-
|
| 718 |
-
Args:
|
| 719 |
-
name: The name of the prompt to render
|
| 720 |
-
arguments: Arguments to pass to the prompt
|
| 721 |
-
|
| 722 |
-
Returns:
|
| 723 |
-
GetPromptResult containing the rendered prompt messages
|
| 724 |
"""
|
| 725 |
-
|
|
|
|
| 726 |
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
|
|
|
| 730 |
if not self._should_enable_component(prompt):
|
| 731 |
-
raise
|
| 732 |
-
return await self._prompt_manager.render_prompt(name, arguments)
|
| 733 |
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
prompt_name = name
|
| 738 |
-
try:
|
| 739 |
-
if mounted_server.prefix:
|
| 740 |
-
# If server has a prefix, check if name matches and strip prefix
|
| 741 |
-
if prompt_name.startswith(f"{mounted_server.prefix}_"):
|
| 742 |
-
prompt_name = prompt_name.removeprefix(
|
| 743 |
-
f"{mounted_server.prefix}_"
|
| 744 |
-
)
|
| 745 |
-
else:
|
| 746 |
-
continue
|
| 747 |
-
return await mounted_server.server._mcp_get_prompt(
|
| 748 |
-
prompt_name, arguments
|
| 749 |
-
)
|
| 750 |
-
except NotFoundError:
|
| 751 |
-
# Prompt not found on this server, try the next one
|
| 752 |
-
continue
|
| 753 |
|
| 754 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
|
| 756 |
def add_tool(self, tool: Tool) -> None:
|
| 757 |
"""Add a tool to the server.
|
|
@@ -919,23 +900,23 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 919 |
enabled=enabled,
|
| 920 |
)
|
| 921 |
|
| 922 |
-
def add_resource(self, resource: Resource
|
| 923 |
"""Add a resource to the server.
|
| 924 |
|
| 925 |
Args:
|
| 926 |
resource: A Resource instance to add
|
| 927 |
"""
|
| 928 |
|
| 929 |
-
self._resource_manager.add_resource(resource
|
| 930 |
self._cache.clear()
|
| 931 |
|
| 932 |
-
def add_template(self, template: ResourceTemplate
|
| 933 |
"""Add a resource template to the server.
|
| 934 |
|
| 935 |
Args:
|
| 936 |
template: A ResourceTemplate instance to add
|
| 937 |
"""
|
| 938 |
-
self._resource_manager.add_template(template
|
| 939 |
|
| 940 |
def add_resource_fn(
|
| 941 |
self,
|
|
@@ -1278,7 +1259,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1278 |
log_level: str | None = None,
|
| 1279 |
path: str | None = None,
|
| 1280 |
uvicorn_config: dict[str, Any] | None = None,
|
| 1281 |
-
middleware: list[
|
| 1282 |
) -> None:
|
| 1283 |
"""Run the server using HTTP transport.
|
| 1284 |
|
|
@@ -1349,7 +1330,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1349 |
self,
|
| 1350 |
path: str | None = None,
|
| 1351 |
message_path: str | None = None,
|
| 1352 |
-
middleware: list[
|
| 1353 |
) -> StarletteWithLifespan:
|
| 1354 |
"""
|
| 1355 |
Create a Starlette app for the SSE server.
|
|
@@ -1379,7 +1360,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1379 |
def streamable_http_app(
|
| 1380 |
self,
|
| 1381 |
path: str | None = None,
|
| 1382 |
-
middleware: list[
|
| 1383 |
) -> StarletteWithLifespan:
|
| 1384 |
"""
|
| 1385 |
Create a Starlette app for the StreamableHTTP server.
|
|
@@ -1400,7 +1381,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1400 |
def http_app(
|
| 1401 |
self,
|
| 1402 |
path: str | None = None,
|
| 1403 |
-
middleware: list[
|
| 1404 |
json_response: bool | None = None,
|
| 1405 |
stateless_http: bool | None = None,
|
| 1406 |
transport: Literal["streamable-http", "sse"] = "streamable-http",
|
|
@@ -1584,11 +1565,16 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1584 |
if as_proxy and not isinstance(server, FastMCPProxy):
|
| 1585 |
server = FastMCPProxy(Client(transport=FastMCPTransport(server)))
|
| 1586 |
|
|
|
|
| 1587 |
mounted_server = MountedServer(
|
| 1588 |
-
server=server,
|
| 1589 |
prefix=prefix,
|
|
|
|
|
|
|
| 1590 |
)
|
| 1591 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 1592 |
self._cache.clear()
|
| 1593 |
|
| 1594 |
async def import_server(
|
|
@@ -1682,10 +1668,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1682 |
# Import tools from the server
|
| 1683 |
for key, tool in (await server.get_tools()).items():
|
| 1684 |
if prefix:
|
| 1685 |
-
|
| 1686 |
-
|
| 1687 |
-
tool_key = key
|
| 1688 |
-
self._tool_manager.add_tool(tool, key=tool_key)
|
| 1689 |
|
| 1690 |
# Import resources and templates from the server
|
| 1691 |
for key, resource in (await server.get_resources()).items():
|
|
@@ -1693,35 +1677,27 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1693 |
resource_key = add_resource_prefix(
|
| 1694 |
key, prefix, self.resource_prefix_format
|
| 1695 |
)
|
| 1696 |
-
|
| 1697 |
-
|
| 1698 |
-
self._resource_manager.add_resource(resource, key=resource_key)
|
| 1699 |
|
| 1700 |
for key, template in (await server.get_resource_templates()).items():
|
| 1701 |
if prefix:
|
| 1702 |
template_key = add_resource_prefix(
|
| 1703 |
key, prefix, self.resource_prefix_format
|
| 1704 |
)
|
| 1705 |
-
|
| 1706 |
-
|
| 1707 |
-
self._resource_manager.add_template(template, key=template_key)
|
| 1708 |
|
| 1709 |
# Import prompts from the server
|
| 1710 |
for key, prompt in (await server.get_prompts()).items():
|
| 1711 |
if prefix:
|
| 1712 |
-
|
| 1713 |
-
|
| 1714 |
-
prompt_key = key
|
| 1715 |
-
self._prompt_manager.add_prompt(prompt, key=prompt_key)
|
| 1716 |
|
| 1717 |
if prefix:
|
| 1718 |
-
logger.
|
| 1719 |
-
logger.debug(f"Imported tools with prefix '{prefix}_'")
|
| 1720 |
-
logger.debug(f"Imported resources and templates with prefix '{prefix}/'")
|
| 1721 |
-
logger.debug(f"Imported prompts with prefix '{prefix}_'")
|
| 1722 |
else:
|
| 1723 |
-
logger.
|
| 1724 |
-
logger.debug("Imported tools, resources, templates, and prompts")
|
| 1725 |
|
| 1726 |
self._cache.clear()
|
| 1727 |
|
|
@@ -1882,6 +1858,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1882 |
class MountedServer:
|
| 1883 |
prefix: str | None
|
| 1884 |
server: FastMCP[Any]
|
|
|
|
| 1885 |
|
| 1886 |
|
| 1887 |
def add_resource_prefix(
|
|
|
|
| 19 |
|
| 20 |
import anyio
|
| 21 |
import httpx
|
| 22 |
+
import mcp.types
|
| 23 |
import uvicorn
|
| 24 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
| 25 |
from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
|
|
|
|
| 35 |
from mcp.types import ResourceTemplate as MCPResourceTemplate
|
| 36 |
from mcp.types import Tool as MCPTool
|
| 37 |
from pydantic import AnyUrl
|
| 38 |
+
from starlette.middleware import Middleware as ASGIMiddleware
|
| 39 |
from starlette.requests import Request
|
| 40 |
from starlette.responses import Response
|
| 41 |
from starlette.routing import BaseRoute, Route
|
|
|
|
| 54 |
create_sse_app,
|
| 55 |
create_streamable_http_app,
|
| 56 |
)
|
| 57 |
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
| 58 |
from fastmcp.settings import Settings
|
| 59 |
from fastmcp.tools import ToolManager
|
| 60 |
from fastmcp.tools.tool import FunctionTool, Tool
|
|
|
|
| 117 |
*,
|
| 118 |
version: str | None = None,
|
| 119 |
auth: OAuthProvider | None = None,
|
| 120 |
+
middleware: list[Middleware] | None = None,
|
| 121 |
lifespan: (
|
| 122 |
Callable[
|
| 123 |
[FastMCP[LifespanResultT]],
|
|
|
|
| 158 |
self._cache = TimedCache(
|
| 159 |
expiration=datetime.timedelta(seconds=cache_expiration_seconds or 0)
|
| 160 |
)
|
|
|
|
| 161 |
self._additional_http_routes: list[BaseRoute] = []
|
| 162 |
self._tool_manager = ToolManager(
|
| 163 |
duplicate_behavior=on_duplicate_tools,
|
|
|
|
| 198 |
self.include_tags = include_tags
|
| 199 |
self.exclude_tags = exclude_tags
|
| 200 |
|
| 201 |
+
self.middleware = middleware or []
|
| 202 |
+
|
| 203 |
# Set up MCP protocol handlers
|
| 204 |
self._setup_handlers()
|
| 205 |
self.dependencies = dependencies or fastmcp.settings.server_dependencies
|
|
|
|
| 323 |
self._mcp_server.read_resource()(self._mcp_read_resource)
|
| 324 |
self._mcp_server.get_prompt()(self._mcp_get_prompt)
|
| 325 |
|
| 326 |
+
async def _apply_middleware(
|
| 327 |
+
self,
|
| 328 |
+
context: MiddlewareContext[Any],
|
| 329 |
+
call_next: Callable[[MiddlewareContext[Any]], Awaitable[Any]],
|
| 330 |
+
) -> Any:
|
| 331 |
+
"""Builds and executes the middleware chain."""
|
| 332 |
+
chain = call_next
|
| 333 |
+
for mw in reversed(self.middleware):
|
| 334 |
+
chain = partial(mw, call_next=chain)
|
| 335 |
+
return await chain(context)
|
| 336 |
+
|
| 337 |
+
def add_middleware(self, middleware: Middleware) -> None:
|
| 338 |
+
self.middleware.append(middleware)
|
| 339 |
+
|
| 340 |
async def get_tools(self) -> dict[str, Tool]:
|
| 341 |
"""Get all registered tools, indexed by registered key."""
|
| 342 |
+
return await self._tool_manager.get_tools()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
|
| 344 |
async def get_tool(self, key: str) -> Tool:
|
| 345 |
tools = await self.get_tools()
|
|
|
|
| 349 |
|
| 350 |
async def get_resources(self) -> dict[str, Resource]:
|
| 351 |
"""Get all registered resources, indexed by registered key."""
|
| 352 |
+
return await self._resource_manager.get_resources()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
|
| 354 |
async def get_resource(self, key: str) -> Resource:
|
| 355 |
resources = await self.get_resources()
|
|
|
|
| 359 |
|
| 360 |
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
| 361 |
"""Get all registered resource templates, indexed by registered key."""
|
| 362 |
+
return await self._resource_manager.get_resource_templates()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
|
| 364 |
async def get_resource_template(self, key: str) -> ResourceTemplate:
|
| 365 |
templates = await self.get_resource_templates()
|
|
|
|
| 371 |
"""
|
| 372 |
List all available prompts.
|
| 373 |
"""
|
| 374 |
+
return await self._prompt_manager.get_prompts()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
|
| 376 |
async def get_prompt(self, key: str) -> Prompt:
|
| 377 |
prompts = await self.get_prompts()
|
|
|
|
| 424 |
return decorator
|
| 425 |
|
| 426 |
async def _mcp_list_tools(self) -> list[MCPTool]:
|
| 427 |
+
logger.debug("Handler called: list_tools")
|
| 428 |
+
|
| 429 |
+
with fastmcp.server.context.Context(fastmcp=self):
|
| 430 |
+
tools = await self._list_tools()
|
| 431 |
+
return [tool.to_mcp_tool(name=tool.key) for tool in tools]
|
| 432 |
+
|
| 433 |
+
async def _list_tools(self) -> list[Tool]:
|
| 434 |
"""
|
| 435 |
List all available tools, in the format expected by the low-level MCP
|
| 436 |
server.
|
| 437 |
|
| 438 |
"""
|
|
|
|
| 439 |
|
| 440 |
+
async def _handler(
|
| 441 |
+
context: MiddlewareContext[mcp.types.ListToolsRequest],
|
| 442 |
+
) -> list[Tool]:
|
| 443 |
+
tools = await self._tool_manager.list_tools() # type: ignore[reportPrivateUsage]
|
| 444 |
+
|
| 445 |
+
mcp_tools: list[Tool] = []
|
| 446 |
+
for tool in tools:
|
| 447 |
+
if self._should_enable_component(tool):
|
| 448 |
+
mcp_tools.append(tool)
|
| 449 |
+
|
| 450 |
+
return mcp_tools
|
| 451 |
+
|
| 452 |
+
with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
|
| 453 |
+
# Create the middleware context.
|
| 454 |
+
mw_context = MiddlewareContext(
|
| 455 |
+
message=mcp.types.ListToolsRequest(method="tools/list"),
|
| 456 |
+
source="client",
|
| 457 |
+
type="request",
|
| 458 |
+
method="tools/list",
|
| 459 |
+
fastmcp_context=fastmcp_ctx,
|
| 460 |
+
)
|
| 461 |
|
| 462 |
+
# Apply the middleware chain.
|
| 463 |
+
return await self._apply_middleware(mw_context, _handler)
|
| 464 |
|
| 465 |
async def _mcp_list_resources(self) -> list[MCPResource]:
|
| 466 |
+
logger.debug("Handler called: list_resources")
|
| 467 |
+
|
| 468 |
+
with fastmcp.server.context.Context(fastmcp=self):
|
| 469 |
+
resources = await self._list_resources()
|
| 470 |
+
return [
|
| 471 |
+
resource.to_mcp_resource(uri=resource.key) for resource in resources
|
| 472 |
+
]
|
| 473 |
+
|
| 474 |
+
async def _list_resources(self) -> list[Resource]:
|
| 475 |
"""
|
| 476 |
List all available resources, in the format expected by the low-level MCP
|
| 477 |
server.
|
| 478 |
|
| 479 |
"""
|
|
|
|
| 480 |
|
| 481 |
+
async def _handler(
|
| 482 |
+
context: MiddlewareContext[dict[str, Any]],
|
| 483 |
+
) -> list[Resource]:
|
| 484 |
+
resources = await self._resource_manager.list_resources() # type: ignore[reportPrivateUsage]
|
| 485 |
+
|
| 486 |
+
mcp_resources: list[Resource] = []
|
| 487 |
+
for resource in resources:
|
| 488 |
+
if self._should_enable_component(resource):
|
| 489 |
+
mcp_resources.append(resource)
|
| 490 |
+
|
| 491 |
+
return mcp_resources
|
| 492 |
+
|
| 493 |
+
with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
|
| 494 |
+
# Create the middleware context.
|
| 495 |
+
mw_context = MiddlewareContext(
|
| 496 |
+
message={}, # List resources doesn't have parameters
|
| 497 |
+
source="client",
|
| 498 |
+
type="request",
|
| 499 |
+
method="resources/list",
|
| 500 |
+
fastmcp_context=fastmcp_ctx,
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
# Apply the middleware chain.
|
| 504 |
+
return await self._apply_middleware(mw_context, _handler)
|
| 505 |
|
| 506 |
async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
|
| 507 |
+
logger.debug("Handler called: list_resource_templates")
|
| 508 |
+
|
| 509 |
+
with fastmcp.server.context.Context(fastmcp=self):
|
| 510 |
+
templates = await self._list_resource_templates()
|
| 511 |
+
return [
|
| 512 |
+
template.to_mcp_template(uriTemplate=template.key)
|
| 513 |
+
for template in templates
|
| 514 |
+
]
|
| 515 |
+
|
| 516 |
+
async def _list_resource_templates(self) -> list[ResourceTemplate]:
|
| 517 |
"""
|
| 518 |
+
List all available resource templates, in the format expected by the low-level MCP
|
| 519 |
+
server.
|
| 520 |
|
| 521 |
"""
|
| 522 |
+
|
| 523 |
+
async def _handler(
|
| 524 |
+
context: MiddlewareContext[dict[str, Any]],
|
| 525 |
+
) -> list[ResourceTemplate]:
|
| 526 |
+
templates = await self._resource_manager.list_resource_templates()
|
| 527 |
+
|
| 528 |
+
mcp_templates: list[ResourceTemplate] = []
|
| 529 |
+
for template in templates:
|
| 530 |
+
if self._should_enable_component(template):
|
| 531 |
+
mcp_templates.append(template)
|
| 532 |
+
|
| 533 |
+
return mcp_templates
|
| 534 |
+
|
| 535 |
+
with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
|
| 536 |
+
# Create the middleware context.
|
| 537 |
+
mw_context = MiddlewareContext(
|
| 538 |
+
message={}, # List resource templates doesn't have parameters
|
| 539 |
+
source="client",
|
| 540 |
+
type="request",
|
| 541 |
+
method="resources/templates/list",
|
| 542 |
+
fastmcp_context=fastmcp_ctx,
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
# Apply the middleware chain.
|
| 546 |
+
return await self._apply_middleware(mw_context, _handler)
|
| 547 |
|
| 548 |
async def _mcp_list_prompts(self) -> list[MCPPrompt]:
|
| 549 |
+
logger.debug("Handler called: list_prompts")
|
| 550 |
+
|
| 551 |
+
with fastmcp.server.context.Context(fastmcp=self):
|
| 552 |
+
prompts = await self._list_prompts()
|
| 553 |
+
return [prompt.to_mcp_prompt(name=prompt.key) for prompt in prompts]
|
| 554 |
+
|
| 555 |
+
async def _list_prompts(self) -> list[Prompt]:
|
| 556 |
"""
|
| 557 |
List all available prompts, in the format expected by the low-level MCP
|
| 558 |
server.
|
| 559 |
|
| 560 |
"""
|
| 561 |
+
|
| 562 |
+
async def _handler(
|
| 563 |
+
context: MiddlewareContext[mcp.types.ListPromptsRequest],
|
| 564 |
+
) -> list[Prompt]:
|
| 565 |
+
prompts = await self._prompt_manager.list_prompts() # type: ignore[reportPrivateUsage]
|
| 566 |
+
|
| 567 |
+
mcp_prompts: list[Prompt] = []
|
| 568 |
+
for prompt in prompts:
|
| 569 |
+
if self._should_enable_component(prompt):
|
| 570 |
+
mcp_prompts.append(prompt)
|
| 571 |
+
|
| 572 |
+
return mcp_prompts
|
| 573 |
+
|
| 574 |
+
with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
|
| 575 |
+
# Create the middleware context.
|
| 576 |
+
mw_context = MiddlewareContext(
|
| 577 |
+
message=mcp.types.ListPromptsRequest(method="prompts/list"),
|
| 578 |
+
source="client",
|
| 579 |
+
type="request",
|
| 580 |
+
method="prompts/list",
|
| 581 |
+
fastmcp_context=fastmcp_ctx,
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
# Apply the middleware chain.
|
| 585 |
+
return await self._apply_middleware(mw_context, _handler)
|
| 586 |
|
| 587 |
async def _mcp_call_tool(
|
| 588 |
self, key: str, arguments: dict[str, Any]
|
|
|
|
| 599 |
Returns:
|
| 600 |
List of MCP Content objects containing the tool results
|
| 601 |
"""
|
| 602 |
+
logger.debug("Handler called: call_tool %s with %s", key, arguments)
|
| 603 |
|
|
|
|
| 604 |
with fastmcp.server.context.Context(fastmcp=self):
|
| 605 |
try:
|
| 606 |
return await self._call_tool(key, arguments)
|
| 607 |
except DisabledError:
|
|
|
|
| 608 |
raise NotFoundError(f"Unknown tool: {key}")
|
| 609 |
except NotFoundError:
|
|
|
|
| 610 |
raise NotFoundError(f"Unknown tool: {key}")
|
| 611 |
|
| 612 |
async def _call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
|
| 613 |
"""
|
| 614 |
+
Applies this server's middleware and delegates the filtered call to the manager.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
"""
|
| 616 |
|
| 617 |
+
async def _handler(
|
| 618 |
+
context: MiddlewareContext[mcp.types.CallToolRequestParams],
|
| 619 |
+
) -> list[MCPContent]:
|
| 620 |
+
tool = await self._tool_manager.get_tool(context.message.name)
|
| 621 |
if not self._should_enable_component(tool):
|
| 622 |
+
raise NotFoundError(f"Unknown tool: {context.message.name!r}")
|
|
|
|
| 623 |
|
| 624 |
+
return await self._tool_manager.call_tool(
|
| 625 |
+
key=context.message.name, arguments=context.message.arguments or {}
|
| 626 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 627 |
|
| 628 |
+
mw_context = MiddlewareContext(
|
| 629 |
+
message=mcp.types.CallToolRequestParams(name=key, arguments=arguments),
|
| 630 |
+
source="client",
|
| 631 |
+
type="request",
|
| 632 |
+
method="tools/call",
|
| 633 |
+
fastmcp_context=fastmcp.server.dependencies.get_context(),
|
| 634 |
+
)
|
| 635 |
+
return await self._apply_middleware(mw_context, _handler)
|
| 636 |
|
| 637 |
async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
| 638 |
"""
|
|
|
|
| 640 |
|
| 641 |
Delegates to _read_resource, which should be overridden by FastMCP subclasses.
|
| 642 |
"""
|
| 643 |
+
logger.debug("Handler called: read_resource %s", uri)
|
| 644 |
|
| 645 |
with fastmcp.server.context.Context(fastmcp=self):
|
| 646 |
try:
|
|
|
|
| 654 |
|
| 655 |
async def _read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
| 656 |
"""
|
| 657 |
+
Applies this server's middleware and delegates the filtered call to the manager.
|
|
|
|
| 658 |
"""
|
| 659 |
+
|
| 660 |
+
async def _handler(
|
| 661 |
+
context: MiddlewareContext[mcp.types.ReadResourceRequestParams],
|
| 662 |
+
) -> list[ReadResourceContents]:
|
| 663 |
+
resource = await self._resource_manager.get_resource(context.message.uri)
|
| 664 |
if not self._should_enable_component(resource):
|
| 665 |
+
raise NotFoundError(f"Unknown resource: {str(context.message.uri)!r}")
|
| 666 |
+
|
| 667 |
+
content = await self._resource_manager.read_resource(context.message.uri)
|
| 668 |
return [
|
| 669 |
ReadResourceContents(
|
| 670 |
content=content,
|
| 671 |
mime_type=resource.mime_type,
|
| 672 |
)
|
| 673 |
]
|
| 674 |
+
|
| 675 |
+
# Convert string URI to AnyUrl if needed
|
| 676 |
+
if isinstance(uri, str):
|
| 677 |
+
uri_param = AnyUrl(uri)
|
| 678 |
else:
|
| 679 |
+
uri_param = uri
|
| 680 |
+
|
| 681 |
+
mw_context = MiddlewareContext(
|
| 682 |
+
message=mcp.types.ReadResourceRequestParams(uri=uri_param),
|
| 683 |
+
source="client",
|
| 684 |
+
type="request",
|
| 685 |
+
method="resources/read",
|
| 686 |
+
fastmcp_context=fastmcp.server.dependencies.get_context(),
|
| 687 |
+
)
|
| 688 |
+
return await self._apply_middleware(mw_context, _handler)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
|
| 690 |
async def _mcp_get_prompt(
|
| 691 |
self, name: str, arguments: dict[str, Any] | None = None
|
|
|
|
| 695 |
|
| 696 |
Delegates to _get_prompt, which should be overridden by FastMCP subclasses.
|
| 697 |
"""
|
| 698 |
+
logger.debug("Handler called: get_prompt %s with %s", name, arguments)
|
| 699 |
|
| 700 |
with fastmcp.server.context.Context(fastmcp=self):
|
| 701 |
try:
|
|
|
|
| 710 |
async def _get_prompt(
|
| 711 |
self, name: str, arguments: dict[str, Any] | None = None
|
| 712 |
) -> GetPromptResult:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 713 |
"""
|
| 714 |
+
Applies this server's middleware and delegates the filtered call to the manager.
|
| 715 |
+
"""
|
| 716 |
|
| 717 |
+
async def _handler(
|
| 718 |
+
context: MiddlewareContext[mcp.types.GetPromptRequestParams],
|
| 719 |
+
) -> GetPromptResult:
|
| 720 |
+
prompt = await self._prompt_manager.get_prompt(context.message.name)
|
| 721 |
if not self._should_enable_component(prompt):
|
| 722 |
+
raise NotFoundError(f"Unknown prompt: {context.message.name!r}")
|
|
|
|
| 723 |
|
| 724 |
+
return await self._prompt_manager.render_prompt(
|
| 725 |
+
name=context.message.name, arguments=context.message.arguments
|
| 726 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
|
| 728 |
+
mw_context = MiddlewareContext(
|
| 729 |
+
message=mcp.types.GetPromptRequestParams(name=name, arguments=arguments),
|
| 730 |
+
source="client",
|
| 731 |
+
type="request",
|
| 732 |
+
method="prompts/get",
|
| 733 |
+
fastmcp_context=fastmcp.server.dependencies.get_context(),
|
| 734 |
+
)
|
| 735 |
+
return await self._apply_middleware(mw_context, _handler)
|
| 736 |
|
| 737 |
def add_tool(self, tool: Tool) -> None:
|
| 738 |
"""Add a tool to the server.
|
|
|
|
| 900 |
enabled=enabled,
|
| 901 |
)
|
| 902 |
|
| 903 |
+
def add_resource(self, resource: Resource) -> None:
|
| 904 |
"""Add a resource to the server.
|
| 905 |
|
| 906 |
Args:
|
| 907 |
resource: A Resource instance to add
|
| 908 |
"""
|
| 909 |
|
| 910 |
+
self._resource_manager.add_resource(resource)
|
| 911 |
self._cache.clear()
|
| 912 |
|
| 913 |
+
def add_template(self, template: ResourceTemplate) -> None:
|
| 914 |
"""Add a resource template to the server.
|
| 915 |
|
| 916 |
Args:
|
| 917 |
template: A ResourceTemplate instance to add
|
| 918 |
"""
|
| 919 |
+
self._resource_manager.add_template(template)
|
| 920 |
|
| 921 |
def add_resource_fn(
|
| 922 |
self,
|
|
|
|
| 1259 |
log_level: str | None = None,
|
| 1260 |
path: str | None = None,
|
| 1261 |
uvicorn_config: dict[str, Any] | None = None,
|
| 1262 |
+
middleware: list[ASGIMiddleware] | None = None,
|
| 1263 |
) -> None:
|
| 1264 |
"""Run the server using HTTP transport.
|
| 1265 |
|
|
|
|
| 1330 |
self,
|
| 1331 |
path: str | None = None,
|
| 1332 |
message_path: str | None = None,
|
| 1333 |
+
middleware: list[ASGIMiddleware] | None = None,
|
| 1334 |
) -> StarletteWithLifespan:
|
| 1335 |
"""
|
| 1336 |
Create a Starlette app for the SSE server.
|
|
|
|
| 1360 |
def streamable_http_app(
|
| 1361 |
self,
|
| 1362 |
path: str | None = None,
|
| 1363 |
+
middleware: list[ASGIMiddleware] | None = None,
|
| 1364 |
) -> StarletteWithLifespan:
|
| 1365 |
"""
|
| 1366 |
Create a Starlette app for the StreamableHTTP server.
|
|
|
|
| 1381 |
def http_app(
|
| 1382 |
self,
|
| 1383 |
path: str | None = None,
|
| 1384 |
+
middleware: list[ASGIMiddleware] | None = None,
|
| 1385 |
json_response: bool | None = None,
|
| 1386 |
stateless_http: bool | None = None,
|
| 1387 |
transport: Literal["streamable-http", "sse"] = "streamable-http",
|
|
|
|
| 1565 |
if as_proxy and not isinstance(server, FastMCPProxy):
|
| 1566 |
server = FastMCPProxy(Client(transport=FastMCPTransport(server)))
|
| 1567 |
|
| 1568 |
+
# Delegate mounting to all three managers
|
| 1569 |
mounted_server = MountedServer(
|
|
|
|
| 1570 |
prefix=prefix,
|
| 1571 |
+
server=server,
|
| 1572 |
+
resource_prefix_format=self.resource_prefix_format,
|
| 1573 |
)
|
| 1574 |
+
self._tool_manager.mount(mounted_server)
|
| 1575 |
+
self._resource_manager.mount(mounted_server)
|
| 1576 |
+
self._prompt_manager.mount(mounted_server)
|
| 1577 |
+
|
| 1578 |
self._cache.clear()
|
| 1579 |
|
| 1580 |
async def import_server(
|
|
|
|
| 1668 |
# Import tools from the server
|
| 1669 |
for key, tool in (await server.get_tools()).items():
|
| 1670 |
if prefix:
|
| 1671 |
+
tool = tool.with_key(f"{prefix}_{key}")
|
| 1672 |
+
self._tool_manager.add_tool(tool)
|
|
|
|
|
|
|
| 1673 |
|
| 1674 |
# Import resources and templates from the server
|
| 1675 |
for key, resource in (await server.get_resources()).items():
|
|
|
|
| 1677 |
resource_key = add_resource_prefix(
|
| 1678 |
key, prefix, self.resource_prefix_format
|
| 1679 |
)
|
| 1680 |
+
resource = resource.with_key(resource_key)
|
| 1681 |
+
self._resource_manager.add_resource(resource)
|
|
|
|
| 1682 |
|
| 1683 |
for key, template in (await server.get_resource_templates()).items():
|
| 1684 |
if prefix:
|
| 1685 |
template_key = add_resource_prefix(
|
| 1686 |
key, prefix, self.resource_prefix_format
|
| 1687 |
)
|
| 1688 |
+
template = template.with_key(template_key)
|
| 1689 |
+
self._resource_manager.add_template(template)
|
|
|
|
| 1690 |
|
| 1691 |
# Import prompts from the server
|
| 1692 |
for key, prompt in (await server.get_prompts()).items():
|
| 1693 |
if prefix:
|
| 1694 |
+
prompt = prompt.with_key(f"{prefix}_{key}")
|
| 1695 |
+
self._prompt_manager.add_prompt(prompt)
|
|
|
|
|
|
|
| 1696 |
|
| 1697 |
if prefix:
|
| 1698 |
+
logger.debug(f"Imported server {server.name} with prefix '{prefix}'")
|
|
|
|
|
|
|
|
|
|
| 1699 |
else:
|
| 1700 |
+
logger.debug(f"Imported server {server.name}")
|
|
|
|
| 1701 |
|
| 1702 |
self._cache.clear()
|
| 1703 |
|
|
|
|
| 1858 |
class MountedServer:
|
| 1859 |
prefix: str | None
|
| 1860 |
server: FastMCP[Any]
|
| 1861 |
+
resource_prefix_format: Literal["protocol", "path"] | None = None
|
| 1862 |
|
| 1863 |
|
| 1864 |
def add_resource_prefix(
|
src/fastmcp/tools/tool_manager.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
|
| 3 |
import warnings
|
| 4 |
from collections.abc import Callable
|
|
@@ -14,7 +14,7 @@ from fastmcp.utilities.logging import get_logger
|
|
| 14 |
from fastmcp.utilities.types import MCPContent
|
| 15 |
|
| 16 |
if TYPE_CHECKING:
|
| 17 |
-
|
| 18 |
|
| 19 |
logger = get_logger(__name__)
|
| 20 |
|
|
@@ -28,6 +28,7 @@ class ToolManager:
|
|
| 28 |
mask_error_details: bool | None = None,
|
| 29 |
):
|
| 30 |
self._tools: dict[str, Tool] = {}
|
|
|
|
| 31 |
self.mask_error_details = mask_error_details or settings.mask_error_details
|
| 32 |
|
| 33 |
# Default to "warn" if None is provided
|
|
@@ -42,23 +43,72 @@ class ToolManager:
|
|
| 42 |
|
| 43 |
self.duplicate_behavior = duplicate_behavior
|
| 44 |
|
| 45 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
"""Check if a tool exists."""
|
| 47 |
-
|
|
|
|
| 48 |
|
| 49 |
-
def get_tool(self, key: str) -> Tool:
|
| 50 |
"""Get tool by key."""
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
| 54 |
|
| 55 |
-
def get_tools(self) -> dict[str, Tool]:
|
| 56 |
-
"""
|
| 57 |
-
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
def list_tools(self) -> list[Tool]:
|
| 60 |
-
"""
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
def add_tool_from_fn(
|
| 64 |
self,
|
|
@@ -89,22 +139,21 @@ class ToolManager:
|
|
| 89 |
)
|
| 90 |
return self.add_tool(tool)
|
| 91 |
|
| 92 |
-
def add_tool(self, tool: Tool
|
| 93 |
"""Register a tool with the server."""
|
| 94 |
-
|
| 95 |
-
existing = self._tools.get(key)
|
| 96 |
if existing:
|
| 97 |
if self.duplicate_behavior == "warn":
|
| 98 |
-
logger.warning(f"Tool already exists: {key}")
|
| 99 |
-
self._tools[key] = tool
|
| 100 |
elif self.duplicate_behavior == "replace":
|
| 101 |
-
self._tools[key] = tool
|
| 102 |
elif self.duplicate_behavior == "error":
|
| 103 |
-
raise ValueError(f"Tool already exists: {key}")
|
| 104 |
elif self.duplicate_behavior == "ignore":
|
| 105 |
return existing
|
| 106 |
else:
|
| 107 |
-
self._tools[key] = tool
|
| 108 |
return tool
|
| 109 |
|
| 110 |
def remove_tool(self, key: str) -> None:
|
|
@@ -119,28 +168,48 @@ class ToolManager:
|
|
| 119 |
if key in self._tools:
|
| 120 |
del self._tools[key]
|
| 121 |
else:
|
| 122 |
-
raise NotFoundError(f"
|
| 123 |
|
| 124 |
async def call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
|
| 125 |
-
"""
|
| 126 |
-
tool
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
|
| 3 |
import warnings
|
| 4 |
from collections.abc import Callable
|
|
|
|
| 14 |
from fastmcp.utilities.types import MCPContent
|
| 15 |
|
| 16 |
if TYPE_CHECKING:
|
| 17 |
+
from fastmcp.server.server import MountedServer
|
| 18 |
|
| 19 |
logger = get_logger(__name__)
|
| 20 |
|
|
|
|
| 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
|
|
|
|
| 43 |
|
| 44 |
self.duplicate_behavior = duplicate_behavior
|
| 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 |
+
"""
|
| 52 |
+
The single, consolidated recursive method for fetching tools. The 'via_server'
|
| 53 |
+
parameter determines the communication path.
|
| 54 |
+
|
| 55 |
+
- via_server=False: Manager-to-manager path for complete, unfiltered inventory
|
| 56 |
+
- via_server=True: Server-to-server path for filtered MCP requests
|
| 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
|
| 64 |
+
child_results = await mounted.server._list_tools()
|
| 65 |
+
else:
|
| 66 |
+
# Use the manager-to-manager unfiltered path
|
| 67 |
+
child_results = await mounted.server._tool_manager.list_tools()
|
| 68 |
+
|
| 69 |
+
# The combination logic is the same for both paths
|
| 70 |
+
child_dict = {t.key: t for t in child_results}
|
| 71 |
+
if mounted.prefix:
|
| 72 |
+
for tool in child_dict.values():
|
| 73 |
+
prefixed_tool = tool.with_key(f"{mounted.prefix}_{tool.key}")
|
| 74 |
+
all_tools[prefixed_tool.key] = prefixed_tool
|
| 75 |
+
else:
|
| 76 |
+
all_tools.update(child_dict)
|
| 77 |
+
except Exception as e:
|
| 78 |
+
# Skip failed mounts silently, matches existing behavior
|
| 79 |
+
logger.warning(
|
| 80 |
+
f"Failed to get tools from mounted server '{mounted.prefix}': {e}"
|
| 81 |
+
)
|
| 82 |
+
continue
|
| 83 |
+
|
| 84 |
+
# Finally, add local tools, which always take precedence
|
| 85 |
+
all_tools.update(self._tools)
|
| 86 |
+
return all_tools
|
| 87 |
+
|
| 88 |
+
async def has_tool(self, key: str) -> bool:
|
| 89 |
"""Check if a tool exists."""
|
| 90 |
+
tools = await self.get_tools()
|
| 91 |
+
return key in tools
|
| 92 |
|
| 93 |
+
async def get_tool(self, key: str) -> Tool:
|
| 94 |
"""Get tool by key."""
|
| 95 |
+
tools = await self.get_tools()
|
| 96 |
+
if key in tools:
|
| 97 |
+
return tools[key]
|
| 98 |
+
raise NotFoundError(f"Tool {key!r} not found")
|
| 99 |
|
| 100 |
+
async def get_tools(self) -> dict[str, Tool]:
|
| 101 |
+
"""
|
| 102 |
+
Gets the complete, unfiltered inventory of all tools.
|
| 103 |
+
"""
|
| 104 |
+
return await self._load_tools(via_server=False)
|
| 105 |
|
| 106 |
+
async def list_tools(self) -> list[Tool]:
|
| 107 |
+
"""
|
| 108 |
+
Lists all tools, applying protocol filtering.
|
| 109 |
+
"""
|
| 110 |
+
tools_dict = await self._load_tools(via_server=True)
|
| 111 |
+
return list(tools_dict.values())
|
| 112 |
|
| 113 |
def add_tool_from_fn(
|
| 114 |
self,
|
|
|
|
| 139 |
)
|
| 140 |
return self.add_tool(tool)
|
| 141 |
|
| 142 |
+
def add_tool(self, tool: Tool) -> Tool:
|
| 143 |
"""Register a tool with the server."""
|
| 144 |
+
existing = self._tools.get(tool.key)
|
|
|
|
| 145 |
if existing:
|
| 146 |
if self.duplicate_behavior == "warn":
|
| 147 |
+
logger.warning(f"Tool already exists: {tool.key}")
|
| 148 |
+
self._tools[tool.key] = tool
|
| 149 |
elif self.duplicate_behavior == "replace":
|
| 150 |
+
self._tools[tool.key] = tool
|
| 151 |
elif self.duplicate_behavior == "error":
|
| 152 |
+
raise ValueError(f"Tool already exists: {tool.key}")
|
| 153 |
elif self.duplicate_behavior == "ignore":
|
| 154 |
return existing
|
| 155 |
else:
|
| 156 |
+
self._tools[tool.key] = tool
|
| 157 |
return tool
|
| 158 |
|
| 159 |
def remove_tool(self, key: str) -> None:
|
|
|
|
| 168 |
if key in self._tools:
|
| 169 |
del self._tools[key]
|
| 170 |
else:
|
| 171 |
+
raise NotFoundError(f"Tool {key!r} not found")
|
| 172 |
|
| 173 |
async def call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
|
| 174 |
+
"""
|
| 175 |
+
Internal API for servers: Finds and calls a tool, respecting the
|
| 176 |
+
filtered protocol path.
|
| 177 |
+
"""
|
| 178 |
+
# 1. Check local tools first. The server will have already applied its filter.
|
| 179 |
+
if key in self._tools:
|
| 180 |
+
tool = await self.get_tool(key)
|
| 181 |
+
if not tool:
|
| 182 |
+
raise NotFoundError(f"Tool {key!r} not found")
|
| 183 |
+
|
| 184 |
+
try:
|
| 185 |
+
return await tool.run(arguments)
|
| 186 |
+
|
| 187 |
+
# raise ToolErrors as-is
|
| 188 |
+
except ToolError as e:
|
| 189 |
+
logger.exception(f"Error calling tool {key!r}: {e}")
|
| 190 |
+
raise e
|
| 191 |
+
|
| 192 |
+
# Handle other exceptions
|
| 193 |
+
except Exception as e:
|
| 194 |
+
logger.exception(f"Error calling tool {key!r}: {e}")
|
| 195 |
+
if self.mask_error_details:
|
| 196 |
+
# Mask internal details
|
| 197 |
+
raise ToolError(f"Error calling tool {key!r}") from e
|
| 198 |
+
else:
|
| 199 |
+
# Include original error details
|
| 200 |
+
raise ToolError(f"Error calling tool {key!r}: {e}") from e
|
| 201 |
+
|
| 202 |
+
# 2. Check mounted servers using the filtered protocol path.
|
| 203 |
+
for mounted in reversed(self._mounted_servers):
|
| 204 |
+
tool_key = key
|
| 205 |
+
if mounted.prefix:
|
| 206 |
+
if key.startswith(f"{mounted.prefix}_"):
|
| 207 |
+
tool_key = key.removeprefix(f"{mounted.prefix}_")
|
| 208 |
+
else:
|
| 209 |
+
continue
|
| 210 |
+
try:
|
| 211 |
+
return await mounted.server._call_tool(tool_key, arguments)
|
| 212 |
+
except NotFoundError:
|
| 213 |
+
continue
|
| 214 |
+
|
| 215 |
+
raise NotFoundError(f"Tool {key!r} not found.")
|
tests/client/test_client.py
CHANGED
|
@@ -833,7 +833,12 @@ class TestInferTransport:
|
|
| 833 |
transport = infer_transport(config)
|
| 834 |
assert isinstance(transport, MCPConfigTransport)
|
| 835 |
assert isinstance(transport.transport, FastMCPTransport)
|
| 836 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 837 |
|
| 838 |
def test_infer_fastmcp_server(self, fastmcp_server):
|
| 839 |
"""FastMCP server instances should infer to FastMCPTransport."""
|
|
|
|
| 833 |
transport = infer_transport(config)
|
| 834 |
assert isinstance(transport, MCPConfigTransport)
|
| 835 |
assert isinstance(transport.transport, FastMCPTransport)
|
| 836 |
+
assert (
|
| 837 |
+
len(
|
| 838 |
+
cast(FastMCP, transport.transport.server)._tool_manager._mounted_servers
|
| 839 |
+
)
|
| 840 |
+
== 2
|
| 841 |
+
)
|
| 842 |
|
| 843 |
def test_infer_fastmcp_server(self, fastmcp_server):
|
| 844 |
"""FastMCP server instances should infer to FastMCPTransport."""
|
tests/deprecated/test_resource_prefixes.py
CHANGED
|
@@ -99,7 +99,7 @@ async def test_import_server_with_legacy_prefixes():
|
|
| 99 |
await main_server.import_server("sub", sub_server) # type: ignore[arg-type]
|
| 100 |
|
| 101 |
# Check that the resource is prefixed using the legacy format
|
| 102 |
-
resources = main_server.
|
| 103 |
|
| 104 |
# In legacy format, the key would be "sub+resource://test"
|
| 105 |
assert "sub+resource://test" in resources
|
|
|
|
| 99 |
await main_server.import_server("sub", sub_server) # type: ignore[arg-type]
|
| 100 |
|
| 101 |
# Check that the resource is prefixed using the legacy format
|
| 102 |
+
resources = await main_server.get_resources()
|
| 103 |
|
| 104 |
# In legacy format, the key would be "sub+resource://test"
|
| 105 |
assert "sub+resource://test" in resources
|
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 |
-
assert {r.name for r in internal_resources} == {"user_data", "system_data"}
|
| 428 |
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
]
|
| 432 |
-
assert len(external_resources) == 1
|
| 433 |
-
assert external_resources[0].name == "weather_data"
|
| 434 |
|
| 435 |
|
| 436 |
class TestCustomResourceKeys:
|
|
@@ -452,7 +464,9 @@ class TestCustomResourceKeys:
|
|
| 452 |
fn=get_data,
|
| 453 |
)
|
| 454 |
|
| 455 |
-
|
|
|
|
|
|
|
| 456 |
|
| 457 |
# Resource should be accessible via custom key
|
| 458 |
assert custom_key in manager._resources
|
|
@@ -477,7 +491,9 @@ class TestCustomResourceKeys:
|
|
| 477 |
name="test_template",
|
| 478 |
)
|
| 479 |
|
| 480 |
-
|
|
|
|
|
|
|
| 481 |
|
| 482 |
# Template should be accessible via custom key
|
| 483 |
assert custom_key in manager._templates
|
|
@@ -502,7 +518,9 @@ class TestCustomResourceKeys:
|
|
| 502 |
fn=get_data,
|
| 503 |
)
|
| 504 |
|
| 505 |
-
|
|
|
|
|
|
|
| 506 |
|
| 507 |
# Should be retrievable by the custom key
|
| 508 |
retrieved = await manager.get_resource(custom_key)
|
|
@@ -529,7 +547,9 @@ class TestCustomResourceKeys:
|
|
| 529 |
name="custom_greeter",
|
| 530 |
)
|
| 531 |
|
| 532 |
-
|
|
|
|
|
|
|
| 533 |
|
| 534 |
# Using a URI that matches the custom key pattern
|
| 535 |
resource = await manager.get_resource("custom://greet/world")
|
|
|
|
| 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:
|
|
|
|
| 464 |
fn=get_data,
|
| 465 |
)
|
| 466 |
|
| 467 |
+
# Use with_key to create a new resource with the custom key
|
| 468 |
+
resource_with_custom_key = resource.with_key(custom_key)
|
| 469 |
+
manager.add_resource(resource_with_custom_key)
|
| 470 |
|
| 471 |
# Resource should be accessible via custom key
|
| 472 |
assert custom_key in manager._resources
|
|
|
|
| 491 |
name="test_template",
|
| 492 |
)
|
| 493 |
|
| 494 |
+
# Use with_key to create a new template with the custom key
|
| 495 |
+
template_with_custom_key = template.with_key(custom_key)
|
| 496 |
+
manager.add_template(template_with_custom_key)
|
| 497 |
|
| 498 |
# Template should be accessible via custom key
|
| 499 |
assert custom_key in manager._templates
|
|
|
|
| 518 |
fn=get_data,
|
| 519 |
)
|
| 520 |
|
| 521 |
+
# Use with_key to create a new resource with the custom key
|
| 522 |
+
resource_with_custom_key = resource.with_key(custom_key)
|
| 523 |
+
manager.add_resource(resource_with_custom_key)
|
| 524 |
|
| 525 |
# Should be retrievable by the custom key
|
| 526 |
retrieved = await manager.get_resource(custom_key)
|
|
|
|
| 547 |
name="custom_greeter",
|
| 548 |
)
|
| 549 |
|
| 550 |
+
# Use with_key to create a new template with the custom key
|
| 551 |
+
template_with_custom_key = template.with_key(custom_key)
|
| 552 |
+
manager.add_template(template_with_custom_key)
|
| 553 |
|
| 554 |
# Using a URI that matches the custom key pattern
|
| 555 |
resource = await manager.get_resource("custom://greet/world")
|
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/middleware/__init__.py
ADDED
|
File without changes
|
tests/server/middleware/test_middleware.py
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections.abc import Callable
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import mcp.types
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from fastmcp import Client, FastMCP
|
| 9 |
+
from fastmcp.server.context import Context
|
| 10 |
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class Recording:
|
| 15 |
+
# the hook is the name of the hook that was called, e.g. "on_list_tools"
|
| 16 |
+
hook: str
|
| 17 |
+
context: MiddlewareContext
|
| 18 |
+
result: mcp.types.ServerResult | None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class RecordingMiddleware(Middleware):
|
| 22 |
+
"""A middleware that automatically records all method calls."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, name: str | None = None):
|
| 25 |
+
super().__init__()
|
| 26 |
+
self.calls: list[Recording] = []
|
| 27 |
+
self.name = name
|
| 28 |
+
|
| 29 |
+
def __getattribute__(self, name: str) -> Callable:
|
| 30 |
+
"""Dynamically create recording methods for any on_* method."""
|
| 31 |
+
if name.startswith("on_"):
|
| 32 |
+
|
| 33 |
+
async def record_and_call(
|
| 34 |
+
context: MiddlewareContext, call_next: Callable
|
| 35 |
+
) -> Any:
|
| 36 |
+
result = await call_next(context)
|
| 37 |
+
|
| 38 |
+
self.calls.append(Recording(hook=name, context=context, result=result))
|
| 39 |
+
|
| 40 |
+
return result
|
| 41 |
+
|
| 42 |
+
return record_and_call
|
| 43 |
+
|
| 44 |
+
return super().__getattribute__(name)
|
| 45 |
+
|
| 46 |
+
def get_calls(
|
| 47 |
+
self, method: str | None = None, hook: str | None = None
|
| 48 |
+
) -> list[Recording]:
|
| 49 |
+
"""
|
| 50 |
+
Get all recorded calls for a specific method or hook.
|
| 51 |
+
Args:
|
| 52 |
+
method: The method to filter by (e.g. "tools/list")
|
| 53 |
+
hook: The hook to filter by (e.g. "on_list_tools")
|
| 54 |
+
Returns:
|
| 55 |
+
A list of recorded calls.
|
| 56 |
+
"""
|
| 57 |
+
calls = []
|
| 58 |
+
for recording in self.calls:
|
| 59 |
+
if method and hook:
|
| 60 |
+
if recording.context.method == method and recording.hook == hook:
|
| 61 |
+
calls.append(recording)
|
| 62 |
+
elif method:
|
| 63 |
+
if recording.context.method == method:
|
| 64 |
+
calls.append(recording)
|
| 65 |
+
elif hook:
|
| 66 |
+
if recording.hook == hook:
|
| 67 |
+
calls.append(recording)
|
| 68 |
+
else:
|
| 69 |
+
calls.append(recording)
|
| 70 |
+
return calls
|
| 71 |
+
|
| 72 |
+
def assert_called(
|
| 73 |
+
self, hook: str | None = None, method: str | None = None, times: int = 1
|
| 74 |
+
) -> bool:
|
| 75 |
+
"""Assert that a hook was called a specific number of times."""
|
| 76 |
+
calls = self.get_calls(hook=hook, method=method)
|
| 77 |
+
actual_times = len(calls)
|
| 78 |
+
identifier = dict(hook=hook, method=method)
|
| 79 |
+
assert actual_times == times, (
|
| 80 |
+
f"Expected {times} calls for {identifier}, "
|
| 81 |
+
f"but was called {actual_times} times"
|
| 82 |
+
)
|
| 83 |
+
return True
|
| 84 |
+
|
| 85 |
+
def assert_not_called(self, hook: str | None = None, method: str | None = None):
|
| 86 |
+
"""Assert that a hook was not called."""
|
| 87 |
+
calls = self.get_calls(hook=hook, method=method)
|
| 88 |
+
assert len(calls) == 0, f"Expected {hook!r} to not be called"
|
| 89 |
+
return True
|
| 90 |
+
|
| 91 |
+
def reset(self):
|
| 92 |
+
"""Clear all recorded calls."""
|
| 93 |
+
self.calls.clear()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@pytest.fixture
|
| 97 |
+
def recording_middleware():
|
| 98 |
+
"""Fixture that provides a recording middleware instance."""
|
| 99 |
+
middleware = RecordingMiddleware(name="recording_middleware")
|
| 100 |
+
yield middleware
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@pytest.fixture
|
| 104 |
+
def mcp_server(recording_middleware):
|
| 105 |
+
mcp = FastMCP()
|
| 106 |
+
|
| 107 |
+
@mcp.tool
|
| 108 |
+
def add(a: int, b: int) -> int:
|
| 109 |
+
return a + b
|
| 110 |
+
|
| 111 |
+
@mcp.resource("resource://test")
|
| 112 |
+
def test_resource() -> str:
|
| 113 |
+
return "test resource"
|
| 114 |
+
|
| 115 |
+
@mcp.resource("resource://test-template/{x}")
|
| 116 |
+
def test_resource_with_path(x: int) -> str:
|
| 117 |
+
return f"test resource with {x}"
|
| 118 |
+
|
| 119 |
+
@mcp.prompt
|
| 120 |
+
def test_prompt(x: str) -> str:
|
| 121 |
+
return f"test prompt with {x}"
|
| 122 |
+
|
| 123 |
+
@mcp.tool
|
| 124 |
+
async def progress_tool(context: Context) -> None:
|
| 125 |
+
await context.report_progress(progress=1, total=10, message="test")
|
| 126 |
+
|
| 127 |
+
@mcp.tool
|
| 128 |
+
async def log_tool(context: Context) -> None:
|
| 129 |
+
await context.info(message="test log")
|
| 130 |
+
|
| 131 |
+
@mcp.tool
|
| 132 |
+
async def sample_tool(context: Context) -> None:
|
| 133 |
+
await context.sample("hello")
|
| 134 |
+
|
| 135 |
+
mcp.add_middleware(recording_middleware)
|
| 136 |
+
|
| 137 |
+
# Register progress handler
|
| 138 |
+
@mcp._mcp_server.progress_notification()
|
| 139 |
+
async def handle_progress(
|
| 140 |
+
progress_token: str | int,
|
| 141 |
+
progress: float,
|
| 142 |
+
total: float | None,
|
| 143 |
+
message: str | None,
|
| 144 |
+
):
|
| 145 |
+
print("HI")
|
| 146 |
+
|
| 147 |
+
return mcp
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class TestMiddlewareHooks:
|
| 151 |
+
async def test_call_tool(
|
| 152 |
+
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
| 153 |
+
):
|
| 154 |
+
async with Client(mcp_server) as client:
|
| 155 |
+
await client.call_tool("add", {"a": 1, "b": 2})
|
| 156 |
+
|
| 157 |
+
assert recording_middleware.assert_called(times=3)
|
| 158 |
+
assert recording_middleware.assert_called(method="tools/call", times=3)
|
| 159 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 160 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 161 |
+
assert recording_middleware.assert_called(hook="on_call_tool", times=1)
|
| 162 |
+
|
| 163 |
+
async def test_read_resource(
|
| 164 |
+
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
| 165 |
+
):
|
| 166 |
+
async with Client(mcp_server) as client:
|
| 167 |
+
await client.read_resource("resource://test")
|
| 168 |
+
|
| 169 |
+
assert recording_middleware.assert_called(times=3)
|
| 170 |
+
assert recording_middleware.assert_called(method="resources/read", times=3)
|
| 171 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 172 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 173 |
+
assert recording_middleware.assert_called(hook="on_read_resource", times=1)
|
| 174 |
+
|
| 175 |
+
async def test_read_resource_template(
|
| 176 |
+
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
| 177 |
+
):
|
| 178 |
+
async with Client(mcp_server) as client:
|
| 179 |
+
await client.read_resource("resource://test-template/1")
|
| 180 |
+
|
| 181 |
+
assert recording_middleware.assert_called(times=3)
|
| 182 |
+
assert recording_middleware.assert_called(method="resources/read", times=3)
|
| 183 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 184 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 185 |
+
assert recording_middleware.assert_called(hook="on_read_resource", times=1)
|
| 186 |
+
|
| 187 |
+
async def test_get_prompt(
|
| 188 |
+
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
| 189 |
+
):
|
| 190 |
+
async with Client(mcp_server) as client:
|
| 191 |
+
await client.get_prompt("test_prompt", {"x": "test"})
|
| 192 |
+
|
| 193 |
+
assert recording_middleware.assert_called(times=3)
|
| 194 |
+
assert recording_middleware.assert_called(method="prompts/get", times=3)
|
| 195 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 196 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 197 |
+
assert recording_middleware.assert_called(hook="on_get_prompt", times=1)
|
| 198 |
+
|
| 199 |
+
async def test_list_tools(
|
| 200 |
+
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
| 201 |
+
):
|
| 202 |
+
async with Client(mcp_server) as client:
|
| 203 |
+
await client.list_tools()
|
| 204 |
+
|
| 205 |
+
assert recording_middleware.assert_called(times=3)
|
| 206 |
+
assert recording_middleware.assert_called(method="tools/list", times=3)
|
| 207 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 208 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 209 |
+
assert recording_middleware.assert_called(hook="on_list_tools", times=1)
|
| 210 |
+
|
| 211 |
+
async def test_list_resources(
|
| 212 |
+
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
| 213 |
+
):
|
| 214 |
+
async with Client(mcp_server) as client:
|
| 215 |
+
await client.list_resources()
|
| 216 |
+
|
| 217 |
+
assert recording_middleware.assert_called(times=3)
|
| 218 |
+
assert recording_middleware.assert_called(method="resources/list", times=3)
|
| 219 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 220 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 221 |
+
assert recording_middleware.assert_called(hook="on_list_resources", times=1)
|
| 222 |
+
|
| 223 |
+
async def test_list_resource_templates(
|
| 224 |
+
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
| 225 |
+
):
|
| 226 |
+
async with Client(mcp_server) as client:
|
| 227 |
+
await client.list_resource_templates()
|
| 228 |
+
|
| 229 |
+
assert recording_middleware.assert_called(times=3)
|
| 230 |
+
assert recording_middleware.assert_called(
|
| 231 |
+
method="resources/templates/list", times=3
|
| 232 |
+
)
|
| 233 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 234 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 235 |
+
assert recording_middleware.assert_called(
|
| 236 |
+
hook="on_list_resource_templates", times=1
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
async def test_list_prompts(
|
| 240 |
+
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
| 241 |
+
):
|
| 242 |
+
async with Client(mcp_server) as client:
|
| 243 |
+
await client.list_prompts()
|
| 244 |
+
|
| 245 |
+
assert recording_middleware.assert_called(times=3)
|
| 246 |
+
assert recording_middleware.assert_called(method="prompts/list", times=3)
|
| 247 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 248 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 249 |
+
assert recording_middleware.assert_called(hook="on_list_prompts", times=1)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class TestNestedMiddlewareHooks:
|
| 253 |
+
@pytest.fixture
|
| 254 |
+
@staticmethod
|
| 255 |
+
def nested_middleware():
|
| 256 |
+
return RecordingMiddleware(name="nested_middleware")
|
| 257 |
+
|
| 258 |
+
@pytest.fixture
|
| 259 |
+
def nested_mcp_server(self, nested_middleware: RecordingMiddleware):
|
| 260 |
+
mcp = FastMCP(name="Nested MCP")
|
| 261 |
+
|
| 262 |
+
@mcp.tool
|
| 263 |
+
def add(a: int, b: int) -> int:
|
| 264 |
+
return a + b
|
| 265 |
+
|
| 266 |
+
@mcp.resource("resource://test")
|
| 267 |
+
def test_resource() -> str:
|
| 268 |
+
return "test resource"
|
| 269 |
+
|
| 270 |
+
@mcp.resource("resource://test-template/{x}")
|
| 271 |
+
def test_resource_with_path(x: int) -> str:
|
| 272 |
+
return f"test resource with {x}"
|
| 273 |
+
|
| 274 |
+
@mcp.prompt
|
| 275 |
+
def test_prompt(x: str) -> str:
|
| 276 |
+
return f"test prompt with {x}"
|
| 277 |
+
|
| 278 |
+
@mcp.tool
|
| 279 |
+
async def progress_tool(context: Context) -> None:
|
| 280 |
+
await context.report_progress(progress=1, total=10, message="test")
|
| 281 |
+
|
| 282 |
+
@mcp.tool
|
| 283 |
+
async def log_tool(context: Context) -> None:
|
| 284 |
+
await context.info(message="test log")
|
| 285 |
+
|
| 286 |
+
@mcp.tool
|
| 287 |
+
async def sample_tool(context: Context) -> None:
|
| 288 |
+
await context.sample("hello")
|
| 289 |
+
|
| 290 |
+
mcp.add_middleware(nested_middleware)
|
| 291 |
+
|
| 292 |
+
return mcp
|
| 293 |
+
|
| 294 |
+
async def test_call_tool_on_parent_server(
|
| 295 |
+
self,
|
| 296 |
+
mcp_server: FastMCP,
|
| 297 |
+
nested_mcp_server: FastMCP,
|
| 298 |
+
recording_middleware: RecordingMiddleware,
|
| 299 |
+
nested_middleware: RecordingMiddleware,
|
| 300 |
+
):
|
| 301 |
+
mcp_server.mount(nested_mcp_server, prefix="nested")
|
| 302 |
+
|
| 303 |
+
async with Client(mcp_server) as client:
|
| 304 |
+
await client.call_tool("add", {"a": 1, "b": 2})
|
| 305 |
+
|
| 306 |
+
assert recording_middleware.assert_called(times=3)
|
| 307 |
+
assert recording_middleware.assert_called(method="tools/call", times=3)
|
| 308 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 309 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 310 |
+
assert recording_middleware.assert_called(hook="on_call_tool", times=1)
|
| 311 |
+
|
| 312 |
+
assert nested_middleware.assert_called(times=0)
|
| 313 |
+
|
| 314 |
+
async def test_call_tool_on_nested_server(
|
| 315 |
+
self,
|
| 316 |
+
mcp_server: FastMCP,
|
| 317 |
+
nested_mcp_server: FastMCP,
|
| 318 |
+
recording_middleware: RecordingMiddleware,
|
| 319 |
+
nested_middleware: RecordingMiddleware,
|
| 320 |
+
):
|
| 321 |
+
mcp_server.mount(nested_mcp_server, prefix="nested")
|
| 322 |
+
|
| 323 |
+
async with Client(mcp_server) as client:
|
| 324 |
+
await client.call_tool("nested_add", {"a": 1, "b": 2})
|
| 325 |
+
|
| 326 |
+
assert recording_middleware.assert_called(times=3)
|
| 327 |
+
assert recording_middleware.assert_called(method="tools/call", times=3)
|
| 328 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 329 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 330 |
+
assert recording_middleware.assert_called(hook="on_call_tool", times=1)
|
| 331 |
+
|
| 332 |
+
assert nested_middleware.assert_called(times=3)
|
| 333 |
+
assert nested_middleware.assert_called(method="tools/call", times=3)
|
| 334 |
+
assert nested_middleware.assert_called(hook="on_message", times=1)
|
| 335 |
+
assert nested_middleware.assert_called(hook="on_request", times=1)
|
| 336 |
+
assert nested_middleware.assert_called(hook="on_call_tool", times=1)
|
| 337 |
+
|
| 338 |
+
async def test_read_resource_on_parent_server(
|
| 339 |
+
self,
|
| 340 |
+
mcp_server: FastMCP,
|
| 341 |
+
nested_mcp_server: FastMCP,
|
| 342 |
+
recording_middleware: RecordingMiddleware,
|
| 343 |
+
nested_middleware: RecordingMiddleware,
|
| 344 |
+
):
|
| 345 |
+
mcp_server.mount(nested_mcp_server, prefix="nested")
|
| 346 |
+
|
| 347 |
+
async with Client(mcp_server) as client:
|
| 348 |
+
await client.read_resource("resource://test")
|
| 349 |
+
|
| 350 |
+
assert recording_middleware.assert_called(times=3)
|
| 351 |
+
assert recording_middleware.assert_called(method="resources/read", times=3)
|
| 352 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 353 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 354 |
+
assert recording_middleware.assert_called(hook="on_read_resource", times=1)
|
| 355 |
+
|
| 356 |
+
assert nested_middleware.assert_called(times=0)
|
| 357 |
+
|
| 358 |
+
async def test_read_resource_on_nested_server(
|
| 359 |
+
self,
|
| 360 |
+
mcp_server: FastMCP,
|
| 361 |
+
nested_mcp_server: FastMCP,
|
| 362 |
+
recording_middleware: RecordingMiddleware,
|
| 363 |
+
nested_middleware: RecordingMiddleware,
|
| 364 |
+
):
|
| 365 |
+
mcp_server.mount(nested_mcp_server, prefix="nested")
|
| 366 |
+
|
| 367 |
+
async with Client(mcp_server) as client:
|
| 368 |
+
await client.read_resource("resource://nested/test")
|
| 369 |
+
|
| 370 |
+
assert recording_middleware.assert_called(times=3)
|
| 371 |
+
assert recording_middleware.assert_called(method="resources/read", times=3)
|
| 372 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 373 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 374 |
+
assert recording_middleware.assert_called(hook="on_read_resource", times=1)
|
| 375 |
+
|
| 376 |
+
assert nested_middleware.assert_called(times=3)
|
| 377 |
+
assert nested_middleware.assert_called(method="resources/read", times=3)
|
| 378 |
+
assert nested_middleware.assert_called(hook="on_message", times=1)
|
| 379 |
+
assert nested_middleware.assert_called(hook="on_request", times=1)
|
| 380 |
+
assert nested_middleware.assert_called(hook="on_read_resource", times=1)
|
| 381 |
+
|
| 382 |
+
async def test_read_resource_template_on_parent_server(
|
| 383 |
+
self,
|
| 384 |
+
mcp_server: FastMCP,
|
| 385 |
+
nested_mcp_server: FastMCP,
|
| 386 |
+
recording_middleware: RecordingMiddleware,
|
| 387 |
+
nested_middleware: RecordingMiddleware,
|
| 388 |
+
):
|
| 389 |
+
mcp_server.mount(nested_mcp_server, prefix="nested")
|
| 390 |
+
|
| 391 |
+
async with Client(mcp_server) as client:
|
| 392 |
+
await client.read_resource("resource://test-template/1")
|
| 393 |
+
|
| 394 |
+
assert recording_middleware.assert_called(times=3)
|
| 395 |
+
assert recording_middleware.assert_called(method="resources/read", times=3)
|
| 396 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 397 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 398 |
+
assert recording_middleware.assert_called(hook="on_read_resource", times=1)
|
| 399 |
+
|
| 400 |
+
assert nested_middleware.assert_called(times=0)
|
| 401 |
+
|
| 402 |
+
async def test_read_resource_template_on_nested_server(
|
| 403 |
+
self,
|
| 404 |
+
mcp_server: FastMCP,
|
| 405 |
+
nested_mcp_server: FastMCP,
|
| 406 |
+
recording_middleware: RecordingMiddleware,
|
| 407 |
+
nested_middleware: RecordingMiddleware,
|
| 408 |
+
):
|
| 409 |
+
mcp_server.mount(nested_mcp_server, prefix="nested")
|
| 410 |
+
|
| 411 |
+
async with Client(mcp_server) as client:
|
| 412 |
+
await client.read_resource("resource://nested/test-template/1")
|
| 413 |
+
|
| 414 |
+
assert recording_middleware.assert_called(times=3)
|
| 415 |
+
assert recording_middleware.assert_called(method="resources/read", times=3)
|
| 416 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 417 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 418 |
+
assert recording_middleware.assert_called(hook="on_read_resource", times=1)
|
| 419 |
+
|
| 420 |
+
assert nested_middleware.assert_called(times=3)
|
| 421 |
+
assert nested_middleware.assert_called(method="resources/read", times=3)
|
| 422 |
+
assert nested_middleware.assert_called(hook="on_message", times=1)
|
| 423 |
+
assert nested_middleware.assert_called(hook="on_request", times=1)
|
| 424 |
+
assert nested_middleware.assert_called(hook="on_read_resource", times=1)
|
| 425 |
+
|
| 426 |
+
async def test_get_prompt_on_parent_server(
|
| 427 |
+
self,
|
| 428 |
+
mcp_server: FastMCP,
|
| 429 |
+
nested_mcp_server: FastMCP,
|
| 430 |
+
recording_middleware: RecordingMiddleware,
|
| 431 |
+
nested_middleware: RecordingMiddleware,
|
| 432 |
+
):
|
| 433 |
+
mcp_server.mount(nested_mcp_server, prefix="nested")
|
| 434 |
+
|
| 435 |
+
async with Client(mcp_server) as client:
|
| 436 |
+
await client.get_prompt("test_prompt", {"x": "test"})
|
| 437 |
+
|
| 438 |
+
assert recording_middleware.assert_called(times=3)
|
| 439 |
+
assert recording_middleware.assert_called(method="prompts/get", times=3)
|
| 440 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 441 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 442 |
+
assert recording_middleware.assert_called(hook="on_get_prompt", times=1)
|
| 443 |
+
|
| 444 |
+
assert nested_middleware.assert_called(times=0)
|
| 445 |
+
|
| 446 |
+
async def test_get_prompt_on_nested_server(
|
| 447 |
+
self,
|
| 448 |
+
mcp_server: FastMCP,
|
| 449 |
+
nested_mcp_server: FastMCP,
|
| 450 |
+
recording_middleware: RecordingMiddleware,
|
| 451 |
+
nested_middleware: RecordingMiddleware,
|
| 452 |
+
):
|
| 453 |
+
mcp_server.mount(nested_mcp_server, prefix="nested")
|
| 454 |
+
|
| 455 |
+
async with Client(mcp_server) as client:
|
| 456 |
+
await client.get_prompt("nested_test_prompt", {"x": "test"})
|
| 457 |
+
|
| 458 |
+
assert recording_middleware.assert_called(times=3)
|
| 459 |
+
assert recording_middleware.assert_called(method="prompts/get", times=3)
|
| 460 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 461 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 462 |
+
assert recording_middleware.assert_called(hook="on_get_prompt", times=1)
|
| 463 |
+
|
| 464 |
+
assert nested_middleware.assert_called(times=3)
|
| 465 |
+
assert nested_middleware.assert_called(method="prompts/get", times=3)
|
| 466 |
+
assert nested_middleware.assert_called(hook="on_message", times=1)
|
| 467 |
+
assert nested_middleware.assert_called(hook="on_request", times=1)
|
| 468 |
+
assert nested_middleware.assert_called(hook="on_get_prompt", times=1)
|
| 469 |
+
|
| 470 |
+
async def test_list_tools_on_nested_server(
|
| 471 |
+
self,
|
| 472 |
+
mcp_server: FastMCP,
|
| 473 |
+
nested_mcp_server: FastMCP,
|
| 474 |
+
recording_middleware: RecordingMiddleware,
|
| 475 |
+
nested_middleware: RecordingMiddleware,
|
| 476 |
+
):
|
| 477 |
+
mcp_server.mount(nested_mcp_server, prefix="nested")
|
| 478 |
+
|
| 479 |
+
async with Client(mcp_server) as client:
|
| 480 |
+
await client.list_tools()
|
| 481 |
+
|
| 482 |
+
assert recording_middleware.assert_called(times=3)
|
| 483 |
+
assert recording_middleware.assert_called(method="tools/list", times=3)
|
| 484 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 485 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 486 |
+
assert recording_middleware.assert_called(hook="on_list_tools", times=1)
|
| 487 |
+
|
| 488 |
+
assert nested_middleware.assert_called(times=3)
|
| 489 |
+
assert nested_middleware.assert_called(method="tools/list", times=3)
|
| 490 |
+
assert nested_middleware.assert_called(hook="on_message", times=1)
|
| 491 |
+
assert nested_middleware.assert_called(hook="on_request", times=1)
|
| 492 |
+
assert nested_middleware.assert_called(hook="on_list_tools", times=1)
|
| 493 |
+
|
| 494 |
+
async def test_list_resources_on_nested_server(
|
| 495 |
+
self,
|
| 496 |
+
mcp_server: FastMCP,
|
| 497 |
+
nested_mcp_server: FastMCP,
|
| 498 |
+
recording_middleware: RecordingMiddleware,
|
| 499 |
+
nested_middleware: RecordingMiddleware,
|
| 500 |
+
):
|
| 501 |
+
mcp_server.mount(nested_mcp_server, prefix="nested")
|
| 502 |
+
|
| 503 |
+
async with Client(mcp_server) as client:
|
| 504 |
+
await client.list_resources()
|
| 505 |
+
|
| 506 |
+
assert recording_middleware.assert_called(times=3)
|
| 507 |
+
assert recording_middleware.assert_called(method="resources/list", times=3)
|
| 508 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 509 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 510 |
+
assert recording_middleware.assert_called(hook="on_list_resources", times=1)
|
| 511 |
+
|
| 512 |
+
assert nested_middleware.assert_called(times=3)
|
| 513 |
+
assert nested_middleware.assert_called(method="resources/list", times=3)
|
| 514 |
+
assert nested_middleware.assert_called(hook="on_message", times=1)
|
| 515 |
+
assert nested_middleware.assert_called(hook="on_request", times=1)
|
| 516 |
+
assert nested_middleware.assert_called(hook="on_list_resources", times=1)
|
| 517 |
+
|
| 518 |
+
async def test_list_resource_templates_on_nested_server(
|
| 519 |
+
self,
|
| 520 |
+
mcp_server: FastMCP,
|
| 521 |
+
nested_mcp_server: FastMCP,
|
| 522 |
+
recording_middleware: RecordingMiddleware,
|
| 523 |
+
nested_middleware: RecordingMiddleware,
|
| 524 |
+
):
|
| 525 |
+
mcp_server.mount(nested_mcp_server, prefix="nested")
|
| 526 |
+
|
| 527 |
+
async with Client(mcp_server) as client:
|
| 528 |
+
await client.list_resource_templates()
|
| 529 |
+
|
| 530 |
+
assert recording_middleware.assert_called(times=3)
|
| 531 |
+
assert recording_middleware.assert_called(
|
| 532 |
+
method="resources/templates/list", times=3
|
| 533 |
+
)
|
| 534 |
+
assert recording_middleware.assert_called(hook="on_message", times=1)
|
| 535 |
+
assert recording_middleware.assert_called(hook="on_request", times=1)
|
| 536 |
+
assert recording_middleware.assert_called(
|
| 537 |
+
hook="on_list_resource_templates", times=1
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
assert nested_middleware.assert_called(times=3)
|
| 541 |
+
assert nested_middleware.assert_called(
|
| 542 |
+
method="resources/templates/list", times=3
|
| 543 |
+
)
|
| 544 |
+
assert nested_middleware.assert_called(hook="on_message", times=1)
|
| 545 |
+
assert nested_middleware.assert_called(hook="on_request", times=1)
|
| 546 |
+
assert nested_middleware.assert_called(
|
| 547 |
+
hook="on_list_resource_templates", times=1
|
| 548 |
+
)
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
class TestProxyServer:
|
| 552 |
+
async def test_call_tool(
|
| 553 |
+
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
| 554 |
+
):
|
| 555 |
+
# proxy server will have its tools listed as well as called in order to
|
| 556 |
+
# run the `should_enable_component` hook prior to the call.
|
| 557 |
+
proxy_server = FastMCP.as_proxy(mcp_server, name="Proxy Server")
|
| 558 |
+
async with Client(proxy_server) as client:
|
| 559 |
+
await client.call_tool("add", {"a": 1, "b": 2})
|
| 560 |
+
|
| 561 |
+
assert recording_middleware.assert_called(times=6)
|
| 562 |
+
assert recording_middleware.assert_called(method="tools/call", times=3)
|
| 563 |
+
assert recording_middleware.assert_called(method="tools/list", times=3)
|
| 564 |
+
assert recording_middleware.assert_called(hook="on_message", times=2)
|
| 565 |
+
assert recording_middleware.assert_called(hook="on_request", times=2)
|
| 566 |
+
assert recording_middleware.assert_called(hook="on_call_tool", times=1)
|
| 567 |
+
assert recording_middleware.assert_called(hook="on_list_tools", times=1)
|
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_import_server.py
CHANGED
|
@@ -25,7 +25,7 @@ async def test_import_basic_functionality():
|
|
| 25 |
assert "sub_tool" in sub_app._tool_manager._tools
|
| 26 |
|
| 27 |
# Verify the original tool still exists in the sub-app
|
| 28 |
-
tool = main_app._tool_manager.get_tool("sub_sub_tool")
|
| 29 |
assert tool is not None
|
| 30 |
assert tool.name == "sub_tool"
|
| 31 |
assert isinstance(tool, FunctionTool)
|
|
@@ -203,7 +203,7 @@ async def test_tool_custom_name_preserved_when_imported():
|
|
| 203 |
await main_app.import_server(api_app, "api")
|
| 204 |
|
| 205 |
# Check that the tool is accessible by its prefixed name
|
| 206 |
-
tool = main_app._tool_manager.get_tool("api_get_data")
|
| 207 |
assert tool is not None
|
| 208 |
|
| 209 |
# Check that the function name is preserved
|
|
@@ -239,7 +239,7 @@ async def test_first_level_importing_with_custom_name():
|
|
| 239 |
await service_app.import_server(provider_app, "provider")
|
| 240 |
|
| 241 |
# Tool is accessible in the service app with the first prefix
|
| 242 |
-
tool = service_app._tool_manager.get_tool("provider_compute")
|
| 243 |
assert tool is not None
|
| 244 |
assert isinstance(tool, FunctionTool)
|
| 245 |
assert tool.fn.__name__ == "calculate_value"
|
|
@@ -259,7 +259,7 @@ async def test_nested_importing_preserves_prefixes():
|
|
| 259 |
await main_app.import_server(service_app, "service")
|
| 260 |
|
| 261 |
# Tool is accessible in the main app with both prefixes
|
| 262 |
-
tool = main_app._tool_manager.get_tool("service_provider_compute")
|
| 263 |
assert tool is not None
|
| 264 |
|
| 265 |
|
|
|
|
| 25 |
assert "sub_tool" in sub_app._tool_manager._tools
|
| 26 |
|
| 27 |
# Verify the original tool still exists in the sub-app
|
| 28 |
+
tool = await main_app._tool_manager.get_tool("sub_sub_tool")
|
| 29 |
assert tool is not None
|
| 30 |
assert tool.name == "sub_tool"
|
| 31 |
assert isinstance(tool, FunctionTool)
|
|
|
|
| 203 |
await main_app.import_server(api_app, "api")
|
| 204 |
|
| 205 |
# Check that the tool is accessible by its prefixed name
|
| 206 |
+
tool = await main_app._tool_manager.get_tool("api_get_data")
|
| 207 |
assert tool is not None
|
| 208 |
|
| 209 |
# Check that the function name is preserved
|
|
|
|
| 239 |
await service_app.import_server(provider_app, "provider")
|
| 240 |
|
| 241 |
# Tool is accessible in the service app with the first prefix
|
| 242 |
+
tool = await service_app._tool_manager.get_tool("provider_compute")
|
| 243 |
assert tool is not None
|
| 244 |
assert isinstance(tool, FunctionTool)
|
| 245 |
assert tool.fn.__name__ == "calculate_value"
|
|
|
|
| 259 |
await main_app.import_server(service_app, "service")
|
| 260 |
|
| 261 |
# Tool is accessible in the main app with both prefixes
|
| 262 |
+
tool = await main_app._tool_manager.get_tool("service_provider_compute")
|
| 263 |
assert tool is not None
|
| 264 |
|
| 265 |
|
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
|
|
@@ -861,7 +862,7 @@ class TestAsProxyKwarg:
|
|
| 861 |
sub = FastMCP("Sub")
|
| 862 |
|
| 863 |
mcp.mount(sub, "sub")
|
| 864 |
-
assert mcp._mounted_servers[0].server is sub
|
| 865 |
|
| 866 |
async def test_as_proxy_false(self):
|
| 867 |
mcp = FastMCP("Main")
|
|
@@ -869,7 +870,7 @@ class TestAsProxyKwarg:
|
|
| 869 |
|
| 870 |
mcp.mount(sub, "sub", as_proxy=False)
|
| 871 |
|
| 872 |
-
assert mcp._mounted_servers[0].server is sub
|
| 873 |
|
| 874 |
async def test_as_proxy_true(self):
|
| 875 |
mcp = FastMCP("Main")
|
|
@@ -877,8 +878,8 @@ class TestAsProxyKwarg:
|
|
| 877 |
|
| 878 |
mcp.mount(sub, "sub", as_proxy=True)
|
| 879 |
|
| 880 |
-
assert mcp._mounted_servers[0].server is not sub
|
| 881 |
-
assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy)
|
| 882 |
|
| 883 |
async def test_as_proxy_defaults_true_if_lifespan(self):
|
| 884 |
@asynccontextmanager
|
|
@@ -890,8 +891,8 @@ class TestAsProxyKwarg:
|
|
| 890 |
|
| 891 |
mcp.mount(sub, "sub")
|
| 892 |
|
| 893 |
-
assert mcp._mounted_servers[0].server is not sub
|
| 894 |
-
assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy)
|
| 895 |
|
| 896 |
async def test_as_proxy_ignored_for_proxy_mounts_default(self):
|
| 897 |
mcp = FastMCP("Main")
|
|
@@ -900,7 +901,7 @@ class TestAsProxyKwarg:
|
|
| 900 |
|
| 901 |
mcp.mount(sub_proxy, "sub")
|
| 902 |
|
| 903 |
-
assert mcp._mounted_servers[0].server is sub_proxy
|
| 904 |
|
| 905 |
async def test_as_proxy_ignored_for_proxy_mounts_false(self):
|
| 906 |
mcp = FastMCP("Main")
|
|
@@ -909,7 +910,7 @@ class TestAsProxyKwarg:
|
|
| 909 |
|
| 910 |
mcp.mount(sub_proxy, "sub", as_proxy=False)
|
| 911 |
|
| 912 |
-
assert mcp._mounted_servers[0].server is sub_proxy
|
| 913 |
|
| 914 |
async def test_as_proxy_ignored_for_proxy_mounts_true(self):
|
| 915 |
mcp = FastMCP("Main")
|
|
@@ -918,7 +919,7 @@ class TestAsProxyKwarg:
|
|
| 918 |
|
| 919 |
mcp.mount(sub_proxy, "sub", as_proxy=True)
|
| 920 |
|
| 921 |
-
assert mcp._mounted_servers[0].server is sub_proxy
|
| 922 |
|
| 923 |
async def test_as_proxy_mounts_still_have_live_link(self):
|
| 924 |
mcp = FastMCP("Main")
|
|
@@ -949,11 +950,14 @@ class TestAsProxyKwarg:
|
|
| 949 |
def hello():
|
| 950 |
return "hi"
|
| 951 |
|
| 952 |
-
mcp.mount(sub,
|
| 953 |
|
| 954 |
assert lifespan_check == []
|
| 955 |
|
| 956 |
async with Client(mcp) as client:
|
| 957 |
-
await client.call_tool("
|
| 958 |
|
| 959 |
-
assert lifespan_check
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 862 |
sub = FastMCP("Sub")
|
| 863 |
|
| 864 |
mcp.mount(sub, "sub")
|
| 865 |
+
assert mcp._tool_manager._mounted_servers[0].server is sub
|
| 866 |
|
| 867 |
async def test_as_proxy_false(self):
|
| 868 |
mcp = FastMCP("Main")
|
|
|
|
| 870 |
|
| 871 |
mcp.mount(sub, "sub", as_proxy=False)
|
| 872 |
|
| 873 |
+
assert mcp._tool_manager._mounted_servers[0].server is sub
|
| 874 |
|
| 875 |
async def test_as_proxy_true(self):
|
| 876 |
mcp = FastMCP("Main")
|
|
|
|
| 878 |
|
| 879 |
mcp.mount(sub, "sub", as_proxy=True)
|
| 880 |
|
| 881 |
+
assert mcp._tool_manager._mounted_servers[0].server is not sub
|
| 882 |
+
assert isinstance(mcp._tool_manager._mounted_servers[0].server, FastMCPProxy)
|
| 883 |
|
| 884 |
async def test_as_proxy_defaults_true_if_lifespan(self):
|
| 885 |
@asynccontextmanager
|
|
|
|
| 891 |
|
| 892 |
mcp.mount(sub, "sub")
|
| 893 |
|
| 894 |
+
assert mcp._tool_manager._mounted_servers[0].server is not sub
|
| 895 |
+
assert isinstance(mcp._tool_manager._mounted_servers[0].server, FastMCPProxy)
|
| 896 |
|
| 897 |
async def test_as_proxy_ignored_for_proxy_mounts_default(self):
|
| 898 |
mcp = FastMCP("Main")
|
|
|
|
| 901 |
|
| 902 |
mcp.mount(sub_proxy, "sub")
|
| 903 |
|
| 904 |
+
assert mcp._tool_manager._mounted_servers[0].server is sub_proxy
|
| 905 |
|
| 906 |
async def test_as_proxy_ignored_for_proxy_mounts_false(self):
|
| 907 |
mcp = FastMCP("Main")
|
|
|
|
| 910 |
|
| 911 |
mcp.mount(sub_proxy, "sub", as_proxy=False)
|
| 912 |
|
| 913 |
+
assert mcp._tool_manager._mounted_servers[0].server is sub_proxy
|
| 914 |
|
| 915 |
async def test_as_proxy_ignored_for_proxy_mounts_true(self):
|
| 916 |
mcp = FastMCP("Main")
|
|
|
|
| 919 |
|
| 920 |
mcp.mount(sub_proxy, "sub", as_proxy=True)
|
| 921 |
|
| 922 |
+
assert mcp._tool_manager._mounted_servers[0].server is sub_proxy
|
| 923 |
|
| 924 |
async def test_as_proxy_mounts_still_have_live_link(self):
|
| 925 |
mcp = FastMCP("Main")
|
|
|
|
| 950 |
def hello():
|
| 951 |
return "hi"
|
| 952 |
|
| 953 |
+
mcp.mount(sub, as_proxy=True)
|
| 954 |
|
| 955 |
assert lifespan_check == []
|
| 956 |
|
| 957 |
async with Client(mcp) as client:
|
| 958 |
+
await client.call_tool("hello", {})
|
| 959 |
|
| 960 |
+
assert len(lifespan_check) > 0
|
| 961 |
+
# in the present implementation the sub server will be invoked 3 times
|
| 962 |
+
# to call its tool
|
| 963 |
+
assert lifespan_check == ["start", "start", "start"]
|
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_server.py
CHANGED
|
@@ -282,7 +282,7 @@ class TestToolDecorator:
|
|
| 282 |
return x * 2
|
| 283 |
|
| 284 |
# Verify the tags were set correctly
|
| 285 |
-
tools = mcp._tool_manager.list_tools()
|
| 286 |
assert len(tools) == 1
|
| 287 |
assert tools[0].tags == {"example", "test-tag"}
|
| 288 |
|
|
|
|
| 282 |
return x * 2
|
| 283 |
|
| 284 |
# Verify the tags were set correctly
|
| 285 |
+
tools = await mcp._tool_manager.list_tools()
|
| 286 |
assert len(tools) == 1
|
| 287 |
assert tools[0].tags == {"example", "test-tag"}
|
| 288 |
|
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 |
|
tests/tools/test_tool_manager.py
CHANGED
|
@@ -17,7 +17,7 @@ from fastmcp.utilities.types import Image
|
|
| 17 |
|
| 18 |
|
| 19 |
class TestAddTools:
|
| 20 |
-
def test_basic_function(self):
|
| 21 |
"""Test registering and running a basic function."""
|
| 22 |
|
| 23 |
def add(a: int, b: int) -> int:
|
|
@@ -28,7 +28,7 @@ class TestAddTools:
|
|
| 28 |
tool = Tool.from_function(add)
|
| 29 |
manager.add_tool(tool)
|
| 30 |
|
| 31 |
-
tool = manager.get_tool("add")
|
| 32 |
assert tool is not None
|
| 33 |
assert tool.name == "add"
|
| 34 |
assert tool.description == "Add two numbers."
|
|
@@ -46,13 +46,13 @@ class TestAddTools:
|
|
| 46 |
tool = Tool.from_function(fetch_data)
|
| 47 |
manager.add_tool(tool)
|
| 48 |
|
| 49 |
-
tool = manager.get_tool("fetch_data")
|
| 50 |
assert tool is not None
|
| 51 |
assert tool.name == "fetch_data"
|
| 52 |
assert tool.description == "Fetch data from URL."
|
| 53 |
assert tool.parameters["properties"]["url"]["type"] == "string"
|
| 54 |
|
| 55 |
-
def test_pydantic_model_function(self):
|
| 56 |
"""Test registering a function that takes a Pydantic model."""
|
| 57 |
|
| 58 |
class UserInput(BaseModel):
|
|
@@ -67,7 +67,7 @@ class TestAddTools:
|
|
| 67 |
tool = Tool.from_function(create_user)
|
| 68 |
manager.add_tool(tool)
|
| 69 |
|
| 70 |
-
tool = manager.get_tool("create_user")
|
| 71 |
assert tool is not None
|
| 72 |
assert tool.name == "create_user"
|
| 73 |
assert tool.description == "Create a new user."
|
|
@@ -75,7 +75,7 @@ class TestAddTools:
|
|
| 75 |
assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
|
| 76 |
assert "flag" in tool.parameters["properties"]
|
| 77 |
|
| 78 |
-
def test_callable_object(self):
|
| 79 |
class Adder:
|
| 80 |
"""Adds two numbers."""
|
| 81 |
|
|
@@ -87,7 +87,7 @@ class TestAddTools:
|
|
| 87 |
tool = Tool.from_function(Adder())
|
| 88 |
manager.add_tool(tool)
|
| 89 |
|
| 90 |
-
tool = manager.get_tool("Adder")
|
| 91 |
assert tool is not None
|
| 92 |
assert tool.name == "Adder"
|
| 93 |
assert tool.description == "Adds two numbers."
|
|
@@ -95,7 +95,7 @@ class TestAddTools:
|
|
| 95 |
assert tool.parameters["properties"]["x"]["type"] == "integer"
|
| 96 |
assert tool.parameters["properties"]["y"]["type"] == "integer"
|
| 97 |
|
| 98 |
-
def test_async_callable_object(self):
|
| 99 |
class Adder:
|
| 100 |
"""Adds two numbers."""
|
| 101 |
|
|
@@ -107,7 +107,7 @@ class TestAddTools:
|
|
| 107 |
tool = Tool.from_function(Adder())
|
| 108 |
manager.add_tool(tool)
|
| 109 |
|
| 110 |
-
tool = manager.get_tool("Adder")
|
| 111 |
assert tool is not None
|
| 112 |
assert tool.name == "Adder"
|
| 113 |
assert tool.description == "Adds two numbers."
|
|
@@ -123,7 +123,7 @@ class TestAddTools:
|
|
| 123 |
tool = Tool.from_function(image_tool)
|
| 124 |
manager.add_tool(tool)
|
| 125 |
|
| 126 |
-
tool = manager.get_tool("image_tool")
|
| 127 |
result = await tool.run({"data": "test.png"})
|
| 128 |
assert tool.parameters["properties"]["data"]["type"] == "string"
|
| 129 |
assert isinstance(result[0], ImageContent)
|
|
@@ -148,7 +148,7 @@ class TestAddTools:
|
|
| 148 |
tool = Tool.from_function(lambda x: x)
|
| 149 |
manager.add_tool(tool)
|
| 150 |
|
| 151 |
-
def test_remove_tool_successfully(self):
|
| 152 |
"""Test removing an added tool by key."""
|
| 153 |
manager = ToolManager()
|
| 154 |
|
|
@@ -157,19 +157,19 @@ class TestAddTools:
|
|
| 157 |
|
| 158 |
tool = Tool.from_function(add)
|
| 159 |
manager.add_tool(tool)
|
| 160 |
-
assert manager.get_tool("add") is not None
|
| 161 |
|
| 162 |
manager.remove_tool("add")
|
| 163 |
with pytest.raises(NotFoundError):
|
| 164 |
-
manager.get_tool("add")
|
| 165 |
|
| 166 |
def test_remove_tool_missing_key(self):
|
| 167 |
"""Test removing a tool that does not exist raises NotFoundError."""
|
| 168 |
manager = ToolManager()
|
| 169 |
-
with pytest.raises(NotFoundError, match=
|
| 170 |
manager.remove_tool("missing")
|
| 171 |
|
| 172 |
-
def test_warn_on_duplicate_tools(self, caplog):
|
| 173 |
"""Test warning on duplicate tools."""
|
| 174 |
manager = ToolManager(duplicate_behavior="warn")
|
| 175 |
|
|
@@ -183,7 +183,7 @@ class TestAddTools:
|
|
| 183 |
|
| 184 |
assert "Tool already exists: test_tool" in caplog.text
|
| 185 |
# Should have the tool
|
| 186 |
-
assert manager.get_tool("test_tool") is not None
|
| 187 |
|
| 188 |
def test_disable_warn_on_duplicate_tools(self, caplog):
|
| 189 |
"""Test disabling warning on duplicate tools."""
|
|
@@ -213,7 +213,7 @@ class TestAddTools:
|
|
| 213 |
tool2 = Tool.from_function(test_fn, name="test_tool")
|
| 214 |
manager.add_tool(tool2)
|
| 215 |
|
| 216 |
-
def test_replace_duplicate_tools(self):
|
| 217 |
"""Test replacing duplicate tools."""
|
| 218 |
manager = ToolManager(duplicate_behavior="replace")
|
| 219 |
|
|
@@ -229,12 +229,12 @@ class TestAddTools:
|
|
| 229 |
manager.add_tool(result)
|
| 230 |
|
| 231 |
# Should have replaced with the new tool
|
| 232 |
-
tool = manager.get_tool("test_tool")
|
| 233 |
assert tool is not None
|
| 234 |
assert isinstance(tool, FunctionTool)
|
| 235 |
assert tool.fn.__name__ == "replacement_fn"
|
| 236 |
|
| 237 |
-
def test_ignore_duplicate_tools(self):
|
| 238 |
"""Test ignoring duplicate tools."""
|
| 239 |
manager = ToolManager(duplicate_behavior="ignore")
|
| 240 |
|
|
@@ -250,7 +250,7 @@ class TestAddTools:
|
|
| 250 |
manager.add_tool(result)
|
| 251 |
|
| 252 |
# Should keep the original
|
| 253 |
-
tool = manager.get_tool("test_tool")
|
| 254 |
assert tool is not None
|
| 255 |
assert isinstance(tool, FunctionTool)
|
| 256 |
assert tool.fn.__name__ == "original_fn"
|
|
@@ -262,7 +262,7 @@ class TestAddTools:
|
|
| 262 |
class TestToolTags:
|
| 263 |
"""Test functionality related to tool tags."""
|
| 264 |
|
| 265 |
-
def test_add_tool_with_tags(self):
|
| 266 |
"""Test adding tags to a tool."""
|
| 267 |
|
| 268 |
def example_tool(x: int) -> int:
|
|
@@ -274,11 +274,11 @@ class TestToolTags:
|
|
| 274 |
manager.add_tool(tool)
|
| 275 |
|
| 276 |
assert tool.tags == {"math", "utility"}
|
| 277 |
-
tool = manager.get_tool("example_tool")
|
| 278 |
assert tool is not None
|
| 279 |
assert tool.tags == {"math", "utility"}
|
| 280 |
|
| 281 |
-
def test_add_tool_with_empty_tags(self):
|
| 282 |
"""Test adding a tool with empty tags set."""
|
| 283 |
|
| 284 |
def example_tool(x: int) -> int:
|
|
@@ -291,7 +291,7 @@ class TestToolTags:
|
|
| 291 |
|
| 292 |
assert tool.tags == set()
|
| 293 |
|
| 294 |
-
def test_add_tool_with_none_tags(self):
|
| 295 |
"""Test adding a tool with None tags."""
|
| 296 |
|
| 297 |
def example_tool(x: int) -> int:
|
|
@@ -304,7 +304,7 @@ class TestToolTags:
|
|
| 304 |
|
| 305 |
assert tool.tags == set()
|
| 306 |
|
| 307 |
-
def test_list_tools_with_tags(self):
|
| 308 |
"""Test listing tools with specific tags."""
|
| 309 |
|
| 310 |
def math_tool(x: int) -> int:
|
|
@@ -328,12 +328,16 @@ class TestToolTags:
|
|
| 328 |
manager.add_tool(tool3)
|
| 329 |
|
| 330 |
# Check if we can filter by tags when listing tools
|
| 331 |
-
math_tools = [
|
|
|
|
|
|
|
| 332 |
assert len(math_tools) == 2
|
| 333 |
assert {tool.name for tool in math_tools} == {"math_tool", "mixed_tool"}
|
| 334 |
|
| 335 |
utility_tools = [
|
| 336 |
-
tool
|
|
|
|
|
|
|
| 337 |
]
|
| 338 |
assert len(utility_tools) == 2
|
| 339 |
assert {tool.name for tool in utility_tools} == {"string_tool", "mixed_tool"}
|
|
@@ -416,7 +420,7 @@ class TestCallTools:
|
|
| 416 |
|
| 417 |
async def test_call_unknown_tool(self):
|
| 418 |
manager = ToolManager()
|
| 419 |
-
with pytest.raises(NotFoundError, match="
|
| 420 |
await manager.call_tool("unknown", {"a": 1})
|
| 421 |
|
| 422 |
async def test_call_tool_with_list_int_input(self):
|
|
@@ -728,7 +732,7 @@ class TestContextHandling:
|
|
| 728 |
class TestCustomToolNames:
|
| 729 |
"""Test adding tools with custom names that differ from their function names."""
|
| 730 |
|
| 731 |
-
def test_add_tool_with_custom_name(self):
|
| 732 |
"""Test adding a tool with a custom name parameter using add_tool_from_fn."""
|
| 733 |
|
| 734 |
def original_fn(x: int) -> int:
|
|
@@ -739,15 +743,15 @@ class TestCustomToolNames:
|
|
| 739 |
manager.add_tool(tool)
|
| 740 |
|
| 741 |
# The tool is stored under the custom name and its .name is also set to custom_name
|
| 742 |
-
assert manager.get_tool("custom_name") is not None
|
| 743 |
assert tool.name == "custom_name"
|
| 744 |
assert isinstance(tool, FunctionTool)
|
| 745 |
assert tool.fn.__name__ == "original_fn"
|
| 746 |
# The tool should not be accessible via its original function name
|
| 747 |
-
with pytest.raises(NotFoundError, match="
|
| 748 |
-
manager.get_tool("original_fn")
|
| 749 |
|
| 750 |
-
def test_add_tool_object_with_custom_key(self):
|
| 751 |
"""Test adding a Tool object with a custom key using add_tool()."""
|
| 752 |
|
| 753 |
def fn(x: int) -> int:
|
|
@@ -756,16 +760,17 @@ class TestCustomToolNames:
|
|
| 756 |
# Create a tool with a specific name
|
| 757 |
tool = Tool.from_function(fn, name="my_tool")
|
| 758 |
manager = ToolManager()
|
| 759 |
-
#
|
| 760 |
-
|
|
|
|
| 761 |
# The tool is accessible under the key
|
| 762 |
-
stored = manager.get_tool("proxy_tool")
|
| 763 |
assert stored is not None
|
| 764 |
# But the tool's .name is unchanged
|
| 765 |
assert stored.name == "my_tool"
|
| 766 |
# The tool is not accessible under its original name
|
| 767 |
-
with pytest.raises(NotFoundError, match="
|
| 768 |
-
manager.get_tool("my_tool")
|
| 769 |
|
| 770 |
async def test_call_tool_with_custom_name(self):
|
| 771 |
"""Test calling a tool added with a custom name."""
|
|
@@ -783,10 +788,10 @@ class TestCustomToolNames:
|
|
| 783 |
assert result[0].text == "15" # type: ignore[attr-defined]
|
| 784 |
|
| 785 |
# Original name should not be registered
|
| 786 |
-
with pytest.raises(NotFoundError, match="
|
| 787 |
await manager.call_tool("multiply", {"a": 5, "b": 3})
|
| 788 |
|
| 789 |
-
def test_replace_tool_keeps_original_name(self):
|
| 790 |
"""Test that replacing a tool with "replace" keeps the original name."""
|
| 791 |
|
| 792 |
def original_fn(x: int) -> int:
|
|
@@ -808,7 +813,7 @@ class TestCustomToolNames:
|
|
| 808 |
manager.add_tool(replacement_tool)
|
| 809 |
|
| 810 |
# The tool object should have been replaced
|
| 811 |
-
stored_tool = manager.get_tool("test_tool")
|
| 812 |
assert stored_tool is not None
|
| 813 |
assert stored_tool == replacement_tool
|
| 814 |
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
class TestAddTools:
|
| 20 |
+
async def test_basic_function(self):
|
| 21 |
"""Test registering and running a basic function."""
|
| 22 |
|
| 23 |
def add(a: int, b: int) -> int:
|
|
|
|
| 28 |
tool = Tool.from_function(add)
|
| 29 |
manager.add_tool(tool)
|
| 30 |
|
| 31 |
+
tool = await manager.get_tool("add")
|
| 32 |
assert tool is not None
|
| 33 |
assert tool.name == "add"
|
| 34 |
assert tool.description == "Add two numbers."
|
|
|
|
| 46 |
tool = Tool.from_function(fetch_data)
|
| 47 |
manager.add_tool(tool)
|
| 48 |
|
| 49 |
+
tool = await manager.get_tool("fetch_data")
|
| 50 |
assert tool is not None
|
| 51 |
assert tool.name == "fetch_data"
|
| 52 |
assert tool.description == "Fetch data from URL."
|
| 53 |
assert tool.parameters["properties"]["url"]["type"] == "string"
|
| 54 |
|
| 55 |
+
async def test_pydantic_model_function(self):
|
| 56 |
"""Test registering a function that takes a Pydantic model."""
|
| 57 |
|
| 58 |
class UserInput(BaseModel):
|
|
|
|
| 67 |
tool = Tool.from_function(create_user)
|
| 68 |
manager.add_tool(tool)
|
| 69 |
|
| 70 |
+
tool = await manager.get_tool("create_user")
|
| 71 |
assert tool is not None
|
| 72 |
assert tool.name == "create_user"
|
| 73 |
assert tool.description == "Create a new user."
|
|
|
|
| 75 |
assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
|
| 76 |
assert "flag" in tool.parameters["properties"]
|
| 77 |
|
| 78 |
+
async def test_callable_object(self):
|
| 79 |
class Adder:
|
| 80 |
"""Adds two numbers."""
|
| 81 |
|
|
|
|
| 87 |
tool = Tool.from_function(Adder())
|
| 88 |
manager.add_tool(tool)
|
| 89 |
|
| 90 |
+
tool = await manager.get_tool("Adder")
|
| 91 |
assert tool is not None
|
| 92 |
assert tool.name == "Adder"
|
| 93 |
assert tool.description == "Adds two numbers."
|
|
|
|
| 95 |
assert tool.parameters["properties"]["x"]["type"] == "integer"
|
| 96 |
assert tool.parameters["properties"]["y"]["type"] == "integer"
|
| 97 |
|
| 98 |
+
async def test_async_callable_object(self):
|
| 99 |
class Adder:
|
| 100 |
"""Adds two numbers."""
|
| 101 |
|
|
|
|
| 107 |
tool = Tool.from_function(Adder())
|
| 108 |
manager.add_tool(tool)
|
| 109 |
|
| 110 |
+
tool = await manager.get_tool("Adder")
|
| 111 |
assert tool is not None
|
| 112 |
assert tool.name == "Adder"
|
| 113 |
assert tool.description == "Adds two numbers."
|
|
|
|
| 123 |
tool = Tool.from_function(image_tool)
|
| 124 |
manager.add_tool(tool)
|
| 125 |
|
| 126 |
+
tool = await manager.get_tool("image_tool")
|
| 127 |
result = await tool.run({"data": "test.png"})
|
| 128 |
assert tool.parameters["properties"]["data"]["type"] == "string"
|
| 129 |
assert isinstance(result[0], ImageContent)
|
|
|
|
| 148 |
tool = Tool.from_function(lambda x: x)
|
| 149 |
manager.add_tool(tool)
|
| 150 |
|
| 151 |
+
async def test_remove_tool_successfully(self):
|
| 152 |
"""Test removing an added tool by key."""
|
| 153 |
manager = ToolManager()
|
| 154 |
|
|
|
|
| 157 |
|
| 158 |
tool = Tool.from_function(add)
|
| 159 |
manager.add_tool(tool)
|
| 160 |
+
assert await manager.get_tool("add") is not None
|
| 161 |
|
| 162 |
manager.remove_tool("add")
|
| 163 |
with pytest.raises(NotFoundError):
|
| 164 |
+
await manager.get_tool("add")
|
| 165 |
|
| 166 |
def test_remove_tool_missing_key(self):
|
| 167 |
"""Test removing a tool that does not exist raises NotFoundError."""
|
| 168 |
manager = ToolManager()
|
| 169 |
+
with pytest.raises(NotFoundError, match="Tool 'missing' not found"):
|
| 170 |
manager.remove_tool("missing")
|
| 171 |
|
| 172 |
+
async def test_warn_on_duplicate_tools(self, caplog):
|
| 173 |
"""Test warning on duplicate tools."""
|
| 174 |
manager = ToolManager(duplicate_behavior="warn")
|
| 175 |
|
|
|
|
| 183 |
|
| 184 |
assert "Tool already exists: test_tool" in caplog.text
|
| 185 |
# Should have the tool
|
| 186 |
+
assert await manager.get_tool("test_tool") is not None
|
| 187 |
|
| 188 |
def test_disable_warn_on_duplicate_tools(self, caplog):
|
| 189 |
"""Test disabling warning on duplicate tools."""
|
|
|
|
| 213 |
tool2 = Tool.from_function(test_fn, name="test_tool")
|
| 214 |
manager.add_tool(tool2)
|
| 215 |
|
| 216 |
+
async def test_replace_duplicate_tools(self):
|
| 217 |
"""Test replacing duplicate tools."""
|
| 218 |
manager = ToolManager(duplicate_behavior="replace")
|
| 219 |
|
|
|
|
| 229 |
manager.add_tool(result)
|
| 230 |
|
| 231 |
# Should have replaced with the new tool
|
| 232 |
+
tool = await manager.get_tool("test_tool")
|
| 233 |
assert tool is not None
|
| 234 |
assert isinstance(tool, FunctionTool)
|
| 235 |
assert tool.fn.__name__ == "replacement_fn"
|
| 236 |
|
| 237 |
+
async def test_ignore_duplicate_tools(self):
|
| 238 |
"""Test ignoring duplicate tools."""
|
| 239 |
manager = ToolManager(duplicate_behavior="ignore")
|
| 240 |
|
|
|
|
| 250 |
manager.add_tool(result)
|
| 251 |
|
| 252 |
# Should keep the original
|
| 253 |
+
tool = await manager.get_tool("test_tool")
|
| 254 |
assert tool is not None
|
| 255 |
assert isinstance(tool, FunctionTool)
|
| 256 |
assert tool.fn.__name__ == "original_fn"
|
|
|
|
| 262 |
class TestToolTags:
|
| 263 |
"""Test functionality related to tool tags."""
|
| 264 |
|
| 265 |
+
async def test_add_tool_with_tags(self):
|
| 266 |
"""Test adding tags to a tool."""
|
| 267 |
|
| 268 |
def example_tool(x: int) -> int:
|
|
|
|
| 274 |
manager.add_tool(tool)
|
| 275 |
|
| 276 |
assert tool.tags == {"math", "utility"}
|
| 277 |
+
tool = await manager.get_tool("example_tool")
|
| 278 |
assert tool is not None
|
| 279 |
assert tool.tags == {"math", "utility"}
|
| 280 |
|
| 281 |
+
async def test_add_tool_with_empty_tags(self):
|
| 282 |
"""Test adding a tool with empty tags set."""
|
| 283 |
|
| 284 |
def example_tool(x: int) -> int:
|
|
|
|
| 291 |
|
| 292 |
assert tool.tags == set()
|
| 293 |
|
| 294 |
+
async def test_add_tool_with_none_tags(self):
|
| 295 |
"""Test adding a tool with None tags."""
|
| 296 |
|
| 297 |
def example_tool(x: int) -> int:
|
|
|
|
| 304 |
|
| 305 |
assert tool.tags == set()
|
| 306 |
|
| 307 |
+
async def test_list_tools_with_tags(self):
|
| 308 |
"""Test listing tools with specific tags."""
|
| 309 |
|
| 310 |
def math_tool(x: int) -> int:
|
|
|
|
| 328 |
manager.add_tool(tool3)
|
| 329 |
|
| 330 |
# Check if we can filter by tags when listing tools
|
| 331 |
+
math_tools = [
|
| 332 |
+
tool for tool in (await manager.get_tools()).values() if "math" in tool.tags
|
| 333 |
+
]
|
| 334 |
assert len(math_tools) == 2
|
| 335 |
assert {tool.name for tool in math_tools} == {"math_tool", "mixed_tool"}
|
| 336 |
|
| 337 |
utility_tools = [
|
| 338 |
+
tool
|
| 339 |
+
for tool in (await manager.get_tools()).values()
|
| 340 |
+
if "utility" in tool.tags
|
| 341 |
]
|
| 342 |
assert len(utility_tools) == 2
|
| 343 |
assert {tool.name for tool in utility_tools} == {"string_tool", "mixed_tool"}
|
|
|
|
| 420 |
|
| 421 |
async def test_call_unknown_tool(self):
|
| 422 |
manager = ToolManager()
|
| 423 |
+
with pytest.raises(NotFoundError, match="Tool 'unknown' not found"):
|
| 424 |
await manager.call_tool("unknown", {"a": 1})
|
| 425 |
|
| 426 |
async def test_call_tool_with_list_int_input(self):
|
|
|
|
| 732 |
class TestCustomToolNames:
|
| 733 |
"""Test adding tools with custom names that differ from their function names."""
|
| 734 |
|
| 735 |
+
async def test_add_tool_with_custom_name(self):
|
| 736 |
"""Test adding a tool with a custom name parameter using add_tool_from_fn."""
|
| 737 |
|
| 738 |
def original_fn(x: int) -> int:
|
|
|
|
| 743 |
manager.add_tool(tool)
|
| 744 |
|
| 745 |
# The tool is stored under the custom name and its .name is also set to custom_name
|
| 746 |
+
assert await manager.get_tool("custom_name") is not None
|
| 747 |
assert tool.name == "custom_name"
|
| 748 |
assert isinstance(tool, FunctionTool)
|
| 749 |
assert tool.fn.__name__ == "original_fn"
|
| 750 |
# The tool should not be accessible via its original function name
|
| 751 |
+
with pytest.raises(NotFoundError, match="Tool 'original_fn' not found"):
|
| 752 |
+
await manager.get_tool("original_fn")
|
| 753 |
|
| 754 |
+
async def test_add_tool_object_with_custom_key(self):
|
| 755 |
"""Test adding a Tool object with a custom key using add_tool()."""
|
| 756 |
|
| 757 |
def fn(x: int) -> int:
|
|
|
|
| 760 |
# Create a tool with a specific name
|
| 761 |
tool = Tool.from_function(fn, name="my_tool")
|
| 762 |
manager = ToolManager()
|
| 763 |
+
# Use with_key to create a new tool with the custom key
|
| 764 |
+
tool_with_custom_key = tool.with_key("proxy_tool")
|
| 765 |
+
manager.add_tool(tool_with_custom_key)
|
| 766 |
# The tool is accessible under the key
|
| 767 |
+
stored = await manager.get_tool("proxy_tool")
|
| 768 |
assert stored is not None
|
| 769 |
# But the tool's .name is unchanged
|
| 770 |
assert stored.name == "my_tool"
|
| 771 |
# The tool is not accessible under its original name
|
| 772 |
+
with pytest.raises(NotFoundError, match="Tool 'my_tool' not found"):
|
| 773 |
+
await manager.get_tool("my_tool")
|
| 774 |
|
| 775 |
async def test_call_tool_with_custom_name(self):
|
| 776 |
"""Test calling a tool added with a custom name."""
|
|
|
|
| 788 |
assert result[0].text == "15" # type: ignore[attr-defined]
|
| 789 |
|
| 790 |
# Original name should not be registered
|
| 791 |
+
with pytest.raises(NotFoundError, match="Tool 'multiply' not found"):
|
| 792 |
await manager.call_tool("multiply", {"a": 5, "b": 3})
|
| 793 |
|
| 794 |
+
async def test_replace_tool_keeps_original_name(self):
|
| 795 |
"""Test that replacing a tool with "replace" keeps the original name."""
|
| 796 |
|
| 797 |
def original_fn(x: int) -> int:
|
|
|
|
| 813 |
manager.add_tool(replacement_tool)
|
| 814 |
|
| 815 |
# The tool object should have been replaced
|
| 816 |
+
stored_tool = await manager.get_tool("test_tool")
|
| 817 |
assert stored_tool is not None
|
| 818 |
assert stored_tool == replacement_tool
|
| 819 |
|