Skip to content

Commit cad9ede

Browse files
committed
test: abandoned augmented tool call leaves no task record
When a client abandons an augmented tools/call while the tool body is still running, the cancellation propagates through the Tasks interceptor uncaught, so no task record is written to the store. Pin that, and that the same connection's later augmented calls still complete normally.
1 parent a2a717e commit cad9ede

1 file changed

Lines changed: 54 additions & 0 deletions

File tree

tests/server/test_tasks.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,60 @@ def rejecting() -> str:
638638
assert recording.puts == []
639639

640640

641+
async def test_abandoned_augmented_call_leaves_no_task_record() -> None:
642+
"""SDK-defined: a client abandoning an augmented `tools/call` cancels the tool body
643+
through the Tasks interceptor, which lets cancellation propagate -- no task record
644+
is written (nothing unfetchable sits in the store until TTL), and the same
645+
connection's later augmented calls still complete normally."""
646+
handler_started = anyio.Event()
647+
handler_cancelled = anyio.Event()
648+
recording = _RecordingStore()
649+
tasks = Tasks(clock=lambda: _FIXED_NOW, store=recording)
650+
mcp = MCPServer("demo", extensions=[tasks])
651+
652+
@mcp.tool(structured_output=False)
653+
async def block() -> str:
654+
handler_started.set()
655+
try:
656+
await anyio.Event().wait() # parked until the client's abandonment cancels it
657+
except anyio.get_cancelled_exc_class():
658+
handler_cancelled.set()
659+
raise
660+
raise NotImplementedError # unreachable: the wait only ends by cancellation
661+
662+
@mcp.tool(structured_output=False)
663+
def echo(text: str) -> str:
664+
return text
665+
666+
async with Client(mcp, extensions=[TasksExtension()]) as client:
667+
abandon = anyio.CancelScope()
668+
669+
async def call_and_abandon() -> None:
670+
with abandon:
671+
await _send_raw(
672+
client, types.CallToolRequest(params=types.CallToolRequestParams(name="block", arguments={}))
673+
)
674+
raise NotImplementedError # unreachable: the abandoned call never resolves
675+
676+
async with anyio.create_task_group() as tg:
677+
tg.start_soon(call_and_abandon)
678+
with anyio.fail_after(5):
679+
await handler_started.wait()
680+
abandon.cancel()
681+
with anyio.fail_after(5):
682+
await handler_cancelled.wait()
683+
684+
# Let the interceptor's unwind finish before inspecting the store.
685+
await anyio.wait_all_tasks_blocked()
686+
assert recording.puts == []
687+
after = await _augmented_call(client)
688+
689+
assert after["resultType"] == "task"
690+
assert after["status"] == "completed"
691+
assert len(recording.puts) == 1
692+
assert recording.puts[0].task.status == "completed"
693+
694+
641695
async def test_get_task_result_parses_completed_and_failed_wire_shapes() -> None:
642696
"""SDK-defined: `GetTaskResult` is the lenient client-side parse model for
643697
`tasks/get` -- it parses the real wire dict of both terminal shapes, carrying

0 commit comments

Comments
 (0)