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),
|
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.
|
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)
|
If you have a question or a support request, please open a new discussion on [GitHub Discussions](https://github.com/modelcontextprotocol/java-sdk/discussions)
|
||||||
or ask a question on [StackOverflow](https://stackoverflow.com/questions/tagged/spring-ai-mcp).
|
|
||||||
|
|
||||||
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.
|
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
|
# 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).
|
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.
|
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
|
- Servlet-based SSE server (`HttpServletSseServerTransport`) for HTTP SSE Server streaming using traditional Servlet API
|
||||||
|
|
||||||
#### Optional SSE Transports
|
#### 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)
|
- [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/spring-projects-experimental/spring-ai-mcp/tree/main/mcp-transport/mcp-webmvc-sse-transport) - Spring MVC based HTTP SSE transport (Server only).
|
- [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.
|
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)
|
### [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
|
```xml
|
||||||
<!-- Core MCP -->
|
<!-- Core MCP -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp</artifactId>
|
<artifactId>mcp</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Optional: WebFlux SSE transport -->
|
<!-- Optional: WebFlux SSE transport -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-webflux-sse-transport</artifactId>
|
<artifactId>mcp-webflux-sse-transport</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Optional: WebMVC SSE transport -->
|
<!-- Optional: WebMVC SSE transport -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-webmvc-sse-transport</artifactId>
|
<artifactId>mcp-webmvc-sse-transport</artifactId>
|
||||||
</dependency>
|
</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.
|
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)
|
- [Java MCP SDK documentation](mcp/README.md)
|
||||||
- [Reference documentation](docs/ref-index.md)
|
- [Reference documentation](docs/ref-index.md)
|
||||||
- [Spring Integration documentation](spring-ai-mcp/README.md)
|
|
||||||
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
@ -125,7 +118,7 @@ mvn test
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
This is an experimental Spring project. Contributions are welcome! Please:
|
Contributions are welcome! Please:
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Create a feature branch
|
2. Create a feature branch
|
||||||
|
|
@ -138,9 +131,9 @@ This is an experimental Spring project. Contributions are welcome! Please:
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
|
||||||
- [GitHub Repository](https://github.com/spring-projects-experimental/spring-ai-mcp)
|
- [GitHub Repository](https://github.com/modelcontextprotocol/java-sdk)
|
||||||
- [Issue Tracker](https://github.com/spring-projects-experimental/spring-ai-mcp/issues)
|
- [Issue Tracker](https://github.com/modelcontextprotocol/java-sdk/issues)
|
||||||
- [CI/CD](https://github.com/spring-projects-experimental/spring-ai-mcp/actions)
|
- [CI/CD](https://github.com/modelcontextprotocol/java-sdk/actions)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-parent</artifactId>
|
<artifactId>mcp-parent</artifactId>
|
||||||
<version>0.7.0-SNAPSHOT</version>
|
<version>0.7.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
@ -16,40 +16,40 @@
|
||||||
<name>Spring AI MCP BOM</name>
|
<name>Spring AI MCP BOM</name>
|
||||||
<description>Spring AI MCP Bill of Materials</description>
|
<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>
|
<scm>
|
||||||
<url>https://github.com/spring-projects-experimental/spring-ai-mcp</url>
|
<url>https://github.com/modelcontextprotocol/java-sdk</url>
|
||||||
<connection>git://github.com/spring-projects-experimental/spring-ai-mcp.git</connection>
|
<connection>git://github.com/modelcontextprotocol/java-sdk.git</connection>
|
||||||
<developerConnection>git@github.com/spring-projects-experimental/spring-ai-mcp.git</developerConnection>
|
<developerConnection>git@github.com/modelcontextprotocol/java-sdk.git</developerConnection>
|
||||||
</scm>
|
</scm>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<!-- Core MCP -->
|
<!-- Core MCP -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp</artifactId>
|
<artifactId>mcp</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- MCP Test -->
|
<!-- MCP Test -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-test</artifactId>
|
<artifactId>mcp-test</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- MCP Transport - WebFlux SSE -->
|
<!-- MCP Transport - WebFlux SSE -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-webflux-sse-transport</artifactId>
|
<artifactId>mcp-webflux-sse-transport</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- MCP Transport - WebMVC SSE -->
|
<!-- MCP Transport - WebMVC SSE -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-webmvc-sse-transport</artifactId>
|
<artifactId>mcp-webmvc-sse-transport</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</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">
|
<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>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-parent</artifactId>
|
<artifactId>mcp-parent</artifactId>
|
||||||
<version>0.7.0-SNAPSHOT</version>
|
<version>0.7.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
* xref:overview.adoc[Overview]
|
* xref:overview.adoc[Overview]
|
||||||
** xref:dependency-management.adoc[Dependency Management]
|
** xref:dependency-management.adoc[Dependency Management]
|
||||||
* xref:mcp.adoc[MCP Java SDK]
|
* xref:mcp.adoc[MCP Java SDK]
|
||||||
* xref:spring-mcp.adoc[Spring AI MCP]
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ Maven::
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-bom</artifactId>
|
<artifactId>mcp-bom</artifactId>
|
||||||
<version>0.6.0-SNAPSHOT</version>
|
<version>0.6.0-SNAPSHOT</version>
|
||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
|
|
@ -35,7 +35,7 @@ Gradle::
|
||||||
[source,groovy,indent=0,subs="verbatim,quotes"]
|
[source,groovy,indent=0,subs="verbatim,quotes"]
|
||||||
----
|
----
|
||||||
dependencies {
|
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
|
=== Core Dependencies
|
||||||
|
|
||||||
* `org.springframework.experimental:mcp` - Core MCP library providing the base functionality and APIs for Model Context Protocol implementation.
|
* `org.modelcontextprotocol.sdk: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.
|
|
||||||
|
|
||||||
=== Transport Dependencies
|
=== Transport Dependencies
|
||||||
|
|
||||||
* `org.springframework.experimental:mcp-webflux-sse-transport` - WebFlux-based Server-Sent Events (SSE) transport implementation for reactive applications.
|
* `org.modelcontextprotocol.sdk: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-webmvc-sse-transport` - WebMVC-based Server-Sent Events (SSE) transport implementation for servlet-based applications.
|
||||||
|
|
||||||
=== Testing Dependencies
|
=== 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]]
|
[[repositories]]
|
||||||
=== Milestone and Snapshot Repositories
|
=== Milestone and Snapshot Repositories
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ The core MCP functionality:
|
||||||
[source,xml]
|
[source,xml]
|
||||||
----
|
----
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp</artifactId>
|
<artifactId>mcp</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
----
|
----
|
||||||
|
|
@ -46,13 +46,13 @@ For HTTP SSE transport implementations, add one of the following dependencies
|
||||||
----
|
----
|
||||||
<!-- Spring WebFlux-based SSE client and server transport -->
|
<!-- Spring WebFlux-based SSE client and server transport -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-webflux-sse-transport</artifactId>
|
<artifactId>mcp-webflux-sse-transport</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Spring WebMVC-based SSE server transport -->
|
<!-- Spring WebMVC-based SSE server transport -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-webmvc-sse-transport</artifactId>
|
<artifactId>mcp-webmvc-sse-transport</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
----
|
----
|
||||||
|
|
@ -64,7 +64,7 @@ The core MCP functionality:
|
||||||
[source,groovy]
|
[source,groovy]
|
||||||
----
|
----
|
||||||
dependencies {
|
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]
|
[source,groovy]
|
||||||
----
|
----
|
||||||
// Spring WebFlux-based SSE client and server transport
|
// 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
|
// 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:
|
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
|
** Servlet-based SSE server (`HttpServletSseServerTransport`) for HTTP SSE Server streaming using traditional Servlet API
|
||||||
|
|
||||||
* *Optional SSE Transports*
|
* *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/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/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-webmvc-sse-transport[WebMvc SSE Transport] - Spring MVC based SSE transport (Server only).
|
||||||
You can use the core `HttpClientSseClientTransport` transport as a SSE client.
|
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
|
== Getting Started
|
||||||
|
|
||||||
[tabs]
|
[tabs]
|
||||||
|
|
@ -49,27 +39,22 @@ Maven::
|
||||||
----
|
----
|
||||||
<!-- Core MCP -->
|
<!-- Core MCP -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp</artifactId>
|
<artifactId>mcp</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Optional: WebFlux SSE transport -->
|
<!-- Optional: WebFlux SSE transport -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-webflux-sse-transport</artifactId>
|
<artifactId>mcp-webflux-sse-transport</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Optional: WebMVC SSE transport -->
|
<!-- Optional: WebMVC SSE transport -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-webmvc-sse-transport</artifactId>
|
<artifactId>mcp-webmvc-sse-transport</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Optional: Spring AI integration -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.experimental</groupId>
|
|
||||||
<artifactId>spring-ai-mcp</artifactId>
|
|
||||||
</dependency>
|
|
||||||
----
|
----
|
||||||
+
|
+
|
||||||
Add Spring milestone repository:
|
Add Spring milestone repository:
|
||||||
|
|
@ -93,10 +78,9 @@ Gradle::
|
||||||
[source,groovy]
|
[source,groovy]
|
||||||
----
|
----
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.springframework.experimental:mcp' // Core
|
implementation 'org.modelcontextprotocol.sdk:mcp' // Core
|
||||||
implementation 'org.springframework.experimental:mcp-webflux-sse-transport' // Optional
|
implementation 'org.modelcontextprotocol.sdk:mcp-webflux-sse-transport' // Optional
|
||||||
implementation 'org.springframework.experimental:mcp-webmvc-sse-transport' // Optional
|
implementation 'org.modelcontextprotocol.sdk:mcp-webmvc-sse-transport' // Optional
|
||||||
implementation 'org.springframework.experimental:spring-ai-mcp' // Optional
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
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">
|
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>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-parent</artifactId>
|
<artifactId>mcp-parent</artifactId>
|
||||||
<version>0.7.0-SNAPSHOT</version>
|
<version>0.7.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
@ -12,17 +12,17 @@
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
<name>Tests for the Java MCP SDK</name>
|
<name>Tests for the Java MCP SDK</name>
|
||||||
<description>Provides some shared test fasilities for the MCP Java SDK</description>
|
<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>
|
<scm>
|
||||||
<url>https://github.com/spring-projects-experimental/spring-ai-mcp</url>
|
<url>https://github.com/modelcontextprotocol/java-sdk</url>
|
||||||
<connection>git://github.com/spring-projects-experimental/spring-ai-mcp.git</connection>
|
<connection>git://github.com/modelcontextprotocol/java-sdk.git</connection>
|
||||||
<developerConnection>git@github.com:spring-projects-experimental/spring-ai-mcp.git</developerConnection>
|
<developerConnection>git@github.com/modelcontextprotocol/java-sdk.git</developerConnection>
|
||||||
</scm>
|
</scm>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp</artifactId>
|
<artifactId>mcp</artifactId>
|
||||||
<version>0.7.0-SNAPSHOT</version>
|
<version>0.7.0-SNAPSHOT</version>
|
||||||
</dependency>
|
</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
|
```xml
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-webflux-sse-transport</artifactId>
|
<artifactId>mcp-webflux-sse-transport</artifactId>
|
||||||
</dependency>
|
</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">
|
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>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-parent</artifactId>
|
<artifactId>mcp-parent</artifactId>
|
||||||
<version>0.7.0-SNAPSHOT</version>
|
<version>0.7.0-SNAPSHOT</version>
|
||||||
<relativePath>../../pom.xml</relativePath>
|
<relativePath>../../pom.xml</relativePath>
|
||||||
|
|
@ -13,23 +13,23 @@
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
<name>WebFlux implementation of the Java MCP SSE transport</name>
|
<name>WebFlux implementation of the Java MCP SSE transport</name>
|
||||||
<description></description>
|
<description></description>
|
||||||
<url>https://github.com/spring-projects-experimental/spring-ai-mcp</url>
|
<url>https://github.com/modelcontextprotocol/java-sdk</url>
|
||||||
|
|
||||||
<scm>
|
<scm>
|
||||||
<url>https://github.com/spring-projects-experimental/spring-ai-mcp</url>
|
<url>https://github.com/modelcontextprotocol/java-sdk</url>
|
||||||
<connection>git://github.com/spring-projects-experimental/spring-ai-mcp.git</connection>
|
<connection>git://github.com/modelcontextprotocol/java-sdk.git</connection>
|
||||||
<developerConnection>git@github.com:spring-projects-experimental/spring-ai-mcp.git</developerConnection>
|
<developerConnection>git@github.com/modelcontextprotocol/java-sdk.git</developerConnection>
|
||||||
</scm>
|
</scm>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp</artifactId>
|
<artifactId>mcp</artifactId>
|
||||||
<version>0.7.0-SNAPSHOT</version>
|
<version>0.7.0-SNAPSHOT</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.experimental</groupId>
|
<groupId>org.modelcontextprotocol.sdk</groupId>
|
||||||
<artifactId>mcp-test</artifactId>
|
<artifactId>mcp-test</artifactId>
|
||||||
<version>0.7.0-SNAPSHOT</version>
|
<version>0.7.0-SNAPSHOT</version>
|
||||||
<scope>test</scope>
|
<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