mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-15 00:01:58 +00:00
docs(java-mcp-server): update instructions for MCP Java SDK 2.x
- Replace SDK 0.14.1 patterns with SDK 2.0.1 builders and Map schemas. - Add the BOM, Gradle, and packaging rules. - Document schema validation, isError results, and structuredContent. - Add transport security, CORS, and Spring AI 2.0 guidance. - Replace sampling, protocol logging, and roots with alternatives. - Rewrite the Common Patterns with code that compiles on SDK 2.0.1. - Update the description in docs/README.instructions.md.
This commit is contained in:
@@ -1,553 +1,312 @@
|
||||
---
|
||||
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"
|
||||
description: 'Instructions for building Model Context Protocol (MCP) servers in Java with the MCP Java SDK 2.x'
|
||||
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));
|
||||
});
|
||||
```
|
||||
# Java MCP Server Development
|
||||
|
||||
These instructions apply to the [MCP Java SDK](https://github.com/modelcontextprotocol/java-sdk) 2.x. SDK 2.0.x supports the MCP specification revision [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) and the earlier revisions. SDK 3.x will add the revision [2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28).
|
||||
|
||||
## Instructions
|
||||
|
||||
- Import the BOM `io.modelcontextprotocol.sdk:mcp-bom:2.0.1` and add the `io.modelcontextprotocol.sdk:mcp` artifact.
|
||||
- In Gradle, import the BOM with `implementation(platform("io.modelcontextprotocol.sdk:mcp-bom:2.0.1"))`. Also add `testRuntimeOnly("org.junit.platform:junit-platform-launcher")`. Without it, the `test` task fails.
|
||||
- The `mcp` artifact contains the SDK core and the Jackson 3 JSON mapper. For Jackson 2, use `mcp-core` and `mcp-json-jackson2`.
|
||||
- Do not use code from SDK 0.x or 1.x. Read the [migration guide](https://github.com/modelcontextprotocol/java-sdk/blob/main/MIGRATION-2.0.md) when you update a server.
|
||||
- The packages are `io.modelcontextprotocol.server`, `io.modelcontextprotocol.spec`, and `io.modelcontextprotocol.json`. The protocol types are nested in `McpSchema`.
|
||||
- Make a server with `McpServer.sync(transport)` or `McpServer.async(transport)`. Set `serverInfo(name, version)` and `capabilities(...)`, and then call `build()`.
|
||||
- Use the sync server for most servers. Use the async server with Project Reactor (`Mono`) only when the tool code does not block a thread.
|
||||
- For stdio transport, use `new StdioServerTransportProvider(McpJsonDefaults.getMapper())`.
|
||||
- Enable only the capabilities that the server uses: `ServerCapabilities.builder().tools(false).resources(false, true).prompts(true).completions().build()`. `tools(listChanged)` and `prompts(listChanged)` enable list change notifications. `resources(subscribe, listChanged)` also enables resource subscriptions.
|
||||
- Register tools with `SyncToolSpecification.builder().tool(tool).callHandler((exchange, request) -> ...)`. The handler gets the arguments as a `Map` from `request.arguments()`.
|
||||
- Use the builders that take the required fields first: `Tool.builder(name, inputSchema)`, `Resource.builder(uri, name)`, `ResourceTemplate.builder(uriTemplate, name)`, `Prompt.builder(name)`, `TextContent.builder(text)`, and `ReadResourceResult.builder(contents)`. The builders without arguments are deprecated.
|
||||
- A required field must not be `null`. A `null` value causes an `IllegalArgumentException`.
|
||||
- Give `inputSchema` and `outputSchema` as `Map<String, Object>` values. The `JsonSchema` record is deprecated.
|
||||
- The server checks each schema against JSON Schema 2020-12 when you call `build()` or `addTool()`. An incorrect schema causes an `IllegalArgumentException`.
|
||||
- The SDK validates the arguments against `inputSchema` before it calls the handler. It also validates `structuredContent` against `outputSchema`. If the validation fails, the client gets a tool result with `isError` set to `true`.
|
||||
- If a tool has an `outputSchema`, return `structuredContent` in each successful result. Without it, the SDK returns a tool result with `isError` set to `true`.
|
||||
- For machine-readable output, set `outputSchema` on the tool and return `CallToolResult.builder().structuredContent(map)`. Also add the same data as text content for older clients.
|
||||
- Set tool hints with `ToolAnnotations.builder().readOnlyHint(true).build()`. Use `destructiveHint(true)` for a destructive tool.
|
||||
- Return `CallToolResult.builder().addTextContent(message).isError(true).build()` for an error that the model must read.
|
||||
- Do not throw an exception or an `McpError` for a tool failure. The client gets a JSON-RPC error, not a tool result. Many hosts do not show this error to the model.
|
||||
- Use `McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS).message(...).build()` only for a protocol error, for example an unknown resource URI.
|
||||
- Register resources with `SyncResourceSpecification`, resource templates with `SyncResourceTemplateSpecification`, prompts with `SyncPromptSpecification`, and completions with `SyncCompletionSpecification`.
|
||||
- Define resource templates with RFC 6570 URI templates, for example `users://{id}`.
|
||||
- Report progress with `exchange.progressNotification(...)` only when `request.progressToken()` is not `null`.
|
||||
- Get user input with `exchange.createElicitation(ElicitFormRequest.builder(message, requestedSchema).build())`. First, make sure that `exchange.getClientCapabilities().elicitation()` is not `null`.
|
||||
- Do not use sampling (`exchange.createMessage()`). The revision 2026-07-28 deprecates it. Call the LLM provider API directly.
|
||||
- Do not use protocol logging (`exchange.loggingNotification()`). The revision 2026-07-28 deprecates it. Log with SLF4J.
|
||||
- Do not use roots (`exchange.listRoots()`). The revision 2026-07-28 deprecates it. Get paths from tool arguments, resource URIs, or the server configuration.
|
||||
- Add or remove tools at runtime with `server.addTool(...)` and `server.removeTool(name)`. Then call `server.notifyToolsListChanged()`.
|
||||
- In a stdio server, stdout is the protocol channel. Do not call `System.out.println()`. Use `slf4j-simple` (it writes to stderr), or set the target of the Logback console appender to `System.err`.
|
||||
- For a remote server, use Streamable HTTP. The SSE transports are deprecated.
|
||||
- For a servlet container, use `HttpServletStreamableServerTransportProvider`. For a server without sessions, use `HttpServletStatelessServerTransport`.
|
||||
- The HTTP transports do not check the `Host` and `Origin` headers by default. Always set `securityValidator(DefaultServerTransportSecurityValidator.builder()...build())`. This also applies to the Spring AI starters.
|
||||
- For Spring Boot, use the MCP starters of [Spring AI](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-overview.html) 2.0 or later, for example `spring-ai-starter-mcp-server-webmvc`. Do not use the `io.modelcontextprotocol.sdk:mcp-spring-*` artifacts.
|
||||
- In Spring AI, write tools as bean methods with `@McpTool` and `@McpToolParam` (from `org.springframework.ai.mcp.annotation`).
|
||||
- For browser clients, configure CORS. Allow the `Mcp-*` request headers. Expose the `Mcp-Session-Id` response header.
|
||||
- Package a stdio server as one executable JAR with the `maven-shade-plugin`. In Gradle, use the `application` plugin and the start script from `installDist`. Do not use `./gradlew run` as the client command.
|
||||
- Test a server with `McpClient.sync(new StdioClientTransport(params, McpJsonDefaults.getMapper()))`. The SDK has no in-memory transport.
|
||||
- Test servers manually with the MCP Inspector.
|
||||
|
||||
## 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)
|
||||
- Give each tool one responsibility.
|
||||
- Write clear tool descriptions. The description tells the model when to use the tool.
|
||||
- Add a `description` to each property in `inputSchema`.
|
||||
- Put each tool specification in its own method. This keeps the tools easy to test.
|
||||
- In the async server, do not block the event loop. Run code that blocks with `Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())`.
|
||||
- Use environment variables for configuration.
|
||||
- Be careful when a tool gives access to the file system or the network.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
The snippets do not show all imports. The protocol types are nested in `io.modelcontextprotocol.spec.McpSchema`. `Mono` is in `reactor.core.publisher`, and `Schedulers` is in `reactor.core.scheduler`.
|
||||
|
||||
### Basic Server Setup (stdio)
|
||||
|
||||
```java
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.modelcontextprotocol.json.McpJsonDefaults;
|
||||
import io.modelcontextprotocol.server.McpServer;
|
||||
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
|
||||
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
|
||||
import io.modelcontextprotocol.spec.McpSchema.Tool;
|
||||
|
||||
public final class McpServerApp {
|
||||
|
||||
static SyncToolSpecification addTool() {
|
||||
Tool tool = Tool.builder("add", Map.of(
|
||||
"type", "object",
|
||||
"properties", Map.of(
|
||||
"a", Map.of("type", "integer", "description", "First number"),
|
||||
"b", Map.of("type", "integer", "description", "Second number")),
|
||||
"required", List.of("a", "b")))
|
||||
.description("Add two integers.")
|
||||
.build();
|
||||
|
||||
return SyncToolSpecification.builder()
|
||||
.tool(tool)
|
||||
.callHandler((exchange, request) -> {
|
||||
long a = ((Number) request.arguments().get("a")).longValue();
|
||||
long b = ((Number) request.arguments().get("b")).longValue();
|
||||
return CallToolResult.builder().addTextContent(String.valueOf(a + b)).build();
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
McpServer.sync(new StdioServerTransportProvider(McpJsonDefaults.getMapper()))
|
||||
.serverInfo("my-server", "0.1.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(false).build())
|
||||
.tools(addTool())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool with Structured Output
|
||||
|
||||
```java
|
||||
static SyncToolSpecification wordCountTool() {
|
||||
Tool tool = Tool.builder("word_count", Map.of(
|
||||
"type", "object",
|
||||
"properties", Map.of("text", Map.of("type", "string")),
|
||||
"required", List.of("text")))
|
||||
.description("Count the words in a text.")
|
||||
.outputSchema(Map.of(
|
||||
"type", "object",
|
||||
"properties", Map.of("words", Map.of("type", "integer")),
|
||||
"required", List.of("words")))
|
||||
.annotations(ToolAnnotations.builder().readOnlyHint(true).build())
|
||||
.build();
|
||||
|
||||
return SyncToolSpecification.builder()
|
||||
.tool(tool)
|
||||
.callHandler((exchange, request) -> {
|
||||
String text = (String) request.arguments().get("text");
|
||||
int words = text.isBlank() ? 0 : text.trim().split("\\s+").length;
|
||||
return CallToolResult.builder()
|
||||
.addTextContent("{\"words\":" + words + "}")
|
||||
.structuredContent(Map.of("words", words))
|
||||
.build();
|
||||
})
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```java
|
||||
static SyncToolSpecification divideTool() {
|
||||
Tool tool = Tool.builder("divide", Map.of(
|
||||
"type", "object",
|
||||
"properties", Map.of("a", Map.of("type", "number"), "b", Map.of("type", "number")),
|
||||
"required", List.of("a", "b")))
|
||||
.description("Divide a by b.")
|
||||
.build();
|
||||
|
||||
return SyncToolSpecification.builder()
|
||||
.tool(tool)
|
||||
.callHandler((exchange, request) -> {
|
||||
double a = ((Number) request.arguments().get("a")).doubleValue();
|
||||
double b = ((Number) request.arguments().get("b")).doubleValue();
|
||||
if (b == 0) {
|
||||
return CallToolResult.builder().addTextContent("b must not be zero.").isError(true).build();
|
||||
}
|
||||
return CallToolResult.builder().addTextContent(String.valueOf(a / b)).build();
|
||||
})
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
### Resources and Resource Templates
|
||||
|
||||
```java
|
||||
static SyncResourceSpecification configResource() {
|
||||
return new SyncResourceSpecification(
|
||||
Resource.builder("config://app", "app-config").mimeType("application/json").build(),
|
||||
(exchange, request) -> ReadResourceResult.builder(List.of(
|
||||
TextResourceContents.builder(request.uri(), "{\"debug\":false}")
|
||||
.mimeType("application/json")
|
||||
.build()))
|
||||
.build());
|
||||
}
|
||||
|
||||
static SyncResourceTemplateSpecification userResource() {
|
||||
return new SyncResourceTemplateSpecification(
|
||||
ResourceTemplate.builder("users://{id}", "user").build(),
|
||||
(exchange, request) -> {
|
||||
String id = request.uri().substring("users://".length());
|
||||
return ReadResourceResult.builder(List.of(
|
||||
TextResourceContents.builder(request.uri(), "User " + id).build()))
|
||||
.build();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Register them with `.resources(configResource())` and `.resourceTemplates(userResource())`.
|
||||
|
||||
### Prompt with Completion
|
||||
|
||||
```java
|
||||
static SyncPromptSpecification reviewPrompt() {
|
||||
Prompt prompt = Prompt.builder("code-review")
|
||||
.description("Review code in a language.")
|
||||
.arguments(List.of(PromptArgument.builder("language").required(true).build()))
|
||||
.build();
|
||||
|
||||
return new SyncPromptSpecification(prompt, (exchange, request) -> GetPromptResult.builder(List.of(
|
||||
PromptMessage.builder(Role.USER,
|
||||
TextContent.builder("Review this " + request.arguments().get("language") + " code.").build())
|
||||
.build()))
|
||||
.build());
|
||||
}
|
||||
|
||||
static SyncCompletionSpecification languageCompletion() {
|
||||
List<String> languages = List.of("java", "kotlin", "scala");
|
||||
return new SyncCompletionSpecification(new PromptReference("code-review"),
|
||||
(exchange, request) -> new CompleteResult(new CompleteResult.CompleteCompletion(
|
||||
languages.stream().filter(l -> l.startsWith(request.argument().value())).toList(), null, false)));
|
||||
}
|
||||
```
|
||||
|
||||
Register them with `.prompts(reviewPrompt())` and `.completions(languageCompletion())`.
|
||||
|
||||
### Async Tool with Progress
|
||||
|
||||
```java
|
||||
static AsyncToolSpecification lookupTool() {
|
||||
Tool tool = Tool.builder("lookup", Map.of(
|
||||
"type", "object",
|
||||
"properties", Map.of("query", Map.of("type", "string")),
|
||||
"required", List.of("query")))
|
||||
.description("Look up a query in a slow backend.")
|
||||
.build();
|
||||
|
||||
return AsyncToolSpecification.builder()
|
||||
.tool(tool)
|
||||
.callHandler((exchange, request) -> {
|
||||
Object token = request.progressToken();
|
||||
Mono<Void> progress = token == null ? Mono.empty()
|
||||
: exchange.progressNotification(
|
||||
ProgressNotification.builder(token, 0.5).total(1.0).message("Halfway").build());
|
||||
return progress.then(Mono.fromCallable(() -> slowLookup((String) request.arguments().get("query")))
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.map(text -> CallToolResult.builder().addTextContent(text).build()));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
Register it on `McpServer.async(transport)`.
|
||||
|
||||
### Streamable HTTP Server (Servlet)
|
||||
|
||||
```java
|
||||
HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider.builder()
|
||||
.jsonMapper(McpJsonDefaults.getMapper())
|
||||
.mcpEndpoint("/mcp")
|
||||
.securityValidator(DefaultServerTransportSecurityValidator.builder()
|
||||
.allowedHost("localhost:8080")
|
||||
.allowedOrigin("http://localhost:*")
|
||||
.build())
|
||||
.build();
|
||||
|
||||
McpServer.sync(transport)
|
||||
.serverInfo("my-server", "0.1.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(false).build())
|
||||
.tools(addTool())
|
||||
.build();
|
||||
```
|
||||
|
||||
The transport is a Jakarta Servlet. Register it in a servlet container at `/mcp` with async support.
|
||||
|
||||
### Spring AI Tool
|
||||
|
||||
```java
|
||||
@Component
|
||||
class CalculatorTools {
|
||||
|
||||
@McpTool(name = "add", description = "Add two integers")
|
||||
public int add(@McpToolParam(description = "First number") int a,
|
||||
@McpToolParam(description = "Second number") int b) {
|
||||
return a + b;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Test
|
||||
|
||||
```java
|
||||
class McpServerAppTest {
|
||||
|
||||
private static McpSyncClient client;
|
||||
|
||||
@BeforeAll
|
||||
static void startServer() {
|
||||
String java = Path.of(System.getProperty("java.home"), "bin", "java").toString();
|
||||
ServerParameters server = ServerParameters.builder(java)
|
||||
.args("-cp", System.getProperty("java.class.path"), McpServerApp.class.getName())
|
||||
.build();
|
||||
client = McpClient.sync(new StdioClientTransport(server, McpJsonDefaults.getMapper())).build();
|
||||
client.initialize();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopServer() {
|
||||
client.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void add() {
|
||||
CallToolResult result = client.callTool(
|
||||
CallToolRequest.builder("add").arguments(Map.of("a", 2, "b", 3)).build());
|
||||
assertEquals("5", ((TextContent) result.content().get(0)).text());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user