mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-28 02:10:38 +02:00
Compare commits
9 commits
main
...
published-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e333fa52df |
||
|
|
9f3ba555c2 |
||
|
|
0a619de571 |
||
|
|
0e941cf051 |
||
|
|
98bbec1f14 |
||
|
|
0747ce0bc1 |
||
|
|
8cf4506aa9 |
||
|
|
3098f8086b |
||
|
|
6bc5dc065a |
16 changed files with 176 additions and 75 deletions
|
|
@ -618,7 +618,7 @@ def search(query: str) -> str:
|
|||
When a response exceeds the limit, the middleware extracts all text content, joins it together, truncates to fit within the limit, and returns a single `TextContent` block. For non-text responses, the serialized JSON is used as the text source.
|
||||
|
||||
<Note>
|
||||
Because truncation can replace structured output with plain text, the middleware omits `outputSchema` from `tools/list` for every tool it limits. Responses below the limit still include their original structured content, but clients cannot validate or hydrate it against an advertised output schema. Tools excluded with the `tools` parameter keep their output schemas.
|
||||
If a tool defines an `output_schema`, truncated responses will no longer conform to that schema — the client will receive a plain `TextContent` block instead of the expected structured output. Keep this in mind when setting size limits for tools with structured responses.
|
||||
</Note>
|
||||
|
||||
```python
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import mcp_types as mt
|
||||
import pydantic_core
|
||||
from mcp_types import TextContent
|
||||
|
||||
from fastmcp.tools.base import InputRequiredToolResult, Tool, ToolResult
|
||||
from fastmcp.tools.base import InputRequiredToolResult, ToolResult
|
||||
|
||||
from .middleware import CallNext, Middleware, MiddlewareContext
|
||||
|
||||
|
|
@ -69,9 +68,6 @@ class ResponseLimitingMiddleware(Middleware):
|
|||
self.truncation_suffix = truncation_suffix
|
||||
self.tools = set(tools) if tools is not None else None
|
||||
|
||||
def _limits_tool(self, name: str) -> bool:
|
||||
return self.tools is None or name in self.tools
|
||||
|
||||
def _truncate_to_result(
|
||||
self,
|
||||
text: str,
|
||||
|
|
@ -97,25 +93,15 @@ class ResponseLimitingMiddleware(Middleware):
|
|||
+ self.truncation_suffix
|
||||
)
|
||||
|
||||
# Preserve original meta, falling back to {} when absent. Having
|
||||
# meta set ensures to_mcp_result() returns a CallToolResult, which
|
||||
# bypasses MCP SDK outputSchema validation — a truncated response
|
||||
# is no longer valid structured output.
|
||||
return ToolResult(
|
||||
content=[TextContent(type="text", text=truncated)],
|
||||
meta=meta,
|
||||
meta=meta if meta is not None else {},
|
||||
)
|
||||
|
||||
async def on_list_tools(
|
||||
self,
|
||||
context: MiddlewareContext[mt.ListToolsRequest],
|
||||
call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]],
|
||||
) -> Sequence[Tool]:
|
||||
"""Hide schemas for tools whose response shape may be truncated to text."""
|
||||
tools = await call_next(context)
|
||||
return [
|
||||
tool.model_copy(update={"output_schema": None})
|
||||
if self._limits_tool(tool.name) and tool.output_schema is not None
|
||||
else tool
|
||||
for tool in tools
|
||||
]
|
||||
|
||||
async def on_call_tool(
|
||||
self,
|
||||
context: MiddlewareContext[mt.CallToolRequestParams],
|
||||
|
|
@ -139,7 +125,7 @@ class ResponseLimitingMiddleware(Middleware):
|
|||
return result
|
||||
|
||||
# Check if we should limit this tool
|
||||
if not self._limits_tool(context.message.name):
|
||||
if self.tools is not None and context.message.name not in self.tools:
|
||||
return result
|
||||
|
||||
# Measure serialized size
|
||||
|
|
|
|||
|
|
@ -307,7 +307,6 @@ async def run_asgi_lifespan(app: ASGIApp) -> AsyncIterator[None]:
|
|||
raise
|
||||
finally:
|
||||
await receive_queue.put({"type": "lifespan.shutdown"})
|
||||
results: tuple[object, ...] = ()
|
||||
with anyio.CancelScope(shield=True):
|
||||
results = await asyncio.gather(
|
||||
shutdown_complete, task, return_exceptions=True
|
||||
|
|
|
|||
|
|
@ -253,15 +253,15 @@ class TestProjectPrepareCommand:
|
|||
mock_find.return_value = None
|
||||
|
||||
# Run command without output_dir - should exit with error for missing output_dir
|
||||
with patch("fastmcp.cli.cli.logger.error") as mock_error:
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with patch("fastmcp.cli.cli.logger.error") as mock_error:
|
||||
await prepare(config_path=None, output_dir=None)
|
||||
|
||||
assert isinstance(exc_info.value, SystemExit)
|
||||
assert exc_info.value.code == 1
|
||||
mock_error.assert_called()
|
||||
error_msg = mock_error.call_args[0][0]
|
||||
assert "--output-dir parameter is required" in error_msg
|
||||
assert isinstance(exc_info.value, SystemExit)
|
||||
assert exc_info.value.code == 1
|
||||
mock_error.assert_called()
|
||||
error_msg = mock_error.call_args[0][0]
|
||||
assert "--output-dir parameter is required" in error_msg
|
||||
|
||||
@patch("pathlib.Path.exists")
|
||||
async def test_project_prepare_config_not_exists(self, mock_exists):
|
||||
|
|
@ -272,15 +272,15 @@ class TestProjectPrepareCommand:
|
|||
mock_exists.return_value = False
|
||||
|
||||
# Run command without output_dir - should exit with error for missing output_dir
|
||||
with patch("fastmcp.cli.cli.logger.error") as mock_error:
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with patch("fastmcp.cli.cli.logger.error") as mock_error:
|
||||
await prepare(config_path="missing.json", output_dir=None)
|
||||
|
||||
assert isinstance(exc_info.value, SystemExit)
|
||||
assert exc_info.value.code == 1
|
||||
mock_error.assert_called()
|
||||
error_msg = mock_error.call_args[0][0]
|
||||
assert "--output-dir parameter is required" in error_msg
|
||||
assert isinstance(exc_info.value, SystemExit)
|
||||
assert exc_info.value.code == 1
|
||||
mock_error.assert_called()
|
||||
error_msg = mock_error.call_args[0][0]
|
||||
assert "--output-dir parameter is required" in error_msg
|
||||
|
||||
@patch("pathlib.Path.exists")
|
||||
@patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.from_file")
|
||||
|
|
@ -295,12 +295,12 @@ class TestProjectPrepareCommand:
|
|||
mock_from_file.return_value = mock_config
|
||||
|
||||
# Run command - should exit with error
|
||||
with patch("fastmcp.cli.cli.console.print") as mock_print:
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with patch("fastmcp.cli.cli.console.print") as mock_print:
|
||||
await prepare(config_path="config.json", output_dir="./test-env")
|
||||
|
||||
assert isinstance(exc_info.value, SystemExit)
|
||||
assert exc_info.value.code == 1
|
||||
# Should print error message
|
||||
error_call = mock_print.call_args_list[-1][0][0]
|
||||
assert "Failed to prepare project" in error_call
|
||||
assert isinstance(exc_info.value, SystemExit)
|
||||
assert exc_info.value.code == 1
|
||||
# Should print error message
|
||||
error_call = mock_print.call_args_list[-1][0][0]
|
||||
assert "Failed to prepare project" in error_call
|
||||
|
|
|
|||
|
|
@ -225,9 +225,9 @@ async def test_oauth_callback_handler_propagates_iss_to_authorization_code_resul
|
|||
tg.start_soon(send_callback)
|
||||
result = await oauth.callback_handler()
|
||||
|
||||
assert result.code == "auth-code-123"
|
||||
assert result.state == "state-xyz"
|
||||
assert result.iss == "https://issuer.example.com"
|
||||
assert result.code == "auth-code-123"
|
||||
assert result.state == "state-xyz"
|
||||
assert result.iss == "https://issuer.example.com"
|
||||
|
||||
|
||||
class TestOAuthClientUrlHandling:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ Client(server) with an in-process FastMCP server.
|
|||
import time
|
||||
|
||||
import pytest
|
||||
from docket import Docket
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.client.transports import FastMCPTransport
|
||||
|
|
@ -20,8 +21,18 @@ def test_transport_repr_includes_server_name():
|
|||
assert repr(transport) == "<FastMCPTransport(server='repr-test')>"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_docket_memory_server():
|
||||
"""Force a fresh memory:// Docket server bound to this test's loop."""
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
yield
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
|
||||
|
||||
@pytest.mark.timeout(10)
|
||||
async def test_task_teardown_does_not_hang():
|
||||
async def test_task_teardown_does_not_hang(reset_docket_memory_server):
|
||||
"""In-memory transport must tear down in under 2 seconds after a task call.
|
||||
|
||||
This is a regression test for a teardown ordering bug where the Docket
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
from docket import Docket
|
||||
from fastmcp_tasks.context import _recall_snapshot, get_task_context
|
||||
from mcp_types import TextContent, TextResourceContents
|
||||
from starlette.requests import Request
|
||||
|
|
@ -13,6 +14,16 @@ from fastmcp_tasks import TasksExtension
|
|||
from tests.tasks.task_helpers import running_task_server, submit_task, wait_for_task
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_docket_memory_server():
|
||||
"""Force a fresh memory:// Docket server bound to this test's event loop."""
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
yield
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
|
||||
|
||||
def _http_request_with_headers(headers: dict[str, str]) -> Request:
|
||||
"""Build a minimal Starlette HTTP request carrying the given headers."""
|
||||
raw_headers = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
|
||||
|
|
@ -235,7 +246,9 @@ def _worker_snapshot_headers() -> dict[str, str]:
|
|||
return dict(snapshot.http_headers)
|
||||
|
||||
|
||||
async def test_background_task_can_read_snapshotted_request_headers():
|
||||
async def test_background_task_can_read_snapshotted_request_headers(
|
||||
reset_docket_memory_server,
|
||||
):
|
||||
"""A background task worker reads the HTTP headers snapshotted at submission.
|
||||
|
||||
There is no client task-submission API yet (Phase 4), so the task is driven
|
||||
|
|
@ -265,7 +278,9 @@ async def test_background_task_can_read_snapshotted_request_headers():
|
|||
assert final.result["structuredContent"] == {"result": "tenant-123"}
|
||||
|
||||
|
||||
async def test_background_task_snapshot_preserves_all_request_headers():
|
||||
async def test_background_task_snapshot_preserves_all_request_headers(
|
||||
reset_docket_memory_server,
|
||||
):
|
||||
"""The task snapshot preserves every request header, including authorization."""
|
||||
server = FastMCP()
|
||||
server.add_extension(TasksExtension())
|
||||
|
|
|
|||
|
|
@ -57,18 +57,14 @@ class TestResponseLimitingMiddleware:
|
|||
)
|
||||
|
||||
@mcp_server.tool()
|
||||
def limited_tool() -> str:
|
||||
return "x" * 10_000
|
||||
def limited_tool() -> ToolResult:
|
||||
return ToolResult(content=[TextContent(type="text", text="x" * 10_000)])
|
||||
|
||||
@mcp_server.tool()
|
||||
def unlimited_tool() -> str:
|
||||
return "y" * 10_000
|
||||
def unlimited_tool() -> ToolResult:
|
||||
return ToolResult(content=[TextContent(type="text", text="y" * 10_000)])
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
tools = {tool.name: tool for tool in await client.list_tools()}
|
||||
assert tools["limited_tool"].output_schema is None
|
||||
assert tools["unlimited_tool"].output_schema is not None
|
||||
|
||||
# Limited tool should be truncated
|
||||
result = await client.call_tool("limited_tool", {})
|
||||
assert "[Response truncated" in result.content[0].text
|
||||
|
|
@ -82,13 +78,10 @@ class TestResponseLimitingMiddleware:
|
|||
mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=100, tools=[]))
|
||||
|
||||
@mcp_server.tool()
|
||||
def any_tool() -> str:
|
||||
return "x" * 10_000
|
||||
def any_tool() -> ToolResult:
|
||||
return ToolResult(content=[TextContent(type="text", text="x" * 10_000)])
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
tools = await client.list_tools()
|
||||
assert tools[0].output_schema is not None
|
||||
|
||||
result = await client.call_tool("any_tool", {})
|
||||
# Should NOT be truncated
|
||||
assert "[Response truncated" not in result.content[0].text
|
||||
|
|
@ -159,10 +152,11 @@ class TestResponseLimitingMiddleware:
|
|||
async def test_truncation_does_not_break_output_schema_tools(
|
||||
self, mcp_server: FastMCP
|
||||
):
|
||||
"""Truncating a tool with outputSchema must not cause client validation errors.
|
||||
"""Truncating a tool with outputSchema must not cause validation errors.
|
||||
|
||||
Regression test for #4926: an end-to-end client rejects truncated results
|
||||
when tools/list still advertises the original outputSchema.
|
||||
Regression test for #3717: the MCP SDK rejects truncated results
|
||||
from tools with outputSchema because structured_content is dropped.
|
||||
We verify the server returns a successful (non-error) truncated result.
|
||||
"""
|
||||
mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=1_000))
|
||||
|
||||
|
|
@ -170,12 +164,7 @@ class TestResponseLimitingMiddleware:
|
|||
def big_answer() -> Answer:
|
||||
return Answer(text="x" * 2_000)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
tools = await client.list_tools()
|
||||
assert tools[0].output_schema is None
|
||||
result = await client.call_tool("big_answer", {})
|
||||
|
||||
assert result.is_error is False
|
||||
result = await mcp_server.call_tool("big_answer", {})
|
||||
first = result.content[0]
|
||||
assert isinstance(first, TextContent)
|
||||
assert "[Response truncated" in first.text
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Advanced mounting scenarios."""
|
||||
|
||||
import pytest
|
||||
from docket import Docket
|
||||
from mcp_types import TextContent
|
||||
from starlette.routing import Route
|
||||
|
||||
|
|
@ -12,6 +13,16 @@ from fastmcp_tasks import TasksExtension
|
|||
from tests.tasks.task_helpers import running_task_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_docket_memory_server():
|
||||
"""Force a fresh memory:// Docket server bound to this test's event loop."""
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
yield
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
|
||||
|
||||
class TestDynamicChanges:
|
||||
"""Test that changes to mounted servers are reflected dynamically."""
|
||||
|
||||
|
|
@ -600,7 +611,9 @@ class TestMountedServerDocketBehavior:
|
|||
includes Docket creation.
|
||||
"""
|
||||
|
||||
async def test_mounted_server_does_not_have_docket(self):
|
||||
async def test_mounted_server_does_not_have_docket(
|
||||
self, reset_docket_memory_server
|
||||
):
|
||||
"""Test that a mounted server doesn't create its own Docket.
|
||||
|
||||
MountedProvider.lifespan() should call only the server's _lifespan
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from contextlib import asynccontextmanager, contextmanager
|
||||
|
||||
import pytest
|
||||
from docket import Docket
|
||||
from mcp_types import TextContent, TextResourceContents
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -15,6 +16,16 @@ from tests.conftest import make_server_request_context
|
|||
HUZZAH = "huzzah!"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_docket_memory_server():
|
||||
"""Force a fresh memory:// Docket server bound to this test's event loop."""
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
yield
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
|
||||
|
||||
class Connection:
|
||||
"""Test connection that tracks whether it's currently open."""
|
||||
|
||||
|
|
@ -1194,7 +1205,9 @@ class TestSharedDependencies:
|
|||
)
|
||||
assert call_count == 1
|
||||
|
||||
async def test_shared_resolves_on_task_capable_server(self):
|
||||
async def test_shared_resolves_on_task_capable_server(
|
||||
self, reset_docket_memory_server
|
||||
):
|
||||
"""Shared() dependencies resolve on a normal request even when the server
|
||||
has task-enabled components.
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from typing import Annotated
|
|||
|
||||
import mcp_types
|
||||
import pytest
|
||||
from docket import Docket
|
||||
from mcp.client._input_required import InputRequiredRoundsExceededError
|
||||
from mcp.server.request_state import RequestStateSecurity
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
|
@ -1176,7 +1177,18 @@ class TestTaskExecution:
|
|||
background task has no such request, so returning a guard result from a task
|
||||
is rejected with a clear error rather than silently yielding empty content."""
|
||||
|
||||
async def test_guard_result_from_task_parks_for_input(self):
|
||||
@pytest.fixture
|
||||
def reset_docket_memory_server(self):
|
||||
"""Force a fresh memory:// Docket server bound to this test's loop."""
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
yield
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
|
||||
async def test_guard_result_from_task_parks_for_input(
|
||||
self, reset_docket_memory_server
|
||||
):
|
||||
mcp = FastMCP("guard-task")
|
||||
mcp.add_extension(TasksExtension())
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pytest
|
||||
from docket import Docket
|
||||
from docket.worker import Worker
|
||||
from fastmcp_tasks.dependencies import CurrentDocket, CurrentWorker
|
||||
|
|
@ -15,6 +16,16 @@ from fastmcp_tasks import TasksExtension
|
|||
HUZZAH = "huzzah!"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_docket_memory_server():
|
||||
"""Force a fresh memory:// Docket server bound to each test's event loop."""
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
yield
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
|
||||
|
||||
async def test_docket_not_initialized_without_task_components():
|
||||
"""Docket is only initialized when task-enabled components exist."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,26 @@ import pytest
|
|||
from fastmcp.utilities.tests import temporary_settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_docket_memory_server():
|
||||
"""Reset the shared memory:// Docket server between tests.
|
||||
|
||||
Docket keeps a process-wide ``Docket._memory_server`` singleton for
|
||||
``memory://`` backends. It persists across tests and across event loops, so a
|
||||
test that inherits a stale server from a previous loop can fail (e.g.
|
||||
``tasks/get`` raising ``TypeError`` from the dead client). Clearing it before
|
||||
and after each test keeps the task suite isolation-safe rather than
|
||||
order-dependent.
|
||||
"""
|
||||
from docket import Docket
|
||||
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
yield
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_settings_home(_settings_home_root: Path):
|
||||
"""Task-local override of the repo-wide ``isolate_settings_home`` fixture.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,16 @@ from tests.tasks.task_helpers import (
|
|||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_docket_memory_server():
|
||||
"""Reset the shared memory:// Docket server between tests for isolation."""
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
yield
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def child_server() -> FastMCP:
|
||||
mcp = FastMCP("child-server")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ client opts the tasks extension in for the request.
|
|||
"""
|
||||
|
||||
import pytest
|
||||
from docket import Docket
|
||||
from fastmcp_tasks.models import CreateTaskResult
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -23,6 +24,16 @@ from tests.tasks.task_helpers import (
|
|||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_docket_memory_server():
|
||||
"""Force a fresh memory:// Docket server bound to each test's event loop."""
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
yield
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
|
||||
|
||||
async def call_tool_with_optin(server: FastMCP, name: str, arguments: dict):
|
||||
"""Run a `tools/call` with the tasks opt-in bound into the request context."""
|
||||
with auth_scope(None), _opted_in_request(name, arguments, None):
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from uuid import UUID
|
|||
|
||||
import mcp_types
|
||||
import pytest
|
||||
from docket import Docket
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
|
@ -30,6 +31,16 @@ from tests.tasks.task_helpers import (
|
|||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_docket_memory_server():
|
||||
"""Force a fresh memory:// Docket server bound to each test's event loop."""
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
yield
|
||||
if hasattr(Docket, "_memory_server"):
|
||||
delattr(Docket, "_memory_server")
|
||||
|
||||
|
||||
def _sync_result_to_wire(result: ToolResult) -> dict[str, Any]:
|
||||
"""Serialize a synchronous ToolResult into the inlined task wire shape."""
|
||||
mcp_result = result.to_mcp_result()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue