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/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(); } } 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 new file mode 100644 index 000000000..17158fe8e --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/config/McpToolConfig.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.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.mcp.McpConstants; +import in.koreatech.koin.mcp.tool.EndpointSpecTools; + +@Configuration +@ConditionalOnProperty(name = McpConstants.SERVER_ENABLED_PROPERTY, 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/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/endpoint/EndpointDescription.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointDescription.java new file mode 100644 index 000000000..2f3777800 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointDescription.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.mcp.dto.endpoint; + +import java.util.List; + +public record EndpointDescription( + String group, + String method, + String path, + String operationId, + String summary, + String description, + List tags, + boolean deprecated, + String deprecatedReason, + ReplacedBy replacedBy, + boolean authRequired +) { +} diff --git a/src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointSummary.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointSummary.java new file mode 100644 index 000000000..42f5f2022 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/EndpointSummary.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.mcp.dto.endpoint; + +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/mcp/dto/endpoint/FindEndpointsResponse.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/FindEndpointsResponse.java new file mode 100644 index 000000000..b6a092376 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/FindEndpointsResponse.java @@ -0,0 +1,6 @@ +package in.koreatech.koin.mcp.dto.endpoint; + +import java.util.List; + +public record FindEndpointsResponse(List items) { +} diff --git a/src/main/java/in/koreatech/koin/mcp/dto/endpoint/ReplacedBy.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/ReplacedBy.java new file mode 100644 index 000000000..06a0ae59e --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/ReplacedBy.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.mcp.dto.endpoint; + +public record ReplacedBy( + String method, + String path +) { +} diff --git a/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointParameter.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointParameter.java new file mode 100644 index 000000000..486c2bcc9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointParameter.java @@ -0,0 +1,11 @@ +package in.koreatech.koin.mcp.dto.endpoint.request; + +import in.koreatech.koin.mcp.dto.schema.EndpointSchema; + +public record EndpointParameter( + String name, + boolean required, + String description, + EndpointSchema schema +) { +} diff --git a/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointParameters.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointParameters.java new file mode 100644 index 000000000..de5774790 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointParameters.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.mcp.dto.endpoint.request; + +import java.util.List; + +public record EndpointParameters( + List path, + List query, + List header +) { +} diff --git a/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointRequestSpec.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointRequestSpec.java new file mode 100644 index 000000000..687c67597 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/EndpointRequestSpec.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.mcp.dto.endpoint.request; + +public record EndpointRequestSpec( + String group, + String method, + String path, + EndpointParameters parameters, + RequestBodySpec requestBody +) { +} diff --git a/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/RequestBodySpec.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/RequestBodySpec.java new file mode 100644 index 000000000..657c8b0bf --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/request/RequestBodySpec.java @@ -0,0 +1,12 @@ +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, + EndpointSchema schema +) { +} diff --git a/src/main/java/in/koreatech/koin/mcp/dto/endpoint/response/EndpointResponse.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/response/EndpointResponse.java new file mode 100644 index 000000000..89037f2d2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/response/EndpointResponse.java @@ -0,0 +1,16 @@ +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 description, + List contentTypes, + EndpointSchema schema +) { +} diff --git a/src/main/java/in/koreatech/koin/mcp/dto/endpoint/response/EndpointResponseSpec.java b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/response/EndpointResponseSpec.java new file mode 100644 index 000000000..9d19ca078 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/endpoint/response/EndpointResponseSpec.java @@ -0,0 +1,11 @@ +package in.koreatech.koin.mcp.dto.endpoint.response; + +import java.util.List; + +public record EndpointResponseSpec( + String group, + String method, + String path, + List responses +) { +} 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/schema/EndpointSchema.java b/src/main/java/in/koreatech/koin/mcp/dto/schema/EndpointSchema.java new file mode 100644 index 000000000..c1cef3bb3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/dto/schema/EndpointSchema.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.mcp.dto.schema; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record EndpointSchema( + String type, + String format, + String description, + Object example, + Boolean nullable, + Boolean deprecated, + List required, + @JsonProperty("enum") + 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/mcp/exception/EndpointSpecException.java b/src/main/java/in/koreatech/koin/mcp/exception/EndpointSpecException.java new file mode 100644 index 000000000..995e66d34 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/exception/EndpointSpecException.java @@ -0,0 +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/model/DeprecatedFilter.java b/src/main/java/in/koreatech/koin/mcp/model/DeprecatedFilter.java new file mode 100644 index 000000000..c3cb418a1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/model/DeprecatedFilter.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.mcp.model; + +public enum DeprecatedFilter { + EXCLUDE, + INCLUDE, + ONLY +} diff --git a/src/main/java/in/koreatech/koin/mcp/model/EndpointEntry.java b/src/main/java/in/koreatech/koin/mcp/model/EndpointEntry.java new file mode 100644 index 000000000..63e0cfcbf --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/model/EndpointEntry.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.mcp.model; + +import java.lang.reflect.Method; +import java.util.List; + +import in.koreatech.koin.global.code.Deprecation; +import io.swagger.v3.oas.models.Operation; + +public record EndpointEntry( + String group, + String method, + String path, + Method docsMethod, + Operation operation, + List tags, + Deprecation deprecation, + boolean deprecated, + boolean authRequired +) { +} 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..6309ab0ff --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointCatalog.java @@ -0,0 +1,406 @@ +package in.koreatech.koin.mcp.service; + +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; +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.McpConstants; +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 = McpConstants.SERVER_ENABLED_PROPERTY, 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(); + private volatile List cachedEntries; + + 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 = 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; + } + 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(openApis, unavailableGroups, group, method, path); + entries.add(new EndpointEntry( + group, + method, + path, + docsMethod, + operation, + operationTags(operation, handlerMethod.getBeanType(), docsMethod), + deprecation, + deprecated(operation, deprecation), + authRequired(docsMethod, operation) + )); + } + } + } + }); + return List.copyOf(entries); + } + + 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; + } + 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) { + 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..d0933e2a0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointSchemaMapper.java @@ -0,0 +1,310 @@ +package in.koreatech.koin.mcp.service; + +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.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; +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 = 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 STRING_TYPE = "string"; + + 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(resolvedParameter.getSchema(), componentSchemas(openAPI), new HashSet<>(), 0) + ); + } + + 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(schema, componentSchemas(openAPI), new HashSet<>(), 0)) + .orElse(null); + } + + public EndpointSchema errorResponseSchema() { + ResolvedSchema resolvedSchema = ModelConverters.getInstance() + .readAllAsResolvedSchema(ErrorResponse.class); + if (resolvedSchema == null || resolvedSchema.schema == null) { + return null; + } + return toEndpointSchema( + 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 Map componentSchemas(OpenAPI openAPI) { + if (openAPI.getComponents() == null || openAPI.getComponents().getSchemas() == null) { + return Map.of(); + } + return openAPI.getComponents().getSchemas(); + } + + 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, referencedSchemas, resolvingRefs, depth + 1) + )); + properties = convertedProperties; + } + + EndpointSchema additionalProperties = null; + if (schema.getAdditionalProperties() instanceof Schema additionalPropertySchema) { + additionalProperties = toEndpointSchema( + additionalPropertySchema, + referencedSchemas, + resolvingRefs, + depth + 1 + ); + } + + return new EndpointSchema( + schema.getType(), + schema.getFormat(), + schema.getDescription(), + schema.getExample(), + schema.getNullable(), + schema.getDeprecated(), + schema.getRequired(), + schema.getEnum(), + properties, + toEndpointSchema(schema.getItems(), referencedSchemas, resolvingRefs, depth + 1), + additionalProperties, + toEndpointSchemaList(schema.getAllOf(), referencedSchemas, resolvingRefs, depth + 1), + toEndpointSchemaList(schema.getOneOf(), referencedSchemas, resolvingRefs, depth + 1), + toEndpointSchemaList(schema.getAnyOf(), referencedSchemas, resolvingRefs, depth + 1), + schema.get$ref(), + null + ); + } + + private List toEndpointSchemaList( + List schemas, + Map referencedSchemas, + Set resolvingRefs, + int depth + ) { + if (schemas == null || schemas.isEmpty()) { + return List.of(); + } + return schemas.stream() + .filter(Schema.class::isInstance) + .map(Schema.class::cast) + .map(schema -> toEndpointSchema(schema, referencedSchemas, resolvingRefs, depth)) + .toList(); + } + + private EndpointSchema 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)) { + return truncatedSchema(fallbackSchema(refName)); + } + Object referencedSchema = referencedSchemas.get(refName); + try { + if (referencedSchema instanceof Schema schema) { + return toEndpointSchema(schema, referencedSchemas, resolvingRefs, depth + 1); + } + return fallbackSchema(refName); + } finally { + resolvingRefs.remove(refName); + } + } + + private EndpointSchema truncatedSchema(Schema schema) { + String ref = schema.get$ref(); + if (ref != null && !ref.isBlank()) { + return truncatedSchema(fallbackSchema(ref.substring(ref.lastIndexOf('/') + 1))); + } + 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 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 scalarSchema("time"); + } + if (refName.endsWith("LocalDate")) { + return scalarSchema("date"); + } + if (refName.endsWith("LocalDateTime") + || refName.endsWith("OffsetDateTime") + || refName.endsWith("ZonedDateTime")) { + return scalarSchema("date-time"); + } + if (refName.endsWith("UUID")) { + return scalarSchema("uuid"); + } + 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 new file mode 100644 index 000000000..2597ab470 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/service/EndpointSpecService.java @@ -0,0 +1,241 @@ +package in.koreatech.koin.mcp.service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +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; +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.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.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.parameters.Parameter; +import io.swagger.v3.oas.models.responses.ApiResponse; + +@Service +@ConditionalOnProperty(name = McpConstants.SERVER_ENABLED_PROPERTY, havingValue = "true") +public class EndpointSpecService { + + private static final String APPLICATION_JSON = "application/json"; + + private final EndpointCatalog endpointCatalog; + private final McpOpenApiProvider openApiProvider; + private final EndpointSchemaMapper schemaMapper; + + public EndpointSpecService( + EndpointCatalog endpointCatalog, + McpOpenApiProvider openApiProvider, + EndpointSchemaMapper schemaMapper + ) { + this.endpointCatalog = endpointCatalog; + this.openApiProvider = openApiProvider; + this.schemaMapper = schemaMapper; + } + + public FindEndpointsResponse findEndpoints(String query, String group, DeprecatedFilter deprecated) { + List items = endpointCatalog.findAll(query, group, deprecated).stream() + .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 = endpointCatalog.findEndpoint(group, method, path); + Operation operation = entry.operation(); + Deprecation deprecation = entry.deprecation(); + + return new EndpointDescription( + endpointCatalog.displayGroup(entry.group()), + entry.method(), + entry.path(), + operationId(operation), + operation == null ? "" : nullToEmpty(operation.getSummary()), + operation == null ? "" : nullToEmpty(operation.getDescription()), + entry.tags(), + entry.deprecated(), + deprecation == null ? null : deprecation.reason(), + replacedBy(deprecation), + entry.authRequired() + ); + } + + public EndpointRequestSpec getEndpointRequestSpec(String group, String method, String path) { + 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 = schemaMapper.toEndpointParameter(parameter, openAPI); + switch (nullToEmpty(parameter.getIn())) { + case "path" -> pathParameters.add(endpointParameter); + case "header" -> headerParameters.add(endpointParameter); + default -> queryParameters.add(endpointParameter); + } + } + + return new EndpointRequestSpec( + endpointCatalog.displayGroup(entry.group()), + entry.method(), + entry.path(), + new EndpointParameters(pathParameters, queryParameters, headerParameters), + schemaMapper.toRequestBodySpec(operation.getRequestBody(), openAPI) + ); + } + + public EndpointResponseSpec getEndpointResponseSpec(String group, String method, String path) { + EndpointEntry entry = endpointCatalog.findEndpoint(group, method, path); + OpenAPI openAPI = openApiProvider.getOpenApi(entry.group()); + Operation operation = openApiOperation(openAPI, entry); + + return new EndpointResponseSpec( + endpointCatalog.displayGroup(entry.group()), + entry.method(), + entry.path(), + nullToEmpty(operation.getResponses()).entrySet().stream() + .map(response -> toEndpointResponse(response.getKey(), response.getValue(), openAPI)) + .toList() + ); + } + + private EndpointResponse toEndpointResponse( + String status, + ApiResponse apiResponse, + OpenAPI openAPI + ) { + String responseStatus = responseStatus(status); + if ("204".equals(responseStatus)) { + return new EndpointResponse( + responseStatus, + nullToEmpty(apiResponse.getDescription()), + List.of(), + null + ); + } + return new EndpointResponse( + responseStatus, + nullToEmpty(apiResponse.getDescription()), + responseContentTypes(apiResponse.getContent(), responseStatus), + responseSchema(apiResponse.getContent(), openAPI, responseStatus) + ); + } + + 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."); + } + 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)) { + return List.of(APPLICATION_JSON); + } + 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 schema; + } + + private EndpointSummary toSummary(EndpointEntry entry) { + Operation operation = entry.operation(); + return new EndpointSummary( + endpointCatalog.displayGroup(entry.group()), + entry.method(), + entry.path(), + operationId(operation), + operation == null ? "" : nullToEmpty(operation.getSummary()), + operation == null ? "" : nullToEmpty(operation.getDescription()), + entry.tags(), + entry.deprecated(), + entry.authRequired() + ); + } + + private String responseStatus(String status) { + int lastSpace = status.lastIndexOf(' '); + return lastSpace == -1 ? status : status.substring(lastSpace + 1); + } + + private boolean isErrorStatus(String status) { + return !status.isBlank() && status.charAt(0) != '2'; + } + + private String operationId(Operation operation) { + return operation == null ? "" : nullToEmpty(operation.getOperationId()); + } + + 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/mcp/service/McpOpenApiProvider.java b/src/main/java/in/koreatech/koin/mcp/service/McpOpenApiProvider.java new file mode 100644 index 000000000..f80da54d8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/service/McpOpenApiProvider.java @@ -0,0 +1,110 @@ +package in.koreatech.koin.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.mcp.McpConstants; +import in.koreatech.koin.mcp.exception.EndpointSpecException; +import io.swagger.v3.oas.models.OpenAPI; + +@Component +@ConditionalOnProperty(name = McpConstants.SERVER_ENABLED_PROPERTY, 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()); + } + } +} diff --git a/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java b/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java new file mode 100644 index 000000000..489de8676 --- /dev/null +++ b/src/main/java/in/koreatech/koin/mcp/tool/EndpointSpecTools.java @@ -0,0 +1,85 @@ +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.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 = McpConstants.SERVER_ENABLED_PROPERTY, 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 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 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 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 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 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 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 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 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) { + 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: