Repository navigation
fix(mcp-server): resolve SSE transports by the canonical server path (#8045) - #8084
Conversation
Aias00
left a comment
There was a problem hiding this comment.
Review (Aias00)
Verdict: REQUEST CHANGES. The production fix is correct and I would merge it as-is — but the test edit silently disables an existing test, and that has to be fixed before this lands.
Blocker: testStreamableHttpNotificationReturnsEmptyBodyViaPluginPath no longer runs
In McpServerPluginTest, the new @Test was inserted above the existing method instead of above the new one, so the annotation now belongs to testCustomMessageEndpointKeepsSseSessionOnTheSameServer and the pre-existing testStreamableHttpNotificationReturnsEmptyBodyViaPluginPath is left as a plain void method:
@Test
void testCustomMessageEndpointKeepsSseSessionOnTheSameServer() {
...
}
void testStreamableHttpNotificationReturnsEmptyBodyViaPluginPath() { // <- no @Test any moreThe javadoc for the streamable-http test is also stranded: it now sits directly above the #8045 javadoc, so the two blocks stack on top of the new test.
This is invisible in CI because the total is still 12 either way. Confirmed on the PR head (307adda7): the surefire report lists testCustomMessageEndpointKeepsSseSessionOnTheSameServer but no testStreamableHttpNotificationReturnsEmptyBodyViaPluginPath, while master runs the latter. So the PR trades one regression guard for another and the check count does not move.
Fix (verified locally on top of 307adda7):
/**
* #8045: with a custom message endpoint, the SSE connect and the subsequent
* JSON-RPC POST must share the same server/session, and no extra shared
* server may be created for the endpoint path.
*/
@Test
void testCustomMessageEndpointKeepsSseSessionOnTheSameServer() { ... }
/**
* A JSON-RPC notification routed through the real plugin path must be
* acknowledged with HTTP 202 and an empty body, not a serialized null
* response body.
*/
@Test
void testStreamableHttpNotificationReturnsEmptyBodyViaPluginPath() { ... }With that, mvn -pl shenyu-plugin/shenyu-plugin-mcp-server -am test -Dtest=McpServerPluginTest runs 13 tests, 0 failures (12 + the new one), so both guards are live.
The production change itself is right
- Root cause confirmed:
ShenyuMcpServerManager.normalizeServerPathonly strips the hard-coded/message,/sseand/streamablehttpsuffixes (lines 516-518), so a custom endpoint such as/rpcsurvives normalization. A POST to/mcp/rpctherefore used to key the lookup on/mcp/rpc, create a second shared server with an empty session map, and answer404for the SSE session established under/mcp. Issue #8045 is exactly that. - Keying on
server.getPath()is the right identity: it is stable across the SSE connect and the message POST, both of which flow through this samehandleSseRequestcall site, and it still normalizes identically for the common cases (/mcp/**→/mcp,/mcp/sse→/mcp), so no existing deployment changes bucket. StringUtils.isNotBlank(...) ? server.getPath() : uriis a sensible fallback for servers configured without a path.
Test strength (once the annotation is restored)
I reverted only the production hunk and re-ran the module: testCustomMessageEndpointKeepsSseSessionOnTheSameServer fails with
AssertionFailedError: no extra shared server may be created under the message endpoint path
==> expected: <false> but was: <true>
so the new test genuinely pins the fix rather than passing vacuously. Good.
Nits (non-blocking)
assertNotEquals(HttpStatus.NOT_FOUND, messageExchange.getResponse().getStatusCode())is the weaker of the two assertions: on the success path the status isnullandassertNotEqualspasses fornulltoo, so a request that produced no response at all would still satisfy it. ThehasMcpServerassertion is doing the real work; consider either dropping this one or tightening it toassertNull(...)/ an explicit "session handle was invoked" check, so the comment and the assertion say the same thing.- Import placement is not alphabetical in either file —
org.apache.commons.lang3.StringUtilslands between twoorg.apache.shenyu.common.*imports inMcpServerPlugin, and in the testorg.springframework.web.reactive.function.server.ServerRequest/ShenyuSseServerTransportProvider/reactor.core.Disposableare appended after the existing block.checkstyle:checkpasses (noImportOrderrule), so this is cosmetic only. org.awaitility.Awaitility.await()is used fully qualified whileDisposable,Objects,Durationare imported. Prefer a static import or a normal import for consistency.awaitilitycomes in via the rootpom.xml<dependencies>, so no module-level declaration is needed.commons-lang3is not declared inshenyu-plugin-mcp-server/pom.xmleither, but three other classes in this module already import it, so this PR is not making that situation worse — worth a separate cleanup, not a blocker here.
CI
All checks green on 307adda7: ci / build / build (shenyu-integrated-test-http) / build (shenyu-integrated-test-k8s-gateway-api-http) / CodeQL Analyze (java) / pr_build / rat_check / check-license-header / registry_dependencies all pass. Green CI is exactly why the missing @Test slipped through — it changes no counts.
|
Fixed — you were right, and the annotation misplacement was entirely my slip: the streamable-http test had lost its The javadoc blocks are back with their own tests, and Also took the nits:
Thanks for verifying the fix reverts to a genuine failure — the new test does pin the behavior. |
Aias00
left a comment
There was a problem hiding this comment.
The displaced @Test is back: testStreamableHttpNotificationReturnsEmptyBodyViaPluginPath carries its annotation again at line 307, so that case runs once more. That was my only blocker.
On the fix itself — keying the transport by the selector's canonical path instead of the request URI is the right identity. normalizeServerPath cannot strip a custom endpoint such as /rpc, so getOrCreateMcpServerTransport(uri, ...) was creating a second provider for the same logical server, and its session map had never seen the SSE session. Using server.getPath() also makes the lookup agree with the key that McpServerPluginDataHandler uses when it registers and removes a server, which is what makes #8086's teardown correct as well. The isNotBlank fallback to uri keeps the old behaviour for a server without a configured path.
The new test is the right shape too: it drives a real SSE request, reads the endpoint out of the stream and then posts to it, rather than asserting on the manager call. That is the only way this regression would have been caught.
Fixes #8045.
Summary
With a custom message endpoint (e.g.
/rpc), the SSE connect creates the transport under the canonical server path and advertises/rpc?sessionId=...— but the subsequent POST of that URI was looked up by its full request URI.normalizeServerPathonly strips/message,/sseand/streamablehttp, so/mcp/rpccreated a second transport whose session map cannot know the SSE session: the client gets404 Session not found, and an extra shared server stays registered.Modifications
handleSseRequestresolves the transport by the cached selector's canonical server path; the message endpoint is used only to decide SSE-vs-message dispatch. A blank server path falls back to the request URI.Verifying this change
/mcpwith endpoint/rpc, thenPOST /mcp/rpc?sessionId=...— no shared server is created under/mcp/rpcand the request is not answered404. Fails on the previous code.