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

View File

@@ -29,6 +29,20 @@ public class McpSseController {
this.mcpSseService = mcpSseService; 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) @GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter defaultSse() throws IOException { public SseEmitter defaultSse() throws IOException {
return openSse("default"); return openSse("default");

View File

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