Copilot commented on code in PR #7893: URL: https://github.com/apache/incubator-seata/pull/7893#discussion_r2646797415
########## console/src/main/java/org/apache/seata/mcp/core/utils/DateUtils.java: ########## @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.core.utils; + +import java.time.DateTimeException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.regex.Pattern; + +public class DateUtils { + + public static final Long ONE_DAY_TIMESTAMP = 86400000L; + + private static final Pattern DATE_PATTERN = Pattern.compile("^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$"); + + public static boolean isValidDate(String dateStr) { + return DATE_PATTERN.matcher(dateStr).matches(); + } + + public static long convertToTimestampFromDate(String dateStr) { + if (!isValidDate(dateStr)) { + throw new DateTimeException("The time format does not match yyyy-mm-dd"); Review Comment: The exception message contains a grammatical error. "The time format does not match yyyy-mm-dd" should use uppercase "MM" and "DD" to be consistent with the actual pattern and with the error message at line 55 which correctly uses "yyyy-MM-dd HH:mm:ss". ```suggestion throw new DateTimeException("The time format does not match yyyy-MM-dd"); ``` ########## console/src/main/java/org/apache/seata/mcp/entity/vo/McpGlobalLockVO.java: ########## @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.entity.vo; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.apache.seata.mcp.core.config.TimestampToStringDeserializer; + +public class McpGlobalLockVO { + + private String xid; + + private String transactionId; + + private String branchId; + + private String resourceId; + + private String tableName; + + private String pk; + + private String rowKey; + + /** + * the vgroup + */ + private String vgroup; + + @JsonDeserialize(using = TimestampToStringDeserializer.class) + private String gmtCreate; + + @JsonDeserialize(using = TimestampToStringDeserializer.class) + private String gmtModified; + + public String getXid() { + return xid; + } + + public void setXid(String xid) { + this.xid = xid; + } + + public String getTransactionId() { + return transactionId; + } + + public void setTransactionId(Long transactionId) { + this.transactionId = String.valueOf(transactionId); + } + + public String getBranchId() { + return branchId; + } + + public void setBranchId(Long branchId) { + this.branchId = String.valueOf(branchId); + } Review Comment: Duplicate setter methods detected. The class has two setters for both `transactionId` and `branchId`: 1. Lines 61-63 and 121-123 for `transactionId` 2. Lines 69-71 and 125-127 for `branchId` The duplicate setters at lines 121-127 accept String parameters while the earlier ones at lines 61-71 accept Long parameters. This creates ambiguity and potential runtime issues. You should remove one set of setters or rename them to clarify their purpose (e.g., `setTransactionIdFromLong` vs `setTransactionIdFromString`). ########## console/src/main/java/org/apache/seata/mcp/service/impl/ConsoleRemoteServiceImpl.java: ########## @@ -0,0 +1,244 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.NamingServerLocalMarker; +import org.apache.seata.common.exception.AuthenticationFailedException; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.console.config.WebSecurityConfig; +import org.apache.seata.console.utils.JwtTokenUtils; +import org.apache.seata.mcp.core.props.NameSpaceDetail; +import org.apache.seata.mcp.service.ConsoleApiService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +import static org.apache.seata.mcp.core.utils.UrlUtils.buildUrl; +import static org.apache.seata.mcp.core.utils.UrlUtils.objectToQueryParamMap; + +@ConditionalOnMissingBean(NamingServerLocalMarker.class) +@Service +public class ConsoleRemoteServiceImpl implements ConsoleApiService { + + private final JwtTokenUtils jwtTokenUtils; + + private final RestTemplate restTemplate; + + private final ObjectMapper objectMapper; + + private final AuthenticationManager authenticationManager; + + public ConsoleRemoteServiceImpl( + JwtTokenUtils jwtTokenUtils, + RestTemplate restTemplate, + ObjectMapper objectMapper, + AuthenticationManager authenticationManager) { + this.jwtTokenUtils = jwtTokenUtils; + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + this.authenticationManager = authenticationManager; + } + + @Value("${seata.console.naming-space-url:http://127.0.0.1:%s}") + private String namingSpaceUrl; + + private final Logger logger = LoggerFactory.getLogger(ConsoleRemoteServiceImpl.class); + + @Value("${server.port:8081}") + private String namingSpacePort; + + @Value("${seata.mcp.auth.enabled}") + private String enabledAuth; + + public String getToken() { + if (Boolean.parseBoolean(enabledAuth)) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + throw new AuthenticationFailedException("No right to be identified"); + } + String originJwt = (String) auth.getCredentials(); + if (!jwtTokenUtils.validateToken(originJwt)) { + throw new AuthenticationFailedException("Invalid token, please log back in to get a new token"); + } + return WebSecurityConfig.TOKEN_PREFIX + originJwt; + } else { + logger.warn( + "Authentication is disabled (seata.mcp.auth.enabled=false); " + + "generating token using internal fallback user. This configuration should not be used in production."); Review Comment: Sensitive warning message in production. The warning at lines 94-96 reveals internal security configuration details and explicitly states that authentication is disabled. This information could be useful to attackers. Consider: 1. Reducing the verbosity of this log message in production 2. Using a different log level (DEBUG instead of WARN) 3. Not mentioning specific configuration keys in production logs ```suggestion logger.debug( "Authentication is disabled; this configuration is not recommended for production environments."); ``` ########## console/src/main/java/org/apache/seata/mcp/service/impl/ConsoleRemoteServiceImpl.java: ########## @@ -0,0 +1,244 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.NamingServerLocalMarker; +import org.apache.seata.common.exception.AuthenticationFailedException; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.console.config.WebSecurityConfig; +import org.apache.seata.console.utils.JwtTokenUtils; +import org.apache.seata.mcp.core.props.NameSpaceDetail; +import org.apache.seata.mcp.service.ConsoleApiService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +import static org.apache.seata.mcp.core.utils.UrlUtils.buildUrl; +import static org.apache.seata.mcp.core.utils.UrlUtils.objectToQueryParamMap; + +@ConditionalOnMissingBean(NamingServerLocalMarker.class) +@Service +public class ConsoleRemoteServiceImpl implements ConsoleApiService { + + private final JwtTokenUtils jwtTokenUtils; + + private final RestTemplate restTemplate; + + private final ObjectMapper objectMapper; + + private final AuthenticationManager authenticationManager; + + public ConsoleRemoteServiceImpl( + JwtTokenUtils jwtTokenUtils, + RestTemplate restTemplate, + ObjectMapper objectMapper, + AuthenticationManager authenticationManager) { + this.jwtTokenUtils = jwtTokenUtils; + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + this.authenticationManager = authenticationManager; + } + + @Value("${seata.console.naming-space-url:http://127.0.0.1:%s}") + private String namingSpaceUrl; + + private final Logger logger = LoggerFactory.getLogger(ConsoleRemoteServiceImpl.class); + + @Value("${server.port:8081}") + private String namingSpacePort; + + @Value("${seata.mcp.auth.enabled}") + private String enabledAuth; + + public String getToken() { + if (Boolean.parseBoolean(enabledAuth)) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + throw new AuthenticationFailedException("No right to be identified"); + } + String originJwt = (String) auth.getCredentials(); + if (!jwtTokenUtils.validateToken(originJwt)) { + throw new AuthenticationFailedException("Invalid token, please log back in to get a new token"); + } + return WebSecurityConfig.TOKEN_PREFIX + originJwt; + } else { + logger.warn( + "Authentication is disabled (seata.mcp.auth.enabled=false); " + + "generating token using internal fallback user. This configuration should not be used in production."); + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken("seata", ""); + Authentication authentication = authenticationManager.authenticate(authenticationToken); + return WebSecurityConfig.TOKEN_PREFIX + jwtTokenUtils.createToken(authentication); + } + } + + public void setNamespaceHeaderAndQueryParam( + NameSpaceDetail nameSpaceDetail, HttpHeaders headers, Map<String, String> queryParams) { + headers.add("x-seata-namespace", nameSpaceDetail.getNamespace()); + if (StringUtils.isNotBlank(nameSpaceDetail.getvGroup())) { + if (queryParams != null) { + queryParams.put("vGroup", nameSpaceDetail.getvGroup()); + } + return; + } + if (nameSpaceDetail.getCluster() != null) { + headers.add("x-seata-cluster", nameSpaceDetail.getCluster()); + } + } + + @Override + public String getCallNameSpace(String path) { + HttpHeaders headers = new HttpHeaders(); + headers.add(WebSecurityConfig.AUTHORIZATION_HEADER, getToken()); + String url = buildUrl(String.format(namingSpaceUrl, namingSpacePort), path, null, null); + HttpEntity<String> entity = new HttpEntity<>(headers); + String responseBody; + try { + ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + + responseBody = response.getBody(); + + if (!response.getStatusCode().is2xxSuccessful()) { + logger.warn("MCP GET request returned non-success status: {}", response.getStatusCode()); + } + return responseBody; + } catch (RestClientException e) { + logger.error("MCP GET Call NameSpace Failed", e); + return "Connect TC Failed"; + } + } + + @Override + public String getCallTC( + NameSpaceDetail nameSpaceDetail, + String path, + Object objectQueryParams, + Map<String, String> queryParams, + HttpHeaders headers) { + if (headers == null) { + headers = new HttpHeaders(); + } + if (nameSpaceDetail == null || !nameSpaceDetail.isValid()) { + return "If you have not specified the namespace of the TC/Server, specify the namespace first"; + } else { + setNamespaceHeaderAndQueryParam(nameSpaceDetail, headers, queryParams); + } + headers.add(WebSecurityConfig.AUTHORIZATION_HEADER, getToken()); + Map<String, Object> queryParamsMap = objectToQueryParamMap(objectQueryParams, objectMapper); + String url = buildUrl(String.format(namingSpaceUrl, namingSpacePort), path, queryParams, queryParamsMap); + HttpEntity<String> entity = new HttpEntity<>(headers); + String responseBody; + try { + ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + + responseBody = response.getBody(); + + if (!response.getStatusCode().is2xxSuccessful()) { + logger.warn("MCP GET request returned non-success status: {}", response.getStatusCode()); + } + return responseBody; + } catch (RestClientException e) { + logger.error("MCP GET Call TC Failed", e); + return "Connect TC Failed"; + } + } + + @Override + public String deleteCallTC( + NameSpaceDetail nameSpaceDetail, + String path, + Object objectQueryParams, + Map<String, String> queryParams, + HttpHeaders headers) { + if (headers == null) { + headers = new HttpHeaders(); + } + if (nameSpaceDetail == null || !nameSpaceDetail.isValid()) { + return "If you have not specified the namespace of the TC/Server, specify the namespace first"; + } else { + setNamespaceHeaderAndQueryParam(nameSpaceDetail, headers, queryParams); + } + headers.add(WebSecurityConfig.AUTHORIZATION_HEADER, getToken()); + Map<String, Object> queryParamsMap = objectToQueryParamMap(objectQueryParams, objectMapper); + String url = buildUrl(String.format(namingSpaceUrl, namingSpacePort), path, queryParams, queryParamsMap); + HttpEntity<String> entity = new HttpEntity<>(headers); + String responseBody; + try { + ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.DELETE, entity, String.class); + + responseBody = response.getBody(); + + if (!response.getStatusCode().is2xxSuccessful()) { + logger.warn("MCP DELETE request returned non-success status: {}", response.getStatusCode()); + } + return responseBody; + } catch (RestClientException e) { + logger.error("MCP DELETE Call TC Failed", e); + return "Connect TC Failed"; + } + } + + @Override + public String putCallTC( + NameSpaceDetail nameSpaceDetail, + String path, + Object objectQueryParams, + Map<String, String> queryParams, + HttpHeaders headers) { + if (headers == null) { + headers = new HttpHeaders(); + } + if (nameSpaceDetail == null || !nameSpaceDetail.isValid()) { + return "If you have not specified the namespace of the TC/Server, specify the namespace first"; + } else { + setNamespaceHeaderAndQueryParam(nameSpaceDetail, headers, queryParams); + } + headers.add(WebSecurityConfig.AUTHORIZATION_HEADER, getToken()); + Map<String, Object> queryParamsMap = objectToQueryParamMap(objectQueryParams, objectMapper); + String url = buildUrl(String.format(namingSpaceUrl, namingSpacePort), path, queryParams, queryParamsMap); + HttpEntity<String> entity = new HttpEntity<>(headers); + String responseBody; + try { + ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.PUT, entity, String.class); + + responseBody = response.getBody(); + + if (!response.getStatusCode().is2xxSuccessful()) { + logger.warn("MCP PUT request returned non-success status: {}", response.getStatusCode()); + } + return responseBody; + } catch (RestClientException e) { + logger.error("MCP Put Call TC Failed", e); + return "Connect TC Failed"; + } Review Comment: Error handling loses exception context. The caught RestClientException instances at lines 134, 169, 204, and 239 are logged, but then simple error strings are returned instead of proper error responses. This makes debugging difficult because: 1. The calling code receives only generic error messages 2. Stack traces and root causes are lost 3. No way to distinguish between different types of failures Consider returning structured error responses or throwing exceptions that preserve the original cause. ########## console/src/main/java/org/apache/seata/mcp/tools/GlobalLockTools.java: ########## @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.tools; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.result.PageResult; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.mcp.core.constant.RPCConstant; +import org.apache.seata.mcp.core.props.MCPProperties; +import org.apache.seata.mcp.core.props.NameSpaceDetail; +import org.apache.seata.mcp.core.utils.DateUtils; +import org.apache.seata.mcp.entity.dto.McpGlobalLockParamDto; +import org.apache.seata.mcp.entity.param.McpGlobalLockDeleteParam; +import org.apache.seata.mcp.entity.param.McpGlobalLockParam; +import org.apache.seata.mcp.entity.vo.McpGlobalLockVO; +import org.apache.seata.mcp.service.ConsoleApiService; +import org.apache.seata.mcp.service.ModifyConfirmService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Service +public class GlobalLockTools { + + private final Logger logger = LoggerFactory.getLogger(GlobalLockTools.class); + + private final ConsoleApiService mcpRPCService; + + private final MCPProperties mcpProperties; + + private final ModifyConfirmService modifyConfirmService; + + private final ObjectMapper objectMapper; + + public GlobalLockTools( + ConsoleApiService mcpRPCService, + MCPProperties mcpProperties, + ModifyConfirmService modifyConfirmService, + ObjectMapper objectMapper) { + this.mcpRPCService = mcpRPCService; + this.mcpProperties = mcpProperties; + this.modifyConfirmService = modifyConfirmService; + this.objectMapper = objectMapper; + } + + @McpTool(description = "Query the global lock information") + public PageResult<McpGlobalLockVO> queryGlobalLock( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global lock parameters") McpGlobalLockParamDto paramDto) { + McpGlobalLockParam param = McpGlobalLockParam.convertFromParamDto(paramDto); + if (param.getTimeStart() != null) { + if (param.getTimeEnd() != null) { + if (DateUtils.judgeExceedTimeDuration( + param.getTimeStart(), param.getTimeEnd(), mcpProperties.getQueryDuration())) { + return PageResult.failure( + "", + String.format( + "The query time span is not allowed to exceed the max query duration: %s hour", + DateUtils.convertToHourFromTimeStamp(mcpProperties.getQueryDuration()))); + } + } else { + param.setTimeEnd(param.getTimeStart() + DateUtils.ONE_DAY_TIMESTAMP); + } + } else { + param.setTimeEnd(null); + param.setTimeStart(null); + } + PageResult<McpGlobalLockVO> result = null; + String response = mcpRPCService.getCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_LOCK_BASE_URL + "/query", param, null, null); + try { + result = objectMapper.readValue(response, new TypeReference<PageResult<McpGlobalLockVO>>() {}); + } catch (JsonProcessingException e) { + logger.error(e.getMessage()); + } + if (result == null) { + return PageResult.failure("", "query global lock failed"); + } else { + return result; + } + } + + @McpTool(description = "Delete the global lock, Get the modify key before you delete") + public String deleteGlobalLock( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global lock delete parameters") McpGlobalLockDeleteParam param, + @McpToolParam(description = "Modify key") String modifyKey) { + if (!modifyConfirmService.isValidKey(modifyKey)) { + return "The modify key is not available"; + } + String result = mcpRPCService.deleteCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_LOCK_BASE_URL + "/delete", param, null, null); + if (StringUtils.isBlank(result)) { + return "delete global lock failed"; Review Comment: The error message template is inconsistent. This message does not include the xid/branchId parameters in the error message format, but similar error messages in the same file (lines 58, 79, 100) do include them. For consistency and better debugging, the error message should include the actual xid and branchId values that failed. ########## console/src/main/java/org/apache/seata/mcp/service/impl/ConsoleRemoteServiceImpl.java: ########## @@ -0,0 +1,244 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.NamingServerLocalMarker; +import org.apache.seata.common.exception.AuthenticationFailedException; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.console.config.WebSecurityConfig; +import org.apache.seata.console.utils.JwtTokenUtils; +import org.apache.seata.mcp.core.props.NameSpaceDetail; +import org.apache.seata.mcp.service.ConsoleApiService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +import static org.apache.seata.mcp.core.utils.UrlUtils.buildUrl; +import static org.apache.seata.mcp.core.utils.UrlUtils.objectToQueryParamMap; + +@ConditionalOnMissingBean(NamingServerLocalMarker.class) +@Service +public class ConsoleRemoteServiceImpl implements ConsoleApiService { + + private final JwtTokenUtils jwtTokenUtils; + + private final RestTemplate restTemplate; + + private final ObjectMapper objectMapper; + + private final AuthenticationManager authenticationManager; + + public ConsoleRemoteServiceImpl( + JwtTokenUtils jwtTokenUtils, + RestTemplate restTemplate, + ObjectMapper objectMapper, + AuthenticationManager authenticationManager) { + this.jwtTokenUtils = jwtTokenUtils; + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + this.authenticationManager = authenticationManager; + } + + @Value("${seata.console.naming-space-url:http://127.0.0.1:%s}") + private String namingSpaceUrl; + + private final Logger logger = LoggerFactory.getLogger(ConsoleRemoteServiceImpl.class); + + @Value("${server.port:8081}") + private String namingSpacePort; + + @Value("${seata.mcp.auth.enabled}") + private String enabledAuth; + + public String getToken() { + if (Boolean.parseBoolean(enabledAuth)) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + throw new AuthenticationFailedException("No right to be identified"); + } + String originJwt = (String) auth.getCredentials(); + if (!jwtTokenUtils.validateToken(originJwt)) { + throw new AuthenticationFailedException("Invalid token, please log back in to get a new token"); + } + return WebSecurityConfig.TOKEN_PREFIX + originJwt; + } else { + logger.warn( + "Authentication is disabled (seata.mcp.auth.enabled=false); " + + "generating token using internal fallback user. This configuration should not be used in production."); + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken("seata", ""); + Authentication authentication = authenticationManager.authenticate(authenticationToken); + return WebSecurityConfig.TOKEN_PREFIX + jwtTokenUtils.createToken(authentication); Review Comment: Hardcoded fallback credentials are a security risk. Line 98 uses hardcoded credentials `new UsernamePasswordAuthenticationToken("seata", "")` when authentication is disabled. Even though this is only used when auth is disabled, hardcoded credentials in source code are a security anti-pattern and could be exploited if: 1. The configuration is accidentally disabled in production 2. The code is copied and reused elsewhere 3. An attacker gains access to disable the auth configuration Consider using a configuration-based approach for fallback credentials or requiring explicit admin approval for disabling authentication. ########## console/src/main/java/org/apache/seata/mcp/tools/GlobalSessionTools.java: ########## @@ -0,0 +1,228 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.tools; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.result.PageResult; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.core.model.GlobalStatus; +import org.apache.seata.mcp.core.constant.RPCConstant; +import org.apache.seata.mcp.core.props.MCPProperties; +import org.apache.seata.mcp.core.props.NameSpaceDetail; +import org.apache.seata.mcp.core.utils.DateUtils; +import org.apache.seata.mcp.entity.dto.McpGlobalSessionParamDto; +import org.apache.seata.mcp.entity.param.McpGlobalAbnormalSessionParam; +import org.apache.seata.mcp.entity.param.McpGlobalSessionParam; +import org.apache.seata.mcp.entity.vo.McpGlobalSessionVO; +import org.apache.seata.mcp.service.ConsoleApiService; +import org.apache.seata.mcp.service.ModifyConfirmService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class GlobalSessionTools { + + private final Logger logger = LoggerFactory.getLogger(GlobalSessionTools.class); + + private final ConsoleApiService mcpRPCService; + + private final MCPProperties mcpProperties; + + private final ObjectMapper objectMapper; + + private final ModifyConfirmService modifyConfirmService; + + private final List<Integer> exceptionStatus = new ArrayList<>(); + + public static final int ABNORMAL_SESSION_PAGE_SIZE = 30; + + public GlobalSessionTools( + ConsoleApiService mcpRPCService, + MCPProperties mcpProperties, + ObjectMapper objectMapper, + ModifyConfirmService modifyConfirmService) { + this.mcpRPCService = mcpRPCService; + this.mcpProperties = mcpProperties; + this.objectMapper = objectMapper; + this.modifyConfirmService = modifyConfirmService; + exceptionStatus.add(GlobalStatus.CommitFailed.getCode()); + exceptionStatus.add(GlobalStatus.TimeoutRollbackFailed.getCode()); + exceptionStatus.add(GlobalStatus.RollbackFailed.getCode()); + } + + @McpTool(description = "Query global transactions") + public PageResult<McpGlobalSessionVO> queryGlobalSession( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Query parameter objects") McpGlobalSessionParamDto paramDto) { + McpGlobalSessionParam param = McpGlobalSessionParam.convertFromDtoParam(paramDto); + if (param.getTimeStart() != null) { + if (param.getTimeEnd() != null) { + if (DateUtils.judgeExceedTimeDuration( + param.getTimeStart(), param.getTimeEnd(), mcpProperties.getQueryDuration())) { + return PageResult.failure( + "", + String.format( + "The query time span is not allowed to exceed the max query duration: %s hours", + DateUtils.convertToHourFromTimeStamp(mcpProperties.getQueryDuration()))); + } + } else { + param.setTimeEnd(param.getTimeStart() + DateUtils.ONE_DAY_TIMESTAMP); + } + } else { + param.setTimeEnd(null); + param.setTimeStart(null); + } + PageResult<McpGlobalSessionVO> pageResult = null; + String result = mcpRPCService.getCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_SESSION_BASE_URL + "/query", param, null, null); + try { + pageResult = objectMapper.readValue(result, new TypeReference<PageResult<McpGlobalSessionVO>>() {}); + } catch (JsonProcessingException e) { + logger.error(e.getMessage()); + } + if (pageResult == null) { + return PageResult.failure("", "query global session failed"); + } else { + return pageResult; + } + } + + @McpTool(description = "Delete the global session, Get the modify key before you delete") + public String deleteGlobalSession( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global transaction id") String xid, + @McpToolParam(description = "Modify key") String modifyKey) { + if (!modifyConfirmService.isValidKey(modifyKey)) { + return "The modify key is not available"; + } + Map<String, String> pathParams = new HashMap<>(); + pathParams.put("xid", xid); + String result = mcpRPCService.deleteCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_SESSION_BASE_URL + "/deleteGlobalSession", null, pathParams, null); + if (StringUtils.isBlank(result)) { + return String.format("delete global session failed, xid: %s", xid); + } else { + return result; + } + } + + @McpTool(description = "Stop the global session retry, Get the modify key before you stop") + public String stopGlobalSession( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global transaction id") String xid, + @McpToolParam(description = "Modify key") String modifyKey) { + if (!modifyConfirmService.isValidKey(modifyKey)) { + return "The modify key is not available"; + } + Map<String, String> pathParams = new HashMap<>(); + pathParams.put("xid", xid); + String result = mcpRPCService.putCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_SESSION_BASE_URL + "/stopGlobalSession", null, pathParams, null); + if (StringUtils.isBlank(result)) { + return String.format("stop global session retry failed, xid: %s", xid); + } else { + return result; + } + } + + @McpTool(description = "Start the global session retry, Get the modify key before you start") + public String startGlobalSession( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global transaction id") String xid, + @McpToolParam(description = "Modify key") String modifyKey) { + if (!modifyConfirmService.isValidKey(modifyKey)) { + return "The modify key is not available"; + } + Map<String, String> pathParams = new HashMap<>(); + pathParams.put("xid", xid); + String result = mcpRPCService.putCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_SESSION_BASE_URL + "/startGlobalSession", null, pathParams, null); + if (StringUtils.isBlank(result)) { + return String.format("start the global session retry failed, xid: %s", xid); + } else { + return result; + } + } + + @McpTool(description = "Send global session to commit or rollback to rm, Get the modify key before you send") + public String sendCommitOrRollback( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global transaction id") String xid, + @McpToolParam(description = "Modify key") String modifyKey) { + if (!modifyConfirmService.isValidKey(modifyKey)) { + return "The modify key is not available"; + } + Map<String, String> pathParams = new HashMap<>(); + pathParams.put("xid", xid); + String result = mcpRPCService.putCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_SESSION_BASE_URL + "/sendCommitOrRollback", null, pathParams, null); + if (StringUtils.isBlank(result)) { + return String.format("send global session to commit or rollback to rm failed, xid: %s", xid); + } else { + return result; + } + } + + @McpTool( + description = + "Change the global session status, Used to change transactions that are in a failed commit or rollback failed state to a retry state, Get the modify key before you change") + public String changeGlobalStatus( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global transaction id") String xid, + @McpToolParam(description = "Modify key") String modifyKey) { + if (!modifyConfirmService.isValidKey(modifyKey)) { + return "The modify key is not available"; + } + Map<String, String> pathParams = new HashMap<>(); + pathParams.put("xid", xid); + String result = mcpRPCService.putCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_SESSION_BASE_URL + "/changeGlobalStatus", null, pathParams, null); + if (StringUtils.isBlank(result)) { + return String.format("change the global session status failed, xid: %s", xid); + } else { + return result; + } + } + + @McpTool(description = "Check out the abnormal transaction information,You can specify the time") + public List<McpGlobalSessionVO> getAbnormalSessions( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Query Param") McpGlobalAbnormalSessionParam abnormalSessionParam) { + List<McpGlobalSessionVO> result = new ArrayList<>(); + McpGlobalSessionParamDto param = McpGlobalSessionParamDto.convertFromAbnormalParam(abnormalSessionParam); + param.setPageSize(ABNORMAL_SESSION_PAGE_SIZE); + for (Integer status : exceptionStatus) { + param.setStatus(status); + List<McpGlobalSessionVO> datas = + queryGlobalSession(nameSpaceDetail, param).getData(); Review Comment: The variable name `datas` is incorrect English. The plural of "data" is "data", not "datas". The variable should be renamed to `data` or `sessionData`. ########## namingserver/src/main/java/org/apache/seata/namingserver/service/ConsoleLocalServiceImpl.java: ########## @@ -0,0 +1,163 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.namingserver.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.metadata.ClusterRole; +import org.apache.seata.common.metadata.Node; +import org.apache.seata.common.metadata.namingserver.NamingServerNode; +import org.apache.seata.common.util.CollectionUtils; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.mcp.core.props.NameSpaceDetail; +import org.apache.seata.mcp.service.ConsoleApiService; +import org.apache.seata.mcp.service.impl.ConsoleRemoteServiceImpl; +import org.apache.seata.namingserver.manager.NamingManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +import static org.apache.seata.common.Constants.RAFT_GROUP_HEADER; +import static org.apache.seata.mcp.core.utils.UrlUtils.buildUrl; +import static org.apache.seata.mcp.core.utils.UrlUtils.objectToQueryParamMap; + +@ConditionalOnBean(ConsoleRemoteServiceImpl.class) +@Primary +@Service +public class ConsoleLocalServiceImpl implements ConsoleApiService { + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + private final NamingManager namingManager; + + private final RestTemplate restTemplate; + + private final ObjectMapper objectMapper; + + public ConsoleLocalServiceImpl(NamingManager namingManager, RestTemplate restTemplate, ObjectMapper objectMapper) { + this.namingManager = namingManager; + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + } + + public String getResult( + NameSpaceDetail nameSpaceDetail, + HttpMethod httpMethod, + String path, + Object objectQueryParams, + Map<String, String> queryParams, + HttpHeaders headers) { + String namespace = nameSpaceDetail.getNamespace(); + String cluster = nameSpaceDetail.getCluster(); + String vgroup = nameSpaceDetail.getvGroup(); + if (StringUtils.isNotBlank(namespace) && (StringUtils.isNotBlank(cluster) || StringUtils.isNotBlank(vgroup))) { + List<NamingServerNode> list = null; + if (StringUtils.isNotBlank(vgroup)) { + list = namingManager.getInstancesByVgroupAndNamespace( + namespace, vgroup, HttpMethod.GET.equals(httpMethod)); + } else if (StringUtils.isNotBlank(cluster)) { + list = namingManager.getInstances(namespace, cluster); + } + if (CollectionUtils.isNotEmpty(list)) { + // Randomly select a node from the list + NamingServerNode node = list.get(ThreadLocalRandom.current().nextInt(list.size())); + Node.Endpoint controlEndpoint = node.getControl(); + if (controlEndpoint != null) { + // Construct the target URL + String baseUrl = "http://" + controlEndpoint.getHost() + ":" + controlEndpoint.getPort(); + Map<String, Object> queryParamsMap = objectToQueryParamMap(objectQueryParams, objectMapper); + String targetUrl = buildUrl(baseUrl, path, queryParams, queryParamsMap); + if (node.getRole() == ClusterRole.LEADER) { + headers.add(RAFT_GROUP_HEADER, node.getUnit()); + } + HttpEntity<String> entity = new HttpEntity<>(headers); + String responseBody; + try { + ResponseEntity<String> response = + restTemplate.exchange(targetUrl, httpMethod, entity, String.class); + + responseBody = response.getBody(); + + if (!response.getStatusCode().is2xxSuccessful()) { + logger.warn("MCP request returned non-success status: {}", response.getStatusCode()); + } + return responseBody; + } catch (RestClientException e) { + logger.error("MCP {} Call TC Failed: {}", httpMethod.name(), e.getMessage()); + throw new RestClientException("Connect TC Failed"); Review Comment: The exception is caught but the original error message is discarded. The caught RestClientException at line 111 is logged with only its message, and then a new generic RestClientException is thrown with "Connect TC Failed". This loses valuable debugging information about why the connection failed. The original exception should be passed as the cause to the new exception. ########## console/src/main/java/org/apache/seata/mcp/tools/GlobalSessionTools.java: ########## @@ -0,0 +1,228 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.tools; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.result.PageResult; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.core.model.GlobalStatus; +import org.apache.seata.mcp.core.constant.RPCConstant; +import org.apache.seata.mcp.core.props.MCPProperties; +import org.apache.seata.mcp.core.props.NameSpaceDetail; +import org.apache.seata.mcp.core.utils.DateUtils; +import org.apache.seata.mcp.entity.dto.McpGlobalSessionParamDto; +import org.apache.seata.mcp.entity.param.McpGlobalAbnormalSessionParam; +import org.apache.seata.mcp.entity.param.McpGlobalSessionParam; +import org.apache.seata.mcp.entity.vo.McpGlobalSessionVO; +import org.apache.seata.mcp.service.ConsoleApiService; +import org.apache.seata.mcp.service.ModifyConfirmService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class GlobalSessionTools { + + private final Logger logger = LoggerFactory.getLogger(GlobalSessionTools.class); + + private final ConsoleApiService mcpRPCService; + + private final MCPProperties mcpProperties; + + private final ObjectMapper objectMapper; + + private final ModifyConfirmService modifyConfirmService; + + private final List<Integer> exceptionStatus = new ArrayList<>(); + + public static final int ABNORMAL_SESSION_PAGE_SIZE = 30; + + public GlobalSessionTools( + ConsoleApiService mcpRPCService, + MCPProperties mcpProperties, + ObjectMapper objectMapper, + ModifyConfirmService modifyConfirmService) { + this.mcpRPCService = mcpRPCService; + this.mcpProperties = mcpProperties; + this.objectMapper = objectMapper; + this.modifyConfirmService = modifyConfirmService; + exceptionStatus.add(GlobalStatus.CommitFailed.getCode()); + exceptionStatus.add(GlobalStatus.TimeoutRollbackFailed.getCode()); + exceptionStatus.add(GlobalStatus.RollbackFailed.getCode()); + } + + @McpTool(description = "Query global transactions") + public PageResult<McpGlobalSessionVO> queryGlobalSession( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Query parameter objects") McpGlobalSessionParamDto paramDto) { + McpGlobalSessionParam param = McpGlobalSessionParam.convertFromDtoParam(paramDto); + if (param.getTimeStart() != null) { + if (param.getTimeEnd() != null) { + if (DateUtils.judgeExceedTimeDuration( + param.getTimeStart(), param.getTimeEnd(), mcpProperties.getQueryDuration())) { + return PageResult.failure( + "", + String.format( + "The query time span is not allowed to exceed the max query duration: %s hours", + DateUtils.convertToHourFromTimeStamp(mcpProperties.getQueryDuration()))); + } + } else { + param.setTimeEnd(param.getTimeStart() + DateUtils.ONE_DAY_TIMESTAMP); + } + } else { + param.setTimeEnd(null); + param.setTimeStart(null); + } Review Comment: The condition check has a logic issue. When `endTime` is null but `startTime` is not null, the code sets `timeEnd` to `startTime + ONE_DAY_TIMESTAMP` (line 93). However, it then immediately proceeds to check if the duration exceeds the max query duration, which will always pass since we just set it to one day. But then on line 96, when `timeStart` is null, both `timeEnd` and `timeStart` are set to null, which means the previous calculation on line 93 gets overwritten. The logic at line 96 should be inside an `else` block or there should be a `return` after line 94 to avoid the null assignment overwriting the calculated timeEnd. ########## console/src/main/java/org/apache/seata/mcp/tools/GlobalLockTools.java: ########## @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.tools; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.result.PageResult; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.mcp.core.constant.RPCConstant; +import org.apache.seata.mcp.core.props.MCPProperties; +import org.apache.seata.mcp.core.props.NameSpaceDetail; +import org.apache.seata.mcp.core.utils.DateUtils; +import org.apache.seata.mcp.entity.dto.McpGlobalLockParamDto; +import org.apache.seata.mcp.entity.param.McpGlobalLockDeleteParam; +import org.apache.seata.mcp.entity.param.McpGlobalLockParam; +import org.apache.seata.mcp.entity.vo.McpGlobalLockVO; +import org.apache.seata.mcp.service.ConsoleApiService; +import org.apache.seata.mcp.service.ModifyConfirmService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Service +public class GlobalLockTools { + + private final Logger logger = LoggerFactory.getLogger(GlobalLockTools.class); + + private final ConsoleApiService mcpRPCService; + + private final MCPProperties mcpProperties; + + private final ModifyConfirmService modifyConfirmService; + + private final ObjectMapper objectMapper; + + public GlobalLockTools( + ConsoleApiService mcpRPCService, + MCPProperties mcpProperties, + ModifyConfirmService modifyConfirmService, + ObjectMapper objectMapper) { + this.mcpRPCService = mcpRPCService; + this.mcpProperties = mcpProperties; + this.modifyConfirmService = modifyConfirmService; + this.objectMapper = objectMapper; + } + + @McpTool(description = "Query the global lock information") + public PageResult<McpGlobalLockVO> queryGlobalLock( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global lock parameters") McpGlobalLockParamDto paramDto) { + McpGlobalLockParam param = McpGlobalLockParam.convertFromParamDto(paramDto); + if (param.getTimeStart() != null) { + if (param.getTimeEnd() != null) { + if (DateUtils.judgeExceedTimeDuration( + param.getTimeStart(), param.getTimeEnd(), mcpProperties.getQueryDuration())) { + return PageResult.failure( + "", + String.format( + "The query time span is not allowed to exceed the max query duration: %s hour", + DateUtils.convertToHourFromTimeStamp(mcpProperties.getQueryDuration()))); + } + } else { + param.setTimeEnd(param.getTimeStart() + DateUtils.ONE_DAY_TIMESTAMP); + } + } else { + param.setTimeEnd(null); + param.setTimeStart(null); + } Review Comment: The same logic issue exists here as in GlobalSessionTools. When `timeStart` is null (line 95), both `timeEnd` and `timeStart` are set to null (lines 86-87), potentially overwriting the calculated `timeEnd` value from line 83. This should be structured with proper if-else logic to prevent the time values from being incorrectly reset. ########## console/src/main/java/org/apache/seata/mcp/tools/GlobalLockTools.java: ########## @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.tools; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.result.PageResult; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.mcp.core.constant.RPCConstant; +import org.apache.seata.mcp.core.props.MCPProperties; +import org.apache.seata.mcp.core.props.NameSpaceDetail; +import org.apache.seata.mcp.core.utils.DateUtils; +import org.apache.seata.mcp.entity.dto.McpGlobalLockParamDto; +import org.apache.seata.mcp.entity.param.McpGlobalLockDeleteParam; +import org.apache.seata.mcp.entity.param.McpGlobalLockParam; +import org.apache.seata.mcp.entity.vo.McpGlobalLockVO; +import org.apache.seata.mcp.service.ConsoleApiService; +import org.apache.seata.mcp.service.ModifyConfirmService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Service +public class GlobalLockTools { + + private final Logger logger = LoggerFactory.getLogger(GlobalLockTools.class); + + private final ConsoleApiService mcpRPCService; + + private final MCPProperties mcpProperties; + + private final ModifyConfirmService modifyConfirmService; + + private final ObjectMapper objectMapper; + + public GlobalLockTools( + ConsoleApiService mcpRPCService, + MCPProperties mcpProperties, + ModifyConfirmService modifyConfirmService, + ObjectMapper objectMapper) { + this.mcpRPCService = mcpRPCService; + this.mcpProperties = mcpProperties; + this.modifyConfirmService = modifyConfirmService; + this.objectMapper = objectMapper; + } + + @McpTool(description = "Query the global lock information") + public PageResult<McpGlobalLockVO> queryGlobalLock( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global lock parameters") McpGlobalLockParamDto paramDto) { + McpGlobalLockParam param = McpGlobalLockParam.convertFromParamDto(paramDto); + if (param.getTimeStart() != null) { + if (param.getTimeEnd() != null) { + if (DateUtils.judgeExceedTimeDuration( + param.getTimeStart(), param.getTimeEnd(), mcpProperties.getQueryDuration())) { + return PageResult.failure( + "", + String.format( + "The query time span is not allowed to exceed the max query duration: %s hour", Review Comment: The error message uses singular "hour" but should be plural "hours" to match the message in GlobalSessionTools.java line 89 which correctly uses "hours". ########## namingserver/src/main/java/org/apache/seata/namingserver/service/ConsoleLocalServiceImpl.java: ########## @@ -0,0 +1,163 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.namingserver.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.metadata.ClusterRole; +import org.apache.seata.common.metadata.Node; +import org.apache.seata.common.metadata.namingserver.NamingServerNode; +import org.apache.seata.common.util.CollectionUtils; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.mcp.core.props.NameSpaceDetail; +import org.apache.seata.mcp.service.ConsoleApiService; +import org.apache.seata.mcp.service.impl.ConsoleRemoteServiceImpl; +import org.apache.seata.namingserver.manager.NamingManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +import static org.apache.seata.common.Constants.RAFT_GROUP_HEADER; +import static org.apache.seata.mcp.core.utils.UrlUtils.buildUrl; +import static org.apache.seata.mcp.core.utils.UrlUtils.objectToQueryParamMap; + +@ConditionalOnBean(ConsoleRemoteServiceImpl.class) +@Primary +@Service +public class ConsoleLocalServiceImpl implements ConsoleApiService { + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + private final NamingManager namingManager; + + private final RestTemplate restTemplate; + + private final ObjectMapper objectMapper; + + public ConsoleLocalServiceImpl(NamingManager namingManager, RestTemplate restTemplate, ObjectMapper objectMapper) { + this.namingManager = namingManager; + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + } + + public String getResult( + NameSpaceDetail nameSpaceDetail, + HttpMethod httpMethod, + String path, + Object objectQueryParams, + Map<String, String> queryParams, + HttpHeaders headers) { + String namespace = nameSpaceDetail.getNamespace(); + String cluster = nameSpaceDetail.getCluster(); + String vgroup = nameSpaceDetail.getvGroup(); + if (StringUtils.isNotBlank(namespace) && (StringUtils.isNotBlank(cluster) || StringUtils.isNotBlank(vgroup))) { + List<NamingServerNode> list = null; + if (StringUtils.isNotBlank(vgroup)) { + list = namingManager.getInstancesByVgroupAndNamespace( + namespace, vgroup, HttpMethod.GET.equals(httpMethod)); + } else if (StringUtils.isNotBlank(cluster)) { + list = namingManager.getInstances(namespace, cluster); + } + if (CollectionUtils.isNotEmpty(list)) { + // Randomly select a node from the list + NamingServerNode node = list.get(ThreadLocalRandom.current().nextInt(list.size())); Review Comment: Random node selection could lead to uneven load distribution and doesn't account for node health or availability. Using `ThreadLocalRandom` for node selection means: 1. Unhealthy or slow nodes might still be selected 2. No failover mechanism if the selected node is unavailable 3. No consideration for node capacity or current load Consider implementing a more robust load balancing strategy with health checks, or at least retry logic to attempt other nodes if the selected one fails. ########## console/src/main/java/org/apache/seata/mcp/tools/GlobalSessionTools.java: ########## @@ -0,0 +1,228 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.seata.mcp.tools; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.result.PageResult; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.core.model.GlobalStatus; +import org.apache.seata.mcp.core.constant.RPCConstant; +import org.apache.seata.mcp.core.props.MCPProperties; +import org.apache.seata.mcp.core.props.NameSpaceDetail; +import org.apache.seata.mcp.core.utils.DateUtils; +import org.apache.seata.mcp.entity.dto.McpGlobalSessionParamDto; +import org.apache.seata.mcp.entity.param.McpGlobalAbnormalSessionParam; +import org.apache.seata.mcp.entity.param.McpGlobalSessionParam; +import org.apache.seata.mcp.entity.vo.McpGlobalSessionVO; +import org.apache.seata.mcp.service.ConsoleApiService; +import org.apache.seata.mcp.service.ModifyConfirmService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class GlobalSessionTools { + + private final Logger logger = LoggerFactory.getLogger(GlobalSessionTools.class); + + private final ConsoleApiService mcpRPCService; + + private final MCPProperties mcpProperties; + + private final ObjectMapper objectMapper; + + private final ModifyConfirmService modifyConfirmService; + + private final List<Integer> exceptionStatus = new ArrayList<>(); + + public static final int ABNORMAL_SESSION_PAGE_SIZE = 30; + + public GlobalSessionTools( + ConsoleApiService mcpRPCService, + MCPProperties mcpProperties, + ObjectMapper objectMapper, + ModifyConfirmService modifyConfirmService) { + this.mcpRPCService = mcpRPCService; + this.mcpProperties = mcpProperties; + this.objectMapper = objectMapper; + this.modifyConfirmService = modifyConfirmService; + exceptionStatus.add(GlobalStatus.CommitFailed.getCode()); + exceptionStatus.add(GlobalStatus.TimeoutRollbackFailed.getCode()); + exceptionStatus.add(GlobalStatus.RollbackFailed.getCode()); + } + + @McpTool(description = "Query global transactions") + public PageResult<McpGlobalSessionVO> queryGlobalSession( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Query parameter objects") McpGlobalSessionParamDto paramDto) { + McpGlobalSessionParam param = McpGlobalSessionParam.convertFromDtoParam(paramDto); + if (param.getTimeStart() != null) { + if (param.getTimeEnd() != null) { + if (DateUtils.judgeExceedTimeDuration( + param.getTimeStart(), param.getTimeEnd(), mcpProperties.getQueryDuration())) { + return PageResult.failure( + "", + String.format( + "The query time span is not allowed to exceed the max query duration: %s hours", + DateUtils.convertToHourFromTimeStamp(mcpProperties.getQueryDuration()))); + } + } else { + param.setTimeEnd(param.getTimeStart() + DateUtils.ONE_DAY_TIMESTAMP); + } + } else { + param.setTimeEnd(null); + param.setTimeStart(null); + } + PageResult<McpGlobalSessionVO> pageResult = null; + String result = mcpRPCService.getCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_SESSION_BASE_URL + "/query", param, null, null); + try { + pageResult = objectMapper.readValue(result, new TypeReference<PageResult<McpGlobalSessionVO>>() {}); + } catch (JsonProcessingException e) { + logger.error(e.getMessage()); + } + if (pageResult == null) { + return PageResult.failure("", "query global session failed"); + } else { + return pageResult; + } + } + + @McpTool(description = "Delete the global session, Get the modify key before you delete") + public String deleteGlobalSession( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global transaction id") String xid, + @McpToolParam(description = "Modify key") String modifyKey) { + if (!modifyConfirmService.isValidKey(modifyKey)) { + return "The modify key is not available"; + } + Map<String, String> pathParams = new HashMap<>(); + pathParams.put("xid", xid); + String result = mcpRPCService.deleteCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_SESSION_BASE_URL + "/deleteGlobalSession", null, pathParams, null); + if (StringUtils.isBlank(result)) { + return String.format("delete global session failed, xid: %s", xid); + } else { + return result; + } + } + + @McpTool(description = "Stop the global session retry, Get the modify key before you stop") + public String stopGlobalSession( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global transaction id") String xid, + @McpToolParam(description = "Modify key") String modifyKey) { + if (!modifyConfirmService.isValidKey(modifyKey)) { + return "The modify key is not available"; + } + Map<String, String> pathParams = new HashMap<>(); + pathParams.put("xid", xid); + String result = mcpRPCService.putCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_SESSION_BASE_URL + "/stopGlobalSession", null, pathParams, null); + if (StringUtils.isBlank(result)) { + return String.format("stop global session retry failed, xid: %s", xid); + } else { + return result; + } + } + + @McpTool(description = "Start the global session retry, Get the modify key before you start") + public String startGlobalSession( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global transaction id") String xid, + @McpToolParam(description = "Modify key") String modifyKey) { + if (!modifyConfirmService.isValidKey(modifyKey)) { + return "The modify key is not available"; + } + Map<String, String> pathParams = new HashMap<>(); + pathParams.put("xid", xid); + String result = mcpRPCService.putCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_SESSION_BASE_URL + "/startGlobalSession", null, pathParams, null); + if (StringUtils.isBlank(result)) { + return String.format("start the global session retry failed, xid: %s", xid); + } else { + return result; + } + } + + @McpTool(description = "Send global session to commit or rollback to rm, Get the modify key before you send") + public String sendCommitOrRollback( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global transaction id") String xid, + @McpToolParam(description = "Modify key") String modifyKey) { + if (!modifyConfirmService.isValidKey(modifyKey)) { + return "The modify key is not available"; + } + Map<String, String> pathParams = new HashMap<>(); + pathParams.put("xid", xid); + String result = mcpRPCService.putCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_SESSION_BASE_URL + "/sendCommitOrRollback", null, pathParams, null); + if (StringUtils.isBlank(result)) { + return String.format("send global session to commit or rollback to rm failed, xid: %s", xid); + } else { + return result; + } + } + + @McpTool( + description = + "Change the global session status, Used to change transactions that are in a failed commit or rollback failed state to a retry state, Get the modify key before you change") + public String changeGlobalStatus( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Global transaction id") String xid, + @McpToolParam(description = "Modify key") String modifyKey) { + if (!modifyConfirmService.isValidKey(modifyKey)) { + return "The modify key is not available"; + } + Map<String, String> pathParams = new HashMap<>(); + pathParams.put("xid", xid); + String result = mcpRPCService.putCallTC( + nameSpaceDetail, RPCConstant.GLOBAL_SESSION_BASE_URL + "/changeGlobalStatus", null, pathParams, null); + if (StringUtils.isBlank(result)) { + return String.format("change the global session status failed, xid: %s", xid); + } else { + return result; + } + } + + @McpTool(description = "Check out the abnormal transaction information,You can specify the time") + public List<McpGlobalSessionVO> getAbnormalSessions( + @McpToolParam(description = "Specify the namespace of the TC node") NameSpaceDetail nameSpaceDetail, + @McpToolParam(description = "Query Param") McpGlobalAbnormalSessionParam abnormalSessionParam) { + List<McpGlobalSessionVO> result = new ArrayList<>(); + McpGlobalSessionParamDto param = McpGlobalSessionParamDto.convertFromAbnormalParam(abnormalSessionParam); + param.setPageSize(ABNORMAL_SESSION_PAGE_SIZE); + for (Integer status : exceptionStatus) { + param.setStatus(status); + List<McpGlobalSessionVO> datas = + queryGlobalSession(nameSpaceDetail, param).getData(); + if (datas != null && !datas.isEmpty()) { + result.addAll(datas); + } + } Review Comment: Potential N+1 query pattern in abnormal session lookup. The method iterates over `exceptionStatus` list (lines 218-225) and calls `queryGlobalSession` for each status. Each call likely results in a separate database query or remote API call. For 3 exception statuses, this means 3 separate round trips. Consider modifying the query to accept multiple statuses at once, or implement batch querying to reduce the number of remote calls and improve performance. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
