Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -818,13 +818,13 @@ private NotificationHandler asyncToolsChangeNotificationHandler(
* @see #readResource(McpSchema.Resource)
*/
public Mono<McpSchema.ListResourcesResult> listResources() {
return this.listResources(McpSchema.FIRST_PAGE)
.expand(result -> (result.nextCursor() != null) ? this.listResources(result.nextCursor()) : Mono.empty())
.reduce(new ArrayList<McpSchema.Resource>(), (accumulated, result) -> {
accumulated.addAll(result.resources());
return accumulated;
})
.map(all -> McpSchema.ListResourcesResult.builder(Collections.unmodifiableList(all)).build());
return this.listResources(McpSchema.FIRST_PAGE).expand(result -> {
String next = result.nextCursor();
return (next != null && !next.isEmpty()) ? this.listResources(next) : Mono.empty();
}).reduce(new ArrayList<McpSchema.Resource>(), (accumulated, result) -> {
accumulated.addAll(result.resources());
return accumulated;
}).map(all -> McpSchema.ListResourcesResult.builder(Collections.unmodifiableList(all)).build());
}

/**
Expand Down Expand Up @@ -904,14 +904,13 @@ public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.ReadResourceReq
* @see McpSchema.ListResourceTemplatesResult
*/
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates() {
return this.listResourceTemplates(McpSchema.FIRST_PAGE)
.expand(result -> (result.nextCursor() != null) ? this.listResourceTemplates(result.nextCursor())
: Mono.empty())
.reduce(new ArrayList<McpSchema.ResourceTemplate>(), (accumulated, result) -> {
accumulated.addAll(result.resourceTemplates());
return accumulated;
})
.map(all -> McpSchema.ListResourceTemplatesResult.builder(Collections.unmodifiableList(all)).build());
return this.listResourceTemplates(McpSchema.FIRST_PAGE).expand(result -> {
String next = result.nextCursor();
return (next != null && !next.isEmpty()) ? this.listResourceTemplates(next) : Mono.empty();
}).reduce(new ArrayList<McpSchema.ResourceTemplate>(), (accumulated, result) -> {
accumulated.addAll(result.resourceTemplates());
return accumulated;
}).map(all -> McpSchema.ListResourceTemplatesResult.builder(Collections.unmodifiableList(all)).build());
}

/**
Expand Down Expand Up @@ -1024,13 +1023,13 @@ private NotificationHandler asyncResourcesUpdatedNotificationHandler(
* @see #getPrompt(GetPromptRequest)
*/
public Mono<ListPromptsResult> listPrompts() {
return this.listPrompts(McpSchema.FIRST_PAGE)
.expand(result -> (result.nextCursor() != null) ? this.listPrompts(result.nextCursor()) : Mono.empty())
.reduce(new ArrayList<McpSchema.Prompt>(), (accumulated, result) -> {
accumulated.addAll(result.prompts());
return accumulated;
})
.map(all -> McpSchema.ListPromptsResult.builder(Collections.unmodifiableList(all)).build());
return this.listPrompts(McpSchema.FIRST_PAGE).expand(result -> {
String next = result.nextCursor();
return (next != null && !next.isEmpty()) ? this.listPrompts(next) : Mono.empty();
}).reduce(new ArrayList<McpSchema.Prompt>(), (accumulated, result) -> {
accumulated.addAll(result.prompts());
return accumulated;
}).map(all -> McpSchema.ListPromptsResult.builder(Collections.unmodifiableList(all)).build());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,29 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {

public static int BAD_REQUEST = 400;

/**
* Determines whether an SSE event should be treated as a "message" event carrying a
* JSON-RPC payload.
*
* <p>
* Per the <a href=
* "https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation">
* SSE specification (WHATWG HTML Living Standard §9.2.6)</a>, an event with no
* explicit {@code event:} field MUST be dispatched as a {@code message} event by
* default. This method applies that rule by treating {@code null} or empty event
* names as equivalent to {@link #MESSAGE_EVENT_TYPE}.
*
* <p>
* This alignment ensures interoperability with MCP servers that emit bare
* {@code data:} frames without an accompanying {@code event:} line, which are valid
* per the SSE spec.
* @param eventName the SSE event name, which may be {@code null} or empty
* @return {@code true} if the event should be parsed as a JSON-RPC message
*/
static boolean isMessageEvent(String eventName) {
return eventName == null || eventName.isEmpty() || MESSAGE_EVENT_TYPE.equals(eventName);
}

private final McpJsonMapper jsonMapper;

private final URI baseUri;
Expand Down Expand Up @@ -323,7 +346,7 @@ else if (statusCode == METHOD_NOT_ALLOWED) {
+ statusCode));
}
else if (statusCode >= 200 && statusCode < 300) {
if (MESSAGE_EVENT_TYPE.equals(sseResponseEvent.sseEvent().event())) {
if (isMessageEvent(sseResponseEvent.sseEvent().event())) {
String data = sseResponseEvent.sseEvent().data();
// Per 2025-11-25 spec (SEP-1699), servers may
// send SSE events
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class DefaultMcpTransportContext implements McpTransportContext {

DefaultMcpTransportContext(Map<String, Object> metadata) {
Assert.notNull(metadata, "The metadata cannot be null");
this.metadata = metadata;
this.metadata = Map.copyOf(metadata);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright 2024-2026 the original author or authors.
*/

package io.modelcontextprotocol.client.transport;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Unit tests for {@link HttpClientStreamableHttpTransport#isMessageEvent(String)}.
*
* <p>
* Verifies that SSE event classification follows the <a href=
* "https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation">
* WHATWG HTML Living Standard §9.2.6</a>: an event without an explicit {@code event:}
* field must be dispatched as a {@code message} event.
*
* @author jiajingda
* @see <a href="https://github.com/modelcontextprotocol/java-sdk/issues/885">#885</a>
*/
class HttpClientStreamableHttpTransportSseEventTypeTest {

@ParameterizedTest
@NullAndEmptySource
void shouldTreatNullOrEmptyEventAsMessage(String eventName) {
assertThat(HttpClientStreamableHttpTransport.isMessageEvent(eventName))
.as("SSE frame with null/empty event field must be treated as a 'message' event per SSE spec")
.isTrue();
}

@Test
void shouldTreatExplicitMessageEventAsMessage() {
assertThat(HttpClientStreamableHttpTransport.isMessageEvent("message"))
.as("Explicit 'message' event must be parsed as a JSON-RPC message")
.isTrue();
}

@ParameterizedTest
@ValueSource(strings = { "ping", "error", "notification", "MESSAGE", "Message", "custom-event" })
void shouldNotTreatOtherEventsAsMessage(String eventName) {
assertThat(HttpClientStreamableHttpTransport.isMessageEvent(eventName))
.as("Non-'message' SSE event '%s' must not be parsed as a JSON-RPC message", eventName)
.isFalse();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright 2026-2026 the original author or authors.
*/

package io.modelcontextprotocol.common;

import java.util.HashMap;
import java.util.Map;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Tests for {@link McpTransportContext#create(Map)}, which is documented to return an
* unmodifiable context.
*/
class McpTransportContextTests {

@Test
void createdContextShouldNotSeeLaterWritesToTheSourceMap() {
Map<String, Object> metadata = new HashMap<>();
metadata.put("tenant", "acme");

McpTransportContext context = McpTransportContext.create(metadata);
metadata.put("tenant", "other");

assertThat(context.get("tenant")).isEqualTo("acme");
}

@Test
void createdContextShouldNotSeeLaterAdditionsToTheSourceMap() {
Map<String, Object> metadata = new HashMap<>();
metadata.put("tenant", "acme");

McpTransportContext context = McpTransportContext.create(metadata);
metadata.put("added-after-the-fact", "surprise");

assertThat(context.get("added-after-the-fact")).isNull();
}

@Test
void createdContextShouldNotBeEmptiedByClearingTheSourceMap() {
Map<String, Object> metadata = new HashMap<>();
metadata.put("tenant", "acme");

McpTransportContext context = McpTransportContext.create(metadata);
metadata.clear();

assertThat(context.get("tenant")).isEqualTo("acme");
}

@Test
void createdContextShouldRemainUsableAsAMapKey() {
Map<String, Object> metadata = new HashMap<>();
metadata.put("tenant", "acme");
McpTransportContext context = McpTransportContext.create(metadata);

Map<McpTransportContext, String> byContext = new HashMap<>();
byContext.put(context, "value");
metadata.put("tenant", "other");

assertThat(byContext.get(context)).isEqualTo("value");
}

@Test
void twoContextsCreatedFromEqualMapsShouldStayEqual() {
Map<String, Object> first = new HashMap<>();
first.put("tenant", "acme");
Map<String, Object> second = new HashMap<>();
second.put("tenant", "acme");

McpTransportContext firstContext = McpTransportContext.create(first);
McpTransportContext secondContext = McpTransportContext.create(second);
assertThat(firstContext).isEqualTo(secondContext);

first.put("tenant", "other");

assertThat(firstContext).isEqualTo(secondContext);
}

@Test
void createdContextFromAnImmutableMapIsAlreadyCorrect() {
McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme"));

assertThat(context.get("tenant")).isEqualTo("acme");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -298,6 +299,42 @@ void testListPromptsWithCursorAndMeta() {

}

@Test
void listResourcesStopsOnEmptyNextCursor() {
var transport = new EmptyCursorTestMcpClientTransport(McpSchema.METHOD_RESOURCES_LIST);
McpAsyncClient client = McpClient.async(transport).build();

McpSchema.ListResourcesResult result = client.listResources().block();

assertThat(result).isNotNull();
assertThat(result.resources()).extracting(McpSchema.Resource::name).containsExactly("test.txt");
assertThat(transport.getRequestCount()).isEqualTo(1);
}

@Test
void listResourceTemplatesStopsOnEmptyNextCursor() {
var transport = new EmptyCursorTestMcpClientTransport(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST);
McpAsyncClient client = McpClient.async(transport).build();

McpSchema.ListResourceTemplatesResult result = client.listResourceTemplates().block();

assertThat(result).isNotNull();
assertThat(result.resourceTemplates()).extracting(McpSchema.ResourceTemplate::name).containsExactly("template");
assertThat(transport.getRequestCount()).isEqualTo(1);
}

@Test
void listPromptsStopsOnEmptyNextCursor() {
var transport = new EmptyCursorTestMcpClientTransport(McpSchema.METHOD_PROMPT_LIST);
McpAsyncClient client = McpClient.async(transport).build();

McpSchema.ListPromptsResult result = client.listPrompts().block();

assertThat(result).isNotNull();
assertThat(result.prompts()).extracting(McpSchema.Prompt::name).containsExactly("test-prompt");
assertThat(transport.getRequestCount()).isEqualTo(1);
}

static class TestMcpClientTransport implements McpClientTransport {

private Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler;
Expand Down Expand Up @@ -397,4 +434,90 @@ public McpSchema.PaginatedRequest getCapturedRequest() {

}

static class EmptyCursorTestMcpClientTransport implements McpClientTransport {

private final String listMethod;

private final AtomicInteger requestCount = new AtomicInteger();

private Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler;

EmptyCursorTestMcpClientTransport(String listMethod) {
this.listMethod = listMethod;
}

@Override
public Mono<Void> connect(Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler) {
this.handler = handler;
return Mono.empty();
}

@Override
public Mono<Void> closeGracefully() {
return Mono.empty();
}

@Override
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
if (!(message instanceof McpSchema.JSONRPCRequest request)) {
return Mono.empty();
}

McpSchema.JSONRPCResponse response;
if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder()
.prompts(false)
.resources(false, false)
.tools(false)
.build();

McpSchema.InitializeResult initResult = McpSchema.InitializeResult
.builder(ProtocolVersions.MCP_2024_11_05, caps, MOCK_SERVER_INFO)
.build();
response = McpSchema.JSONRPCResponse.result(request.id(), initResult);
}
else if (this.listMethod.equals(request.method())) {
this.requestCount.incrementAndGet();
response = McpSchema.JSONRPCResponse.result(request.id(), resultForMethod(request.method()));
}
else {
return Mono.empty();
}

return this.handler.apply(Mono.just(response)).then();
}

private Object resultForMethod(String method) {
if (McpSchema.METHOD_RESOURCES_LIST.equals(method)) {
McpSchema.Resource resource = McpSchema.Resource.builder("file:///test.txt", "test.txt").build();
return McpSchema.ListResourcesResult.builder(List.of(resource)).nextCursor("").build();
}
if (McpSchema.METHOD_RESOURCES_TEMPLATES_LIST.equals(method)) {
McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder("file:///{name}", "template")
.build();
return McpSchema.ListResourceTemplatesResult.builder(List.of(template)).nextCursor("").build();
}
if (McpSchema.METHOD_PROMPT_LIST.equals(method)) {
McpSchema.Prompt prompt = McpSchema.Prompt.builder("test-prompt").build();
return McpSchema.ListPromptsResult.builder(List.of(prompt)).nextCursor("").build();
}
throw new IllegalArgumentException("Unsupported method: " + method);
}

@Override
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
return JSON_MAPPER.convertValue(data, new TypeRef<>() {
@Override
public java.lang.reflect.Type getType() {
return typeRef.getType();
}
});
}

int getRequestCount() {
return this.requestCount.get();
}

}

}
Loading