[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.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 -> {
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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:}
|
||||||
|
|||||||
Reference in New Issue
Block a user