From 67a45e8c2303a388fcd331b9a188dcfdec2fcc2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= Date: Sun, 17 May 2026 02:28:06 +0900 Subject: [PATCH 01/13] feat: API deprecated metadata support --- .../koin/global/code/Deprecation.java | 24 +++++ .../code/DeprecationOperationCustomizer.java | 87 +++++++++++++++++++ .../global/config/SwaggerGroupConfig.java | 18 ++-- 3 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/global/code/Deprecation.java create mode 100644 src/main/java/in/koreatech/koin/global/code/DeprecationOperationCustomizer.java diff --git a/src/main/java/in/koreatech/koin/global/code/Deprecation.java b/src/main/java/in/koreatech/koin/global/code/Deprecation.java new file mode 100644 index 000000000..beeff63c0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/code/Deprecation.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.global.code; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Documented +@Target(METHOD) +@Retention(RUNTIME) +public @interface Deprecation { + + String since() default ""; + + String reason() default ""; + + String replacedByMethod() default ""; + + String replacedByPath() default ""; + + boolean forRemoval() default false; +} diff --git a/src/main/java/in/koreatech/koin/global/code/DeprecationOperationCustomizer.java b/src/main/java/in/koreatech/koin/global/code/DeprecationOperationCustomizer.java new file mode 100644 index 000000000..00042b28e --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/code/DeprecationOperationCustomizer.java @@ -0,0 +1,87 @@ +package in.koreatech.koin.global.code; + +import java.lang.reflect.Method; +import java.util.Map; + +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; + +import io.swagger.v3.oas.models.Operation; + +@Component +public class DeprecationOperationCustomizer implements OperationCustomizer { + + public static final String EXTENSION_SINCE = "x-deprecated-since"; + public static final String EXTENSION_REASON = "x-deprecated-reason"; + public static final String EXTENSION_REPLACED_BY = "x-replaced-by"; + public static final String EXTENSION_FOR_REMOVAL = "x-for-removal"; + + @Override + public Operation customize(Operation operation, HandlerMethod handlerMethod) { + Deprecation deprecation = findDeprecation(handlerMethod); + if (deprecation == null) { + return operation; + } + + operation.setDeprecated(true); + addIfNotBlank(operation, EXTENSION_SINCE, deprecation.since()); + addIfNotBlank(operation, EXTENSION_REASON, deprecation.reason()); + if (!deprecation.replacedByMethod().isBlank() || !deprecation.replacedByPath().isBlank()) { + operation.addExtension(EXTENSION_REPLACED_BY, Map.of( + "method", deprecation.replacedByMethod(), + "path", deprecation.replacedByPath() + )); + } + operation.addExtension(EXTENSION_FOR_REMOVAL, deprecation.forRemoval()); + return operation; + } + + private void addIfNotBlank(Operation operation, String name, String value) { + if (!value.isBlank()) { + operation.addExtension(name, value); + } + } + + private Deprecation findDeprecation(HandlerMethod handlerMethod) { + Deprecation deprecation = handlerMethod.getMethodAnnotation(Deprecation.class); + if (deprecation != null) { + return deprecation; + } + + Method controllerMethod = handlerMethod.getMethod(); + for (Class apiInterface : handlerMethod.getBeanType().getInterfaces()) { + Deprecation interfaceDeprecation = findInterfaceMethodDeprecation(apiInterface, controllerMethod); + if (interfaceDeprecation != null) { + return interfaceDeprecation; + } + } + return null; + } + + private Deprecation findInterfaceMethodDeprecation(Class apiInterface, Method controllerMethod) { + for (Method interfaceMethod : apiInterface.getMethods()) { + if (hasSameSignature(interfaceMethod, controllerMethod)) { + return interfaceMethod.getAnnotation(Deprecation.class); + } + } + return null; + } + + private boolean hasSameSignature(Method left, Method right) { + if (!left.getName().equals(right.getName())) { + return false; + } + Class[] leftTypes = left.getParameterTypes(); + Class[] rightTypes = right.getParameterTypes(); + if (leftTypes.length != rightTypes.length) { + return false; + } + for (int i = 0; i < leftTypes.length; i++) { + if (!leftTypes[i].equals(rightTypes[i])) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java index cb6956325..0754bfc85 100644 --- a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java +++ b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java @@ -5,14 +5,20 @@ import org.springframework.context.annotation.Configuration; import in.koreatech.koin.global.code.ApiResponseCodesOperationCustomizer; +import in.koreatech.koin.global.code.DeprecationOperationCustomizer; @Configuration public class SwaggerGroupConfig { - private final ApiResponseCodesOperationCustomizer customizer; + private final ApiResponseCodesOperationCustomizer apiResponseCodesOperationCustomizer; + private final DeprecationOperationCustomizer deprecationOperationCustomizer; - public SwaggerGroupConfig(ApiResponseCodesOperationCustomizer customizer) { - this.customizer = customizer; + public SwaggerGroupConfig( + ApiResponseCodesOperationCustomizer apiResponseCodesOperationCustomizer, + DeprecationOperationCustomizer deprecationOperationCustomizer + ) { + this.apiResponseCodesOperationCustomizer = apiResponseCodesOperationCustomizer; + this.deprecationOperationCustomizer = deprecationOperationCustomizer; } @Bean @@ -20,7 +26,8 @@ public GroupedOpenApi loginApi() { return GroupedOpenApi.builder() .group("0. Login API") .pathsToMatch("/**/login") - .addOperationCustomizer(customizer) + .addOperationCustomizer(deprecationOperationCustomizer) + .addOperationCustomizer(apiResponseCodesOperationCustomizer) .build(); } @@ -133,7 +140,8 @@ private GroupedOpenApi createGroupedOpenApi( return GroupedOpenApi.builder() .group(groupName) .packagesToScan(packagesPath) - .addOperationCustomizer(customizer) + .addOperationCustomizer(deprecationOperationCustomizer) + .addOperationCustomizer(apiResponseCodesOperationCustomizer) .build(); } } From 1e6da642c2d2763e2cf4913e44c890f6faa5316c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= Date: Sun, 17 May 2026 02:28:21 +0900 Subject: [PATCH 02/13] feat: expose endpoint specs via MCP --- .env.example | 1 + build.gradle | 13 + .../koin/global/mcp/config/McpToolConfig.java | 21 + .../global/mcp/dto/EndpointDescription.java | 20 + .../global/mcp/dto/EndpointParameter.java | 9 + .../global/mcp/dto/EndpointParameters.java | 10 + .../global/mcp/dto/EndpointRequestSpec.java | 10 + .../koin/global/mcp/dto/EndpointResponse.java | 11 + .../global/mcp/dto/EndpointResponseSpec.java | 11 + .../koin/global/mcp/dto/EndpointSchema.java | 27 + .../koin/global/mcp/dto/EndpointSummary.java | 16 + .../global/mcp/dto/FindEndpointsResponse.java | 6 + .../koin/global/mcp/dto/ReplacedBy.java | 7 + .../koin/global/mcp/dto/RequestBodySpec.java | 10 + .../mcp/exception/EndpointSpecException.java | 15 + .../global/mcp/model/DeprecatedFilter.java | 7 + .../koin/global/mcp/model/EndpointEntry.java | 22 + .../mcp/service/EndpointSpecService.java | 673 ++++++++++++++++++ .../global/mcp/tool/EndpointSpecTools.java | 106 +++ src/main/resources/application.yml | 17 + 20 files changed, 1012 insertions(+) create mode 100644 src/main/java/in/koreatech/koin/global/mcp/config/McpToolConfig.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/dto/EndpointDescription.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameter.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameters.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/dto/EndpointRequestSpec.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponse.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponseSpec.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSummary.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/dto/FindEndpointsResponse.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/dto/ReplacedBy.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/dto/RequestBodySpec.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/exception/EndpointSpecException.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/model/DeprecatedFilter.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/model/EndpointEntry.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/service/EndpointSpecService.java create mode 100644 src/main/java/in/koreatech/koin/global/mcp/tool/EndpointSpecTools.java diff --git a/.env.example b/.env.example index c2a17e4b1..b630bd7a5 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,7 @@ MONGODB_URI=mongodb://koin:your-mongo-password@127.0.0.1:27017/koin?authSource=a # Swagger SWAGGER_SERVER_URL=http://localhost:8080 +KOIN_MCP_ENABLED=false # AWS SES AWS_SES_ACCESS_KEY=test diff --git a/build.gradle b/build.gradle index fe030008a..d4a0ce1b9 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,12 @@ repositories { mavenCentral() } +dependencyManagement { + imports { + mavenBom 'org.springframework.ai:spring-ai-bom:1.1.2' + } +} + dependencies { // spring boot starters implementation 'org.springframework.boot:spring-boot-starter-web' @@ -84,6 +90,9 @@ dependencies { // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + // mcp + implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc' + // lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' @@ -108,3 +117,7 @@ tasks.named('bootBuildImage') { tasks.named('test') { useJUnitPlatform() } + +tasks.withType(JavaCompile).configureEach { + options.compilerArgs += '-parameters' +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/config/McpToolConfig.java b/src/main/java/in/koreatech/koin/global/mcp/config/McpToolConfig.java new file mode 100644 index 000000000..ad3fbd1d4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/config/McpToolConfig.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.global.mcp.config; + +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.ai.tool.method.MethodToolCallbackProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import in.koreatech.koin.global.mcp.tool.EndpointSpecTools; + +@Configuration +@ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") +public class McpToolConfig { + + @Bean + public ToolCallbackProvider endpointSpecToolCallbackProvider(EndpointSpecTools endpointSpecTools) { + return MethodToolCallbackProvider.builder() + .toolObjects(endpointSpecTools) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointDescription.java b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointDescription.java new file mode 100644 index 000000000..7a9620f34 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointDescription.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.global.mcp.dto; + +import java.util.List; + +public record EndpointDescription( + String group, + String method, + String path, + String operationId, + String summary, + String description, + List tags, + boolean deprecated, + String deprecatedSince, + String deprecatedReason, + ReplacedBy replacedBy, + boolean forRemoval, + boolean authRequired +) { +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameter.java b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameter.java new file mode 100644 index 000000000..da65aef8c --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameter.java @@ -0,0 +1,9 @@ +package in.koreatech.koin.global.mcp.dto; + +public record EndpointParameter( + String name, + boolean required, + String description, + EndpointSchema schema +) { +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameters.java b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameters.java new file mode 100644 index 000000000..3a3646a4f --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameters.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.global.mcp.dto; + +import java.util.List; + +public record EndpointParameters( + List path, + List query, + List header +) { +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointRequestSpec.java b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointRequestSpec.java new file mode 100644 index 000000000..ca522d42e --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointRequestSpec.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.global.mcp.dto; + +public record EndpointRequestSpec( + String group, + String method, + String path, + EndpointParameters parameters, + RequestBodySpec requestBody +) { +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponse.java b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponse.java new file mode 100644 index 000000000..03c45b85b --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponse.java @@ -0,0 +1,11 @@ +package in.koreatech.koin.global.mcp.dto; + +import java.util.List; + +public record EndpointResponse( + String status, + String description, + List contentTypes, + EndpointSchema schema +) { +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponseSpec.java b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponseSpec.java new file mode 100644 index 000000000..20a566f6f --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponseSpec.java @@ -0,0 +1,11 @@ +package in.koreatech.koin.global.mcp.dto; + +import java.util.List; + +public record EndpointResponseSpec( + String group, + String method, + String path, + List responses +) { +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java new file mode 100644 index 000000000..5ddd84ced --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java @@ -0,0 +1,27 @@ +package in.koreatech.koin.global.mcp.dto; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record EndpointSchema( + String type, + String format, + String description, + Object example, + Boolean nullable, + Boolean deprecated, + List required, + List enumValues, + Map properties, + EndpointSchema items, + EndpointSchema additionalProperties, + List allOf, + List oneOf, + List anyOf, + String ref, + Boolean truncated +) { +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSummary.java b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSummary.java new file mode 100644 index 000000000..d46a82805 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSummary.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.global.mcp.dto; + +import java.util.List; + +public record EndpointSummary( + String group, + String method, + String path, + String operationId, + String summary, + String description, + List tags, + boolean deprecated, + boolean authRequired +) { +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/FindEndpointsResponse.java b/src/main/java/in/koreatech/koin/global/mcp/dto/FindEndpointsResponse.java new file mode 100644 index 000000000..331ba78a7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/FindEndpointsResponse.java @@ -0,0 +1,6 @@ +package in.koreatech.koin.global.mcp.dto; + +import java.util.List; + +public record FindEndpointsResponse(List items) { +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/ReplacedBy.java b/src/main/java/in/koreatech/koin/global/mcp/dto/ReplacedBy.java new file mode 100644 index 000000000..cfe4dd946 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/ReplacedBy.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.global.mcp.dto; + +public record ReplacedBy( + String method, + String path +) { +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/RequestBodySpec.java b/src/main/java/in/koreatech/koin/global/mcp/dto/RequestBodySpec.java new file mode 100644 index 000000000..b2142a3af --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/RequestBodySpec.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.global.mcp.dto; + +import java.util.List; + +public record RequestBodySpec( + boolean required, + List contentTypes, + EndpointSchema schema +) { +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/exception/EndpointSpecException.java b/src/main/java/in/koreatech/koin/global/mcp/exception/EndpointSpecException.java new file mode 100644 index 000000000..996af507f --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/exception/EndpointSpecException.java @@ -0,0 +1,15 @@ +package in.koreatech.koin.global.mcp.exception; + +public class EndpointSpecException extends RuntimeException { + + private final String code; + + public EndpointSpecException(String code, String message) { + super(message); + this.code = code; + } + + public String getCode() { + return code; + } +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/model/DeprecatedFilter.java b/src/main/java/in/koreatech/koin/global/mcp/model/DeprecatedFilter.java new file mode 100644 index 000000000..547d506e8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/model/DeprecatedFilter.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.global.mcp.model; + +public enum DeprecatedFilter { + EXCLUDE, + INCLUDE, + ONLY +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/model/EndpointEntry.java b/src/main/java/in/koreatech/koin/global/mcp/model/EndpointEntry.java new file mode 100644 index 000000000..6309bf10b --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/model/EndpointEntry.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.global.mcp.model; + +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.List; + +import in.koreatech.koin.global.code.Deprecation; +import io.swagger.v3.oas.annotations.Operation; + +public record EndpointEntry( + String group, + String method, + String path, + Method docsMethod, + Operation operation, + List tags, + Deprecation deprecation, + boolean deprecated, + boolean authRequired, + Type returnType +) { +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/service/EndpointSpecService.java b/src/main/java/in/koreatech/koin/global/mcp/service/EndpointSpecService.java new file mode 100644 index 000000000..bf5101a06 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/service/EndpointSpecService.java @@ -0,0 +1,673 @@ +package in.koreatech.koin.global.mcp.service; + +import static org.springframework.util.MimeTypeUtils.APPLICATION_JSON_VALUE; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ValueConstants; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.code.Deprecation; +import in.koreatech.koin.global.mcp.dto.EndpointDescription; +import in.koreatech.koin.global.mcp.dto.EndpointParameter; +import in.koreatech.koin.global.mcp.dto.EndpointParameters; +import in.koreatech.koin.global.mcp.dto.EndpointRequestSpec; +import in.koreatech.koin.global.mcp.dto.EndpointResponse; +import in.koreatech.koin.global.mcp.dto.EndpointResponseSpec; +import in.koreatech.koin.global.mcp.dto.EndpointSchema; +import in.koreatech.koin.global.mcp.dto.EndpointSummary; +import in.koreatech.koin.global.mcp.dto.FindEndpointsResponse; +import in.koreatech.koin.global.mcp.dto.ReplacedBy; +import in.koreatech.koin.global.mcp.dto.RequestBodySpec; +import in.koreatech.koin.global.mcp.exception.EndpointSpecException; +import in.koreatech.koin.global.mcp.model.DeprecatedFilter; +import in.koreatech.koin.global.mcp.model.EndpointEntry; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.converter.ResolvedSchema; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.models.media.Schema; + +@Service +@ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") +public class EndpointSpecService { + + private static final String ROOT_PACKAGE = "in.koreatech.koin"; + private static final int SCHEMA_MAX_DEPTH = 5; + + private final RequestMappingHandlerMapping handlerMapping; + private final List groupedOpenApis; + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + public EndpointSpecService( + @Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping, + List groupedOpenApis + ) { + this.handlerMapping = handlerMapping; + this.groupedOpenApis = groupedOpenApis; + } + + public FindEndpointsResponse findEndpoints(String query, String group, DeprecatedFilter deprecated) { + String normalizedGroup = normalize(group); + String normalizedQuery = normalize(query); + DeprecatedFilter filter = deprecated == null ? DeprecatedFilter.EXCLUDE : deprecated; + + List items = endpointEntries().stream() + .filter(entry -> normalizedGroup == null || matchesGroupFilter(entry.group(), normalizedGroup)) + .filter(entry -> matchesDeprecatedFilter(entry.deprecated(), filter)) + .filter(entry -> matchesQuery(entry, normalizedQuery)) + .map(this::toSummary) + .distinct() + .sorted(Comparator + .comparing(EndpointSummary::group) + .thenComparing(EndpointSummary::path) + .thenComparing(EndpointSummary::method)) + .toList(); + + return new FindEndpointsResponse(items); + } + + public EndpointDescription getEndpointDescription(String group, String method, String path) { + EndpointEntry entry = findEndpoint(group, method, path); + Operation operation = entry.operation(); + Deprecation deprecation = entry.deprecation(); + + return new EndpointDescription( + entry.group(), + entry.method(), + entry.path(), + operationId(operation), + operation == null ? "" : nullToEmpty(operation.summary()), + operation == null ? "" : nullToEmpty(operation.description()), + entry.tags(), + entry.deprecated(), + deprecation == null ? "" : deprecation.since(), + deprecation == null ? "" : deprecation.reason(), + deprecation == null || deprecation.replacedByMethod().isBlank() && deprecation.replacedByPath().isBlank() + ? null + : new ReplacedBy(deprecation.replacedByMethod(), deprecation.replacedByPath()), + deprecation != null && deprecation.forRemoval(), + entry.authRequired() + ); + } + + public EndpointRequestSpec getEndpointRequestSpec(String group, String method, String path) { + EndpointEntry entry = findEndpoint(group, method, path); + Method docsMethod = entry.docsMethod(); + + List pathParameters = new ArrayList<>(); + List queryParameters = new ArrayList<>(); + List headerParameters = new ArrayList<>(); + RequestBodySpec requestBody = null; + + java.lang.reflect.Parameter[] parameters = docsMethod.getParameters(); + for (java.lang.reflect.Parameter parameter : parameters) { + if (parameter.isAnnotationPresent(Auth.class)) { + continue; + } + PathVariable pathVariable = parameter.getAnnotation(PathVariable.class); + if (pathVariable != null) { + pathParameters.add(toParameter(parameter, parameterName(pathVariable.name(), pathVariable.value(), parameter), true)); + continue; + } + + RequestParam requestParam = parameter.getAnnotation(RequestParam.class); + if (requestParam != null) { + boolean required = requestParam.required() && ValueConstants.DEFAULT_NONE.equals(requestParam.defaultValue()); + queryParameters.add(toParameter(parameter, parameterName(requestParam.name(), requestParam.value(), parameter), required)); + continue; + } + + RequestHeader requestHeader = parameter.getAnnotation(RequestHeader.class); + if (requestHeader != null) { + boolean required = requestHeader.required() && ValueConstants.DEFAULT_NONE.equals(requestHeader.defaultValue()); + headerParameters.add(toParameter(parameter, parameterName(requestHeader.name(), requestHeader.value(), parameter), required)); + continue; + } + + RequestBody body = parameter.getAnnotation(RequestBody.class); + if (body != null) { + requestBody = new RequestBodySpec( + body.required(), + List.of(APPLICATION_JSON_VALUE), + loadEndpointSchema(parameter.getParameterizedType()) + ); + } + } + + return new EndpointRequestSpec( + entry.group(), + entry.method(), + entry.path(), + new EndpointParameters(pathParameters, queryParameters, headerParameters), + requestBody + ); + } + + public EndpointResponseSpec getEndpointResponseSpec(String group, String method, String path) { + EndpointEntry entry = findEndpoint(group, method, path); + ApiResponses apiResponses = entry.docsMethod().getAnnotation(ApiResponses.class); + List responses = new ArrayList<>(); + + if (apiResponses != null) { + for (ApiResponse apiResponse : apiResponses.value()) { + responses.add(toEndpointResponse(apiResponse, entry.returnType())); + } + } + + if (responses.isEmpty()) { + responses.add(defaultSuccessResponse(entry.returnType())); + } + + return new EndpointResponseSpec( + entry.group(), + entry.method(), + entry.path(), + responses + ); + } + + private EndpointResponse toEndpointResponse(ApiResponse apiResponse, Type returnType) { + EndpointSchema schema = null; + List contentTypes = new ArrayList<>(); + + for (Content content : apiResponse.content()) { + if (content.schema().hidden()) { + continue; + } + String mediaType = content.mediaType().isBlank() ? APPLICATION_JSON_VALUE : content.mediaType(); + contentTypes.add(mediaType); + if (!Void.class.equals(content.schema().implementation())) { + schema = loadEndpointSchema(content.schema().implementation()); + } + } + + if (schema == null && isSuccess(apiResponse.responseCode())) { + schema = loadEndpointSchema(returnType); + if (schema != null && contentTypes.isEmpty()) { + contentTypes.add(APPLICATION_JSON_VALUE); + } + } + + return new EndpointResponse( + apiResponse.responseCode(), + nullToEmpty(apiResponse.description()), + contentTypes, + schema + ); + } + + private EndpointResponse defaultSuccessResponse(Type returnType) { + return new EndpointResponse( + Void.class.equals(returnType) || void.class.equals(returnType) ? "204" : "200", + "", + Void.class.equals(returnType) || void.class.equals(returnType) ? List.of() : List.of(APPLICATION_JSON_VALUE), + loadEndpointSchema(returnType) + ); + } + + private EndpointParameter toParameter(java.lang.reflect.Parameter parameter, String name, boolean required) { + Parameter swaggerParameter = parameter.getAnnotation(Parameter.class); + return new EndpointParameter( + name, + required, + swaggerParameter == null ? "" : nullToEmpty(swaggerParameter.description()), + loadEndpointSchema(parameter.getParameterizedType()) + ); + } + + private EndpointSummary toSummary(EndpointEntry entry) { + Operation operation = entry.operation(); + return new EndpointSummary( + entry.group(), + entry.method(), + entry.path(), + operationId(operation), + operation == null ? "" : nullToEmpty(operation.summary()), + operation == null ? "" : nullToEmpty(operation.description()), + entry.tags(), + entry.deprecated(), + entry.authRequired() + ); + } + + private EndpointEntry findEndpoint(String group, String method, String path) { + if (method == null || method.isBlank()) { + throw new EndpointSpecException("METHOD_REQUIRED", "method is required."); + } + if (path == null || path.isBlank()) { + throw new EndpointSpecException("PATH_REQUIRED", "path is required."); + } + String normalizedGroup = normalize(group); + String normalizedMethod = method.toUpperCase(Locale.ROOT); + + List matches = endpointEntries().stream() + .filter(entry -> normalizedGroup == null || matchesGroupFilter(entry.group(), normalizedGroup)) + .filter(entry -> entry.method().equals(normalizedMethod)) + .filter(entry -> entry.path().equals(path)) + .toList(); + + if (matches.isEmpty()) { + throw new EndpointSpecException("ENDPOINT_NOT_FOUND", "No endpoint found."); + } + if (matches.size() > 1) { + throw new EndpointSpecException("AMBIGUOUS_ENDPOINT", "Multiple endpoints found. Please specify group."); + } + return matches.get(0); + } + + private boolean matchesQuery(EndpointEntry entry, String query) { + if (query == null) { + return true; + } + Operation operation = entry.operation(); + String haystack = String.join(" ", + entry.path(), + entry.method(), + entry.group(), + operationId(operation), + operation == null ? "" : nullToEmpty(operation.summary()), + operation == null ? "" : nullToEmpty(operation.description()), + String.join(" ", entry.tags()) + ).toLowerCase(Locale.ROOT); + return haystack.contains(query.toLowerCase(Locale.ROOT)); + } + + private boolean matchesDeprecatedFilter(boolean deprecated, DeprecatedFilter filter) { + return switch (filter) { + case EXCLUDE -> !deprecated; + case INCLUDE -> true; + case ONLY -> deprecated; + }; + } + + private List endpointEntries() { + List entries = new ArrayList<>(); + handlerMapping.getHandlerMethods().forEach((info, handlerMethod) -> { + if (!handlerMethod.getBeanType().getPackageName().startsWith(ROOT_PACKAGE)) { + return; + } + Method docsMethod = findDocsMethod(handlerMethod); + Operation operation = findOperation(docsMethod); + Deprecation deprecation = findDeprecation(docsMethod); + List tags = findTags(handlerMethod.getBeanType(), docsMethod); + + for (String path : paths(info)) { + for (String group : groupsOf(handlerMethod.getBeanType(), path)) { + for (String method : methods(info)) { + entries.add(new EndpointEntry( + group, + method, + path, + docsMethod, + operation, + tags, + deprecation, + operation != null && operation.deprecated() || deprecation != null, + authRequired(docsMethod, operation), + actualReturnType(docsMethod) + )); + } + } + } + }); + return entries; + } + + private Method findDocsMethod(HandlerMethod handlerMethod) { + Method controllerMethod = handlerMethod.getMethod(); + for (Class apiInterface : handlerMethod.getBeanType().getInterfaces()) { + for (Method interfaceMethod : apiInterface.getMethods()) { + if (hasSameSignature(interfaceMethod, controllerMethod)) { + return interfaceMethod; + } + } + } + return controllerMethod; + } + + private Operation findOperation(Method method) { + return AnnotatedElementUtils.findMergedAnnotation(method, Operation.class); + } + + private Deprecation findDeprecation(Method method) { + return AnnotatedElementUtils.findMergedAnnotation(method, Deprecation.class); + } + + private List findTags(Class beanType, Method method) { + Set tags = new LinkedHashSet<>(); + Tag methodTag = AnnotatedElementUtils.findMergedAnnotation(method, Tag.class); + if (methodTag != null && !methodTag.name().isBlank()) { + tags.add(methodTag.name()); + } + for (Class apiInterface : beanType.getInterfaces()) { + Tag interfaceTag = AnnotatedElementUtils.findMergedAnnotation(apiInterface, Tag.class); + if (interfaceTag != null && !interfaceTag.name().isBlank()) { + tags.add(interfaceTag.name()); + } + } + Tag classTag = AnnotatedElementUtils.findMergedAnnotation(beanType, Tag.class); + if (classTag != null && !classTag.name().isBlank()) { + tags.add(classTag.name()); + } + return List.copyOf(tags); + } + + private boolean authRequired(Method method, Operation operation) { + boolean hasAuthParameter = Arrays.stream(method.getParameters()) + .anyMatch(parameter -> parameter.isAnnotationPresent(Auth.class)); + if (hasAuthParameter) { + return true; + } + if (operation != null && operation.security().length > 0) { + return true; + } + return method.isAnnotationPresent(SecurityRequirement.class); + } + + private List paths(RequestMappingInfo info) { + if (info.getPathPatternsCondition() != null) { + return info.getPathPatternsCondition().getPatterns().stream() + .map(pattern -> pattern.getPatternString()) + .toList(); + } + if (info.getPatternsCondition() != null) { + return List.copyOf(info.getPatternsCondition().getPatterns()); + } + return List.of(); + } + + private List methods(RequestMappingInfo info) { + Set methods = info.getMethodsCondition().getMethods(); + if (methods.isEmpty()) { + return Arrays.stream(RequestMethod.values()).map(Enum::name).toList(); + } + return methods.stream().map(Enum::name).toList(); + } + + private List groupsOf(Class beanType, String path) { + String packageName = beanType.getPackageName(); + List matchedGroups = groupedOpenApis.stream() + .filter(groupedOpenApi -> matchesGroupedOpenApi(groupedOpenApi, packageName, path)) + .map(GroupedOpenApi::getGroup) + .toList(); + + return matchedGroups.isEmpty() ? List.of("unknown") : matchedGroups; + } + + private boolean matchesGroupedOpenApi(GroupedOpenApi groupedOpenApi, String packageName, String path) { + if (matchesAny(path, groupedOpenApi.getPathsToExclude())) { + return false; + } + if (startsWithAny(packageName, groupedOpenApi.getPackagesToExclude())) { + return false; + } + + boolean pathMatched = isEmpty(groupedOpenApi.getPathsToMatch()) + || matchesAny(path, groupedOpenApi.getPathsToMatch()); + boolean packageMatched = isEmpty(groupedOpenApi.getPackagesToScan()) + || startsWithAny(packageName, groupedOpenApi.getPackagesToScan()); + + return pathMatched && packageMatched; + } + + private boolean isEmpty(List values) { + return values == null || values.isEmpty(); + } + + private boolean matchesAny(String path, List patterns) { + if (patterns == null || patterns.isEmpty()) { + return false; + } + return patterns.stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); + } + + private boolean startsWithAny(String packageName, List packagePrefixes) { + if (packagePrefixes == null || packagePrefixes.isEmpty()) { + return false; + } + return packagePrefixes.stream().anyMatch(packageName::startsWith); + } + + private boolean matchesGroupFilter(String actualGroup, String requestedGroup) { + return normalizeGroup(actualGroup).equals(requestedGroup) + || actualGroup.equalsIgnoreCase(requestedGroup); + } + + private Type actualReturnType(Method method) { + Type returnType = method.getGenericReturnType(); + if (returnType instanceof ParameterizedType parameterizedType + && parameterizedType.getRawType().equals(ResponseEntity.class)) { + return parameterizedType.getActualTypeArguments()[0]; + } + return returnType; + } + + private Schema loadSchema(Type type) { + if (type.equals(Void.class) || type.equals(void.class)) { + return null; + } + ResolvedSchema resolvedSchema = ModelConverters.getInstance().readAllAsResolvedSchema(type); + if (resolvedSchema == null || resolvedSchema.schema == null) { + return scalarSchema(type); + } + return resolveRefs(resolvedSchema.schema, resolvedSchema.referencedSchemas, new HashSet<>(), 0); + } + + private Schema scalarSchema(Type type) { + if (!(type instanceof Class clazz)) { + return null; + } + if (String.class.equals(clazz) || Character.class.equals(clazz) || char.class.equals(clazz)) { + return new Schema<>().type("string"); + } + if (Boolean.class.equals(clazz) || boolean.class.equals(clazz)) { + return new Schema<>().type("boolean"); + } + if (Integer.class.equals(clazz) || int.class.equals(clazz)) { + return new Schema<>().type("integer").format("int32"); + } + if (Long.class.equals(clazz) || long.class.equals(clazz)) { + return new Schema<>().type("integer").format("int64"); + } + if (Float.class.equals(clazz) || float.class.equals(clazz)) { + return new Schema<>().type("number").format("float"); + } + if (Double.class.equals(clazz) || double.class.equals(clazz)) { + return new Schema<>().type("number").format("double"); + } + return null; + } + + private EndpointSchema loadEndpointSchema(Type type) { + return toEndpointSchema(loadSchema(type)); + } + + private EndpointSchema toEndpointSchema(Schema schema) { + if (schema == null) { + return null; + } + + Map properties = null; + if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { + Map convertedProperties = new LinkedHashMap<>(); + schema.getProperties().forEach((name, property) -> + convertedProperties.put(name, toEndpointSchema((Schema)property))); + properties = convertedProperties; + } + + EndpointSchema additionalProperties = null; + if (schema.getAdditionalProperties() instanceof Schema additionalPropertySchema) { + additionalProperties = toEndpointSchema(additionalPropertySchema); + } + + return new EndpointSchema( + schema.getType(), + schema.getFormat(), + schema.getDescription(), + schema.getExample(), + schema.getNullable(), + schema.getDeprecated(), + schema.getRequired(), + schema.getEnum(), + properties, + toEndpointSchema(schema.getItems()), + additionalProperties, + toEndpointSchemaList(schema.getAllOf()), + toEndpointSchemaList(schema.getOneOf()), + toEndpointSchemaList(schema.getAnyOf()), + schema.get$ref(), + isTruncated(schema) + ); + } + + private List toEndpointSchemaList(List schemas) { + if (schemas == null || schemas.isEmpty()) { + return null; + } + return schemas.stream() + .map(this::toEndpointSchema) + .toList(); + } + + private Boolean isTruncated(Schema schema) { + if (schema.getExtensions() == null) { + return null; + } + Object truncated = schema.getExtensions().get("x-truncated"); + return Boolean.TRUE.equals(truncated) ? true : null; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private Schema resolveRefs( + Schema schema, + Map referencedSchemas, + Set resolvingRefs, + int depth + ) { + if (schema == null || depth > SCHEMA_MAX_DEPTH) { + return schema; + } + + String ref = schema.get$ref(); + if (ref != null && !ref.isBlank()) { + String refName = ref.substring(ref.lastIndexOf('/') + 1); + if (!resolvingRefs.add(refName)) { + Schema truncatedSchema = new Schema<>().$ref(ref); + truncatedSchema.addExtension("x-truncated", true); + return truncatedSchema; + } + Schema referencedSchema = referencedSchemas.get(refName); + Schema resolved = referencedSchema == null + ? schema + : resolveRefs(referencedSchema, referencedSchemas, resolvingRefs, depth + 1); + resolvingRefs.remove(refName); + return resolved; + } + + if (schema.getProperties() != null) { + schema.getProperties().replaceAll((name, property) -> + resolveRefs((Schema)property, referencedSchemas, resolvingRefs, depth + 1)); + } + if (schema.getItems() != null) { + schema.setItems(resolveRefs(schema.getItems(), referencedSchemas, resolvingRefs, depth + 1)); + } + if (schema.getAdditionalProperties() instanceof Schema additionalProperties) { + schema.setAdditionalProperties(resolveRefs(additionalProperties, referencedSchemas, resolvingRefs, depth + 1)); + } + schema.setAllOf(resolveSchemaList(schema.getAllOf(), referencedSchemas, resolvingRefs, depth)); + schema.setOneOf(resolveSchemaList(schema.getOneOf(), referencedSchemas, resolvingRefs, depth)); + schema.setAnyOf(resolveSchemaList(schema.getAnyOf(), referencedSchemas, resolvingRefs, depth)); + return schema; + } + + private List resolveSchemaList( + List schemas, + Map referencedSchemas, + Set resolvingRefs, + int depth + ) { + if (schemas == null) { + return null; + } + return schemas.stream() + .map(schema -> (Schema)resolveRefs(schema, referencedSchemas, resolvingRefs, depth + 1)) + .toList(); + } + + private String parameterName(String name, String value, java.lang.reflect.Parameter parameter) { + if (!name.isBlank()) { + return name; + } + if (!value.isBlank()) { + return value; + } + return parameter.getName(); + } + + private String operationId(Operation operation) { + return operation == null ? "" : nullToEmpty(operation.operationId()); + } + + private boolean isSuccess(String status) { + return status != null && !status.isBlank() && status.charAt(0) == '2'; + } + + private boolean hasSameSignature(Method left, Method right) { + return left.getName().equals(right.getName()) + && Arrays.equals(left.getParameterTypes(), right.getParameterTypes()); + } + + private String normalize(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim().toLowerCase(Locale.ROOT); + } + + private String normalizeGroup(String value) { + return normalize(value) + .replaceFirst("^\\d+\\.\\s*", "") + .replaceFirst("\\s+api$", "") + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-|-$)", ""); + } + + private String nullToEmpty(String value) { + return Objects.requireNonNullElse(value, ""); + } +} diff --git a/src/main/java/in/koreatech/koin/global/mcp/tool/EndpointSpecTools.java b/src/main/java/in/koreatech/koin/global/mcp/tool/EndpointSpecTools.java new file mode 100644 index 000000000..50bfb0d30 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/tool/EndpointSpecTools.java @@ -0,0 +1,106 @@ +package in.koreatech.koin.global.mcp.tool; + +import java.util.Locale; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.global.mcp.dto.EndpointDescription; +import in.koreatech.koin.global.mcp.dto.EndpointRequestSpec; +import in.koreatech.koin.global.mcp.dto.EndpointResponseSpec; +import in.koreatech.koin.global.mcp.dto.FindEndpointsResponse; +import in.koreatech.koin.global.mcp.exception.EndpointSpecException; +import in.koreatech.koin.global.mcp.model.DeprecatedFilter; +import in.koreatech.koin.global.mcp.service.EndpointSpecService; + +@Component +@ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") +public class EndpointSpecTools { + + private final EndpointSpecService endpointSpecService; + + public EndpointSpecTools(EndpointSpecService endpointSpecService) { + this.endpointSpecService = endpointSpecService; + } + + @Tool(description = "Find KOIN API endpoints by keyword, or list all endpoints when query is omitted. This is read-only and never sends API requests.") + public FindEndpointsResponse find_endpoints( + @ToolParam(description = "Optional keyword matched against path, method, group, operation id, summary, description, and tags.", required = false) String query, + @ToolParam(description = "Optional endpoint group. Exact group names and normalized names like 'business' are supported.", required = false) String group, + @ToolParam(description = "Deprecated endpoint filter. Use 'exclude' by default, 'include' to include deprecated endpoints, or 'only' to return deprecated endpoints only.", required = false) String deprecated + ) { + return findEndpoints(query, group, deprecated); + } + + @Tool(description = "Get endpoint description metadata excluding request and response body details. This is read-only and never sends API requests.") + public EndpointDescription get_endpoint_description( + @ToolParam(description = "Endpoint group from find_endpoints. Required when the same method and path exist in multiple groups.") String group, + @ToolParam(description = "HTTP method, such as GET, POST, PUT, PATCH, or DELETE.") String method, + @ToolParam(description = "Endpoint path, such as /v2/shops/{id}.") String path + ) { + return getEndpointDescription(group, method, path); + } + + @Tool(description = "Get endpoint request parameters and request body schema. This is read-only and never sends API requests.") + public EndpointRequestSpec get_endpoint_request_spec( + @ToolParam(description = "Endpoint group from find_endpoints. Required when the same method and path exist in multiple groups.") String group, + @ToolParam(description = "HTTP method, such as GET, POST, PUT, PATCH, or DELETE.") String method, + @ToolParam(description = "Endpoint path, such as /v2/shops/{id}.") String path + ) { + return getEndpointRequestSpec(group, method, path); + } + + @Tool(description = "Get endpoint response status codes and response body schemas paired by status code. This is read-only and never sends API requests.") + public EndpointResponseSpec get_endpoint_response_spec( + @ToolParam(description = "Endpoint group from find_endpoints. Required when the same method and path exist in multiple groups.") String group, + @ToolParam(description = "HTTP method, such as GET, POST, PUT, PATCH, or DELETE.") String method, + @ToolParam(description = "Endpoint path, such as /v2/shops/{id}.") String path + ) { + return getEndpointResponseSpec(group, method, path); + } + + /** + * Find KOIN API endpoints by keyword, or list all endpoints when query is omitted. + */ + public FindEndpointsResponse findEndpoints(String query, String group, String deprecated) { + return endpointSpecService.findEndpoints(query, group, parseDeprecatedFilter(deprecated)); + } + + /** + * Get basic description metadata for one KOIN endpoint. + */ + public EndpointDescription getEndpointDescription(String group, String method, String path) { + return endpointSpecService.getEndpointDescription(group, method, path); + } + + /** + * Get request parameters and request body schema for one KOIN endpoint. + */ + public EndpointRequestSpec getEndpointRequestSpec(String group, String method, String path) { + return endpointSpecService.getEndpointRequestSpec(group, method, path); + } + + /** + * Get response status codes and response body schemas for one KOIN endpoint. + */ + public EndpointResponseSpec getEndpointResponseSpec(String group, String method, String path) { + return endpointSpecService.getEndpointResponseSpec(group, method, path); + } + + private DeprecatedFilter parseDeprecatedFilter(String deprecated) { + if (deprecated == null || deprecated.isBlank()) { + return DeprecatedFilter.EXCLUDE; + } + return switch (deprecated.trim().toLowerCase(Locale.ROOT)) { + case "exclude" -> DeprecatedFilter.EXCLUDE; + case "include" -> DeprecatedFilter.INCLUDE; + case "only" -> DeprecatedFilter.ONLY; + default -> throw new EndpointSpecException( + "INVALID_DEPRECATED_FILTER", + "deprecated must be one of exclude, include, only." + ); + }; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f90606c14..27fb054d0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,6 +4,23 @@ jwt: expiration-time: ${JWT_ACCESS_TOKEN_EXPIRATION_TIME} spring: + ai: + mcp: + server: + enabled: ${KOIN_MCP_ENABLED:false} + name: koin-api-mcp-server + version: 1.0.0 + type: SYNC + protocol: STREAMABLE + instructions: "Read-only tools for discovering KOIN API endpoint descriptions, request specs, and response specs." + streamable-http: + mcp-endpoint: /mcp + capabilities: + tool: true + resource: false + prompt: false + completion: false + mvc: throw-exception-if-no-handler-found: true web: From 50382fb8c96488a2cc68ab59db6ddaafee1eacd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= Date: Tue, 19 May 2026 11:53:15 +0900 Subject: [PATCH 03/13] refactor: reuse springdoc openapi for mcp specs --- .../ApiResponseCodesOperationCustomizer.java | 32 +- .../koin/global/mcp/dto/EndpointResponse.java | 4 + .../koin/global/mcp/dto/EndpointSchema.java | 27 ++ .../koin/global/mcp/model/EndpointEntry.java | 6 +- .../mcp/service/EndpointSpecService.java | 363 ++++++++---------- .../mcp/service/McpOpenApiProvider.java | 109 ++++++ 6 files changed, 333 insertions(+), 208 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/global/mcp/service/McpOpenApiProvider.java diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java index 65bf8ea28..111ad21ad 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java @@ -38,6 +38,7 @@ @Component public class ApiResponseCodesOperationCustomizer implements OperationCustomizer { + private static final String MESSAGE_FIELD = "message"; private static final String TRACE_ID_EXAMPLE = "123e4567-e89b-12d3-a456-426614174000"; // 에러 스키마를 미리 생성하여 최적화 @@ -59,6 +60,7 @@ public Operation customize(Operation operation, HandlerMethod handler) { ApiResponseCode code = codes[i]; String key = String.format("%d) %d", i + 1, code.getHttpStatus().value()); responses.put(key, createApiResponse( + code, code.getMessage(), () -> createResponseBody(code, handler, returnType) )); @@ -70,21 +72,26 @@ public Operation customize(Operation operation, HandlerMethod handler) { private Type getActualResponseType(HandlerMethod handler) { Type returnType = handler.getMethod().getGenericReturnType(); - if (returnType instanceof ParameterizedType parameterizedType) { - if (parameterizedType.getRawType().equals(ResponseEntity.class)) { - return parameterizedType.getActualTypeArguments()[0]; - } + if (returnType instanceof ParameterizedType parameterizedType + && parameterizedType.getRawType().equals(ResponseEntity.class)) { + return parameterizedType.getActualTypeArguments()[0]; } return returnType; } private ApiResponse createApiResponse( + ApiResponseCode code, String description, Supplier supplier ) { - return new ApiResponse() - .description(description) - .content(new Content().addMediaType(APPLICATION_JSON_VALUE, supplier.get())); + ApiResponse apiResponse = new ApiResponse().description(description); + MediaType mediaType = supplier.get(); + if (mediaType != null) { + apiResponse.content(new Content().addMediaType(APPLICATION_JSON_VALUE, mediaType)); + } + apiResponse.addExtension("x-koin-code", code.getCode()); + apiResponse.addExtension("x-http-status", code.getHttpStatus().value()); + return apiResponse; } private MediaType createResponseBody( @@ -92,6 +99,9 @@ private MediaType createResponseBody( HandlerMethod handler, Type returnType ) { + if (code.getHttpStatus().value() == 204) { + return null; + } if (code.getHttpStatus().is2xxSuccessful()) { return new MediaType().schema(loadSchema(returnType)); } @@ -104,7 +114,7 @@ private MediaType createResponseBody( private Map createGenericErrorExample(ApiResponseCode code) { return Map.of( "code", code.getCode(), - "message", code.getMessage(), + MESSAGE_FIELD, code.getMessage(), "errorTraceId", TRACE_ID_EXAMPLE ); } @@ -115,13 +125,13 @@ private Map createInvalidRequestBodyErrorExample( ) { List> fieldErrors = extractFieldErrors(handler); String firstMsg = fieldErrors.stream() - .map(e -> (String)e.get("message")) + .map(e -> (String)e.get(MESSAGE_FIELD)) .findFirst() .orElse(code.getMessage()); Map example = new LinkedHashMap<>(); example.put("code", code.getCode()); - example.put("message", firstMsg); + example.put(MESSAGE_FIELD, firstMsg); example.put("errorTraceId", TRACE_ID_EXAMPLE); example.put("fieldErrors", fieldErrors); return example; @@ -173,7 +183,7 @@ private Map fieldErrorEntry(Field field, Annotation ann) { return Map.of( "field", name, "constraint", constraint, - "message", msg + MESSAGE_FIELD, msg ); } diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponse.java b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponse.java index 03c45b85b..c1e02324c 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponse.java +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponse.java @@ -2,8 +2,12 @@ import java.util.List; +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) public record EndpointResponse( String status, + String code, String description, List contentTypes, EndpointSchema schema diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java index 5ddd84ced..60138109a 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java @@ -4,7 +4,11 @@ import java.util.Map; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +@Builder @JsonInclude(JsonInclude.Include.NON_EMPTY) public record EndpointSchema( String type, @@ -14,6 +18,7 @@ public record EndpointSchema( Boolean nullable, Boolean deprecated, List required, + @JsonProperty("enum") List enumValues, Map properties, EndpointSchema items, @@ -24,4 +29,26 @@ public record EndpointSchema( String ref, Boolean truncated ) { + + public static EndpointSchema object(Map properties, List required) { + return EndpointSchema.builder() + .type("object") + .properties(properties) + .required(required == null || required.isEmpty() ? null : List.copyOf(required)) + .build(); + } + + public static EndpointSchema array(EndpointSchema items) { + return EndpointSchema.builder() + .type("array") + .items(items) + .build(); + } + + public static EndpointSchema file() { + return EndpointSchema.builder() + .type("string") + .format("binary") + .build(); + } } diff --git a/src/main/java/in/koreatech/koin/global/mcp/model/EndpointEntry.java b/src/main/java/in/koreatech/koin/global/mcp/model/EndpointEntry.java index 6309bf10b..05542fbc9 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/model/EndpointEntry.java +++ b/src/main/java/in/koreatech/koin/global/mcp/model/EndpointEntry.java @@ -1,11 +1,10 @@ package in.koreatech.koin.global.mcp.model; import java.lang.reflect.Method; -import java.lang.reflect.Type; import java.util.List; import in.koreatech.koin.global.code.Deprecation; -import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.models.Operation; public record EndpointEntry( String group, @@ -16,7 +15,6 @@ public record EndpointEntry( List tags, Deprecation deprecation, boolean deprecated, - boolean authRequired, - Type returnType + boolean authRequired ) { } diff --git a/src/main/java/in/koreatech/koin/global/mcp/service/EndpointSpecService.java b/src/main/java/in/koreatech/koin/global/mcp/service/EndpointSpecService.java index bf5101a06..219ae7545 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/service/EndpointSpecService.java +++ b/src/main/java/in/koreatech/koin/global/mcp/service/EndpointSpecService.java @@ -1,13 +1,9 @@ package in.koreatech.koin.global.mcp.service; -import static org.springframework.util.MimeTypeUtils.APPLICATION_JSON_VALUE; - -import java.lang.annotation.Annotation; import java.lang.reflect.Method; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.LinkedHashMap; @@ -22,15 +18,9 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.annotation.AnnotatedElementUtils; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.util.AntPathMatcher; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ValueConstants; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.mvc.method.RequestMappingInfo; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; @@ -51,16 +41,17 @@ import in.koreatech.koin.global.mcp.exception.EndpointSpecException; import in.koreatech.koin.global.mcp.model.DeprecatedFilter; import in.koreatech.koin.global.mcp.model.EndpointEntry; -import io.swagger.v3.core.converter.ModelConverters; -import io.swagger.v3.core.converter.ResolvedSchema; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; @Service @ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") @@ -71,14 +62,17 @@ public class EndpointSpecService { private final RequestMappingHandlerMapping handlerMapping; private final List groupedOpenApis; + private final McpOpenApiProvider openApiProvider; private final AntPathMatcher pathMatcher = new AntPathMatcher(); public EndpointSpecService( @Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping, - List groupedOpenApis + List groupedOpenApis, + McpOpenApiProvider openApiProvider ) { this.handlerMapping = handlerMapping; this.groupedOpenApis = groupedOpenApis; + this.openApiProvider = openApiProvider; } public FindEndpointsResponse findEndpoints(String query, String group, DeprecatedFilter deprecated) { @@ -111,8 +105,8 @@ public EndpointDescription getEndpointDescription(String group, String method, S entry.method(), entry.path(), operationId(operation), - operation == null ? "" : nullToEmpty(operation.summary()), - operation == null ? "" : nullToEmpty(operation.description()), + operation == null ? "" : nullToEmpty(operation.getSummary()), + operation == null ? "" : nullToEmpty(operation.getDescription()), entry.tags(), entry.deprecated(), deprecation == null ? "" : deprecation.since(), @@ -127,45 +121,18 @@ public EndpointDescription getEndpointDescription(String group, String method, S public EndpointRequestSpec getEndpointRequestSpec(String group, String method, String path) { EndpointEntry entry = findEndpoint(group, method, path); - Method docsMethod = entry.docsMethod(); - List pathParameters = new ArrayList<>(); List queryParameters = new ArrayList<>(); List headerParameters = new ArrayList<>(); - RequestBodySpec requestBody = null; - - java.lang.reflect.Parameter[] parameters = docsMethod.getParameters(); - for (java.lang.reflect.Parameter parameter : parameters) { - if (parameter.isAnnotationPresent(Auth.class)) { - continue; - } - PathVariable pathVariable = parameter.getAnnotation(PathVariable.class); - if (pathVariable != null) { - pathParameters.add(toParameter(parameter, parameterName(pathVariable.name(), pathVariable.value(), parameter), true)); - continue; - } - - RequestParam requestParam = parameter.getAnnotation(RequestParam.class); - if (requestParam != null) { - boolean required = requestParam.required() && ValueConstants.DEFAULT_NONE.equals(requestParam.defaultValue()); - queryParameters.add(toParameter(parameter, parameterName(requestParam.name(), requestParam.value(), parameter), required)); - continue; - } - - RequestHeader requestHeader = parameter.getAnnotation(RequestHeader.class); - if (requestHeader != null) { - boolean required = requestHeader.required() && ValueConstants.DEFAULT_NONE.equals(requestHeader.defaultValue()); - headerParameters.add(toParameter(parameter, parameterName(requestHeader.name(), requestHeader.value(), parameter), required)); - continue; - } - - RequestBody body = parameter.getAnnotation(RequestBody.class); - if (body != null) { - requestBody = new RequestBodySpec( - body.required(), - List.of(APPLICATION_JSON_VALUE), - loadEndpointSchema(parameter.getParameterizedType()) - ); + OpenAPI openAPI = openApiProvider.getOpenApi(entry.group()); + Operation operation = openApiOperation(openAPI, entry); + + for (Parameter parameter : nullToEmpty(operation.getParameters())) { + EndpointParameter endpointParameter = toEndpointParameter(parameter, openAPI); + switch (nullToEmpty(parameter.getIn())) { + case "path" -> pathParameters.add(endpointParameter); + case "header" -> headerParameters.add(endpointParameter); + default -> queryParameters.add(endpointParameter); } } @@ -174,80 +141,140 @@ public EndpointRequestSpec getEndpointRequestSpec(String group, String method, S entry.method(), entry.path(), new EndpointParameters(pathParameters, queryParameters, headerParameters), - requestBody + toRequestBodySpec(operation.getRequestBody(), openAPI) ); } public EndpointResponseSpec getEndpointResponseSpec(String group, String method, String path) { EndpointEntry entry = findEndpoint(group, method, path); - ApiResponses apiResponses = entry.docsMethod().getAnnotation(ApiResponses.class); - List responses = new ArrayList<>(); - - if (apiResponses != null) { - for (ApiResponse apiResponse : apiResponses.value()) { - responses.add(toEndpointResponse(apiResponse, entry.returnType())); - } - } - - if (responses.isEmpty()) { - responses.add(defaultSuccessResponse(entry.returnType())); - } + OpenAPI openAPI = openApiProvider.getOpenApi(entry.group()); + Operation operation = openApiOperation(openAPI, entry); return new EndpointResponseSpec( entry.group(), entry.method(), entry.path(), - responses + nullToEmpty(operation.getResponses()).entrySet().stream() + .map(response -> toEndpointResponse(response.getKey(), response.getValue(), openAPI)) + .toList() ); } - private EndpointResponse toEndpointResponse(ApiResponse apiResponse, Type returnType) { - EndpointSchema schema = null; - List contentTypes = new ArrayList<>(); + private EndpointResponse toEndpointResponse(String status, ApiResponse apiResponse, OpenAPI openAPI) { + String responseStatus = responseStatus(status, apiResponse); + if ("204".equals(responseStatus)) { + return new EndpointResponse( + responseStatus, + responseExtension(apiResponse, "x-koin-code"), + nullToEmpty(apiResponse.getDescription()), + List.of(), + null + ); + } + return new EndpointResponse( + responseStatus, + responseExtension(apiResponse, "x-koin-code"), + nullToEmpty(apiResponse.getDescription()), + contentTypes(apiResponse.getContent()), + firstContentSchema(apiResponse.getContent(), openAPI) + ); + } - for (Content content : apiResponse.content()) { - if (content.schema().hidden()) { - continue; - } - String mediaType = content.mediaType().isBlank() ? APPLICATION_JSON_VALUE : content.mediaType(); - contentTypes.add(mediaType); - if (!Void.class.equals(content.schema().implementation())) { - schema = loadEndpointSchema(content.schema().implementation()); - } + private String responseStatus(String status, ApiResponse apiResponse) { + String extensionStatus = responseExtension(apiResponse, "x-http-status"); + if (extensionStatus != null) { + return extensionStatus; } + int lastSpace = status.lastIndexOf(' '); + return lastSpace == -1 ? status : status.substring(lastSpace + 1); + } - if (schema == null && isSuccess(apiResponse.responseCode())) { - schema = loadEndpointSchema(returnType); - if (schema != null && contentTypes.isEmpty()) { - contentTypes.add(APPLICATION_JSON_VALUE); - } + private String responseExtension(ApiResponse apiResponse, String key) { + if (apiResponse.getExtensions() == null || !apiResponse.getExtensions().containsKey(key)) { + return null; } + return String.valueOf(apiResponse.getExtensions().get(key)); + } - return new EndpointResponse( - apiResponse.responseCode(), - nullToEmpty(apiResponse.description()), - contentTypes, - schema + private EndpointParameter toEndpointParameter(Parameter parameter, OpenAPI openAPI) { + Parameter resolvedParameter = resolveParameter(parameter, openAPI); + return new EndpointParameter( + resolvedParameter.getName(), + Boolean.TRUE.equals(resolvedParameter.getRequired()), + nullToEmpty(resolvedParameter.getDescription()), + toEndpointSchema(resolveSchema(resolvedParameter.getSchema(), openAPI)) ); } - private EndpointResponse defaultSuccessResponse(Type returnType) { - return new EndpointResponse( - Void.class.equals(returnType) || void.class.equals(returnType) ? "204" : "200", - "", - Void.class.equals(returnType) || void.class.equals(returnType) ? List.of() : List.of(APPLICATION_JSON_VALUE), - loadEndpointSchema(returnType) + private RequestBodySpec toRequestBodySpec(RequestBody requestBody, OpenAPI openAPI) { + if (requestBody == null) { + return null; + } + return new RequestBodySpec( + Boolean.TRUE.equals(requestBody.getRequired()), + contentTypes(requestBody.getContent()), + firstContentSchema(requestBody.getContent(), openAPI) ); } - private EndpointParameter toParameter(java.lang.reflect.Parameter parameter, String name, boolean required) { - Parameter swaggerParameter = parameter.getAnnotation(Parameter.class); - return new EndpointParameter( - name, - required, - swaggerParameter == null ? "" : nullToEmpty(swaggerParameter.description()), - loadEndpointSchema(parameter.getParameterizedType()) - ); + private List contentTypes(Content content) { + return content == null ? List.of() : List.copyOf(content.keySet()); + } + + private EndpointSchema firstContentSchema(Content content, OpenAPI openAPI) { + if (content == null || content.isEmpty()) { + return null; + } + return content.values().stream() + .map(MediaType::getSchema) + .filter(Objects::nonNull) + .findFirst() + .map(schema -> toEndpointSchema(resolveSchema(schema, openAPI))) + .orElse(null); + } + + private Parameter resolveParameter(Parameter parameter, OpenAPI openAPI) { + String ref = parameter.get$ref(); + if (ref == null || ref.isBlank() + || openAPI.getComponents() == null + || openAPI.getComponents().getParameters() == null) { + return parameter; + } + String name = ref.substring(ref.lastIndexOf('/') + 1); + return openAPI.getComponents().getParameters().getOrDefault(name, parameter); + } + + private Schema resolveSchema(Schema schema, OpenAPI openAPI) { + if (openAPI.getComponents() == null) { + return schema; + } + return resolveRefs(schema, openAPI.getComponents().getSchemas(), new HashSet<>(), 0); + } + + private Operation openApiOperation(OpenAPI openAPI, EndpointEntry entry) { + PathItem pathItem = openAPI.getPaths().get(entry.path()); + if (pathItem == null) { + throw new EndpointSpecException("OPENAPI_PATH_NOT_FOUND", "No OpenAPI path found."); + } + Operation operation = pathItem.readOperationsMap().get(PathItem.HttpMethod.valueOf(entry.method())); + if (operation == null) { + throw new EndpointSpecException("OPENAPI_OPERATION_NOT_FOUND", "No OpenAPI operation found."); + } + return operation; + } + + private Operation openApiOperation(String group, String method, String path) { + OpenAPI openAPI; + try { + openAPI = openApiProvider.getOpenApi(group); + } catch (EndpointSpecException ignored) { + return null; + } + PathItem pathItem = openAPI.getPaths().get(path); + if (pathItem == null) { + return null; + } + return pathItem.readOperationsMap().get(PathItem.HttpMethod.valueOf(method)); } private EndpointSummary toSummary(EndpointEntry entry) { @@ -257,8 +284,8 @@ private EndpointSummary toSummary(EndpointEntry entry) { entry.method(), entry.path(), operationId(operation), - operation == null ? "" : nullToEmpty(operation.summary()), - operation == null ? "" : nullToEmpty(operation.description()), + operation == null ? "" : nullToEmpty(operation.getSummary()), + operation == null ? "" : nullToEmpty(operation.getDescription()), entry.tags(), entry.deprecated(), entry.authRequired() @@ -300,8 +327,8 @@ private boolean matchesQuery(EndpointEntry entry, String query) { entry.method(), entry.group(), operationId(operation), - operation == null ? "" : nullToEmpty(operation.summary()), - operation == null ? "" : nullToEmpty(operation.description()), + operation == null ? "" : nullToEmpty(operation.getSummary()), + operation == null ? "" : nullToEmpty(operation.getDescription()), String.join(" ", entry.tags()) ).toLowerCase(Locale.ROOT); return haystack.contains(query.toLowerCase(Locale.ROOT)); @@ -322,24 +349,22 @@ private List endpointEntries() { return; } Method docsMethod = findDocsMethod(handlerMethod); - Operation operation = findOperation(docsMethod); Deprecation deprecation = findDeprecation(docsMethod); - List tags = findTags(handlerMethod.getBeanType(), docsMethod); for (String path : paths(info)) { for (String group : groupsOf(handlerMethod.getBeanType(), path)) { for (String method : methods(info)) { + Operation operation = openApiOperation(group, method, path); entries.add(new EndpointEntry( group, method, path, docsMethod, operation, - tags, + operationTags(operation, handlerMethod.getBeanType(), docsMethod), deprecation, - operation != null && operation.deprecated() || deprecation != null, - authRequired(docsMethod, operation), - actualReturnType(docsMethod) + operation != null && Boolean.TRUE.equals(operation.getDeprecated()) || deprecation != null, + authRequired(docsMethod, operation) )); } } @@ -360,14 +385,16 @@ private Method findDocsMethod(HandlerMethod handlerMethod) { return controllerMethod; } - private Operation findOperation(Method method) { - return AnnotatedElementUtils.findMergedAnnotation(method, Operation.class); - } - private Deprecation findDeprecation(Method method) { return AnnotatedElementUtils.findMergedAnnotation(method, Deprecation.class); } + private List operationTags(Operation operation, Class beanType, Method method) { + return operation != null && operation.getTags() != null + ? operation.getTags() + : findTags(beanType, method); + } + private List findTags(Class beanType, Method method) { Set tags = new LinkedHashSet<>(); Tag methodTag = AnnotatedElementUtils.findMergedAnnotation(method, Tag.class); @@ -393,7 +420,7 @@ private boolean authRequired(Method method, Operation operation) { if (hasAuthParameter) { return true; } - if (operation != null && operation.security().length > 0) { + if (operation != null && operation.getSecurity() != null && !operation.getSecurity().isEmpty()) { return true; } return method.isAnnotationPresent(SecurityRequirement.class); @@ -468,55 +495,6 @@ private boolean matchesGroupFilter(String actualGroup, String requestedGroup) { || actualGroup.equalsIgnoreCase(requestedGroup); } - private Type actualReturnType(Method method) { - Type returnType = method.getGenericReturnType(); - if (returnType instanceof ParameterizedType parameterizedType - && parameterizedType.getRawType().equals(ResponseEntity.class)) { - return parameterizedType.getActualTypeArguments()[0]; - } - return returnType; - } - - private Schema loadSchema(Type type) { - if (type.equals(Void.class) || type.equals(void.class)) { - return null; - } - ResolvedSchema resolvedSchema = ModelConverters.getInstance().readAllAsResolvedSchema(type); - if (resolvedSchema == null || resolvedSchema.schema == null) { - return scalarSchema(type); - } - return resolveRefs(resolvedSchema.schema, resolvedSchema.referencedSchemas, new HashSet<>(), 0); - } - - private Schema scalarSchema(Type type) { - if (!(type instanceof Class clazz)) { - return null; - } - if (String.class.equals(clazz) || Character.class.equals(clazz) || char.class.equals(clazz)) { - return new Schema<>().type("string"); - } - if (Boolean.class.equals(clazz) || boolean.class.equals(clazz)) { - return new Schema<>().type("boolean"); - } - if (Integer.class.equals(clazz) || int.class.equals(clazz)) { - return new Schema<>().type("integer").format("int32"); - } - if (Long.class.equals(clazz) || long.class.equals(clazz)) { - return new Schema<>().type("integer").format("int64"); - } - if (Float.class.equals(clazz) || float.class.equals(clazz)) { - return new Schema<>().type("number").format("float"); - } - if (Double.class.equals(clazz) || double.class.equals(clazz)) { - return new Schema<>().type("number").format("double"); - } - return null; - } - - private EndpointSchema loadEndpointSchema(Type type) { - return toEndpointSchema(loadSchema(type)); - } - private EndpointSchema toEndpointSchema(Schema schema) { if (schema == null) { return null; @@ -557,7 +535,7 @@ private EndpointSchema toEndpointSchema(Schema schema) { private List toEndpointSchemaList(List schemas) { if (schemas == null || schemas.isEmpty()) { - return null; + return Collections.emptyList(); } return schemas.stream() .map(this::toEndpointSchema) @@ -572,7 +550,7 @@ private Boolean isTruncated(Schema schema) { return Boolean.TRUE.equals(truncated) ? true : null; } - @SuppressWarnings({"rawtypes", "unchecked"}) + @SuppressWarnings({"rawtypes"}) private Schema resolveRefs( Schema schema, Map referencedSchemas, @@ -584,7 +562,7 @@ private Schema resolveRefs( } String ref = schema.get$ref(); - if (ref != null && !ref.isBlank()) { + if (ref != null && !ref.isBlank() && referencedSchemas != null) { String refName = ref.substring(ref.lastIndexOf('/') + 1); if (!resolvingRefs.add(refName)) { Schema truncatedSchema = new Schema<>().$ref(ref); @@ -601,13 +579,14 @@ private Schema resolveRefs( if (schema.getProperties() != null) { schema.getProperties().replaceAll((name, property) -> - resolveRefs((Schema)property, referencedSchemas, resolvingRefs, depth + 1)); + resolveRefs(property, referencedSchemas, resolvingRefs, depth + 1)); } if (schema.getItems() != null) { schema.setItems(resolveRefs(schema.getItems(), referencedSchemas, resolvingRefs, depth + 1)); } if (schema.getAdditionalProperties() instanceof Schema additionalProperties) { - schema.setAdditionalProperties(resolveRefs(additionalProperties, referencedSchemas, resolvingRefs, depth + 1)); + schema.setAdditionalProperties( + resolveRefs(additionalProperties, referencedSchemas, resolvingRefs, depth + 1)); } schema.setAllOf(resolveSchemaList(schema.getAllOf(), referencedSchemas, resolvingRefs, depth)); schema.setOneOf(resolveSchemaList(schema.getOneOf(), referencedSchemas, resolvingRefs, depth)); @@ -629,22 +608,8 @@ private List resolveSchemaList( .toList(); } - private String parameterName(String name, String value, java.lang.reflect.Parameter parameter) { - if (!name.isBlank()) { - return name; - } - if (!value.isBlank()) { - return value; - } - return parameter.getName(); - } - private String operationId(Operation operation) { - return operation == null ? "" : nullToEmpty(operation.operationId()); - } - - private boolean isSuccess(String status) { - return status != null && !status.isBlank() && status.charAt(0) == '2'; + return operation == null ? "" : nullToEmpty(operation.getOperationId()); } private boolean hasSameSignature(Method left, Method right) { @@ -660,14 +625,26 @@ private String normalize(String value) { } private String normalizeGroup(String value) { - return normalize(value) + String normalized = normalize(value); + if (normalized == null) { + return ""; + } + return normalized .replaceFirst("^\\d+\\.\\s*", "") .replaceFirst("\\s+api$", "") .replaceAll("[^a-z0-9]+", "-") - .replaceAll("(^-|-$)", ""); + .replaceAll("(^-)|(-$)", ""); } private String nullToEmpty(String value) { return Objects.requireNonNullElse(value, ""); } + + private List nullToEmpty(List values) { + return values == null ? List.of() : values; + } + + private Map nullToEmpty(Map values) { + return values == null ? Map.of() : values; + } } diff --git a/src/main/java/in/koreatech/koin/global/mcp/service/McpOpenApiProvider.java b/src/main/java/in/koreatech/koin/global/mcp/service/McpOpenApiProvider.java new file mode 100644 index 000000000..816004333 --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/mcp/service/McpOpenApiProvider.java @@ -0,0 +1,109 @@ +package in.koreatech.koin.global.mcp.service; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springdoc.core.customizers.SpringDocCustomizers; +import org.springdoc.core.models.GroupedOpenApi; +import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springdoc.core.providers.SpringDocProviders; +import org.springdoc.core.service.AbstractRequestService; +import org.springdoc.core.service.GenericResponseService; +import org.springdoc.core.service.OpenAPIService; +import org.springdoc.core.service.OperationService; +import org.springdoc.webmvc.api.OpenApiWebMvcResource; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.global.mcp.exception.EndpointSpecException; +import io.swagger.v3.oas.models.OpenAPI; + +@Component +@ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") +public class McpOpenApiProvider { + + private final Map resources; + + public McpOpenApiProvider( + List groupedOpenApis, + ObjectFactory openApiServiceFactory, + AbstractRequestService requestService, + GenericResponseService responseService, + OperationService operationService, + SpringDocConfigProperties springDocConfigProperties, + SpringDocProviders springDocProviders + ) { + OpenApiResourceDependencies dependencies = new OpenApiResourceDependencies( + openApiServiceFactory, + requestService, + responseService, + operationService, + springDocConfigProperties, + springDocProviders + ); + this.resources = groupedOpenApis.stream() + .collect(Collectors.toMap( + GroupedOpenApi::getGroup, + groupedOpenApi -> new ExposedOpenApiResource( + groupedOpenApi.getGroup(), + dependencies, + groupCustomizers(groupedOpenApi) + ) + )); + } + + public OpenAPI getOpenApi(String group) { + ExposedOpenApiResource resource = resources.get(group); + if (resource == null) { + throw new EndpointSpecException("OPENAPI_GROUP_NOT_FOUND", "No OpenAPI resource found."); + } + return resource.getOpenApi(); + } + + private SpringDocCustomizers groupCustomizers(GroupedOpenApi groupedOpenApi) { + return new SpringDocCustomizers( + Optional.of(groupedOpenApi.getOpenApiCustomizers()), + Optional.of(groupedOpenApi.getOperationCustomizers()), + Optional.of(groupedOpenApi.getRouterOperationCustomizers()), + Optional.of(groupedOpenApi.getOpenApiMethodFilters()) + ); + } + + private record OpenApiResourceDependencies( + ObjectFactory openApiServiceFactory, + AbstractRequestService requestService, + GenericResponseService responseService, + OperationService operationService, + SpringDocConfigProperties springDocConfigProperties, + SpringDocProviders springDocProviders + ) { + } + + private static class ExposedOpenApiResource extends OpenApiWebMvcResource { + + private ExposedOpenApiResource( + String groupName, + OpenApiResourceDependencies dependencies, + SpringDocCustomizers springDocCustomizers + ) { + super( + groupName, + dependencies.openApiServiceFactory(), + dependencies.requestService(), + dependencies.responseService(), + dependencies.operationService(), + dependencies.springDocConfigProperties(), + dependencies.springDocProviders(), + springDocCustomizers + ); + } + + private OpenAPI getOpenApi() { + return super.getOpenApi(Locale.getDefault()); + } + } +} From 04110e16a765c3f57d51a584aa3e010ce2649225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= Date: Tue, 19 May 2026 11:55:15 +0900 Subject: [PATCH 04/13] refactor: remove unused endpoint schema helpers --- .../koin/global/mcp/dto/EndpointSchema.java | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java index 60138109a..5eba3ff21 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java +++ b/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java @@ -6,9 +6,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Builder; - -@Builder @JsonInclude(JsonInclude.Include.NON_EMPTY) public record EndpointSchema( String type, @@ -29,26 +26,4 @@ public record EndpointSchema( String ref, Boolean truncated ) { - - public static EndpointSchema object(Map properties, List required) { - return EndpointSchema.builder() - .type("object") - .properties(properties) - .required(required == null || required.isEmpty() ? null : List.copyOf(required)) - .build(); - } - - public static EndpointSchema array(EndpointSchema items) { - return EndpointSchema.builder() - .type("array") - .items(items) - .build(); - } - - public static EndpointSchema file() { - return EndpointSchema.builder() - .type("string") - .format("binary") - .build(); - } } From de185138ecb25825b988ce0d8101ce7e9048c3c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= Date: Tue, 19 May 2026 12:05:28 +0900 Subject: [PATCH 05/13] refactor: use lombok getter for endpoint spec exception --- .../koin/global/mcp/exception/EndpointSpecException.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/in/koreatech/koin/global/mcp/exception/EndpointSpecException.java b/src/main/java/in/koreatech/koin/global/mcp/exception/EndpointSpecException.java index 996af507f..aea5d9679 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/exception/EndpointSpecException.java +++ b/src/main/java/in/koreatech/koin/global/mcp/exception/EndpointSpecException.java @@ -1,5 +1,8 @@ package in.koreatech.koin.global.mcp.exception; +import lombok.Getter; + +@Getter public class EndpointSpecException extends RuntimeException { private final String code; @@ -8,8 +11,4 @@ public EndpointSpecException(String code, String message) { super(message); this.code = code; } - - public String getCode() { - return code; - } } From 5421eb066d5cad07437b97e4e66cfc3f3f4b012d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= Date: Tue, 19 May 2026 12:19:38 +0900 Subject: [PATCH 06/13] refactor: move mcp package to top level --- .../ApiResponseCodesOperationCustomizer.java | 16 +--- .../mcp/config/McpToolConfig.java | 4 +- .../mcp/dto/EndpointDescription.java | 2 +- .../mcp/dto/EndpointParameter.java | 2 +- .../mcp/dto/EndpointParameters.java | 2 +- .../mcp/dto/EndpointRequestSpec.java | 2 +- .../mcp/dto/EndpointResponse.java | 2 +- .../mcp/dto/EndpointResponseSpec.java | 2 +- .../{global => }/mcp/dto/EndpointSchema.java | 2 +- .../{global => }/mcp/dto/EndpointSummary.java | 2 +- .../mcp/dto/FindEndpointsResponse.java | 2 +- .../koin/{global => }/mcp/dto/ReplacedBy.java | 2 +- .../{global => }/mcp/dto/RequestBodySpec.java | 2 +- .../mcp/exception/EndpointSpecException.java | 2 +- .../mcp/model/DeprecatedFilter.java | 2 +- .../{global => }/mcp/model/EndpointEntry.java | 2 +- .../mcp/service/EndpointSpecService.java | 75 ++++++++++++------- .../mcp/service/McpOpenApiProvider.java | 4 +- .../mcp/tool/EndpointSpecTools.java | 16 ++-- 19 files changed, 76 insertions(+), 67 deletions(-) rename src/main/java/in/koreatech/koin/{global => }/mcp/config/McpToolConfig.java (86%) rename src/main/java/in/koreatech/koin/{global => }/mcp/dto/EndpointDescription.java (89%) rename src/main/java/in/koreatech/koin/{global => }/mcp/dto/EndpointParameter.java (75%) rename src/main/java/in/koreatech/koin/{global => }/mcp/dto/EndpointParameters.java (80%) rename src/main/java/in/koreatech/koin/{global => }/mcp/dto/EndpointRequestSpec.java (79%) rename src/main/java/in/koreatech/koin/{global => }/mcp/dto/EndpointResponse.java (86%) rename src/main/java/in/koreatech/koin/{global => }/mcp/dto/EndpointResponseSpec.java (79%) rename src/main/java/in/koreatech/koin/{global => }/mcp/dto/EndpointSchema.java (94%) rename src/main/java/in/koreatech/koin/{global => }/mcp/dto/EndpointSummary.java (85%) rename src/main/java/in/koreatech/koin/{global => }/mcp/dto/FindEndpointsResponse.java (69%) rename src/main/java/in/koreatech/koin/{global => }/mcp/dto/ReplacedBy.java (61%) rename src/main/java/in/koreatech/koin/{global => }/mcp/dto/RequestBodySpec.java (77%) rename src/main/java/in/koreatech/koin/{global => }/mcp/exception/EndpointSpecException.java (83%) rename src/main/java/in/koreatech/koin/{global => }/mcp/model/DeprecatedFilter.java (61%) rename src/main/java/in/koreatech/koin/{global => }/mcp/model/EndpointEntry.java (89%) rename src/main/java/in/koreatech/koin/{global => }/mcp/service/EndpointSpecService.java (92%) rename src/main/java/in/koreatech/koin/{global => }/mcp/service/McpOpenApiProvider.java (97%) rename src/main/java/in/koreatech/koin/{global => }/mcp/tool/EndpointSpecTools.java (91%) diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java index 111ad21ad..44c76b1fe 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java @@ -60,7 +60,6 @@ public Operation customize(Operation operation, HandlerMethod handler) { ApiResponseCode code = codes[i]; String key = String.format("%d) %d", i + 1, code.getHttpStatus().value()); responses.put(key, createApiResponse( - code, code.getMessage(), () -> createResponseBody(code, handler, returnType) )); @@ -80,18 +79,12 @@ private Type getActualResponseType(HandlerMethod handler) { } private ApiResponse createApiResponse( - ApiResponseCode code, String description, Supplier supplier ) { - ApiResponse apiResponse = new ApiResponse().description(description); - MediaType mediaType = supplier.get(); - if (mediaType != null) { - apiResponse.content(new Content().addMediaType(APPLICATION_JSON_VALUE, mediaType)); - } - apiResponse.addExtension("x-koin-code", code.getCode()); - apiResponse.addExtension("x-http-status", code.getHttpStatus().value()); - return apiResponse; + return new ApiResponse() + .description(description) + .content(new Content().addMediaType(APPLICATION_JSON_VALUE, supplier.get())); } private MediaType createResponseBody( @@ -99,9 +92,6 @@ private MediaType createResponseBody( HandlerMethod handler, Type returnType ) { - if (code.getHttpStatus().value() == 204) { - return null; - } if (code.getHttpStatus().is2xxSuccessful()) { return new MediaType().schema(loadSchema(returnType)); } diff --git a/src/main/java/in/koreatech/koin/global/mcp/config/McpToolConfig.java b/src/main/java/in/koreatech/koin/mcp/config/McpToolConfig.java similarity index 86% rename from src/main/java/in/koreatech/koin/global/mcp/config/McpToolConfig.java rename to src/main/java/in/koreatech/koin/mcp/config/McpToolConfig.java index ad3fbd1d4..388815bff 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/config/McpToolConfig.java +++ b/src/main/java/in/koreatech/koin/mcp/config/McpToolConfig.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.config; +package in.koreatech.koin.mcp.config; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.ai.tool.method.MethodToolCallbackProvider; @@ -6,7 +6,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import in.koreatech.koin.global.mcp.tool.EndpointSpecTools; +import in.koreatech.koin.mcp.tool.EndpointSpecTools; @Configuration @ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointDescription.java b/src/main/java/in/koreatech/koin/mcp/dto/EndpointDescription.java similarity index 89% rename from src/main/java/in/koreatech/koin/global/mcp/dto/EndpointDescription.java rename to src/main/java/in/koreatech/koin/mcp/dto/EndpointDescription.java index 7a9620f34..9795c5a0c 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointDescription.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/EndpointDescription.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.dto; +package in.koreatech.koin.mcp.dto; import java.util.List; diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameter.java b/src/main/java/in/koreatech/koin/mcp/dto/EndpointParameter.java similarity index 75% rename from src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameter.java rename to src/main/java/in/koreatech/koin/mcp/dto/EndpointParameter.java index da65aef8c..0607987b8 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameter.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/EndpointParameter.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.dto; +package in.koreatech.koin.mcp.dto; public record EndpointParameter( String name, diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameters.java b/src/main/java/in/koreatech/koin/mcp/dto/EndpointParameters.java similarity index 80% rename from src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameters.java rename to src/main/java/in/koreatech/koin/mcp/dto/EndpointParameters.java index 3a3646a4f..f2296442b 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointParameters.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/EndpointParameters.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.dto; +package in.koreatech.koin.mcp.dto; import java.util.List; diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointRequestSpec.java b/src/main/java/in/koreatech/koin/mcp/dto/EndpointRequestSpec.java similarity index 79% rename from src/main/java/in/koreatech/koin/global/mcp/dto/EndpointRequestSpec.java rename to src/main/java/in/koreatech/koin/mcp/dto/EndpointRequestSpec.java index ca522d42e..93cde07da 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointRequestSpec.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/EndpointRequestSpec.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.dto; +package in.koreatech.koin.mcp.dto; public record EndpointRequestSpec( String group, diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponse.java b/src/main/java/in/koreatech/koin/mcp/dto/EndpointResponse.java similarity index 86% rename from src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponse.java rename to src/main/java/in/koreatech/koin/mcp/dto/EndpointResponse.java index c1e02324c..731deee5e 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponse.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/EndpointResponse.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.dto; +package in.koreatech.koin.mcp.dto; import java.util.List; diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponseSpec.java b/src/main/java/in/koreatech/koin/mcp/dto/EndpointResponseSpec.java similarity index 79% rename from src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponseSpec.java rename to src/main/java/in/koreatech/koin/mcp/dto/EndpointResponseSpec.java index 20a566f6f..7e7a65805 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointResponseSpec.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/EndpointResponseSpec.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.dto; +package in.koreatech.koin.mcp.dto; import java.util.List; diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java b/src/main/java/in/koreatech/koin/mcp/dto/EndpointSchema.java similarity index 94% rename from src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java rename to src/main/java/in/koreatech/koin/mcp/dto/EndpointSchema.java index 5eba3ff21..94405a22e 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSchema.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/EndpointSchema.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.dto; +package in.koreatech.koin.mcp.dto; import java.util.List; import java.util.Map; diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSummary.java b/src/main/java/in/koreatech/koin/mcp/dto/EndpointSummary.java similarity index 85% rename from src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSummary.java rename to src/main/java/in/koreatech/koin/mcp/dto/EndpointSummary.java index d46a82805..a095db5b8 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/EndpointSummary.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/EndpointSummary.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.dto; +package in.koreatech.koin.mcp.dto; import java.util.List; diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/FindEndpointsResponse.java b/src/main/java/in/koreatech/koin/mcp/dto/FindEndpointsResponse.java similarity index 69% rename from src/main/java/in/koreatech/koin/global/mcp/dto/FindEndpointsResponse.java rename to src/main/java/in/koreatech/koin/mcp/dto/FindEndpointsResponse.java index 331ba78a7..08c6cb5df 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/FindEndpointsResponse.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/FindEndpointsResponse.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.dto; +package in.koreatech.koin.mcp.dto; import java.util.List; diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/ReplacedBy.java b/src/main/java/in/koreatech/koin/mcp/dto/ReplacedBy.java similarity index 61% rename from src/main/java/in/koreatech/koin/global/mcp/dto/ReplacedBy.java rename to src/main/java/in/koreatech/koin/mcp/dto/ReplacedBy.java index cfe4dd946..ab5359b5f 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/ReplacedBy.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/ReplacedBy.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.dto; +package in.koreatech.koin.mcp.dto; public record ReplacedBy( String method, diff --git a/src/main/java/in/koreatech/koin/global/mcp/dto/RequestBodySpec.java b/src/main/java/in/koreatech/koin/mcp/dto/RequestBodySpec.java similarity index 77% rename from src/main/java/in/koreatech/koin/global/mcp/dto/RequestBodySpec.java rename to src/main/java/in/koreatech/koin/mcp/dto/RequestBodySpec.java index b2142a3af..f5c811ce9 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/dto/RequestBodySpec.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/RequestBodySpec.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.dto; +package in.koreatech.koin.mcp.dto; import java.util.List; diff --git a/src/main/java/in/koreatech/koin/global/mcp/exception/EndpointSpecException.java b/src/main/java/in/koreatech/koin/mcp/exception/EndpointSpecException.java similarity index 83% rename from src/main/java/in/koreatech/koin/global/mcp/exception/EndpointSpecException.java rename to src/main/java/in/koreatech/koin/mcp/exception/EndpointSpecException.java index aea5d9679..5972f8066 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/exception/EndpointSpecException.java +++ b/src/main/java/in/koreatech/koin/mcp/exception/EndpointSpecException.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.exception; +package in.koreatech.koin.mcp.exception; import lombok.Getter; diff --git a/src/main/java/in/koreatech/koin/global/mcp/model/DeprecatedFilter.java b/src/main/java/in/koreatech/koin/mcp/model/DeprecatedFilter.java similarity index 61% rename from src/main/java/in/koreatech/koin/global/mcp/model/DeprecatedFilter.java rename to src/main/java/in/koreatech/koin/mcp/model/DeprecatedFilter.java index 547d506e8..c3cb418a1 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/model/DeprecatedFilter.java +++ b/src/main/java/in/koreatech/koin/mcp/model/DeprecatedFilter.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.model; +package in.koreatech.koin.mcp.model; public enum DeprecatedFilter { EXCLUDE, diff --git a/src/main/java/in/koreatech/koin/global/mcp/model/EndpointEntry.java b/src/main/java/in/koreatech/koin/mcp/model/EndpointEntry.java similarity index 89% rename from src/main/java/in/koreatech/koin/global/mcp/model/EndpointEntry.java rename to src/main/java/in/koreatech/koin/mcp/model/EndpointEntry.java index 05542fbc9..63e0cfcbf 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/model/EndpointEntry.java +++ b/src/main/java/in/koreatech/koin/mcp/model/EndpointEntry.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.model; +package in.koreatech.koin.mcp.model; import java.lang.reflect.Method; import java.util.List; diff --git a/src/main/java/in/koreatech/koin/global/mcp/service/EndpointSpecService.java b/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java similarity index 92% rename from src/main/java/in/koreatech/koin/global/mcp/service/EndpointSpecService.java rename to src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java index 219ae7545..1841a6fb7 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/service/EndpointSpecService.java +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.service; +package in.koreatech.koin.mcp.service; import java.lang.reflect.Method; import java.util.ArrayList; @@ -26,21 +26,23 @@ import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.code.ApiResponseCodes; import in.koreatech.koin.global.code.Deprecation; -import in.koreatech.koin.global.mcp.dto.EndpointDescription; -import in.koreatech.koin.global.mcp.dto.EndpointParameter; -import in.koreatech.koin.global.mcp.dto.EndpointParameters; -import in.koreatech.koin.global.mcp.dto.EndpointRequestSpec; -import in.koreatech.koin.global.mcp.dto.EndpointResponse; -import in.koreatech.koin.global.mcp.dto.EndpointResponseSpec; -import in.koreatech.koin.global.mcp.dto.EndpointSchema; -import in.koreatech.koin.global.mcp.dto.EndpointSummary; -import in.koreatech.koin.global.mcp.dto.FindEndpointsResponse; -import in.koreatech.koin.global.mcp.dto.ReplacedBy; -import in.koreatech.koin.global.mcp.dto.RequestBodySpec; -import in.koreatech.koin.global.mcp.exception.EndpointSpecException; -import in.koreatech.koin.global.mcp.model.DeprecatedFilter; -import in.koreatech.koin.global.mcp.model.EndpointEntry; +import in.koreatech.koin.mcp.dto.EndpointDescription; +import in.koreatech.koin.mcp.dto.EndpointParameter; +import in.koreatech.koin.mcp.dto.EndpointParameters; +import in.koreatech.koin.mcp.dto.EndpointRequestSpec; +import in.koreatech.koin.mcp.dto.EndpointResponse; +import in.koreatech.koin.mcp.dto.EndpointResponseSpec; +import in.koreatech.koin.mcp.dto.EndpointSchema; +import in.koreatech.koin.mcp.dto.EndpointSummary; +import in.koreatech.koin.mcp.dto.FindEndpointsResponse; +import in.koreatech.koin.mcp.dto.ReplacedBy; +import in.koreatech.koin.mcp.dto.RequestBodySpec; +import in.koreatech.koin.mcp.exception.EndpointSpecException; +import in.koreatech.koin.mcp.model.DeprecatedFilter; +import in.koreatech.koin.mcp.model.EndpointEntry; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.models.OpenAPI; @@ -155,17 +157,23 @@ public EndpointResponseSpec getEndpointResponseSpec(String group, String method, entry.method(), entry.path(), nullToEmpty(operation.getResponses()).entrySet().stream() - .map(response -> toEndpointResponse(response.getKey(), response.getValue(), openAPI)) + .map(response -> toEndpointResponse(response.getKey(), response.getValue(), openAPI, entry.docsMethod())) .toList() ); } - private EndpointResponse toEndpointResponse(String status, ApiResponse apiResponse, OpenAPI openAPI) { - String responseStatus = responseStatus(status, apiResponse); + private EndpointResponse toEndpointResponse( + String status, + ApiResponse apiResponse, + OpenAPI openAPI, + Method method + ) { + String responseStatus = responseStatus(status); + String responseCode = responseCode(status, method); if ("204".equals(responseStatus)) { return new EndpointResponse( responseStatus, - responseExtension(apiResponse, "x-koin-code"), + responseCode, nullToEmpty(apiResponse.getDescription()), List.of(), null @@ -173,27 +181,38 @@ private EndpointResponse toEndpointResponse(String status, ApiResponse apiRespon } return new EndpointResponse( responseStatus, - responseExtension(apiResponse, "x-koin-code"), + responseCode, nullToEmpty(apiResponse.getDescription()), contentTypes(apiResponse.getContent()), firstContentSchema(apiResponse.getContent(), openAPI) ); } - private String responseStatus(String status, ApiResponse apiResponse) { - String extensionStatus = responseExtension(apiResponse, "x-http-status"); - if (extensionStatus != null) { - return extensionStatus; - } + private String responseStatus(String status) { int lastSpace = status.lastIndexOf(' '); return lastSpace == -1 ? status : status.substring(lastSpace + 1); } - private String responseExtension(ApiResponse apiResponse, String key) { - if (apiResponse.getExtensions() == null || !apiResponse.getExtensions().containsKey(key)) { + private String responseCode(String status, Method method) { + ApiResponseCodes apiResponseCodes = AnnotatedElementUtils.findMergedAnnotation(method, ApiResponseCodes.class); + int responseIndex = responseIndex(status); + if (apiResponseCodes == null || responseIndex < 0 || responseIndex >= apiResponseCodes.value().length) { return null; } - return String.valueOf(apiResponse.getExtensions().get(key)); + ApiResponseCode apiResponseCode = apiResponseCodes.value()[responseIndex]; + return apiResponseCode.getCode(); + } + + private int responseIndex(String status) { + int delimiterIndex = status.indexOf(')'); + if (delimiterIndex == -1) { + return -1; + } + try { + return Integer.parseInt(status.substring(0, delimiterIndex)) - 1; + } catch (NumberFormatException ignored) { + return -1; + } } private EndpointParameter toEndpointParameter(Parameter parameter, OpenAPI openAPI) { diff --git a/src/main/java/in/koreatech/koin/global/mcp/service/McpOpenApiProvider.java b/src/main/java/in/koreatech/koin/mcp/service/McpOpenApiProvider.java similarity index 97% rename from src/main/java/in/koreatech/koin/global/mcp/service/McpOpenApiProvider.java rename to src/main/java/in/koreatech/koin/mcp/service/McpOpenApiProvider.java index 816004333..10245884e 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/service/McpOpenApiProvider.java +++ b/src/main/java/in/koreatech/koin/mcp/service/McpOpenApiProvider.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.service; +package in.koreatech.koin.mcp.service; import java.util.List; import java.util.Locale; @@ -19,7 +19,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -import in.koreatech.koin.global.mcp.exception.EndpointSpecException; +import in.koreatech.koin.mcp.exception.EndpointSpecException; import io.swagger.v3.oas.models.OpenAPI; @Component diff --git a/src/main/java/in/koreatech/koin/global/mcp/tool/EndpointSpecTools.java b/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java similarity index 91% rename from src/main/java/in/koreatech/koin/global/mcp/tool/EndpointSpecTools.java rename to src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java index 50bfb0d30..3a6720861 100644 --- a/src/main/java/in/koreatech/koin/global/mcp/tool/EndpointSpecTools.java +++ b/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.global.mcp.tool; +package in.koreatech.koin.mcp.tool; import java.util.Locale; @@ -7,13 +7,13 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -import in.koreatech.koin.global.mcp.dto.EndpointDescription; -import in.koreatech.koin.global.mcp.dto.EndpointRequestSpec; -import in.koreatech.koin.global.mcp.dto.EndpointResponseSpec; -import in.koreatech.koin.global.mcp.dto.FindEndpointsResponse; -import in.koreatech.koin.global.mcp.exception.EndpointSpecException; -import in.koreatech.koin.global.mcp.model.DeprecatedFilter; -import in.koreatech.koin.global.mcp.service.EndpointSpecService; +import in.koreatech.koin.mcp.dto.EndpointDescription; +import in.koreatech.koin.mcp.dto.EndpointRequestSpec; +import in.koreatech.koin.mcp.dto.EndpointResponseSpec; +import in.koreatech.koin.mcp.dto.FindEndpointsResponse; +import in.koreatech.koin.mcp.exception.EndpointSpecException; +import in.koreatech.koin.mcp.model.DeprecatedFilter; +import in.koreatech.koin.mcp.service.EndpointSpecService; @Component @ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") From 433db95f6342af186945a4c9225c6c98404baf21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= Date: Tue, 19 May 2026 14:00:00 +0900 Subject: [PATCH 07/13] revert: restore api response codes customizer --- .../ApiResponseCodesOperationCustomizer.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java index 44c76b1fe..65bf8ea28 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java @@ -38,7 +38,6 @@ @Component public class ApiResponseCodesOperationCustomizer implements OperationCustomizer { - private static final String MESSAGE_FIELD = "message"; private static final String TRACE_ID_EXAMPLE = "123e4567-e89b-12d3-a456-426614174000"; // 에러 스키마를 미리 생성하여 최적화 @@ -71,9 +70,10 @@ public Operation customize(Operation operation, HandlerMethod handler) { private Type getActualResponseType(HandlerMethod handler) { Type returnType = handler.getMethod().getGenericReturnType(); - if (returnType instanceof ParameterizedType parameterizedType - && parameterizedType.getRawType().equals(ResponseEntity.class)) { - return parameterizedType.getActualTypeArguments()[0]; + if (returnType instanceof ParameterizedType parameterizedType) { + if (parameterizedType.getRawType().equals(ResponseEntity.class)) { + return parameterizedType.getActualTypeArguments()[0]; + } } return returnType; } @@ -104,7 +104,7 @@ private MediaType createResponseBody( private Map createGenericErrorExample(ApiResponseCode code) { return Map.of( "code", code.getCode(), - MESSAGE_FIELD, code.getMessage(), + "message", code.getMessage(), "errorTraceId", TRACE_ID_EXAMPLE ); } @@ -115,13 +115,13 @@ private Map createInvalidRequestBodyErrorExample( ) { List> fieldErrors = extractFieldErrors(handler); String firstMsg = fieldErrors.stream() - .map(e -> (String)e.get(MESSAGE_FIELD)) + .map(e -> (String)e.get("message")) .findFirst() .orElse(code.getMessage()); Map example = new LinkedHashMap<>(); example.put("code", code.getCode()); - example.put(MESSAGE_FIELD, firstMsg); + example.put("message", firstMsg); example.put("errorTraceId", TRACE_ID_EXAMPLE); example.put("fieldErrors", fieldErrors); return example; @@ -173,7 +173,7 @@ private Map fieldErrorEntry(Field field, Annotation ann) { return Map.of( "field", name, "constraint", constraint, - MESSAGE_FIELD, msg + "message", msg ); } From 5a683f896a82982dfc3bcda23ec2b17812efe943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= Date: Tue, 19 May 2026 14:04:00 +0900 Subject: [PATCH 08/13] refactor: simplify endpoint spec tools --- .../koin/mcp/tool/EndpointSpecTools.java | 34 ++----------------- 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java b/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java index 3a6720861..3d32fd956 100644 --- a/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java +++ b/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java @@ -31,7 +31,7 @@ public FindEndpointsResponse find_endpoints( @ToolParam(description = "Optional endpoint group. Exact group names and normalized names like 'business' are supported.", required = false) String group, @ToolParam(description = "Deprecated endpoint filter. Use 'exclude' by default, 'include' to include deprecated endpoints, or 'only' to return deprecated endpoints only.", required = false) String deprecated ) { - return findEndpoints(query, group, deprecated); + return endpointSpecService.findEndpoints(query, group, parseDeprecatedFilter(deprecated)); } @Tool(description = "Get endpoint description metadata excluding request and response body details. This is read-only and never sends API requests.") @@ -40,7 +40,7 @@ public EndpointDescription get_endpoint_description( @ToolParam(description = "HTTP method, such as GET, POST, PUT, PATCH, or DELETE.") String method, @ToolParam(description = "Endpoint path, such as /v2/shops/{id}.") String path ) { - return getEndpointDescription(group, method, path); + return endpointSpecService.getEndpointDescription(group, method, path); } @Tool(description = "Get endpoint request parameters and request body schema. This is read-only and never sends API requests.") @@ -49,7 +49,7 @@ public EndpointRequestSpec get_endpoint_request_spec( @ToolParam(description = "HTTP method, such as GET, POST, PUT, PATCH, or DELETE.") String method, @ToolParam(description = "Endpoint path, such as /v2/shops/{id}.") String path ) { - return getEndpointRequestSpec(group, method, path); + return endpointSpecService.getEndpointRequestSpec(group, method, path); } @Tool(description = "Get endpoint response status codes and response body schemas paired by status code. This is read-only and never sends API requests.") @@ -58,34 +58,6 @@ public EndpointResponseSpec get_endpoint_response_spec( @ToolParam(description = "HTTP method, such as GET, POST, PUT, PATCH, or DELETE.") String method, @ToolParam(description = "Endpoint path, such as /v2/shops/{id}.") String path ) { - return getEndpointResponseSpec(group, method, path); - } - - /** - * Find KOIN API endpoints by keyword, or list all endpoints when query is omitted. - */ - public FindEndpointsResponse findEndpoints(String query, String group, String deprecated) { - return endpointSpecService.findEndpoints(query, group, parseDeprecatedFilter(deprecated)); - } - - /** - * Get basic description metadata for one KOIN endpoint. - */ - public EndpointDescription getEndpointDescription(String group, String method, String path) { - return endpointSpecService.getEndpointDescription(group, method, path); - } - - /** - * Get request parameters and request body schema for one KOIN endpoint. - */ - public EndpointRequestSpec getEndpointRequestSpec(String group, String method, String path) { - return endpointSpecService.getEndpointRequestSpec(group, method, path); - } - - /** - * Get response status codes and response body schemas for one KOIN endpoint. - */ - public EndpointResponseSpec getEndpointResponseSpec(String group, String method, String path) { return endpointSpecService.getEndpointResponseSpec(group, method, path); } From cdb38e782f5c971bc314ecdade1f54abad87be0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= Date: Tue, 19 May 2026 15:12:13 +0900 Subject: [PATCH 09/13] feat: align swagger mcp response contract --- .../mcp/dto/endpoint/EndpointCandidate.java | 8 + .../{ => endpoint}/EndpointDescription.java | 4 +- .../dto/{ => endpoint}/EndpointSummary.java | 2 +- .../{ => endpoint}/FindEndpointsResponse.java | 2 +- .../mcp/dto/{ => endpoint}/ReplacedBy.java | 2 +- .../request}/EndpointParameter.java | 4 +- .../request}/EndpointParameters.java | 2 +- .../request}/EndpointRequestSpec.java | 2 +- .../request}/RequestBodySpec.java | 4 +- .../response}/EndpointResponse.java | 5 +- .../response}/EndpointResponseSpec.java | 2 +- .../koin/mcp/dto/error/McpError.java | 17 ++ .../koin/mcp/dto/error/McpErrorResponse.java | 15 ++ .../mcp/dto/{ => schema}/EndpointSchema.java | 2 +- .../mcp/exception/EndpointSpecException.java | 21 ++ .../koin/mcp/service/EndpointSpecService.java | 185 +++++++++++++----- .../koin/mcp/tool/EndpointSpecTools.java | 36 ++-- 17 files changed, 232 insertions(+), 81 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointCandidate.java rename src/main/java/in/koreatech/koin/mcp/dto/{ => endpoint}/EndpointDescription.java (78%) rename src/main/java/in/koreatech/koin/mcp/dto/{ => endpoint}/EndpointSummary.java (85%) rename src/main/java/in/koreatech/koin/mcp/dto/{ => endpoint}/FindEndpointsResponse.java (68%) rename src/main/java/in/koreatech/koin/mcp/dto/{ => endpoint}/ReplacedBy.java (60%) rename src/main/java/in/koreatech/koin/mcp/dto/{ => endpoint/request}/EndpointParameter.java (54%) rename src/main/java/in/koreatech/koin/mcp/dto/{ => endpoint/request}/EndpointParameters.java (76%) rename src/main/java/in/koreatech/koin/mcp/dto/{ => endpoint/request}/EndpointRequestSpec.java (75%) rename src/main/java/in/koreatech/koin/mcp/dto/{ => endpoint/request}/RequestBodySpec.java (56%) rename src/main/java/in/koreatech/koin/mcp/dto/{ => endpoint/response}/EndpointResponse.java (70%) rename src/main/java/in/koreatech/koin/mcp/dto/{ => endpoint/response}/EndpointResponseSpec.java (74%) create mode 100644 src/main/java/in/koreatech/koin/mcp/dto/error/McpError.java create mode 100644 src/main/java/in/koreatech/koin/mcp/dto/error/McpErrorResponse.java rename src/main/java/in/koreatech/koin/mcp/dto/{ => schema}/EndpointSchema.java (94%) diff --git a/src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointCandidate.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointCandidate.java new file mode 100644 index 000000000..43bf71664 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointCandidate.java @@ -0,0 +1,8 @@ +package in.koreatech.koin.mcp.dto.endpoint; + +public record EndpointCandidate( + String group, + String method, + String path +) { +} diff --git a/src/main/java/in/koreatech/koin/mcp/dto/EndpointDescription.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointDescription.java similarity index 78% rename from src/main/java/in/koreatech/koin/mcp/dto/EndpointDescription.java rename to src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointDescription.java index 9795c5a0c..2f3777800 100644 --- a/src/main/java/in/koreatech/koin/mcp/dto/EndpointDescription.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointDescription.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.mcp.dto; +package in.koreatech.koin.mcp.dto.endpoint; import java.util.List; @@ -11,10 +11,8 @@ public record EndpointDescription( String description, List tags, boolean deprecated, - String deprecatedSince, String deprecatedReason, ReplacedBy replacedBy, - boolean forRemoval, boolean authRequired ) { } diff --git a/src/main/java/in/koreatech/koin/mcp/dto/EndpointSummary.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointSummary.java similarity index 85% rename from src/main/java/in/koreatech/koin/mcp/dto/EndpointSummary.java rename to src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointSummary.java index a095db5b8..42f5f2022 100644 --- a/src/main/java/in/koreatech/koin/mcp/dto/EndpointSummary.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointSummary.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.mcp.dto; +package in.koreatech.koin.mcp.dto.endpoint; import java.util.List; diff --git a/src/main/java/in/koreatech/koin/mcp/dto/FindEndpointsResponse.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/FindEndpointsResponse.java similarity index 68% rename from src/main/java/in/koreatech/koin/mcp/dto/FindEndpointsResponse.java rename to src/main/java/in/koreatech/koin/mcp/dto/endpoint/FindEndpointsResponse.java index 08c6cb5df..b6a092376 100644 --- a/src/main/java/in/koreatech/koin/mcp/dto/FindEndpointsResponse.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/FindEndpointsResponse.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.mcp.dto; +package in.koreatech.koin.mcp.dto.endpoint; import java.util.List; diff --git a/src/main/java/in/koreatech/koin/mcp/dto/ReplacedBy.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/ReplacedBy.java similarity index 60% rename from src/main/java/in/koreatech/koin/mcp/dto/ReplacedBy.java rename to src/main/java/in/koreatech/koin/mcp/dto/endpoint/ReplacedBy.java index ab5359b5f..06a0ae59e 100644 --- a/src/main/java/in/koreatech/koin/mcp/dto/ReplacedBy.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/ReplacedBy.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.mcp.dto; +package in.koreatech.koin.mcp.dto.endpoint; public record ReplacedBy( String method, diff --git a/src/main/java/in/koreatech/koin/mcp/dto/EndpointParameter.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointParameter.java similarity index 54% rename from src/main/java/in/koreatech/koin/mcp/dto/EndpointParameter.java rename to src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointParameter.java index 0607987b8..486c2bcc9 100644 --- a/src/main/java/in/koreatech/koin/mcp/dto/EndpointParameter.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointParameter.java @@ -1,4 +1,6 @@ -package in.koreatech.koin.mcp.dto; +package in.koreatech.koin.mcp.dto.endpoint.request; + +import in.koreatech.koin.mcp.dto.schema.EndpointSchema; public record EndpointParameter( String name, diff --git a/src/main/java/in/koreatech/koin/mcp/dto/EndpointParameters.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointParameters.java similarity index 76% rename from src/main/java/in/koreatech/koin/mcp/dto/EndpointParameters.java rename to src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointParameters.java index f2296442b..de5774790 100644 --- a/src/main/java/in/koreatech/koin/mcp/dto/EndpointParameters.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointParameters.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.mcp.dto; +package in.koreatech.koin.mcp.dto.endpoint.request; import java.util.List; diff --git a/src/main/java/in/koreatech/koin/mcp/dto/EndpointRequestSpec.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointRequestSpec.java similarity index 75% rename from src/main/java/in/koreatech/koin/mcp/dto/EndpointRequestSpec.java rename to src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointRequestSpec.java index 93cde07da..687c67597 100644 --- a/src/main/java/in/koreatech/koin/mcp/dto/EndpointRequestSpec.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointRequestSpec.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.mcp.dto; +package in.koreatech.koin.mcp.dto.endpoint.request; public record EndpointRequestSpec( String group, diff --git a/src/main/java/in/koreatech/koin/mcp/dto/RequestBodySpec.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/RequestBodySpec.java similarity index 56% rename from src/main/java/in/koreatech/koin/mcp/dto/RequestBodySpec.java rename to src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/RequestBodySpec.java index f5c811ce9..657c8b0bf 100644 --- a/src/main/java/in/koreatech/koin/mcp/dto/RequestBodySpec.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/RequestBodySpec.java @@ -1,7 +1,9 @@ -package in.koreatech.koin.mcp.dto; +package in.koreatech.koin.mcp.dto.endpoint.request; import java.util.List; +import in.koreatech.koin.mcp.dto.schema.EndpointSchema; + public record RequestBodySpec( boolean required, List contentTypes, diff --git a/src/main/java/in/koreatech/koin/mcp/dto/EndpointResponse.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/response/EndpointResponse.java similarity index 70% rename from src/main/java/in/koreatech/koin/mcp/dto/EndpointResponse.java rename to src/main/java/in/koreatech/koin/mcp/dto/endpoint/response/EndpointResponse.java index 731deee5e..89037f2d2 100644 --- a/src/main/java/in/koreatech/koin/mcp/dto/EndpointResponse.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/response/EndpointResponse.java @@ -1,13 +1,14 @@ -package in.koreatech.koin.mcp.dto; +package in.koreatech.koin.mcp.dto.endpoint.response; import java.util.List; import com.fasterxml.jackson.annotation.JsonInclude; +import in.koreatech.koin.mcp.dto.schema.EndpointSchema; + @JsonInclude(JsonInclude.Include.NON_EMPTY) public record EndpointResponse( String status, - String code, String description, List contentTypes, EndpointSchema schema diff --git a/src/main/java/in/koreatech/koin/mcp/dto/EndpointResponseSpec.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/response/EndpointResponseSpec.java similarity index 74% rename from src/main/java/in/koreatech/koin/mcp/dto/EndpointResponseSpec.java rename to src/main/java/in/koreatech/koin/mcp/dto/endpoint/response/EndpointResponseSpec.java index 7e7a65805..9d19ca078 100644 --- a/src/main/java/in/koreatech/koin/mcp/dto/EndpointResponseSpec.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/response/EndpointResponseSpec.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.mcp.dto; +package in.koreatech.koin.mcp.dto.endpoint.response; import java.util.List; diff --git a/src/main/java/in/koreatech/koin/mcp/dto/error/McpError.java b/src/main/java/in/koreatech/koin/mcp/dto/error/McpError.java new file mode 100644 index 000000000..7aa482f3e --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/error/McpError.java @@ -0,0 +1,17 @@ +package in.koreatech.koin.mcp.dto.error; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import in.koreatech.koin.mcp.dto.endpoint.EndpointCandidate; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record McpError( + String code, + String message, + Map details, + List candidates +) { +} diff --git a/src/main/java/in/koreatech/koin/mcp/dto/error/McpErrorResponse.java b/src/main/java/in/koreatech/koin/mcp/dto/error/McpErrorResponse.java new file mode 100644 index 000000000..03a3c6145 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/error/McpErrorResponse.java @@ -0,0 +1,15 @@ +package in.koreatech.koin.mcp.dto.error; + +import in.koreatech.koin.mcp.exception.EndpointSpecException; + +public record McpErrorResponse(McpError error) { + + public static McpErrorResponse from(EndpointSpecException exception) { + return new McpErrorResponse(new McpError( + exception.getCode(), + exception.getMessage(), + exception.getDetails(), + exception.getCandidates() + )); + } +} diff --git a/src/main/java/in/koreatech/koin/mcp/dto/EndpointSchema.java b/src/main/java/in/koreatech/koin/mcp/dto/schema/EndpointSchema.java similarity index 94% rename from src/main/java/in/koreatech/koin/mcp/dto/EndpointSchema.java rename to src/main/java/in/koreatech/koin/mcp/dto/schema/EndpointSchema.java index 94405a22e..c1cef3bb3 100644 --- a/src/main/java/in/koreatech/koin/mcp/dto/EndpointSchema.java +++ b/src/main/java/in/koreatech/koin/mcp/dto/schema/EndpointSchema.java @@ -1,4 +1,4 @@ -package in.koreatech.koin.mcp.dto; +package in.koreatech.koin.mcp.dto.schema; import java.util.List; import java.util.Map; diff --git a/src/main/java/in/koreatech/koin/mcp/exception/EndpointSpecException.java b/src/main/java/in/koreatech/koin/mcp/exception/EndpointSpecException.java index 5972f8066..995e66d34 100644 --- a/src/main/java/in/koreatech/koin/mcp/exception/EndpointSpecException.java +++ b/src/main/java/in/koreatech/koin/mcp/exception/EndpointSpecException.java @@ -1,14 +1,35 @@ package in.koreatech.koin.mcp.exception; +import java.util.List; +import java.util.Map; + +import in.koreatech.koin.mcp.dto.endpoint.EndpointCandidate; import lombok.Getter; @Getter public class EndpointSpecException extends RuntimeException { private final String code; + private final Map details; + private final transient List candidates; public EndpointSpecException(String code, String message) { + this(code, message, Map.of(), List.of()); + } + + public EndpointSpecException(String code, String message, Map details) { + this(code, message, details, List.of()); + } + + public EndpointSpecException( + String code, + String message, + Map details, + List candidates + ) { super(message); this.code = code; + this.details = details; + this.candidates = candidates; } } diff --git a/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java b/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java index 1841a6fb7..41804d825 100644 --- a/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java @@ -26,23 +26,25 @@ import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import in.koreatech.koin.global.auth.Auth; -import in.koreatech.koin.global.code.ApiResponseCode; -import in.koreatech.koin.global.code.ApiResponseCodes; import in.koreatech.koin.global.code.Deprecation; -import in.koreatech.koin.mcp.dto.EndpointDescription; -import in.koreatech.koin.mcp.dto.EndpointParameter; -import in.koreatech.koin.mcp.dto.EndpointParameters; -import in.koreatech.koin.mcp.dto.EndpointRequestSpec; -import in.koreatech.koin.mcp.dto.EndpointResponse; -import in.koreatech.koin.mcp.dto.EndpointResponseSpec; -import in.koreatech.koin.mcp.dto.EndpointSchema; -import in.koreatech.koin.mcp.dto.EndpointSummary; -import in.koreatech.koin.mcp.dto.FindEndpointsResponse; -import in.koreatech.koin.mcp.dto.ReplacedBy; -import in.koreatech.koin.mcp.dto.RequestBodySpec; +import in.koreatech.koin.global.exception.ErrorResponse; +import in.koreatech.koin.mcp.dto.endpoint.EndpointCandidate; +import in.koreatech.koin.mcp.dto.endpoint.EndpointDescription; +import in.koreatech.koin.mcp.dto.endpoint.EndpointSummary; +import in.koreatech.koin.mcp.dto.endpoint.FindEndpointsResponse; +import in.koreatech.koin.mcp.dto.endpoint.ReplacedBy; +import in.koreatech.koin.mcp.dto.endpoint.request.EndpointParameter; +import in.koreatech.koin.mcp.dto.endpoint.request.EndpointParameters; +import in.koreatech.koin.mcp.dto.endpoint.request.EndpointRequestSpec; +import in.koreatech.koin.mcp.dto.endpoint.request.RequestBodySpec; +import in.koreatech.koin.mcp.dto.endpoint.response.EndpointResponse; +import in.koreatech.koin.mcp.dto.endpoint.response.EndpointResponseSpec; +import in.koreatech.koin.mcp.dto.schema.EndpointSchema; import in.koreatech.koin.mcp.exception.EndpointSpecException; import in.koreatech.koin.mcp.model.DeprecatedFilter; import in.koreatech.koin.mcp.model.EndpointEntry; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.converter.ResolvedSchema; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.models.OpenAPI; @@ -61,6 +63,7 @@ public class EndpointSpecService { private static final String ROOT_PACKAGE = "in.koreatech.koin"; private static final int SCHEMA_MAX_DEPTH = 5; + private static final String APPLICATION_JSON = "application/json"; private final RequestMappingHandlerMapping handlerMapping; private final List groupedOpenApis; @@ -103,7 +106,7 @@ public EndpointDescription getEndpointDescription(String group, String method, S Deprecation deprecation = entry.deprecation(); return new EndpointDescription( - entry.group(), + displayGroup(entry.group()), entry.method(), entry.path(), operationId(operation), @@ -111,12 +114,10 @@ public EndpointDescription getEndpointDescription(String group, String method, S operation == null ? "" : nullToEmpty(operation.getDescription()), entry.tags(), entry.deprecated(), - deprecation == null ? "" : deprecation.since(), - deprecation == null ? "" : deprecation.reason(), + deprecation == null ? null : deprecation.reason(), deprecation == null || deprecation.replacedByMethod().isBlank() && deprecation.replacedByPath().isBlank() ? null : new ReplacedBy(deprecation.replacedByMethod(), deprecation.replacedByPath()), - deprecation != null && deprecation.forRemoval(), entry.authRequired() ); } @@ -139,7 +140,7 @@ public EndpointRequestSpec getEndpointRequestSpec(String group, String method, S } return new EndpointRequestSpec( - entry.group(), + displayGroup(entry.group()), entry.method(), entry.path(), new EndpointParameters(pathParameters, queryParameters, headerParameters), @@ -153,11 +154,11 @@ public EndpointResponseSpec getEndpointResponseSpec(String group, String method, Operation operation = openApiOperation(openAPI, entry); return new EndpointResponseSpec( - entry.group(), + displayGroup(entry.group()), entry.method(), entry.path(), nullToEmpty(operation.getResponses()).entrySet().stream() - .map(response -> toEndpointResponse(response.getKey(), response.getValue(), openAPI, entry.docsMethod())) + .map(response -> toEndpointResponse(response.getKey(), response.getValue(), openAPI)) .toList() ); } @@ -165,15 +166,12 @@ public EndpointResponseSpec getEndpointResponseSpec(String group, String method, private EndpointResponse toEndpointResponse( String status, ApiResponse apiResponse, - OpenAPI openAPI, - Method method + OpenAPI openAPI ) { String responseStatus = responseStatus(status); - String responseCode = responseCode(status, method); if ("204".equals(responseStatus)) { return new EndpointResponse( responseStatus, - responseCode, nullToEmpty(apiResponse.getDescription()), List.of(), null @@ -181,10 +179,9 @@ private EndpointResponse toEndpointResponse( } return new EndpointResponse( responseStatus, - responseCode, nullToEmpty(apiResponse.getDescription()), - contentTypes(apiResponse.getContent()), - firstContentSchema(apiResponse.getContent(), openAPI) + responseContentTypes(apiResponse.getContent(), responseStatus), + responseSchema(apiResponse.getContent(), openAPI, responseStatus) ); } @@ -193,26 +190,24 @@ private String responseStatus(String status) { return lastSpace == -1 ? status : status.substring(lastSpace + 1); } - private String responseCode(String status, Method method) { - ApiResponseCodes apiResponseCodes = AnnotatedElementUtils.findMergedAnnotation(method, ApiResponseCodes.class); - int responseIndex = responseIndex(status); - if (apiResponseCodes == null || responseIndex < 0 || responseIndex >= apiResponseCodes.value().length) { - return null; + private List responseContentTypes(Content content, String responseStatus) { + List contentTypes = contentTypes(content); + if (contentTypes.isEmpty() && isErrorStatus(responseStatus)) { + return List.of(APPLICATION_JSON); } - ApiResponseCode apiResponseCode = apiResponseCodes.value()[responseIndex]; - return apiResponseCode.getCode(); + return contentTypes; } - private int responseIndex(String status) { - int delimiterIndex = status.indexOf(')'); - if (delimiterIndex == -1) { - return -1; - } - try { - return Integer.parseInt(status.substring(0, delimiterIndex)) - 1; - } catch (NumberFormatException ignored) { - return -1; + private EndpointSchema responseSchema(Content content, OpenAPI openAPI, String responseStatus) { + EndpointSchema schema = firstContentSchema(content, openAPI); + if (schema == null && isErrorStatus(responseStatus)) { + return errorResponseSchema(); } + return schema; + } + + private boolean isErrorStatus(String status) { + return !status.isBlank() && status.charAt(0) != '2'; } private EndpointParameter toEndpointParameter(Parameter parameter, OpenAPI openAPI) { @@ -237,7 +232,12 @@ private RequestBodySpec toRequestBodySpec(RequestBody requestBody, OpenAPI openA } private List contentTypes(Content content) { - return content == null ? List.of() : List.copyOf(content.keySet()); + if (content == null) { + return List.of(); + } + return content.keySet().stream() + .map(contentType -> "*/*".equals(contentType) ? APPLICATION_JSON : contentType) + .toList(); } private EndpointSchema firstContentSchema(Content content, OpenAPI openAPI) { @@ -299,7 +299,7 @@ private Operation openApiOperation(String group, String method, String path) { private EndpointSummary toSummary(EndpointEntry entry) { Operation operation = entry.operation(); return new EndpointSummary( - entry.group(), + displayGroup(entry.group()), entry.method(), entry.path(), operationId(operation), @@ -328,14 +328,39 @@ private EndpointEntry findEndpoint(String group, String method, String path) { .toList(); if (matches.isEmpty()) { - throw new EndpointSpecException("ENDPOINT_NOT_FOUND", "No endpoint found."); + throw new EndpointSpecException( + "ENDPOINT_NOT_FOUND", + "No endpoint found.", + endpointDetails(group, normalizedMethod, path) + ); } if (matches.size() > 1) { - throw new EndpointSpecException("AMBIGUOUS_ENDPOINT", "Multiple endpoints found. Please specify group."); + throw new EndpointSpecException( + "AMBIGUOUS_ENDPOINT", + "Multiple endpoints found. Please specify group.", + Map.of(), + matches.stream() + .map(this::toCandidate) + .toList() + ); } return matches.get(0); } + private Map endpointDetails(String group, String method, String path) { + Map details = new LinkedHashMap<>(); + if (group != null && !group.isBlank()) { + details.put("group", displayGroup(group)); + } + details.put("method", method); + details.put("path", path); + return details; + } + + private EndpointCandidate toCandidate(EndpointEntry entry) { + return new EndpointCandidate(displayGroup(entry.group()), entry.method(), entry.path()); + } + private boolean matchesQuery(EndpointEntry entry, String query) { if (query == null) { return true; @@ -514,6 +539,10 @@ private boolean matchesGroupFilter(String actualGroup, String requestedGroup) { || actualGroup.equalsIgnoreCase(requestedGroup); } + private String displayGroup(String group) { + return normalizeGroup(group); + } + private EndpointSchema toEndpointSchema(Schema schema) { if (schema == null) { return null; @@ -569,28 +598,33 @@ private Boolean isTruncated(Schema schema) { return Boolean.TRUE.equals(truncated) ? true : null; } - @SuppressWarnings({"rawtypes"}) private Schema resolveRefs( Schema schema, Map referencedSchemas, Set resolvingRefs, int depth ) { - if (schema == null || depth > SCHEMA_MAX_DEPTH) { + if (schema == null) { return schema; } + if (depth > SCHEMA_MAX_DEPTH) { + return truncatedSchema(schema); + } String ref = schema.get$ref(); - if (ref != null && !ref.isBlank() && referencedSchemas != null) { + if (ref != null && !ref.isBlank()) { String refName = ref.substring(ref.lastIndexOf('/') + 1); + if (referencedSchemas == null) { + return fallbackSchema(refName); + } if (!resolvingRefs.add(refName)) { - Schema truncatedSchema = new Schema<>().$ref(ref); + Schema truncatedSchema = fallbackSchema(refName); truncatedSchema.addExtension("x-truncated", true); return truncatedSchema; } Schema referencedSchema = referencedSchemas.get(refName); Schema resolved = referencedSchema == null - ? schema + ? fallbackSchema(refName) : resolveRefs(referencedSchema, referencedSchemas, resolvingRefs, depth + 1); resolvingRefs.remove(refName); return resolved; @@ -613,6 +647,21 @@ private Schema resolveRefs( return schema; } + private Schema truncatedSchema(Schema schema) { + String ref = schema.get$ref(); + if (ref != null && !ref.isBlank()) { + Schema fallback = fallbackSchema(ref.substring(ref.lastIndexOf('/') + 1)); + fallback.addExtension("x-truncated", true); + return fallback; + } + Schema truncated = new Schema<>() + .type(schema.getType() == null ? "object" : schema.getType()) + .format(schema.getFormat()) + .description(schema.getDescription()); + truncated.addExtension("x-truncated", true); + return truncated; + } + private List resolveSchemaList( List schemas, Map referencedSchemas, @@ -627,6 +676,38 @@ private List resolveSchemaList( .toList(); } + private EndpointSchema errorResponseSchema() { + ResolvedSchema resolvedSchema = ModelConverters.getInstance() + .readAllAsResolvedSchema(ErrorResponse.class); + if (resolvedSchema == null || resolvedSchema.schema == null) { + return null; + } + return toEndpointSchema(resolveRefs( + resolvedSchema.schema, + resolvedSchema.referencedSchemas, + new HashSet<>(), + 0 + )); + } + + private Schema fallbackSchema(String refName) { + if (refName.endsWith("LocalTime")) { + return new Schema<>().type("string").format("time"); + } + if (refName.endsWith("LocalDate")) { + return new Schema<>().type("string").format("date"); + } + if (refName.endsWith("LocalDateTime") + || refName.endsWith("OffsetDateTime") + || refName.endsWith("ZonedDateTime")) { + return new Schema<>().type("string").format("date-time"); + } + if (refName.endsWith("UUID")) { + return new Schema<>().type("string").format("uuid"); + } + return new Schema<>().type("object").description("Unresolved schema: " + refName); + } + private String operationId(Operation operation) { return operation == null ? "" : nullToEmpty(operation.getOperationId()); } diff --git a/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java b/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java index 3d32fd956..fe2ceea90 100644 --- a/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java +++ b/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java @@ -1,16 +1,14 @@ package in.koreatech.koin.mcp.tool; import java.util.Locale; +import java.util.function.Supplier; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -import in.koreatech.koin.mcp.dto.EndpointDescription; -import in.koreatech.koin.mcp.dto.EndpointRequestSpec; -import in.koreatech.koin.mcp.dto.EndpointResponseSpec; -import in.koreatech.koin.mcp.dto.FindEndpointsResponse; +import in.koreatech.koin.mcp.dto.error.McpErrorResponse; import in.koreatech.koin.mcp.exception.EndpointSpecException; import in.koreatech.koin.mcp.model.DeprecatedFilter; import in.koreatech.koin.mcp.service.EndpointSpecService; @@ -26,39 +24,47 @@ public EndpointSpecTools(EndpointSpecService endpointSpecService) { } @Tool(description = "Find KOIN API endpoints by keyword, or list all endpoints when query is omitted. This is read-only and never sends API requests.") - public FindEndpointsResponse find_endpoints( + public Object find_endpoints( @ToolParam(description = "Optional keyword matched against path, method, group, operation id, summary, description, and tags.", required = false) String query, @ToolParam(description = "Optional endpoint group. Exact group names and normalized names like 'business' are supported.", required = false) String group, @ToolParam(description = "Deprecated endpoint filter. Use 'exclude' by default, 'include' to include deprecated endpoints, or 'only' to return deprecated endpoints only.", required = false) String deprecated ) { - return endpointSpecService.findEndpoints(query, group, parseDeprecatedFilter(deprecated)); + return handle(() -> endpointSpecService.findEndpoints(query, group, parseDeprecatedFilter(deprecated))); } @Tool(description = "Get endpoint description metadata excluding request and response body details. This is read-only and never sends API requests.") - public EndpointDescription get_endpoint_description( - @ToolParam(description = "Endpoint group from find_endpoints. Required when the same method and path exist in multiple groups.") String group, + public Object get_endpoint_description( + @ToolParam(description = "Optional endpoint group from find_endpoints. Required only when the same method and path exist in multiple groups.", required = false) String group, @ToolParam(description = "HTTP method, such as GET, POST, PUT, PATCH, or DELETE.") String method, @ToolParam(description = "Endpoint path, such as /v2/shops/{id}.") String path ) { - return endpointSpecService.getEndpointDescription(group, method, path); + return handle(() -> endpointSpecService.getEndpointDescription(group, method, path)); } @Tool(description = "Get endpoint request parameters and request body schema. This is read-only and never sends API requests.") - public EndpointRequestSpec get_endpoint_request_spec( - @ToolParam(description = "Endpoint group from find_endpoints. Required when the same method and path exist in multiple groups.") String group, + public Object get_endpoint_request_spec( + @ToolParam(description = "Optional endpoint group from find_endpoints. Required only when the same method and path exist in multiple groups.", required = false) String group, @ToolParam(description = "HTTP method, such as GET, POST, PUT, PATCH, or DELETE.") String method, @ToolParam(description = "Endpoint path, such as /v2/shops/{id}.") String path ) { - return endpointSpecService.getEndpointRequestSpec(group, method, path); + return handle(() -> endpointSpecService.getEndpointRequestSpec(group, method, path)); } @Tool(description = "Get endpoint response status codes and response body schemas paired by status code. This is read-only and never sends API requests.") - public EndpointResponseSpec get_endpoint_response_spec( - @ToolParam(description = "Endpoint group from find_endpoints. Required when the same method and path exist in multiple groups.") String group, + public Object get_endpoint_response_spec( + @ToolParam(description = "Optional endpoint group from find_endpoints. Required only when the same method and path exist in multiple groups.", required = false) String group, @ToolParam(description = "HTTP method, such as GET, POST, PUT, PATCH, or DELETE.") String method, @ToolParam(description = "Endpoint path, such as /v2/shops/{id}.") String path ) { - return endpointSpecService.getEndpointResponseSpec(group, method, path); + return handle(() -> endpointSpecService.getEndpointResponseSpec(group, method, path)); + } + + private Object handle(Supplier supplier) { + try { + return supplier.get(); + } catch (EndpointSpecException exception) { + return McpErrorResponse.from(exception); + } } private DeprecatedFilter parseDeprecatedFilter(String deprecated) { From 5c44adbade3174748aa7f91644a7dfd3b7a3f826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= Date: Tue, 19 May 2026 15:16:29 +0900 Subject: [PATCH 10/13] refactor: split endpoint spec service --- .../koin/mcp/service/EndpointCatalog.java | 347 ++++++++++ .../mcp/service/EndpointSchemaMapper.java | 276 ++++++++ .../koin/mcp/service/EndpointSpecService.java | 606 ++---------------- 3 files changed, 660 insertions(+), 569 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/mcp/service/EndpointCatalog.java create mode 100644 src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java diff --git a/src/main/java/in/koreatech/koin/mcp/service/EndpointCatalog.java b/src/main/java/in/koreatech/koin/mcp/service/EndpointCatalog.java new file mode 100644 index 000000000..369b7a748 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointCatalog.java @@ -0,0 +1,347 @@ +package in.koreatech.koin.mcp.service; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.code.Deprecation; +import in.koreatech.koin.mcp.dto.endpoint.EndpointCandidate; +import in.koreatech.koin.mcp.exception.EndpointSpecException; +import in.koreatech.koin.mcp.model.DeprecatedFilter; +import in.koreatech.koin.mcp.model.EndpointEntry; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; + +@Component +@ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") +public class EndpointCatalog { + + private static final String ROOT_PACKAGE = "in.koreatech.koin"; + + private final RequestMappingHandlerMapping handlerMapping; + private final List groupedOpenApis; + private final McpOpenApiProvider openApiProvider; + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + public EndpointCatalog( + @Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping, + List groupedOpenApis, + McpOpenApiProvider openApiProvider + ) { + this.handlerMapping = handlerMapping; + this.groupedOpenApis = groupedOpenApis; + this.openApiProvider = openApiProvider; + } + + public List findAll(String query, String group, DeprecatedFilter deprecated) { + String normalizedGroup = normalize(group); + String normalizedQuery = normalize(query); + DeprecatedFilter filter = deprecated == null ? DeprecatedFilter.EXCLUDE : deprecated; + + return entries().stream() + .filter(entry -> normalizedGroup == null || matchesGroupFilter(entry.group(), normalizedGroup)) + .filter(entry -> matchesDeprecatedFilter(entry.deprecated(), filter)) + .filter(entry -> matchesQuery(entry, normalizedQuery)) + .toList(); + } + + public EndpointEntry findEndpoint(String group, String method, String path) { + if (method == null || method.isBlank()) { + throw new EndpointSpecException("METHOD_REQUIRED", "method is required."); + } + if (path == null || path.isBlank()) { + throw new EndpointSpecException("PATH_REQUIRED", "path is required."); + } + String normalizedGroup = normalize(group); + String normalizedMethod = method.toUpperCase(Locale.ROOT); + + List matches = entries().stream() + .filter(entry -> normalizedGroup == null || matchesGroupFilter(entry.group(), normalizedGroup)) + .filter(entry -> entry.method().equals(normalizedMethod)) + .filter(entry -> entry.path().equals(path)) + .toList(); + + if (matches.isEmpty()) { + throw new EndpointSpecException( + "ENDPOINT_NOT_FOUND", + "No endpoint found.", + endpointDetails(group, normalizedMethod, path) + ); + } + if (matches.size() > 1) { + throw new EndpointSpecException( + "AMBIGUOUS_ENDPOINT", + "Multiple endpoints found. Please specify group.", + Map.of(), + matches.stream() + .map(this::toCandidate) + .toList() + ); + } + return matches.get(0); + } + + public String displayGroup(String group) { + return normalizeGroup(group); + } + + private List entries() { + List entries = new ArrayList<>(); + handlerMapping.getHandlerMethods().forEach((info, handlerMethod) -> { + if (!handlerMethod.getBeanType().getPackageName().startsWith(ROOT_PACKAGE)) { + return; + } + Method docsMethod = findDocsMethod(handlerMethod); + Deprecation deprecation = findDeprecation(docsMethod); + + for (String path : paths(info)) { + for (String group : groupsOf(handlerMethod.getBeanType(), path)) { + for (String method : methods(info)) { + Operation operation = openApiOperation(group, method, path); + entries.add(new EndpointEntry( + group, + method, + path, + docsMethod, + operation, + operationTags(operation, handlerMethod.getBeanType(), docsMethod), + deprecation, + operation != null && Boolean.TRUE.equals(operation.getDeprecated()) || deprecation != null, + authRequired(docsMethod, operation) + )); + } + } + } + }); + return entries; + } + + private Operation openApiOperation(String group, String method, String path) { + OpenAPI openAPI; + try { + openAPI = openApiProvider.getOpenApi(group); + } catch (EndpointSpecException ignored) { + return null; + } + PathItem pathItem = openAPI.getPaths().get(path); + if (pathItem == null) { + return null; + } + return pathItem.readOperationsMap().get(PathItem.HttpMethod.valueOf(method)); + } + + private Method findDocsMethod(HandlerMethod handlerMethod) { + Method controllerMethod = handlerMethod.getMethod(); + for (Class apiInterface : handlerMethod.getBeanType().getInterfaces()) { + for (Method interfaceMethod : apiInterface.getMethods()) { + if (hasSameSignature(interfaceMethod, controllerMethod)) { + return interfaceMethod; + } + } + } + return controllerMethod; + } + + private Deprecation findDeprecation(Method method) { + return AnnotatedElementUtils.findMergedAnnotation(method, Deprecation.class); + } + + private List operationTags(Operation operation, Class beanType, Method method) { + return operation != null && operation.getTags() != null + ? operation.getTags() + : findTags(beanType, method); + } + + private List findTags(Class beanType, Method method) { + Set tags = new LinkedHashSet<>(); + Tag methodTag = AnnotatedElementUtils.findMergedAnnotation(method, Tag.class); + if (methodTag != null && !methodTag.name().isBlank()) { + tags.add(methodTag.name()); + } + for (Class apiInterface : beanType.getInterfaces()) { + Tag interfaceTag = AnnotatedElementUtils.findMergedAnnotation(apiInterface, Tag.class); + if (interfaceTag != null && !interfaceTag.name().isBlank()) { + tags.add(interfaceTag.name()); + } + } + Tag classTag = AnnotatedElementUtils.findMergedAnnotation(beanType, Tag.class); + if (classTag != null && !classTag.name().isBlank()) { + tags.add(classTag.name()); + } + return List.copyOf(tags); + } + + private boolean authRequired(Method method, Operation operation) { + boolean hasAuthParameter = Arrays.stream(method.getParameters()) + .anyMatch(parameter -> parameter.isAnnotationPresent(Auth.class)); + if (hasAuthParameter) { + return true; + } + if (operation != null && operation.getSecurity() != null && !operation.getSecurity().isEmpty()) { + return true; + } + return method.isAnnotationPresent(SecurityRequirement.class); + } + + private List paths(RequestMappingInfo info) { + if (info.getPathPatternsCondition() != null) { + return info.getPathPatternsCondition().getPatterns().stream() + .map(pattern -> pattern.getPatternString()) + .toList(); + } + if (info.getPatternsCondition() != null) { + return List.copyOf(info.getPatternsCondition().getPatterns()); + } + return List.of(); + } + + private List methods(RequestMappingInfo info) { + Set methods = info.getMethodsCondition().getMethods(); + if (methods.isEmpty()) { + return Arrays.stream(RequestMethod.values()).map(Enum::name).toList(); + } + return methods.stream().map(Enum::name).toList(); + } + + private List groupsOf(Class beanType, String path) { + String packageName = beanType.getPackageName(); + List matchedGroups = groupedOpenApis.stream() + .filter(groupedOpenApi -> matchesGroupedOpenApi(groupedOpenApi, packageName, path)) + .map(GroupedOpenApi::getGroup) + .toList(); + + return matchedGroups.isEmpty() ? List.of("unknown") : matchedGroups; + } + + private boolean matchesGroupedOpenApi(GroupedOpenApi groupedOpenApi, String packageName, String path) { + if (matchesAny(path, groupedOpenApi.getPathsToExclude())) { + return false; + } + if (startsWithAny(packageName, groupedOpenApi.getPackagesToExclude())) { + return false; + } + + boolean pathMatched = isEmpty(groupedOpenApi.getPathsToMatch()) + || matchesAny(path, groupedOpenApi.getPathsToMatch()); + boolean packageMatched = isEmpty(groupedOpenApi.getPackagesToScan()) + || startsWithAny(packageName, groupedOpenApi.getPackagesToScan()); + + return pathMatched && packageMatched; + } + + private boolean matchesAny(String path, List patterns) { + if (patterns == null || patterns.isEmpty()) { + return false; + } + return patterns.stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); + } + + private boolean startsWithAny(String packageName, List packagePrefixes) { + if (packagePrefixes == null || packagePrefixes.isEmpty()) { + return false; + } + return packagePrefixes.stream().anyMatch(packageName::startsWith); + } + + private boolean matchesGroupFilter(String actualGroup, String requestedGroup) { + return normalizeGroup(actualGroup).equals(requestedGroup) + || actualGroup.equalsIgnoreCase(requestedGroup); + } + + private boolean matchesQuery(EndpointEntry entry, String query) { + if (query == null) { + return true; + } + Operation operation = entry.operation(); + String haystack = String.join(" ", + entry.path(), + entry.method(), + entry.group(), + operationId(operation), + operation == null ? "" : nullToEmpty(operation.getSummary()), + operation == null ? "" : nullToEmpty(operation.getDescription()), + String.join(" ", entry.tags()) + ).toLowerCase(Locale.ROOT); + return haystack.contains(query.toLowerCase(Locale.ROOT)); + } + + private boolean matchesDeprecatedFilter(boolean deprecated, DeprecatedFilter filter) { + return switch (filter) { + case EXCLUDE -> !deprecated; + case INCLUDE -> true; + case ONLY -> deprecated; + }; + } + + private Map endpointDetails(String group, String method, String path) { + Map details = new LinkedHashMap<>(); + if (group != null && !group.isBlank()) { + details.put("group", displayGroup(group)); + } + details.put("method", method); + details.put("path", path); + return details; + } + + private EndpointCandidate toCandidate(EndpointEntry entry) { + return new EndpointCandidate(displayGroup(entry.group()), entry.method(), entry.path()); + } + + private boolean hasSameSignature(Method left, Method right) { + return left.getName().equals(right.getName()) + && Arrays.equals(left.getParameterTypes(), right.getParameterTypes()); + } + + private boolean isEmpty(List values) { + return values == null || values.isEmpty(); + } + + private String operationId(Operation operation) { + return operation == null ? "" : nullToEmpty(operation.getOperationId()); + } + + private String normalizeGroup(String value) { + String normalized = normalize(value); + if (normalized == null) { + return ""; + } + return normalized + .replaceFirst("^\\d+\\.\\s*", "") + .replaceFirst("\\s+api$", "") + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-)|(-$)", ""); + } + + private String normalize(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim().toLowerCase(Locale.ROOT); + } + + private String nullToEmpty(String value) { + return Objects.requireNonNullElse(value, ""); + } +} diff --git a/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java b/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java new file mode 100644 index 000000000..250981c2c --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java @@ -0,0 +1,276 @@ +package in.koreatech.koin.mcp.service; + +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.global.exception.ErrorResponse; +import in.koreatech.koin.mcp.dto.endpoint.request.EndpointParameter; +import in.koreatech.koin.mcp.dto.endpoint.request.RequestBodySpec; +import in.koreatech.koin.mcp.dto.schema.EndpointSchema; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.converter.ResolvedSchema; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.parameters.RequestBody; + +@Component +@ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") +public class EndpointSchemaMapper { + + private static final int SCHEMA_MAX_DEPTH = 5; + private static final String APPLICATION_JSON = "application/json"; + + public EndpointParameter toEndpointParameter(Parameter parameter, OpenAPI openAPI) { + Parameter resolvedParameter = resolveParameter(parameter, openAPI); + return new EndpointParameter( + resolvedParameter.getName(), + Boolean.TRUE.equals(resolvedParameter.getRequired()), + Objects.requireNonNullElse(resolvedParameter.getDescription(), ""), + toEndpointSchema(resolveSchema(resolvedParameter.getSchema(), openAPI)) + ); + } + + public RequestBodySpec toRequestBodySpec(RequestBody requestBody, OpenAPI openAPI) { + if (requestBody == null) { + return null; + } + return new RequestBodySpec( + Boolean.TRUE.equals(requestBody.getRequired()), + contentTypes(requestBody.getContent()), + firstContentSchema(requestBody.getContent(), openAPI) + ); + } + + public List contentTypes(Content content) { + if (content == null) { + return List.of(); + } + return content.keySet().stream() + .map(contentType -> "*/*".equals(contentType) ? APPLICATION_JSON : contentType) + .toList(); + } + + public EndpointSchema firstContentSchema(Content content, OpenAPI openAPI) { + if (content == null || content.isEmpty()) { + return null; + } + return content.values().stream() + .map(MediaType::getSchema) + .filter(Objects::nonNull) + .findFirst() + .map(schema -> toEndpointSchema(resolveSchema(schema, openAPI))) + .orElse(null); + } + + public EndpointSchema errorResponseSchema() { + ResolvedSchema resolvedSchema = ModelConverters.getInstance() + .readAllAsResolvedSchema(ErrorResponse.class); + if (resolvedSchema == null || resolvedSchema.schema == null) { + return null; + } + return toEndpointSchema(resolveRefs( + resolvedSchema.schema, + resolvedSchema.referencedSchemas, + new HashSet<>(), + 0 + )); + } + + private Parameter resolveParameter(Parameter parameter, OpenAPI openAPI) { + String ref = parameter.get$ref(); + if (ref == null || ref.isBlank() + || openAPI.getComponents() == null + || openAPI.getComponents().getParameters() == null) { + return parameter; + } + String name = ref.substring(ref.lastIndexOf('/') + 1); + return openAPI.getComponents().getParameters().getOrDefault(name, parameter); + } + + private Schema resolveSchema(Schema schema, OpenAPI openAPI) { + if (openAPI.getComponents() == null) { + return schema; + } + return resolveRefs(schema, openAPI.getComponents().getSchemas(), new HashSet<>(), 0); + } + + private EndpointSchema toEndpointSchema(Schema schema) { + if (schema == null) { + return null; + } + + Map properties = null; + if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { + Map convertedProperties = new LinkedHashMap<>(); + schema.getProperties().forEach((name, property) -> + convertedProperties.put(name, toEndpointSchema((Schema)property))); + properties = convertedProperties; + } + + EndpointSchema additionalProperties = null; + if (schema.getAdditionalProperties() instanceof Schema additionalPropertySchema) { + additionalProperties = toEndpointSchema(additionalPropertySchema); + } + + return new EndpointSchema( + schema.getType(), + schema.getFormat(), + schema.getDescription(), + schema.getExample(), + schema.getNullable(), + schema.getDeprecated(), + schema.getRequired(), + schema.getEnum(), + properties, + toEndpointSchema(schema.getItems()), + additionalProperties, + toEndpointSchemaList(schema.getAllOf()), + toEndpointSchemaList(schema.getOneOf()), + toEndpointSchemaList(schema.getAnyOf()), + schema.get$ref(), + isTruncated(schema) + ); + } + + private List toEndpointSchemaList(List schemas) { + if (schemas == null || schemas.isEmpty()) { + return Collections.emptyList(); + } + return schemas.stream() + .map(this::toEndpointSchema) + .toList(); + } + + private Boolean isTruncated(Schema schema) { + if (schema.getExtensions() == null) { + return null; + } + Object truncated = schema.getExtensions().get("x-truncated"); + return Boolean.TRUE.equals(truncated) ? true : null; + } + + private Schema resolveRefs( + Schema schema, + Map referencedSchemas, + Set resolvingRefs, + int depth + ) { + if (schema == null) { + return schema; + } + if (depth > SCHEMA_MAX_DEPTH) { + return truncatedSchema(schema); + } + + String ref = schema.get$ref(); + if (ref != null && !ref.isBlank()) { + return resolveRef(ref, referencedSchemas, resolvingRefs, depth); + } + + resolveChildSchemas(schema, referencedSchemas, resolvingRefs, depth); + return schema; + } + + private Schema resolveRef( + String ref, + Map referencedSchemas, + Set resolvingRefs, + int depth + ) { + String refName = ref.substring(ref.lastIndexOf('/') + 1); + if (referencedSchemas == null) { + return fallbackSchema(refName); + } + if (!resolvingRefs.add(refName)) { + Schema truncatedSchema = fallbackSchema(refName); + truncatedSchema.addExtension("x-truncated", true); + return truncatedSchema; + } + Schema referencedSchema = referencedSchemas.get(refName); + Schema resolved = referencedSchema == null + ? fallbackSchema(refName) + : resolveRefs(referencedSchema, referencedSchemas, resolvingRefs, depth + 1); + resolvingRefs.remove(refName); + return resolved; + } + + private void resolveChildSchemas( + Schema schema, + Map referencedSchemas, + Set resolvingRefs, + int depth + ) { + if (schema.getProperties() != null) { + schema.getProperties().replaceAll((name, property) -> + resolveRefs(property, referencedSchemas, resolvingRefs, depth + 1)); + } + if (schema.getItems() != null) { + schema.setItems(resolveRefs(schema.getItems(), referencedSchemas, resolvingRefs, depth + 1)); + } + if (schema.getAdditionalProperties() instanceof Schema additionalProperties) { + schema.setAdditionalProperties( + resolveRefs(additionalProperties, referencedSchemas, resolvingRefs, depth + 1)); + } + schema.setAllOf(resolveSchemaList(schema.getAllOf(), referencedSchemas, resolvingRefs, depth)); + schema.setOneOf(resolveSchemaList(schema.getOneOf(), referencedSchemas, resolvingRefs, depth)); + schema.setAnyOf(resolveSchemaList(schema.getAnyOf(), referencedSchemas, resolvingRefs, depth)); + } + + private Schema truncatedSchema(Schema schema) { + String ref = schema.get$ref(); + if (ref != null && !ref.isBlank()) { + Schema fallback = fallbackSchema(ref.substring(ref.lastIndexOf('/') + 1)); + fallback.addExtension("x-truncated", true); + return fallback; + } + Schema truncated = new Schema<>() + .type(schema.getType() == null ? "object" : schema.getType()) + .format(schema.getFormat()) + .description(schema.getDescription()); + truncated.addExtension("x-truncated", true); + return truncated; + } + + private List resolveSchemaList( + List schemas, + Map referencedSchemas, + Set resolvingRefs, + int depth + ) { + if (schemas == null) { + return null; + } + return schemas.stream() + .map(schema -> (Schema)resolveRefs(schema, referencedSchemas, resolvingRefs, depth + 1)) + .toList(); + } + + private Schema fallbackSchema(String refName) { + if (refName.endsWith("LocalTime")) { + return new Schema<>().type("string").format("time"); + } + if (refName.endsWith("LocalDate")) { + return new Schema<>().type("string").format("date"); + } + if (refName.endsWith("LocalDateTime") + || refName.endsWith("OffsetDateTime") + || refName.endsWith("ZonedDateTime")) { + return new Schema<>().type("string").format("date-time"); + } + if (refName.endsWith("UUID")) { + return new Schema<>().type("string").format("uuid"); + } + return new Schema<>().type("object").description("Unresolved schema: " + refName); + } +} diff --git a/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java b/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java index 41804d825..ceb92e10f 100644 --- a/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java @@ -1,34 +1,15 @@ package in.koreatech.koin.mcp.service; -import java.lang.reflect.Method; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; import java.util.Comparator; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.Set; -import org.springdoc.core.models.GroupedOpenApi; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.stereotype.Service; -import org.springframework.util.AntPathMatcher; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.mvc.method.RequestMappingInfo; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; -import in.koreatech.koin.global.auth.Auth; import in.koreatech.koin.global.code.Deprecation; -import in.koreatech.koin.global.exception.ErrorResponse; -import in.koreatech.koin.mcp.dto.endpoint.EndpointCandidate; import in.koreatech.koin.mcp.dto.endpoint.EndpointDescription; import in.koreatech.koin.mcp.dto.endpoint.EndpointSummary; import in.koreatech.koin.mcp.dto.endpoint.FindEndpointsResponse; @@ -36,59 +17,41 @@ import in.koreatech.koin.mcp.dto.endpoint.request.EndpointParameter; import in.koreatech.koin.mcp.dto.endpoint.request.EndpointParameters; import in.koreatech.koin.mcp.dto.endpoint.request.EndpointRequestSpec; -import in.koreatech.koin.mcp.dto.endpoint.request.RequestBodySpec; import in.koreatech.koin.mcp.dto.endpoint.response.EndpointResponse; import in.koreatech.koin.mcp.dto.endpoint.response.EndpointResponseSpec; import in.koreatech.koin.mcp.dto.schema.EndpointSchema; import in.koreatech.koin.mcp.exception.EndpointSpecException; import in.koreatech.koin.mcp.model.DeprecatedFilter; import in.koreatech.koin.mcp.model.EndpointEntry; -import io.swagger.v3.core.converter.ModelConverters; -import io.swagger.v3.core.converter.ResolvedSchema; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.oas.models.media.Content; -import io.swagger.v3.oas.models.media.MediaType; -import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.Parameter; -import io.swagger.v3.oas.models.parameters.RequestBody; import io.swagger.v3.oas.models.responses.ApiResponse; @Service @ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") public class EndpointSpecService { - private static final String ROOT_PACKAGE = "in.koreatech.koin"; - private static final int SCHEMA_MAX_DEPTH = 5; private static final String APPLICATION_JSON = "application/json"; - private final RequestMappingHandlerMapping handlerMapping; - private final List groupedOpenApis; + private final EndpointCatalog endpointCatalog; private final McpOpenApiProvider openApiProvider; - private final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final EndpointSchemaMapper schemaMapper; public EndpointSpecService( - @Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping, - List groupedOpenApis, - McpOpenApiProvider openApiProvider + EndpointCatalog endpointCatalog, + McpOpenApiProvider openApiProvider, + EndpointSchemaMapper schemaMapper ) { - this.handlerMapping = handlerMapping; - this.groupedOpenApis = groupedOpenApis; + this.endpointCatalog = endpointCatalog; this.openApiProvider = openApiProvider; + this.schemaMapper = schemaMapper; } public FindEndpointsResponse findEndpoints(String query, String group, DeprecatedFilter deprecated) { - String normalizedGroup = normalize(group); - String normalizedQuery = normalize(query); - DeprecatedFilter filter = deprecated == null ? DeprecatedFilter.EXCLUDE : deprecated; - - List items = endpointEntries().stream() - .filter(entry -> normalizedGroup == null || matchesGroupFilter(entry.group(), normalizedGroup)) - .filter(entry -> matchesDeprecatedFilter(entry.deprecated(), filter)) - .filter(entry -> matchesQuery(entry, normalizedQuery)) + List items = endpointCatalog.findAll(query, group, deprecated).stream() .map(this::toSummary) .distinct() .sorted(Comparator @@ -101,12 +64,12 @@ public FindEndpointsResponse findEndpoints(String query, String group, Deprecate } public EndpointDescription getEndpointDescription(String group, String method, String path) { - EndpointEntry entry = findEndpoint(group, method, path); + EndpointEntry entry = endpointCatalog.findEndpoint(group, method, path); Operation operation = entry.operation(); Deprecation deprecation = entry.deprecation(); return new EndpointDescription( - displayGroup(entry.group()), + endpointCatalog.displayGroup(entry.group()), entry.method(), entry.path(), operationId(operation), @@ -123,15 +86,15 @@ public EndpointDescription getEndpointDescription(String group, String method, S } public EndpointRequestSpec getEndpointRequestSpec(String group, String method, String path) { - EndpointEntry entry = findEndpoint(group, method, path); - List pathParameters = new ArrayList<>(); - List queryParameters = new ArrayList<>(); - List headerParameters = new ArrayList<>(); + EndpointEntry entry = endpointCatalog.findEndpoint(group, method, path); OpenAPI openAPI = openApiProvider.getOpenApi(entry.group()); Operation operation = openApiOperation(openAPI, entry); + List pathParameters = new ArrayList<>(); + List queryParameters = new ArrayList<>(); + List headerParameters = new ArrayList<>(); for (Parameter parameter : nullToEmpty(operation.getParameters())) { - EndpointParameter endpointParameter = toEndpointParameter(parameter, openAPI); + EndpointParameter endpointParameter = schemaMapper.toEndpointParameter(parameter, openAPI); switch (nullToEmpty(parameter.getIn())) { case "path" -> pathParameters.add(endpointParameter); case "header" -> headerParameters.add(endpointParameter); @@ -140,21 +103,21 @@ public EndpointRequestSpec getEndpointRequestSpec(String group, String method, S } return new EndpointRequestSpec( - displayGroup(entry.group()), + endpointCatalog.displayGroup(entry.group()), entry.method(), entry.path(), new EndpointParameters(pathParameters, queryParameters, headerParameters), - toRequestBodySpec(operation.getRequestBody(), openAPI) + schemaMapper.toRequestBodySpec(operation.getRequestBody(), openAPI) ); } public EndpointResponseSpec getEndpointResponseSpec(String group, String method, String path) { - EndpointEntry entry = findEndpoint(group, method, path); + EndpointEntry entry = endpointCatalog.findEndpoint(group, method, path); OpenAPI openAPI = openApiProvider.getOpenApi(entry.group()); Operation operation = openApiOperation(openAPI, entry); return new EndpointResponseSpec( - displayGroup(entry.group()), + endpointCatalog.displayGroup(entry.group()), entry.method(), entry.path(), nullToEmpty(operation.getResponses()).entrySet().stream() @@ -185,91 +148,6 @@ private EndpointResponse toEndpointResponse( ); } - private String responseStatus(String status) { - int lastSpace = status.lastIndexOf(' '); - return lastSpace == -1 ? status : status.substring(lastSpace + 1); - } - - private List responseContentTypes(Content content, String responseStatus) { - List contentTypes = contentTypes(content); - if (contentTypes.isEmpty() && isErrorStatus(responseStatus)) { - return List.of(APPLICATION_JSON); - } - return contentTypes; - } - - private EndpointSchema responseSchema(Content content, OpenAPI openAPI, String responseStatus) { - EndpointSchema schema = firstContentSchema(content, openAPI); - if (schema == null && isErrorStatus(responseStatus)) { - return errorResponseSchema(); - } - return schema; - } - - private boolean isErrorStatus(String status) { - return !status.isBlank() && status.charAt(0) != '2'; - } - - private EndpointParameter toEndpointParameter(Parameter parameter, OpenAPI openAPI) { - Parameter resolvedParameter = resolveParameter(parameter, openAPI); - return new EndpointParameter( - resolvedParameter.getName(), - Boolean.TRUE.equals(resolvedParameter.getRequired()), - nullToEmpty(resolvedParameter.getDescription()), - toEndpointSchema(resolveSchema(resolvedParameter.getSchema(), openAPI)) - ); - } - - private RequestBodySpec toRequestBodySpec(RequestBody requestBody, OpenAPI openAPI) { - if (requestBody == null) { - return null; - } - return new RequestBodySpec( - Boolean.TRUE.equals(requestBody.getRequired()), - contentTypes(requestBody.getContent()), - firstContentSchema(requestBody.getContent(), openAPI) - ); - } - - private List contentTypes(Content content) { - if (content == null) { - return List.of(); - } - return content.keySet().stream() - .map(contentType -> "*/*".equals(contentType) ? APPLICATION_JSON : contentType) - .toList(); - } - - private EndpointSchema firstContentSchema(Content content, OpenAPI openAPI) { - if (content == null || content.isEmpty()) { - return null; - } - return content.values().stream() - .map(MediaType::getSchema) - .filter(Objects::nonNull) - .findFirst() - .map(schema -> toEndpointSchema(resolveSchema(schema, openAPI))) - .orElse(null); - } - - private Parameter resolveParameter(Parameter parameter, OpenAPI openAPI) { - String ref = parameter.get$ref(); - if (ref == null || ref.isBlank() - || openAPI.getComponents() == null - || openAPI.getComponents().getParameters() == null) { - return parameter; - } - String name = ref.substring(ref.lastIndexOf('/') + 1); - return openAPI.getComponents().getParameters().getOrDefault(name, parameter); - } - - private Schema resolveSchema(Schema schema, OpenAPI openAPI) { - if (openAPI.getComponents() == null) { - return schema; - } - return resolveRefs(schema, openAPI.getComponents().getSchemas(), new HashSet<>(), 0); - } - private Operation openApiOperation(OpenAPI openAPI, EndpointEntry entry) { PathItem pathItem = openAPI.getPaths().get(entry.path()); if (pathItem == null) { @@ -282,24 +160,26 @@ private Operation openApiOperation(OpenAPI openAPI, EndpointEntry entry) { return operation; } - private Operation openApiOperation(String group, String method, String path) { - OpenAPI openAPI; - try { - openAPI = openApiProvider.getOpenApi(group); - } catch (EndpointSpecException ignored) { - return null; + private List responseContentTypes(Content content, String responseStatus) { + List contentTypes = schemaMapper.contentTypes(content); + if (contentTypes.isEmpty() && isErrorStatus(responseStatus)) { + return List.of(APPLICATION_JSON); } - PathItem pathItem = openAPI.getPaths().get(path); - if (pathItem == null) { - return null; + return contentTypes; + } + + private EndpointSchema responseSchema(Content content, OpenAPI openAPI, String responseStatus) { + EndpointSchema schema = schemaMapper.firstContentSchema(content, openAPI); + if (schema == null && isErrorStatus(responseStatus)) { + return schemaMapper.errorResponseSchema(); } - return pathItem.readOperationsMap().get(PathItem.HttpMethod.valueOf(method)); + return schema; } private EndpointSummary toSummary(EndpointEntry entry) { Operation operation = entry.operation(); return new EndpointSummary( - displayGroup(entry.group()), + endpointCatalog.displayGroup(entry.group()), entry.method(), entry.path(), operationId(operation), @@ -311,431 +191,19 @@ private EndpointSummary toSummary(EndpointEntry entry) { ); } - private EndpointEntry findEndpoint(String group, String method, String path) { - if (method == null || method.isBlank()) { - throw new EndpointSpecException("METHOD_REQUIRED", "method is required."); - } - if (path == null || path.isBlank()) { - throw new EndpointSpecException("PATH_REQUIRED", "path is required."); - } - String normalizedGroup = normalize(group); - String normalizedMethod = method.toUpperCase(Locale.ROOT); - - List matches = endpointEntries().stream() - .filter(entry -> normalizedGroup == null || matchesGroupFilter(entry.group(), normalizedGroup)) - .filter(entry -> entry.method().equals(normalizedMethod)) - .filter(entry -> entry.path().equals(path)) - .toList(); - - if (matches.isEmpty()) { - throw new EndpointSpecException( - "ENDPOINT_NOT_FOUND", - "No endpoint found.", - endpointDetails(group, normalizedMethod, path) - ); - } - if (matches.size() > 1) { - throw new EndpointSpecException( - "AMBIGUOUS_ENDPOINT", - "Multiple endpoints found. Please specify group.", - Map.of(), - matches.stream() - .map(this::toCandidate) - .toList() - ); - } - return matches.get(0); - } - - private Map endpointDetails(String group, String method, String path) { - Map details = new LinkedHashMap<>(); - if (group != null && !group.isBlank()) { - details.put("group", displayGroup(group)); - } - details.put("method", method); - details.put("path", path); - return details; - } - - private EndpointCandidate toCandidate(EndpointEntry entry) { - return new EndpointCandidate(displayGroup(entry.group()), entry.method(), entry.path()); - } - - private boolean matchesQuery(EndpointEntry entry, String query) { - if (query == null) { - return true; - } - Operation operation = entry.operation(); - String haystack = String.join(" ", - entry.path(), - entry.method(), - entry.group(), - operationId(operation), - operation == null ? "" : nullToEmpty(operation.getSummary()), - operation == null ? "" : nullToEmpty(operation.getDescription()), - String.join(" ", entry.tags()) - ).toLowerCase(Locale.ROOT); - return haystack.contains(query.toLowerCase(Locale.ROOT)); - } - - private boolean matchesDeprecatedFilter(boolean deprecated, DeprecatedFilter filter) { - return switch (filter) { - case EXCLUDE -> !deprecated; - case INCLUDE -> true; - case ONLY -> deprecated; - }; - } - - private List endpointEntries() { - List entries = new ArrayList<>(); - handlerMapping.getHandlerMethods().forEach((info, handlerMethod) -> { - if (!handlerMethod.getBeanType().getPackageName().startsWith(ROOT_PACKAGE)) { - return; - } - Method docsMethod = findDocsMethod(handlerMethod); - Deprecation deprecation = findDeprecation(docsMethod); - - for (String path : paths(info)) { - for (String group : groupsOf(handlerMethod.getBeanType(), path)) { - for (String method : methods(info)) { - Operation operation = openApiOperation(group, method, path); - entries.add(new EndpointEntry( - group, - method, - path, - docsMethod, - operation, - operationTags(operation, handlerMethod.getBeanType(), docsMethod), - deprecation, - operation != null && Boolean.TRUE.equals(operation.getDeprecated()) || deprecation != null, - authRequired(docsMethod, operation) - )); - } - } - } - }); - return entries; - } - - private Method findDocsMethod(HandlerMethod handlerMethod) { - Method controllerMethod = handlerMethod.getMethod(); - for (Class apiInterface : handlerMethod.getBeanType().getInterfaces()) { - for (Method interfaceMethod : apiInterface.getMethods()) { - if (hasSameSignature(interfaceMethod, controllerMethod)) { - return interfaceMethod; - } - } - } - return controllerMethod; - } - - private Deprecation findDeprecation(Method method) { - return AnnotatedElementUtils.findMergedAnnotation(method, Deprecation.class); - } - - private List operationTags(Operation operation, Class beanType, Method method) { - return operation != null && operation.getTags() != null - ? operation.getTags() - : findTags(beanType, method); - } - - private List findTags(Class beanType, Method method) { - Set tags = new LinkedHashSet<>(); - Tag methodTag = AnnotatedElementUtils.findMergedAnnotation(method, Tag.class); - if (methodTag != null && !methodTag.name().isBlank()) { - tags.add(methodTag.name()); - } - for (Class apiInterface : beanType.getInterfaces()) { - Tag interfaceTag = AnnotatedElementUtils.findMergedAnnotation(apiInterface, Tag.class); - if (interfaceTag != null && !interfaceTag.name().isBlank()) { - tags.add(interfaceTag.name()); - } - } - Tag classTag = AnnotatedElementUtils.findMergedAnnotation(beanType, Tag.class); - if (classTag != null && !classTag.name().isBlank()) { - tags.add(classTag.name()); - } - return List.copyOf(tags); - } - - private boolean authRequired(Method method, Operation operation) { - boolean hasAuthParameter = Arrays.stream(method.getParameters()) - .anyMatch(parameter -> parameter.isAnnotationPresent(Auth.class)); - if (hasAuthParameter) { - return true; - } - if (operation != null && operation.getSecurity() != null && !operation.getSecurity().isEmpty()) { - return true; - } - return method.isAnnotationPresent(SecurityRequirement.class); - } - - private List paths(RequestMappingInfo info) { - if (info.getPathPatternsCondition() != null) { - return info.getPathPatternsCondition().getPatterns().stream() - .map(pattern -> pattern.getPatternString()) - .toList(); - } - if (info.getPatternsCondition() != null) { - return List.copyOf(info.getPatternsCondition().getPatterns()); - } - return List.of(); - } - - private List methods(RequestMappingInfo info) { - Set methods = info.getMethodsCondition().getMethods(); - if (methods.isEmpty()) { - return Arrays.stream(RequestMethod.values()).map(Enum::name).toList(); - } - return methods.stream().map(Enum::name).toList(); - } - - private List groupsOf(Class beanType, String path) { - String packageName = beanType.getPackageName(); - List matchedGroups = groupedOpenApis.stream() - .filter(groupedOpenApi -> matchesGroupedOpenApi(groupedOpenApi, packageName, path)) - .map(GroupedOpenApi::getGroup) - .toList(); - - return matchedGroups.isEmpty() ? List.of("unknown") : matchedGroups; - } - - private boolean matchesGroupedOpenApi(GroupedOpenApi groupedOpenApi, String packageName, String path) { - if (matchesAny(path, groupedOpenApi.getPathsToExclude())) { - return false; - } - if (startsWithAny(packageName, groupedOpenApi.getPackagesToExclude())) { - return false; - } - - boolean pathMatched = isEmpty(groupedOpenApi.getPathsToMatch()) - || matchesAny(path, groupedOpenApi.getPathsToMatch()); - boolean packageMatched = isEmpty(groupedOpenApi.getPackagesToScan()) - || startsWithAny(packageName, groupedOpenApi.getPackagesToScan()); - - return pathMatched && packageMatched; - } - - private boolean isEmpty(List values) { - return values == null || values.isEmpty(); - } - - private boolean matchesAny(String path, List patterns) { - if (patterns == null || patterns.isEmpty()) { - return false; - } - return patterns.stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); - } - - private boolean startsWithAny(String packageName, List packagePrefixes) { - if (packagePrefixes == null || packagePrefixes.isEmpty()) { - return false; - } - return packagePrefixes.stream().anyMatch(packageName::startsWith); - } - - private boolean matchesGroupFilter(String actualGroup, String requestedGroup) { - return normalizeGroup(actualGroup).equals(requestedGroup) - || actualGroup.equalsIgnoreCase(requestedGroup); - } - - private String displayGroup(String group) { - return normalizeGroup(group); - } - - private EndpointSchema toEndpointSchema(Schema schema) { - if (schema == null) { - return null; - } - - Map properties = null; - if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { - Map convertedProperties = new LinkedHashMap<>(); - schema.getProperties().forEach((name, property) -> - convertedProperties.put(name, toEndpointSchema((Schema)property))); - properties = convertedProperties; - } - - EndpointSchema additionalProperties = null; - if (schema.getAdditionalProperties() instanceof Schema additionalPropertySchema) { - additionalProperties = toEndpointSchema(additionalPropertySchema); - } - - return new EndpointSchema( - schema.getType(), - schema.getFormat(), - schema.getDescription(), - schema.getExample(), - schema.getNullable(), - schema.getDeprecated(), - schema.getRequired(), - schema.getEnum(), - properties, - toEndpointSchema(schema.getItems()), - additionalProperties, - toEndpointSchemaList(schema.getAllOf()), - toEndpointSchemaList(schema.getOneOf()), - toEndpointSchemaList(schema.getAnyOf()), - schema.get$ref(), - isTruncated(schema) - ); - } - - private List toEndpointSchemaList(List schemas) { - if (schemas == null || schemas.isEmpty()) { - return Collections.emptyList(); - } - return schemas.stream() - .map(this::toEndpointSchema) - .toList(); - } - - private Boolean isTruncated(Schema schema) { - if (schema.getExtensions() == null) { - return null; - } - Object truncated = schema.getExtensions().get("x-truncated"); - return Boolean.TRUE.equals(truncated) ? true : null; - } - - private Schema resolveRefs( - Schema schema, - Map referencedSchemas, - Set resolvingRefs, - int depth - ) { - if (schema == null) { - return schema; - } - if (depth > SCHEMA_MAX_DEPTH) { - return truncatedSchema(schema); - } - - String ref = schema.get$ref(); - if (ref != null && !ref.isBlank()) { - String refName = ref.substring(ref.lastIndexOf('/') + 1); - if (referencedSchemas == null) { - return fallbackSchema(refName); - } - if (!resolvingRefs.add(refName)) { - Schema truncatedSchema = fallbackSchema(refName); - truncatedSchema.addExtension("x-truncated", true); - return truncatedSchema; - } - Schema referencedSchema = referencedSchemas.get(refName); - Schema resolved = referencedSchema == null - ? fallbackSchema(refName) - : resolveRefs(referencedSchema, referencedSchemas, resolvingRefs, depth + 1); - resolvingRefs.remove(refName); - return resolved; - } - - if (schema.getProperties() != null) { - schema.getProperties().replaceAll((name, property) -> - resolveRefs(property, referencedSchemas, resolvingRefs, depth + 1)); - } - if (schema.getItems() != null) { - schema.setItems(resolveRefs(schema.getItems(), referencedSchemas, resolvingRefs, depth + 1)); - } - if (schema.getAdditionalProperties() instanceof Schema additionalProperties) { - schema.setAdditionalProperties( - resolveRefs(additionalProperties, referencedSchemas, resolvingRefs, depth + 1)); - } - schema.setAllOf(resolveSchemaList(schema.getAllOf(), referencedSchemas, resolvingRefs, depth)); - schema.setOneOf(resolveSchemaList(schema.getOneOf(), referencedSchemas, resolvingRefs, depth)); - schema.setAnyOf(resolveSchemaList(schema.getAnyOf(), referencedSchemas, resolvingRefs, depth)); - return schema; - } - - private Schema truncatedSchema(Schema schema) { - String ref = schema.get$ref(); - if (ref != null && !ref.isBlank()) { - Schema fallback = fallbackSchema(ref.substring(ref.lastIndexOf('/') + 1)); - fallback.addExtension("x-truncated", true); - return fallback; - } - Schema truncated = new Schema<>() - .type(schema.getType() == null ? "object" : schema.getType()) - .format(schema.getFormat()) - .description(schema.getDescription()); - truncated.addExtension("x-truncated", true); - return truncated; - } - - private List resolveSchemaList( - List schemas, - Map referencedSchemas, - Set resolvingRefs, - int depth - ) { - if (schemas == null) { - return null; - } - return schemas.stream() - .map(schema -> (Schema)resolveRefs(schema, referencedSchemas, resolvingRefs, depth + 1)) - .toList(); - } - - private EndpointSchema errorResponseSchema() { - ResolvedSchema resolvedSchema = ModelConverters.getInstance() - .readAllAsResolvedSchema(ErrorResponse.class); - if (resolvedSchema == null || resolvedSchema.schema == null) { - return null; - } - return toEndpointSchema(resolveRefs( - resolvedSchema.schema, - resolvedSchema.referencedSchemas, - new HashSet<>(), - 0 - )); + private String responseStatus(String status) { + int lastSpace = status.lastIndexOf(' '); + return lastSpace == -1 ? status : status.substring(lastSpace + 1); } - private Schema fallbackSchema(String refName) { - if (refName.endsWith("LocalTime")) { - return new Schema<>().type("string").format("time"); - } - if (refName.endsWith("LocalDate")) { - return new Schema<>().type("string").format("date"); - } - if (refName.endsWith("LocalDateTime") - || refName.endsWith("OffsetDateTime") - || refName.endsWith("ZonedDateTime")) { - return new Schema<>().type("string").format("date-time"); - } - if (refName.endsWith("UUID")) { - return new Schema<>().type("string").format("uuid"); - } - return new Schema<>().type("object").description("Unresolved schema: " + refName); + private boolean isErrorStatus(String status) { + return !status.isBlank() && status.charAt(0) != '2'; } private String operationId(Operation operation) { return operation == null ? "" : nullToEmpty(operation.getOperationId()); } - private boolean hasSameSignature(Method left, Method right) { - return left.getName().equals(right.getName()) - && Arrays.equals(left.getParameterTypes(), right.getParameterTypes()); - } - - private String normalize(String value) { - if (value == null || value.isBlank()) { - return null; - } - return value.trim().toLowerCase(Locale.ROOT); - } - - private String normalizeGroup(String value) { - String normalized = normalize(value); - if (normalized == null) { - return ""; - } - return normalized - .replaceFirst("^\\d+\\.\\s*", "") - .replaceFirst("\\s+api$", "") - .replaceAll("[^a-z0-9]+", "-") - .replaceAll("(^-)|(-$)", ""); - } - private String nullToEmpty(String value) { return Objects.requireNonNullElse(value, ""); } From 923dd844589ac55bed61bd10385d138a47734a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= Date: Tue, 19 May 2026 15:41:42 +0900 Subject: [PATCH 11/13] fix: resolve endpoint schema mapper warnings --- .../mcp/service/EndpointSchemaMapper.java | 61 ++++++++----------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java b/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java index 250981c2c..62803a400 100644 --- a/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java @@ -30,6 +30,8 @@ public class EndpointSchemaMapper { private static final int SCHEMA_MAX_DEPTH = 5; private static final String APPLICATION_JSON = "application/json"; + private static final String TRUNCATED_EXTENSION = "x-truncated"; + private static final String STRING_TYPE = "string"; public EndpointParameter toEndpointParameter(Parameter parameter, OpenAPI openAPI) { Parameter resolvedParameter = resolveParameter(parameter, openAPI); @@ -114,7 +116,7 @@ private EndpointSchema toEndpointSchema(Schema schema) { if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { Map convertedProperties = new LinkedHashMap<>(); schema.getProperties().forEach((name, property) -> - convertedProperties.put(name, toEndpointSchema((Schema)property))); + convertedProperties.put(name, toEndpointSchema(property))); properties = convertedProperties; } @@ -139,35 +141,37 @@ private EndpointSchema toEndpointSchema(Schema schema) { toEndpointSchemaList(schema.getOneOf()), toEndpointSchemaList(schema.getAnyOf()), schema.get$ref(), - isTruncated(schema) + isTruncated(schema) ? true : null ); } - private List toEndpointSchemaList(List schemas) { + private List toEndpointSchemaList(List schemas) { if (schemas == null || schemas.isEmpty()) { return Collections.emptyList(); } return schemas.stream() + .filter(Schema.class::isInstance) + .map(Schema.class::cast) .map(this::toEndpointSchema) .toList(); } - private Boolean isTruncated(Schema schema) { + private boolean isTruncated(Schema schema) { if (schema.getExtensions() == null) { - return null; + return false; } - Object truncated = schema.getExtensions().get("x-truncated"); - return Boolean.TRUE.equals(truncated) ? true : null; + Object truncated = schema.getExtensions().get(TRUNCATED_EXTENSION); + return Boolean.TRUE.equals(truncated); } private Schema resolveRefs( Schema schema, - Map referencedSchemas, + Map referencedSchemas, Set resolvingRefs, int depth ) { if (schema == null) { - return schema; + return null; } if (depth > SCHEMA_MAX_DEPTH) { return truncatedSchema(schema); @@ -184,7 +188,7 @@ private Schema resolveRefs( private Schema resolveRef( String ref, - Map referencedSchemas, + Map referencedSchemas, Set resolvingRefs, int depth ) { @@ -194,20 +198,20 @@ private Schema resolveRef( } if (!resolvingRefs.add(refName)) { Schema truncatedSchema = fallbackSchema(refName); - truncatedSchema.addExtension("x-truncated", true); + truncatedSchema.addExtension(TRUNCATED_EXTENSION, true); return truncatedSchema; } - Schema referencedSchema = referencedSchemas.get(refName); + Object referencedSchema = referencedSchemas.get(refName); Schema resolved = referencedSchema == null ? fallbackSchema(refName) - : resolveRefs(referencedSchema, referencedSchemas, resolvingRefs, depth + 1); + : resolveRefs((Schema)referencedSchema, referencedSchemas, resolvingRefs, depth + 1); resolvingRefs.remove(refName); return resolved; } private void resolveChildSchemas( Schema schema, - Map referencedSchemas, + Map referencedSchemas, Set resolvingRefs, int depth ) { @@ -222,54 +226,37 @@ private void resolveChildSchemas( schema.setAdditionalProperties( resolveRefs(additionalProperties, referencedSchemas, resolvingRefs, depth + 1)); } - schema.setAllOf(resolveSchemaList(schema.getAllOf(), referencedSchemas, resolvingRefs, depth)); - schema.setOneOf(resolveSchemaList(schema.getOneOf(), referencedSchemas, resolvingRefs, depth)); - schema.setAnyOf(resolveSchemaList(schema.getAnyOf(), referencedSchemas, resolvingRefs, depth)); } private Schema truncatedSchema(Schema schema) { String ref = schema.get$ref(); if (ref != null && !ref.isBlank()) { Schema fallback = fallbackSchema(ref.substring(ref.lastIndexOf('/') + 1)); - fallback.addExtension("x-truncated", true); + fallback.addExtension(TRUNCATED_EXTENSION, true); return fallback; } Schema truncated = new Schema<>() .type(schema.getType() == null ? "object" : schema.getType()) .format(schema.getFormat()) .description(schema.getDescription()); - truncated.addExtension("x-truncated", true); + truncated.addExtension(TRUNCATED_EXTENSION, true); return truncated; } - private List resolveSchemaList( - List schemas, - Map referencedSchemas, - Set resolvingRefs, - int depth - ) { - if (schemas == null) { - return null; - } - return schemas.stream() - .map(schema -> (Schema)resolveRefs(schema, referencedSchemas, resolvingRefs, depth + 1)) - .toList(); - } - private Schema fallbackSchema(String refName) { if (refName.endsWith("LocalTime")) { - return new Schema<>().type("string").format("time"); + return new Schema<>().type(STRING_TYPE).format("time"); } if (refName.endsWith("LocalDate")) { - return new Schema<>().type("string").format("date"); + return new Schema<>().type(STRING_TYPE).format("date"); } if (refName.endsWith("LocalDateTime") || refName.endsWith("OffsetDateTime") || refName.endsWith("ZonedDateTime")) { - return new Schema<>().type("string").format("date-time"); + return new Schema<>().type(STRING_TYPE).format("date-time"); } if (refName.endsWith("UUID")) { - return new Schema<>().type("string").format("uuid"); + return new Schema<>().type(STRING_TYPE).format("uuid"); } return new Schema<>().type("object").description("Unresolved schema: " + refName); } From ce8418f4584c6c5396fe97d52e4aa7f2dc6513cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= Date: Tue, 19 May 2026 18:02:32 +0900 Subject: [PATCH 12/13] refactor: tidy mcp constants --- src/main/java/in/koreatech/koin/mcp/McpConstants.java | 9 +++++++++ .../java/in/koreatech/koin/mcp/config/McpToolConfig.java | 3 ++- .../in/koreatech/koin/mcp/service/EndpointCatalog.java | 3 ++- .../koreatech/koin/mcp/service/EndpointSchemaMapper.java | 8 +++++--- .../koreatech/koin/mcp/service/EndpointSpecService.java | 3 ++- .../koreatech/koin/mcp/service/McpOpenApiProvider.java | 3 ++- .../in/koreatech/koin/mcp/tool/EndpointSpecTools.java | 3 ++- 7 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/mcp/McpConstants.java diff --git a/src/main/java/in/koreatech/koin/mcp/McpConstants.java b/src/main/java/in/koreatech/koin/mcp/McpConstants.java new file mode 100644 index 000000000..3baf41dfd --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/McpConstants.java @@ -0,0 +1,9 @@ +package in.koreatech.koin.mcp; + +public final class McpConstants { + + public static final String SERVER_ENABLED_PROPERTY = "spring.ai.mcp.server.enabled"; + + private McpConstants() { + } +} diff --git a/src/main/java/in/koreatech/koin/mcp/config/McpToolConfig.java b/src/main/java/in/koreatech/koin/mcp/config/McpToolConfig.java index 388815bff..17158fe8e 100644 --- a/src/main/java/in/koreatech/koin/mcp/config/McpToolConfig.java +++ b/src/main/java/in/koreatech/koin/mcp/config/McpToolConfig.java @@ -6,10 +6,11 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import in.koreatech.koin.mcp.McpConstants; import in.koreatech.koin.mcp.tool.EndpointSpecTools; @Configuration -@ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") +@ConditionalOnProperty(name = McpConstants.SERVER_ENABLED_PROPERTY, havingValue = "true") public class McpToolConfig { @Bean diff --git a/src/main/java/in/koreatech/koin/mcp/service/EndpointCatalog.java b/src/main/java/in/koreatech/koin/mcp/service/EndpointCatalog.java index 369b7a748..1ab5563c5 100644 --- a/src/main/java/in/koreatech/koin/mcp/service/EndpointCatalog.java +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointCatalog.java @@ -24,6 +24,7 @@ import in.koreatech.koin.global.auth.Auth; import in.koreatech.koin.global.code.Deprecation; +import in.koreatech.koin.mcp.McpConstants; import in.koreatech.koin.mcp.dto.endpoint.EndpointCandidate; import in.koreatech.koin.mcp.exception.EndpointSpecException; import in.koreatech.koin.mcp.model.DeprecatedFilter; @@ -35,7 +36,7 @@ import io.swagger.v3.oas.models.PathItem; @Component -@ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") +@ConditionalOnProperty(name = McpConstants.SERVER_ENABLED_PROPERTY, havingValue = "true") public class EndpointCatalog { private static final String ROOT_PACKAGE = "in.koreatech.koin"; diff --git a/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java b/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java index 62803a400..4017e18a0 100644 --- a/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Component; import in.koreatech.koin.global.exception.ErrorResponse; +import in.koreatech.koin.mcp.McpConstants; import in.koreatech.koin.mcp.dto.endpoint.request.EndpointParameter; import in.koreatech.koin.mcp.dto.endpoint.request.RequestBodySpec; import in.koreatech.koin.mcp.dto.schema.EndpointSchema; @@ -25,11 +26,12 @@ import io.swagger.v3.oas.models.parameters.RequestBody; @Component -@ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") +@ConditionalOnProperty(name = McpConstants.SERVER_ENABLED_PROPERTY, havingValue = "true") public class EndpointSchemaMapper { private static final int SCHEMA_MAX_DEPTH = 5; private static final String APPLICATION_JSON = "application/json"; + private static final String OBJECT_TYPE = "object"; private static final String TRUNCATED_EXTENSION = "x-truncated"; private static final String STRING_TYPE = "string"; @@ -236,7 +238,7 @@ private Schema truncatedSchema(Schema schema) { return fallback; } Schema truncated = new Schema<>() - .type(schema.getType() == null ? "object" : schema.getType()) + .type(schema.getType() == null ? OBJECT_TYPE : schema.getType()) .format(schema.getFormat()) .description(schema.getDescription()); truncated.addExtension(TRUNCATED_EXTENSION, true); @@ -258,6 +260,6 @@ private Schema fallbackSchema(String refName) { if (refName.endsWith("UUID")) { return new Schema<>().type(STRING_TYPE).format("uuid"); } - return new Schema<>().type("object").description("Unresolved schema: " + refName); + return new Schema<>().type(OBJECT_TYPE).description("Unresolved schema: " + refName); } } diff --git a/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java b/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java index ceb92e10f..893dd0edb 100644 --- a/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; import in.koreatech.koin.global.code.Deprecation; +import in.koreatech.koin.mcp.McpConstants; import in.koreatech.koin.mcp.dto.endpoint.EndpointDescription; import in.koreatech.koin.mcp.dto.endpoint.EndpointSummary; import in.koreatech.koin.mcp.dto.endpoint.FindEndpointsResponse; @@ -31,7 +32,7 @@ import io.swagger.v3.oas.models.responses.ApiResponse; @Service -@ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") +@ConditionalOnProperty(name = McpConstants.SERVER_ENABLED_PROPERTY, havingValue = "true") public class EndpointSpecService { private static final String APPLICATION_JSON = "application/json"; diff --git a/src/main/java/in/koreatech/koin/mcp/service/McpOpenApiProvider.java b/src/main/java/in/koreatech/koin/mcp/service/McpOpenApiProvider.java index 10245884e..f80da54d8 100644 --- a/src/main/java/in/koreatech/koin/mcp/service/McpOpenApiProvider.java +++ b/src/main/java/in/koreatech/koin/mcp/service/McpOpenApiProvider.java @@ -19,11 +19,12 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import in.koreatech.koin.mcp.McpConstants; import in.koreatech.koin.mcp.exception.EndpointSpecException; import io.swagger.v3.oas.models.OpenAPI; @Component -@ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") +@ConditionalOnProperty(name = McpConstants.SERVER_ENABLED_PROPERTY, havingValue = "true") public class McpOpenApiProvider { private final Map resources; diff --git a/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java b/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java index fe2ceea90..489de8676 100644 --- a/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java +++ b/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java @@ -8,13 +8,14 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import in.koreatech.koin.mcp.McpConstants; import in.koreatech.koin.mcp.dto.error.McpErrorResponse; import in.koreatech.koin.mcp.exception.EndpointSpecException; import in.koreatech.koin.mcp.model.DeprecatedFilter; import in.koreatech.koin.mcp.service.EndpointSpecService; @Component -@ConditionalOnProperty(name = "spring.ai.mcp.server.enabled", havingValue = "true") +@ConditionalOnProperty(name = McpConstants.SERVER_ENABLED_PROPERTY, havingValue = "true") public class EndpointSpecTools { private final EndpointSpecService endpointSpecService; From 01cab3754cc8686dffdc02a2c806ce528a3218c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EB=B9=88?= Date: Tue, 19 May 2026 18:24:18 +0900 Subject: [PATCH 13/13] fix: address swagger mcp review comments --- .../koin/mcp/service/EndpointCatalog.java | 76 +++++- .../mcp/service/EndpointSchemaMapper.java | 233 +++++++++++------- .../koin/mcp/service/EndpointSpecService.java | 30 ++- 3 files changed, 232 insertions(+), 107 deletions(-) diff --git a/src/main/java/in/koreatech/koin/mcp/service/EndpointCatalog.java b/src/main/java/in/koreatech/koin/mcp/service/EndpointCatalog.java index 1ab5563c5..6309ab0ff 100644 --- a/src/main/java/in/koreatech/koin/mcp/service/EndpointCatalog.java +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointCatalog.java @@ -3,6 +3,7 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -45,6 +46,7 @@ public class EndpointCatalog { private final List groupedOpenApis; private final McpOpenApiProvider openApiProvider; private final AntPathMatcher pathMatcher = new AntPathMatcher(); + private volatile List cachedEntries; public EndpointCatalog( @Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping, @@ -109,7 +111,23 @@ public String displayGroup(String group) { } private List entries() { + List entries = cachedEntries; + if (entries == null) { + synchronized (this) { + entries = cachedEntries; + if (entries == null) { + entries = loadEntries(); + cachedEntries = entries; + } + } + } + return entries; + } + + private List loadEntries() { List entries = new ArrayList<>(); + Map openApis = new LinkedHashMap<>(); + Set unavailableGroups = new HashSet<>(); handlerMapping.getHandlerMethods().forEach((info, handlerMethod) -> { if (!handlerMethod.getBeanType().getPackageName().startsWith(ROOT_PACKAGE)) { return; @@ -120,7 +138,7 @@ private List entries() { for (String path : paths(info)) { for (String group : groupsOf(handlerMethod.getBeanType(), path)) { for (String method : methods(info)) { - Operation operation = openApiOperation(group, method, path); + Operation operation = openApiOperation(openApis, unavailableGroups, group, method, path); entries.add(new EndpointEntry( group, method, @@ -129,28 +147,68 @@ private List entries() { operation, operationTags(operation, handlerMethod.getBeanType(), docsMethod), deprecation, - operation != null && Boolean.TRUE.equals(operation.getDeprecated()) || deprecation != null, + deprecated(operation, deprecation), authRequired(docsMethod, operation) )); } } } }); - return entries; + return List.copyOf(entries); } - private Operation openApiOperation(String group, String method, String path) { - OpenAPI openAPI; - try { - openAPI = openApiProvider.getOpenApi(group); - } catch (EndpointSpecException ignored) { + private Operation openApiOperation( + Map openApis, + Set unavailableGroups, + String group, + String method, + String path + ) { + OpenAPI openAPI = openApi(openApis, unavailableGroups, group); + if (openAPI == null || openAPI.getPaths() == null) { return null; } PathItem pathItem = openAPI.getPaths().get(path); if (pathItem == null) { return null; } - return pathItem.readOperationsMap().get(PathItem.HttpMethod.valueOf(method)); + PathItem.HttpMethod httpMethod = httpMethod(method); + if (httpMethod == null) { + return null; + } + return pathItem.readOperationsMap().get(httpMethod); + } + + private OpenAPI openApi(Map openApis, Set unavailableGroups, String group) { + if (unavailableGroups.contains(group)) { + return null; + } + if (openApis.containsKey(group)) { + return openApis.get(group); + } + try { + OpenAPI openAPI = openApiProvider.getOpenApi(group); + openApis.put(group, openAPI); + return openAPI; + } catch (EndpointSpecException ignored) { + unavailableGroups.add(group); + return null; + } + } + + private PathItem.HttpMethod httpMethod(String method) { + try { + return PathItem.HttpMethod.valueOf(method); + } catch (IllegalArgumentException exception) { + return null; + } + } + + private boolean deprecated(Operation operation, Deprecation deprecation) { + if (deprecation != null) { + return true; + } + return operation != null && Boolean.TRUE.equals(operation.getDeprecated()); } private Method findDocsMethod(HandlerMethod handlerMethod) { diff --git a/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java b/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java index 4017e18a0..d0933e2a0 100644 --- a/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java @@ -1,6 +1,5 @@ package in.koreatech.koin.mcp.service; -import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; @@ -32,7 +31,6 @@ public class EndpointSchemaMapper { private static final int SCHEMA_MAX_DEPTH = 5; private static final String APPLICATION_JSON = "application/json"; private static final String OBJECT_TYPE = "object"; - private static final String TRUNCATED_EXTENSION = "x-truncated"; private static final String STRING_TYPE = "string"; public EndpointParameter toEndpointParameter(Parameter parameter, OpenAPI openAPI) { @@ -41,7 +39,7 @@ public EndpointParameter toEndpointParameter(Parameter parameter, OpenAPI openAP resolvedParameter.getName(), Boolean.TRUE.equals(resolvedParameter.getRequired()), Objects.requireNonNullElse(resolvedParameter.getDescription(), ""), - toEndpointSchema(resolveSchema(resolvedParameter.getSchema(), openAPI)) + toEndpointSchema(resolvedParameter.getSchema(), componentSchemas(openAPI), new HashSet<>(), 0) ); } @@ -73,7 +71,7 @@ public EndpointSchema firstContentSchema(Content content, OpenAPI openAPI) { .map(MediaType::getSchema) .filter(Objects::nonNull) .findFirst() - .map(schema -> toEndpointSchema(resolveSchema(schema, openAPI))) + .map(schema -> toEndpointSchema(schema, componentSchemas(openAPI), new HashSet<>(), 0)) .orElse(null); } @@ -83,12 +81,12 @@ public EndpointSchema errorResponseSchema() { if (resolvedSchema == null || resolvedSchema.schema == null) { return null; } - return toEndpointSchema(resolveRefs( + return toEndpointSchema( resolvedSchema.schema, resolvedSchema.referencedSchemas, new HashSet<>(), 0 - )); + ); } private Parameter resolveParameter(Parameter parameter, OpenAPI openAPI) { @@ -102,29 +100,50 @@ private Parameter resolveParameter(Parameter parameter, OpenAPI openAPI) { return openAPI.getComponents().getParameters().getOrDefault(name, parameter); } - private Schema resolveSchema(Schema schema, OpenAPI openAPI) { - if (openAPI.getComponents() == null) { - return schema; + private Map componentSchemas(OpenAPI openAPI) { + if (openAPI.getComponents() == null || openAPI.getComponents().getSchemas() == null) { + return Map.of(); } - return resolveRefs(schema, openAPI.getComponents().getSchemas(), new HashSet<>(), 0); + return openAPI.getComponents().getSchemas(); } - private EndpointSchema toEndpointSchema(Schema schema) { + private EndpointSchema toEndpointSchema( + Schema schema, + Map referencedSchemas, + Set resolvingRefs, + int depth + ) { if (schema == null) { return null; } + if (depth > SCHEMA_MAX_DEPTH) { + return truncatedSchema(schema); + } + + String ref = schema.get$ref(); + if (ref != null && !ref.isBlank()) { + return resolveRef(ref, referencedSchemas, resolvingRefs, depth); + } Map properties = null; if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { Map convertedProperties = new LinkedHashMap<>(); schema.getProperties().forEach((name, property) -> - convertedProperties.put(name, toEndpointSchema(property))); + convertedProperties.put( + name, + toEndpointSchema(property, referencedSchemas, resolvingRefs, depth + 1) + )); properties = convertedProperties; } EndpointSchema additionalProperties = null; if (schema.getAdditionalProperties() instanceof Schema additionalPropertySchema) { - additionalProperties = toEndpointSchema(additionalPropertySchema); + additionalProperties = toEndpointSchema( + additionalPropertySchema, + referencedSchemas, + resolvingRefs, + depth + 1 + ); } return new EndpointSchema( @@ -137,58 +156,33 @@ private EndpointSchema toEndpointSchema(Schema schema) { schema.getRequired(), schema.getEnum(), properties, - toEndpointSchema(schema.getItems()), + toEndpointSchema(schema.getItems(), referencedSchemas, resolvingRefs, depth + 1), additionalProperties, - toEndpointSchemaList(schema.getAllOf()), - toEndpointSchemaList(schema.getOneOf()), - toEndpointSchemaList(schema.getAnyOf()), + toEndpointSchemaList(schema.getAllOf(), referencedSchemas, resolvingRefs, depth + 1), + toEndpointSchemaList(schema.getOneOf(), referencedSchemas, resolvingRefs, depth + 1), + toEndpointSchemaList(schema.getAnyOf(), referencedSchemas, resolvingRefs, depth + 1), schema.get$ref(), - isTruncated(schema) ? true : null + null ); } - private List toEndpointSchemaList(List schemas) { + private List toEndpointSchemaList( + List schemas, + Map referencedSchemas, + Set resolvingRefs, + int depth + ) { if (schemas == null || schemas.isEmpty()) { - return Collections.emptyList(); + return List.of(); } return schemas.stream() .filter(Schema.class::isInstance) .map(Schema.class::cast) - .map(this::toEndpointSchema) + .map(schema -> toEndpointSchema(schema, referencedSchemas, resolvingRefs, depth)) .toList(); } - private boolean isTruncated(Schema schema) { - if (schema.getExtensions() == null) { - return false; - } - Object truncated = schema.getExtensions().get(TRUNCATED_EXTENSION); - return Boolean.TRUE.equals(truncated); - } - - private Schema resolveRefs( - Schema schema, - Map referencedSchemas, - Set resolvingRefs, - int depth - ) { - if (schema == null) { - return null; - } - if (depth > SCHEMA_MAX_DEPTH) { - return truncatedSchema(schema); - } - - String ref = schema.get$ref(); - if (ref != null && !ref.isBlank()) { - return resolveRef(ref, referencedSchemas, resolvingRefs, depth); - } - - resolveChildSchemas(schema, referencedSchemas, resolvingRefs, depth); - return schema; - } - - private Schema resolveRef( + private EndpointSchema resolveRef( String ref, Map referencedSchemas, Set resolvingRefs, @@ -199,67 +193,118 @@ private Schema resolveRef( return fallbackSchema(refName); } if (!resolvingRefs.add(refName)) { - Schema truncatedSchema = fallbackSchema(refName); - truncatedSchema.addExtension(TRUNCATED_EXTENSION, true); - return truncatedSchema; + return truncatedSchema(fallbackSchema(refName)); } Object referencedSchema = referencedSchemas.get(refName); - Schema resolved = referencedSchema == null - ? fallbackSchema(refName) - : resolveRefs((Schema)referencedSchema, referencedSchemas, resolvingRefs, depth + 1); - resolvingRefs.remove(refName); - return resolved; - } - - private void resolveChildSchemas( - Schema schema, - Map referencedSchemas, - Set resolvingRefs, - int depth - ) { - if (schema.getProperties() != null) { - schema.getProperties().replaceAll((name, property) -> - resolveRefs(property, referencedSchemas, resolvingRefs, depth + 1)); - } - if (schema.getItems() != null) { - schema.setItems(resolveRefs(schema.getItems(), referencedSchemas, resolvingRefs, depth + 1)); - } - if (schema.getAdditionalProperties() instanceof Schema additionalProperties) { - schema.setAdditionalProperties( - resolveRefs(additionalProperties, referencedSchemas, resolvingRefs, depth + 1)); + try { + if (referencedSchema instanceof Schema schema) { + return toEndpointSchema(schema, referencedSchemas, resolvingRefs, depth + 1); + } + return fallbackSchema(refName); + } finally { + resolvingRefs.remove(refName); } } - private Schema truncatedSchema(Schema schema) { + private EndpointSchema truncatedSchema(Schema schema) { String ref = schema.get$ref(); if (ref != null && !ref.isBlank()) { - Schema fallback = fallbackSchema(ref.substring(ref.lastIndexOf('/') + 1)); - fallback.addExtension(TRUNCATED_EXTENSION, true); - return fallback; + return truncatedSchema(fallbackSchema(ref.substring(ref.lastIndexOf('/') + 1))); } - Schema truncated = new Schema<>() - .type(schema.getType() == null ? OBJECT_TYPE : schema.getType()) - .format(schema.getFormat()) - .description(schema.getDescription()); - truncated.addExtension(TRUNCATED_EXTENSION, true); - return truncated; + return new EndpointSchema( + schema.getType() == null ? OBJECT_TYPE : schema.getType(), + schema.getFormat(), + schema.getDescription(), + schema.getExample(), + schema.getNullable(), + schema.getDeprecated(), + schema.getRequired(), + schema.getEnum(), + null, + null, + null, + null, + null, + null, + null, + true + ); } - private Schema fallbackSchema(String refName) { + private EndpointSchema truncatedSchema(EndpointSchema schema) { + return new EndpointSchema( + schema.type(), + schema.format(), + schema.description(), + schema.example(), + schema.nullable(), + schema.deprecated(), + schema.required(), + schema.enumValues(), + schema.properties(), + schema.items(), + schema.additionalProperties(), + schema.allOf(), + schema.oneOf(), + schema.anyOf(), + schema.ref(), + true + ); + } + + private EndpointSchema fallbackSchema(String refName) { if (refName.endsWith("LocalTime")) { - return new Schema<>().type(STRING_TYPE).format("time"); + return scalarSchema("time"); } if (refName.endsWith("LocalDate")) { - return new Schema<>().type(STRING_TYPE).format("date"); + return scalarSchema("date"); } if (refName.endsWith("LocalDateTime") || refName.endsWith("OffsetDateTime") || refName.endsWith("ZonedDateTime")) { - return new Schema<>().type(STRING_TYPE).format("date-time"); + return scalarSchema("date-time"); } if (refName.endsWith("UUID")) { - return new Schema<>().type(STRING_TYPE).format("uuid"); + return scalarSchema("uuid"); } - return new Schema<>().type(OBJECT_TYPE).description("Unresolved schema: " + refName); + return new EndpointSchema( + OBJECT_TYPE, + null, + "Unresolved schema: " + refName, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ); + } + + private EndpointSchema scalarSchema(String format) { + return new EndpointSchema( + STRING_TYPE, + format, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ); } } diff --git a/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java b/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java index 893dd0edb..2597ab470 100644 --- a/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java @@ -79,9 +79,7 @@ public EndpointDescription getEndpointDescription(String group, String method, S entry.tags(), entry.deprecated(), deprecation == null ? null : deprecation.reason(), - deprecation == null || deprecation.replacedByMethod().isBlank() && deprecation.replacedByPath().isBlank() - ? null - : new ReplacedBy(deprecation.replacedByMethod(), deprecation.replacedByPath()), + replacedBy(deprecation), entry.authRequired() ); } @@ -154,13 +152,37 @@ private Operation openApiOperation(OpenAPI openAPI, EndpointEntry entry) { if (pathItem == null) { throw new EndpointSpecException("OPENAPI_PATH_NOT_FOUND", "No OpenAPI path found."); } - Operation operation = pathItem.readOperationsMap().get(PathItem.HttpMethod.valueOf(entry.method())); + PathItem.HttpMethod httpMethod = httpMethod(entry.method()); + if (httpMethod == null) { + throw new EndpointSpecException("UNSUPPORTED_HTTP_METHOD", "Unsupported HTTP method."); + } + Operation operation = pathItem.readOperationsMap().get(httpMethod); if (operation == null) { throw new EndpointSpecException("OPENAPI_OPERATION_NOT_FOUND", "No OpenAPI operation found."); } return operation; } + private ReplacedBy replacedBy(Deprecation deprecation) { + if (!hasReplacement(deprecation)) { + return null; + } + return new ReplacedBy(deprecation.replacedByMethod(), deprecation.replacedByPath()); + } + + private boolean hasReplacement(Deprecation deprecation) { + return deprecation != null + && (!deprecation.replacedByMethod().isBlank() || !deprecation.replacedByPath().isBlank()); + } + + private PathItem.HttpMethod httpMethod(String method) { + try { + return PathItem.HttpMethod.valueOf(method); + } catch (IllegalArgumentException exception) { + return null; + } + } + private List responseContentTypes(Content content, String responseStatus) { List contentTypes = schemaMapper.contentTypes(content); if (contentTypes.isEmpty() && isErrorStatus(responseStatus)) {