Compare commits

...
Author SHA1 Message Date
Daniel Garnier-Moiroux
8a7002f2cd Release version 1.1.4
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-08-19 15:41:18 +02:00
Daniel Garnier-Moiroux
2bb95bcd69 Bound HTTP client reads
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-08-19 15:32:04 +02:00
Daniel Garnier-Moiroux
9e4d5fa827 Bound HTTP server reads
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-08-19 15:32:02 +02:00
Daniel Garnier-Moiroux
43625c2d25 Run Publish Snapshot workflow on 1.1.x branch
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-05-21 16:54:59 +02:00
Daniel Garnier-Moiroux
2394e441e9 Next development version
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-05-21 16:50:52 +02:00
Daniel Garnier-Moiroux
f4c25f00f6 Release version 1.1.3
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-05-21 16:49:34 +02:00
Daniel Garnier-Moiroux
45589adf61 Fix assertions in HttpClientSseClientTransportTests 2026-05-04 15:36:51 +02:00
Daniel Garnier-Moiroux
0be6032728 DefaultSseMessageEndpointValidator allows same-origin message endpoints
- Backport #945

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-05-04 15:22:31 +02:00
Daniel Garnier-Moiroux
f4c8371dc2 Validate message endpoint in SSE client transport
- Backport #943

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-04-30 17:57:53 +02:00
Christian Tzolov
34e9b8c118 Next development version
Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
2026-04-24 17:20:32 +03:00
Christian Tzolov
e9e1a2f34d Release version 1.1.2
Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
2026-04-24 16:29:58 +03:00
Daniel Garnier-Moiroux
141063c99c Polish gh-906 2026-04-09 11:12:29 +02:00
smohite04
92bb47b67c feat: add support for meta parameter in client paginated list queries (#906)
* feat: add support for meta parameter in client paginated list queries#

- resources/list
- resources/templates/list
- prompts/list
- tools/list

paginated list operations extended in this review:
- listResources(String cursor, Map<String, Object> meta)
- listResourceTemplates(String cursor, Map<String, Object> meta)
- listPrompts(String cursor, Map<String, Object> meta)

Closes #907

Co-authored-by: SHEETAL MOHITE <mohishee@amazon.com>
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-04-09 11:12:29 +02:00
RameshReddy Adutla
9ead4d154e Fix UTF-8 encoding for non-ASCII tool names in HTTP client transports
Both HttpClientSseClientTransport and HttpClientStreamableHttpTransport
set Content-Type to 'application/json' without specifying the charset.
While Java's BodyPublishers.ofString() uses UTF-8 by default, the
missing charset in the header can cause the server to interpret the
request body using a different encoding (e.g., ISO-8859-1), corrupting
non-ASCII characters such as Chinese tool names.

Explicitly set Content-Type to 'application/json; charset=utf-8' in
POST requests on both client transports.

Fixes #260
Backport of #850

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Daniel Garnier-Moiroux <git@garnier.wf>
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-04-08 15:05:52 +02:00
Daniel Garnier-Moiroux
8e1c5d0cd4 HttpClientStreamableHttpTransport: handle HTTP 405
- Fixes #877

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-04-02 17:26:27 +02:00
300:29:1
7e4c179b77 Deprecate Builder.customizeRequest() in favor of httpRequestCustomizer() (#791)
Deprecate Builder.customizeRequest() in HttpClientSseClientTransport and HttpClientStreamableHttpTransport

customizeRequest() executes its consumer once at build time, freezing
headers into the shared requestBuilder. This silently breaks OAuth
token refresh scenarios where the Authorization header needs to be
updated after the transport is built.

Add @Deprecated and update Javadoc to clarify the build-time-only
semantics and guide users toward httpRequestCustomizer() or
asyncHttpRequestCustomizer() which run on every request.

Closes #788

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-04-02 11:39:17 +02:00
Daniel Garnier-Moiroux
3c27d8e3f8 Next development version
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-03-27 20:54:05 +01:00
Daniel Garnier-Moiroux
5968078106 Release version 1.1.1
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
2026-03-27 20:53:25 +01:00
Daniel Garnier-Moiroux
d69501fd28
Merge commit from fork 2026-03-27 18:19:49 +00:00
Christian Tzolov
c9b936835f Next development version
Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
2026-03-13 13:28:37 +01:00
Christian Tzolov
d4d8072693 Release version 1.1.0
Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
2026-03-13 12:55:15 +01:00
46 changed files with 2545 additions and 186 deletions

View file

@ -2,7 +2,7 @@ name: Publish Snapshot
on: on:
push: push:
branches: [ "main" ] branches: [ "1.1.x" ]
jobs: jobs:
build: build:

View file

@ -27,7 +27,7 @@ Once we catch up on the most recent MCP specification revision we aim to fully s
### v1.x Development ### v1.x Development
The Java SDK is currently in active development as v1.x, following a recent stable 1.0.0 release. The SDK provides: The Java SDK is currently in active development as v1.x, following a recent stable 1.1.0 release. The SDK provides:
- MCP protocol implementation - MCP protocol implementation
- Synchronous and asynchronous programming models - Synchronous and asynchronous programming models

View file

@ -87,7 +87,7 @@ cd conformance-tests/client-jdk-http-client
# Run all scenarios # Run all scenarios
for scenario in initialize tools_call elicitation-sep1034-client-defaults sse-retry; do for scenario in initialize tools_call elicitation-sep1034-client-defaults sse-retry; do
npx @modelcontextprotocol/conformance client \ npx @modelcontextprotocol/conformance client \
--command "java -jar target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \ --command "java -jar target/client-jdk-http-client-1.1.1-SNAPSHOT.jar" \
--scenario $scenario --scenario $scenario
done done
``` ```
@ -104,7 +104,7 @@ cd conformance-tests/client-spring-http-client
# Run auth suite # Run auth suite
npx @modelcontextprotocol/conformance@0.1.15 client \ npx @modelcontextprotocol/conformance@0.1.15 client \
--spec-version 2025-11-25 \ --spec-version 2025-11-25 \
--command "java -jar target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \ --command "java -jar target/client-spring-http-client-1.1.1-SNAPSHOT.jar" \
--suite auth --suite auth
``` ```

View file

@ -54,7 +54,7 @@ cd conformance-tests/client-jdk-http-client
This creates an executable JAR at: This creates an executable JAR at:
``` ```
target/client-jdk-http-client-1.1.0-SNAPSHOT.jar target/client-jdk-http-client-1.1.1-SNAPSHOT.jar
``` ```
## Running Tests ## Running Tests
@ -65,19 +65,19 @@ Run a single scenario:
```bash ```bash
npx @modelcontextprotocol/conformance client \ npx @modelcontextprotocol/conformance client \
--command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \ --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.1-SNAPSHOT.jar" \
--scenario initialize --scenario initialize
npx @modelcontextprotocol/conformance client \ npx @modelcontextprotocol/conformance client \
--command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \ --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.1-SNAPSHOT.jar" \
--scenario tools_call --scenario tools_call
npx @modelcontextprotocol/conformance client \ npx @modelcontextprotocol/conformance client \
--command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \ --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.1-SNAPSHOT.jar" \
--scenario elicitation-sep1034-client-defaults --scenario elicitation-sep1034-client-defaults
npx @modelcontextprotocol/conformance client \ npx @modelcontextprotocol/conformance client \
--command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \ --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.1-SNAPSHOT.jar" \
--scenario sse-retry --scenario sse-retry
``` ```
@ -85,7 +85,7 @@ Run with verbose output:
```bash ```bash
npx @modelcontextprotocol/conformance client \ npx @modelcontextprotocol/conformance client \
--command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \ --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.1-SNAPSHOT.jar" \
--scenario initialize \ --scenario initialize \
--verbose --verbose
``` ```
@ -96,7 +96,7 @@ You can also run the client manually if you have a test server:
```bash ```bash
export MCP_CONFORMANCE_SCENARIO=initialize export MCP_CONFORMANCE_SCENARIO=initialize
java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar http://localhost:3000/mcp java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.1-SNAPSHOT.jar http://localhost:3000/mcp
``` ```
## Test Results ## Test Results

View file

@ -6,7 +6,7 @@
<parent> <parent>
<groupId>io.modelcontextprotocol.sdk</groupId> <groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>conformance-tests</artifactId> <artifactId>conformance-tests</artifactId>
<version>1.1.0-SNAPSHOT</version> <version>1.1.4</version>
</parent> </parent>
<artifactId>client-jdk-http-client</artifactId> <artifactId>client-jdk-http-client</artifactId>
<packaging>jar</packaging> <packaging>jar</packaging>
@ -28,7 +28,7 @@
<dependency> <dependency>
<groupId>io.modelcontextprotocol.sdk</groupId> <groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId> <artifactId>mcp</artifactId>
<version>1.1.0-SNAPSHOT</version> <version>1.1.4</version>
</dependency> </dependency>
<!-- Logging --> <!-- Logging -->

View file

@ -67,7 +67,7 @@ cd conformance-tests/client-spring-http-client
This creates an executable JAR at: This creates an executable JAR at:
``` ```
target/client-spring-http-client-1.1.0-SNAPSHOT.jar target/client-spring-http-client-1.1.1-SNAPSHOT.jar
``` ```
## Running Tests ## Running Tests
@ -79,7 +79,7 @@ Run the full auth suite:
```bash ```bash
npx @modelcontextprotocol/conformance@0.1.15 client \ npx @modelcontextprotocol/conformance@0.1.15 client \
--spec-version 2025-11-25 \ --spec-version 2025-11-25 \
--command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \ --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.1-SNAPSHOT.jar" \
--suite auth --suite auth
``` ```
@ -88,7 +88,7 @@ Run a single scenario:
```bash ```bash
npx @modelcontextprotocol/conformance@0.1.15 client \ npx @modelcontextprotocol/conformance@0.1.15 client \
--spec-version 2025-11-25 \ --spec-version 2025-11-25 \
--command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \ --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.1-SNAPSHOT.jar" \
--scenario auth/metadata-default --scenario auth/metadata-default
``` ```
@ -97,7 +97,7 @@ Run with verbose output:
```bash ```bash
npx @modelcontextprotocol/conformance@0.1.15 client \ npx @modelcontextprotocol/conformance@0.1.15 client \
--spec-version 2025-11-25 \ --spec-version 2025-11-25 \
--command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \ --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.1-SNAPSHOT.jar" \
--scenario auth/metadata-default \ --scenario auth/metadata-default \
--verbose --verbose
``` ```
@ -108,7 +108,7 @@ You can also run the client manually if you have a test server:
```bash ```bash
export MCP_CONFORMANCE_SCENARIO=auth/metadata-default export MCP_CONFORMANCE_SCENARIO=auth/metadata-default
java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar http://localhost:3000/mcp java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.1-SNAPSHOT.jar http://localhost:3000/mcp
``` ```
## Known Issues ## Known Issues

View file

@ -6,7 +6,7 @@
<parent> <parent>
<groupId>io.modelcontextprotocol.sdk</groupId> <groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>conformance-tests</artifactId> <artifactId>conformance-tests</artifactId>
<version>1.1.0-SNAPSHOT</version> <version>1.1.4</version>
</parent> </parent>
<artifactId>client-spring-http-client</artifactId> <artifactId>client-spring-http-client</artifactId>
<packaging>jar</packaging> <packaging>jar</packaging>

View file

@ -6,7 +6,7 @@
<parent> <parent>
<groupId>io.modelcontextprotocol.sdk</groupId> <groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-parent</artifactId> <artifactId>mcp-parent</artifactId>
<version>1.1.0-SNAPSHOT</version> <version>1.1.4</version>
</parent> </parent>
<artifactId>conformance-tests</artifactId> <artifactId>conformance-tests</artifactId>
<packaging>pom</packaging> <packaging>pom</packaging>

View file

@ -6,7 +6,7 @@
<parent> <parent>
<groupId>io.modelcontextprotocol.sdk</groupId> <groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>conformance-tests</artifactId> <artifactId>conformance-tests</artifactId>
<version>1.1.0-SNAPSHOT</version> <version>1.1.4</version>
</parent> </parent>
<artifactId>server-servlet</artifactId> <artifactId>server-servlet</artifactId>
<packaging>jar</packaging> <packaging>jar</packaging>
@ -28,7 +28,7 @@
<dependency> <dependency>
<groupId>io.modelcontextprotocol.sdk</groupId> <groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId> <artifactId>mcp</artifactId>
<version>1.1.0-SNAPSHOT</version> <version>1.1.4</version>
</dependency> </dependency>
<dependency> <dependency>

View file

@ -123,7 +123,7 @@ Add the BOM to your project:
<dependency> <dependency>
<groupId>io.modelcontextprotocol.sdk</groupId> <groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-bom</artifactId> <artifactId>mcp-bom</artifactId>
<version>1.0.0</version> <version>1.1.0</version>
<type>pom</type> <type>pom</type>
<scope>import</scope> <scope>import</scope>
</dependency> </dependency>
@ -135,7 +135,7 @@ Add the BOM to your project:
```groovy ```groovy
dependencies { dependencies {
implementation platform("io.modelcontextprotocol.sdk:mcp-bom:1.0.0") implementation platform("io.modelcontextprotocol.sdk:mcp-bom:1.1.0")
//... //...
} }
``` ```

View file

@ -7,7 +7,7 @@
<parent> <parent>
<groupId>io.modelcontextprotocol.sdk</groupId> <groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-parent</artifactId> <artifactId>mcp-parent</artifactId>
<version>1.1.0-SNAPSHOT</version> <version>1.1.4</version>
</parent> </parent>
<artifactId>mcp-bom</artifactId> <artifactId>mcp-bom</artifactId>

View file

@ -6,7 +6,7 @@
<parent> <parent>
<groupId>io.modelcontextprotocol.sdk</groupId> <groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-parent</artifactId> <artifactId>mcp-parent</artifactId>
<version>1.1.0-SNAPSHOT</version> <version>1.1.4</version>
</parent> </parent>
<artifactId>mcp-core</artifactId> <artifactId>mcp-core</artifactId>
<packaging>jar</packaging> <packaging>jar</packaging>

View file

@ -303,7 +303,7 @@ public class McpAsyncClient {
return Mono.empty(); return Mono.empty();
} }
return this.listToolsInternal(init, McpSchema.FIRST_PAGE).doOnNext(listToolsResult -> { return this.listToolsInternal(init, McpSchema.FIRST_PAGE, null).doOnNext(listToolsResult -> {
listToolsResult.tools() listToolsResult.tools()
.forEach(tool -> logger.debug("Tool {} schema: {}", tool.name(), tool.outputSchema())); .forEach(tool -> logger.debug("Tool {} schema: {}", tool.name(), tool.outputSchema()));
if (enableCallToolSchemaCaching && listToolsResult.tools() != null) { if (enableCallToolSchemaCaching && listToolsResult.tools() != null) {
@ -645,16 +645,27 @@ public class McpAsyncClient {
* @return A Mono that emits the list of tools result * @return A Mono that emits the list of tools result
*/ */
public Mono<McpSchema.ListToolsResult> listTools(String cursor) { public Mono<McpSchema.ListToolsResult> listTools(String cursor) {
return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor)); return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, null));
} }
private Mono<McpSchema.ListToolsResult> listToolsInternal(Initialization init, String cursor) { /**
* Retrieves a paginated list of tools with optional metadata.
* @param cursor Optional pagination cursor from a previous list request
* @param meta Optional metadata to include in the request (_meta field)
* @return A Mono that emits the list of tools result
*/
public Mono<McpSchema.ListToolsResult> listTools(String cursor, Map<String, Object> meta) {
return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, meta));
}
private Mono<McpSchema.ListToolsResult> listToolsInternal(Initialization init, String cursor,
Map<String, Object> meta) {
if (init.initializeResult().capabilities().tools() == null) { if (init.initializeResult().capabilities().tools() == null) {
return Mono.error(new IllegalStateException("Server does not provide tools capability")); return Mono.error(new IllegalStateException("Server does not provide tools capability"));
} }
return init.mcpSession() return init.mcpSession()
.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor), .sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor, meta),
LIST_TOOLS_RESULT_TYPE_REF) LIST_TOOLS_RESULT_TYPE_REF)
.doOnNext(result -> { .doOnNext(result -> {
// Validate tool names (warn only) // Validate tool names (warn only)
@ -725,12 +736,30 @@ public class McpAsyncClient {
* @see #readResource(McpSchema.Resource) * @see #readResource(McpSchema.Resource)
*/ */
public Mono<McpSchema.ListResourcesResult> listResources(String cursor) { public Mono<McpSchema.ListResourcesResult> listResources(String cursor) {
return this.listResourcesInternal(cursor, null);
}
/**
* Retrieves a paginated list of resources provided by the server. Resources represent
* any kind of UTF-8 encoded data that an MCP server makes available to clients, such
* as database records, API responses, log files, and more.
* @param cursor Optional pagination cursor from a previous list request
* @param meta Optional metadata to include in the request (_meta field)
* @return A Mono that completes with the list of resources result.
* @see McpSchema.ListResourcesResult
* @see #readResource(McpSchema.Resource)
*/
public Mono<McpSchema.ListResourcesResult> listResources(String cursor, Map<String, Object> meta) {
return this.listResourcesInternal(cursor, meta);
}
private Mono<McpSchema.ListResourcesResult> listResourcesInternal(String cursor, Map<String, Object> meta) {
return this.initializer.withInitialization("listing resources", init -> { return this.initializer.withInitialization("listing resources", init -> {
if (init.initializeResult().capabilities().resources() == null) { if (init.initializeResult().capabilities().resources() == null) {
return Mono.error(new IllegalStateException("Server does not provide the resources capability")); return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
} }
return init.mcpSession() return init.mcpSession()
.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor), .sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor, meta),
LIST_RESOURCES_RESULT_TYPE_REF); LIST_RESOURCES_RESULT_TYPE_REF);
}); });
} }
@ -795,12 +824,30 @@ public class McpAsyncClient {
* @see McpSchema.ListResourceTemplatesResult * @see McpSchema.ListResourceTemplatesResult
*/ */
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor) { public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor) {
return this.listResourceTemplatesInternal(cursor, null);
}
/**
* Retrieves a paginated list of resource templates provided by the server. Resource
* templates allow servers to expose parameterized resources using URI templates,
* enabling dynamic resource access based on variable parameters.
* @param cursor Optional pagination cursor from a previous list request
* @param meta Optional metadata to include in the request (_meta field)
* @return A Mono that completes with the list of resource templates result.
* @see McpSchema.ListResourceTemplatesResult
*/
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor, Map<String, Object> meta) {
return this.listResourceTemplatesInternal(cursor, meta);
}
private Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplatesInternal(String cursor,
Map<String, Object> meta) {
return this.initializer.withInitialization("listing resource templates", init -> { return this.initializer.withInitialization("listing resource templates", init -> {
if (init.initializeResult().capabilities().resources() == null) { if (init.initializeResult().capabilities().resources() == null) {
return Mono.error(new IllegalStateException("Server does not provide the resources capability")); return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
} }
return init.mcpSession() return init.mcpSession()
.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest(cursor), .sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest(cursor, meta),
LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF); LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF);
}); });
} }
@ -895,8 +942,26 @@ public class McpAsyncClient {
* @see #getPrompt(GetPromptRequest) * @see #getPrompt(GetPromptRequest)
*/ */
public Mono<ListPromptsResult> listPrompts(String cursor) { public Mono<ListPromptsResult> listPrompts(String cursor) {
return this.initializer.withInitialization("listing prompts", init -> init.mcpSession() return this.listPromptsInternal(cursor, null);
.sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor), LIST_PROMPTS_RESULT_TYPE_REF)); }
/**
* Retrieves a paginated list of prompts with optional metadata.
* @param cursor Optional pagination cursor from a previous list request
* @param meta Optional metadata to include in the request (_meta field)
* @return A Mono that completes with the list of prompts result.
* @see McpSchema.ListPromptsResult
* @see #getPrompt(GetPromptRequest)
*/
public Mono<ListPromptsResult> listPrompts(String cursor, Map<String, Object> meta) {
return this.listPromptsInternal(cursor, meta);
}
private Mono<ListPromptsResult> listPromptsInternal(String cursor, Map<String, Object> meta) {
return this.initializer.withInitialization("listing prompts",
init -> init.mcpSession()
.sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor, meta),
LIST_PROMPTS_RESULT_TYPE_REF));
} }
/** /**

View file

@ -5,6 +5,7 @@
package io.modelcontextprotocol.client; package io.modelcontextprotocol.client;
import java.time.Duration; import java.time.Duration;
import java.util.Map;
import java.util.function.Supplier; import java.util.function.Supplier;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -259,6 +260,18 @@ public class McpSyncClient implements AutoCloseable {
} }
/**
* Retrieves a paginated list of tools provided by the server.
* @param cursor Optional pagination cursor from a previous list request
* @param meta Optional metadata to include in the request (_meta field)
* @return The list of tools result containing: - tools: List of available tools, each
* with a name, description, and input schema - nextCursor: Optional cursor for
* pagination if more tools are available
*/
public McpSchema.ListToolsResult listTools(String cursor, Map<String, Object> meta) {
return withProvidedContext(this.delegate.listTools(cursor, meta)).block();
}
// -------------------------- // --------------------------
// Resources // Resources
// -------------------------- // --------------------------
@ -282,6 +295,17 @@ public class McpSyncClient implements AutoCloseable {
} }
/**
* Retrieves a paginated list of resources with optional metadata.
* @param cursor Optional pagination cursor from a previous list request
* @param meta Optional metadata to include in the request (_meta field)
* @return The list of resources result
*/
public McpSchema.ListResourcesResult listResources(String cursor, Map<String, Object> meta) {
return withProvidedContext(this.delegate.listResources(cursor, meta)).block();
}
/** /**
* Send a resources/read request. * Send a resources/read request.
* @param resource the resource to read * @param resource the resource to read
@ -324,6 +348,20 @@ public class McpSyncClient implements AutoCloseable {
} }
/**
* Resource templates allow servers to expose parameterized resources using URI
* templates. Arguments may be auto-completed through the completion API.
*
* Retrieves a paginated list of resource templates provided by the server.
* @param cursor Optional pagination cursor from a previous list request
* @param meta Optional metadata to include in the request (_meta field)
* @return The list of resource templates result.
*/
public McpSchema.ListResourceTemplatesResult listResourceTemplates(String cursor, Map<String, Object> meta) {
return withProvidedContext(this.delegate.listResourceTemplates(cursor, meta)).block();
}
/** /**
* Subscriptions. The protocol supports optional subscriptions to resource changes. * Subscriptions. The protocol supports optional subscriptions to resource changes.
* Clients can subscribe to specific resources and receive notifications when they * Clients can subscribe to specific resources and receive notifications when they
@ -370,6 +408,17 @@ public class McpSyncClient implements AutoCloseable {
} }
/**
* Retrieves a paginated list of prompts provided by the server.
* @param cursor Optional pagination cursor from a previous list request
* @param meta Optional metadata to include in the request (_meta field)
* @return The list of prompts result.
*/
public ListPromptsResult listPrompts(String cursor, Map<String, Object> meta) {
return withProvidedContext(this.delegate.listPrompts(cursor, meta)).block();
}
public GetPromptResult getPrompt(GetPromptRequest getPromptRequest) { public GetPromptResult getPrompt(GetPromptRequest getPromptRequest) {
return withProvidedContext(this.delegate.getPrompt(getPromptRequest)).block(); return withProvidedContext(this.delegate.getPrompt(getPromptRequest)).block();
} }

View file

@ -0,0 +1,61 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.client.transport;
import java.net.URI;
import java.net.URISyntaxException;
import io.modelcontextprotocol.util.Assert;
/**
* Default {@link SseMessageEndpointValidator} that validates the {@code message} endpoint
* advertised by an SSE server. Message endpoints must either have the same origin as the
* SSE uri, or be a relative uri.
*
* @author Daniel Garnier-Moiroux
*/
public final class DefaultSseMessageEndpointValidator implements SseMessageEndpointValidator {
@Override
public void validate(URI sseUri, String messageEndpoint) throws InvalidSseMessageEndpointException {
Assert.hasText(messageEndpoint, "messageEndpoint must not be empty");
URI endpointUri;
try {
endpointUri = new URI(messageEndpoint);
}
catch (URISyntaxException ex) {
throw new InvalidSseMessageEndpointException("messageEndpoint is not a valid URI: " + ex.getMessage(),
messageEndpoint);
}
if (endpointUri.isAbsolute() || endpointUri.getRawAuthority() != null) {
String scheme = endpointUri.getScheme();
String host = endpointUri.getHost();
int port = endpointUri.getPort();
boolean sameScheme = scheme != null && scheme.equalsIgnoreCase(sseUri.getScheme());
boolean sameHost = host != null && host.equalsIgnoreCase(sseUri.getHost());
boolean samePort = port == sseUri.getPort();
if (!sameScheme || !sameHost || !samePort) {
throw new InvalidSseMessageEndpointException(
"messageEndpoint must be a relative path or a same-origin URI", messageEndpoint);
}
}
// Exclude path-traversal
String decodedPath = endpointUri.getPath();
if (decodedPath != null) {
for (String segment : decodedPath.split("/", -1)) {
if (".".equals(segment) || "..".equals(segment)) {
throw new InvalidSseMessageEndpointException(
"messageEndpoint must not contain path-traversal segments", messageEndpoint);
}
}
}
}
}

View file

@ -16,8 +16,6 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function; import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent; import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent;
import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer;
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
@ -33,6 +31,8 @@ import io.modelcontextprotocol.spec.McpTransportException;
import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.spec.ProtocolVersions;
import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.Utils; import io.modelcontextprotocol.util.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.Disposable; import reactor.core.Disposable;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@ -82,6 +82,11 @@ public class HttpClientSseClientTransport implements McpClientTransport {
/** Default SSE endpoint path */ /** Default SSE endpoint path */
private static final String DEFAULT_SSE_ENDPOINT = "/sse"; private static final String DEFAULT_SSE_ENDPOINT = "/sse";
/**
* Default maximum number of bytes read for a single inbound message.
*/
private static final int DEFAULT_MAX_RESPONSE_SIZE = 16 * 1024 * 1024; // 16MiB
/** Base URI for the MCP server */ /** Base URI for the MCP server */
private final URI baseUri; private final URI baseUri;
@ -117,6 +122,17 @@ public class HttpClientSseClientTransport implements McpClientTransport {
*/ */
private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer; private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer;
/**
* Validator for the message endpoint;
*/
private final SseMessageEndpointValidator messageEndpointValidator;
/**
* Maximum number of bytes read for a single inbound message, whether it arrives on
* the SSE stream or as the response to a posted message.
*/
private final int maxResponseSize;
/** /**
* Creates a new transport instance with custom HTTP client builder, object mapper, * Creates a new transport instance with custom HTTP client builder, object mapper,
* and headers. * and headers.
@ -127,22 +143,30 @@ public class HttpClientSseClientTransport implements McpClientTransport {
* @param jsonMapper the object mapper for JSON serialization/deserialization * @param jsonMapper the object mapper for JSON serialization/deserialization
* @param httpRequestCustomizer customizer for the requestBuilder before executing * @param httpRequestCustomizer customizer for the requestBuilder before executing
* requests * requests
* @param messageEndpointValidator validator for the message endpoint
* @param maxResponseSize the maximum number of bytes read for a single inbound
* message
* @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null * @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null
*/ */
HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri, HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri,
String sseEndpoint, McpJsonMapper jsonMapper, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer) { String sseEndpoint, McpJsonMapper jsonMapper, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
SseMessageEndpointValidator messageEndpointValidator, int maxResponseSize) {
Assert.notNull(jsonMapper, "jsonMapper must not be null"); Assert.notNull(jsonMapper, "jsonMapper must not be null");
Assert.hasText(baseUri, "baseUri must not be empty"); Assert.hasText(baseUri, "baseUri must not be empty");
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty"); Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
Assert.notNull(httpClient, "httpClient must not be null"); Assert.notNull(httpClient, "httpClient must not be null");
Assert.notNull(requestBuilder, "requestBuilder must not be null"); Assert.notNull(requestBuilder, "requestBuilder must not be null");
Assert.notNull(httpRequestCustomizer, "httpRequestCustomizer must not be null"); Assert.notNull(httpRequestCustomizer, "httpRequestCustomizer must not be null");
Assert.notNull(messageEndpointValidator, "messageEndpointValidator must not be null");
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
this.baseUri = URI.create(baseUri); this.baseUri = URI.create(baseUri);
this.sseEndpoint = sseEndpoint; this.sseEndpoint = sseEndpoint;
this.jsonMapper = jsonMapper; this.jsonMapper = jsonMapper;
this.httpClient = httpClient; this.httpClient = httpClient;
this.requestBuilder = requestBuilder; this.requestBuilder = requestBuilder;
this.httpRequestCustomizer = httpRequestCustomizer; this.httpRequestCustomizer = httpRequestCustomizer;
this.messageEndpointValidator = messageEndpointValidator;
this.maxResponseSize = maxResponseSize;
} }
@Override @Override
@ -178,6 +202,10 @@ public class HttpClientSseClientTransport implements McpClientTransport {
private Duration connectTimeout = Duration.ofSeconds(10); private Duration connectTimeout = Duration.ofSeconds(10);
private SseMessageEndpointValidator messageEndpointValidator = new DefaultSseMessageEndpointValidator();
private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE;
/** /**
* Creates a new builder instance. * Creates a new builder instance.
*/ */
@ -241,10 +269,17 @@ public class HttpClientSseClientTransport implements McpClientTransport {
} }
/** /**
* Customizes the HTTP client builder. * Applies the given consumer to the shared {@link HttpRequest.Builder} <b>once,
* at build time</b>. Any headers set here are frozen into the template and
* <b>cannot be updated</b> after the transport is built.
* @param requestCustomizer the consumer to customize the HTTP request builder * @param requestCustomizer the consumer to customize the HTTP request builder
* @return this builder * @return this builder
* @deprecated Use {@link #requestBuilder(HttpRequest.Builder)} for stable
* headers, or {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)}
* / {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} for
* dynamic per-request customization.
*/ */
@Deprecated
public Builder customizeRequest(final Consumer<HttpRequest.Builder> requestCustomizer) { public Builder customizeRequest(final Consumer<HttpRequest.Builder> requestCustomizer) {
Assert.notNull(requestCustomizer, "requestCustomizer must not be null"); Assert.notNull(requestCustomizer, "requestCustomizer must not be null");
requestCustomizer.accept(requestBuilder); requestCustomizer.accept(requestBuilder);
@ -308,6 +343,38 @@ public class HttpClientSseClientTransport implements McpClientTransport {
return this; return this;
} }
/**
* Sets the validator that ensure the message endpoint returned over the SSE
* connection is valid.
* @param messageEndpointValidator the validator
* @return this builder
*/
public Builder messageEndpointValidator(SseMessageEndpointValidator messageEndpointValidator) {
Assert.notNull(messageEndpointValidator, "messageEndpointValidator must not be null");
this.messageEndpointValidator = messageEndpointValidator;
return this;
}
/**
* Sets the maximum number of bytes read for a single inbound message, whether it
* arrives on the SSE stream or as the response to a posted message. A peer that
* sends a larger message (or never terminates one) has its stream aborted instead
* of forcing the transport to buffer it in memory. Defaults to 16MiB.
*
* <p>
* The bound applies per message, not to the stream as a whole: a long-lived SSE
* stream may deliver any number of messages, each up to this size. SSE field
* framing is allowed a small amount of headroom on top of this size, so a message
* of exactly this many bytes is still accepted.
* @param maxResponseSize the maximum inbound message size, in bytes
* @return this builder
*/
public Builder maxResponseSize(int maxResponseSize) {
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
this.maxResponseSize = maxResponseSize;
return this;
}
/** /**
* Builds a new {@link HttpClientSseClientTransport} instance. * Builds a new {@link HttpClientSseClientTransport} instance.
* @return a new transport instance * @return a new transport instance
@ -315,7 +382,8 @@ public class HttpClientSseClientTransport implements McpClientTransport {
public HttpClientSseClientTransport build() { public HttpClientSseClientTransport build() {
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build(); HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint, return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint,
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer); jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer,
messageEndpointValidator, maxResponseSize);
} }
} }
@ -334,13 +402,15 @@ public class HttpClientSseClientTransport implements McpClientTransport {
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null, transportContext)); return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null, transportContext));
}).flatMap(requestBuilder -> Mono.create(sink -> { }).flatMap(requestBuilder -> Mono.create(sink -> {
Disposable connection = Flux.<ResponseEvent>create(sseSink -> this.httpClient Disposable connection = Flux.<ResponseEvent>create(
.sendAsync(requestBuilder.build(), sseSink -> this.httpClient
responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink)) .sendAsync(requestBuilder.build(),
.exceptionallyCompose(e -> { responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink,
sseSink.error(e); this.maxResponseSize))
return CompletableFuture.failedFuture(e); .exceptionallyCompose(e -> {
})) sseSink.error(e);
return CompletableFuture.failedFuture(e);
}))
.map(responseEvent -> (ResponseSubscribers.SseResponseEvent) responseEvent) .map(responseEvent -> (ResponseSubscribers.SseResponseEvent) responseEvent)
.flatMap(responseEvent -> { .flatMap(responseEvent -> {
if (isClosing) { if (isClosing) {
@ -353,6 +423,14 @@ public class HttpClientSseClientTransport implements McpClientTransport {
try { try {
if (ENDPOINT_EVENT_TYPE.equals(responseEvent.sseEvent().event())) { if (ENDPOINT_EVENT_TYPE.equals(responseEvent.sseEvent().event())) {
String messageEndpointUri = responseEvent.sseEvent().data(); String messageEndpointUri = responseEvent.sseEvent().data();
try {
messageEndpointValidator.validate(uri, messageEndpointUri);
}
catch (InvalidSseMessageEndpointException e) {
sink.error(e);
this.messageEndpointSink.tryEmitError(e);
return Flux.error(e);
}
if (this.messageEndpointSink.tryEmitValue(messageEndpointUri).isSuccess()) { if (this.messageEndpointSink.tryEmitValue(messageEndpointUri).isSuccess()) {
sink.success(); sink.success();
return Flux.empty(); // No further processing needed return Flux.empty(); // No further processing needed
@ -456,14 +534,15 @@ public class HttpClientSseClientTransport implements McpClientTransport {
return Mono.deferContextual(ctx -> { return Mono.deferContextual(ctx -> {
var builder = this.requestBuilder.copy() var builder = this.requestBuilder.copy()
.uri(requestUri) .uri(requestUri)
.header(HttpHeaders.CONTENT_TYPE, "application/json") .header(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
.header(MCP_PROTOCOL_VERSION_HEADER_NAME, MCP_PROTOCOL_VERSION) .header(MCP_PROTOCOL_VERSION_HEADER_NAME, MCP_PROTOCOL_VERSION)
.POST(HttpRequest.BodyPublishers.ofString(body)); .POST(HttpRequest.BodyPublishers.ofString(body));
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
return Mono.from(this.httpRequestCustomizer.customize(builder, "POST", requestUri, body, transportContext)); return Mono.from(this.httpRequestCustomizer.customize(builder, "POST", requestUri, body, transportContext));
}).flatMap(customizedBuilder -> { }).flatMap(customizedBuilder -> {
var request = customizedBuilder.build(); var request = customizedBuilder.build();
return Mono.fromFuture(httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())); return Mono.fromFuture(
httpClient.sendAsync(request, ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
}); });
} }

View file

@ -8,7 +8,6 @@ import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandler; import java.net.http.HttpResponse.BodyHandler;
import java.time.Duration; import java.time.Duration;
import java.util.Collections; import java.util.Collections;
@ -85,6 +84,11 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private static final String DEFAULT_ENDPOINT = "/mcp"; private static final String DEFAULT_ENDPOINT = "/mcp";
/**
* Default maximum number of bytes read for a single inbound message.
*/
private static final int DEFAULT_MAX_RESPONSE_SIZE = 16 * 1024 * 1024; // 16MiB
/** /**
* HTTP client for sending messages to the server. Uses HTTP POST over the message * HTTP client for sending messages to the server. Uses HTTP POST over the message
* endpoint * endpoint
@ -102,6 +106,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private static final String APPLICATION_JSON = "application/json"; private static final String APPLICATION_JSON = "application/json";
private static final String APPLICATION_JSON_UTF8 = "application/json; charset=utf-8";
private static final String TEXT_EVENT_STREAM = "text/event-stream"; private static final String TEXT_EVENT_STREAM = "text/event-stream";
public static int NOT_FOUND = 404; public static int NOT_FOUND = 404;
@ -134,10 +140,18 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private final String latestSupportedProtocolVersion; private final String latestSupportedProtocolVersion;
/**
* Maximum number of bytes read for a single inbound message, whether it arrives on an
* SSE stream or as a JSON response body.
*/
private final int maxResponseSize;
private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient, private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient,
HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams, HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams,
boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer, boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
McpHttpClientAuthorizationErrorHandler authorizationErrorHandler, List<String> supportedProtocolVersions) { McpHttpClientAuthorizationErrorHandler authorizationErrorHandler, List<String> supportedProtocolVersions,
int maxResponseSize) {
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
this.jsonMapper = jsonMapper; this.jsonMapper = jsonMapper;
this.httpClient = httpClient; this.httpClient = httpClient;
this.requestBuilder = requestBuilder; this.requestBuilder = requestBuilder;
@ -153,6 +167,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
.sorted(Comparator.reverseOrder()) .sorted(Comparator.reverseOrder())
.findFirst() .findFirst()
.get(); .get();
this.maxResponseSize = maxResponseSize;
} }
@Override @Override
@ -209,7 +224,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return Mono.from(this.httpRequestCustomizer.customize(builder, "DELETE", uri, null, transportContext)); return Mono.from(this.httpRequestCustomizer.customize(builder, "DELETE", uri, null, transportContext));
}).flatMap(requestBuilder -> { }).flatMap(requestBuilder -> {
var request = requestBuilder.build(); var request = requestBuilder.build();
return Mono.fromFuture(() -> this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())); return Mono.fromFuture(() -> this.httpClient.sendAsync(request,
ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
}).then(); }).then();
} }
@ -298,6 +314,10 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
"Authorization error connecting to SSE stream", "Authorization error connecting to SSE stream",
responseEvent.responseInfo())); responseEvent.responseInfo()));
} }
else if (statusCode == METHOD_NOT_ALLOWED) {
logger.debug("The server does not support SSE streams, using request-response mode.");
return Flux.empty();
}
if (!(responseEvent instanceof ResponseSubscribers.SseResponseEvent sseResponseEvent)) { if (!(responseEvent instanceof ResponseSubscribers.SseResponseEvent sseResponseEvent)) {
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException( return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
@ -344,10 +364,6 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return Flux.empty(); return Flux.empty();
} }
} }
else if (statusCode == METHOD_NOT_ALLOWED) { // NotAllowed
logger.debug("The server does not support SSE streams, using request-response mode.");
return Flux.empty();
}
else if (statusCode == NOT_FOUND) { else if (statusCode == NOT_FOUND) {
if (transportSession != null && transportSession.sessionId().isPresent()) { if (transportSession != null && transportSession.sessionId().isPresent()) {
@ -432,16 +448,16 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
if (contentType.contains(TEXT_EVENT_STREAM)) { if (contentType.contains(TEXT_EVENT_STREAM)) {
// For SSE streams, use line subscriber that returns Void // For SSE streams, use line subscriber that returns Void
logger.debug("Received SSE stream response, using line subscriber"); logger.debug("Received SSE stream response, using line subscriber");
return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink); return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink, this.maxResponseSize);
} }
else if (contentType.contains(APPLICATION_JSON)) { else if (contentType.contains(APPLICATION_JSON)) {
// For JSON responses and others, use string subscriber // For JSON responses and others, use string subscriber
logger.debug("Received response, using string subscriber"); logger.debug("Received response, using string subscriber");
return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink); return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink, this.maxResponseSize);
} }
logger.debug("Received Bodyless response, using discarding subscriber"); logger.debug("Received Bodyless response, using discarding subscriber");
return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink); return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink, this.maxResponseSize);
}; };
return responseBodyHandler; return responseBodyHandler;
@ -477,7 +493,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
var builder = requestBuilder.uri(uri) var builder = requestBuilder.uri(uri)
.header(HttpHeaders.ACCEPT, APPLICATION_JSON + ", " + TEXT_EVENT_STREAM) .header(HttpHeaders.ACCEPT, APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
.header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF8)
.header(HttpHeaders.CACHE_CONTROL, "no-cache") .header(HttpHeaders.CACHE_CONTROL, "no-cache")
.header(HttpHeaders.PROTOCOL_VERSION, .header(HttpHeaders.PROTOCOL_VERSION,
ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION, ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION,
@ -629,6 +645,10 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException( return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
"Bad Request. Status code:" + statusCode + ", response-event:" + responseEvent)); "Bad Request. Status code:" + statusCode + ", response-event:" + responseEvent));
} }
else if (statusCode >= 400 && statusCode < 500) {
return Flux.<McpSchema.JSONRPCMessage>error(
new McpTransportException("Invalid request. Status code: " + statusCode));
}
return Flux.<McpSchema.JSONRPCMessage>error( return Flux.<McpSchema.JSONRPCMessage>error(
new RuntimeException("Failed to send message: " + responseEvent)); new RuntimeException("Failed to send message: " + responseEvent));
@ -695,6 +715,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private McpHttpClientAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientAuthorizationErrorHandler.NOOP; private McpHttpClientAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientAuthorizationErrorHandler.NOOP;
private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE;
/** /**
* Creates a new builder with the specified base URI. * Creates a new builder with the specified base URI.
* @param baseUri the base URI of the MCP server * @param baseUri the base URI of the MCP server
@ -738,10 +760,17 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
} }
/** /**
* Customizes the HTTP client builder. * Applies the given consumer to the shared {@link HttpRequest.Builder} <b>once,
* at build time</b>. Any headers set here are frozen into the template and
* <b>cannot be updated</b> after the transport is built.
* @param requestCustomizer the consumer to customize the HTTP request builder * @param requestCustomizer the consumer to customize the HTTP request builder
* @return this builder * @return this builder
* @deprecated Use {@link #requestBuilder(HttpRequest.Builder)} for stable
* headers, or {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)}
* / {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} for
* dynamic per-request customization.
*/ */
@Deprecated
public Builder customizeRequest(final Consumer<HttpRequest.Builder> requestCustomizer) { public Builder customizeRequest(final Consumer<HttpRequest.Builder> requestCustomizer) {
Assert.notNull(requestCustomizer, "requestCustomizer must not be null"); Assert.notNull(requestCustomizer, "requestCustomizer must not be null");
requestCustomizer.accept(requestBuilder); requestCustomizer.accept(requestBuilder);
@ -878,6 +907,26 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return this; return this;
} }
/**
* Sets the maximum number of bytes read for a single inbound message, whether it
* arrives on an SSE stream or as a JSON response body. A peer that sends a larger
* message (or never terminates one) has its stream aborted instead of forcing the
* transport to buffer it in memory. Defaults to 16MiB.
*
* <p>
* The bound applies per message, not to the stream as a whole: a long-lived SSE
* stream may deliver any number of messages, each up to this size. SSE field
* framing is allowed a small amount of headroom on top of this size, so a message
* of exactly this many bytes is still accepted.
* @param maxResponseSize the maximum inbound message size, in bytes
* @return this builder
*/
public Builder maxResponseSize(int maxResponseSize) {
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
this.maxResponseSize = maxResponseSize;
return this;
}
/** /**
* Construct a fresh instance of {@link HttpClientStreamableHttpTransport} using * Construct a fresh instance of {@link HttpClientStreamableHttpTransport} using
* the current builder configuration. * the current builder configuration.
@ -887,7 +936,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build(); HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup, httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup,
httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions); httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions, maxResponseSize);
} }
} }

View file

@ -0,0 +1,26 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.client.transport;
/**
* Exception thrown when the {@code message} endpoint returned from the SSE connection is
* not valid.
*
* @author Daniel Garnier-Moiroux
*/
public class InvalidSseMessageEndpointException extends Exception {
private final String messageEndpoint;
public InvalidSseMessageEndpointException(String message, String messageEndpoint) {
super(message);
this.messageEndpoint = messageEndpoint;
}
public String getMessageEndpoint() {
return messageEndpoint;
}
}

View file

@ -5,8 +5,13 @@
package io.modelcontextprotocol.client.transport; package io.modelcontextprotocol.client.transport;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandler;
import java.net.http.HttpResponse.BodySubscriber; import java.net.http.HttpResponse.BodySubscriber;
import java.net.http.HttpResponse.ResponseInfo; import java.net.http.HttpResponse.ResponseInfo;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Flow;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -30,11 +35,21 @@ import reactor.core.publisher.FluxSink;
* *
* @author Christian Tzolov * @author Christian Tzolov
* @author Dariusz Jędrzejczyk * @author Dariusz Jędrzejczyk
* @author Daniel Garnier-Moiroux
*/ */
class ResponseSubscribers { class ResponseSubscribers {
private static final Logger logger = LoggerFactory.getLogger(ResponseSubscribers.class); private static final Logger logger = LoggerFactory.getLogger(ResponseSubscribers.class);
/**
* Bytes of SSE field framing a single line may carry on top of the message payload:
* {@code "event: "} is the longest field prefix this parser recognises. Line
* terminators are not counted, as they reset the running line length. Without this
* allowance, an event carrying exactly the maximum message size would be rejected
* because of the bytes the SSE wire format adds around it.
*/
private static final int SSE_FRAMING_OVERHEAD = "event: ".length();
record SseEvent(String id, String event, String data) { record SseEvent(String id, String event, String data) {
} }
@ -54,19 +69,80 @@ class ResponseSubscribers {
record AggregateResponseEvent(ResponseInfo responseInfo, String data) implements ResponseEvent { record AggregateResponseEvent(ResponseInfo responseInfo, String data) implements ResponseEvent {
} }
static BodySubscriber<Void> sseToBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) { /**
return HttpResponse.BodySubscribers * Creates a {@link BodySubscriber} that parses a Server-Sent Events stream, bounding
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new SseLineSubscriber(responseInfo, sink))); * how much memory a single inbound message may occupy. Both the size of an individual
* line (as read off the wire before a terminator is seen) and the accumulated size of
* a multi-line SSE event are capped at {@code maxSize}; a peer exceeding either limit
* has its stream aborted instead of forcing the transport to buffer it in memory. The
* line bound is allowed {@link #SSE_FRAMING_OVERHEAD} extra bytes so that the SSE
* framing around a payload does not count against the payload's own budget.
* @param responseInfo the HTTP response information
* @param sink the sink to emit parsed events to
* @param maxSize the maximum number of bytes read for a single inbound message
*/
static BodySubscriber<Void> sseToBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink,
int maxSize) {
BodySubscriber<Void> lineSubscriber = HttpResponse.BodySubscribers
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new SseLineSubscriber(responseInfo, sink, maxSize)));
return new BoundedLineBodySubscriber(lineSubscriber, plusFramingOverhead(maxSize));
} }
static BodySubscriber<Void> aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) { /**
return HttpResponse.BodySubscribers * Adds {@link #SSE_FRAMING_OVERHEAD} to {@code maxSize}, saturating at
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new AggregateSubscriber(responseInfo, sink))); * {@link Integer#MAX_VALUE} rather than overflowing into a negative bound that would
* reject everything.
*/
private static int plusFramingOverhead(int maxSize) {
return maxSize > Integer.MAX_VALUE - SSE_FRAMING_OVERHEAD ? Integer.MAX_VALUE : maxSize + SSE_FRAMING_OVERHEAD;
} }
static BodySubscriber<Void> bodilessBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) { /**
return HttpResponse.BodySubscribers * Creates a {@link BodySubscriber} that aggregates the whole response body into a
* single event, bounding how much memory it may occupy. Both the size of an
* individual line (as read off the wire before a terminator is seen) and the total
* accumulated body are capped at {@code maxSize}; a peer exceeding either limit has
* its response aborted instead of forcing the transport to buffer it in memory.
* @param responseInfo the HTTP response information
* @param sink the sink to emit the aggregated event to
* @param maxSize the maximum number of bytes read for the response body
*/
static BodySubscriber<Void> aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink,
int maxSize) {
BodySubscriber<Void> lineSubscriber = HttpResponse.BodySubscribers
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new AggregateSubscriber(responseInfo, sink, maxSize)));
return new BoundedLineBodySubscriber(lineSubscriber, maxSize);
}
/**
* Creates a {@link BodySubscriber} that discards the response body, bounding how much
* memory reading it may occupy. The body is discarded as it arrives, but the
* underlying line subscriber still buffers each line before handing it over, so a
* peer sending a line longer than {@code maxSize} has its response aborted.
* @param responseInfo the HTTP response information
* @param sink the sink to emit the completion event to
* @param maxSize the maximum number of bytes read for a single line
*/
static BodySubscriber<Void> bodilessBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink,
int maxSize) {
BodySubscriber<Void> lineSubscriber = HttpResponse.BodySubscribers
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new BodilessResponseLineSubscriber(responseInfo, sink))); .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new BodilessResponseLineSubscriber(responseInfo, sink)));
return new BoundedLineBodySubscriber(lineSubscriber, maxSize);
}
/**
* Creates a {@link BodyHandler} that reads the response body into a string, bounding
* how much memory it may occupy. A peer sending more than {@code maxSize} bytes has
* its response aborted instead of forcing the transport to buffer it in memory.
*
* <p>
* Decoding matches {@link HttpResponse.BodyHandlers#ofString()}, including its
* handling of the charset declared in the {@code Content-Type} header.
* @param maxSize the maximum number of bytes read for the response body
*/
static BodyHandler<String> boundedStringBodyHandler(int maxSize) {
BodyHandler<String> delegate = HttpResponse.BodyHandlers.ofString();
return responseInfo -> new BoundedTotalBodySubscriber<>(delegate.apply(responseInfo), maxSize);
} }
static class SseLineSubscriber extends BaseSubscriber<String> { static class SseLineSubscriber extends BaseSubscriber<String> {
@ -112,18 +188,30 @@ class ResponseSubscribers {
*/ */
private ResponseInfo responseInfo; private ResponseInfo responseInfo;
/**
* The maximum number of bytes that may accumulate for a single SSE event. A peer
* that never terminates an event (e.g. an endless stream of {@code data:} lines)
* has its stream aborted instead of exhausting memory. The accumulated data is
* measured in characters, which for UTF-8 is never more than the number of bytes
* it was decoded from.
*/
private final int maxSize;
/** /**
* Creates a new LineSubscriber that will emit parsed SSE events to the provided * Creates a new LineSubscriber that will emit parsed SSE events to the provided
* sink. * sink.
* @param sink the {@link FluxSink} to emit parsed {@link ResponseEvent} objects * @param sink the {@link FluxSink} to emit parsed {@link ResponseEvent} objects
* to * to
* @param maxSize the maximum number of bytes that may accumulate for a single SSE
* event
*/ */
public SseLineSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) { public SseLineSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink, int maxSize) {
this.sink = sink; this.sink = sink;
this.eventBuilder = new StringBuilder(); this.eventBuilder = new StringBuilder();
this.currentEventId = new AtomicReference<>(); this.currentEventId = new AtomicReference<>();
this.currentEventType = new AtomicReference<>(); this.currentEventType = new AtomicReference<>();
this.responseInfo = responseInfo; this.responseInfo = responseInfo;
this.maxSize = maxSize;
} }
@Override @Override
@ -155,7 +243,18 @@ class ResponseSubscribers {
if (line.startsWith("data:")) { if (line.startsWith("data:")) {
var matcher = EVENT_DATA_PATTERN.matcher(line); var matcher = EVENT_DATA_PATTERN.matcher(line);
if (matcher.find()) { if (matcher.find()) {
this.eventBuilder.append(matcher.group(1).trim()).append("\n"); String data = matcher.group(1).trim();
// Measured before appending, so that an event carrying exactly
// maxSize of data is accepted: the trailing separator below is
// stripped again before the event is emitted.
if (this.eventBuilder.length() + data.length() > this.maxSize) {
upstream().cancel();
this.sink.error(
new McpTransportException("Inbound SSE event exceeds the maximum allowed size of "
+ this.maxSize + " bytes"));
return;
}
this.eventBuilder.append(data).append("\n");
} }
upstream().request(1); upstream().request(1);
} }
@ -225,15 +324,26 @@ class ResponseSubscribers {
volatile boolean hasRequestedDemand = false; volatile boolean hasRequestedDemand = false;
/**
* The maximum number of bytes that may accumulate for the aggregated response
* body. A peer that sends a larger body has its response aborted instead of
* exhausting memory. The accumulated body is measured in characters, which for
* UTF-8 is never more than the number of bytes it was decoded from.
*/
private final int maxSize;
/** /**
* Creates a new JsonLineSubscriber that will emit parsed JSON-RPC messages. * Creates a new JsonLineSubscriber that will emit parsed JSON-RPC messages.
* @param sink the {@link FluxSink} to emit parsed {@link ResponseEvent} objects * @param sink the {@link FluxSink} to emit parsed {@link ResponseEvent} objects
* to * to
* @param maxSize the maximum number of bytes that may accumulate for the
* aggregated response body
*/ */
public AggregateSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) { public AggregateSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink, int maxSize) {
this.sink = sink; this.sink = sink;
this.eventBuilder = new StringBuilder(); this.eventBuilder = new StringBuilder();
this.responseInfo = responseInfo; this.responseInfo = responseInfo;
this.maxSize = maxSize;
} }
@Override @Override
@ -252,6 +362,15 @@ class ResponseSubscribers {
@Override @Override
protected void hookOnNext(String line) { protected void hookOnNext(String line) {
// Measured before appending, so that a body of exactly maxSize is accepted.
// The separator this adds back for each line stands in for the terminator the
// peer sent, which the line subscriber has already stripped.
if (this.eventBuilder.length() + line.length() > this.maxSize) {
upstream().cancel();
this.sink.error(new McpTransportException(
"Inbound response body exceeds the maximum allowed size of " + this.maxSize + " bytes"));
return;
}
this.eventBuilder.append(line).append("\n"); this.eventBuilder.append(line).append("\n");
} }
@ -324,4 +443,177 @@ class ResponseSubscribers {
} }
/**
* Base for {@link BodySubscriber} wrappers that transparently forward the response
* body to a delegate, but abort it once the peer exceeds a size bound.
*
* <p>
* Aborting cancels the upstream subscription, which closes the connection, and
* signals a {@link McpTransportException} to the delegate so the failure surfaces
* both through the body's {@link CompletionStage} and through any sink the delegate
* feeds.
*/
abstract static class BoundedBodySubscriber<T> implements BodySubscriber<T> {
private final BodySubscriber<T> delegate;
protected final int maxSize;
/**
* What the bound applies to, e.g. {@code "Inbound line"}, used to build the
* failure message.
*/
private final String boundedEntity;
private Flow.Subscription subscription;
private volatile boolean done = false;
BoundedBodySubscriber(BodySubscriber<T> delegate, int maxSize, String boundedEntity) {
this.delegate = delegate;
this.maxSize = maxSize;
this.boundedEntity = boundedEntity;
}
@Override
public CompletionStage<T> getBody() {
return this.delegate.getBody();
}
@Override
public void onSubscribe(Flow.Subscription subscription) {
this.subscription = subscription;
this.delegate.onSubscribe(subscription);
}
@Override
public void onNext(List<ByteBuffer> buffers) {
if (this.done) {
return;
}
for (ByteBuffer buffer : buffers) {
if (!checkSize(buffer)) {
this.done = true;
this.subscription.cancel();
this.delegate.onError(new McpTransportException(
this.boundedEntity + " exceeds the maximum allowed size of " + this.maxSize + " bytes"));
return;
}
}
this.delegate.onNext(buffers);
}
/**
* Accounts for the bytes in {@code buffer}, which must be inspected with absolute
* reads only so the delegate still sees the original position.
* @param buffer the buffer about to be handed to the delegate
* @return {@code true} to accept the buffer, or {@code false} to abort the
* response because the bound has been exceeded
*/
protected abstract boolean checkSize(ByteBuffer buffer);
@Override
public void onError(Throwable throwable) {
if (this.done) {
return;
}
this.done = true;
this.delegate.onError(throwable);
}
@Override
public void onComplete() {
if (this.done) {
return;
}
this.done = true;
this.delegate.onComplete();
}
}
/**
* A {@link BoundedBodySubscriber} that aborts the response once a single line (a run
* of bytes with no CR/LF terminator) exceeds {@code maxSize} bytes.
*
* <p>
* {@link HttpResponse.BodySubscribers#fromLineSubscriber} buffers characters until it
* encounters a line terminator, so a peer that never terminates a line (or sends an
* enormous one) would force the transport to buffer it in memory. This wrapper counts
* bytes as they arrive off the wire and cancels the subscription before that buffer
* can grow without bound.
*/
static final class BoundedLineBodySubscriber extends BoundedBodySubscriber<Void> {
private long bytesSinceLineTerminator = 0;
BoundedLineBodySubscriber(BodySubscriber<Void> delegate, int maxSize) {
super(delegate, maxSize, "Inbound line");
}
@Override
protected boolean checkSize(ByteBuffer buffer) {
int position = buffer.position();
int limit = buffer.limit();
if (position == limit) {
return true;
}
if (this.bytesSinceLineTerminator + (limit - position) <= this.maxSize) {
// No line ending in this buffer can exceed the limit, because there are
// not enough bytes since the last terminator for one to. Only the
// trailing (still unterminated) run matters, so scan back to the last
// terminator instead of walking every byte.
this.bytesSinceLineTerminator = lengthOfTrailingRun(buffer, position, limit);
return true;
}
// The limit is within reach, so account for every line exactly.
for (int i = position; i < limit; i++) {
byte b = buffer.get(i);
if (b == '\n' || b == '\r') {
this.bytesSinceLineTerminator = 0;
}
else if (++this.bytesSinceLineTerminator > this.maxSize) {
return false;
}
}
return true;
}
/**
* Returns the number of bytes after the last line terminator in the buffer, or
* the whole span added to the running count when the buffer holds no terminator.
*/
private long lengthOfTrailingRun(ByteBuffer buffer, int position, int limit) {
for (int i = limit - 1; i >= position; i--) {
byte b = buffer.get(i);
if (b == '\n' || b == '\r') {
return limit - 1 - i;
}
}
return this.bytesSinceLineTerminator + (limit - position);
}
}
/**
* A {@link BoundedBodySubscriber} that aborts the response once the body as a whole
* exceeds {@code maxSize} bytes. Suitable for delegates that aggregate the entire
* body in memory, such as {@link HttpResponse.BodyHandlers#ofString()}.
*/
static final class BoundedTotalBodySubscriber<T> extends BoundedBodySubscriber<T> {
private long totalBytes = 0;
BoundedTotalBodySubscriber(BodySubscriber<T> delegate, int maxSize) {
super(delegate, maxSize, "Inbound response body");
}
@Override
protected boolean checkSize(ByteBuffer buffer) {
this.totalBytes += buffer.remaining();
return this.totalBytes <= this.maxSize;
}
}
} }

View file

@ -0,0 +1,27 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.client.transport;
import java.net.URI;
/**
* Validate the that message endpoint in the SSE transport is valid. Throws
* {@link InvalidSseMessageEndpointException} when then endpoint is not valid.
*
* @author Daniel Garnier-Moiroux
*/
@FunctionalInterface
public interface SseMessageEndpointValidator {
/**
* Validate the message endpoint coming from an SSE connection. Throws if not valid.
* @param sseUri the URI used to establish the SSE connection
* @param messageEndpoint the message endpoint from the SSE connection
* @throws InvalidSseMessageEndpointException error thrown if the message endpoint is
* not valid.
*/
void validate(URI sseUri, String messageEndpoint) throws InvalidSseMessageEndpointException;
}

Some files were not shown because too many files have changed in this diff Show more