Jeremiah Lowin Claude commited on
Commit
0907fff
·
unverified ·
1 Parent(s): 2127c96

Fix middleware list result types (#1125)

Browse files

* Catch more openapi changes

* Simplify middleware list operations to use lists directly

- Updated middleware signatures to work with list[Tool], list[Resource], etc instead of wrapper objects
- Removed unnecessary ListToolsResult, ListResourcesResult wrapper types
- Updated documentation to show simpler list-based filtering pattern
- Added tests for list-based middleware filtering

Closes #1121

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

docs/servers/middleware.mdx CHANGED
@@ -181,23 +181,23 @@ class ComponentAccessMiddleware(Middleware):
181
 
182
  ### Working with Listing Results
183
 
184
- For listing operations, the middleware `call_next` function returns a special object that contains the full list of FastMCP components prior to being converted to MCP format. You can modify this object and return it to the client. Specifically, the `ListToolsResult`, `ListResourcesResult`, `ListResourceTemplatesResult`, and `ListPromptsResult` objects can all be imported from fastmcp.server.middleware for this purpose. For example:
185
 
186
  ```python
187
- from fastmcp.server.middleware import Middleware, MiddlewareContext, ListToolsResult
188
 
189
  class ListingFilterMiddleware(Middleware):
190
  async def on_list_tools(self, context: MiddlewareContext, call_next):
191
  result = await call_next(context)
192
 
193
  # Filter out tools with "private" tag
194
- filtered_tools = {
195
- name: tool for name, tool in result.tools.items()
196
  if "private" not in tool.tags
197
- }
198
 
199
- # Return modified result
200
- return ListToolsResult(tools=filtered_tools)
201
  ```
202
 
203
  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.
 
181
 
182
  ### Working with Listing Results
183
 
184
+ For listing operations, the middleware `call_next` function returns a list of FastMCP components prior to being converted to MCP format. You can filter or modify this list and return it to the client. For example:
185
 
186
  ```python
187
+ from fastmcp.server.middleware import Middleware, MiddlewareContext
188
 
189
  class ListingFilterMiddleware(Middleware):
190
  async def on_list_tools(self, context: MiddlewareContext, call_next):
191
  result = await call_next(context)
192
 
193
  # Filter out tools with "private" tag
194
+ filtered_tools = [
195
+ tool for tool in result
196
  if "private" not in tool.tags
197
+ ]
198
 
199
+ # Return modified list
200
+ return filtered_tools
201
  ```
202
 
203
  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.
src/fastmcp/server/middleware/__init__.py CHANGED
@@ -2,18 +2,10 @@ from .middleware import (
2
  Middleware,
3
  MiddlewareContext,
4
  CallNext,
5
- ListToolsResult,
6
- ListResourcesResult,
7
- ListResourceTemplatesResult,
8
- ListPromptsResult,
9
  )
10
 
11
  __all__ = [
12
  "Middleware",
13
  "MiddlewareContext",
14
  "CallNext",
15
- "ListToolsResult",
16
- "ListResourcesResult",
17
- "ListResourceTemplatesResult",
18
- "ListPromptsResult",
19
  ]
 
2
  Middleware,
3
  MiddlewareContext,
4
  CallNext,
 
 
 
 
5
  )
6
 
7
  __all__ = [
8
  "Middleware",
9
  "MiddlewareContext",
10
  "CallNext",
 
 
 
 
11
  ]
src/fastmcp/server/middleware/middleware.py CHANGED
@@ -29,10 +29,6 @@ __all__ = [
29
  "Middleware",
30
  "MiddlewareContext",
31
  "CallNext",
32
- "ListToolsResult",
33
- "ListResourcesResult",
34
- "ListResourceTemplatesResult",
35
- "ListPromptsResult",
36
  ]
37
 
38
  logger = logging.getLogger(__name__)
@@ -62,26 +58,6 @@ ServerResultT = TypeVar(
62
  )
63
 
64
 
65
- @dataclass(kw_only=True)
66
- class ListToolsResult:
67
- tools: dict[str, Tool]
68
-
69
-
70
- @dataclass(kw_only=True)
71
- class ListResourcesResult:
72
- resources: list[Resource]
73
-
74
-
75
- @dataclass(kw_only=True)
76
- class ListResourceTemplatesResult:
77
- resource_templates: list[ResourceTemplate]
78
-
79
-
80
- @dataclass(kw_only=True)
81
- class ListPromptsResult:
82
- prompts: list[Prompt]
83
-
84
-
85
  @runtime_checkable
86
  class ServerResultProtocol(Protocol[ServerResultT]):
87
  root: ServerResultT
@@ -212,29 +188,27 @@ class Middleware:
212
  async def on_list_tools(
213
  self,
214
  context: MiddlewareContext[mt.ListToolsRequest],
215
- call_next: CallNext[mt.ListToolsRequest, ListToolsResult],
216
- ) -> ListToolsResult:
217
  return await call_next(context)
218
 
219
  async def on_list_resources(
220
  self,
221
  context: MiddlewareContext[mt.ListResourcesRequest],
222
- call_next: CallNext[mt.ListResourcesRequest, ListResourcesResult],
223
- ) -> ListResourcesResult:
224
  return await call_next(context)
225
 
226
  async def on_list_resource_templates(
227
  self,
228
  context: MiddlewareContext[mt.ListResourceTemplatesRequest],
229
- call_next: CallNext[
230
- mt.ListResourceTemplatesRequest, ListResourceTemplatesResult
231
- ],
232
- ) -> ListResourceTemplatesResult:
233
  return await call_next(context)
234
 
235
  async def on_list_prompts(
236
  self,
237
  context: MiddlewareContext[mt.ListPromptsRequest],
238
- call_next: CallNext[mt.ListPromptsRequest, ListPromptsResult],
239
- ) -> ListPromptsResult:
240
  return await call_next(context)
 
29
  "Middleware",
30
  "MiddlewareContext",
31
  "CallNext",
 
 
 
 
32
  ]
33
 
34
  logger = logging.getLogger(__name__)
 
58
  )
59
 
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  @runtime_checkable
62
  class ServerResultProtocol(Protocol[ServerResultT]):
63
  root: ServerResultT
 
188
  async def on_list_tools(
189
  self,
190
  context: MiddlewareContext[mt.ListToolsRequest],
191
+ call_next: CallNext[mt.ListToolsRequest, list[Tool]],
192
+ ) -> list[Tool]:
193
  return await call_next(context)
194
 
195
  async def on_list_resources(
196
  self,
197
  context: MiddlewareContext[mt.ListResourcesRequest],
198
+ call_next: CallNext[mt.ListResourcesRequest, list[Resource]],
199
+ ) -> list[Resource]:
200
  return await call_next(context)
201
 
202
  async def on_list_resource_templates(
203
  self,
204
  context: MiddlewareContext[mt.ListResourceTemplatesRequest],
205
+ call_next: CallNext[mt.ListResourceTemplatesRequest, list[ResourceTemplate]],
206
+ ) -> list[ResourceTemplate]:
 
 
207
  return await call_next(context)
208
 
209
  async def on_list_prompts(
210
  self,
211
  context: MiddlewareContext[mt.ListPromptsRequest],
212
+ call_next: CallNext[mt.ListPromptsRequest, list[Prompt]],
213
+ ) -> list[Prompt]:
214
  return await call_next(context)
tests/server/middleware/test_middleware.py CHANGED
@@ -225,6 +225,12 @@ class TestMiddlewareHooks:
225
  assert recording_middleware.assert_called(hook="on_request", at_least=1)
226
  assert recording_middleware.assert_called(hook="on_list_tools", at_least=1)
227
 
 
 
 
 
 
 
228
  async def test_list_resources(
229
  self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
230
  ):
@@ -237,6 +243,12 @@ class TestMiddlewareHooks:
237
  assert recording_middleware.assert_called(hook="on_request", at_least=1)
238
  assert recording_middleware.assert_called(hook="on_list_resources", at_least=1)
239
 
 
 
 
 
 
 
240
  async def test_list_resource_templates(
241
  self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
242
  ):
@@ -253,6 +265,14 @@ class TestMiddlewareHooks:
253
  hook="on_list_resource_templates", at_least=1
254
  )
255
 
 
 
 
 
 
 
 
 
256
  async def test_list_prompts(
257
  self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
258
  ):
@@ -265,6 +285,132 @@ class TestMiddlewareHooks:
265
  assert recording_middleware.assert_called(hook="on_request", at_least=1)
266
  assert recording_middleware.assert_called(hook="on_list_prompts", at_least=1)
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
  class TestNestedMiddlewareHooks:
270
  @pytest.fixture
 
225
  assert recording_middleware.assert_called(hook="on_request", at_least=1)
226
  assert recording_middleware.assert_called(hook="on_list_tools", at_least=1)
227
 
228
+ # Verify the middleware receives a list of tools
229
+ list_tools_calls = recording_middleware.get_calls(hook="on_list_tools")
230
+ assert len(list_tools_calls) > 0
231
+ result = list_tools_calls[0].result
232
+ assert isinstance(result, list)
233
+
234
  async def test_list_resources(
235
  self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
236
  ):
 
243
  assert recording_middleware.assert_called(hook="on_request", at_least=1)
244
  assert recording_middleware.assert_called(hook="on_list_resources", at_least=1)
245
 
246
+ # Verify the middleware receives a list of resources
247
+ list_resources_calls = recording_middleware.get_calls(hook="on_list_resources")
248
+ assert len(list_resources_calls) > 0
249
+ result = list_resources_calls[0].result
250
+ assert isinstance(result, list)
251
+
252
  async def test_list_resource_templates(
253
  self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
254
  ):
 
265
  hook="on_list_resource_templates", at_least=1
266
  )
267
 
268
+ # Verify the middleware receives a list of resource templates
269
+ list_templates_calls = recording_middleware.get_calls(
270
+ hook="on_list_resource_templates"
271
+ )
272
+ assert len(list_templates_calls) > 0
273
+ result = list_templates_calls[0].result
274
+ assert isinstance(result, list)
275
+
276
  async def test_list_prompts(
277
  self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
278
  ):
 
285
  assert recording_middleware.assert_called(hook="on_request", at_least=1)
286
  assert recording_middleware.assert_called(hook="on_list_prompts", at_least=1)
287
 
288
+ # Verify the middleware receives a list of prompts
289
+ list_prompts_calls = recording_middleware.get_calls(hook="on_list_prompts")
290
+ assert len(list_prompts_calls) > 0
291
+ result = list_prompts_calls[0].result
292
+ assert isinstance(result, list)
293
+
294
+ async def test_list_tools_filtering_middleware(self):
295
+ """Test that middleware can filter tools."""
296
+
297
+ class FilteringMiddleware(Middleware):
298
+ async def on_list_tools(self, context: MiddlewareContext, call_next):
299
+ result = await call_next(context)
300
+ # Filter out tools with "private" tag - simple list filtering
301
+ filtered_tools = [tool for tool in result if "private" not in tool.tags]
302
+ return filtered_tools
303
+
304
+ server = FastMCP("TestServer")
305
+
306
+ @server.tool
307
+ def public_tool(name: str) -> str:
308
+ return f"Hello {name}"
309
+
310
+ @server.tool(tags={"private"})
311
+ def private_tool(secret: str) -> str:
312
+ return f"Secret: {secret}"
313
+
314
+ server.add_middleware(FilteringMiddleware())
315
+
316
+ async with Client(server) as client:
317
+ tools = await client.list_tools()
318
+
319
+ assert len(tools) == 1
320
+ assert tools[0].name == "public_tool"
321
+
322
+ async def test_list_resources_filtering_middleware(self):
323
+ """Test that middleware can filter resources."""
324
+
325
+ class FilteringMiddleware(Middleware):
326
+ async def on_list_resources(self, context: MiddlewareContext, call_next):
327
+ result = await call_next(context)
328
+ # Filter out resources with "private" tag
329
+ filtered_resources = [
330
+ resource for resource in result if "private" not in resource.tags
331
+ ]
332
+ return filtered_resources
333
+
334
+ server = FastMCP("TestServer")
335
+
336
+ @server.resource("resource://public")
337
+ def public_resource() -> str:
338
+ return "public data"
339
+
340
+ @server.resource("resource://private", tags={"private"})
341
+ def private_resource() -> str:
342
+ return "private data"
343
+
344
+ server.add_middleware(FilteringMiddleware())
345
+
346
+ async with Client(server) as client:
347
+ resources = await client.list_resources()
348
+
349
+ assert len(resources) == 1
350
+ assert str(resources[0].uri) == "resource://public"
351
+
352
+ async def test_list_resource_templates_filtering_middleware(self):
353
+ """Test that middleware can filter resource templates."""
354
+
355
+ class FilteringMiddleware(Middleware):
356
+ async def on_list_resource_templates(
357
+ self, context: MiddlewareContext, call_next
358
+ ):
359
+ result = await call_next(context)
360
+ # Filter out templates with "private" tag
361
+ filtered_templates = [
362
+ template for template in result if "private" not in template.tags
363
+ ]
364
+ return filtered_templates
365
+
366
+ server = FastMCP("TestServer")
367
+
368
+ @server.resource("resource://public/{x}")
369
+ def public_template(x: str) -> str:
370
+ return f"public {x}"
371
+
372
+ @server.resource("resource://private/{x}", tags={"private"})
373
+ def private_template(x: str) -> str:
374
+ return f"private {x}"
375
+
376
+ server.add_middleware(FilteringMiddleware())
377
+
378
+ async with Client(server) as client:
379
+ templates = await client.list_resource_templates()
380
+
381
+ assert len(templates) == 1
382
+ assert str(templates[0].uriTemplate) == "resource://public/{x}"
383
+
384
+ async def test_list_prompts_filtering_middleware(self):
385
+ """Test that middleware can filter prompts."""
386
+
387
+ class FilteringMiddleware(Middleware):
388
+ async def on_list_prompts(self, context: MiddlewareContext, call_next):
389
+ result = await call_next(context)
390
+ # Filter out prompts with "private" tag
391
+ filtered_prompts = [
392
+ prompt for prompt in result if "private" not in prompt.tags
393
+ ]
394
+ return filtered_prompts
395
+
396
+ server = FastMCP("TestServer")
397
+
398
+ @server.prompt
399
+ def public_prompt(name: str) -> str:
400
+ return f"Hello {name}"
401
+
402
+ @server.prompt(tags={"private"})
403
+ def private_prompt(secret: str) -> str:
404
+ return f"Secret: {secret}"
405
+
406
+ server.add_middleware(FilteringMiddleware())
407
+
408
+ async with Client(server) as client:
409
+ prompts = await client.list_prompts()
410
+
411
+ assert len(prompts) == 1
412
+ assert prompts[0].name == "public_prompt"
413
+
414
 
415
  class TestNestedMiddlewareHooks:
416
  @pytest.fixture