fix(mcp): align SDK and wire streamable server manually
All checks were successful
Build and publish Docker image / Build and push CPU image (push) Successful in 2m10s
Build and publish Docker image / Build and push GPU image (push) Successful in 3m2s

- align all io.modelcontextprotocol.sdk artifacts to 0.18.1 via
  dependencyManagement so Spring AI transitives no longer pull mcp 0.10.0
- exclude Spring AI's legacy MCP server/webmvc auto-config, which is binary-
  incompatible with the 0.18.1 streamable transport APIs
- build McpSyncServer directly against WebMvcStreamableServerTransportProvider
  and adapt Spring AI ToolCallbacks to MCP SyncToolSpecifications manually
- keep /mcp as the sole Streamable HTTP endpoint for both initialize/tool calls
  and optional SSE event streams
- update MCP transport documentation to match the new runtime

Validated locally with:
- POST /mcp initialize -> HTTP 200 + Mcp-Session-Id
- POST /mcp tools/list -> returns resolve-library-id + get-library-docs
This commit is contained in:
moze
2026-05-06 03:05:22 +02:00
parent 343a4ff3c3
commit e54e1dd33b
6 changed files with 128 additions and 20 deletions

View File

@@ -57,12 +57,10 @@
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-spring-webmvc</artifactId>
<version>0.18.1</version>
</dependency>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-json-jackson2</artifactId>
<version>0.18.1</version>
</dependency>
<!-- H2 + Flyway -->

View File

@@ -1,10 +1,20 @@
package com.trueref.adapter.in.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.ai.mcp.server.autoconfigure.McpServerProperties;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -14,16 +24,16 @@ import org.springframework.web.servlet.function.ServerResponse;
/**
* Wires the MCP Streamable HTTP transport (2025-03-26 spec) and registers trueref tool callbacks.
*
* <p>Spring AI 1.0.0 only ships {@link io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider}
* (legacy SSE). We exclude {@code McpWebMvcServerAutoConfiguration} in {@code application.yml} and
* register {@link WebMvcStreamableServerTransportProvider} directly — Spring AI's
* {@code McpServerAutoConfiguration} accepts any {@code McpServerTransportProvider} bean.
* <p>Spring AI 1.0.0 only auto-configures the legacy SSE/stdIO MCP server path. The current
* Streamable HTTP provider in MCP SDK 0.18.1 uses the newer
* {@code McpStreamableServerTransportProvider} interface, so we exclude Spring AI's MCP server
* auto-configuration in {@code application.yml} and build the {@link McpSyncServer} manually.
*
* <p>Clients connect with {@code type: http} at {@code POST /mcp} (JSON-RPC) and optionally open
* a long-poll GET {@code /mcp} stream for server-initiated notifications.
*/
@Configuration
@EnableConfigurationProperties(McpProperties.class)
@EnableConfigurationProperties({McpProperties.class, McpServerProperties.class})
public class McpConfig {
/** Streamable HTTP transport — handles both POST (JSON-RPC) and GET (SSE stream) on /mcp. */
@@ -42,8 +52,85 @@ public class McpConfig {
return transport.getRouterFunction();
}
@Bean
public McpSyncServer mcpSyncServer(
WebMvcStreamableServerTransportProvider transport,
ObjectMapper objectMapper,
McpServerProperties serverProperties,
ObjectProvider<List<ToolCallback>> toolCallbacks,
List<ToolCallbackProvider> toolCallbackProviders) {
var jsonMapper = new JacksonMcpJsonMapper(objectMapper);
var capabilitiesBuilder = McpSchema.ServerCapabilities.builder();
var serverBuilder = McpServer.sync(transport)
.serverInfo(serverProperties.getName(), serverProperties.getVersion());
if (serverProperties.getCapabilities().isTool()) {
capabilitiesBuilder.tools(serverProperties.isToolChangeNotification());
List<McpServerFeatures.SyncToolSpecification> toolSpecifications = new ArrayList<>();
toolCallbacks.stream()
.flatMap(List::stream)
.map(toolCallback -> toSyncToolSpecification(toolCallback, objectMapper, jsonMapper))
.forEach(toolSpecifications::add);
toolCallbackProviders.stream()
.flatMap(provider -> Arrays.stream(provider.getToolCallbacks()))
.map(toolCallback -> toSyncToolSpecification(toolCallback, objectMapper, jsonMapper))
.forEach(toolSpecifications::add);
if (!toolSpecifications.isEmpty()) {
serverBuilder.tools(toolSpecifications);
}
}
if (serverProperties.getCapabilities().isResource()) {
capabilitiesBuilder.resources(false, serverProperties.isResourceChangeNotification());
}
if (serverProperties.getCapabilities().isPrompt()) {
capabilitiesBuilder.prompts(serverProperties.isPromptChangeNotification());
}
if (serverProperties.getCapabilities().isCompletion()) {
capabilitiesBuilder.completions();
}
if (serverProperties.getInstructions() != null) {
serverBuilder.instructions(serverProperties.getInstructions());
}
if (serverProperties.getRequestTimeout() != null) {
serverBuilder.requestTimeout(serverProperties.getRequestTimeout());
}
return serverBuilder.capabilities(capabilitiesBuilder.build()).build();
}
@Bean
public ToolCallbackProvider trueRefMcpToolCallbacks(TrueRefMcpTools tools) {
return MethodToolCallbackProvider.builder().toolObjects(tools).build();
}
private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(
ToolCallback toolCallback,
ObjectMapper objectMapper,
JacksonMcpJsonMapper jsonMapper) {
var tool = McpSchema.Tool.builder()
.name(toolCallback.getToolDefinition().name())
.description(toolCallback.getToolDefinition().description())
.inputSchema(jsonMapper, toolCallback.getToolDefinition().inputSchema())
.build();
return new McpServerFeatures.SyncToolSpecification(tool, (exchange, arguments) -> {
try {
String result = toolCallback.call(objectMapper.writeValueAsString(arguments));
return new McpSchema.CallToolResult(result, false);
}
catch (Exception ex) {
String message = ex.getMessage();
if (message == null || message.isBlank()) {
message = ex.getClass().getSimpleName();
}
return new McpSchema.CallToolResult(message, true);
}
});
}
}

View File

@@ -1,16 +1,12 @@
/**
* Driving adapter: Model Context Protocol (MCP) server exposing the two Context7-compatible
* tools ({@code resolve-library-id}, {@code get-library-docs}) over Spring AI's MCP WebMVC
* transport. The HTTP message endpoint is wired to {@code POST /mcp} via
* {@code spring.ai.mcp.server.sse-message-endpoint}.
* transport. The adapter publishes the current Streamable HTTP transport on {@code /mcp}.
*
* <p>Spring AI 1.0.0 ships the SSE-based WebMVC transport
* ({@code WebMvcSseServerTransportProvider}); the 2025-03-26 "Streamable HTTP" transport is
* not a separate selectable property in this version. Clients that POST JSON-RPC to the
* configured message endpoint receive JSON-RPC responses; the server additionally opens an
* SSE stream on the configured {@code sse-endpoint} for server-initiated notifications. This
* is the closest equivalent Spring AI 1.0.0 provides to Streamable HTTP and is the
* intended/only transport of this adapter.
* <p>Spring AI 1.0.0 still auto-configures only the legacy SSE WebMVC transport, so this adapter
* wires {@code WebMvcStreamableServerTransportProvider} manually. Clients send JSON-RPC requests
* to {@code POST /mcp}; server-initiated notifications may be streamed from {@code GET /mcp}
* using the MCP Streamable HTTP transport.
*/
@org.jspecify.annotations.NullMarked
package com.trueref.adapter.in.mcp;