Jeremiah Lowin commited on
Commit
81da84f
·
unverified ·
2 Parent(s): 45ae1c75603a97

Merge branch 'main' into dev-20250619-173841

Browse files
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("sub", sub)
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
- pass
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 get_prompt(self, key: str) -> Prompt:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  """Get prompt by key."""
46
- if key in self._prompts:
47
- return self._prompts[key]
 
48
  raise NotFoundError(f"Unknown prompt: {key}")
49
 
50
- def get_prompts(self) -> dict[str, Prompt]:
51
- """Get all registered prompts, indexed by registered key."""
52
- return self._prompts
 
 
 
 
 
 
 
 
 
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, key: str | None = None) -> 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
- """Render a prompt by name with arguments."""
100
- prompt = self.get_prompt(name)
101
- if not prompt:
102
- raise NotFoundError(f"Unknown prompt: {name}")
103
-
104
- try:
105
- messages = await prompt.render(arguments)
106
- return GetPromptResult(description=prompt.description, messages=messages)
107
-
108
- # Pass through PromptErrors as-is
109
- except PromptError as e:
110
- logger.exception(f"Error rendering prompt {name!r}: {e}")
111
- raise e
112
-
113
- # Handle other exceptions
114
- except Exception as e:
115
- logger.exception(f"Error rendering prompt {name!r}: {e}")
116
- if self.mask_error_details:
117
- # Mask internal details
118
- raise PromptError(f"Error rendering prompt {name!r}")
119
- else:
120
- # Include original error details
121
- raise PromptError(f"Error rendering prompt {name!r}: {e}")
122
-
123
- def has_prompt(self, key: str) -> bool:
124
- """Check if a prompt exists."""
125
- return key in self._prompts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, key: str | None = None) -> Resource:
143
  """Add a resource to the manager.
144
 
145
  Args:
146
- resource: A Resource instance to add
147
- key: Optional URI to use as the storage key (if different from resource.uri)
 
148
  """
149
- storage_key = key or str(resource.uri)
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: {storage_key}")
163
- self._resources[storage_key] = resource
164
  elif self.duplicate_behavior == "replace":
165
- self._resources[storage_key] = resource
166
  elif self.duplicate_behavior == "error":
167
- raise ValueError(f"Resource already exists: {storage_key}")
168
  elif self.duplicate_behavior == "ignore":
169
  return existing
170
- self._resources[storage_key] = resource
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
- key: Optional URI template to use as the storage key (if different from template.uri_template)
 
208
 
209
  Returns:
210
  The added template. If a template with the same URI already exists,
211
  returns the existing template.
212
  """
213
- uri_template_str = str(template.uri_template)
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: {storage_key}")
228
- self._templates[storage_key] = template
229
  elif self.duplicate_behavior == "replace":
230
- self._templates[storage_key] = template
231
  elif self.duplicate_behavior == "error":
232
- raise ValueError(f"Template already exists: {storage_key}")
233
  elif self.duplicate_behavior == "ignore":
234
  return existing
235
- self._templates[storage_key] = template
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
- if uri_str in self._resources:
 
 
 
242
  return True
243
- for template_key in self._templates.keys():
 
 
 
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
- if resource := self._resources.get(uri_str):
 
262
  return resource
263
 
264
- # Then check templates - use the utility function to match against storage keys
265
- for storage_key, template in self._templates.items():
 
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
- """Read a resource contents."""
293
- resource = await self.get_resource(uri)
294
-
295
- try:
296
- return await resource.read()
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
- def get_templates(self) -> dict[str, ResourceTemplate]:
318
- """Get all registered templates, keyed by URI template."""
319
- return self._templates
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return self.fastmcp._mcp_server.request_context
 
 
 
 
 
 
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
- async def from_client(cls, client: Client, tool: mcp.types.Tool) -> ProxyTool:
 
40
  return cls(
41
  client=client,
42
- name=tool.name,
43
- description=tool.description,
44
- parameters=tool.inputSchema,
 
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
- # the client context manager will swallow any exceptions inside a TaskGroup
53
- # so we return the raw result and raise an exception ourselves
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
- async def from_client(
75
- cls, client: Client, resource: mcp.types.Resource
76
  ) -> ProxyResource:
 
77
  return cls(
78
  client=client,
79
- uri=resource.uri,
80
- name=resource.name,
81
- description=resource.description,
82
- mime_type=resource.mimeType,
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
- async def from_client(
106
- cls, client: Client, template: mcp.types.ResourceTemplate
107
  ) -> ProxyTemplate:
 
108
  return cls(
109
  client=client,
110
- uri_template=template.uriTemplate,
111
- name=template.name,
112
- description=template.description,
113
- parameters={},
 
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
- async def from_client(cls, client: Client, prompt: mcp.types.Prompt) -> ProxyPrompt:
 
 
 
 
 
 
 
 
 
 
 
157
  return cls(
158
  client=client,
159
- name=prompt.name,
160
- description=prompt.description,
161
- arguments=[a.model_dump() for a in prompt.arguments or []],
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
- async def get_tools(self) -> dict[str, Tool]:
176
- tools = await super().get_tools()
177
-
178
- async with self.client:
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
- if (tools := self._cache.get("tools")) is self._cache.NOT_FOUND:
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
- if (resources := self._cache.get("resources")) is self._cache.NOT_FOUND:
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
- if (
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
- mcp_tools: list[MCPTool] = []
521
- for key, tool in tools.items():
522
- if self._should_enable_component(tool):
523
- mcp_tools.append(tool.to_mcp_tool(name=key))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
 
525
- return mcp_tools
 
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
- mcp_resources: list[MCPResource] = []
536
- for key, resource in resources.items():
537
- if self._should_enable_component(resource):
538
- mcp_resources.append(resource.to_mcp_resource(uri=key))
539
- return mcp_resources
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- MCP server.
545
 
546
  """
547
- templates = await self.get_resource_templates()
548
- mcp_templates: list[MCPResourceTemplate] = []
549
- for key, template in templates.items():
550
- if self._should_enable_component(template):
551
- mcp_templates.append(template.to_mcp_template(uriTemplate=key))
552
- return mcp_templates
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- prompts = await self.get_prompts()
561
- mcp_prompts: list[MCPPrompt] = []
562
- for key, prompt in prompts.items():
563
- if self._should_enable_component(prompt):
564
- mcp_prompts.append(prompt.to_mcp_prompt(name=key))
565
- return mcp_prompts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("Call tool: %s with %s", key, arguments)
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
- Call a tool with raw MCP arguments. FastMCP subclasses should override
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
- # Get tool, checking first from our tools, then from the mounted servers
609
- if self._tool_manager.has_tool(key):
610
- tool = self._tool_manager.get_tool(key)
 
611
  if not self._should_enable_component(tool):
612
- raise DisabledError(f"Tool {key!r} is disabled")
613
- return await self._tool_manager.call_tool(key, arguments)
614
 
615
- # Check mounted servers to see if they have the tool
616
- # iterate such that new mounts take precedence over older ones
617
- for mounted_server in reversed(self._mounted_servers):
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
- raise NotFoundError(f"Unknown tool: {key!r}")
 
 
 
 
 
 
 
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("Read resource: %s", uri)
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
- Read a resource by URI, in the format expected by the low-level MCP
654
- server.
655
  """
656
- if self._resource_manager.has_resource(uri):
657
- resource = await self._resource_manager.get_resource(uri)
 
 
 
658
  if not self._should_enable_component(resource):
659
- raise DisabledError(f"Resource {str(uri)!r} is disabled")
660
- content = await self._resource_manager.read_resource(uri)
 
661
  return [
662
  ReadResourceContents(
663
  content=content,
664
  mime_type=resource.mime_type,
665
  )
666
  ]
 
 
 
 
667
  else:
668
- # iterate such that new mounts take precedence over older ones
669
- for mounted_server in reversed(self._mounted_servers):
670
- resource_uri = uri
671
- try:
672
- if mounted_server.prefix:
673
- # If server has a prefix, check if URI matches and strip prefix
674
- if has_resource_prefix(
675
- str(resource_uri),
676
- mounted_server.prefix,
677
- self.resource_prefix_format,
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("Get prompt: %s with %s", name, arguments)
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
- logger.debug("Get prompt: %s with %s", name, arguments)
 
726
 
727
- # Get prompt, checking first from our prompts, then from the mounted servers
728
- if self._prompt_manager.has_prompt(name):
729
- prompt = self._prompt_manager.get_prompt(name)
 
730
  if not self._should_enable_component(prompt):
731
- raise DisabledError(f"Prompt {name!r} is disabled")
732
- return await self._prompt_manager.render_prompt(name, arguments)
733
 
734
- # Check mounted servers to see if they have the prompt
735
- # iterate such that new mounts take precedence over older ones
736
- for mounted_server in reversed(self._mounted_servers):
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
- raise NotFoundError(f"Unknown prompt: {name}")
 
 
 
 
 
 
 
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, key: str | None = None) -> None:
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, key=key)
930
  self._cache.clear()
931
 
932
- def add_template(self, template: ResourceTemplate, key: str | None = None) -> None:
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, key=key)
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[Middleware] | None = None,
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[Middleware] | None = None,
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[Middleware] | None = None,
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[Middleware] | None = None,
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._mounted_servers.append(mounted_server)
 
 
 
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
- tool_key = f"{prefix}_{key}"
1686
- else:
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
- else:
1697
- resource_key = key
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
- else:
1706
- template_key = key
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
- prompt_key = f"{prefix}_{key}"
1713
- else:
1714
- prompt_key = key
1715
- self._prompt_manager.add_prompt(prompt, key=prompt_key)
1716
 
1717
  if prefix:
1718
- logger.info(f"Imported server {server.name} with prefix '{prefix}'")
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.info(f"Imported server {server.name}")
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 as _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
- pass
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 has_tool(self, key: str) -> bool:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  """Check if a tool exists."""
47
- return key in self._tools
 
48
 
49
- def get_tool(self, key: str) -> Tool:
50
  """Get tool by key."""
51
- if key in self._tools:
52
- return self._tools[key]
53
- raise NotFoundError(f"Unknown tool: {key}")
 
54
 
55
- def get_tools(self) -> dict[str, Tool]:
56
- """Get all registered tools, indexed by registered key."""
57
- return self._tools
 
 
58
 
59
- def list_tools(self) -> list[Tool]:
60
- """List all registered tools."""
61
- return list(self.get_tools().values())
 
 
 
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, key: str | None = None) -> Tool:
93
  """Register a tool with the server."""
94
- key = key or tool.name
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"Unknown tool: {key}")
123
 
124
  async def call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
125
- """Call a tool by name with arguments."""
126
- tool = self.get_tool(key)
127
- if not tool:
128
- raise NotFoundError(f"Unknown tool: {key}")
129
-
130
- try:
131
- return await tool.run(arguments)
132
-
133
- # raise ToolErrors as-is
134
- except ToolError as e:
135
- logger.exception(f"Error calling tool {key!r}: {e}")
136
- raise e
137
-
138
- # Handle other exceptions
139
- except Exception as e:
140
- logger.exception(f"Error calling tool {key!r}: {e}")
141
- if self.mask_error_details:
142
- # Mask internal details
143
- raise ToolError(f"Error calling tool {key!r}") from e
144
- else:
145
- # Include original error details
146
- raise ToolError(f"Error calling tool {key!r}: {e}") from e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 len(cast(FastMCP, transport.transport.server)._mounted_servers) == 2
 
 
 
 
 
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._resource_manager.get_resources()
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
- simple_prompts = [
336
- p for p in manager.get_prompts().values() if "simple" in p.tags
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 manager.get_prompts().values() if "nlp" in p.tags]
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
- assert len(manager.get_resources()) == 1
49
- assert resource in manager.get_resources().values()
 
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
- assert len(manager.get_resources()) == 1
65
- assert resource in manager.get_resources().values()
 
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
- assert len(manager.get_resources()) == 1
84
- assert resource in manager.get_resources().values()
 
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 = list(manager.get_resources().values())
135
- assert len(resources) == 1
136
- assert resources[0].name == "replacement"
 
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 = list(manager.get_resources().values())
160
- assert len(resources) == 1
161
- assert resources[0].name == "original"
 
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
- assert manager.get_templates() == {"test://{id}": template}
 
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
- templates = list(manager.get_templates().values())
 
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
- templates = list(manager.get_templates().values())
 
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
- resources = list(manager.get_resources().values())
 
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
- resources = list(manager.get_resources().values())
 
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
- templates = list(manager.get_templates().values())
 
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", "external"},
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", "internal"},
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", "internal"},
416
  )
417
 
418
  manager.add_resource(resource1)
419
  manager.add_resource(resource2)
420
  manager.add_resource(resource3)
421
 
422
- # Filter resources by tags
423
- internal_resources = [
424
- r for r in manager.get_resources().values() if "internal" in r.tags
425
- ]
426
- assert len(internal_resources) == 2
427
- assert {r.name for r in internal_resources} == {"user_data", "system_data"}
428
 
429
- external_resources = [
430
- r for r in manager.get_resources().values() if "external" in r.tags
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
- manager.add_resource(resource, key=custom_key)
 
 
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
- manager.add_template(template, key=custom_key)
 
 
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
- manager.add_resource(resource, key=custom_key)
 
 
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
- manager.add_template(template, key=custom_key)
 
 
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 fastmcp_openapi_server_with_all_types(
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(fastmcp_openapi_server_with_all_types) as 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
- fastmcp_openapi_server_with_all_types: FastMCPOpenAPI,
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(fastmcp_openapi_server_with_all_types) as 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(fastmcp_openapi_server_with_all_types) as 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
- fastmcp_openapi_server_with_all_types: FastMCPOpenAPI,
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(fastmcp_openapi_server_with_all_types) as 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(fastmcp_openapi_server_with_all_types) as 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(fastmcp_openapi_server_with_all_types) as 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
- fastmcp_openapi_server_with_all_types: FastMCPOpenAPI,
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(fastmcp_openapi_server_with_all_types) as 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
- fastmcp_openapi_server_with_all_types: FastMCPOpenAPI,
376
  api_client,
377
  ):
378
  """Test reading a resource that returns bytes."""
379
- async with Client(fastmcp_openapi_server_with_all_types) as 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
- fastmcp_openapi_server_with_all_types: FastMCPOpenAPI,
389
  api_client,
390
  ):
391
  """Test reading a resource that returns a string."""
392
- async with Client(fastmcp_openapi_server_with_all_types) as 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, fastmcp_openapi_server_with_all_types: FastMCPOpenAPI
400
  ):
401
  """
402
  By default, resource templates exclude GET methods without parameters
403
  """
404
- async with Client(fastmcp_openapi_server_with_all_types) as 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
- fastmcp_openapi_server_with_all_types: FastMCPOpenAPI,
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(fastmcp_openapi_server_with_all_types) as 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
- fastmcp_openapi_server_with_all_types: FastMCPOpenAPI,
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(fastmcp_openapi_server_with_all_types) as 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(fastmcp_openapi_server_with_all_types) as 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, fastmcp_openapi_server_with_all_types: FastMCPOpenAPI
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 = fastmcp_openapi_server_with_all_types._tool_manager.list_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, fastmcp_openapi_server_with_all_types: FastMCPOpenAPI
506
  ):
507
  """Test that tags from OpenAPI routes are correctly transferred to Resources."""
508
  # Get internal resources directly
509
- resources = list(
510
- fastmcp_openapi_server_with_all_types._resource_manager.get_resources().values()
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, fastmcp_openapi_server_with_all_types: FastMCPOpenAPI
527
  ):
528
  """Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
529
  # Get internal resource templates directly
530
- templates = list(
531
- fastmcp_openapi_server_with_all_types._resource_manager.get_templates().values()
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, fastmcp_openapi_server_with_all_types: FastMCPOpenAPI
548
  ):
549
  """Test that tags are preserved when creating resources from templates."""
550
  # Get internal resource templates directly
551
- templates = list(
552
- fastmcp_openapi_server_with_all_types._resource_manager.get_templates().values()
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 simple_server_with_all_types(self, simple_openapi_spec, mock_client):
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, simple_server_with_all_types
1183
  ):
1184
  """Test that a Resource includes the route description."""
1185
  resources = list(
1186
- simple_server_with_all_types._resource_manager.get_resources().values()
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, simple_server_with_all_types
1197
  ):
1198
  """Test that a Resource includes the response description."""
1199
  resources = list(
1200
- simple_server_with_all_types._resource_manager.get_resources().values()
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, simple_server_with_all_types
1211
  ):
1212
  """Test that a Resource description includes response model field descriptions."""
1213
  resources = list(
1214
- simple_server_with_all_types._resource_manager.get_resources().values()
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, simple_server_with_all_types
1234
  ):
1235
  """Test that a ResourceTemplate includes the route description."""
1236
- templates = list(
1237
- simple_server_with_all_types._resource_manager.get_templates().values()
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, simple_server_with_all_types
1248
  ):
1249
  """Test that a ResourceTemplate includes the function docstring."""
1250
- templates = list(
1251
- simple_server_with_all_types._resource_manager.get_templates().values()
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, simple_server_with_all_types
1262
  ):
1263
  """Test that a ResourceTemplate includes path parameter descriptions."""
1264
- templates = list(
1265
- simple_server_with_all_types._resource_manager.get_templates().values()
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, simple_server_with_all_types
1276
  ):
1277
  """Test that a ResourceTemplate includes query parameter descriptions."""
1278
- templates = list(
1279
- simple_server_with_all_types._resource_manager.get_templates().values()
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, simple_server_with_all_types
1290
  ):
1291
  """Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
1292
- templates = list(
1293
- simple_server_with_all_types._resource_manager.get_templates().values()
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, simple_server_with_all_types):
1315
  """Test that a Tool includes the route description."""
1316
- tools = simple_server_with_all_types._tool_manager.list_tools()
 
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, simple_server_with_all_types):
1325
  """Test that a Tool includes the function docstring."""
1326
- tools = simple_server_with_all_types._tool_manager.list_tools()
 
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, simple_server_with_all_types
1337
  ):
1338
  """Test that a Tool's parameter schema includes property descriptions from request model."""
1339
- tools = simple_server_with_all_types._tool_manager.list_tools()
 
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, simple_server_with_all_types):
1360
  """Test that Resource descriptions are accessible via the client API."""
1361
- async with Client(simple_server_with_all_types) as 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, simple_server_with_all_types):
1374
  """Test that ResourceTemplate descriptions are accessible via the client API."""
1375
- async with Client(simple_server_with_all_types) as 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, simple_server_with_all_types):
1388
  """Test that Tool descriptions are accessible via the client API."""
1389
- async with Client(simple_server_with_all_types) as 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, simple_server_with_all_types):
1402
  """Test that Tool parameter schemas are accessible via the client API."""
1403
- async with Client(simple_server_with_all_types) as 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
- for name, resource in server._resource_manager.get_resources().items():
 
1537
  print(f" Resource: {name}, Name attribute: {resource.name}")
1538
 
1539
  print("\nDEBUG - Templates created:")
1540
- for name, template in server._resource_manager.get_templates().items():
 
1541
  print(f" Template: {name}, Name attribute: {template.name}")
1542
 
1543
  print("\nDEBUG - Tools created:")
1544
- for tool in server._tool_manager.list_tools():
 
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
- resources = list(fastapi_server._resource_manager.get_resources().values())
 
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(self, fastapi_server):
 
 
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
- resources = list(fastapi_server._resource_manager.get_resources().values())
 
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
- templates = list(fastapi_server._resource_manager.get_templates().values())
 
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(self, fastapi_server):
 
 
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
- templates = list(fastapi_server._resource_manager.get_templates().values())
 
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(self, fastapi_server):
 
 
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
- templates = list(fastapi_server._resource_manager.get_templates().values())
 
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(self, fastapi_server):
 
 
1634
  """Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
1635
- templates = list(fastapi_server._resource_manager.get_templates().values())
 
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
- tools = fastapi_server._tool_manager.list_tools()
 
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
- tools = fastapi_server._tool_manager.list_tools()
 
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 = fastmcp_openapi_server_with_all_types._tool_manager.list_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
- resources = list(
1777
- fastmcp_openapi_server_with_all_types._resource_manager.get_resources().values()
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, fastmcp_openapi_server_with_all_types: FastMCPOpenAPI
1790
  ):
1791
  """Test that OpenAPIResourceTemplate's __repr__ method works without recursion errors."""
1792
- templates = list(
1793
- fastmcp_openapi_server_with_all_types._resource_manager.get_templates().values()
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
- tools = server._tool_manager.get_tools()
2011
- tool_names = {t.name for t in tools.values()}
2012
 
2013
- resources = server._resource_manager.get_resources()
2014
- resource_names = {r.name for r in resources.values()}
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
- resources = server._resource_manager.get_resources()
2044
- resource_names = {r.name for r in resources.values()}
2045
 
2046
- tools = server._tool_manager.get_tools()
2047
- tool_names = {t.name for t in tools.values()}
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
- tools = server._tool_manager.get_tools()
2079
- tool_names = {t.name for t in tools.values()}
2080
 
2081
- resources = server._resource_manager.get_resources()
2082
- resource_names = {r.name for r in resources.values()}
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
- tools = server._tool_manager.get_tools()
2114
- tool_names = {t.name for t in tools.values()}
2115
 
2116
- resources = server._resource_manager.get_resources()
2117
- resource_names = {r.name for r in resources.values()}
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
- tools = server._tool_manager.get_tools()
2144
- tool_names = {t.name for t in tools.values()}
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
- templates = list(server._resource_manager.get_templates().values())
2255
- template_names = {template.name for template in templates}
2256
  assert "user_detail" in template_names
2257
 
2258
  # Check resources use custom names
2259
- resources = list(server._resource_manager.get_resources().values())
2260
- resource_names = {resource.name for resource in resources}
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
- templates = list(server._resource_manager.get_templates().values())
2283
- template_names = {template.name for template in templates}
2284
 
2285
- resources = list(server._resource_manager.get_resources().values())
2286
- resource_names = {resource.name for resource in resources}
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
- resources = list(server._resource_manager.get_resources().values())
2304
  resource_names = {
2305
- resource.name for resource in resources if resource.name is not None
 
 
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
- resources = list(server._resource_manager.get_resources().values())
2337
- all_names.extend(resource.name for resource in resources)
2338
 
2339
- templates = list(server._resource_manager.get_templates().values())
2340
- all_names.extend(template.name for template in templates)
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
- resources = list(server._resource_manager.get_resources().values())
2423
  resource_names = {
2424
- resource.name for resource in resources if resource.name is not None
 
 
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
- resources = list(server._resource_manager.get_resources().values())
 
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
- templates = list(server._resource_manager.get_templates().values())
 
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
- templates = list(server._resource_manager.get_templates().values())
 
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
- resources = list(server._resource_manager.get_resources().values())
 
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
- async def test_mount_with_no_prefix(self):
 
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, "sub", as_proxy=True)
953
 
954
  assert lifespan_check == []
955
 
956
  async with Client(mcp) as client:
957
- await client.call_tool("sub_hello", {})
958
 
959
- assert lifespan_check == ["start"]
 
 
 
 
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
- tools = mcp._tool_manager.list_tools()
 
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
- tools = mcp._tool_manager.list_tools()
 
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
- tools = mcp._tool_manager.list_tools()
 
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
- tools = mcp._tool_manager.list_tools()
 
23
  assert len(tools) == 1
24
- assert "state" not in echo.parameters["properties"]
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
- tools = mcp._tool_manager.list_tools()
 
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=f"Unknown tool: {'missing'}"):
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 = [tool for tool in manager.list_tools() if "math" in tool.tags]
 
 
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 for tool in manager.list_tools() if "utility" in tool.tags
 
 
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="Unknown tool: unknown"):
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="Unknown tool: original_fn"):
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
- # Store it under a different name
760
- manager.add_tool(tool, key="proxy_tool")
 
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="Unknown tool: my_tool"):
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="Unknown tool: multiply"):
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