Summary
The ModelContextProtocol server stores active requests per MCP session, but loses the session scope at the Server-to-Tool API boundary. Two clients may legally use the same JSON-RPC request ID in different sessions. If both invoke the same globally shared asynchronous tool, cancellation from one session can cancel the other session’s invocation because IModelContextProtocolTool::CancelAsync receives only the raw client RequestId.
What Type of Bug are you experiencing?
Editor
Steps to Reproduce
- Enable the experimental ModelContextProtocol plugin in Unreal Engine 5.8.2.
- Register a cancellable asynchronous tool whose action remains in progress for several seconds. The shipped FModelContextProtocolAsyncActionTool adapter demonstrates the affected behavior.
- Initialize two MCP clients and retain their different Mcp-Session-Id values.
- From Session A, call the tool with JSON-RPC id 7.
- Before A completes, call the same tool from Session B, also with JSON-RPC id 7.
- From Session B, send a notifications/cancelled notification with params.requestId set to 7 and include Session B’s Mcp-Session-Id header.
Expected Result
Only Session B’s tool invocation is cancelled. Session A continues normally.
Observed Result
The server finds Session B’s request in Session B’s ActiveRequests, but then calls the globally shared tool as CancelAsync(RequestId) with only the client JSON-RPC ID.
FModelContextProtocolAsyncActionTool searches its shared InProgressActions array and cancels the first action whose stored JSON-RPC ID equals 7. If A was started before B, this selects Session A’s action. Session B’s actual action continues running while its server context has already been removed, so its eventual result is discarded.
Once the two in-flight actions have the same ID and insertion order, this result is deterministic rather than a data race.
Affects Versions
5.8
Platform(s)
Windows
Additional Notes
Relevant UE 5.8.2 source:
- ModelContextProtocolSession.h: FModelContextProtocolSession::ActiveRequests is per session, but FModelContextProtocolToolRequestId contains only the raw JSON value.
- IModelContextProtocolTool.h: RunAsync and CancelAsync receive only FModelContextProtocolToolRequestId.
- ModelContextProtocolServer.cpp: the cancellation handler finds the request in the correct Session and then calls Context->Tool->CancelAsync(ToolRequestId).
- ModelContextProtocolModule.cpp: tools are globally registered and FindTool returns the shared tool instance.
- ModelContextProtocolToolAsyncAction.cpp: CancelAsync walks the shared InProgressActions array, cancels the first matching raw request ID, and breaks.
Inspected UE 5.8.2 source commit: ff8421f2b8cb4feb76fff57965a1effc53a6eb7b.
The same API-level issue is present in the inspected UE6 source snapshot. UE6’s IModelContextProtocolTool comment explicitly says that RequestId is unique only within its session and a tool must not key global state on it alone, but RunAsync/CancelAsync still provide no SessionId or server-unique invocation identity. The per-session InvocationSerial and cancelled-ID tracking prevent late-result misdelivery and same-session reuse; they do not tell a shared tool which session’s action to cancel.
MCP permits request IDs to be reused by different sessions:
Although UModelContextProtocolToolAsyncAction is deprecated in favor of ToolsetDefinition, this remains a public IModelContextProtocolTool contract issue, and the shipped adapter provides a concrete wrong-cancellation path.
Suggested fix: assign a server-global opaque invocation identity and pass it to both RunAsync and CancelAsync, or use a composite identity containing SessionId, client RequestId, and preferably InvocationSerial. Update FModelContextProtocolAsyncActionTool to match that identity. Add a regression test with two initialized sessions, one shared tool instance, identical JSON-RPC IDs, overlapping async calls, and cancellation from only the second session.