Skip to content

Commit ee474ec

Browse files
damianmomotgooglecopybara-github
authored andcommitted
fix(mcp): honor custom URL sub-paths in StreamableHttpServerParameters
DefaultMcpTransportBuilder now splits the URL into a base URI and endpoint path so custom sub-paths like "/mcp/stream" are preserved. URLs without a path keep the transport's default "/mcp" endpoint. PiperOrigin-RevId: 921352444
1 parent d3e7f31 commit ee474ec

2 files changed

Lines changed: 327 additions & 9 deletions

File tree

core/src/main/java/com/google/adk/tools/mcp/DefaultMcpTransportBuilder.java

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.google.adk.tools.mcp;
22

3+
import static com.google.common.base.Strings.isNullOrEmpty;
4+
35
import com.google.common.collect.ImmutableMap;
46
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
57
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
@@ -8,6 +10,8 @@
810
import io.modelcontextprotocol.json.McpJsonDefaults;
911
import io.modelcontextprotocol.json.McpJsonMapper;
1012
import io.modelcontextprotocol.spec.McpClientTransport;
13+
import java.net.URI;
14+
import java.net.URISyntaxException;
1115
import java.util.Collection;
1216
import java.util.Optional;
1317
import reactor.core.publisher.Mono;
@@ -44,20 +48,60 @@ public McpClientTransport build(Object connectionParams) {
4448
.orElse(""))))
4549
.build();
4650
} else if (connectionParams instanceof StreamableHttpServerParameters streamableParams) {
47-
return HttpClientStreamableHttpTransport.builder(streamableParams.url())
48-
.connectTimeout(streamableParams.timeout())
49-
.jsonMapper(jsonMapper)
50-
.asyncHttpRequestCustomizer(
51-
(builder, method, uri, body, context) -> {
52-
streamableParams.headers().forEach((key, value) -> builder.header(key, value));
53-
return Mono.just(builder);
54-
})
55-
.build();
51+
// Split the URL so the transport's URI.resolve does not drop a custom path (b/513186321).
52+
SplitUri split = splitBaseAndEndpoint(streamableParams.url());
53+
HttpClientStreamableHttpTransport.Builder builder =
54+
HttpClientStreamableHttpTransport.builder(split.baseUri())
55+
.connectTimeout(streamableParams.timeout())
56+
.jsonMapper(jsonMapper)
57+
.asyncHttpRequestCustomizer(
58+
(requestBuilder, method, uri, body, context) -> {
59+
streamableParams
60+
.headers()
61+
.forEach((key, value) -> requestBuilder.header(key, value));
62+
return Mono.just(requestBuilder);
63+
});
64+
if (split.endpoint() != null) {
65+
builder.endpoint(split.endpoint());
66+
}
67+
return builder.build();
5668
} else {
5769
throw new IllegalArgumentException(
5870
"DefaultMcpTransportBuilder supports only ServerParameters, SseServerParameters, or"
5971
+ " StreamableHttpServerParameters, but got "
6072
+ connectionParams.getClass().getName());
6173
}
6274
}
75+
76+
/**
77+
* Splits the URL into a base URI (scheme + authority) and endpoint (path + query + fragment).
78+
* Returns a null endpoint when the URL has no meaningful path or cannot be split, so the
79+
* transport falls back to its default endpoint.
80+
*/
81+
private static SplitUri splitBaseAndEndpoint(String url) {
82+
URI uri;
83+
try {
84+
uri = new URI(url);
85+
} catch (URISyntaxException e) {
86+
return new SplitUri(url, null);
87+
}
88+
if (uri.getScheme() == null || uri.getAuthority() == null) {
89+
return new SplitUri(url, null);
90+
}
91+
String path = uri.getRawPath();
92+
if (isNullOrEmpty(path) || path.equals("/")) {
93+
return new SplitUri(url, null);
94+
}
95+
String baseUri = uri.getScheme() + "://" + uri.getAuthority();
96+
StringBuilder endpoint = new StringBuilder(path);
97+
if (uri.getRawQuery() != null) {
98+
endpoint.append('?').append(uri.getRawQuery());
99+
}
100+
if (uri.getRawFragment() != null) {
101+
endpoint.append('#').append(uri.getRawFragment());
102+
}
103+
return new SplitUri(baseUri, endpoint.toString());
104+
}
105+
106+
private record SplitUri(String baseUri, String endpoint) {}
63107
}
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
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,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.tools.mcp;
18+
19+
import static com.google.common.truth.Truth.assertThat;
20+
import static org.junit.Assert.assertThrows;
21+
22+
import com.google.common.collect.ImmutableMap;
23+
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
24+
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
25+
import io.modelcontextprotocol.client.transport.ServerParameters;
26+
import io.modelcontextprotocol.client.transport.StdioClientTransport;
27+
import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer;
28+
import io.modelcontextprotocol.common.McpTransportContext;
29+
import io.modelcontextprotocol.spec.McpClientTransport;
30+
import java.lang.reflect.Field;
31+
import java.net.URI;
32+
import java.net.http.HttpRequest;
33+
import java.util.HashMap;
34+
import java.util.Map;
35+
import org.junit.Test;
36+
import org.junit.runner.RunWith;
37+
import org.junit.runners.JUnit4;
38+
import reactor.core.publisher.Mono;
39+
40+
/** Unit tests for {@link DefaultMcpTransportBuilder}. */
41+
@RunWith(JUnit4.class)
42+
public final class DefaultMcpTransportBuilderTest {
43+
44+
private final DefaultMcpTransportBuilder transportBuilder = new DefaultMcpTransportBuilder();
45+
46+
@Test
47+
public void build_withServerParameters_returnsStdioTransport() {
48+
ServerParameters params = ServerParameters.builder("test-command").build();
49+
50+
McpClientTransport transport = transportBuilder.build(params);
51+
52+
assertThat(transport).isInstanceOf(StdioClientTransport.class);
53+
}
54+
55+
@Test
56+
public void build_withSseServerParameters_returnsSseTransport() {
57+
SseServerParameters params = SseServerParameters.builder().url("http://localhost:1234").build();
58+
59+
McpClientTransport transport = transportBuilder.build(params);
60+
61+
assertThat(transport).isInstanceOf(HttpClientSseClientTransport.class);
62+
}
63+
64+
@Test
65+
public void build_withStreamableHttpServerParameters_returnsStreamableHttpTransport() {
66+
StreamableHttpServerParameters params =
67+
StreamableHttpServerParameters.builder().url("http://localhost:1234").build();
68+
69+
McpClientTransport transport = transportBuilder.build(params);
70+
71+
assertThat(transport).isInstanceOf(HttpClientStreamableHttpTransport.class);
72+
}
73+
74+
@Test
75+
public void build_withUnknownConnectionParams_throwsIllegalArgumentException() {
76+
Object unknownParams = new Object();
77+
78+
IllegalArgumentException ex =
79+
assertThrows(IllegalArgumentException.class, () -> transportBuilder.build(unknownParams));
80+
81+
assertThat(ex).hasMessageThat().contains("DefaultMcpTransportBuilder supports only");
82+
}
83+
84+
@Test
85+
public void build_withStreamableHttpUrlWithoutPath_usesDefaultEndpoint() throws Exception {
86+
StreamableHttpServerParameters params =
87+
StreamableHttpServerParameters.builder().url("http://localhost:8080").build();
88+
89+
HttpClientStreamableHttpTransport transport =
90+
(HttpClientStreamableHttpTransport) transportBuilder.build(params);
91+
92+
assertThat(getBaseUri(transport)).isEqualTo(URI.create("http://localhost:8080"));
93+
assertThat(getEndpoint(transport)).isEqualTo("/mcp");
94+
}
95+
96+
@Test
97+
public void build_withStreamableHttpUrlWithRootPath_usesDefaultEndpoint() throws Exception {
98+
StreamableHttpServerParameters params =
99+
StreamableHttpServerParameters.builder().url("http://localhost:8080/").build();
100+
101+
HttpClientStreamableHttpTransport transport =
102+
(HttpClientStreamableHttpTransport) transportBuilder.build(params);
103+
104+
assertThat(getEndpoint(transport)).isEqualTo("/mcp");
105+
}
106+
107+
@Test
108+
public void build_withStreamableHttpCustomEndpointPath_preservesCustomPath() throws Exception {
109+
// Regression test for google/adk-java#1196.
110+
StreamableHttpServerParameters params =
111+
StreamableHttpServerParameters.builder().url("http://localhost:8080/mcp/stream").build();
112+
113+
HttpClientStreamableHttpTransport transport =
114+
(HttpClientStreamableHttpTransport) transportBuilder.build(params);
115+
116+
assertThat(getBaseUri(transport)).isEqualTo(URI.create("http://localhost:8080"));
117+
assertThat(getEndpoint(transport)).isEqualTo("/mcp/stream");
118+
}
119+
120+
@Test
121+
public void build_withStreamableHttpCustomEndpoint_resolvesToFullUrl() throws Exception {
122+
StreamableHttpServerParameters params =
123+
StreamableHttpServerParameters.builder().url("http://localhost:8080/mcp/stream").build();
124+
125+
HttpClientStreamableHttpTransport transport =
126+
(HttpClientStreamableHttpTransport) transportBuilder.build(params);
127+
128+
URI resolved = getBaseUri(transport).resolve(getEndpoint(transport));
129+
assertThat(resolved).isEqualTo(URI.create("http://localhost:8080/mcp/stream"));
130+
}
131+
132+
@Test
133+
public void build_withStreamableHttpDeepCustomPath_preservesEntirePath() throws Exception {
134+
StreamableHttpServerParameters params =
135+
StreamableHttpServerParameters.builder()
136+
.url("https://example.com/api/v1/mcp/stream")
137+
.build();
138+
139+
HttpClientStreamableHttpTransport transport =
140+
(HttpClientStreamableHttpTransport) transportBuilder.build(params);
141+
142+
assertThat(getBaseUri(transport)).isEqualTo(URI.create("https://example.com"));
143+
assertThat(getEndpoint(transport)).isEqualTo("/api/v1/mcp/stream");
144+
}
145+
146+
@Test
147+
public void build_withStreamableHttpQueryAndFragment_preservesQueryAndFragment()
148+
throws Exception {
149+
StreamableHttpServerParameters params =
150+
StreamableHttpServerParameters.builder()
151+
.url("https://example.com/mcp/stream?token=abc#frag")
152+
.build();
153+
154+
HttpClientStreamableHttpTransport transport =
155+
(HttpClientStreamableHttpTransport) transportBuilder.build(params);
156+
157+
assertThat(getBaseUri(transport)).isEqualTo(URI.create("https://example.com"));
158+
assertThat(getEndpoint(transport)).isEqualTo("/mcp/stream?token=abc#frag");
159+
}
160+
161+
@Test
162+
public void build_withStreamableHttpEncodedPath_preservesEncoding() throws Exception {
163+
StreamableHttpServerParameters params =
164+
StreamableHttpServerParameters.builder()
165+
.url("https://example.com/mcp%20stream/path")
166+
.build();
167+
168+
HttpClientStreamableHttpTransport transport =
169+
(HttpClientStreamableHttpTransport) transportBuilder.build(params);
170+
171+
assertThat(getBaseUri(transport)).isEqualTo(URI.create("https://example.com"));
172+
assertThat(getEndpoint(transport)).isEqualTo("/mcp%20stream/path");
173+
}
174+
175+
@Test
176+
public void build_withStreamableHttpHeaders_customizerForwardsHeadersToRequest()
177+
throws Exception {
178+
StreamableHttpServerParameters params =
179+
StreamableHttpServerParameters.builder()
180+
.url("http://localhost:8080/mcp/stream")
181+
.headers(ImmutableMap.of("X-Custom", "value", "Authorization", "Bearer token"))
182+
.build();
183+
184+
HttpClientStreamableHttpTransport transport =
185+
(HttpClientStreamableHttpTransport) transportBuilder.build(params);
186+
McpAsyncHttpClientRequestCustomizer customizer = getCustomizer(transport);
187+
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create("http://x/"));
188+
189+
HttpRequest.Builder returned =
190+
Mono.from(
191+
customizer.customize(
192+
requestBuilder,
193+
"POST",
194+
URI.create("http://x/"),
195+
null,
196+
McpTransportContext.EMPTY))
197+
.block();
198+
199+
assertThat(returned).isSameInstanceAs(requestBuilder);
200+
Map<String, String> headers = collectHeaders(requestBuilder);
201+
assertThat(headers).containsEntry("X-Custom", "value");
202+
assertThat(headers).containsEntry("Authorization", "Bearer token");
203+
}
204+
205+
@Test
206+
public void build_withStreamableHttpEmptyHeaders_customizerIsNoOp() throws Exception {
207+
StreamableHttpServerParameters params =
208+
StreamableHttpServerParameters.builder()
209+
.url("http://localhost:8080/mcp/stream")
210+
.headers(ImmutableMap.of())
211+
.build();
212+
213+
HttpClientStreamableHttpTransport transport =
214+
(HttpClientStreamableHttpTransport) transportBuilder.build(params);
215+
McpAsyncHttpClientRequestCustomizer customizer = getCustomizer(transport);
216+
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create("http://x/"));
217+
218+
Mono.from(
219+
customizer.customize(
220+
requestBuilder, "POST", URI.create("http://x/"), null, McpTransportContext.EMPTY))
221+
.block();
222+
223+
assertThat(collectHeaders(requestBuilder)).isEmpty();
224+
}
225+
226+
@Test
227+
public void build_withStreamableHttpMalformedUrl_doesNotMaskUnderlyingError() {
228+
// Unparseable URL: split helper forwards it as-is so the transport surfaces its own error.
229+
StreamableHttpServerParameters params =
230+
StreamableHttpServerParameters.builder().url("http://example.com/path with space").build();
231+
232+
assertThrows(IllegalArgumentException.class, () -> transportBuilder.build(params));
233+
}
234+
235+
@Test
236+
public void build_withStreamableHttpSchemelessUrl_forwardsUnchangedAsBaseUri() throws Exception {
237+
// No scheme/authority: split helper forwards the URL as-is and keeps the default endpoint.
238+
StreamableHttpServerParameters params =
239+
StreamableHttpServerParameters.builder().url("relative/path").build();
240+
241+
HttpClientStreamableHttpTransport transport =
242+
(HttpClientStreamableHttpTransport) transportBuilder.build(params);
243+
244+
assertThat(getBaseUri(transport)).isEqualTo(URI.create("relative/path"));
245+
assertThat(getEndpoint(transport)).isEqualTo("/mcp");
246+
}
247+
248+
private static URI getBaseUri(HttpClientStreamableHttpTransport transport) throws Exception {
249+
Field field = HttpClientStreamableHttpTransport.class.getDeclaredField("baseUri");
250+
field.setAccessible(true);
251+
return (URI) field.get(transport);
252+
}
253+
254+
private static String getEndpoint(HttpClientStreamableHttpTransport transport) throws Exception {
255+
Field field = HttpClientStreamableHttpTransport.class.getDeclaredField("endpoint");
256+
field.setAccessible(true);
257+
return (String) field.get(transport);
258+
}
259+
260+
private static McpAsyncHttpClientRequestCustomizer getCustomizer(
261+
HttpClientStreamableHttpTransport transport) throws Exception {
262+
Field field = HttpClientStreamableHttpTransport.class.getDeclaredField("httpRequestCustomizer");
263+
field.setAccessible(true);
264+
return (McpAsyncHttpClientRequestCustomizer) field.get(transport);
265+
}
266+
267+
/** Reads back the headers set on a builder by building a throwaway request. */
268+
private static Map<String, String> collectHeaders(HttpRequest.Builder builder) {
269+
HttpRequest request = builder.GET().build();
270+
Map<String, String> result = new HashMap<>();
271+
request.headers().map().forEach((key, values) -> result.put(key, String.join(",", values)));
272+
return result;
273+
}
274+
}

0 commit comments

Comments
 (0)