Skip to content

Commit e1ccefe

Browse files
jfallowsclaude
andauthored
Add test for elicitation challenge after tool result (#1842)
* fix(binding-mcp): fail loud on elicitation challenge after JSON result When an mcp server is composed in front of an arbitrary mcp-speaking binding, the reply-side frame arrival order is not guaranteed. Once the POST response has committed to application/json, a subsequent elicitation CHALLENGE (which requires text/event-stream) is an illegal logical-state transition that cannot be honored — the content-type is already on the wire and cannot be upgraded. Detect this in McpRequestStream.onAppChallenge via the existing state (replyOpening(server.state) && !sseUpgrade) and tear the request down (app RESET + net ABORT) instead of emitting a malformed SSE event into a JSON response. The FLUSH-after-commit case already failed loud. Adds the tools.call.elicit.after.result scenario with McpServerIT, NetworkIT and ApplicationIT coverage. * test(binding-mcp): use server.timeout.yaml for shouldCallToolElicitCompleted shouldCallToolElicitCompleted exercises the URL-elicitation hold-and-resume flow, which requires options.timeout > 0. The test was annotated @configuration("server.yaml") (which has no timeout option), so session.requestTimeout resolved to 0 and the elicitation was rejected with -32042 URLElicitationRequiredError instead of streaming over text/event-stream — failing the test (and turning develop CI red on this test). Restore @configuration("server.timeout.yaml") so the elicitation hold path is exercised as intended. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 837336b commit e1ccefe

8 files changed

Lines changed: 441 additions & 2 deletions

File tree

runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpServerFactory.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4570,7 +4570,11 @@ private void onAppChallenge(
45704570
challengeEx = mcpChallengeExRO.tryWrap(extension.buffer(), extension.offset(), extension.limit());
45714571
}
45724572

4573-
if (challengeEx != null && challengeEx.kind() == McpChallengeExFW.KIND_ELICIT_CREATE)
4573+
if (sse == null && McpState.replyOpening(server.state) && !server.sseUpgrade)
4574+
{
4575+
cleanupApp(traceId, authorization);
4576+
}
4577+
else if (challengeEx != null && challengeEx.kind() == McpChallengeExFW.KIND_ELICIT_CREATE)
45744578
{
45754579
onAppChallengeElicitCreate(traceId, authorization, challengeEx.elicitCreate());
45764580
}

runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpServerIT.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ public void shouldCallToolWithUpstreamResumableFlush() throws Exception
315315
}
316316

317317
@Test
318-
@Configuration("server.yaml")
318+
@Configuration("server.timeout.yaml")
319319
@Specification({
320320
"${net}/tools.call.elicit.completed/client",
321321
"${app}/tools.call.elicit.completed/server"})
@@ -324,6 +324,16 @@ public void shouldCallToolElicitCompleted() throws Exception
324324
k3po.finish();
325325
}
326326

327+
@Test
328+
@Configuration("server.timeout.yaml")
329+
@Specification({
330+
"${net}/tools.call.elicit.after.result/client",
331+
"${app}/tools.call.elicit.after.result/server"})
332+
public void shouldCallToolElicitAfterResult() throws Exception
333+
{
334+
k3po.finish();
335+
}
336+
327337
@Test
328338
@Configuration("server.timeout.yaml")
329339
@Specification({
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#
2+
# Copyright 2021-2024 Aklivity Inc
3+
#
4+
# Licensed under the Aklivity Community License (the "License"); you may not use
5+
# this file except in compliance with the License. You may obtain a copy of the
6+
# License at
7+
#
8+
# https://www.aklivity.io/aklivity-community-license/
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
# WARRANTIES OF ANY KIND, either express or implied. See the License for the
13+
# specific language governing permissions and limitations under the License.
14+
#
15+
16+
connect "zilla://streams/app0"
17+
option zilla:window 8192
18+
option zilla:transmission "half-duplex"
19+
20+
write zilla:begin.ext ${mcp:beginEx()
21+
.typeId(zilla:id("mcp"))
22+
.lifecycle()
23+
.build()
24+
.build()}
25+
26+
connected
27+
28+
read zilla:begin.ext ${mcp:matchBeginEx()
29+
.typeId(zilla:id("mcp"))
30+
.lifecycle()
31+
.sessionId("session-1")
32+
.build()
33+
.build()}
34+
35+
read notify LIFECYCLE_INITIALIZED
36+
37+
connect await LIFECYCLE_INITIALIZED
38+
"zilla://streams/app0"
39+
option zilla:window 8192
40+
option zilla:transmission "half-duplex"
41+
42+
write zilla:begin.ext ${mcp:beginEx()
43+
.typeId(zilla:id("mcp"))
44+
.toolsCall()
45+
.sessionId("session-1")
46+
.name("get_weather")
47+
.contentLength(59)
48+
.timeout(30000)
49+
.build()
50+
.build()}
51+
52+
connected
53+
54+
write '{'
55+
'"name":"get_weather",'
56+
'"arguments":'
57+
'{'
58+
'"location": "New York"'
59+
'}'
60+
'}'
61+
62+
read '{'
63+
'"content":'
64+
'['
65+
'{'
66+
'"type": "text",'
67+
'"text": "Current weather in New York:\\nTemperature: 72°F\\nConditions: Partly cloudy"'
68+
'}'
69+
'],'
70+
'"isError": false'
71+
'}'
72+
73+
read notify RESULT_DELIVERED
74+
75+
write advised zilla:challenge ${mcp:matchChallengeEx()
76+
.typeId(zilla:id("mcp"))
77+
.elicitCreate()
78+
.id("1")
79+
.url("https://server.example.com/authorize?state=7f3a9b1c&redirect_uri=%s".formatted(http:encodeQuery("https://replace.me/callback")))
80+
.build()
81+
.build()}
82+
83+
read abort
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#
2+
# Copyright 2021-2024 Aklivity Inc
3+
#
4+
# Licensed under the Aklivity Community License (the "License"); you may not use
5+
# this file except in compliance with the License. You may obtain a copy of the
6+
# License at
7+
#
8+
# https://www.aklivity.io/aklivity-community-license/
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
# WARRANTIES OF ANY KIND, either express or implied. See the License for the
13+
# specific language governing permissions and limitations under the License.
14+
#
15+
16+
property serverAddress "zilla://streams/app0"
17+
18+
accept ${serverAddress}
19+
option zilla:window 8192
20+
option zilla:transmission "half-duplex"
21+
22+
accepted
23+
24+
read zilla:begin.ext ${mcp:matchBeginEx()
25+
.typeId(zilla:id("mcp"))
26+
.lifecycle()
27+
.build()
28+
.build()}
29+
30+
connected
31+
32+
write zilla:begin.ext ${mcp:beginEx()
33+
.typeId(zilla:id("mcp"))
34+
.lifecycle()
35+
.sessionId("session-1")
36+
.build()
37+
.build()}
38+
write flush
39+
40+
accepted
41+
42+
read zilla:begin.ext ${mcp:matchBeginEx()
43+
.typeId(zilla:id("mcp"))
44+
.toolsCall()
45+
.sessionId("session-1")
46+
.name("get_weather")
47+
.contentLength(59)
48+
.build()
49+
.build()}
50+
51+
connected
52+
53+
read '{'
54+
'"name":"get_weather",'
55+
'"arguments":'
56+
'{'
57+
'"location": "New York"'
58+
'}'
59+
'}'
60+
61+
write flush
62+
63+
# result delivered first — commits the response to application/json
64+
write '{'
65+
'"content":'
66+
'['
67+
'{'
68+
'"type": "text",'
69+
'"text": "Current weather in New York:\\nTemperature: 72°F\\nConditions: Partly cloudy"'
70+
'}'
71+
'],'
72+
'"isError": false'
73+
'}'
74+
write flush
75+
76+
read await RESULT_DELIVERED
77+
78+
# illegal: elicitation challenge after the result has already been returned
79+
read advise zilla:challenge ${mcp:challengeEx()
80+
.typeId(zilla:id("mcp"))
81+
.elicitCreate()
82+
.id("1")
83+
.url("https://server.example.com/authorize?state=7f3a9b1c&redirect_uri=%s".formatted(http:encodeQuery("https://replace.me/callback")))
84+
.build()
85+
.build()}
86+
87+
write aborted
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#
2+
# Copyright 2021-2024 Aklivity Inc
3+
#
4+
# Licensed under the Aklivity Community License (the "License"); you may not use
5+
# this file except in compliance with the License. You may obtain a copy of the
6+
# License at
7+
#
8+
# https://www.aklivity.io/aklivity-community-license/
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
# WARRANTIES OF ANY KIND, either express or implied. See the License for the
13+
# specific language governing permissions and limitations under the License.
14+
#
15+
16+
connect "zilla://streams/net0"
17+
option zilla:window 8192
18+
option zilla:transmission "half-duplex"
19+
20+
write zilla:begin.ext ${http:beginEx()
21+
.typeId(zilla:id("http"))
22+
.header(":method", "POST")
23+
.header(":scheme", "http")
24+
.header(":authority", "localhost:8080")
25+
.header(":path", "/mcp")
26+
.header("content-type", "application/json")
27+
.header("accept", "application/json, text/event-stream")
28+
.header("mcp-protocol-version", "2025-11-25")
29+
.build()}
30+
31+
connected
32+
33+
write '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"elicitation":{"url":{}}},"clientInfo":{"name":"test","version":"1.0"}}}'
34+
write close
35+
36+
read zilla:begin.ext ${http:matchBeginEx()
37+
.typeId(zilla:id("http"))
38+
.header(":status", "200")
39+
.header("content-type", "application/json")
40+
.header("mcp-session-id", "transport-1")
41+
.build()}
42+
43+
read '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"prompts":{"listChanged":true},"resources":{"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"zilla","version":"1.0"}}}'
44+
read closed
45+
read notify LIFECYCLE_INITIALIZING
46+
47+
connect await LIFECYCLE_INITIALIZING
48+
"zilla://streams/net0"
49+
option zilla:window 8192
50+
option zilla:transmission "half-duplex"
51+
52+
write zilla:begin.ext ${http:beginEx()
53+
.typeId(zilla:id("http"))
54+
.header(":method", "POST")
55+
.header(":scheme", "http")
56+
.header(":authority", "localhost:8080")
57+
.header(":path", "/mcp")
58+
.header("content-type", "application/json")
59+
.header("accept", "application/json, text/event-stream")
60+
.header("mcp-protocol-version", "2025-11-25")
61+
.header("mcp-session-id", "transport-1")
62+
.build()}
63+
64+
connected
65+
66+
write '{"jsonrpc":"2.0","method":"notifications/initialized"}'
67+
write close
68+
69+
read closed
70+
read notify LIFECYCLE_INITIALIZED
71+
72+
connect await LIFECYCLE_INITIALIZED
73+
"zilla://streams/net0"
74+
option zilla:window 8192
75+
option zilla:transmission "half-duplex"
76+
77+
write zilla:begin.ext ${http:beginEx()
78+
.typeId(zilla:id("http"))
79+
.header(":method", "POST")
80+
.header(":scheme", "http")
81+
.header(":authority", "localhost:8080")
82+
.header(":path", "/mcp")
83+
.header("content-type", "application/json")
84+
.header("accept", "application/json, text/event-stream")
85+
.header("mcp-protocol-version", "2025-11-25")
86+
.header("mcp-session-id", "transport-1")
87+
.header("content-length", "115")
88+
.build()}
89+
90+
connected
91+
92+
write '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_weather","arguments":{"location": "New York"}}}'
93+
94+
write close
95+
96+
read zilla:begin.ext ${http:matchBeginEx()
97+
.typeId(zilla:id("http"))
98+
.header(":status", "200")
99+
.header("content-type", "application/json")
100+
.build()}
101+
102+
read '{'
103+
'"jsonrpc":"2.0",'
104+
'"id":2,'
105+
'"result":'
106+
'{'
107+
'"content":'
108+
'['
109+
'{'
110+
'"type": "text",'
111+
'"text": "Current weather in New York:\\nTemperature: 72°F\\nConditions: Partly cloudy"'
112+
'}'
113+
'],'
114+
'"isError": false'
115+
'}'
116+
117+
read notify RESULT_DELIVERED
118+
119+
read aborted

0 commit comments

Comments
 (0)