This is an automated email from the ASF dual-hosted git repository. liuhongyu pushed a commit to branch feat/mcp_server_support_header in repository https://gitbox.apache.org/repos/asf/shenyu.git
commit 4e6c5ff35260c79f32836f24df6b77f1bc9213ae Author: liuhy <[email protected]> AuthorDate: Tue Feb 10 10:19:04 2026 +0800 feat: add CORS support with configurable allowed headers in MCP server --- .../shenyu/plugin/mcp/server/McpServerPlugin.java | 73 +++++++++++++++++++-- .../mcp/server/manager/ShenyuMcpServerManager.java | 28 +++++++- ...henyuStreamableHttpServerTransportProvider.java | 76 ++++++++++++++++++---- .../transport/StreamableHttpProviderBuilder.java | 15 ++++- .../mcp/server/McpServerPluginConfiguration.java | 24 +++++-- 5 files changed, 191 insertions(+), 25 deletions(-) diff --git a/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/McpServerPlugin.java b/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/McpServerPlugin.java index ec540683c7..60268d34f4 100644 --- a/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/McpServerPlugin.java +++ b/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/McpServerPlugin.java @@ -43,9 +43,11 @@ import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.nio.charset.StandardCharsets; /** @@ -109,10 +111,17 @@ public class McpServerPlugin extends AbstractShenyuPlugin { */ private static final String BEARER_PREFIX = "Bearer "; + private static final String CORS_ALLOW_METHODS = "GET, POST, OPTIONS"; + + private static final String CORS_FALLBACK_ALLOW_HEADERS = + "Content-Type, Mcp-Session-Id, Authorization, Last-Event-ID, Mcp-Protocol-Version, X-Request, XRequest, xrequest"; + private final ShenyuMcpServerManager shenyuMcpServerManager; private final List<HttpMessageReader<?>> messageReaders; + private final String configuredCorsAllowHeaders; + /** * Constructs a new MCP server plugin. * @@ -121,8 +130,22 @@ public class McpServerPlugin extends AbstractShenyuPlugin { */ public McpServerPlugin(final ShenyuMcpServerManager shenyuMcpServerManager, final List<HttpMessageReader<?>> messageReaders) { + this(shenyuMcpServerManager, messageReaders, null); + } + + /** + * Constructs a new MCP server plugin. + * + * @param shenyuMcpServerManager the MCP server manager for handling transport providers + * @param messageReaders the HTTP message readers for request processing + * @param configuredCorsAllowHeaders CORS allow headers configured by {@code shenyu.cross.allowedHeaders} + */ + public McpServerPlugin(final ShenyuMcpServerManager shenyuMcpServerManager, + final List<HttpMessageReader<?>> messageReaders, + final String configuredCorsAllowHeaders) { this.shenyuMcpServerManager = shenyuMcpServerManager; this.messageReaders = messageReaders; + this.configuredCorsAllowHeaders = configuredCorsAllowHeaders; } @Override @@ -203,6 +226,10 @@ public class McpServerPlugin extends AbstractShenyuPlugin { final SelectorData selector, final String uri) { + if ("OPTIONS".equalsIgnoreCase(exchange.getRequest().getMethod().name())) { + return handleCorsPreflight(exchange); + } + if (isStreamableHttpProtocol(uri)) { return handleStreamableHttpRequest(exchange, chain, request, uri); } else if (isSseProtocol(uri)) { @@ -276,6 +303,19 @@ public class McpServerPlugin extends AbstractShenyuPlugin { return uri.contains(SSE_PATH) || uri.endsWith(SSE_PATH) || uri.endsWith(MESSAGE_ENDPOINT); } + /** + * Handles CORS preflight (OPTIONS) requests. + * + * @param exchange the server web exchange + * @return a Mono representing completion + */ + private Mono<Void> handleCorsPreflight(final ServerWebExchange exchange) { + exchange.getResponse().setStatusCode(HttpStatus.OK); + setCorsHeaders(exchange); + exchange.getResponse().getHeaders().set("Access-Control-Max-Age", "3600"); + return exchange.getResponse().setComplete(); + } + /** * Handles Streamable HTTP MCP requests with unified endpoint processing. * @@ -579,11 +619,34 @@ public class McpServerPlugin extends AbstractShenyuPlugin { * @param exchange the server web exchange */ private void setCorsHeaders(final ServerWebExchange exchange) { - exchange.getResponse().getHeaders().set("Access-Control-Allow-Origin", "*"); - exchange.getResponse().getHeaders().set("Access-Control-Allow-Headers", - "Content-Type, Mcp-Session-Id, Authorization, Last-Event-ID, Mcp-Protocol-Version"); - exchange.getResponse().getHeaders().set("Access-Control-Allow-Methods", - "GET, POST, OPTIONS"); + exchange.getResponse().getHeaders().set("Access-Control-Allow-Origin", resolveAllowOrigin(exchange)); + exchange.getResponse().getHeaders().set("Access-Control-Allow-Headers", resolveAllowHeaders(exchange)); + exchange.getResponse().getHeaders().set("Access-Control-Allow-Methods", CORS_ALLOW_METHODS); + exchange.getResponse().getHeaders().set("Vary", "Origin, Access-Control-Request-Headers"); + } + + private String resolveAllowOrigin(final ServerWebExchange exchange) { + final String origin = exchange.getRequest().getHeaders().getFirst("Origin"); + return Objects.nonNull(origin) && !origin.isBlank() ? origin : "*"; + } + + private String resolveAllowHeaders(final ServerWebExchange exchange) { + final Set<String> allowedHeaders = new LinkedHashSet<>(); + final String allowHeaders = Objects.nonNull(configuredCorsAllowHeaders) && !configuredCorsAllowHeaders.isBlank() + ? configuredCorsAllowHeaders : CORS_FALLBACK_ALLOW_HEADERS; + for (String header : allowHeaders.split(",")) { + allowedHeaders.add(header.trim()); + } + final String requestedHeaders = exchange.getRequest().getHeaders().getFirst("Access-Control-Request-Headers"); + if (Objects.nonNull(requestedHeaders) && !requestedHeaders.isBlank()) { + for (String requestedHeader : requestedHeaders.split(",")) { + final String header = requestedHeader.trim(); + if (!header.isEmpty()) { + allowedHeaders.add(header); + } + } + } + return String.join(", ", allowedHeaders); } /** diff --git a/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/manager/ShenyuMcpServerManager.java b/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/manager/ShenyuMcpServerManager.java index e31493d587..f60589a91a 100644 --- a/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/manager/ShenyuMcpServerManager.java +++ b/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/manager/ShenyuMcpServerManager.java @@ -37,12 +37,12 @@ import org.springframework.util.AntPathMatcher; import org.springframework.web.reactive.function.server.HandlerFunction; import java.time.Duration; +import java.util.Collections; +import java.util.HashSet; import java.util.Map; import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; import java.util.Set; -import java.util.HashSet; -import java.util.Collections; +import java.util.concurrent.ConcurrentHashMap; /** * Enhanced Manager for MCP servers supporting shared server instances across multiple transport protocols. @@ -75,6 +75,11 @@ public class ShenyuMcpServerManager { */ private final ObjectMapper objectMapper = new ObjectMapper(); + /** + * CORS allow headers configured by {@code shenyu.cross.allowedHeaders}. + */ + private final String corsAllowedHeaders; + /** * Map to store normalized path to shared McpAsyncServer mapping. * Key: normalized server path, Value: shared McpAsyncServer instance @@ -91,6 +96,22 @@ public class ShenyuMcpServerManager { */ private final Map<String, CompositeTransportProvider> compositeTransportMap = new ConcurrentHashMap<>(); + /** + * Instantiates a new manager with default CORS allow headers handling. + */ + public ShenyuMcpServerManager() { + this(null); + } + + /** + * Instantiates a new manager. + * + * @param corsAllowedHeaders CORS allow headers configured by {@code shenyu.cross.allowedHeaders} + */ + public ShenyuMcpServerManager(final String corsAllowedHeaders) { + this.corsAllowedHeaders = corsAllowedHeaders; + } + /** * Get or create a shared MCP server for the given path, supporting multiple transport protocols. * @@ -244,6 +265,7 @@ public class ShenyuMcpServerManager { ShenyuStreamableHttpServerTransportProvider transportProvider = ShenyuStreamableHttpServerTransportProvider.builder() .objectMapper(objectMapper) .endpoint(originalUri) + .allowedHeaders(corsAllowedHeaders) .build(); // Register routes for original URI diff --git a/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProvider.java b/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProvider.java index 31fa494bb3..23f1ac99b6 100644 --- a/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProvider.java +++ b/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProvider.java @@ -39,8 +39,10 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.io.IOException; +import java.util.LinkedHashSet; import java.util.Objects; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** @@ -92,10 +94,19 @@ public class ShenyuStreamableHttpServerTransportProvider implements McpServerTra private static final String SERVER_VERSION = "1.0.0"; + private static final String CORS_ALLOW_METHODS = "GET, POST, OPTIONS"; + + private static final String CORS_ALLOW_POST_METHODS = "POST, OPTIONS"; + + private static final String CORS_FALLBACK_ALLOW_HEADERS = + "Content-Type, Mcp-Session-Id, Authorization, Last-Event-ID, Mcp-Protocol-Version, X-Request, XRequest, xrequest"; + private final ObjectMapper objectMapper; private final McpJsonMapper jsonMapper; + private final String configuredCorsAllowHeaders; + private McpServerSession.Factory sessionFactory; /** @@ -123,10 +134,25 @@ public class ShenyuStreamableHttpServerTransportProvider implements McpServerTra * @throws IllegalArgumentException if objectMapper or endpoint is null */ public ShenyuStreamableHttpServerTransportProvider(final ObjectMapper objectMapper, final String endpoint) { + this(objectMapper, endpoint, null); + } + + /** + * Constructs a new Streamable HTTP server transport provider instance. + * + * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization + * @param endpoint The endpoint path for the Streamable HTTP MCP transport + * @param configuredCorsAllowHeaders CORS allow headers configured by {@code shenyu.cross.allowedHeaders} + * @throws IllegalArgumentException if objectMapper or endpoint is null + */ + public ShenyuStreamableHttpServerTransportProvider(final ObjectMapper objectMapper, + final String endpoint, + final String configuredCorsAllowHeaders) { Assert.notNull(objectMapper, "ObjectMapper must not be null"); Assert.notNull(endpoint, "Endpoint must not be null"); this.objectMapper = objectMapper; this.jsonMapper = new JacksonMcpJsonMapper(objectMapper); + this.configuredCorsAllowHeaders = configuredCorsAllowHeaders; LOGGER.debug("Created Streamable HTTP transport provider for endpoint: {}", endpoint); } @@ -195,18 +221,12 @@ public class ShenyuStreamableHttpServerTransportProvider implements McpServerTra } if ("OPTIONS".equalsIgnoreCase(request.methodName())) { // Handle CORS preflight requests - return ServerResponse.ok() - .header("Access-Control-Allow-Origin", "*") - .header("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Authorization, Mcp-Protocol-Version") - .header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + return applyCorsHeaders(request, ServerResponse.ok(), CORS_ALLOW_METHODS) .header("Access-Control-Max-Age", "3600") .build(); } else if ("GET".equalsIgnoreCase(request.methodName())) { // Streamable HTTP protocol does not support GET requests, return 405 error - return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED) - .header("Access-Control-Allow-Origin", "*") - .header("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Authorization, Mcp-Protocol-Version") - .header("Access-Control-Allow-Methods", "POST, OPTIONS") + return applyCorsHeaders(request, ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED), CORS_ALLOW_POST_METHODS) .header("Allow", "POST, OPTIONS") .contentType(MediaType.APPLICATION_JSON) .bodyValue(new java.util.HashMap<String, Object>() {{ @@ -219,10 +239,8 @@ public class ShenyuStreamableHttpServerTransportProvider implements McpServerTra // Extract ServerWebExchange from ServerRequest final ServerWebExchange exchange = request.exchange(); return handleMessageEndpoint(exchange, request).flatMap(result -> { - ServerResponse.BodyBuilder builder = ServerResponse.status(HttpStatus.valueOf(result.getStatusCode())) - .header("Access-Control-Allow-Origin", "*") - .header("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Authorization, Mcp-Protocol-Version") - .header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + ServerResponse.BodyBuilder builder = applyCorsHeaders(request, + ServerResponse.status(HttpStatus.valueOf(result.getStatusCode())), CORS_ALLOW_METHODS); if (Objects.nonNull(result.getSessionId())) { builder.header(SESSION_ID_HEADER, result.getSessionId()); } @@ -643,6 +661,40 @@ public class ShenyuStreamableHttpServerTransportProvider implements McpServerTra return response; } + private ServerResponse.BodyBuilder applyCorsHeaders(final ServerRequest request, + final ServerResponse.BodyBuilder builder, + final String allowMethods) { + return builder + .header("Access-Control-Allow-Origin", resolveAllowOrigin(request)) + .header("Access-Control-Allow-Headers", resolveAllowHeaders(request)) + .header("Access-Control-Allow-Methods", allowMethods) + .header("Vary", "Origin, Access-Control-Request-Headers"); + } + + private String resolveAllowOrigin(final ServerRequest request) { + final String origin = request.headers().firstHeader("Origin"); + return Objects.nonNull(origin) && !origin.isBlank() ? origin : "*"; + } + + private String resolveAllowHeaders(final ServerRequest request) { + final Set<String> allowedHeaders = new LinkedHashSet<>(); + final String allowHeaders = Objects.nonNull(configuredCorsAllowHeaders) && !configuredCorsAllowHeaders.isBlank() + ? configuredCorsAllowHeaders : CORS_FALLBACK_ALLOW_HEADERS; + for (String header : allowHeaders.split(",")) { + allowedHeaders.add(header.trim()); + } + final String requestedHeaders = request.headers().firstHeader("Access-Control-Request-Headers"); + if (Objects.nonNull(requestedHeaders) && !requestedHeaders.isBlank()) { + for (String requestedHeader : requestedHeaders.split(",")) { + final String header = requestedHeader.trim(); + if (!header.isEmpty()) { + allowedHeaders.add(header); + } + } + } + return String.join(", ", allowedHeaders); + } + /** * Extracts the session ID from the request headers or query parameters. * diff --git a/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/transport/StreamableHttpProviderBuilder.java b/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/transport/StreamableHttpProviderBuilder.java index 749359f80f..8ca200556d 100644 --- a/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/transport/StreamableHttpProviderBuilder.java +++ b/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/transport/StreamableHttpProviderBuilder.java @@ -42,6 +42,8 @@ public class StreamableHttpProviderBuilder { private String endpoint = DEFAULT_ENDPOINT; + private String allowedHeaders; + /** * Sets the ObjectMapper for JSON serialization/deserialization. * @@ -68,6 +70,17 @@ public class StreamableHttpProviderBuilder { return this; } + /** + * Sets configured CORS allow headers. + * + * @param allowedHeaders comma-separated headers from configuration + * @return this builder for method chaining + */ + public StreamableHttpProviderBuilder allowedHeaders(final String allowedHeaders) { + this.allowedHeaders = allowedHeaders; + return this; + } + /** * Builds a new ShenyuStreamableHttpServerTransportProvider instance. * @@ -76,6 +89,6 @@ public class StreamableHttpProviderBuilder { */ public ShenyuStreamableHttpServerTransportProvider build() { Assert.notNull(objectMapper, "ObjectMapper must be configured"); - return new ShenyuStreamableHttpServerTransportProvider(objectMapper, endpoint); + return new ShenyuStreamableHttpServerTransportProvider(objectMapper, endpoint, allowedHeaders); } } diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-mcp-server/src/main/java/org/apache/shenyu/springboot/starter/plugin/mcp/server/McpServerPluginConfiguration.java b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-mcp-server/src/main/java/org/apache/shenyu/springboot/starter/plugin/mcp/server/McpServerPluginConfiguration.java index 195be3881e..3d92cfb2d3 100644 --- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-mcp-server/src/main/java/org/apache/shenyu/springboot/starter/plugin/mcp/server/McpServerPluginConfiguration.java +++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-mcp-server/src/main/java/org/apache/shenyu/springboot/starter/plugin/mcp/server/McpServerPluginConfiguration.java @@ -17,6 +17,7 @@ package org.apache.shenyu.springboot.starter.plugin.mcp.server; +import org.apache.shenyu.common.config.ShenyuConfig; import org.apache.shenyu.plugin.api.ShenyuPlugin; import org.apache.shenyu.plugin.base.handler.PluginDataHandler; import org.apache.shenyu.plugin.mcp.server.McpServerPlugin; @@ -28,6 +29,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.http.codec.ServerCodecConfigurer; +import java.util.Objects; + /** * The type Mock plugin configuration. */ @@ -55,9 +58,15 @@ public class McpServerPluginConfiguration { // return RouterFunctions.route(RequestPredicates.all(), shenyuMcpServerManager::dispatch); // } + /** + * Shenyu mcp server manager. + * + * @param shenyuConfig the shenyu config + * @return the shenyu mcp server manager + */ @Bean - public ShenyuMcpServerManager shenyuMcpServerManager() { - return new ShenyuMcpServerManager(); + public ShenyuMcpServerManager shenyuMcpServerManager(final ShenyuConfig shenyuConfig) { + return new ShenyuMcpServerManager(resolveCorsAllowedHeaders(shenyuConfig)); } /** @@ -77,11 +86,18 @@ public class McpServerPluginConfiguration { * * @param shenyuMcpServerManager the shenyu mcp server manager * @param configurer the server codec configurer + * @param shenyuConfig the shenyu config * @return the shenyu plugin */ @Bean public ShenyuPlugin mcpServerPlugin(final ShenyuMcpServerManager shenyuMcpServerManager, - final ServerCodecConfigurer configurer) { - return new McpServerPlugin(shenyuMcpServerManager, configurer.getReaders()); + final ServerCodecConfigurer configurer, + final ShenyuConfig shenyuConfig) { + return new McpServerPlugin(shenyuMcpServerManager, configurer.getReaders(), resolveCorsAllowedHeaders(shenyuConfig)); + } + + private String resolveCorsAllowedHeaders(final ShenyuConfig shenyuConfig) { + return Objects.nonNull(shenyuConfig) && Objects.nonNull(shenyuConfig.getCross()) + ? shenyuConfig.getCross().getAllowedHeaders() : null; } }
