|
| 1 | +package io.tolgee.mcp |
| 2 | + |
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper |
| 4 | +import io.modelcontextprotocol.server.McpNotificationHandler |
| 5 | +import io.modelcontextprotocol.server.McpRequestHandler |
| 6 | +import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider |
| 7 | +import io.modelcontextprotocol.spec.DefaultMcpStreamableServerSessionFactory |
| 8 | +import io.modelcontextprotocol.spec.McpSchema |
| 9 | +import io.modelcontextprotocol.spec.McpStreamableServerSession |
| 10 | +import jakarta.servlet.FilterChain |
| 11 | +import jakarta.servlet.http.HttpServletRequest |
| 12 | +import jakarta.servlet.http.HttpServletResponse |
| 13 | +import jakarta.servlet.http.HttpServletResponseWrapper |
| 14 | +import org.redisson.api.RedissonClient |
| 15 | +import org.slf4j.LoggerFactory |
| 16 | +import org.springframework.web.filter.OncePerRequestFilter |
| 17 | +import java.time.Duration |
| 18 | +import java.util.concurrent.ConcurrentHashMap |
| 19 | + |
| 20 | +/** |
| 21 | + * Servlet filter that syncs MCP sessions to Redis for multi-replica deployments. |
| 22 | + * |
| 23 | + * The MCP Java SDK's [WebMvcStreamableServerTransportProvider] stores sessions in an in-memory |
| 24 | + * `ConcurrentHashMap`. When running multiple replicas behind a load balancer, requests with an |
| 25 | + * `Mcp-Session-Id` header may land on a replica that doesn't have the session, resulting in |
| 26 | + * 404 "Session not found" errors. |
| 27 | + * |
| 28 | + * This filter works around the issue by: |
| 29 | + * - **Before handler**: If the request carries an `Mcp-Session-Id` not present in the local map, |
| 30 | + * reconstructs the session from Redis and injects it into the transport provider's sessions map. |
| 31 | + * - **After handler**: If the response sets a new `Mcp-Session-Id` (initialize), persists the |
| 32 | + * session metadata to Redis. |
| 33 | + * |
| 34 | + * Uses reflection because [WebMvcStreamableServerTransportProvider] has a private constructor and |
| 35 | + * all private fields/methods — it cannot be extended or configured with custom session storage. |
| 36 | + * |
| 37 | + * When Redis is unavailable (e.g. single-instance deployment), the filter is a no-op. |
| 38 | + * |
| 39 | + * This is a temporary workaround until the SDK provides a proper session storage API. |
| 40 | + * Tracked upstream: https://github.com/modelcontextprotocol/java-sdk/issues/201 |
| 41 | + * SDK maintainer confirmed persistent session storage is planned: |
| 42 | + * https://github.com/modelcontextprotocol/java-sdk/issues/201#issuecomment-3915069460 |
| 43 | + */ |
| 44 | +class McpSessionRedisFilter( |
| 45 | + private val transportProvider: WebMvcStreamableServerTransportProvider, |
| 46 | + private val redissonClient: RedissonClient?, |
| 47 | + private val objectMapper: ObjectMapper, |
| 48 | +) : OncePerRequestFilter() { |
| 49 | + private val log = LoggerFactory.getLogger(McpSessionRedisFilter::class.java) |
| 50 | + |
| 51 | + private val sessionsMap: ConcurrentHashMap<String, McpStreamableServerSession> by lazy { |
| 52 | + loadSessionsMap() |
| 53 | + } |
| 54 | + |
| 55 | + private val factoryFields: FactoryFields by lazy { |
| 56 | + loadFactoryFields() |
| 57 | + } |
| 58 | + |
| 59 | + override fun doFilterInternal( |
| 60 | + request: HttpServletRequest, |
| 61 | + response: HttpServletResponse, |
| 62 | + filterChain: FilterChain, |
| 63 | + ) { |
| 64 | + if (redissonClient == null) { |
| 65 | + filterChain.doFilter(request, response) |
| 66 | + return |
| 67 | + } |
| 68 | + |
| 69 | + val sessionId = request.getHeader(MCP_SESSION_ID_HEADER) |
| 70 | + |
| 71 | + // Pre-handle: recover session from Redis if not in local map |
| 72 | + if (sessionId != null && !sessionsMap.containsKey(sessionId)) { |
| 73 | + recoverSessionFromRedis(sessionId) |
| 74 | + } |
| 75 | + |
| 76 | + // Wrap response to capture new session ID from initialize responses |
| 77 | + val responseWrapper = SessionIdCapturingResponseWrapper(response) |
| 78 | + filterChain.doFilter(request, responseWrapper) |
| 79 | + |
| 80 | + // Post-handle: persist new session to Redis |
| 81 | + val newSessionId = responseWrapper.capturedSessionId |
| 82 | + if (newSessionId != null) { |
| 83 | + persistSessionToRedis(newSessionId) |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + private fun recoverSessionFromRedis(sessionId: String) { |
| 88 | + try { |
| 89 | + val bucket = redissonClient!!.getBucket<String>("$REDIS_KEY_PREFIX$sessionId") |
| 90 | + val json = bucket.get() ?: return |
| 91 | + |
| 92 | + val sessionData = objectMapper.readValue(json, McpSessionData::class.java) |
| 93 | + |
| 94 | + val clientCapabilities = |
| 95 | + sessionData.clientCapabilitiesJson?.let { |
| 96 | + objectMapper.readValue(it, McpSchema.ClientCapabilities::class.java) |
| 97 | + } |
| 98 | + val clientInfo = |
| 99 | + sessionData.clientInfoJson?.let { |
| 100 | + objectMapper.readValue(it, McpSchema.Implementation::class.java) |
| 101 | + } |
| 102 | + |
| 103 | + @Suppress("UNCHECKED_CAST") |
| 104 | + val session = |
| 105 | + McpStreamableServerSession( |
| 106 | + sessionId, |
| 107 | + clientCapabilities, |
| 108 | + clientInfo, |
| 109 | + factoryFields.requestTimeout, |
| 110 | + factoryFields.requestHandlers as Map<String, McpRequestHandler<*>>, |
| 111 | + factoryFields.notificationHandlers as Map<String, McpNotificationHandler>, |
| 112 | + ) |
| 113 | + |
| 114 | + val existing = sessionsMap.putIfAbsent(sessionId, session) |
| 115 | + if (existing != null) { |
| 116 | + log.debug("MCP session {} was already recovered by another thread", sessionId) |
| 117 | + } else { |
| 118 | + log.debug("Recovered MCP session {} from Redis", sessionId) |
| 119 | + } |
| 120 | + } catch (e: Exception) { |
| 121 | + log.warn("Failed to recover MCP session {} from Redis", sessionId, e) |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + private fun persistSessionToRedis(sessionId: String) { |
| 126 | + try { |
| 127 | + val session = sessionsMap[sessionId] ?: return |
| 128 | + |
| 129 | + val clientCapabilities = getPrivateField<Any?>(session, "clientCapabilities") |
| 130 | + val clientInfo = getPrivateField<Any?>(session, "clientInfo") |
| 131 | + |
| 132 | + val capabilitiesValue = unwrapField(clientCapabilities, "clientCapabilities") |
| 133 | + val infoValue = unwrapField(clientInfo, "clientInfo") |
| 134 | + |
| 135 | + val sessionData = |
| 136 | + McpSessionData( |
| 137 | + clientCapabilitiesJson = capabilitiesValue?.let { objectMapper.writeValueAsString(it) }, |
| 138 | + clientInfoJson = infoValue?.let { objectMapper.writeValueAsString(it) }, |
| 139 | + ) |
| 140 | + |
| 141 | + val bucket = redissonClient!!.getBucket<String>("$REDIS_KEY_PREFIX$sessionId") |
| 142 | + bucket.set(objectMapper.writeValueAsString(sessionData), SESSION_TTL) |
| 143 | + log.debug("Persisted MCP session {} to Redis", sessionId) |
| 144 | + } catch (e: Exception) { |
| 145 | + log.warn("Failed to persist MCP session {} to Redis", sessionId, e) |
| 146 | + } |
| 147 | + } |
| 148 | + |
| 149 | + private fun unwrapField( |
| 150 | + fieldValue: Any?, |
| 151 | + fieldName: String, |
| 152 | + ): Any? { |
| 153 | + if (fieldValue == null) return null |
| 154 | + if (fieldValue is java.util.concurrent.atomic.AtomicReference<*>) { |
| 155 | + return fieldValue.get() |
| 156 | + } |
| 157 | + log.warn( |
| 158 | + "Expected AtomicReference for field '{}' but got {}; using value directly", |
| 159 | + fieldName, |
| 160 | + fieldValue.javaClass.name, |
| 161 | + ) |
| 162 | + return fieldValue |
| 163 | + } |
| 164 | + |
| 165 | + @Suppress("UNCHECKED_CAST") |
| 166 | + private fun loadSessionsMap(): ConcurrentHashMap<String, McpStreamableServerSession> { |
| 167 | + val field = WebMvcStreamableServerTransportProvider::class.java.getDeclaredField("sessions") |
| 168 | + field.isAccessible = true |
| 169 | + return field.get(transportProvider) as ConcurrentHashMap<String, McpStreamableServerSession> |
| 170 | + } |
| 171 | + |
| 172 | + private fun loadFactoryFields(): FactoryFields { |
| 173 | + val factoryField = WebMvcStreamableServerTransportProvider::class.java.getDeclaredField("sessionFactory") |
| 174 | + factoryField.isAccessible = true |
| 175 | + val factory = factoryField.get(transportProvider) |
| 176 | + |
| 177 | + if (factory !is DefaultMcpStreamableServerSessionFactory) { |
| 178 | + throw IllegalStateException( |
| 179 | + "Expected DefaultMcpStreamableServerSessionFactory but got ${factory?.javaClass?.name}", |
| 180 | + ) |
| 181 | + } |
| 182 | + |
| 183 | + val timeoutField = DefaultMcpStreamableServerSessionFactory::class.java.getDeclaredField("requestTimeout") |
| 184 | + timeoutField.isAccessible = true |
| 185 | + val requestTimeout = timeoutField.get(factory) as Duration |
| 186 | + |
| 187 | + val handlersField = DefaultMcpStreamableServerSessionFactory::class.java.getDeclaredField("requestHandlers") |
| 188 | + handlersField.isAccessible = true |
| 189 | + |
| 190 | + @Suppress("UNCHECKED_CAST") |
| 191 | + val requestHandlers = handlersField.get(factory) as Map<String, Any> |
| 192 | + |
| 193 | + val notifField = DefaultMcpStreamableServerSessionFactory::class.java.getDeclaredField("notificationHandlers") |
| 194 | + notifField.isAccessible = true |
| 195 | + |
| 196 | + @Suppress("UNCHECKED_CAST") |
| 197 | + val notificationHandlers = notifField.get(factory) as Map<String, Any> |
| 198 | + |
| 199 | + return FactoryFields(requestTimeout, requestHandlers, notificationHandlers) |
| 200 | + } |
| 201 | + |
| 202 | + @Suppress("UNCHECKED_CAST") |
| 203 | + private fun <T> getPrivateField( |
| 204 | + obj: Any, |
| 205 | + fieldName: String, |
| 206 | + ): T { |
| 207 | + val field = obj.javaClass.getDeclaredField(fieldName) |
| 208 | + field.isAccessible = true |
| 209 | + return field.get(obj) as T |
| 210 | + } |
| 211 | + |
| 212 | + private class SessionIdCapturingResponseWrapper( |
| 213 | + response: HttpServletResponse, |
| 214 | + ) : HttpServletResponseWrapper(response) { |
| 215 | + var capturedSessionId: String? = null |
| 216 | + private set |
| 217 | + |
| 218 | + override fun setHeader( |
| 219 | + name: String, |
| 220 | + value: String?, |
| 221 | + ) { |
| 222 | + if (name == MCP_SESSION_ID_HEADER && value != null) { |
| 223 | + capturedSessionId = value |
| 224 | + } |
| 225 | + super.setHeader(name, value) |
| 226 | + } |
| 227 | + |
| 228 | + override fun addHeader( |
| 229 | + name: String, |
| 230 | + value: String?, |
| 231 | + ) { |
| 232 | + if (name == MCP_SESSION_ID_HEADER && value != null) { |
| 233 | + capturedSessionId = value |
| 234 | + } |
| 235 | + super.addHeader(name, value) |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + private data class FactoryFields( |
| 240 | + val requestTimeout: Duration, |
| 241 | + val requestHandlers: Map<String, Any>, |
| 242 | + val notificationHandlers: Map<String, Any>, |
| 243 | + ) |
| 244 | + |
| 245 | + companion object { |
| 246 | + private const val MCP_SESSION_ID_HEADER = "Mcp-Session-Id" |
| 247 | + private const val REDIS_KEY_PREFIX = "mcp_session:" |
| 248 | + private val SESSION_TTL = Duration.ofHours(48) |
| 249 | + } |
| 250 | +} |
0 commit comments