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

@@ -198,7 +198,7 @@ End-to-end smoke after first assembly:
- `POST /api/repos` + `GET /api/repos` — round-trips a repo. - `POST /api/repos` + `GET /api/repos` — round-trips a repo.
- `GET /swagger-ui.html` → 302 redirect (to `/swagger-ui/index.html`), `GET /v3/api-docs` → 200. - `GET /swagger-ui.html` → 302 redirect (to `/swagger-ui/index.html`), `GET /v3/api-docs` → 200.
- `GET /` → 200 (SvelteKit SPA served from Spring static resources). - `GET /` → 200 (SvelteKit SPA served from Spring static resources).
- `POST /mcp` one-shot JSON-RPC returns HTTP 500 — expected, the WebMVC MCP transport requires an SSE session established by `GET /sse` first; MCP clients that implement the Streamable-HTTP spec do this automatically. Verified MCP tools register: `tools/list` handler is reached (error thrown is transport-level session lookup, not bean wiring). - Historical note: at this point the server still used the legacy WebMVC SSE transport, so `POST /mcp` without an established `GET /sse` session returned HTTP 500. This was later replaced by the Streamable HTTP transport on `GET`/`POST /mcp`.
Fixes landed during smoke: Fixes landed during smoke:
- `V1__init_schema.sql`: H2 in PostgreSQL mode rejects `AUTO_INCREMENT`. Switched `job_log_events.id` to `BIGINT GENERATED BY DEFAULT AS IDENTITY` and removed the explicit `NULL` constraint. - `V1__init_schema.sql`: H2 in PostgreSQL mode rejects `AUTO_INCREMENT`. Switched `job_log_events.id` to `BIGINT GENERATED BY DEFAULT AS IDENTITY` and removed the explicit `NULL` constraint.

24
pom.xml
View File

@@ -33,6 +33,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.0.0</spring-ai.version> <spring-ai.version>1.0.0</spring-ai.version>
<mcp.sdk.version>0.18.1</mcp.sdk.version>
<springdoc.version>2.8.6</springdoc.version> <springdoc.version>2.8.6</springdoc.version>
<jgit.version>7.3.0.202506031305-r</jgit.version> <jgit.version>7.3.0.202506031305-r</jgit.version>
<lucene.version>10.4.0</lucene.version> <lucene.version>10.4.0</lucene.version>
@@ -83,6 +84,29 @@
<scope>import</scope> <scope>import</scope>
</dependency> </dependency>
<!-- MCP Java SDK: keep Spring AI's transitives and our direct transport/json
dependencies on one compatible version line. -->
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>${mcp.sdk.version}</version>
</dependency>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-core</artifactId>
<version>${mcp.sdk.version}</version>
</dependency>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-spring-webmvc</artifactId>
<version>${mcp.sdk.version}</version>
</dependency>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-json-jackson2</artifactId>
<version>${mcp.sdk.version}</version>
</dependency>
<!-- 3rd-party --> <!-- 3rd-party -->
<dependency> <dependency>
<groupId>org.springdoc</groupId> <groupId>org.springdoc</groupId>

View File

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

View File

@@ -1,10 +1,20 @@
package com.trueref.adapter.in.mcp; package com.trueref.adapter.in.mcp;
import com.fasterxml.jackson.databind.ObjectMapper; 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.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider; 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.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider; import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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. * 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} * <p>Spring AI 1.0.0 only auto-configures the legacy SSE/stdIO MCP server path. The current
* (legacy SSE). We exclude {@code McpWebMvcServerAutoConfiguration} in {@code application.yml} and * Streamable HTTP provider in MCP SDK 0.18.1 uses the newer
* register {@link WebMvcStreamableServerTransportProvider} directly — Spring AI's * {@code McpStreamableServerTransportProvider} interface, so we exclude Spring AI's MCP server
* {@code McpServerAutoConfiguration} accepts any {@code McpServerTransportProvider} bean. * 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 * <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. * a long-poll GET {@code /mcp} stream for server-initiated notifications.
*/ */
@Configuration @Configuration
@EnableConfigurationProperties(McpProperties.class) @EnableConfigurationProperties({McpProperties.class, McpServerProperties.class})
public class McpConfig { public class McpConfig {
/** Streamable HTTP transport — handles both POST (JSON-RPC) and GET (SSE stream) on /mcp. */ /** Streamable HTTP transport — handles both POST (JSON-RPC) and GET (SSE stream) on /mcp. */
@@ -42,8 +52,85 @@ public class McpConfig {
return transport.getRouterFunction(); 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 @Bean
public ToolCallbackProvider trueRefMcpToolCallbacks(TrueRefMcpTools tools) { public ToolCallbackProvider trueRefMcpToolCallbacks(TrueRefMcpTools tools) {
return MethodToolCallbackProvider.builder().toolObjects(tools).build(); 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 * 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 * 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 * transport. The adapter publishes the current Streamable HTTP transport on {@code /mcp}.
* {@code spring.ai.mcp.server.sse-message-endpoint}.
* *
* <p>Spring AI 1.0.0 ships the SSE-based WebMVC transport * <p>Spring AI 1.0.0 still auto-configures only the legacy SSE WebMVC transport, so this adapter
* ({@code WebMvcSseServerTransportProvider}); the 2025-03-26 "Streamable HTTP" transport is * wires {@code WebMvcStreamableServerTransportProvider} manually. Clients send JSON-RPC requests
* not a separate selectable property in this version. Clients that POST JSON-RPC to the * to {@code POST /mcp}; server-initiated notifications may be streamed from {@code GET /mcp}
* configured message endpoint receive JSON-RPC responses; the server additionally opens an * using the MCP Streamable HTTP transport.
* 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.
*/ */
@org.jspecify.annotations.NullMarked @org.jspecify.annotations.NullMarked
package com.trueref.adapter.in.mcp; package com.trueref.adapter.in.mcp;

View File

@@ -21,11 +21,14 @@ spring:
async: async:
request-timeout: 0 # MCP GET streams must not time out request-timeout: 0 # MCP GET streams must not time out
# Spring AI MCP server — Streamable HTTP transport (MCP spec 2025-03-26). # Spring AI MCP server — Streamable HTTP transport (MCP spec 2025-03-26).
# McpWebMvcServerAutoConfiguration (SSE transport) is excluded below; # Spring AI 1.0.0's MCP auto-config only knows the legacy SSE/stdIO transport interfaces,
# WebMvcStreamableServerTransportProvider is wired manually in McpConfig. # so both MCP auto-config classes are excluded below and the Streamable HTTP transport plus
# McpSyncServer are wired manually in McpConfig.
# Clients connect with type: http at POST /mcp. # Clients connect with type: http at POST /mcp.
autoconfigure: autoconfigure:
exclude: org.springframework.ai.mcp.server.autoconfigure.McpWebMvcServerAutoConfiguration exclude:
- org.springframework.ai.mcp.server.autoconfigure.McpWebMvcServerAutoConfiguration
- org.springframework.ai.mcp.server.autoconfigure.McpServerAutoConfiguration
ai: ai:
mcp: mcp:
server: server: