This is an automated email from the ASF dual-hosted git repository.
sihengyu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new 6f82818371 feat: add CORS support with configurable allowed headers in
MCP server (#6295)
6f82818371 is described below
commit 6f828183710981cd5635b4bb38bc6229df9cfa5e
Author: aias00 <[email protected]>
AuthorDate: Wed Feb 25 18:11:20 2026 +0800
feat: add CORS support with configurable allowed headers in MCP server
(#6295)
* feat: add CORS support with configurable allowed headers in MCP server
* Update
shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/McpServerPlugin.java
Co-authored-by: Copilot <[email protected]>
* feat: enhance CORS support by refining allowed methods and headers
handling
---------
Co-authored-by: Copilot <[email protected]>
---
.../shenyu/plugin/mcp/server/McpServerPlugin.java | 93 ++++++++++++++-
.../mcp/server/manager/ShenyuMcpServerManager.java | 28 ++++-
...henyuStreamableHttpServerTransportProvider.java | 132 ++++++++++++++++++---
.../transport/StreamableHttpProviderBuilder.java | 15 ++-
.../plugin/mcp/server/McpServerPluginTest.java | 52 ++++++++
...uStreamableHttpServerTransportProviderTest.java | 87 ++++++++++++++
.../mcp/server/McpServerPluginConfiguration.java | 24 +++-
7 files changed, 400 insertions(+), 31 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..0c268eb361 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
@@ -36,6 +36,7 @@ import
org.apache.shenyu.plugin.mcp.server.transport.SseEventFormatter;
import org.apache.shenyu.plugin.mcp.server.transport.MessageHandlingResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.web.reactive.function.server.ServerRequest;
@@ -43,9 +44,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 +112,19 @@ 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_STREAMABLE_ALLOW_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 ShenyuMcpServerManager shenyuMcpServerManager;
private final List<HttpMessageReader<?>> messageReaders;
+ private final String configuredCorsAllowHeaders;
+
/**
* Constructs a new MCP server plugin.
*
@@ -121,8 +133,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 +229,10 @@ public class McpServerPlugin extends AbstractShenyuPlugin {
final SelectorData selector,
final String uri) {
+ if
("OPTIONS".equalsIgnoreCase(exchange.getRequest().getMethod().name())) {
+ return handleCorsPreflight(exchange, uri);
+ }
+
if (isStreamableHttpProtocol(uri)) {
return handleStreamableHttpRequest(exchange, chain, request, uri);
} else if (isSseProtocol(uri)) {
@@ -276,6 +306,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,
final String uri) {
+ exchange.getResponse().setStatusCode(HttpStatus.OK);
+ setCorsHeaders(exchange, resolveAllowMethods(uri));
+ exchange.getResponse().getHeaders().set("Access-Control-Max-Age",
"3600");
+ return exchange.getResponse().setComplete();
+ }
+
/**
* Handles Streamable HTTP MCP requests with unified endpoint processing.
*
@@ -579,11 +622,51 @@ 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");
+ setCorsHeaders(exchange,
resolveAllowMethods(exchange.getRequest().getURI().getRawPath()));
+ }
+
+ private void setCorsHeaders(final ServerWebExchange exchange, final String
allowMethods) {
+ 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",
allowMethods);
+ mergeVaryHeaders(exchange);
+ }
+
+ private String resolveAllowMethods(final String uri) {
+ return isStreamableHttpProtocol(uri) ? CORS_STREAMABLE_ALLOW_METHODS :
CORS_ALLOW_METHODS;
+ }
+
+ 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(",")) {
+ final String trimmed = header.trim();
+ if (!trimmed.isEmpty()) {
+ allowedHeaders.add(trimmed);
+ }
+ }
+ return String.join(", ", allowedHeaders);
+ }
+
+ private void mergeVaryHeaders(final ServerWebExchange exchange) {
+ final Set<String> varyValues = new LinkedHashSet<>();
+ for (String varyHeader :
exchange.getResponse().getHeaders().getOrEmpty(HttpHeaders.VARY)) {
+ for (String varyValue : varyHeader.split(",")) {
+ final String trimmed = varyValue.trim();
+ if (!trimmed.isEmpty()) {
+ varyValues.add(trimmed);
+ }
+ }
+ }
+ varyValues.add("Origin");
+ varyValues.add("Access-Control-Request-Headers");
+ exchange.getResponse().getHeaders().setVary(List.copyOf(varyValues));
}
/**
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..f67c9c8d51 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
@@ -30,6 +30,7 @@ import io.modelcontextprotocol.util.Assert;
import org.apache.shenyu.plugin.mcp.server.holder.ShenyuMcpExchangeHolder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.server.ServerRequest;
@@ -39,8 +40,12 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.io.IOException;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
import java.util.Objects;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
@@ -92,10 +97,17 @@ public class ShenyuStreamableHttpServerTransportProvider
implements McpServerTra
private static final String SERVER_VERSION = "1.0.0";
+ private static final String CORS_ALLOW_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 +135,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);
}
@@ -189,24 +216,19 @@ public class ShenyuStreamableHttpServerTransportProvider
implements McpServerTra
if (isClosing) {
return
ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is
shutting down");
}
- if (Objects.isNull(sessionFactory)) {
- LOGGER.error("SessionFactory is null - MCP server not properly
initialized");
- return
ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).bodyValue("MCP server
not properly initialized");
- }
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())) {
+ }
+ if (Objects.isNull(sessionFactory)) {
+ LOGGER.error("SessionFactory is null - MCP server not properly
initialized");
+ return
ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).bodyValue("MCP server
not properly initialized");
+ }
+ 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_METHODS)
.header("Allow", "POST, OPTIONS")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new java.util.HashMap<String, Object>() {{
@@ -215,14 +237,13 @@ public class ShenyuStreamableHttpServerTransportProvider
implements McpServerTra
put("message", "Streamable HTTP does not
support GET requests. Please use POST requests for all MCP operations.");
}});
}});
- } else if ("POST".equalsIgnoreCase(request.methodName())) {
+ }
+ if ("POST".equalsIgnoreCase(request.methodName())) {
// 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 +664,81 @@ public class ShenyuStreamableHttpServerTransportProvider
implements McpServerTra
return response;
}
+ private ServerResponse.BodyBuilder applyCorsHeaders(final ServerRequest
request,
+ final
ServerResponse.BodyBuilder builder,
+ final String
allowMethods) {
+ return builder.headers(headers -> {
+ headers.set("Access-Control-Allow-Origin",
resolveAllowOrigin(request));
+ headers.set("Access-Control-Allow-Headers",
resolveAllowHeaders(request));
+ headers.set("Access-Control-Allow-Methods", allowMethods);
+ mergeVaryHeaders(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> configuredHeaders =
toHeaderSet(resolveConfiguredAllowHeaders());
+ final String requestedHeaders =
request.headers().firstHeader("Access-Control-Request-Headers");
+ if (configuredHeaders.contains("*")) {
+ if (Objects.nonNull(requestedHeaders) &&
!requestedHeaders.isBlank()) {
+ return String.join(", ", toHeaderSet(requestedHeaders));
+ }
+ return "*";
+ }
+ if (Objects.isNull(requestedHeaders) || requestedHeaders.isBlank()) {
+ return String.join(", ", configuredHeaders);
+ }
+ final Set<String> configuredLowercaseHeaders = new LinkedHashSet<>();
+ for (String header : configuredHeaders) {
+ configuredLowercaseHeaders.add(header.toLowerCase(Locale.ROOT));
+ }
+ final Set<String> effectiveHeaders = new LinkedHashSet<>();
+ for (String requestedHeader : requestedHeaders.split(",")) {
+ final String header = requestedHeader.trim();
+ if (!header.isEmpty() &&
configuredLowercaseHeaders.contains(header.toLowerCase(Locale.ROOT))) {
+ effectiveHeaders.add(header);
+ }
+ }
+ return effectiveHeaders.isEmpty()
+ ? String.join(", ", configuredHeaders)
+ : String.join(", ", effectiveHeaders);
+ }
+
+ private String resolveConfiguredAllowHeaders() {
+ return Objects.nonNull(configuredCorsAllowHeaders) &&
!configuredCorsAllowHeaders.isBlank()
+ ? configuredCorsAllowHeaders : CORS_FALLBACK_ALLOW_HEADERS;
+ }
+
+ private Set<String> toHeaderSet(final String headers) {
+ final Set<String> headerSet = new LinkedHashSet<>();
+ for (String header : headers.split(",")) {
+ final String trimmed = header.trim();
+ if (!trimmed.isEmpty()) {
+ headerSet.add(trimmed);
+ }
+ }
+ return headerSet;
+ }
+
+ private void mergeVaryHeaders(final HttpHeaders headers) {
+ final Set<String> varyValues = new LinkedHashSet<>();
+ for (String varyHeader : headers.getOrEmpty(HttpHeaders.VARY)) {
+ for (String varyValue : varyHeader.split(",")) {
+ final String trimmed = varyValue.trim();
+ if (!trimmed.isEmpty()) {
+ varyValues.add(trimmed);
+ }
+ }
+ }
+ varyValues.add("Origin");
+ varyValues.add("Access-Control-Request-Headers");
+ headers.setVary(List.copyOf(varyValues));
+ }
+
/**
* 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-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/McpServerPluginTest.java
b/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/McpServerPluginTest.java
index 52fbc89ce4..ea27e72bda 100644
---
a/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/McpServerPluginTest.java
+++
b/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/McpServerPluginTest.java
@@ -30,14 +30,19 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpStatus;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.net.URI;
import java.util.List;
+import java.util.Locale;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -150,4 +155,51 @@ class McpServerPluginTest {
String rawPath = mcpServerPlugin.getRawPath(exchange);
assertEquals("/test/path", rawPath);
}
+
+ @Test
+ void testPreflightWithConfiguredAllowHeaders() {
+ final McpServerPlugin plugin = new
McpServerPlugin(shenyuMcpServerManager,
+ HandlerStrategies.withDefaults().messageReaders(),
"Content-Type, XRequest, Authorization");
+ final MockServerWebExchange webExchange =
MockServerWebExchange.from(MockServerHttpRequest
+ .options("/mcp/streamablehttp")
+ .header("Origin", "http://localhost:6274")
+ .header("Access-Control-Request-Headers", "xrequest,
authorization")
+ .build());
+ webExchange.getAttributes().put(Constants.CONTEXT, new
ShenyuContext());
+
webExchange.getResponse().getHeaders().setVary(List.of("Accept-Encoding"));
+
when(shenyuMcpServerManager.canRoute("/mcp/streamablehttp")).thenReturn(true);
+
+ StepVerifier.create(plugin.doExecute(webExchange, chain, selector,
rule))
+ .verifyComplete();
+
+ assertEquals(HttpStatus.OK, webExchange.getResponse().getStatusCode());
+ assertEquals("http://localhost:6274",
+
webExchange.getResponse().getHeaders().getFirst("Access-Control-Allow-Origin"));
+ assertEquals("POST, OPTIONS",
+
webExchange.getResponse().getHeaders().getFirst("Access-Control-Allow-Methods"));
+ assertEquals("Content-Type, XRequest, Authorization",
+
webExchange.getResponse().getHeaders().getFirst("Access-Control-Allow-Headers"));
+
assertTrue(webExchange.getResponse().getHeaders().getVary().contains("Accept-Encoding"));
+
assertTrue(webExchange.getResponse().getHeaders().getVary().contains("Origin"));
+
assertTrue(webExchange.getResponse().getHeaders().getVary().contains("Access-Control-Request-Headers"));
+ }
+
+ @Test
+ void testPreflightWithFallbackAllowHeaders() {
+ final McpServerPlugin plugin = new
McpServerPlugin(shenyuMcpServerManager,
+ HandlerStrategies.withDefaults().messageReaders());
+ final MockServerWebExchange webExchange =
MockServerWebExchange.from(MockServerHttpRequest
+ .options("/mcp/streamablehttp")
+ .header("Origin", "http://localhost:6274")
+ .header("Access-Control-Request-Headers", "xrequest")
+ .build());
+ webExchange.getAttributes().put(Constants.CONTEXT, new
ShenyuContext());
+
when(shenyuMcpServerManager.canRoute("/mcp/streamablehttp")).thenReturn(true);
+
+ StepVerifier.create(plugin.doExecute(webExchange, chain, selector,
rule))
+ .verifyComplete();
+
+ final String allowHeaders =
webExchange.getResponse().getHeaders().getFirst("Access-Control-Allow-Headers");
+ assertTrue(allowHeaders.toLowerCase(Locale.ROOT).contains("xrequest"));
+ }
}
diff --git
a/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProviderTest.java
b/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProviderTest.java
new file mode 100644
index 0000000000..d10af3ccb1
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProviderTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.mcp.server.transport;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.web.reactive.function.server.HandlerStrategies;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import org.springframework.web.reactive.function.server.ServerResponse;
+import reactor.test.StepVerifier;
+
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test case for {@link ShenyuStreamableHttpServerTransportProvider}.
+ */
+class ShenyuStreamableHttpServerTransportProviderTest {
+
+ @Test
+ void testPreflightUsesConfiguredHeadersAndMethods() {
+ ShenyuStreamableHttpServerTransportProvider provider =
+ new ShenyuStreamableHttpServerTransportProvider(new
ObjectMapper(),
+ "/mcp/streamablehttp", "Content-Type, XRequest");
+ ServerRequest request =
createRequest(MockServerHttpRequest.options("/mcp/streamablehttp")
+ .header("Origin", "http://localhost:6274")
+ .header("Access-Control-Request-Headers", "xrequest,
authorization")
+ .build());
+
+ StepVerifier.create(provider.handleUnifiedEndpoint(request))
+ .assertNext(response -> {
+ assertEquals(HttpStatus.OK, response.statusCode());
+ assertEquals("POST, OPTIONS",
+
response.headers().getFirst("Access-Control-Allow-Methods"));
+ assertEquals("xrequest",
+
response.headers().getFirst("Access-Control-Allow-Headers"));
+
assertTrue(response.headers().getVary().contains("Origin"));
+
assertTrue(response.headers().getVary().contains("Access-Control-Request-Headers"));
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void testPreflightUsesFallbackHeaders() {
+ ShenyuStreamableHttpServerTransportProvider provider =
+ new ShenyuStreamableHttpServerTransportProvider(new
ObjectMapper(),
+ "/mcp/streamablehttp");
+ ServerRequest request =
createRequest(MockServerHttpRequest.options("/mcp/streamablehttp")
+ .header("Origin", "http://localhost:6274")
+ .header("Access-Control-Request-Headers", "xrequest")
+ .build());
+
+ ServerResponse response =
provider.handleUnifiedEndpoint(request).block();
+ assertNotNull(response);
+ assertEquals(HttpStatus.OK, response.statusCode());
+ String allowHeaders =
response.headers().getFirst("Access-Control-Allow-Headers");
+ assertTrue(allowHeaders.toLowerCase(Locale.ROOT).contains("xrequest"));
+ }
+
+ private ServerRequest createRequest(final MockServerHttpRequest request) {
+ return ServerRequest.create(
+ MockServerWebExchange.from(request),
+ HandlerStrategies.withDefaults().messageReaders()
+ );
+ }
+}
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;
}
}