Skip to content

Commit ba8ea28

Browse files
committed
Fix the baseUrl is configured with a trailing slash
Signed-off-by: lance <leehaut@gmail.com>
1 parent 5667caf commit ba8ea28

File tree

6 files changed

+448
-24
lines changed

6 files changed

+448
-24
lines changed

mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import io.modelcontextprotocol.spec.ProtocolVersions;
2727
import io.modelcontextprotocol.util.Assert;
2828
import io.modelcontextprotocol.util.KeepAliveScheduler;
29+
import io.modelcontextprotocol.util.UriBuilder;
2930
import jakarta.servlet.AsyncContext;
3031
import jakarta.servlet.ServletException;
3132
import jakarta.servlet.annotation.WebServlet;
@@ -87,6 +88,8 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
8788
/** Event type for endpoint information */
8889
public static final String ENDPOINT_EVENT_TYPE = "endpoint";
8990

91+
public static final String SESSION_ID = "sessionId";
92+
9093
public static final String DEFAULT_BASE_URL = "";
9194

9295
/** JSON mapper for serialization/deserialization */
@@ -243,7 +246,17 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response)
243246
this.sessions.put(sessionId, session);
244247

245248
// Send initial endpoint event
246-
this.sendEvent(writer, ENDPOINT_EVENT_TYPE, this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId);
249+
this.sendEvent(writer, ENDPOINT_EVENT_TYPE, buildEndpointUrl(sessionId));
250+
}
251+
252+
/**
253+
* Constructs the full message endpoint URL by combining the base URL, message path,
254+
* and the required session_id query parameter.
255+
* @param sessionId the unique session identifier
256+
* @return the fully qualified endpoint URL as a string
257+
*/
258+
private String buildEndpointUrl(String sessionId) {
259+
return UriBuilder.from(this.baseUrl).path(this.messageEndpoint).queryParam(SESSION_ID, sessionId).buildString();
247260
}
248261

249262
/**
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
/*
2+
* Copyright 2025 - 2025 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.util;
6+
7+
import java.net.URI;
8+
import java.net.URISyntaxException;
9+
import java.net.URLEncoder;
10+
import java.nio.charset.StandardCharsets;
11+
import java.util.ArrayList;
12+
import java.util.LinkedHashMap;
13+
import java.util.List;
14+
import java.util.Map;
15+
import java.util.StringJoiner;
16+
17+
/**
18+
* Building URL strings from a base URL, an additional path segment, and optional query
19+
* parameters.
20+
* <p>
21+
* Example usage:
22+
* </p>
23+
* <pre>{@code
24+
* String url = UriBuilder.build(
25+
* "https://api.example.com",
26+
* "/v1/messages",
27+
* Map.of("session_id", "abc123", "format", "json")
28+
* );
29+
* // Result: https://api.example.com/v1/messages?session_id=abc123&format=json
30+
* }</pre>
31+
*
32+
* @author lance
33+
*/
34+
public final class UriBuilder {
35+
36+
private final String scheme;
37+
38+
private final String host;
39+
40+
private final int port;
41+
42+
private final List<String> pathSegments;
43+
44+
private final Map<String, List<String>> queryParams;
45+
46+
private final String fragment;
47+
48+
private UriBuilder(Builder builder) {
49+
this.scheme = builder.scheme;
50+
this.host = builder.host;
51+
this.port = builder.port;
52+
this.pathSegments = List.copyOf(builder.pathSegments);
53+
this.queryParams = deepCopy(builder.queryParams);
54+
this.fragment = builder.fragment;
55+
}
56+
57+
/**
58+
* Parses a full URL string and returns a mutable {@link Builder} pre-filled with its
59+
* components.
60+
*/
61+
public static Builder from(String url) {
62+
if (url == null || url.isBlank()) {
63+
throw new IllegalArgumentException("URL must not be null or blank");
64+
}
65+
66+
URI uri;
67+
try {
68+
uri = new URI(url.trim());
69+
}
70+
catch (URISyntaxException e) {
71+
throw new IllegalArgumentException("Invalid URL: " + url, e);
72+
}
73+
74+
Builder builder = new Builder().scheme(uri.getScheme()).host(uri.getHost()).port(normalizePort(uri));
75+
76+
addPathSegments(builder, uri.getRawPath());
77+
addQuery(uri.getRawQuery(), builder);
78+
addFragment(builder, uri.getRawFragment());
79+
return builder;
80+
}
81+
82+
/**
83+
* Returns default port (80 for http, 443 for https) if not explicitly set
84+
*/
85+
private static int normalizePort(URI uri) {
86+
int port = uri.getPort();
87+
if (port != -1) {
88+
return port;
89+
}
90+
String scheme = uri.getScheme();
91+
return "https".equalsIgnoreCase(scheme) ? 443 : 80;
92+
}
93+
94+
/**
95+
* Splits raw path and adds non-empty segments (filters out "//")
96+
*/
97+
private static void addPathSegments(Builder builder, String rawPath) {
98+
if (rawPath == null || rawPath.isEmpty() || "/".equals(rawPath)) {
99+
return;
100+
}
101+
for (String segment : rawPath.split("/")) {
102+
if (!segment.isEmpty()) {
103+
builder.pathSegment(segment);
104+
}
105+
}
106+
}
107+
108+
/**
109+
* Parses raw query string into key → list of values
110+
*/
111+
private static void addQuery(String rawQuery, Builder builder) {
112+
if (rawQuery == null || rawQuery.isEmpty()) {
113+
return;
114+
}
115+
for (String pair : rawQuery.split("&")) {
116+
if (pair.isEmpty()) {
117+
continue;
118+
}
119+
120+
String[] kv = pair.split("=", 2);
121+
String key = kv[0];
122+
String value = kv.length > 1 ? kv[1] : null;
123+
124+
builder.queryParam(key, value);
125+
}
126+
}
127+
128+
/**
129+
* Sets fragment if present
130+
*/
131+
private static void addFragment(Builder builder, String rawFragment) {
132+
if (rawFragment != null && !rawFragment.isEmpty()) {
133+
builder.fragment(rawFragment);
134+
}
135+
}
136+
137+
@Override
138+
public String toString() {
139+
return buildString();
140+
}
141+
142+
/**
143+
* Builds the final URL string with proper encoding and formatting
144+
*/
145+
public String buildString() {
146+
StringBuilder sb = new StringBuilder();
147+
appendScheme(sb);
148+
appendAuthority(sb);
149+
appendPath(sb);
150+
appendQuery(sb);
151+
appendFragment(sb);
152+
return sb.toString();
153+
}
154+
155+
private void appendScheme(StringBuilder sb) {
156+
if (scheme != null) {
157+
sb.append(scheme).append("://");
158+
}
159+
}
160+
161+
private void appendAuthority(StringBuilder sb) {
162+
if (host == null) {
163+
return;
164+
}
165+
166+
sb.append(host);
167+
if (shouldAppendPort()) {
168+
sb.append(':').append(port);
169+
}
170+
}
171+
172+
/**
173+
* Omit port if it matches the default for the scheme
174+
*/
175+
private boolean shouldAppendPort() {
176+
if (port <= 0) {
177+
return false;
178+
}
179+
if ("http".equalsIgnoreCase(scheme) && port == 80) {
180+
return false;
181+
}
182+
return !"https".equalsIgnoreCase(scheme) || port != 443;
183+
}
184+
185+
private void appendPath(StringBuilder sb) {
186+
if (!pathSegments.isEmpty()) {
187+
for (String seg : pathSegments) {
188+
sb.append('/').append(encode(seg));
189+
}
190+
}
191+
}
192+
193+
/**
194+
* Appends URL query parameters to the given.
195+
*/
196+
private void appendQuery(StringBuilder sb) {
197+
if (queryParams.isEmpty()) {
198+
return;
199+
}
200+
201+
StringJoiner joiner = new StringJoiner("&");
202+
for (Map.Entry<String, List<String>> entry : queryParams.entrySet()) {
203+
String encodedKey = encode(entry.getKey());
204+
List<String> values = entry.getValue();
205+
206+
// key only (no value)
207+
if (values == null || values.isEmpty()) {
208+
joiner.add(encodedKey);
209+
continue;
210+
}
211+
212+
for (String value : values) {
213+
if (value == null) {
214+
joiner.add(encodedKey);
215+
}
216+
else {
217+
joiner.add(encodedKey + "=" + encode(value));
218+
}
219+
}
220+
}
221+
222+
sb.append('?').append(joiner);
223+
}
224+
225+
private void appendFragment(StringBuilder sb) {
226+
if (fragment != null) {
227+
sb.append('#').append(encode(fragment));
228+
}
229+
}
230+
231+
/**
232+
* Percent-encodes using UTF-8, keeps '/' unencoded in path
233+
*/
234+
private static String encode(String s) {
235+
if (s == null) {
236+
return "";
237+
}
238+
return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("\\+", "%20").replace("%2F", "/");
239+
}
240+
241+
/**
242+
* Deep copy of query parameter map (defensive)
243+
*/
244+
private static Map<String, List<String>> deepCopy(Map<String, List<String>> src) {
245+
Map<String, List<String>> copy = new LinkedHashMap<>();
246+
src.forEach((k, v) -> copy.put(k, new ArrayList<>(v)));
247+
return copy;
248+
}
249+
250+
/**
251+
* Mutable builder for constructing {@link UriBuilder} instances
252+
*/
253+
public static class Builder {
254+
255+
private String scheme;
256+
257+
private String host;
258+
259+
private int port = -1;
260+
261+
private final List<String> pathSegments = new ArrayList<>();
262+
263+
private final Map<String, List<String>> queryParams = new LinkedHashMap<>();
264+
265+
private String fragment;
266+
267+
private Builder() {
268+
}
269+
270+
public Builder scheme(String scheme) {
271+
this.scheme = scheme;
272+
return this;
273+
}
274+
275+
public Builder host(String host) {
276+
this.host = host == null ? null : host.toLowerCase();
277+
return this;
278+
}
279+
280+
public Builder port(int port) {
281+
this.port = port;
282+
return this;
283+
}
284+
285+
/**
286+
* Adds a single decoded path segment
287+
*/
288+
public void pathSegment(String segment) {
289+
if (segment != null && !segment.isEmpty()) {
290+
pathSegments.add(segment);
291+
}
292+
}
293+
294+
/**
295+
* Adds multiple segments from a path string (ignores empty parts)
296+
*/
297+
public Builder path(String path) {
298+
if (path != null) {
299+
for (String seg : path.split("/")) {
300+
if (!seg.isEmpty()) {
301+
pathSegments.add(seg);
302+
}
303+
}
304+
}
305+
return this;
306+
}
307+
308+
/**
309+
* Adds one or more values for a query parameter (null value = no "=")
310+
*/
311+
public Builder queryParam(String key, String... values) {
312+
if (key != null) {
313+
List<String> list = queryParams.computeIfAbsent(key, k -> new ArrayList<>());
314+
if (values != null) {
315+
for (String v : values) {
316+
if (v != null) {
317+
list.add(v);
318+
}
319+
}
320+
}
321+
else {
322+
list.add(null);
323+
}
324+
}
325+
326+
return this;
327+
}
328+
329+
public void fragment(String fragment) {
330+
this.fragment = fragment;
331+
}
332+
333+
public UriBuilder build() {
334+
return new UriBuilder(this);
335+
}
336+
337+
public String buildString() {
338+
return new UriBuilder(this).buildString();
339+
}
340+
341+
}
342+
343+
}

0 commit comments

Comments
 (0)