From 9d3e384203db77ac3ccd0081681573ebb470e830 Mon Sep 17 00:00:00 2001 From: devmrko Date: Tue, 7 Jul 2026 17:52:29 +0900 Subject: [PATCH] [Developer] add Codex streamable MCP endpoint --- .../config/McpAccessTokenFilter.java | 71 +++++++++++++++++++ .../vpdbackoffice/config/SecurityConfig.java | 9 ++- .../vpdbackoffice/web/McpSseController.java | 14 ++++ src/main/resources/application.yml | 2 + 4 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/config/McpAccessTokenFilter.java diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/McpAccessTokenFilter.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/McpAccessTokenFilter.java new file mode 100644 index 0000000..27049ce --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/McpAccessTokenFilter.java @@ -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) + ); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java index 147f1a8..c8e4045 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java @@ -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 -> { }) diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java index 81c54a0..6834b3c 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java @@ -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 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"); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9c085a9..f0f72ba 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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:}