[Developer] add Codex streamable MCP endpoint
This commit is contained in:
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 -> {
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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:}
|
||||
|
||||
Reference in New Issue
Block a user