From e54e1dd33bd6d7317de5f86f4a12f811dabf2e05 Mon Sep 17 00:00:00 2001 From: moze Date: Wed, 6 May 2026 03:05:22 +0200 Subject: [PATCH] fix(mcp): align SDK and wire streamable server manually - 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 --- FINDINGS.md | 2 +- pom.xml | 24 +++++ trueref-adapters/pom.xml | 2 - .../com/trueref/adapter/in/mcp/McpConfig.java | 97 ++++++++++++++++++- .../trueref/adapter/in/mcp/package-info.java | 14 +-- .../src/main/resources/application.yml | 9 +- 6 files changed, 128 insertions(+), 20 deletions(-) diff --git a/FINDINGS.md b/FINDINGS.md index 76f4aec..e6cec5d 100644 --- a/FINDINGS.md +++ b/FINDINGS.md @@ -198,7 +198,7 @@ End-to-end smoke after first assembly: - `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 /` → 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: - `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. diff --git a/pom.xml b/pom.xml index caaa0ff..6c4eca0 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,7 @@ UTF-8 1.0.0 + 0.18.1 2.8.6 7.3.0.202506031305-r 10.4.0 @@ -83,6 +84,29 @@ import + + + io.modelcontextprotocol.sdk + mcp + ${mcp.sdk.version} + + + io.modelcontextprotocol.sdk + mcp-core + ${mcp.sdk.version} + + + io.modelcontextprotocol.sdk + mcp-spring-webmvc + ${mcp.sdk.version} + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + ${mcp.sdk.version} + + org.springdoc diff --git a/trueref-adapters/pom.xml b/trueref-adapters/pom.xml index 1a69af1..a1bb72b 100644 --- a/trueref-adapters/pom.xml +++ b/trueref-adapters/pom.xml @@ -57,12 +57,10 @@ io.modelcontextprotocol.sdk mcp-spring-webmvc - 0.18.1 io.modelcontextprotocol.sdk mcp-json-jackson2 - 0.18.1 diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/McpConfig.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/McpConfig.java index e936600..04cf349 100644 --- a/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/McpConfig.java +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/McpConfig.java @@ -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. * - *

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. + *

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. * *

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> toolCallbacks, + List 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 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); + } + }); + } } diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/package-info.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/package-info.java index 1dfd957..bfa5331 100644 --- a/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/package-info.java +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/package-info.java @@ -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}. * - *

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. + *

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; diff --git a/trueref-bootstrap/src/main/resources/application.yml b/trueref-bootstrap/src/main/resources/application.yml index c6f8bee..81caef7 100644 --- a/trueref-bootstrap/src/main/resources/application.yml +++ b/trueref-bootstrap/src/main/resources/application.yml @@ -21,11 +21,14 @@ spring: async: request-timeout: 0 # MCP GET streams must not time out # Spring AI MCP server — Streamable HTTP transport (MCP spec 2025-03-26). - # McpWebMvcServerAutoConfiguration (SSE transport) is excluded below; - # WebMvcStreamableServerTransportProvider is wired manually in McpConfig. + # Spring AI 1.0.0's MCP auto-config only knows the legacy SSE/stdIO transport interfaces, + # 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. 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: mcp: server: