pFad - Phone/Frame/Anonymizer/Declutterfier! Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

URL: http://github.com/modelcontextprotocol/python-sdk/commit/3ce0f76e6e3b33f035b6b28421e1b7c6dbe8c77f

="all" rel="stylesheet" href="https://github.githubassets.com/assets/global-7a1ad343bd40328c.css" /> Don't block the event loop on sync resource and prompt functions (#2380) · modelcontextprotocol/python-sdk@3ce0f76 · GitHub
Skip to content

Commit 3ce0f76

Browse files
authored
Don't block the event loop on sync resource and prompt functions (#2380)
1 parent e6235d1 commit 3ce0f76

6 files changed

Lines changed: 107 additions & 13 deletions

File tree

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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import threading
2+
3+
import anyio
4+
import anyio.from_thread
15
import pytest
26
from pydantic import BaseModel
37

@@ -190,3 +194,51 @@ def get_data() -> str: # pragma: no cover
190194
)
191195

192196
assert resource.meta is None
197+
198+
199+
@pytest.mark.anyio
200+
async def test_sync_fn_runs_in_worker_thread():
201+
"""Sync resource functions must run in a worker thread, not the event loop."""
202+
203+
main_thread = threading.get_ident()
204+
fn_thread: list[int] = []
205+
206+
def blocking_fn() -> str:
207+
fn_thread.append(threading.get_ident())
208+
return "data"
209+
210+
resource = FunctionResource(uri="resource://test", name="test", fn=blocking_fn)
211+
result = await resource.read()
212+
213+
assert result == "data"
214+
assert fn_thread[0] != main_thread
215+
216+
217+
@pytest.mark.anyio
218+
async def test_sync_fn_does_not_block_event_loop():
219+
"""A blocking sync resource function must not stall the event loop.
220+
221+
On regression (sync runs inline), anyio.from_thread.run_sync raises
222+
RuntimeError because there is no worker-thread context, failing fast.
223+
"""
224+
handler_entered = anyio.Event()
225+
release = threading.Event()
226+
227+
def blocking_fn() -> str:
228+
anyio.from_thread.run_sync(handler_entered.set)
229+
release.wait()
230+
return "done"
231+
232+
resource = FunctionResource(uri="resource://test", name="test", fn=blocking_fn)
233+
result: list[str | bytes] = []
234+
235+
async def run() -> None:
236+
result.append(await resource.read())
237+
238+
with anyio.fail_after(5):
239+
async with anyio.create_task_group() as tg:
240+
tg.start_soon(run)
241+
await handler_entered.wait()
242+
release.set()
243+
244+
assert result == ["done"]

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)
pFad - Phonifier reborn

Pfad - The Proxy pFad © 2024 Your Company Name. All rights reserved.





Check this box to remove all script contents from the fetched content.



Check this box to remove all images from the fetched content.


Check this box to remove all CSS styles from the fetched content.


Check this box to keep images inefficiently compressed and original size.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy