Skip to content

Commit 904dc9f

Browse files
committed
Don't block the event loop on sync resource and prompt functions
PR #1909 fixed this for tools by running sync functions via anyio.to_thread.run_sync, but the same blocking pattern existed in FunctionResource.read, ResourceTemplate.create_resource, and Prompt.render. All three called self.fn() directly and checked inspect.iscoroutine(result) afterward, so a blocking sync @mcp.resource or @mcp.prompt handler would still freeze the event loop. This applies the same fix: check inspect.iscoroutinefunction(self.fn) up front and dispatch sync functions to a worker thread. Verified that pydantic.validate_call (used to wrap stored functions in templates and prompts) preserves async-ness, so the check works correctly on the wrapped function. Github-Issue: #1646
1 parent e6235d1 commit 904dc9f

File tree

6 files changed

+75
-13
lines changed

6 files changed

+75
-13
lines changed

src/mcp/server/mcpserver/prompts/base.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
from __future__ import annotations
44

5+
import functools
56
import inspect
67
from collections.abc import Awaitable, Callable, Sequence
78
from typing import TYPE_CHECKING, Any, Literal
89

10+
import anyio.to_thread
911
import pydantic_core
1012
from pydantic import BaseModel, Field, TypeAdapter, validate_call
1113

@@ -155,10 +157,10 @@ async def render(
155157
# Add context to arguments if needed
156158
call_args = inject_context(self.fn, arguments or {}, context, self.context_kwarg)
157159

158-
# Call function and check if result is a coroutine
159-
result = self.fn(**call_args)
160-
if inspect.iscoroutine(result):
161-
result = await result
160+
if inspect.iscoroutinefunction(self.fn):
161+
result = await self.fn(**call_args)
162+
else:
163+
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args))
162164

163165
# Validate messages
164166
if not isinstance(result, list | tuple):

src/mcp/server/mcpserver/resources/templates.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
from __future__ import annotations
44

5+
import functools
56
import inspect
67
import re
78
from collections.abc import Callable
89
from typing import TYPE_CHECKING, Any
910
from urllib.parse import unquote
1011

12+
import anyio.to_thread
1113
from pydantic import BaseModel, Field, validate_call
1214

1315
from mcp.server.mcpserver.resources.types import FunctionResource, Resource
@@ -110,10 +112,10 @@ async def create_resource(
110112
# Add context to params if needed
111113
params = inject_context(self.fn, params, context, self.context_kwarg)
112114

113-
# Call function and check if result is a coroutine
114-
result = self.fn(**params)
115-
if inspect.iscoroutine(result):
116-
result = await result
115+
if inspect.iscoroutinefunction(self.fn):
116+
result = await self.fn(**params)
117+
else:
118+
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **params))
117119

118120
return FunctionResource(
119121
uri=uri, # type: ignore

src/mcp/server/mcpserver/resources/types.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,10 @@ class FunctionResource(Resource):
5555
async def read(self) -> str | bytes:
5656
"""Read the resource by calling the wrapped function."""
5757
try:
58-
# Call the function first to see if it returns a coroutine
59-
result = self.fn()
60-
# If it's a coroutine, await it
61-
if inspect.iscoroutine(result):
62-
result = await result
58+
if inspect.iscoroutinefunction(self.fn):
59+
result = await self.fn()
60+
else:
61+
result = await anyio.to_thread.run_sync(self.fn)
6362

6463
if isinstance(result, Resource): # pragma: no cover
6564
return await result.read()

tests/server/mcpserver/prompts/test_base.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import threading
12
from typing import Any
23

34
import pytest
@@ -190,3 +191,21 @@ async def fn() -> dict[str, Any]:
190191
)
191192
)
192193
]
194+
195+
196+
@pytest.mark.anyio
197+
async def test_sync_fn_runs_in_worker_thread():
198+
"""Sync prompt functions must run in a worker thread, not the event loop."""
199+
200+
main_thread = threading.get_ident()
201+
fn_thread: list[int] = []
202+
203+
def blocking_fn() -> str:
204+
fn_thread.append(threading.get_ident())
205+
return "hello"
206+
207+
prompt = Prompt.from_function(blocking_fn)
208+
messages = await prompt.render(None, Context())
209+
210+
assert messages == [UserMessage(content=TextContent(type="text", text="hello"))]
211+
assert fn_thread[0] != main_thread

tests/server/mcpserver/resources/test_function_resources.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import threading
2+
13
import pytest
24
from pydantic import BaseModel
35

@@ -190,3 +192,21 @@ def get_data() -> str: # pragma: no cover
190192
)
191193

192194
assert resource.meta is None
195+
196+
197+
@pytest.mark.anyio
198+
async def test_sync_fn_runs_in_worker_thread():
199+
"""Sync resource functions must run in a worker thread, not the event loop."""
200+
201+
main_thread = threading.get_ident()
202+
fn_thread: list[int] = []
203+
204+
def blocking_fn() -> str:
205+
fn_thread.append(threading.get_ident())
206+
return "data"
207+
208+
resource = FunctionResource(uri="resource://test", name="test", fn=blocking_fn)
209+
result = await resource.read()
210+
211+
assert result == "data"
212+
assert fn_thread[0] != main_thread

tests/server/mcpserver/resources/test_resource_template.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import threading
23
from typing import Any
34

45
import pytest
@@ -310,3 +311,22 @@ def get_item(item_id: str) -> str:
310311
assert resource.meta == metadata
311312
assert resource.meta["category"] == "inventory"
312313
assert resource.meta["cacheable"] is True
314+
315+
316+
@pytest.mark.anyio
317+
async def test_sync_fn_runs_in_worker_thread():
318+
"""Sync template functions must run in a worker thread, not the event loop."""
319+
320+
main_thread = threading.get_ident()
321+
fn_thread: list[int] = []
322+
323+
def blocking_fn(name: str) -> str:
324+
fn_thread.append(threading.get_ident())
325+
return f"hello {name}"
326+
327+
template = ResourceTemplate.from_function(fn=blocking_fn, uri_template="test://{name}")
328+
resource = await template.create_resource("test://world", {"name": "world"}, Context())
329+
330+
assert isinstance(resource, FunctionResource)
331+
assert await resource.read() == "hello world"
332+
assert fn_thread[0] != main_thread

0 commit comments

Comments
 (0)