[Developer] add Codex streamable MCP endpoint

This commit is contained in:
devmrko
2026-07-07 17:52:29 +09:00
parent 6921682bcd
commit 9d3e384203
4 changed files with 94 additions and 2 deletions

View File

@@ -0,0 +1,71 @@
package com.cloudhandson.vpdbackoffice.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Authenticates only the streamable HTTP MCP endpoint with a service token.
* Business-data authorization remains the bearerToken tool argument, which is
* resolved to the VPD context by the ORDS handler.
*/
@Component
public class McpAccessTokenFilter extends OncePerRequestFilter {
private final String accessToken;
public McpAccessTokenFilter(@Value("${backoffice.mcp.access-token:}") String accessToken) {
this.accessToken = accessToken == null ? "" : accessToken.trim();
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !"/mcp".equals(request.getRequestURI());
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String bearerToken = bearerToken(request.getHeader("Authorization"));
if (!accessToken.isBlank() && bearerToken != null && constantTimeEquals(accessToken, bearerToken)) {
var authentication = new UsernamePasswordAuthenticationToken(
"mcp-client",
null,
List.of(new SimpleGrantedAuthority("ROLE_MCP"))
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String bearerToken(String authorization) {
if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
return null;
}
String value = authorization.substring(7).trim();
return value.isEmpty() ? null : value;
}
private boolean constantTimeEquals(String expected, String actual) {
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
actual.getBytes(StandardCharsets.UTF_8)
);
}
}

View File

@@ -8,7 +8,9 @@ import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.http.HttpMethod;
@Configuration
public class SecurityConfig {
@@ -16,21 +18,24 @@ public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(
HttpSecurity http,
BackofficeProperties properties
BackofficeProperties properties,
McpAccessTokenFilter mcpAccessTokenFilter
) throws Exception {
if (properties.security().requireHttps()) {
http.requiresChannel(channel -> channel.anyRequest().requiresSecure());
}
return http
.addFilterBefore(mcpAccessTokenFilter, BasicAuthenticationFilter.class)
.csrf(csrf -> csrf.ignoringRequestMatchers(
"/mcp/messages", "/mcp/*/messages", "/dds/mcp/messages"))
"/mcp", "/mcp/messages", "/mcp/*/messages", "/dds/mcp/messages"))
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31_536_000)))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/webjars/**", "/dds/mcp/sse", "/dds/mcp/messages")
.permitAll()
.requestMatchers(HttpMethod.POST, "/mcp").hasAnyRole("ADMIN", "MCP")
.anyRequest().authenticated())
.httpBasic(basic -> {
})

View File

@@ -29,6 +29,20 @@ public class McpSseController {
this.mcpSseService = mcpSseService;
}
/**
* Streamable HTTP transport for Codex and other current MCP clients.
* The legacy SSE endpoints remain available for existing integrations.
*/
@PostMapping(path = "/mcp", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ObjectNode> streamableMessage(@RequestBody JsonNode request) {
// JSON-RPC notifications never receive a response body. Current MCP
// clients send notifications/initialized immediately after initialize.
if (request != null && !request.has("id")) {
return ResponseEntity.accepted().build();
}
return ResponseEntity.ok(mcpSseService.handle("default", request));
}
@GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter defaultSse() throws IOException {
return openSse("default");

View File

@@ -53,3 +53,5 @@ backoffice:
embedding-model: ${BACKOFFICE_AI_EMBEDDING_MODEL:}
api-key: ${BACKOFFICE_AI_API_KEY:}
timeout: ${BACKOFFICE_AI_TIMEOUT_SECONDS:30}s
mcp:
access-token: ${BACKOFFICE_MCP_ACCESS_TOKEN:}