* Add Java MCP Server Development collection - Add Java MCP server best practices instruction - Add Java MCP server project generator prompt - Add Java MCP expert chat mode - Add Java MCP development collection manifest - Update category READMEs Features: - Server setup with official MCP Java SDK - Tool/resource/prompt handlers with reactive streams - Project Reactor for async processing - Spring Boot integration with starters - Stdio and HTTP transport support - JSON schema construction with fluent builders - Synchronous facade for blocking use cases - Maven and Gradle build configurations * Fix Java MCP generator prompt - Add missing import statements for handler classes - Add handler registration in test server setup --------- Co-authored-by: Aaron Powell <me@aaron-powell.com>
554 lines
14 KiB
Markdown
554 lines
14 KiB
Markdown
---
|
|
description: 'Best practices and patterns for building Model Context Protocol (MCP) servers in Java using the official MCP Java SDK with reactive streams and Spring integration.'
|
|
applyTo: "**/*.java, **/pom.xml, **/build.gradle, **/build.gradle.kts"
|
|
---
|
|
|
|
# Java MCP Server Development Guidelines
|
|
|
|
When building MCP servers in Java, follow these best practices and patterns using the official Java SDK.
|
|
|
|
## Dependencies
|
|
|
|
Add the MCP Java SDK to your Maven project:
|
|
|
|
```xml
|
|
<dependencies>
|
|
<dependency>
|
|
<groupId>io.modelcontextprotocol.sdk</groupId>
|
|
<artifactId>mcp</artifactId>
|
|
<version>0.14.1</version>
|
|
</dependency>
|
|
</dependencies>
|
|
```
|
|
|
|
Or for Gradle:
|
|
|
|
```kotlin
|
|
dependencies {
|
|
implementation("io.modelcontextprotocol.sdk:mcp:0.14.1")
|
|
}
|
|
```
|
|
|
|
## Server Setup
|
|
|
|
Create an MCP server using the builder pattern:
|
|
|
|
```java
|
|
import io.mcp.server.McpServer;
|
|
import io.mcp.server.McpServerBuilder;
|
|
import io.mcp.server.transport.StdioServerTransport;
|
|
|
|
McpServer server = McpServerBuilder.builder()
|
|
.serverInfo("my-server", "1.0.0")
|
|
.capabilities(capabilities -> capabilities
|
|
.tools(true)
|
|
.resources(true)
|
|
.prompts(true))
|
|
.build();
|
|
|
|
// Start with stdio transport
|
|
StdioServerTransport transport = new StdioServerTransport();
|
|
server.start(transport).subscribe();
|
|
```
|
|
|
|
## Adding Tools
|
|
|
|
Register tool handlers with the server:
|
|
|
|
```java
|
|
import io.mcp.server.tool.Tool;
|
|
import io.mcp.server.tool.ToolHandler;
|
|
import reactor.core.publisher.Mono;
|
|
|
|
// Define a tool
|
|
Tool searchTool = Tool.builder()
|
|
.name("search")
|
|
.description("Search for information")
|
|
.inputSchema(JsonSchema.object()
|
|
.property("query", JsonSchema.string()
|
|
.description("Search query")
|
|
.required(true))
|
|
.property("limit", JsonSchema.integer()
|
|
.description("Maximum results")
|
|
.defaultValue(10)))
|
|
.build();
|
|
|
|
// Register tool handler
|
|
server.addToolHandler("search", (arguments) -> {
|
|
String query = arguments.get("query").asText();
|
|
int limit = arguments.has("limit")
|
|
? arguments.get("limit").asInt()
|
|
: 10;
|
|
|
|
// Perform search
|
|
List<String> results = performSearch(query, limit);
|
|
|
|
return Mono.just(ToolResponse.success()
|
|
.addTextContent("Found " + results.size() + " results")
|
|
.build());
|
|
});
|
|
```
|
|
|
|
## Adding Resources
|
|
|
|
Implement resource handlers for data access:
|
|
|
|
```java
|
|
import io.mcp.server.resource.Resource;
|
|
import io.mcp.server.resource.ResourceHandler;
|
|
|
|
// Register resource list handler
|
|
server.addResourceListHandler(() -> {
|
|
List<Resource> resources = List.of(
|
|
Resource.builder()
|
|
.name("Data File")
|
|
.uri("resource://data/example.txt")
|
|
.description("Example data file")
|
|
.mimeType("text/plain")
|
|
.build()
|
|
);
|
|
return Mono.just(resources);
|
|
});
|
|
|
|
// Register resource read handler
|
|
server.addResourceReadHandler((uri) -> {
|
|
if (uri.equals("resource://data/example.txt")) {
|
|
String content = loadResourceContent(uri);
|
|
return Mono.just(ResourceContent.text(content, uri));
|
|
}
|
|
throw new ResourceNotFoundException(uri);
|
|
});
|
|
|
|
// Register resource subscribe handler
|
|
server.addResourceSubscribeHandler((uri) -> {
|
|
subscriptions.add(uri);
|
|
log.info("Client subscribed to {}", uri);
|
|
return Mono.empty();
|
|
});
|
|
```
|
|
|
|
## Adding Prompts
|
|
|
|
Implement prompt handlers for templated conversations:
|
|
|
|
```java
|
|
import io.mcp.server.prompt.Prompt;
|
|
import io.mcp.server.prompt.PromptMessage;
|
|
import io.mcp.server.prompt.PromptArgument;
|
|
|
|
// Register prompt list handler
|
|
server.addPromptListHandler(() -> {
|
|
List<Prompt> prompts = List.of(
|
|
Prompt.builder()
|
|
.name("analyze")
|
|
.description("Analyze a topic")
|
|
.argument(PromptArgument.builder()
|
|
.name("topic")
|
|
.description("Topic to analyze")
|
|
.required(true)
|
|
.build())
|
|
.argument(PromptArgument.builder()
|
|
.name("depth")
|
|
.description("Analysis depth")
|
|
.required(false)
|
|
.build())
|
|
.build()
|
|
);
|
|
return Mono.just(prompts);
|
|
});
|
|
|
|
// Register prompt get handler
|
|
server.addPromptGetHandler((name, arguments) -> {
|
|
if (name.equals("analyze")) {
|
|
String topic = arguments.getOrDefault("topic", "general");
|
|
String depth = arguments.getOrDefault("depth", "basic");
|
|
|
|
List<PromptMessage> messages = List.of(
|
|
PromptMessage.user("Please analyze this topic: " + topic),
|
|
PromptMessage.assistant("I'll provide a " + depth + " analysis of " + topic)
|
|
);
|
|
|
|
return Mono.just(PromptResult.builder()
|
|
.description("Analysis of " + topic + " at " + depth + " level")
|
|
.messages(messages)
|
|
.build());
|
|
}
|
|
throw new PromptNotFoundException(name);
|
|
});
|
|
```
|
|
|
|
## Reactive Streams Pattern
|
|
|
|
The Java SDK uses Reactive Streams (Project Reactor) for asynchronous processing:
|
|
|
|
```java
|
|
// Return Mono for single results
|
|
server.addToolHandler("process", (args) -> {
|
|
return Mono.fromCallable(() -> {
|
|
String result = expensiveOperation(args);
|
|
return ToolResponse.success()
|
|
.addTextContent(result)
|
|
.build();
|
|
}).subscribeOn(Schedulers.boundedElastic());
|
|
});
|
|
|
|
// Return Flux for streaming results
|
|
server.addResourceListHandler(() -> {
|
|
return Flux.fromIterable(getResources())
|
|
.map(r -> Resource.builder()
|
|
.uri(r.getUri())
|
|
.name(r.getName())
|
|
.build())
|
|
.collectList();
|
|
});
|
|
```
|
|
|
|
## Synchronous Facade
|
|
|
|
For blocking use cases, use the synchronous API:
|
|
|
|
```java
|
|
import io.mcp.server.McpSyncServer;
|
|
|
|
McpSyncServer syncServer = server.toSyncServer();
|
|
|
|
// Blocking tool handler
|
|
syncServer.addToolHandler("greet", (args) -> {
|
|
String name = args.get("name").asText();
|
|
return ToolResponse.success()
|
|
.addTextContent("Hello, " + name + "!")
|
|
.build();
|
|
});
|
|
```
|
|
|
|
## Transport Configuration
|
|
|
|
### Stdio Transport
|
|
|
|
For local subprocess communication:
|
|
|
|
```java
|
|
import io.mcp.server.transport.StdioServerTransport;
|
|
|
|
StdioServerTransport transport = new StdioServerTransport();
|
|
server.start(transport).block();
|
|
```
|
|
|
|
### HTTP Transport (Servlet)
|
|
|
|
For HTTP-based servers:
|
|
|
|
```java
|
|
import io.mcp.server.transport.ServletServerTransport;
|
|
import jakarta.servlet.http.HttpServlet;
|
|
|
|
public class McpServlet extends HttpServlet {
|
|
private final McpServer server;
|
|
private final ServletServerTransport transport;
|
|
|
|
public McpServlet() {
|
|
this.server = createMcpServer();
|
|
this.transport = new ServletServerTransport();
|
|
}
|
|
|
|
@Override
|
|
protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
|
|
transport.handleRequest(server, req, resp).block();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Spring Boot Integration
|
|
|
|
Use the Spring Boot starter for seamless integration:
|
|
|
|
```xml
|
|
<dependency>
|
|
<groupId>io.modelcontextprotocol.sdk</groupId>
|
|
<artifactId>mcp-spring-boot-starter</artifactId>
|
|
<version>0.14.1</version>
|
|
</dependency>
|
|
```
|
|
|
|
Configure the server with Spring:
|
|
|
|
```java
|
|
import org.springframework.context.annotation.Configuration;
|
|
import io.mcp.spring.McpServerConfigurer;
|
|
|
|
@Configuration
|
|
public class McpConfiguration {
|
|
|
|
@Bean
|
|
public McpServerConfigurer mcpServerConfigurer() {
|
|
return server -> server
|
|
.serverInfo("spring-server", "1.0.0")
|
|
.capabilities(cap -> cap
|
|
.tools(true)
|
|
.resources(true)
|
|
.prompts(true));
|
|
}
|
|
}
|
|
```
|
|
|
|
Register handlers as Spring beans:
|
|
|
|
```java
|
|
import org.springframework.stereotype.Component;
|
|
import io.mcp.spring.ToolHandler;
|
|
|
|
@Component
|
|
public class SearchToolHandler implements ToolHandler {
|
|
|
|
@Override
|
|
public String getName() {
|
|
return "search";
|
|
}
|
|
|
|
@Override
|
|
public Tool getTool() {
|
|
return Tool.builder()
|
|
.name("search")
|
|
.description("Search for information")
|
|
.inputSchema(JsonSchema.object()
|
|
.property("query", JsonSchema.string().required(true)))
|
|
.build();
|
|
}
|
|
|
|
@Override
|
|
public Mono<ToolResponse> handle(JsonNode arguments) {
|
|
String query = arguments.get("query").asText();
|
|
return Mono.just(ToolResponse.success()
|
|
.addTextContent("Search results for: " + query)
|
|
.build());
|
|
}
|
|
}
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
Use proper error handling with MCP exceptions:
|
|
|
|
```java
|
|
server.addToolHandler("risky", (args) -> {
|
|
return Mono.fromCallable(() -> {
|
|
try {
|
|
String result = riskyOperation(args);
|
|
return ToolResponse.success()
|
|
.addTextContent(result)
|
|
.build();
|
|
} catch (ValidationException e) {
|
|
return ToolResponse.error()
|
|
.message("Invalid input: " + e.getMessage())
|
|
.build();
|
|
} catch (Exception e) {
|
|
log.error("Unexpected error", e);
|
|
return ToolResponse.error()
|
|
.message("Internal error occurred")
|
|
.build();
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
## JSON Schema Construction
|
|
|
|
Use the fluent schema builder:
|
|
|
|
```java
|
|
import io.mcp.json.JsonSchema;
|
|
|
|
JsonSchema schema = JsonSchema.object()
|
|
.property("name", JsonSchema.string()
|
|
.description("User's name")
|
|
.minLength(1)
|
|
.maxLength(100)
|
|
.required(true))
|
|
.property("age", JsonSchema.integer()
|
|
.description("User's age")
|
|
.minimum(0)
|
|
.maximum(150))
|
|
.property("email", JsonSchema.string()
|
|
.description("Email address")
|
|
.format("email")
|
|
.required(true))
|
|
.property("tags", JsonSchema.array()
|
|
.items(JsonSchema.string())
|
|
.uniqueItems(true))
|
|
.additionalProperties(false)
|
|
.build();
|
|
```
|
|
|
|
## Logging and Observability
|
|
|
|
Use SLF4J for logging:
|
|
|
|
```java
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(MyMcpServer.class);
|
|
|
|
server.addToolHandler("process", (args) -> {
|
|
log.info("Tool called: process, args: {}", args);
|
|
|
|
return Mono.fromCallable(() -> {
|
|
String result = process(args);
|
|
log.debug("Processing completed successfully");
|
|
return ToolResponse.success()
|
|
.addTextContent(result)
|
|
.build();
|
|
}).doOnError(error -> {
|
|
log.error("Processing failed", error);
|
|
});
|
|
});
|
|
```
|
|
|
|
Propagate context with Reactor:
|
|
|
|
```java
|
|
import reactor.util.context.Context;
|
|
|
|
server.addToolHandler("traced", (args) -> {
|
|
return Mono.deferContextual(ctx -> {
|
|
String traceId = ctx.get("traceId");
|
|
log.info("Processing with traceId: {}", traceId);
|
|
|
|
return Mono.just(ToolResponse.success()
|
|
.addTextContent("Processed")
|
|
.build());
|
|
});
|
|
});
|
|
```
|
|
|
|
## Testing
|
|
|
|
Write tests using the synchronous API:
|
|
|
|
```java
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.Assertions.assertThat;
|
|
|
|
class McpServerTest {
|
|
|
|
@Test
|
|
void testToolHandler() {
|
|
McpServer server = createTestServer();
|
|
McpSyncServer syncServer = server.toSyncServer();
|
|
|
|
JsonNode args = objectMapper.createObjectNode()
|
|
.put("query", "test");
|
|
|
|
ToolResponse response = syncServer.callTool("search", args);
|
|
|
|
assertThat(response.isError()).isFalse();
|
|
assertThat(response.getContent()).hasSize(1);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Jackson Integration
|
|
|
|
The SDK uses Jackson for JSON serialization. Customize as needed:
|
|
|
|
```java
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
|
|
|
ObjectMapper mapper = new ObjectMapper();
|
|
mapper.registerModule(new JavaTimeModule());
|
|
|
|
// Use custom mapper with server
|
|
McpServer server = McpServerBuilder.builder()
|
|
.objectMapper(mapper)
|
|
.build();
|
|
```
|
|
|
|
## Content Types
|
|
|
|
Support multiple content types in responses:
|
|
|
|
```java
|
|
import io.mcp.server.content.Content;
|
|
|
|
server.addToolHandler("multi", (args) -> {
|
|
return Mono.just(ToolResponse.success()
|
|
.addTextContent("Plain text response")
|
|
.addImageContent(imageBytes, "image/png")
|
|
.addResourceContent("resource://data", "application/json", jsonData)
|
|
.build());
|
|
});
|
|
```
|
|
|
|
## Server Lifecycle
|
|
|
|
Properly manage server lifecycle:
|
|
|
|
```java
|
|
import reactor.core.Disposable;
|
|
|
|
Disposable serverDisposable = server.start(transport).subscribe();
|
|
|
|
// Graceful shutdown
|
|
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
|
log.info("Shutting down MCP server");
|
|
serverDisposable.dispose();
|
|
server.stop().block();
|
|
}));
|
|
```
|
|
|
|
## Common Patterns
|
|
|
|
### Request Validation
|
|
|
|
```java
|
|
server.addToolHandler("validate", (args) -> {
|
|
if (!args.has("required_field")) {
|
|
return Mono.just(ToolResponse.error()
|
|
.message("Missing required_field")
|
|
.build());
|
|
}
|
|
|
|
return processRequest(args);
|
|
});
|
|
```
|
|
|
|
### Async Operations
|
|
|
|
```java
|
|
server.addToolHandler("async", (args) -> {
|
|
return Mono.fromCallable(() -> callExternalApi(args))
|
|
.timeout(Duration.ofSeconds(30))
|
|
.onErrorResume(TimeoutException.class, e ->
|
|
Mono.just(ToolResponse.error()
|
|
.message("Operation timed out")
|
|
.build()))
|
|
.subscribeOn(Schedulers.boundedElastic());
|
|
});
|
|
```
|
|
|
|
### Resource Caching
|
|
|
|
```java
|
|
private final Map<String, String> cache = new ConcurrentHashMap<>();
|
|
|
|
server.addResourceReadHandler((uri) -> {
|
|
return Mono.fromCallable(() ->
|
|
cache.computeIfAbsent(uri, this::loadResource))
|
|
.map(content -> ResourceContent.text(content, uri));
|
|
});
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Use Reactive Streams** for async operations and backpressure
|
|
2. **Leverage Spring Boot** starter for enterprise applications
|
|
3. **Implement proper error handling** with specific error messages
|
|
4. **Use SLF4J** for logging, not System.out
|
|
5. **Validate inputs** in tool and prompt handlers
|
|
6. **Support graceful shutdown** with proper resource cleanup
|
|
7. **Use bounded elastic scheduler** for blocking operations
|
|
8. **Propagate context** for observability in reactive chains
|
|
9. **Test with synchronous API** for simplicity
|
|
10. **Follow Java naming conventions** (camelCase for methods, PascalCase for classes)
|