Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a7002f2cd | ||
|
|
2bb95bcd69 | ||
|
|
9e4d5fa827 | ||
|
|
43625c2d25 | ||
|
|
2394e441e9 | ||
|
|
f4c25f00f6 | ||
|
|
45589adf61 | ||
|
|
0be6032728 | ||
|
|
f4c8371dc2 | ||
|
|
34e9b8c118 | ||
|
|
e9e1a2f34d | ||
|
|
141063c99c | ||
|
|
92bb47b67c | ||
|
|
9ead4d154e | ||
|
|
8e1c5d0cd4 | ||
|
|
7e4c179b77 | ||
|
|
3c27d8e3f8 | ||
|
|
5968078106 | ||
|
|
d69501fd28 |
||
|
|
c9b936835f | ||
|
|
d4d8072693 |
46 changed files with 2545 additions and 186 deletions
2
.github/workflows/publish-snapshot.yml
vendored
2
.github/workflows/publish-snapshot.yml
vendored
|
|
@ -2,7 +2,7 @@ name: Publish Snapshot
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: [ "1.1.x" ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
//...
|
//...
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue