Refactor package structure and dependencies
- Updated package prefix: `org.springframework.ai.mcp` → `org.modelcontextprotocol` - Changed POM groupId: `org.springframework.experimental` → `org.modelcontextprotocol.sdk` - Update all dependecies and BOM - Updated documentation references accordingly Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
parent
25ad5ed052
commit
3112e5ede7
88 changed files with 483 additions and 1624 deletions
5
.github/ISSUE_TEMPLATE/miscellaneous.md
vendored
5
.github/ISSUE_TEMPLATE/miscellaneous.md
vendored
|
|
@ -10,8 +10,7 @@ assignees: ''
|
|||
For anything other than bug reports and feature requests (performance, refactoring, etc),
|
||||
just go ahead and file the issue. Please provide as many details as possible.
|
||||
|
||||
If you have a question or a support request, please open a new discussion on [GitHub Discussions](https://github.com/spring-projects-experimental/spring-ai-mcp/discussions)
|
||||
or ask a question on [StackOverflow](https://stackoverflow.com/questions/tagged/spring-ai-mcp).
|
||||
If you have a question or a support request, please open a new discussion on [GitHub Discussions](https://github.com/modelcontextprotocol/java-sdk/discussions)
|
||||
|
||||
Please do **not** create issues on the [Issue Tracker](https://github.com/spring-projects-experimental/spring-ai-mcp/issues) for questions or support requests.
|
||||
Please do **not** create issues on the [Issue Tracker](https://github.com/modelcontextprotocol/java-sdk/issues) for questions or support requests.
|
||||
We would like to keep the issue tracker **exclusively** for bug reports and feature requests.
|
||||
|
|
|
|||
27
README.md
27
README.md
|
|
@ -1,5 +1,5 @@
|
|||
# Java & Spring MCP
|
||||
[](https://github.com/spring-projects-experimental/spring-ai-mcp/actions/workflows/continuous-integration.yml)
|
||||
[](https://github.com/modelcontextprotocol/java-sdk/actions/workflows/continuous-integration.yml)
|
||||
|
||||
Set of projects that provide Java SDK and Spring Framework integration for the [Model Context Protocol](https://modelcontextprotocol.org/docs/concepts/architecture).
|
||||
It enables Java applications to interact with AI models and tools through a standardized interface, supporting both synchronous and asynchronous communication patterns.
|
||||
|
|
@ -28,8 +28,8 @@ Java implementation of the Model Context Protocol specification. It includes:
|
|||
- Servlet-based SSE server (`HttpServletSseServerTransport`) for HTTP SSE Server streaming using traditional Servlet API
|
||||
|
||||
#### Optional SSE Transports
|
||||
- [WebFlux SSE Transport](https://github.com/spring-projects-experimental/spring-ai-mcp/tree/main/mcp-transport/mcp-webflux-sse-transport) - Reactive HTTP streaming with Spring WebFlux (Client & Server)
|
||||
- [WebMvc SSE Transport](https://github.com/spring-projects-experimental/spring-ai-mcp/tree/main/mcp-transport/mcp-webmvc-sse-transport) - Spring MVC based HTTP SSE transport (Server only).
|
||||
- [WebFlux SSE Transport](https://github.com/modelcontextprotocol/java-sdk/tree/main/mcp-transport/mcp-webflux-sse-transport) - Reactive HTTP streaming with Spring WebFlux (Client & Server)
|
||||
- [WebMvc SSE Transport](https://github.com/modelcontextprotocol/java-sdk/tree/main/mcp-transport/mcp-webmvc-sse-transport) - Spring MVC based HTTP SSE transport (Server only).
|
||||
You can use the core `HttpClientSseClientTransport` transport as a SSE client.
|
||||
|
||||
### [Spring AI MCP](https://docs.spring.io/spring-ai-mcp/reference/spring-mcp.html)
|
||||
|
|
@ -47,27 +47,21 @@ Add the following dependencies to your Maven project:
|
|||
```xml
|
||||
<!-- Core MCP -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Optional: WebFlux SSE transport -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-webflux-sse-transport</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Optional: WebMVC SSE transport -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-webmvc-sse-transport</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Optional: Spring AI integration -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<artifactId>spring-ai-mcp</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
This is a milestone release, not available on Maven Central.
|
||||
|
|
@ -105,7 +99,6 @@ Explore these MCP examples in the [spring-ai-examples/model-context-protocol](ht
|
|||
|
||||
- [Java MCP SDK documentation](mcp/README.md)
|
||||
- [Reference documentation](docs/ref-index.md)
|
||||
- [Spring Integration documentation](spring-ai-mcp/README.md)
|
||||
|
||||
|
||||
## Development
|
||||
|
|
@ -125,7 +118,7 @@ mvn test
|
|||
|
||||
## Contributing
|
||||
|
||||
This is an experimental Spring project. Contributions are welcome! Please:
|
||||
Contributions are welcome! Please:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
|
|
@ -138,9 +131,9 @@ This is an experimental Spring project. Contributions are welcome! Please:
|
|||
|
||||
## Links
|
||||
|
||||
- [GitHub Repository](https://github.com/spring-projects-experimental/spring-ai-mcp)
|
||||
- [Issue Tracker](https://github.com/spring-projects-experimental/spring-ai-mcp/issues)
|
||||
- [CI/CD](https://github.com/spring-projects-experimental/spring-ai-mcp/actions)
|
||||
- [GitHub Repository](https://github.com/modelcontextprotocol/java-sdk)
|
||||
- [Issue Tracker](https://github.com/modelcontextprotocol/java-sdk/issues)
|
||||
- [CI/CD](https://github.com/modelcontextprotocol/java-sdk/actions)
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-parent</artifactId>
|
||||
<version>0.7.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
|
@ -16,40 +16,40 @@
|
|||
<name>Spring AI MCP BOM</name>
|
||||
<description>Spring AI MCP Bill of Materials</description>
|
||||
|
||||
<url>https://github.com/spring-projects-experimental/spring-ai-mcp</url>
|
||||
<url>https://github.com/modelcontextprotocol/java-sdk</url>
|
||||
|
||||
<scm>
|
||||
<url>https://github.com/spring-projects-experimental/spring-ai-mcp</url>
|
||||
<connection>git://github.com/spring-projects-experimental/spring-ai-mcp.git</connection>
|
||||
<developerConnection>git@github.com/spring-projects-experimental/spring-ai-mcp.git</developerConnection>
|
||||
</scm>
|
||||
<scm>
|
||||
<url>https://github.com/modelcontextprotocol/java-sdk</url>
|
||||
<connection>git://github.com/modelcontextprotocol/java-sdk.git</connection>
|
||||
<developerConnection>git@github.com/modelcontextprotocol/java-sdk.git</developerConnection>
|
||||
</scm>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- Core MCP -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MCP Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-test</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MCP Transport - WebFlux SSE -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-webflux-sse-transport</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MCP Transport - WebMVC SSE -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-webmvc-sse-transport</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
# Breaking Changes for Spring AI MCP 0.5.0-SNAPSHOT
|
||||
|
||||
## Major Changes
|
||||
|
||||
1. Transport Implementation Modularization
|
||||
- SSE transport implementations have been moved to dedicated modules:
|
||||
- `mcp-webflux-sse-transport`: WebFlux-based SSE transport
|
||||
- `mcp-webmvc-sse-transport`: WebMVC-based SSE transport
|
||||
- Base MCP module no longer includes transport implementations
|
||||
|
||||
2. Class Renames
|
||||
- `SseClientTransport` → `WebFluxSseClientTransport`
|
||||
- `SseServerTransport` → `WebFluxSseServerTransport`
|
||||
|
||||
3. Project Structure
|
||||
- Test utilities moved to new `mcp-test` module
|
||||
- All samples moved to the the https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### 1. Update Dependencies
|
||||
|
||||
If using SSE transport, add the appropriate transport module to your pom.xml:
|
||||
|
||||
For WebFlux SSE transport:
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<artifactId>mcp-webflux-sse-transport</artifactId>
|
||||
<version>0.5.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
For WebMVC SSE transport:
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<artifactId>mcp-webmvc-sse-transport</artifactId>
|
||||
<version>0.5.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### 2. Update Transport Class References
|
||||
|
||||
Replace:
|
||||
```java
|
||||
import org.springframework.ai.mcp.client.transport.SseClientTransport;
|
||||
import org.springframework.ai.mcp.server.transport.SseServerTransport;
|
||||
```
|
||||
|
||||
With:
|
||||
```java
|
||||
// For WebFlux
|
||||
import org.springframework.ai.mcp.client.transport.WebFluxSseClientTransport;
|
||||
import org.springframework.ai.mcp.server.transport.WebFluxSseServerTransport;
|
||||
|
||||
// Or for WebMVC
|
||||
import org.springframework.ai.mcp.server.transport.WebMvcSseServerTransport;
|
||||
```
|
||||
|
||||
### 3. Update Transport Instantiation
|
||||
|
||||
Replace:
|
||||
```java
|
||||
var transport = new SseClientTransport(webClientBuilder);
|
||||
```
|
||||
|
||||
With:
|
||||
```java
|
||||
var transport = new WebFluxSseClientTransport(webClientBuilder);
|
||||
```
|
||||
|
||||
Replace:
|
||||
```java
|
||||
var serverTransport = new SseServerTransport(objectMapper, messageEndpoint);
|
||||
```
|
||||
|
||||
With:
|
||||
```java
|
||||
// For WebFlux
|
||||
var serverTransport = new WebFluxSseServerTransport(objectMapper, messageEndpoint);
|
||||
|
||||
// Or for WebMVC
|
||||
var serverTransport = new WebMvcSseServerTransport(objectMapper, messageEndpoint);
|
||||
```
|
||||
|
||||
### 4. Test Dependencies
|
||||
|
||||
If you're using MCP test utilities, add the test module dependency:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<artifactId>mcp-test</artifactId>
|
||||
<version>0.5.0-SNAPSHOT</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Other Changes
|
||||
|
||||
- Spring AI dependency updated to 1.0.0-M5
|
||||
- Improved dependency management and reduced transitive dependencies
|
||||
- Sample application restructured under samples/ directory
|
||||
- New test utilities and base test classes in mcp-test module
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
# Spring AI MCP 0.6.0 Migration Guide
|
||||
|
||||
This guide outlines the steps required to migrate your code to Spring AI MCP 0.6.0.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- New builder patterns for `McpClient` and `McpServer`
|
||||
- Introduction of dedicated feature classes for sync/async operations
|
||||
- Enhanced type safety and reactive support
|
||||
- Deprecated methods and classes marked for removal
|
||||
|
||||
## Client Migration
|
||||
|
||||
### Creating Clients
|
||||
|
||||
Before:
|
||||
```java
|
||||
// Sync client
|
||||
McpClient.using(transport)
|
||||
.requestTimeout(Duration.ofSeconds(5))
|
||||
.sync();
|
||||
|
||||
// Async client
|
||||
McpClient.using(transport)
|
||||
.requestTimeout(Duration.ofSeconds(5))
|
||||
.async();
|
||||
```
|
||||
|
||||
After:
|
||||
```java
|
||||
// Sync client
|
||||
McpClient.sync(transport)
|
||||
.requestTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
|
||||
// Async client
|
||||
McpClient.async(transport)
|
||||
.requestTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
```
|
||||
|
||||
### Change Consumers
|
||||
|
||||
Before:
|
||||
```java
|
||||
// Sync client
|
||||
McpClient.using(transport)
|
||||
.toolsChangeConsumer(tools -> handleTools(tools))
|
||||
.resourcesChangeConsumer(resources -> handleResources(resources))
|
||||
.promptsChangeConsumer(prompts -> handlePrompts(prompts))
|
||||
.sync();
|
||||
|
||||
// Async client
|
||||
McpClient.using(transport)
|
||||
.toolsChangeConsumer(tools -> handleTools(tools))
|
||||
.resourcesChangeConsumer(resources -> handleResources(resources))
|
||||
.promptsChangeConsumer(prompts -> handlePrompts(prompts))
|
||||
.async();
|
||||
```
|
||||
|
||||
After:
|
||||
```java
|
||||
// Sync client
|
||||
McpClient.sync(transport)
|
||||
.toolsChangeConsumer(tools -> handleTools(tools))
|
||||
.resourcesChangeConsumer(resources -> handleResources(resources))
|
||||
.promptsChangeConsumer(prompts -> handlePrompts(prompts))
|
||||
.build();
|
||||
|
||||
// Async client
|
||||
McpClient.async(transport)
|
||||
.toolsChangeConsumer(tools -> Mono.fromRunnable(() -> handleTools(tools)))
|
||||
.resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> handleResources(resources)))
|
||||
.promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> handlePrompts(prompts)))
|
||||
.build();
|
||||
```
|
||||
|
||||
### Sampling Handlers
|
||||
|
||||
Before:
|
||||
```java
|
||||
// Sync client
|
||||
McpClient.using(transport)
|
||||
.sampling(request -> new CreateMessageResult("response"))
|
||||
.sync();
|
||||
|
||||
// Async client
|
||||
McpClient.using(transport)
|
||||
.sampling(request -> new CreateMessageResult("response"))
|
||||
.async();
|
||||
```
|
||||
|
||||
After:
|
||||
```java
|
||||
// Sync client
|
||||
McpClient.sync(transport)
|
||||
.sampling(request -> new CreateMessageResult("response"))
|
||||
.build();
|
||||
|
||||
// Async client
|
||||
McpClient.async(transport)
|
||||
.sampling(request -> Mono.just(new CreateMessageResult("response")))
|
||||
.build();
|
||||
```
|
||||
|
||||
## Server Migration
|
||||
|
||||
### Creating Servers
|
||||
|
||||
Before:
|
||||
```java
|
||||
// Sync server
|
||||
McpServer.using(transport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.sync();
|
||||
|
||||
// Async server
|
||||
McpServer.using(transport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.async();
|
||||
```
|
||||
|
||||
After:
|
||||
```java
|
||||
// Sync server
|
||||
McpServer.sync(transport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
|
||||
// Async server
|
||||
McpServer.async(transport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
```
|
||||
|
||||
### Tool Registration
|
||||
|
||||
Before:
|
||||
```java
|
||||
// Using ToolRegistration record
|
||||
new ToolRegistration(
|
||||
new Tool("calculator", "Performs calculations", schema),
|
||||
args -> new CallToolResult("result")
|
||||
);
|
||||
```
|
||||
|
||||
After:
|
||||
```java
|
||||
// Sync server
|
||||
new McpServerFeatures.SyncToolRegistration(
|
||||
new Tool("calculator", "Performs calculations", schema),
|
||||
args -> new CallToolResult("result")
|
||||
);
|
||||
|
||||
// Async server
|
||||
new McpServerFeatures.AsyncToolRegistration(
|
||||
new Tool("calculator", "Performs calculations", schema),
|
||||
args -> Mono.just(new CallToolResult("result"))
|
||||
);
|
||||
```
|
||||
|
||||
### Resource Registration
|
||||
|
||||
Before:
|
||||
```java
|
||||
// Using ResourceRegistration record
|
||||
new ResourceRegistration(
|
||||
new Resource("docs", "Documentation", "text/markdown"),
|
||||
request -> new ReadResourceResult(content)
|
||||
);
|
||||
```
|
||||
|
||||
After:
|
||||
```java
|
||||
// Sync server
|
||||
new McpServerFeatures.SyncResourceRegistration(
|
||||
new Resource("docs", "Documentation", "text/markdown"),
|
||||
request -> new ReadResourceResult(content)
|
||||
);
|
||||
|
||||
// Async server
|
||||
new McpServerFeatures.AsyncResourceRegistration(
|
||||
new Resource("docs", "Documentation", "text/markdown"),
|
||||
request -> Mono.just(new ReadResourceResult(content))
|
||||
);
|
||||
```
|
||||
|
||||
### Prompt Registration
|
||||
|
||||
Before:
|
||||
```java
|
||||
// Using PromptRegistration record
|
||||
new PromptRegistration(
|
||||
new Prompt("analyze", "Code analysis"),
|
||||
request -> new GetPromptResult("result")
|
||||
);
|
||||
```
|
||||
|
||||
After:
|
||||
```java
|
||||
// Sync server
|
||||
new McpServerFeatures.SyncPromptRegistration(
|
||||
new Prompt("analyze", "Code analysis"),
|
||||
request -> new GetPromptResult("result")
|
||||
);
|
||||
|
||||
// Async server
|
||||
new McpServerFeatures.AsyncPromptRegistration(
|
||||
new Prompt("analyze", "Code analysis"),
|
||||
request -> Mono.just(new GetPromptResult("result"))
|
||||
);
|
||||
```
|
||||
|
||||
## Spring Integration Changes
|
||||
|
||||
### Tool Helper Changes
|
||||
|
||||
Before:
|
||||
```java
|
||||
ToolHelper.toToolRegistration(functionCallback);
|
||||
ToolHelper.toToolRegistration(functionCallbacks);
|
||||
```
|
||||
|
||||
After:
|
||||
```java
|
||||
ToolHelper.toSyncToolRegistration(functionCallback);
|
||||
ToolHelper.toSyncToolRegistration(functionCallbacks);
|
||||
```
|
||||
|
||||
## Deprecated APIs
|
||||
|
||||
The following APIs are deprecated and will be removed in a future release:
|
||||
|
||||
- `McpClient.using()` - Use `McpClient.sync()` or `McpClient.async()` instead
|
||||
- `McpServer.using()` - Use `McpServer.sync()` or `McpServer.async()` instead
|
||||
- `McpServer.ToolRegistration` - Use `McpServerFeatures.SyncToolRegistration` or `McpServerFeatures.AsyncToolRegistration` instead
|
||||
- `McpServer.ResourceRegistration` - Use `McpServerFeatures.SyncResourceRegistration` or `McpServerFeatures.AsyncResourceRegistration` instead
|
||||
- `McpServer.PromptRegistration` - Use `McpServerFeatures.SyncPromptRegistration` or `McpServerFeatures.AsyncPromptRegistration` instead
|
||||
- `ToolHelper.toToolRegistration()` - Use `ToolHelper.toSyncToolRegistration()` instead
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-parent</artifactId>
|
||||
<version>0.7.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
* xref:overview.adoc[Overview]
|
||||
** xref:dependency-management.adoc[Dependency Management]
|
||||
* xref:mcp.adoc[MCP Java SDK]
|
||||
* xref:spring-mcp.adoc[Spring AI MCP]
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ Maven::
|
|||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-bom</artifactId>
|
||||
<version>0.6.0-SNAPSHOT</version>
|
||||
<type>pom</type>
|
||||
|
|
@ -35,7 +35,7 @@ Gradle::
|
|||
[source,groovy,indent=0,subs="verbatim,quotes"]
|
||||
----
|
||||
dependencies {
|
||||
implementation platform("org.springframework.experimental:mcp-bom:0.6.0-SNAPSHOT")
|
||||
implementation platform("org.modelcontextprotocol.sdk:mcp-bom:0.6.0-SNAPSHOT")
|
||||
//...
|
||||
}
|
||||
----
|
||||
|
|
@ -53,17 +53,16 @@ The following dependencies are available and managed by the BOM:
|
|||
|
||||
=== Core Dependencies
|
||||
|
||||
* `org.springframework.experimental:mcp` - Core MCP library providing the base functionality and APIs for Model Context Protocol implementation.
|
||||
* `org.springframework.experimental:spring-ai-mcp` - Spring AI integration with MCP, providing Spring-specific features and utilities.
|
||||
* `org.modelcontextprotocol.sdk:mcp` - Core MCP library providing the base functionality and APIs for Model Context Protocol implementation.
|
||||
|
||||
=== Transport Dependencies
|
||||
|
||||
* `org.springframework.experimental:mcp-webflux-sse-transport` - WebFlux-based Server-Sent Events (SSE) transport implementation for reactive applications.
|
||||
* `org.springframework.experimental:mcp-webmvc-sse-transport` - WebMVC-based Server-Sent Events (SSE) transport implementation for servlet-based applications.
|
||||
* `org.modelcontextprotocol.sdk:mcp-webflux-sse-transport` - WebFlux-based Server-Sent Events (SSE) transport implementation for reactive applications.
|
||||
* `org.modelcontextprotocol.sdk:mcp-webmvc-sse-transport` - WebMVC-based Server-Sent Events (SSE) transport implementation for servlet-based applications.
|
||||
|
||||
=== Testing Dependencies
|
||||
|
||||
* `org.springframework.experimental:mcp-test` - Testing utilities and support for MCP-based applications.
|
||||
* `org.modelcontextprotocol.sdk:mcp-test` - Testing utilities and support for MCP-based applications.
|
||||
|
||||
[[repositories]]
|
||||
=== Milestone and Snapshot Repositories
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ The core MCP functionality:
|
|||
[source,xml]
|
||||
----
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp</artifactId>
|
||||
</dependency>
|
||||
----
|
||||
|
|
@ -46,13 +46,13 @@ For HTTP SSE transport implementations, add one of the following dependencies
|
|||
----
|
||||
<!-- Spring WebFlux-based SSE client and server transport -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-webflux-sse-transport</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring WebMVC-based SSE server transport -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-webmvc-sse-transport</artifactId>
|
||||
</dependency>
|
||||
----
|
||||
|
|
@ -64,7 +64,7 @@ The core MCP functionality:
|
|||
[source,groovy]
|
||||
----
|
||||
dependencies {
|
||||
implementation 'org.springframework.experimental:mcp'
|
||||
implementation 'org.modelcontextprotocol.sdk:mcp'
|
||||
}
|
||||
----
|
||||
+
|
||||
|
|
@ -74,10 +74,10 @@ For HTTP SSE transport implementations, add one of the following dependencies
|
|||
[source,groovy]
|
||||
----
|
||||
// Spring WebFlux-based SSE client and server transport
|
||||
implementation 'org.springframework.experimental:mcp-webflux-sse-transport'
|
||||
implementation 'org.modelcontextprotocol.sdk:mcp-webflux-sse-transport'
|
||||
|
||||
// Spring WebMVC-based SSE server transport
|
||||
implementation 'org.springframework.experimental:mcp-webmvc-sse-transport'
|
||||
implementation 'org.modelcontextprotocol.sdk:mcp-webmvc-sse-transport'
|
||||
----
|
||||
======
|
||||
|
||||
|
|
@ -543,7 +543,7 @@ public class McpServerConfig implements WebMvcConfigurer {
|
|||
}
|
||||
----
|
||||
+
|
||||
(You can implment non Spring web container as well link:https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/mcp/src/test/java/org/springframework/ai/mcp/server/transport/HttpServletSseServerTransportIntegrationTests.java[HttpServletSseServerTransportIntegrationTests])
|
||||
(You can implment non Spring web container as well link:https://github.com/modelcontextprotocol/java-sdk/blob/main/mcp/src/test/java/org/springframework/ai/mcp/server/transport/HttpServletSseServerTransportIntegrationTests.java[HttpServletSseServerTransportIntegrationTests])
|
||||
+
|
||||
Implements the MCP HTTP with SSE transport specification using the traditional Servlet API, providing:
|
||||
+
|
||||
|
|
|
|||
|
|
@ -25,20 +25,10 @@ Core implementation of the Model Context Protocol specification, providing:
|
|||
** Servlet-based SSE server (`HttpServletSseServerTransport`) for HTTP SSE Server streaming using traditional Servlet API
|
||||
|
||||
* *Optional SSE Transports*
|
||||
** link:https://github.com/spring-projects-experimental/spring-ai-mcp/tree/main/mcp-transport/mcp-webflux-sse-transport[WebFlux SSE Transport] - Reactive HTTP streaming with Spring WebFlux (Client & Server)
|
||||
** link:https://github.com/spring-projects-experimental/spring-ai-mcp/tree/main/mcp-transport/mcp-webmvc-sse-transport[WebMvc SSE Transport] - Spring MVC based SSE transport (Server only).
|
||||
** link:https://github.com/modelcontextprotocol/java-sdk/tree/main/mcp-transport/mcp-webflux-sse-transport[WebFlux SSE Transport] - Reactive HTTP streaming with Spring WebFlux (Client & Server)
|
||||
** link:https://github.com/modelcontextprotocol/java-sdk/tree/main/mcp-transport/mcp-webmvc-sse-transport[WebMvc SSE Transport] - Spring MVC based SSE transport (Server only).
|
||||
You can use the core `HttpClientSseClientTransport` transport as a SSE client.
|
||||
|
||||
=== xref:spring-mcp.adoc[Spring AI MCP]
|
||||
Spring integration features:
|
||||
|
||||
* Spring AI tool/function calling system integration
|
||||
* Bidirectional conversion between Spring AI function callbacks and MCP tools
|
||||
* JSON schema generation for tool input validation
|
||||
* Automatic type conversion and error handling
|
||||
* Spring-friendly MCP client abstractions
|
||||
* Auto-configurations (WIP)
|
||||
|
||||
== Getting Started
|
||||
|
||||
[tabs]
|
||||
|
|
@ -49,27 +39,22 @@ Maven::
|
|||
----
|
||||
<!-- Core MCP -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Optional: WebFlux SSE transport -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-webflux-sse-transport</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Optional: WebMVC SSE transport -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-webmvc-sse-transport</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Optional: Spring AI integration -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<artifactId>spring-ai-mcp</artifactId>
|
||||
</dependency>
|
||||
----
|
||||
+
|
||||
Add Spring milestone repository:
|
||||
|
|
@ -93,10 +78,9 @@ Gradle::
|
|||
[source,groovy]
|
||||
----
|
||||
dependencies {
|
||||
implementation 'org.springframework.experimental:mcp' // Core
|
||||
implementation 'org.springframework.experimental:mcp-webflux-sse-transport' // Optional
|
||||
implementation 'org.springframework.experimental:mcp-webmvc-sse-transport' // Optional
|
||||
implementation 'org.springframework.experimental:spring-ai-mcp' // Optional
|
||||
implementation 'org.modelcontextprotocol.sdk:mcp' // Core
|
||||
implementation 'org.modelcontextprotocol.sdk:mcp-webflux-sse-transport' // Optional
|
||||
implementation 'org.modelcontextprotocol.sdk:mcp-webmvc-sse-transport' // Optional
|
||||
}
|
||||
|
||||
repositories {
|
||||
|
|
|
|||
|
|
@ -1,162 +0,0 @@
|
|||
= Spring AI MCP Spring
|
||||
:page-title: Spring AI MCP Spring
|
||||
:doctype: book
|
||||
:icons: font
|
||||
:source-highlighter: highlight.js
|
||||
:toc: left
|
||||
|
||||
Spring Integration module for Model Control Protocol (MCP) that provides Spring-specific functionality for working with MCP clients.
|
||||
|
||||
== Overview
|
||||
|
||||
The `spring-ai-mcp` module is part of the https://github.com/spring-projects-experimental/spring-ai-mcp[Spring AI MCP] project. It provides Spring Framework integration for the Model Control Protocol (MCP), enabling seamless integration of MCP functionality within Spring applications.
|
||||
|
||||
== Features
|
||||
|
||||
* Spring integration for MCP clients
|
||||
* Bidirectional conversion between Spring AI function callbacks and MCP tools
|
||||
* JSON schema generation for tool input validation
|
||||
* Automatic type conversion and error handling
|
||||
* Integration with Spring AI's function calling capabilities
|
||||
|
||||
== Main Components
|
||||
|
||||
=== McpFunctionCallback
|
||||
|
||||
The `McpFunctionCallback` class implements Spring AI's `FunctionCallback` interface and provides integration between Spring AI's function calling system and MCP tools. Key features include:
|
||||
|
||||
* Automatic conversion between JSON and Java objects for tool arguments
|
||||
* Synchronous tool execution support
|
||||
* Error handling and result formatting
|
||||
* Integration with Spring AI's function calling system
|
||||
|
||||
Example usage:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
// Create an MCP client
|
||||
McpSyncClient mcpClient = McpClient.using(transport)
|
||||
.sync();
|
||||
|
||||
// Create a function callback for an MCP tool
|
||||
Tool calculatorTool = new Tool("calculator", "Basic calculator",
|
||||
Map.of("operation", "string", "a", "number", "b", "number"));
|
||||
FunctionCallback callback = new McpFunctionCallback(mcpClient, calculatorTool);
|
||||
|
||||
// Use the callback with Spring AI
|
||||
String result = callback.call("{\"operation\": \"add\", \"a\": 2, \"b\": 3}");
|
||||
----
|
||||
|
||||
=== ToolHelper
|
||||
|
||||
The `ToolHelper` utility class facilitates the integration between Spring AI's function callbacks and MCP's tool system. It provides methods for:
|
||||
|
||||
* Converting Spring AI's `FunctionCallback` instances to MCP tool registrations
|
||||
* Generating JSON schemas for tool input validation
|
||||
* Handling error cases and result formatting
|
||||
|
||||
Example usage:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
// Convert Spring AI function callbacks to MCP tool registrations
|
||||
List<FunctionCallback> callbacks = List.of(
|
||||
new CalculatorFunction(),
|
||||
new WeatherFunction()
|
||||
);
|
||||
List<ToolRegistration> tools = ToolHelper.toToolRegistration(callbacks);
|
||||
|
||||
// Generate JSON schema for tool inputs
|
||||
Map<String, Class<?>> inputTypes = Map.of(
|
||||
"calculator", CalculatorInput.class,
|
||||
"weather", WeatherInput.class
|
||||
);
|
||||
String schema = ToolHelper.generateJsonSchema(inputTypes);
|
||||
----
|
||||
|
||||
==== Converting Function Callbacks to Tools
|
||||
|
||||
The `ToolHelper` provides several methods to convert Spring AI function callbacks to MCP tools:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
// Convert a single function callback
|
||||
ToolRegistration tool = ToolHelper.toToolRegistration(myCallback);
|
||||
|
||||
// Convert multiple callbacks
|
||||
List<ToolRegistration> tools = ToolHelper.toToolRegistration(callback1, callback2);
|
||||
|
||||
// Convert a list of callbacks
|
||||
List<ToolRegistration> tools = ToolHelper.toToolRegistration(callbackList);
|
||||
----
|
||||
|
||||
==== JSON Schema Generation
|
||||
|
||||
The `ToolHelper` can generate JSON schemas for tool input validation:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
// Using default ObjectMapper
|
||||
String schema = ToolHelper.generateJsonSchema(inputTypes);
|
||||
|
||||
// Using custom ObjectMapper
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
String schema = ToolHelper.generateJsonSchema(inputTypes, mapper);
|
||||
----
|
||||
|
||||
The generated schema follows the JSON Schema Draft 2020-12 specification and:
|
||||
* Validates the structure of tool inputs
|
||||
* Excludes ToolContext class from schema generation
|
||||
* Uses Jackson's JsonSchemaGenerator for accurate type representation
|
||||
|
||||
== Usage
|
||||
|
||||
To use this module, add the following dependency to your Maven project:
|
||||
|
||||
[source,xml]
|
||||
----
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<artifactId>spring-ai-mcp</artifactId>
|
||||
</dependency>
|
||||
----
|
||||
|
||||
Reffer to the xref:dependency-management.adoc[Dependency Management] page for more information.
|
||||
|
||||
=== Example: Creating an MCP Tool Server with Spring AI Functions
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Configuration
|
||||
class McpConfig {
|
||||
|
||||
@Bean
|
||||
McpServer mcpServer(List<FunctionCallback> callbacks) {
|
||||
// Convert Spring AI callbacks to MCP tools
|
||||
List<ToolRegistration> tools = ToolHelper.toToolRegistration(callbacks);
|
||||
|
||||
return McpServer.using(transport)
|
||||
.info("spring-ai-server", "1.0.0")
|
||||
.tools(tools)
|
||||
.sync();
|
||||
}
|
||||
|
||||
@Bean
|
||||
FunctionCallback calculatorFunction() {
|
||||
return FunctionCallback.builder()
|
||||
.name("calculator")
|
||||
.description("Basic calculator")
|
||||
.function(input -> {
|
||||
// Function implementation
|
||||
return result;
|
||||
})
|
||||
.build();
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
This configuration:
|
||||
1. Creates Spring AI function callbacks
|
||||
2. Converts them to MCP tools using ToolHelper
|
||||
3. Registers the tools with an MCP server
|
||||
4. Makes the tools available for discovery and execution by MCP clients
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-parent</artifactId>
|
||||
<version>0.7.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
|
@ -12,17 +12,17 @@
|
|||
<packaging>jar</packaging>
|
||||
<name>Tests for the Java MCP SDK</name>
|
||||
<description>Provides some shared test fasilities for the MCP Java SDK</description>
|
||||
<url>https://github.com/spring-projects-experimental/spring-ai-mcp</url>
|
||||
<url>https://github.com/modelcontextprotocol/java-sdk</url>
|
||||
|
||||
<scm>
|
||||
<url>https://github.com/spring-projects-experimental/spring-ai-mcp</url>
|
||||
<connection>git://github.com/spring-projects-experimental/spring-ai-mcp.git</connection>
|
||||
<developerConnection>git@github.com:spring-projects-experimental/spring-ai-mcp.git</developerConnection>
|
||||
<url>https://github.com/modelcontextprotocol/java-sdk</url>
|
||||
<connection>git://github.com/modelcontextprotocol/java-sdk.git</connection>
|
||||
<developerConnection>git@github.com/modelcontextprotocol/java-sdk.git</developerConnection>
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp</artifactId>
|
||||
<version>0.7.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.modelcontextprotocol;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.modelcontextprotocol.spec.ClientMcpTransport;
|
||||
import org.modelcontextprotocol.spec.McpSchema;
|
||||
import org.modelcontextprotocol.spec.ServerMcpTransport;
|
||||
import org.modelcontextprotocol.spec.McpSchema.JSONRPCNotification;
|
||||
import org.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport {
|
||||
|
||||
private final AtomicInteger inboundMessageCount = new AtomicInteger(0);
|
||||
|
||||
private final Sinks.Many<McpSchema.JSONRPCMessage> outgoing = Sinks.many().multicast().onBackpressureBuffer();
|
||||
|
||||
private final Sinks.Many<McpSchema.JSONRPCMessage> inbound = Sinks.many().unicast().onBackpressureBuffer();
|
||||
|
||||
private final Flux<McpSchema.JSONRPCMessage> outboundView = outgoing.asFlux().cache(1);
|
||||
|
||||
public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) {
|
||||
if (inbound.tryEmitNext(message).isFailure()) {
|
||||
throw new RuntimeException("Failed to emit message " + message);
|
||||
}
|
||||
inboundMessageCount.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
|
||||
if (outgoing.tryEmitNext(message).isFailure()) {
|
||||
return Mono.error(new RuntimeException("Can't emit outgoing message " + message));
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
public McpSchema.JSONRPCRequest getLastSentMessageAsRequest() {
|
||||
return (JSONRPCRequest) outboundView.blockFirst();
|
||||
}
|
||||
|
||||
public McpSchema.JSONRPCNotification getLastSentMessageAsNotifiation() {
|
||||
return (JSONRPCNotification) outboundView.blockFirst();
|
||||
}
|
||||
|
||||
public McpSchema.JSONRPCMessage getLastSentMessage() {
|
||||
return outboundView.blockFirst();
|
||||
}
|
||||
|
||||
private volatile boolean connected = false;
|
||||
|
||||
@Override
|
||||
public Mono<Void> connect(Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler) {
|
||||
if (connected) {
|
||||
return Mono.error(new IllegalStateException("Already connected"));
|
||||
}
|
||||
connected = true;
|
||||
return inbound.asFlux()
|
||||
.publishOn(Schedulers.boundedElastic())
|
||||
.flatMap(message -> Mono.just(message).transform(handler))
|
||||
.doFinally(signal -> connected = false)
|
||||
.then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.defer(() -> {
|
||||
connected = false;
|
||||
outgoing.tryEmitComplete();
|
||||
inbound.tryEmitComplete();
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeReference<T> typeRef) {
|
||||
return new ObjectMapper().convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.modelcontextprotocol.client;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.modelcontextprotocol.spec.ClientMcpTransport;
|
||||
import org.modelcontextprotocol.spec.McpSchema;
|
||||
import org.modelcontextprotocol.spec.McpSchema.CallToolRequest;
|
||||
import org.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
||||
import org.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
|
||||
import org.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
|
||||
import org.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
|
||||
import org.modelcontextprotocol.spec.McpSchema.Prompt;
|
||||
import org.modelcontextprotocol.spec.McpSchema.Resource;
|
||||
import org.modelcontextprotocol.spec.McpSchema.Root;
|
||||
import org.modelcontextprotocol.spec.McpSchema.SubscribeRequest;
|
||||
import org.modelcontextprotocol.spec.McpSchema.Tool;
|
||||
import org.modelcontextprotocol.spec.McpSchema.UnsubscribeRequest;
|
||||
import org.modelcontextprotocol.spec.McpTransport;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Test suite for the {@link McpAsyncClient} that can be used with different
|
||||
* {@link McpTransport} implementations.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
public abstract class AbstractMcpAsyncClientTests {
|
||||
|
||||
private McpAsyncClient mcpAsyncClient;
|
||||
|
||||
protected ClientMcpTransport mcpTransport;
|
||||
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(20);
|
||||
|
||||
private static final String ECHO_TEST_MESSAGE = "Hello MCP Spring AI!";
|
||||
|
||||
abstract protected ClientMcpTransport createMcpTransport();
|
||||
|
||||
protected void onStart() {
|
||||
}
|
||||
|
||||
protected void onClose() {
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
onStart();
|
||||
this.mcpTransport = createMcpTransport();
|
||||
|
||||
assertThatCode(() -> {
|
||||
mcpAsyncClient = McpClient.async(mcpTransport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.build();
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (mcpAsyncClient != null) {
|
||||
assertThatCode(() -> mcpAsyncClient.closeGracefully().block(Duration.ofSeconds(10)))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConstructorWithInvalidArguments() {
|
||||
assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Transport must not be null");
|
||||
|
||||
assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build())
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Request timeout must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListTools() {
|
||||
StepVerifier.create(mcpAsyncClient.listTools(null)).consumeNextWith(result -> {
|
||||
assertThat(result.tools()).isNotNull().isNotEmpty();
|
||||
|
||||
Tool firstTool = result.tools().get(0);
|
||||
assertThat(firstTool.name()).isNotNull();
|
||||
assertThat(firstTool.description()).isNotNull();
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPing() {
|
||||
assertThatCode(() -> mcpAsyncClient.ping().block()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallTool() {
|
||||
CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE));
|
||||
|
||||
StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)).consumeNextWith(callToolResult -> {
|
||||
assertThat(callToolResult).isNotNull().satisfies(result -> {
|
||||
assertThat(result.content()).isNotNull();
|
||||
assertThat(result.isError()).isNull();
|
||||
});
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallToolWithInvalidTool() {
|
||||
CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", ECHO_TEST_MESSAGE));
|
||||
|
||||
assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResources() {
|
||||
StepVerifier.create(mcpAsyncClient.listResources(null)).consumeNextWith(resources -> {
|
||||
assertThat(resources).isNotNull().satisfies(result -> {
|
||||
assertThat(result.resources()).isNotNull();
|
||||
|
||||
if (!result.resources().isEmpty()) {
|
||||
Resource firstResource = result.resources().get(0);
|
||||
assertThat(firstResource.uri()).isNotNull();
|
||||
assertThat(firstResource.name()).isNotNull();
|
||||
}
|
||||
});
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMcpAsyncClientState() {
|
||||
assertThat(mcpAsyncClient).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListPrompts() {
|
||||
StepVerifier.create(mcpAsyncClient.listPrompts(null)).consumeNextWith(prompts -> {
|
||||
assertThat(prompts).isNotNull().satisfies(result -> {
|
||||
assertThat(result.prompts()).isNotNull();
|
||||
|
||||
if (!result.prompts().isEmpty()) {
|
||||
Prompt firstPrompt = result.prompts().get(0);
|
||||
assertThat(firstPrompt.name()).isNotNull();
|
||||
assertThat(firstPrompt.description()).isNotNull();
|
||||
}
|
||||
});
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetPrompt() {
|
||||
StepVerifier.create(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of())))
|
||||
.consumeNextWith(prompt -> {
|
||||
assertThat(prompt).isNotNull().satisfies(result -> {
|
||||
assertThat(result.messages()).isNotEmpty();
|
||||
assertThat(result.messages()).hasSize(1);
|
||||
});
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsListChanged() {
|
||||
assertThatCode(() -> mcpAsyncClient.rootsListChangedNotification().block()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInitializeWithRootsListProviders() {
|
||||
var transport = createMcpTransport();
|
||||
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.roots(new Root("file:///test/path", "test-root"))
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> client.initialize().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||
|
||||
assertThatCode(() -> client.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddRoot() {
|
||||
Root newRoot = new Root("file:///new/test/path", "new-test-root");
|
||||
assertThatCode(() -> mcpAsyncClient.addRoot(newRoot).block()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddRootWithNullValue() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.addRoot(null).block()).hasMessageContaining("Root must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveRoot() {
|
||||
Root root = new Root("file:///test/path/to/remove", "root-to-remove");
|
||||
assertThatCode(() -> {
|
||||
mcpAsyncClient.addRoot(root).block();
|
||||
mcpAsyncClient.removeRoot(root.uri()).block();
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveNonExistentRoot() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.removeRoot("nonexistent-uri").block())
|
||||
.hasMessageContaining("Root with uri 'nonexistent-uri' not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Disabled
|
||||
void testReadResource() {
|
||||
StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> {
|
||||
if (!resources.resources().isEmpty()) {
|
||||
Resource firstResource = resources.resources().get(0);
|
||||
StepVerifier.create(mcpAsyncClient.readResource(firstResource)).consumeNextWith(result -> {
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.contents()).isNotNull();
|
||||
}).verifyComplete();
|
||||
}
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourceTemplates() {
|
||||
StepVerifier.create(mcpAsyncClient.listResourceTemplates()).consumeNextWith(result -> {
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.resourceTemplates()).isNotNull();
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
// @Test
|
||||
void testResourceSubscription() {
|
||||
StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> {
|
||||
if (!resources.resources().isEmpty()) {
|
||||
Resource firstResource = resources.resources().get(0);
|
||||
|
||||
// Test subscribe
|
||||
StepVerifier.create(mcpAsyncClient.subscribeResource(new SubscribeRequest(firstResource.uri())))
|
||||
.verifyComplete();
|
||||
|
||||
// Test unsubscribe
|
||||
StepVerifier.create(mcpAsyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri())))
|
||||
.verifyComplete();
|
||||
}
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNotificationHandlers() {
|
||||
AtomicBoolean toolsNotificationReceived = new AtomicBoolean(false);
|
||||
AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false);
|
||||
AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false);
|
||||
|
||||
var transport = createMcpTransport();
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true)))
|
||||
.resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true)))
|
||||
.promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true)))
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> {
|
||||
client.initialize().block();
|
||||
// Trigger notifications
|
||||
client.sendResourcesListChanged().block();
|
||||
client.promptListChangedNotification().block();
|
||||
client.closeGracefully().block();
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInitializeWithSamplingCapability() {
|
||||
var transport = createMcpTransport();
|
||||
|
||||
var capabilities = ClientCapabilities.builder().sampling().build();
|
||||
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.capabilities(capabilities)
|
||||
.sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build()))
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> {
|
||||
client.initialize().block(Duration.ofSeconds(10));
|
||||
client.closeGracefully().block(Duration.ofSeconds(10));
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInitializeWithAllCapabilities() {
|
||||
var transport = createMcpTransport();
|
||||
|
||||
var capabilities = ClientCapabilities.builder()
|
||||
.experimental(Map.of("feature", "test"))
|
||||
.roots(true)
|
||||
.sampling()
|
||||
.build();
|
||||
|
||||
Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler = request -> Mono
|
||||
.just(CreateMessageResult.builder().message("test").model("test-model").build());
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.capabilities(capabilities)
|
||||
.sampling(samplingHandler)
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> {
|
||||
var result = client.initialize().block(Duration.ofSeconds(10));
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.capabilities()).isNotNull();
|
||||
client.closeGracefully().block(Duration.ofSeconds(10));
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Logging Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testLoggingLevels() {
|
||||
// Test all logging levels
|
||||
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
|
||||
StepVerifier.create(mcpAsyncClient.setLoggingLevel(level)).verifyComplete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoggingConsumer() {
|
||||
AtomicBoolean logReceived = new AtomicBoolean(false);
|
||||
var transport = createMcpTransport();
|
||||
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true)))
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> {
|
||||
client.initialize().block(Duration.ofSeconds(10));
|
||||
client.closeGracefully().block(Duration.ofSeconds(10));
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoggingWithNullNotification() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.setLoggingLevel(null).block())
|
||||
.hasMessageContaining("Logging level must not be null");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.modelcontextprotocol.client;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.modelcontextprotocol.spec.ClientMcpTransport;
|
||||
import org.modelcontextprotocol.spec.McpSchema;
|
||||
import org.modelcontextprotocol.spec.McpSchema.CallToolRequest;
|
||||
import org.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||
import org.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
||||
import org.modelcontextprotocol.spec.McpSchema.ListResourceTemplatesResult;
|
||||
import org.modelcontextprotocol.spec.McpSchema.ListResourcesResult;
|
||||
import org.modelcontextprotocol.spec.McpSchema.ListToolsResult;
|
||||
import org.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
|
||||
import org.modelcontextprotocol.spec.McpSchema.Resource;
|
||||
import org.modelcontextprotocol.spec.McpSchema.Root;
|
||||
import org.modelcontextprotocol.spec.McpSchema.SubscribeRequest;
|
||||
import org.modelcontextprotocol.spec.McpSchema.TextContent;
|
||||
import org.modelcontextprotocol.spec.McpSchema.Tool;
|
||||
import org.modelcontextprotocol.spec.McpSchema.UnsubscribeRequest;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Unit tests for MCP Client Session functionality.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
public abstract class AbstractMcpSyncClientTests {
|
||||
|
||||
private McpSyncClient mcpSyncClient;
|
||||
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
private static final String TEST_MESSAGE = "Hello MCP Spring AI!";
|
||||
|
||||
protected ClientMcpTransport mcpTransport;
|
||||
|
||||
abstract protected ClientMcpTransport createMcpTransport();
|
||||
|
||||
abstract protected void onStart();
|
||||
|
||||
abstract protected void onClose();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
onStart();
|
||||
this.mcpTransport = createMcpTransport();
|
||||
|
||||
assertThatCode(() -> {
|
||||
mcpSyncClient = McpClient.sync(mcpTransport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.build();
|
||||
mcpSyncClient.initialize();
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (mcpSyncClient != null) {
|
||||
assertThatCode(() -> mcpSyncClient.close()).doesNotThrowAnyException();
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConstructorWithInvalidArguments() {
|
||||
assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Transport must not be null");
|
||||
|
||||
assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build())
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Request timeout must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListTools() {
|
||||
ListToolsResult tools = mcpSyncClient.listTools(null);
|
||||
|
||||
assertThat(tools).isNotNull().satisfies(result -> {
|
||||
assertThat(result.tools()).isNotNull().isNotEmpty();
|
||||
|
||||
Tool firstTool = result.tools().get(0);
|
||||
assertThat(firstTool.name()).isNotNull();
|
||||
assertThat(firstTool.description()).isNotNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallTools() {
|
||||
CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4)));
|
||||
|
||||
assertThat(toolResult).isNotNull().satisfies(result -> {
|
||||
|
||||
assertThat(result.content()).hasSize(1);
|
||||
|
||||
TextContent content = (TextContent) result.content().get(0);
|
||||
|
||||
assertThat(content).isNotNull();
|
||||
assertThat(content.text()).isNotNull();
|
||||
assertThat(content.text()).contains("7");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPing() {
|
||||
assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallTool() {
|
||||
CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE));
|
||||
|
||||
CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest);
|
||||
|
||||
assertThat(callToolResult).isNotNull().satisfies(result -> {
|
||||
assertThat(result.content()).isNotNull();
|
||||
assertThat(result.isError()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallToolWithInvalidTool() {
|
||||
CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE));
|
||||
|
||||
assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsListChanged() {
|
||||
assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResources() {
|
||||
ListResourcesResult resources = mcpSyncClient.listResources(null);
|
||||
|
||||
assertThat(resources).isNotNull().satisfies(result -> {
|
||||
assertThat(result.resources()).isNotNull();
|
||||
|
||||
if (!result.resources().isEmpty()) {
|
||||
Resource firstResource = result.resources().get(0);
|
||||
assertThat(firstResource.uri()).isNotNull();
|
||||
assertThat(firstResource.name()).isNotNull();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testClientSessionState() {
|
||||
assertThat(mcpSyncClient).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInitializeWithRootsListProviders() {
|
||||
var transport = createMcpTransport();
|
||||
|
||||
var client = McpClient.sync(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.roots(new Root("file:///test/path", "test-root"))
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> {
|
||||
client.initialize();
|
||||
client.close();
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddRoot() {
|
||||
Root newRoot = new Root("file:///new/test/path", "new-test-root");
|
||||
assertThatCode(() -> mcpSyncClient.addRoot(newRoot)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddRootWithNullValue() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.addRoot(null)).hasMessageContaining("Root must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveRoot() {
|
||||
Root root = new Root("file:///test/path/to/remove", "root-to-remove");
|
||||
assertThatCode(() -> {
|
||||
mcpSyncClient.addRoot(root);
|
||||
mcpSyncClient.removeRoot(root.uri());
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveNonExistentRoot() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.removeRoot("nonexistent-uri"))
|
||||
.hasMessageContaining("Root with uri 'nonexistent-uri' not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReadResource() {
|
||||
ListResourcesResult resources = mcpSyncClient.listResources(null);
|
||||
|
||||
if (!resources.resources().isEmpty()) {
|
||||
Resource firstResource = resources.resources().get(0);
|
||||
ReadResourceResult result = mcpSyncClient.readResource(firstResource);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.contents()).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourceTemplates() {
|
||||
ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(null);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.resourceTemplates()).isNotNull();
|
||||
}
|
||||
|
||||
// @Test
|
||||
void testResourceSubscription() {
|
||||
ListResourcesResult resources = mcpSyncClient.listResources(null);
|
||||
|
||||
if (!resources.resources().isEmpty()) {
|
||||
Resource firstResource = resources.resources().get(0);
|
||||
|
||||
// Test subscribe
|
||||
assertThatCode(() -> mcpSyncClient.subscribeResource(new SubscribeRequest(firstResource.uri())))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
// Test unsubscribe
|
||||
assertThatCode(() -> mcpSyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri())))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNotificationHandlers() {
|
||||
AtomicBoolean toolsNotificationReceived = new AtomicBoolean(false);
|
||||
AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false);
|
||||
AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false);
|
||||
|
||||
var transport = createMcpTransport();
|
||||
var client = McpClient.sync(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.toolsChangeConsumer(tools -> toolsNotificationReceived.set(true))
|
||||
.resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true))
|
||||
.promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true))
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> {
|
||||
client.initialize();
|
||||
// Trigger notifications
|
||||
client.sendResourcesListChanged();
|
||||
client.promptListChangedNotification();
|
||||
client.close();
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Logging Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testLoggingLevels() {
|
||||
// Test all logging levels
|
||||
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
|
||||
assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoggingConsumer() {
|
||||
AtomicBoolean logReceived = new AtomicBoolean(false);
|
||||
var transport = createMcpTransport();
|
||||
|
||||
var client = McpClient.sync(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.loggingConsumer(notification -> logReceived.set(true))
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> {
|
||||
client.initialize();
|
||||
client.close();
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoggingWithNullNotification() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(null))
|
||||
.hasMessageContaining("Logging level must not be null");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,475 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.modelcontextprotocol.server;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.modelcontextprotocol.spec.McpError;
|
||||
import org.modelcontextprotocol.spec.McpSchema;
|
||||
import org.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||
import org.modelcontextprotocol.spec.McpSchema.GetPromptResult;
|
||||
import org.modelcontextprotocol.spec.McpSchema.Prompt;
|
||||
import org.modelcontextprotocol.spec.McpSchema.PromptMessage;
|
||||
import org.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
|
||||
import org.modelcontextprotocol.spec.McpSchema.Resource;
|
||||
import org.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
|
||||
import org.modelcontextprotocol.spec.McpSchema.Tool;
|
||||
import org.modelcontextprotocol.spec.McpTransport;
|
||||
import org.modelcontextprotocol.spec.ServerMcpTransport;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Test suite for the {@link McpAsyncServer} that can be used with different
|
||||
* {@link McpTransport} implementations.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
public abstract class AbstractMcpAsyncServerTests {
|
||||
|
||||
private static final String TEST_TOOL_NAME = "test-tool";
|
||||
|
||||
private static final String TEST_RESOURCE_URI = "test://resource";
|
||||
|
||||
private static final String TEST_PROMPT_NAME = "test-prompt";
|
||||
|
||||
abstract protected ServerMcpTransport createMcpTransport();
|
||||
|
||||
protected void onStart() {
|
||||
}
|
||||
|
||||
protected void onClose() {
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
onClose();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Server Lifecycle Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testConstructorWithInvalidArguments() {
|
||||
assertThatThrownBy(() -> McpServer.async(null)).isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Transport must not be null");
|
||||
|
||||
assertThatThrownBy(() -> McpServer.async(createMcpTransport()).serverInfo((McpSchema.Implementation) null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Server info must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGracefulShutdown() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.closeGracefully()).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testImmediateClose() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatCode(() -> mcpAsyncServer.close()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Tools Tests
|
||||
// ---------------------------------------
|
||||
String emptyJsonSchema = """
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
""";
|
||||
|
||||
@Test
|
||||
void testAddTool() {
|
||||
Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema);
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolRegistration(newTool,
|
||||
args -> Mono.just(new CallToolResult(List.of(), false)))))
|
||||
.verifyComplete();
|
||||
|
||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddDuplicateTool() {
|
||||
Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
|
||||
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tool(duplicateTool, args -> Mono.just(new CallToolResult(List.of(), false)))
|
||||
.build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolRegistration(duplicateTool,
|
||||
args -> Mono.just(new CallToolResult(List.of(), false)))))
|
||||
.verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class)
|
||||
.hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists");
|
||||
});
|
||||
|
||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveTool() {
|
||||
Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
|
||||
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tool(too, args -> Mono.just(new CallToolResult(List.of(), false)))
|
||||
.build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.removeTool(TEST_TOOL_NAME)).verifyComplete();
|
||||
|
||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveNonexistentTool() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.removeTool("nonexistent-tool")).verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class).hasMessage("Tool with name 'nonexistent-tool' not found");
|
||||
});
|
||||
|
||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNotifyToolsListChanged() {
|
||||
Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
|
||||
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tool(too, args -> Mono.just(new CallToolResult(List.of(), false)))
|
||||
.build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.notifyToolsListChanged()).verifyComplete();
|
||||
|
||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Resources Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testNotifyResourcesListChanged() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.notifyResourcesListChanged()).verifyComplete();
|
||||
|
||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddResource() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().resources(true, false).build())
|
||||
.build();
|
||||
|
||||
Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
|
||||
null);
|
||||
McpServerFeatures.AsyncResourceRegistration registration = new McpServerFeatures.AsyncResourceRegistration(
|
||||
resource, req -> Mono.just(new ReadResourceResult(List.of())));
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.addResource(registration)).verifyComplete();
|
||||
|
||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddResourceWithNullRegistration() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().resources(true, false).build())
|
||||
.build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.addResource((McpServerFeatures.AsyncResourceRegistration) null))
|
||||
.verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class).hasMessage("Resource must not be null");
|
||||
});
|
||||
|
||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddResourceWithoutCapability() {
|
||||
// Create a server without resource capabilities
|
||||
McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
|
||||
Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
|
||||
null);
|
||||
McpServerFeatures.AsyncResourceRegistration registration = new McpServerFeatures.AsyncResourceRegistration(
|
||||
resource, req -> Mono.just(new ReadResourceResult(List.of())));
|
||||
|
||||
StepVerifier.create(serverWithoutResources.addResource(registration)).verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class)
|
||||
.hasMessage("Server must be configured with resource capabilities");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveResourceWithoutCapability() {
|
||||
// Create a server without resource capabilities
|
||||
McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
|
||||
StepVerifier.create(serverWithoutResources.removeResource(TEST_RESOURCE_URI)).verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class)
|
||||
.hasMessage("Server must be configured with resource capabilities");
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Prompts Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testNotifyPromptsListChanged() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.notifyPromptsListChanged()).verifyComplete();
|
||||
|
||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddPromptWithNullRegistration() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().prompts(false).build())
|
||||
.build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.addPrompt((McpServerFeatures.AsyncPromptRegistration) null))
|
||||
.verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class).hasMessage("Prompt registration must not be null");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddPromptWithoutCapability() {
|
||||
// Create a server without prompt capabilities
|
||||
McpAsyncServer serverWithoutPrompts = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
|
||||
Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of());
|
||||
McpServerFeatures.AsyncPromptRegistration registration = new McpServerFeatures.AsyncPromptRegistration(prompt,
|
||||
req -> Mono.just(new GetPromptResult("Test prompt description", List
|
||||
.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))));
|
||||
|
||||
StepVerifier.create(serverWithoutPrompts.addPrompt(registration)).verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class)
|
||||
.hasMessage("Server must be configured with prompt capabilities");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemovePromptWithoutCapability() {
|
||||
// Create a server without prompt capabilities
|
||||
McpAsyncServer serverWithoutPrompts = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
|
||||
StepVerifier.create(serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class)
|
||||
.hasMessage("Server must be configured with prompt capabilities");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemovePrompt() {
|
||||
String TEST_PROMPT_NAME_TO_REMOVE = "TEST_PROMPT_NAME678";
|
||||
|
||||
Prompt prompt = new Prompt(TEST_PROMPT_NAME_TO_REMOVE, "Test Prompt", List.of());
|
||||
McpServerFeatures.AsyncPromptRegistration registration = new McpServerFeatures.AsyncPromptRegistration(prompt,
|
||||
req -> Mono.just(new GetPromptResult("Test prompt description", List
|
||||
.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))));
|
||||
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().prompts(true).build())
|
||||
.prompts(registration)
|
||||
.build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.removePrompt(TEST_PROMPT_NAME_TO_REMOVE)).verifyComplete();
|
||||
|
||||
assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveNonexistentPrompt() {
|
||||
var mcpAsyncServer2 = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().prompts(true).build())
|
||||
.build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer2.removePrompt("nonexistent-prompt")).verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class)
|
||||
.hasMessage("Prompt with name 'nonexistent-prompt' not found");
|
||||
});
|
||||
|
||||
assertThatCode(() -> mcpAsyncServer2.closeGracefully().block(Duration.ofSeconds(10)))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Roots Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testRootsChangeConsumers() {
|
||||
// Test with single consumer
|
||||
var rootsReceived = new McpSchema.Root[1];
|
||||
var consumerCalled = new boolean[1];
|
||||
|
||||
var singleConsumerServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.rootsChangeConsumers(List.of(roots -> Mono.fromRunnable(() -> {
|
||||
consumerCalled[0] = true;
|
||||
if (!roots.isEmpty()) {
|
||||
rootsReceived[0] = roots.get(0);
|
||||
}
|
||||
})))
|
||||
.build();
|
||||
|
||||
assertThat(singleConsumerServer).isNotNull();
|
||||
assertThatCode(() -> singleConsumerServer.closeGracefully().block(Duration.ofSeconds(10)))
|
||||
.doesNotThrowAnyException();
|
||||
onClose();
|
||||
|
||||
// Test with multiple consumers
|
||||
var consumer1Called = new boolean[1];
|
||||
var consumer2Called = new boolean[1];
|
||||
var rootsContent = new List[1];
|
||||
|
||||
var multipleConsumersServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.rootsChangeConsumers(List.of(roots -> Mono.fromRunnable(() -> {
|
||||
consumer1Called[0] = true;
|
||||
rootsContent[0] = roots;
|
||||
}), roots -> Mono.fromRunnable(() -> consumer2Called[0] = true)))
|
||||
.build();
|
||||
|
||||
assertThat(multipleConsumersServer).isNotNull();
|
||||
assertThatCode(() -> multipleConsumersServer.closeGracefully().block(Duration.ofSeconds(10)))
|
||||
.doesNotThrowAnyException();
|
||||
onClose();
|
||||
|
||||
// Test error handling
|
||||
var errorHandlingServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.rootsChangeConsumers(List.of(roots -> {
|
||||
throw new RuntimeException("Test error");
|
||||
}))
|
||||
.build();
|
||||
|
||||
assertThat(errorHandlingServer).isNotNull();
|
||||
assertThatCode(() -> errorHandlingServer.closeGracefully().block(Duration.ofSeconds(10)))
|
||||
.doesNotThrowAnyException();
|
||||
onClose();
|
||||
|
||||
// Test without consumers
|
||||
var noConsumersServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThat(noConsumersServer).isNotNull();
|
||||
assertThatCode(() -> noConsumersServer.closeGracefully().block(Duration.ofSeconds(10)))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Logging Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testLoggingLevels() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().logging().build())
|
||||
.build();
|
||||
|
||||
// Test all logging levels
|
||||
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
|
||||
var notification = McpSchema.LoggingMessageNotification.builder()
|
||||
.level(level)
|
||||
.logger("test-logger")
|
||||
.data("Test message with level " + level)
|
||||
.build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.loggingNotification(notification)).verifyComplete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoggingWithoutCapability() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().build()) // No logging capability
|
||||
.build();
|
||||
|
||||
var notification = McpSchema.LoggingMessageNotification.builder()
|
||||
.level(McpSchema.LoggingLevel.INFO)
|
||||
.logger("test-logger")
|
||||
.data("Test log message")
|
||||
.build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.loggingNotification(notification)).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoggingWithNullNotification() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().logging().build())
|
||||
.build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.loggingNotification(null)).verifyError(McpError.class);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,443 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.modelcontextprotocol.server;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.modelcontextprotocol.spec.McpError;
|
||||
import org.modelcontextprotocol.spec.McpSchema;
|
||||
import org.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||
import org.modelcontextprotocol.spec.McpSchema.GetPromptResult;
|
||||
import org.modelcontextprotocol.spec.McpSchema.Prompt;
|
||||
import org.modelcontextprotocol.spec.McpSchema.PromptMessage;
|
||||
import org.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
|
||||
import org.modelcontextprotocol.spec.McpSchema.Resource;
|
||||
import org.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
|
||||
import org.modelcontextprotocol.spec.McpSchema.Tool;
|
||||
import org.modelcontextprotocol.spec.McpTransport;
|
||||
import org.modelcontextprotocol.spec.ServerMcpTransport;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Test suite for the {@link McpSyncServer} that can be used with different
|
||||
* {@link McpTransport} implementations.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
public abstract class AbstractMcpSyncServerTests {
|
||||
|
||||
private static final String TEST_TOOL_NAME = "test-tool";
|
||||
|
||||
private static final String TEST_RESOURCE_URI = "test://resource";
|
||||
|
||||
private static final String TEST_PROMPT_NAME = "test-prompt";
|
||||
|
||||
abstract protected ServerMcpTransport createMcpTransport();
|
||||
|
||||
protected void onStart() {
|
||||
}
|
||||
|
||||
protected void onClose() {
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// onStart();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
onClose();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Server Lifecycle Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testConstructorWithInvalidArguments() {
|
||||
assertThatThrownBy(() -> McpServer.sync(null)).isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Transport must not be null");
|
||||
|
||||
assertThatThrownBy(() -> McpServer.sync(createMcpTransport()).serverInfo(null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Server info must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGracefulShutdown() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testImmediateClose() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.close()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAsyncServer() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThat(mcpSyncServer.getAsyncServer()).isNotNull();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Tools Tests
|
||||
// ---------------------------------------
|
||||
|
||||
String emptyJsonSchema = """
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
""";
|
||||
|
||||
@Test
|
||||
void testAddTool() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.build();
|
||||
|
||||
Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema);
|
||||
assertThatCode(() -> mcpSyncServer
|
||||
.addTool(new McpServerFeatures.SyncToolRegistration(newTool, args -> new CallToolResult(List.of(), false))))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddDuplicateTool() {
|
||||
Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
|
||||
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tool(duplicateTool, args -> new CallToolResult(List.of(), false))
|
||||
.build();
|
||||
|
||||
assertThatThrownBy(() -> mcpSyncServer.addTool(new McpServerFeatures.SyncToolRegistration(duplicateTool,
|
||||
args -> new CallToolResult(List.of(), false))))
|
||||
.isInstanceOf(McpError.class)
|
||||
.hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists");
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveTool() {
|
||||
Tool tool = new McpSchema.Tool(TEST_TOOL_NAME, "Test tool", emptyJsonSchema);
|
||||
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tool(tool, args -> new CallToolResult(List.of(), false))
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.removeTool(TEST_TOOL_NAME)).doesNotThrowAnyException();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveNonexistentTool() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.build();
|
||||
|
||||
assertThatThrownBy(() -> mcpSyncServer.removeTool("nonexistent-tool")).isInstanceOf(McpError.class)
|
||||
.hasMessage("Tool with name 'nonexistent-tool' not found");
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNotifyToolsListChanged() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.notifyToolsListChanged()).doesNotThrowAnyException();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Resources Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testNotifyResourcesListChanged() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.notifyResourcesListChanged()).doesNotThrowAnyException();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddResource() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().resources(true, false).build())
|
||||
.build();
|
||||
|
||||
Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
|
||||
null);
|
||||
McpServerFeatures.SyncResourceRegistration registration = new McpServerFeatures.SyncResourceRegistration(
|
||||
resource, req -> new ReadResourceResult(List.of()));
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.addResource(registration)).doesNotThrowAnyException();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddResourceWithNullRegistration() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().resources(true, false).build())
|
||||
.build();
|
||||
|
||||
assertThatThrownBy(() -> mcpSyncServer.addResource((McpServerFeatures.SyncResourceRegistration) null))
|
||||
.isInstanceOf(McpError.class)
|
||||
.hasMessage("Resource must not be null");
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddResourceWithoutCapability() {
|
||||
var serverWithoutResources = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
|
||||
null);
|
||||
McpServerFeatures.SyncResourceRegistration registration = new McpServerFeatures.SyncResourceRegistration(
|
||||
resource, req -> new ReadResourceResult(List.of()));
|
||||
|
||||
assertThatThrownBy(() -> serverWithoutResources.addResource(registration)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Server must be configured with resource capabilities");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveResourceWithoutCapability() {
|
||||
var serverWithoutResources = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Server must be configured with resource capabilities");
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Prompts Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testNotifyPromptsListChanged() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.notifyPromptsListChanged()).doesNotThrowAnyException();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddPromptWithNullRegistration() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().prompts(false).build())
|
||||
.build();
|
||||
|
||||
assertThatThrownBy(() -> mcpSyncServer.addPrompt((McpServerFeatures.SyncPromptRegistration) null))
|
||||
.isInstanceOf(McpError.class)
|
||||
.hasMessage("Prompt registration must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddPromptWithoutCapability() {
|
||||
var serverWithoutPrompts = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of());
|
||||
McpServerFeatures.SyncPromptRegistration registration = new McpServerFeatures.SyncPromptRegistration(prompt,
|
||||
req -> new GetPromptResult("Test prompt description", List
|
||||
.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))));
|
||||
|
||||
assertThatThrownBy(() -> serverWithoutPrompts.addPrompt(registration)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Server must be configured with prompt capabilities");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemovePromptWithoutCapability() {
|
||||
var serverWithoutPrompts = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatThrownBy(() -> serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Server must be configured with prompt capabilities");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemovePrompt() {
|
||||
Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of());
|
||||
McpServerFeatures.SyncPromptRegistration registration = new McpServerFeatures.SyncPromptRegistration(prompt,
|
||||
req -> new GetPromptResult("Test prompt description", List
|
||||
.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))));
|
||||
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().prompts(true).build())
|
||||
.prompts(registration)
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.removePrompt(TEST_PROMPT_NAME)).doesNotThrowAnyException();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveNonexistentPrompt() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().prompts(true).build())
|
||||
.build();
|
||||
|
||||
assertThatThrownBy(() -> mcpSyncServer.removePrompt("nonexistent-prompt")).isInstanceOf(McpError.class)
|
||||
.hasMessage("Prompt with name 'nonexistent-prompt' not found");
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Roots Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testRootsChangeConsumers() {
|
||||
// Test with single consumer
|
||||
var rootsReceived = new McpSchema.Root[1];
|
||||
var consumerCalled = new boolean[1];
|
||||
|
||||
var singleConsumerServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.rootsChangeConsumers(List.of(roots -> {
|
||||
consumerCalled[0] = true;
|
||||
if (!roots.isEmpty()) {
|
||||
rootsReceived[0] = roots.get(0);
|
||||
}
|
||||
}))
|
||||
.build();
|
||||
|
||||
assertThat(singleConsumerServer).isNotNull();
|
||||
assertThatCode(() -> singleConsumerServer.closeGracefully()).doesNotThrowAnyException();
|
||||
onClose();
|
||||
|
||||
// Test with multiple consumers
|
||||
var consumer1Called = new boolean[1];
|
||||
var consumer2Called = new boolean[1];
|
||||
var rootsContent = new List[1];
|
||||
|
||||
var multipleConsumersServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.rootsChangeConsumers(List.of(roots -> {
|
||||
consumer1Called[0] = true;
|
||||
rootsContent[0] = roots;
|
||||
}, roots -> consumer2Called[0] = true))
|
||||
.build();
|
||||
|
||||
assertThat(multipleConsumersServer).isNotNull();
|
||||
assertThatCode(() -> multipleConsumersServer.closeGracefully()).doesNotThrowAnyException();
|
||||
onClose();
|
||||
|
||||
// Test error handling
|
||||
var errorHandlingServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.rootsChangeConsumers(List.of(roots -> {
|
||||
throw new RuntimeException("Test error");
|
||||
}))
|
||||
.build();
|
||||
|
||||
assertThat(errorHandlingServer).isNotNull();
|
||||
assertThatCode(() -> errorHandlingServer.closeGracefully()).doesNotThrowAnyException();
|
||||
onClose();
|
||||
|
||||
// Test without consumers
|
||||
var noConsumersServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThat(noConsumersServer).isNotNull();
|
||||
assertThatCode(() -> noConsumersServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Logging Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testLoggingLevels() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().logging().build())
|
||||
.build();
|
||||
|
||||
// Test all logging levels
|
||||
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
|
||||
var notification = McpSchema.LoggingMessageNotification.builder()
|
||||
.level(level)
|
||||
.logger("test-logger")
|
||||
.data("Test message with level " + level)
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.loggingNotification(notification)).doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoggingWithoutCapability() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().build()) // No logging capability
|
||||
.build();
|
||||
|
||||
var notification = McpSchema.LoggingMessageNotification.builder()
|
||||
.level(McpSchema.LoggingLevel.INFO)
|
||||
.logger("test-logger")
|
||||
.data("Test log message")
|
||||
.build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.loggingNotification(notification)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoggingWithNullNotification() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransport())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().logging().build())
|
||||
.build();
|
||||
|
||||
assertThatThrownBy(() -> mcpSyncServer.loggingNotification(null)).isInstanceOf(McpError.class);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-webflux-sse-transport</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-parent</artifactId>
|
||||
<version>0.7.0-SNAPSHOT</version>
|
||||
<relativePath>../../pom.xml</relativePath>
|
||||
|
|
@ -13,23 +13,23 @@
|
|||
<packaging>jar</packaging>
|
||||
<name>WebFlux implementation of the Java MCP SSE transport</name>
|
||||
<description></description>
|
||||
<url>https://github.com/spring-projects-experimental/spring-ai-mcp</url>
|
||||
<url>https://github.com/modelcontextprotocol/java-sdk</url>
|
||||
|
||||
<scm>
|
||||
<url>https://github.com/spring-projects-experimental/spring-ai-mcp</url>
|
||||
<connection>git://github.com/spring-projects-experimental/spring-ai-mcp.git</connection>
|
||||
<developerConnection>git@github.com:spring-projects-experimental/spring-ai-mcp.git</developerConnection>
|
||||
<url>https://github.com/modelcontextprotocol/java-sdk</url>
|
||||
<connection>git://github.com/modelcontextprotocol/java-sdk.git</connection>
|
||||
<developerConnection>git@github.com/modelcontextprotocol/java-sdk.git</developerConnection>
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp</artifactId>
|
||||
<version>0.7.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-test</artifactId>
|
||||
<version>0.7.0-SNAPSHOT</version>
|
||||
<scope>test</scope>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,336 @@
|
|||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.modelcontextprotocol.client.transport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.modelcontextprotocol.spec.ClientMcpTransport;
|
||||
import org.modelcontextprotocol.spec.McpError;
|
||||
import org.modelcontextprotocol.spec.McpSchema;
|
||||
import org.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
|
||||
import org.modelcontextprotocol.util.Assert;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.Disposable;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import reactor.core.publisher.SynchronousSink;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import reactor.util.retry.Retry;
|
||||
import reactor.util.retry.Retry.RetrySignal;
|
||||
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
/**
|
||||
* Server-Sent Events (SSE) implementation of the
|
||||
* {@link org.modelcontextprotocol.spec.McpTransport} that follows the MCP HTTP with SSE
|
||||
* transport specification.
|
||||
*
|
||||
* <p>
|
||||
* This transport establishes a bidirectional communication channel where:
|
||||
* <ul>
|
||||
* <li>Inbound messages are received through an SSE connection from the server</li>
|
||||
* <li>Outbound messages are sent via HTTP POST requests to a server-provided
|
||||
* endpoint</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The message flow follows these steps:
|
||||
* <ol>
|
||||
* <li>The client establishes an SSE connection to the server's /sse endpoint</li>
|
||||
* <li>The server sends an 'endpoint' event containing the URI for sending messages</li>
|
||||
* </ol>
|
||||
*
|
||||
* This implementation uses {@link WebClient} for HTTP communications and supports JSON
|
||||
* serialization/deserialization of messages.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @see <a href=
|
||||
* "https://spec.modelcontextprotocol.io/specification/basic/transports/#http-with-sse">MCP
|
||||
* HTTP with SSE Transport Specification</a>
|
||||
*/
|
||||
public class WebFluxSseClientTransport implements ClientMcpTransport {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebFluxSseClientTransport.class);
|
||||
|
||||
/**
|
||||
* Event type for JSON-RPC messages received through the SSE connection. The server
|
||||
* sends messages with this event type to transmit JSON-RPC protocol data.
|
||||
*/
|
||||
private static final String MESSAGE_EVENT_TYPE = "message";
|
||||
|
||||
/**
|
||||
* Event type for receiving the message endpoint URI from the server. The server MUST
|
||||
* send this event when a client connects, providing the URI where the client should
|
||||
* send its messages via HTTP POST.
|
||||
*/
|
||||
private static final String ENDPOINT_EVENT_TYPE = "endpoint";
|
||||
|
||||
/**
|
||||
* Default SSE endpoint path as specified by the MCP transport specification. This
|
||||
* endpoint is used to establish the SSE connection with the server.
|
||||
*/
|
||||
private static final String SSE_ENDPOINT = "/sse";
|
||||
|
||||
/**
|
||||
* Type reference for parsing SSE events containing string data.
|
||||
*/
|
||||
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_TYPE = new ParameterizedTypeReference<>() {
|
||||
};
|
||||
|
||||
/**
|
||||
* WebClient instance for handling both SSE connections and HTTP POST requests. Used
|
||||
* for establishing the SSE connection and sending outbound messages.
|
||||
*/
|
||||
private final WebClient webClient;
|
||||
|
||||
/**
|
||||
* ObjectMapper for serializing outbound messages and deserializing inbound messages.
|
||||
* Handles conversion between JSON-RPC messages and their string representation.
|
||||
*/
|
||||
protected ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Subscription for the SSE connection handling inbound messages. Used for cleanup
|
||||
* during transport shutdown.
|
||||
*/
|
||||
private Disposable inboundSubscription;
|
||||
|
||||
/**
|
||||
* Flag indicating if the transport is in the process of shutting down. Used to
|
||||
* prevent new operations during shutdown and handle cleanup gracefully.
|
||||
*/
|
||||
private volatile boolean isClosing = false;
|
||||
|
||||
/**
|
||||
* Sink for managing the message endpoint URI provided by the server. Stores the most
|
||||
* recent endpoint URI and makes it available for outbound message processing.
|
||||
*/
|
||||
protected final Sinks.One<String> messageEndpointSink = Sinks.one();
|
||||
|
||||
/**
|
||||
* Constructs a new SseClientTransport with the specified WebClient builder. Uses a
|
||||
* default ObjectMapper instance for JSON processing.
|
||||
* @param webClientBuilder the WebClient.Builder to use for creating the WebClient
|
||||
* instance
|
||||
* @throws IllegalArgumentException if webClientBuilder is null
|
||||
*/
|
||||
public WebFluxSseClientTransport(WebClient.Builder webClientBuilder) {
|
||||
this(webClientBuilder, new ObjectMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new SseClientTransport with the specified WebClient builder and
|
||||
* ObjectMapper. Initializes both inbound and outbound message processing pipelines.
|
||||
* @param webClientBuilder the WebClient.Builder to use for creating the WebClient
|
||||
* instance
|
||||
* @param objectMapper the ObjectMapper to use for JSON processing
|
||||
* @throws IllegalArgumentException if either parameter is null
|
||||
*/
|
||||
public WebFluxSseClientTransport(WebClient.Builder webClientBuilder, ObjectMapper objectMapper) {
|
||||
Assert.notNull(objectMapper, "ObjectMapper must not be null");
|
||||
Assert.notNull(webClientBuilder, "WebClient.Builder must not be null");
|
||||
|
||||
this.objectMapper = objectMapper;
|
||||
this.webClient = webClientBuilder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes a connection to the MCP server using Server-Sent Events (SSE). This
|
||||
* method initiates the SSE connection and sets up the message processing pipeline.
|
||||
*
|
||||
* <p>
|
||||
* The connection process follows these steps:
|
||||
* <ol>
|
||||
* <li>Establishes an SSE connection to the server's /sse endpoint</li>
|
||||
* <li>Waits for the server to send an 'endpoint' event with the message posting
|
||||
* URI</li>
|
||||
* <li>Sets up message handling for incoming JSON-RPC messages</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>
|
||||
* The connection is considered established only after receiving the endpoint event
|
||||
* from the server.
|
||||
* @param handler a function that processes incoming JSON-RPC messages and returns
|
||||
* responses
|
||||
* @return a Mono that completes when the connection is fully established
|
||||
* @throws McpError if there's an error processing SSE events or if an unrecognized
|
||||
* event type is received
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler) {
|
||||
Flux<ServerSentEvent<String>> events = eventStream();
|
||||
this.inboundSubscription = events.concatMap(event -> Mono.just(event).<JSONRPCMessage>handle((e, s) -> {
|
||||
if (ENDPOINT_EVENT_TYPE.equals(event.event())) {
|
||||
String messageEndpointUri = event.data();
|
||||
if (messageEndpointSink.tryEmitValue(messageEndpointUri).isSuccess()) {
|
||||
s.complete();
|
||||
}
|
||||
else {
|
||||
// TODO: clarify with the spec if multiple events can be
|
||||
// received
|
||||
s.error(new McpError("Failed to handle SSE endpoint event"));
|
||||
}
|
||||
}
|
||||
else if (MESSAGE_EVENT_TYPE.equals(event.event())) {
|
||||
try {
|
||||
JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.objectMapper, event.data());
|
||||
s.next(message);
|
||||
}
|
||||
catch (IOException ioException) {
|
||||
s.error(ioException);
|
||||
}
|
||||
}
|
||||
else {
|
||||
s.error(new McpError("Received unrecognized SSE event type: " + event.event()));
|
||||
}
|
||||
}).transform(handler)).subscribe();
|
||||
|
||||
// The connection is established once the server sends the endpoint event
|
||||
return messageEndpointSink.asMono().then();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a JSON-RPC message to the server using the endpoint provided during
|
||||
* connection.
|
||||
*
|
||||
* <p>
|
||||
* Messages are sent via HTTP POST requests to the server-provided endpoint URI. The
|
||||
* message is serialized to JSON before transmission. If the transport is in the
|
||||
* process of closing, the message send operation is skipped gracefully.
|
||||
* @param message the JSON-RPC message to send
|
||||
* @return a Mono that completes when the message has been sent successfully
|
||||
* @throws RuntimeException if message serialization fails
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> sendMessage(JSONRPCMessage message) {
|
||||
// The messageEndpoint is the endpoint URI to send the messages
|
||||
// It is provided by the server as part of the endpoint event
|
||||
return messageEndpointSink.asMono().flatMap(messageEndpointUri -> {
|
||||
if (isClosing) {
|
||||
return Mono.empty();
|
||||
}
|
||||
try {
|
||||
String jsonText = this.objectMapper.writeValueAsString(message);
|
||||
return webClient.post()
|
||||
.uri(messageEndpointUri)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(jsonText)
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.doOnSuccess(response -> {
|
||||
logger.debug("Message sent successfully");
|
||||
})
|
||||
.doOnError(error -> {
|
||||
if (!isClosing) {
|
||||
logger.error("Error sending message: {}", error.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (IOException e) {
|
||||
if (!isClosing) {
|
||||
return Mono.error(new RuntimeException("Failed to serialize message", e));
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
}).then(); // TODO: Consider non-200-ok response
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes and starts the inbound SSE event processing. Establishes the SSE
|
||||
* connection and sets up event handling for both message and endpoint events.
|
||||
* Includes automatic retry logic for handling transient connection failures.
|
||||
*/
|
||||
// visible for tests
|
||||
protected Flux<ServerSentEvent<String>> eventStream() {// @formatter:off
|
||||
return this.webClient
|
||||
.get()
|
||||
.uri(SSE_ENDPOINT)
|
||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||
.retrieve()
|
||||
.bodyToFlux(SSE_TYPE)
|
||||
.retryWhen(Retry.from(retrySignal -> retrySignal.handle(inboundRetryHandler)));
|
||||
} // @formatter:on
|
||||
|
||||
/**
|
||||
* Retry handler for the inbound SSE stream. Implements the retry logic for handling
|
||||
* connection failures and other errors.
|
||||
*/
|
||||
private BiConsumer<RetrySignal, SynchronousSink<Object>> inboundRetryHandler = (retrySpec, sink) -> {
|
||||
if (isClosing) {
|
||||
logger.debug("SSE connection closed during shutdown");
|
||||
sink.error(retrySpec.failure());
|
||||
return;
|
||||
}
|
||||
if (retrySpec.failure() instanceof IOException) {
|
||||
logger.debug("Retrying SSE connection after IO error");
|
||||
sink.next(retrySpec);
|
||||
return;
|
||||
}
|
||||
logger.error("Fatal SSE error, not retrying: {}", retrySpec.failure().getMessage());
|
||||
sink.error(retrySpec.failure());
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements graceful shutdown of the transport. Cleans up all resources including
|
||||
* subscriptions and schedulers. Ensures orderly shutdown of both inbound and outbound
|
||||
* message processing.
|
||||
* @return a Mono that completes when shutdown is finished
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() { // @formatter:off
|
||||
return Mono.fromRunnable(() -> {
|
||||
isClosing = true;
|
||||
|
||||
// Dispose of subscriptions
|
||||
|
||||
if (inboundSubscription != null) {
|
||||
inboundSubscription.dispose();
|
||||
}
|
||||
|
||||
})
|
||||
.then()
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
} // @formatter:on
|
||||
|
||||
/**
|
||||
* Unmarshals data from a generic Object into the specified type using the configured
|
||||
* ObjectMapper.
|
||||
*
|
||||
* <p>
|
||||
* This method is particularly useful when working with JSON-RPC parameters or result
|
||||
* objects that need to be converted to specific Java types. It leverages Jackson's
|
||||
* type conversion capabilities to handle complex object structures.
|
||||
* @param <T> the target type to convert the data into
|
||||
* @param data the source object to convert
|
||||
* @param typeRef the TypeReference describing the target type
|
||||
* @return the unmarshaled object of type T
|
||||
* @throws IllegalArgumentException if the conversion cannot be performed
|
||||
*/
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeReference<T> typeRef) {
|
||||
return this.objectMapper.convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue